mirror of
https://github.com/pockethost/pockethost.git
synced 2026-03-06 00:58:49 +00:00
enh: rename pockethost.io to dashboard
This commit is contained in:
35
packages/dashboard/src/components/AccordionItem.svelte
Normal file
35
packages/dashboard/src/components/AccordionItem.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { uniqueId } from '@s-libs/micro-dash'
|
||||
|
||||
export let title = ''
|
||||
export let show: boolean = false
|
||||
export let header: 'primary' | 'danger' = 'primary'
|
||||
const uid = uniqueId('a')
|
||||
const headerId = `header${uid}`
|
||||
const bodyId = `body${uid}`
|
||||
</script>
|
||||
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id={headerId}>
|
||||
<button
|
||||
class="accordion-button {show ? '' : 'collapsed'} text-bg-{header} "
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{bodyId}"
|
||||
aria-expanded="true"
|
||||
aria-controls={bodyId}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
</h2>
|
||||
<div
|
||||
id={bodyId}
|
||||
class="accordion-collapse collapse {show ? 'show' : ''} "
|
||||
aria-labelledby={bodyId}
|
||||
data-bs-parent="#accordionExample"
|
||||
>
|
||||
<div class="accordion-body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
24
packages/dashboard/src/components/AlertBar.svelte
Normal file
24
packages/dashboard/src/components/AlertBar.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { AlertTypes } from '$components/AlertBar.types'
|
||||
|
||||
export let title: string = ''
|
||||
export let text: string = ''
|
||||
export let icon: string = ''
|
||||
export let alertType: AlertTypes = AlertTypes.Warning
|
||||
</script>
|
||||
|
||||
<div class="alert alert-{alertType} d-flex gap-3 align-items-center" role="alert">
|
||||
{#if icon}
|
||||
<i class={icon} />
|
||||
{/if}
|
||||
|
||||
<div class="w-100">
|
||||
{#if title}<p class="fw-bold mb-0">{title}</p>{/if}
|
||||
|
||||
{#if text}
|
||||
{text}
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
10
packages/dashboard/src/components/AlertBar.types.ts
Normal file
10
packages/dashboard/src/components/AlertBar.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export enum AlertTypes {
|
||||
Primary = 'primary',
|
||||
Secondary = 'secondary',
|
||||
Success = 'success',
|
||||
Danger = 'danger',
|
||||
Warning = 'warning',
|
||||
Info = 'info',
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
}
|
||||
36
packages/dashboard/src/components/Clipboard.svelte
Normal file
36
packages/dashboard/src/components/Clipboard.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let text: string
|
||||
|
||||
let textarea: HTMLTextAreaElement
|
||||
|
||||
async function copy() {
|
||||
textarea.select()
|
||||
document.execCommand('Copy')
|
||||
await tick()
|
||||
textarea.blur()
|
||||
dispatch('copy')
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot {copy} />
|
||||
|
||||
<textarea bind:this={textarea} value={text} />
|
||||
|
||||
<style>
|
||||
textarea {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
border: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
37
packages/dashboard/src/components/CodeSample.svelte
Normal file
37
packages/dashboard/src/components/CodeSample.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import CopyButton from '$components/CopyButton.svelte'
|
||||
import { logger } from '@pockethost/common'
|
||||
import { Highlight } from 'svelte-highlight'
|
||||
import { typescript, type LanguageType } from 'svelte-highlight/languages'
|
||||
|
||||
const { dbg } = logger()
|
||||
|
||||
export let code: string
|
||||
export let language: LanguageType<'typescript' | 'bash'> = typescript
|
||||
|
||||
const handleCopy = () => {
|
||||
dbg('copied')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="copy-container">
|
||||
<Highlight {language} {code} />
|
||||
|
||||
<div class="copy-button">
|
||||
<CopyButton {code} copy={handleCopy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.copy-container {
|
||||
position: relative;
|
||||
margin: 5px;
|
||||
border: 1px solid gray;
|
||||
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
packages/dashboard/src/components/CopyButton.svelte
Normal file
19
packages/dashboard/src/components/CopyButton.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import Clipboard from '$components/Clipboard.svelte'
|
||||
import TinyButton from './helpers/TinyButton.svelte'
|
||||
|
||||
let isCopied = false
|
||||
export let code: string
|
||||
export let copy: () => void
|
||||
|
||||
const handleCopy = () => {
|
||||
isCopied = true
|
||||
copy()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Clipboard text={code} let:copy on:copy={handleCopy}>
|
||||
<TinyButton click={copy} style={isCopied ? 'success' : 'primary'}
|
||||
>{isCopied ? 'Copied!' : 'Copy'}</TinyButton
|
||||
>
|
||||
</Clipboard>
|
||||
46
packages/dashboard/src/components/FeatureCard.svelte
Normal file
46
packages/dashboard/src/components/FeatureCard.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
export let title: string = ''
|
||||
export let subtitle: string = ''
|
||||
export let icon: string = ''
|
||||
export let fullHeight: boolean = false
|
||||
</script>
|
||||
|
||||
<div class="card {fullHeight && 'h-100'}">
|
||||
<div class="card-body">
|
||||
{#if icon}
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div class="card-icon">
|
||||
<i class={icon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if title}<h5 class="card-title {!subtitle && 'mb-0'}">{title}</h5>{/if}
|
||||
{#if subtitle}<h6 class="card-subtitle mb-0 text-muted">{subtitle}</h6>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if title}<h5 class="card-title">{title}</h5>{/if}
|
||||
{#if subtitle}<h6 class="card-subtitle mb-2 text-muted">{subtitle}</h6>{/if}
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border: 0;
|
||||
box-shadow: var(--soft-box-shadow);
|
||||
border-radius: 18px;
|
||||
}
|
||||
.card-icon {
|
||||
background-color: var(--bs-gray-200);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 35px;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { faq } from '$src/docs'
|
||||
// ts interface for the Question type
|
||||
interface Question {
|
||||
title: string
|
||||
body: string
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
interface rawQ {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
// Array of Questions, if the boolean <collapsed> is set to false, the answer is displayed
|
||||
const questions: Question[] = faq.outline.map((q: rawQ) => ({ ...q, collapsed: true }))
|
||||
</script>
|
||||
|
||||
<div class="accordion w-100">
|
||||
{#each questions as question}
|
||||
<div class="accordion-item shadow">
|
||||
<h5 class="accordion-header">
|
||||
<button
|
||||
class="accordion-button title {question.collapsed ? 'collapsed' : ''}"
|
||||
type="button"
|
||||
on:click={() => (question.collapsed = !question.collapsed)}
|
||||
>
|
||||
{question.title}
|
||||
</button>
|
||||
</h5>
|
||||
<div class="accordion-collapse {question.collapsed ? 'collapse' : ''}">
|
||||
<div class="accordion-body">
|
||||
{@html question.body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition'
|
||||
import { onMount } from 'svelte'
|
||||
import RetroBoxContainer from '$components/RetroBoxContainer.svelte'
|
||||
|
||||
let isReady: boolean = false
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(() => {
|
||||
isReady = true
|
||||
}, 3000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<RetroBoxContainer minHeight={500} bgColor="#fff">
|
||||
{#if !isReady}
|
||||
<div class="hero-animation-content text-center">
|
||||
<p>Creating Your New Instance...</p>
|
||||
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isReady}
|
||||
<div in:fade={{ duration: 1000 }}>
|
||||
<img
|
||||
src="/images/pocketbase-intro-screen.jpg"
|
||||
alt="Screenshot of the Pocketbase Intro UI"
|
||||
class="img-fluid"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</RetroBoxContainer>
|
||||
|
||||
<style>
|
||||
.hero-animation-content {
|
||||
color: #222;
|
||||
}
|
||||
</style>
|
||||
15
packages/dashboard/src/components/InitializeTooltips.svelte
Normal file
15
packages/dashboard/src/components/InitializeTooltips.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script>
|
||||
import { afterNavigate } from '$app/navigation'
|
||||
import { browser } from '$app/environment'
|
||||
|
||||
// Tooltips must be manually initialized
|
||||
// https://getbootstrap.com/docs/5.2/components/tooltips/#enable-tooltips
|
||||
afterNavigate(() => {
|
||||
if (browser) {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
|
||||
const tooltipList = [...tooltipTriggerList].map(
|
||||
(tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
154
packages/dashboard/src/components/InstanceGeneratorWidget.svelte
Normal file
154
packages/dashboard/src/components/InstanceGeneratorWidget.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import AlertBar from '$components/AlertBar.svelte'
|
||||
import { handleInstanceGeneratorWidget } from '$util/database'
|
||||
import { getRandomElementFromArray } from '$util/utilities'
|
||||
import { generateSlug } from 'random-word-slugs'
|
||||
|
||||
// Controls the spin animation of the instance regeneration button
|
||||
let rotationCounter: number = 0
|
||||
|
||||
let email: string = ''
|
||||
let password: string = ''
|
||||
let instanceName: string = generateSlug(2)
|
||||
let formError: string = ''
|
||||
|
||||
let isFormButtonDisabled: boolean = true
|
||||
$: isFormButtonDisabled = email.length === 0 || password.length === 0 || instanceName.length === 0
|
||||
|
||||
let isProcessing: boolean = false
|
||||
|
||||
// Fun quotes when waiting for the instance to load. This could take up to 10 seconds
|
||||
let processingQuotesArray = [
|
||||
'Did you know it takes fourteen sentient robots to create each instance on PocketHost?'
|
||||
]
|
||||
|
||||
let processingQuote = getRandomElementFromArray(processingQuotesArray)
|
||||
|
||||
const handleInstanceNameRegeneration = () => {
|
||||
rotationCounter = rotationCounter + 180
|
||||
instanceName = generateSlug(2)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
isFormButtonDisabled = true
|
||||
isProcessing = true
|
||||
|
||||
await handleInstanceGeneratorWidget(email, password, instanceName, (error) => {
|
||||
formError = error
|
||||
})
|
||||
|
||||
isFormButtonDisabled = false
|
||||
|
||||
isProcessing = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isProcessing}
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-1">Creating Your New Instance...</h3>
|
||||
|
||||
<p class="small text-muted mb-0">{processingQuote}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<h3 class="mb-3">Create Your Instance Now</h3>
|
||||
|
||||
<form class="row align-items-center" on:submit={handleSubmit}>
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="form-floating mb-3 mb-lg-3">
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
autocomplete="email"
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
<label for="email">Email</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="form-floating mb-3 mb-lg-3">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
autocomplete="new-password"
|
||||
bind:value={password}
|
||||
required
|
||||
/>
|
||||
<label for="password">Password</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="form-floating mb-3 mb-lg-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="instance"
|
||||
placeholder="Instance"
|
||||
bind:value={instanceName}
|
||||
required
|
||||
/>
|
||||
<label for="instance">Instance Name</label>
|
||||
|
||||
<button
|
||||
aria-label="Regenerate Instance Name"
|
||||
type="button"
|
||||
style="transform: rotate({rotationCounter}deg);"
|
||||
class="btn btn-light rounded-circle regenerate-instance-name-btn"
|
||||
on:click={handleInstanceNameRegeneration}
|
||||
>
|
||||
<i class="bi bi-arrow-repeat" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3 mb-lg-3 text-lg-start text-center">
|
||||
<button type="submit" class="btn btn-primary" disabled={isFormButtonDisabled}>
|
||||
Create <i class="bi bi-arrow-right-short" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<AlertBar icon="bi bi-exclamation-triangle-fill" text={formError} />
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.row {
|
||||
--bs-gutter-x: 0.5rem;
|
||||
}
|
||||
|
||||
.btn.btn-primary {
|
||||
--bs-btn-padding-y: 12px;
|
||||
}
|
||||
|
||||
.regenerate-instance-name-btn {
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
position: absolute;
|
||||
z-index: 500;
|
||||
top: 10px;
|
||||
right: 7px;
|
||||
transition: all 200ms;
|
||||
}
|
||||
</style>
|
||||
42
packages/dashboard/src/components/MediaQuery.svelte
Normal file
42
packages/dashboard/src/components/MediaQuery.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
// Documentation Source
|
||||
// https://svelte.dev/repl/26eb44932920421da01e2e21539494cd?version=3.51.0
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let query = ''
|
||||
|
||||
let mql: MediaQueryList | undefined = undefined
|
||||
let mqlListener: (e: MediaQueryListEvent) => void
|
||||
let wasMounted = false
|
||||
let matches = false
|
||||
|
||||
onMount(() => {
|
||||
wasMounted = true
|
||||
return () => {
|
||||
removeActiveListener()
|
||||
}
|
||||
})
|
||||
|
||||
$: {
|
||||
if (wasMounted) {
|
||||
removeActiveListener()
|
||||
addNewListener(query)
|
||||
}
|
||||
}
|
||||
|
||||
function addNewListener(query: string) {
|
||||
mql = window.matchMedia(query)
|
||||
mqlListener = (v) => (matches = v.matches)
|
||||
mql.addListener(mqlListener)
|
||||
matches = mql.matches
|
||||
}
|
||||
|
||||
function removeActiveListener() {
|
||||
if (mql && mqlListener) {
|
||||
mql.removeListener(mqlListener)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot {matches} />
|
||||
67
packages/dashboard/src/components/MiniEdit.svelte
Normal file
67
packages/dashboard/src/components/MiniEdit.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import TinyButton from '$components/helpers/TinyButton.svelte'
|
||||
import { logger } from '@pockethost/common'
|
||||
|
||||
export let value: string = ''
|
||||
export let disabled: boolean = false
|
||||
export let save: (newValue: string) => Promise<string> = async () => 'saved'
|
||||
|
||||
const { dbg, error } = logger().create('MiniEdit.svelte')
|
||||
|
||||
let msg = ''
|
||||
let err = ''
|
||||
let oldValue = value
|
||||
let editedValue = value
|
||||
let editMode = false
|
||||
let inputField: HTMLInputElement
|
||||
|
||||
const startEdit = () => {
|
||||
msg = ''
|
||||
err = ''
|
||||
oldValue = editedValue
|
||||
editMode = true
|
||||
setTimeout(() => {
|
||||
inputField.focus()
|
||||
inputField.select()
|
||||
}, 0)
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
editedValue = oldValue
|
||||
editMode = false
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
msg = ''
|
||||
err = ''
|
||||
save(editedValue)
|
||||
.then((res) => {
|
||||
editMode = false
|
||||
msg = res
|
||||
})
|
||||
.catch((e) => {
|
||||
error(`Got an error on save`, e)
|
||||
err = e.data?.data?.subdomain?.message || e.message
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !editMode || disabled}
|
||||
{editedValue}
|
||||
<TinyButton click={startEdit} {disabled}>edit</TinyButton>
|
||||
{/if}
|
||||
{#if editMode && !disabled}
|
||||
<input
|
||||
bind:this={inputField}
|
||||
type="text"
|
||||
bind:value={editedValue}
|
||||
on:focus={(event) => inputField.select()}
|
||||
/>
|
||||
<TinyButton style="success" {disabled} click={saveEdit}>save</TinyButton>
|
||||
<TinyButton style="danger" {disabled} click={cancelEdit}>cancel</TinyButton>
|
||||
{/if}
|
||||
{#if msg}
|
||||
<span class="text-success">{msg}</span>
|
||||
{/if}
|
||||
{#if err}
|
||||
<span class="text-danger">{err}</span>
|
||||
{/if}
|
||||
31
packages/dashboard/src/components/MiniToggle.svelte
Normal file
31
packages/dashboard/src/components/MiniToggle.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { uniqueId } from '@s-libs/micro-dash'
|
||||
|
||||
export let value: boolean = false
|
||||
export let save: (newValue: boolean) => Promise<string> = async () => 'saved'
|
||||
|
||||
const id = uniqueId()
|
||||
let msg = ''
|
||||
|
||||
const onChange = () => {
|
||||
save(value)
|
||||
.then((res) => {
|
||||
msg = res
|
||||
})
|
||||
.catch((e) => {
|
||||
msg = e.message
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
{id}
|
||||
bind:checked={value}
|
||||
on:change={onChange}
|
||||
/>
|
||||
<label class="form-check-label" for={id}><slot /></label>
|
||||
</div>
|
||||
195
packages/dashboard/src/components/Navbar.svelte
Normal file
195
packages/dashboard/src/components/Navbar.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import MediaQuery from '$components/MediaQuery.svelte'
|
||||
import ThemeToggle from '$components/ThemeToggle.svelte'
|
||||
import { PUBLIC_POCKETHOST_VERSION } from '$src/env'
|
||||
import { handleLogoutAndRedirect } from '$util/database'
|
||||
import { isUserLoggedIn } from '$util/stores'
|
||||
import AuthStateGuard from './helpers/AuthStateGuard.svelte'
|
||||
</script>
|
||||
|
||||
<header class="container-fluid">
|
||||
<nav class="navbar navbar-expand-md">
|
||||
<a href="/" class="logo text-decoration-none d-flex align-items-center">
|
||||
<img src="/images/logo-square.png" alt="PocketHost Logo" class="img-fluid" />
|
||||
<h1>Pocket<span>Host</span></h1>
|
||||
<sup class="">{PUBLIC_POCKETHOST_VERSION}</sup>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="btn btn-light mobile-nav-button navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#nav-links"
|
||||
aria-controls="nav-links"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i class="bi bi-list" />
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="nav-links">
|
||||
<ul class="navbar-nav ms-auto mb-2 mb-md-0">
|
||||
<AuthStateGuard>
|
||||
{#if $isUserLoggedIn}
|
||||
<li class="nav-item text-md-start text-center">
|
||||
<a class="nav-link" href="/dashboard">Dashboard</a>
|
||||
</li>
|
||||
|
||||
<MediaQuery query="(min-width: 768px)" let:matches>
|
||||
{#if matches}
|
||||
<li class="nav-item dropdown">
|
||||
<button
|
||||
class="btn border-0 nav-link dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="Click to expand the Account Dropdown"
|
||||
title="Account Dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</button>
|
||||
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" type="button" on:click={handleLogoutAndRedirect}
|
||||
>Logout</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link text-md-start text-center"
|
||||
href="/"
|
||||
on:click={handleLogoutAndRedirect}>Logout</a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
</MediaQuery>
|
||||
{/if}
|
||||
|
||||
{#if !$isUserLoggedIn}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-md-start text-center" href="/signup">Sign up</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-md-start text-center" href="/login">Log in</a>
|
||||
</li>
|
||||
{/if}
|
||||
</AuthStateGuard>
|
||||
|
||||
<li class="nav-item text-center">
|
||||
<a
|
||||
href="https://pockethost.gitbook.io/manual/overview/faq"
|
||||
class="nav-link btn btn-outline-dark rounded-1 d-inline-block px-3"
|
||||
rel="noreferrer">FAQ</a
|
||||
>
|
||||
</li>
|
||||
|
||||
<li class="nav-item text-center">
|
||||
<a
|
||||
href="https://github.com/benallfree/pockethost/discussions"
|
||||
class="nav-link btn btn-outline-dark rounded-1 d-inline-block px-3"
|
||||
target="_blank"
|
||||
rel="noreferrer">Support</a
|
||||
>
|
||||
</li>
|
||||
|
||||
<li class="nav-item text-center">
|
||||
<a
|
||||
href="https://pockethost.gitbook.io/manual/"
|
||||
class="nav-link btn btn-outline-dark rounded-1 d-inline-block px-3"
|
||||
rel="noreferrer">Docs</a
|
||||
>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link text-md-start text-center"
|
||||
href="https://github.com/benallfree/pockethost"
|
||||
target="_blank"
|
||||
aria-label="Link to our Github Project"
|
||||
title="Link to our Github Project"
|
||||
rel="noopener"
|
||||
>
|
||||
<i class="bi bi-github" /><span class="nav-github-link">Github</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item text-center">
|
||||
<ThemeToggle navLink={true} />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
background-color: var(--bs-body-bg);
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--bs-gray-300);
|
||||
}
|
||||
|
||||
.logo {
|
||||
img {
|
||||
max-width: 50px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 300;
|
||||
margin: 0;
|
||||
color: var(--bs-body-color);
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
background-image: linear-gradient(
|
||||
83.2deg,
|
||||
rgba(150, 93, 233, 1) 10.8%,
|
||||
rgba(99, 88, 238, 1) 94.3%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
sup {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--bs-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-nav-button {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.nav-github-link {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.nav-github-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
packages/dashboard/src/components/ProvisioningStatus.svelte
Normal file
69
packages/dashboard/src/components/ProvisioningStatus.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { InstanceStatus } from '@pockethost/common/src/schema'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let status: InstanceStatus = InstanceStatus.Idle
|
||||
|
||||
let badgeColor: string = 'bg-secondary'
|
||||
|
||||
if (!status) {
|
||||
status = InstanceStatus.Idle
|
||||
}
|
||||
|
||||
const handleBadgeColor = () => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
badgeColor = 'bg-secondary'
|
||||
break
|
||||
case 'porting':
|
||||
badgeColor = 'bg-info'
|
||||
break
|
||||
case 'starting':
|
||||
badgeColor = 'bg-warning'
|
||||
break
|
||||
case 'running':
|
||||
badgeColor = 'bg-success'
|
||||
break
|
||||
case 'failed':
|
||||
badgeColor = 'bg-danger'
|
||||
break
|
||||
default:
|
||||
badgeColor = 'bg-secondary'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
handleBadgeColor()
|
||||
})
|
||||
|
||||
// Watch for changes with the status variable and update the badge color
|
||||
$: if (status) handleBadgeColor()
|
||||
</script>
|
||||
|
||||
<div class={`badge ${badgeColor} ${status === 'running' && 'pulse'}`}>{status}</div>
|
||||
|
||||
<style lang="scss">
|
||||
.pulse {
|
||||
box-shadow: 0 0 0 0 rgba(46, 204, 113, 1);
|
||||
transform: scale(1);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.7);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(46, 204, 113, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
packages/dashboard/src/components/RetroBoxContainer.svelte
Normal file
35
packages/dashboard/src/components/RetroBoxContainer.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
export let minHeight: number = 0
|
||||
export let bgColor: string = 'var(--bs-body-bg)'
|
||||
|
||||
// Construct the CSS styles since Svelte doesn't support CSS injection yet
|
||||
let cssStyles = `min-height: ${minHeight}px; background-color: ${bgColor};`
|
||||
</script>
|
||||
|
||||
<div class="homepage-hero-animation" style={cssStyles}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.homepage-hero-animation {
|
||||
box-shadow: var(--bs-primary) 0 0 0 3px inset, var(--bs-body-bg) 10px -10px 0px -3px,
|
||||
var(--bs-success) 10px -10px, var(--bs-body-bg) 20px -20px 0px -3px,
|
||||
var(--bs-warning) 20px -20px, var(--bs-body-bg) 30px -30px 0px -3px,
|
||||
var(--bs-orange) 30px -30px, var(--bs-body-bg) 40px -40px 0px -3px,
|
||||
var(--bs-danger) 40px -40px;
|
||||
border: 0;
|
||||
border-radius: 25px;
|
||||
padding: 30px;
|
||||
margin-right: 45px;
|
||||
margin-top: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.homepage-hero-animation {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
packages/dashboard/src/components/ThemeToggle.svelte
Normal file
45
packages/dashboard/src/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment'
|
||||
import { onMount } from 'svelte'
|
||||
import {
|
||||
THEME_ICONS,
|
||||
ThemeNames,
|
||||
currentIcon,
|
||||
getCurrentTheme,
|
||||
setCurrentTheme
|
||||
} from './helpers/theme'
|
||||
|
||||
// This can change the CSS a bit depending on where the theme toggle is rendered
|
||||
export let navLink: boolean = false
|
||||
|
||||
// Set the default icon to be light mode
|
||||
let iconClass: string = browser ? currentIcon() : ''
|
||||
|
||||
// Wait for the DOM to be available
|
||||
onMount(() => {
|
||||
updateTheme(getCurrentTheme())
|
||||
})
|
||||
|
||||
// Alternate the theme values on toggle click
|
||||
const handleClick = () => {
|
||||
const newTheme = getCurrentTheme() === ThemeNames.Light ? ThemeNames.Dark : ThemeNames.Light
|
||||
updateTheme(newTheme)
|
||||
}
|
||||
|
||||
const updateTheme = (themeName: ThemeNames) => {
|
||||
// Update the icon class name to toggle between light and dark mode
|
||||
iconClass = THEME_ICONS[themeName]
|
||||
|
||||
setCurrentTheme(themeName)
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="{navLink && 'nav-link'} btn border-0 d-inline-block"
|
||||
aria-label="Toggle the site theme"
|
||||
title="Toggle the site theme"
|
||||
on:click={handleClick}
|
||||
>
|
||||
<i class={iconClass} />
|
||||
</button>
|
||||
63
packages/dashboard/src/components/VerifyAccountBar.svelte
Normal file
63
packages/dashboard/src/components/VerifyAccountBar.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import AlertBar from '$components/AlertBar.svelte'
|
||||
import { AlertTypes } from '$components/AlertBar.types'
|
||||
import { handleResendVerificationEmail } from '$util/database'
|
||||
import { isUserLoggedIn, isUserVerified } from '$util/stores'
|
||||
|
||||
let defaultAlertBarType: AlertTypes = AlertTypes.Warning
|
||||
|
||||
let isButtonProcessing: boolean = false
|
||||
let formError: string = ''
|
||||
|
||||
const handleClick = () => {
|
||||
// Reset the alert type if resubmitted
|
||||
defaultAlertBarType = AlertTypes.Warning
|
||||
|
||||
// Update the state
|
||||
isButtonProcessing = true
|
||||
|
||||
handleResendVerificationEmail((error) => {
|
||||
formError = error
|
||||
defaultAlertBarType = AlertTypes.Danger
|
||||
isButtonProcessing = false
|
||||
})
|
||||
|
||||
// Wait a bit after the success before showing the button again
|
||||
setTimeout(() => {
|
||||
isButtonProcessing = false
|
||||
}, 5000)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isUserLoggedIn && !$isUserVerified}
|
||||
<div class="container py-3">
|
||||
<AlertBar alertType={defaultAlertBarType}>
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center gap-3">
|
||||
<i class="bi bi-envelope-exclamation" />
|
||||
|
||||
<div>Please verify your account by clicking the link in your email</div>
|
||||
|
||||
{#if isButtonProcessing}
|
||||
<div class="success-icon">
|
||||
<i class="bi bi-check-square" />
|
||||
Sent!
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-outline-secondary" on:click={handleClick}
|
||||
>Resend Email</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<div class="border-top text-center mt-2 pt-2">{formError}</div>
|
||||
{/if}
|
||||
</AlertBar>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.success-icon {
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { isAuthStateInitialized } from '$util/stores'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
onMount(() => {})
|
||||
</script>
|
||||
|
||||
{#if $isAuthStateInitialized}
|
||||
<slot />
|
||||
{/if}
|
||||
30
packages/dashboard/src/components/helpers/Meta.svelte
Normal file
30
packages/dashboard/src/components/helpers/Meta.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import { PUBLIC_APP_DOMAIN, PUBLIC_APP_PROTOCOL } from '$src/env'
|
||||
|
||||
const baseUrl = `${PUBLIC_APP_PROTOCOL}://${PUBLIC_APP_DOMAIN}/`
|
||||
const imageUrl = `${baseUrl}poster.png`
|
||||
const tagline = `Get a PocketBase backend for your next app in under 10 seconds.`
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- HTML Meta Tags -->
|
||||
<title>PocketHost</title>
|
||||
<meta name="description" content={tagline} />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Facebook Meta Tags -->
|
||||
<meta property="og:url" content={baseUrl} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="PocketHost" />
|
||||
<meta property="og:description" content={tagline} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
|
||||
<!-- Twitter Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:domain" content={PUBLIC_APP_DOMAIN} />
|
||||
<meta property="twitter:url" content={baseUrl} />
|
||||
<meta name="twitter:title" content="PocketHost" />
|
||||
<meta name="twitter:description" content={tagline} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
</svelte:head>
|
||||
21
packages/dashboard/src/components/helpers/Protect.svelte
Normal file
21
packages/dashboard/src/components/helpers/Protect.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_ROUTES } from '$src/env'
|
||||
import { client } from '$src/pocketbase'
|
||||
import { getRouter } from '$util/utilities'
|
||||
import { logger } from '@pockethost/common'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
onMount(() => {
|
||||
const { isLoggedIn } = client()
|
||||
if (isLoggedIn()) return
|
||||
const router = getRouter()
|
||||
|
||||
const { pathname } = router
|
||||
if (!PUBLIC_ROUTES.find((matcher) => matcher.match(pathname))) {
|
||||
const { warn } = logger()
|
||||
// Send user to the homepage
|
||||
warn(`${pathname} is a private route`)
|
||||
window.location.href = '/'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<script>
|
||||
{
|
||||
const THEME_ATTRIBUTE = 'data-bs-theme'
|
||||
const currentTheme =
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((row) => row.startsWith('theme='))
|
||||
?.split('=')?.[1] || 'light'
|
||||
|
||||
document.querySelector('html')?.setAttribute(THEME_ATTRIBUTE, currentTheme)
|
||||
const theme = document.querySelector('#hljs-link')
|
||||
if (currentTheme === 'light') {
|
||||
theme.href =
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</svelte:head>
|
||||
13
packages/dashboard/src/components/helpers/TinyButton.svelte
Normal file
13
packages/dashboard/src/components/helpers/TinyButton.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
export let disabled: boolean = false
|
||||
export let style: 'primary' | 'warning' | 'danger' | 'success' = 'primary'
|
||||
export let click: () => void = () => {}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-{style}"
|
||||
{disabled}
|
||||
style="--bs-btn-padding-y: .05rem; --bs-btn-padding-x: .5rem; --bs-btn-font-size: .75rem;"
|
||||
on:click={click}><slot /></button
|
||||
>
|
||||
50
packages/dashboard/src/components/helpers/theme.ts
Normal file
50
packages/dashboard/src/components/helpers/theme.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { assertTruthy } from '@pockethost/common'
|
||||
import { find } from '@s-libs/micro-dash'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
// Set some default values to be referenced later
|
||||
export enum ThemeNames {
|
||||
Light = 'light',
|
||||
Dark = 'dark',
|
||||
}
|
||||
export const HLJS_THEMES = {
|
||||
[ThemeNames.Light]:
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/default.min.css',
|
||||
[ThemeNames.Dark]:
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/a11y-dark.min.css',
|
||||
}
|
||||
export const ALLOWED_THEMES: ThemeNames[] = [ThemeNames.Light, ThemeNames.Dark]
|
||||
export const DEFAULT_THEME: ThemeNames = ThemeNames.Light
|
||||
export const STORAGE_NAME: string = 'theme'
|
||||
export const THEME_ATTRIBUTE: string = 'data-bs-theme'
|
||||
export const THEME_ICONS: { [_ in ThemeNames]: string } = {
|
||||
[ThemeNames.Light]: 'bi bi-moon-stars',
|
||||
[ThemeNames.Dark]: 'bi bi-brightness-high',
|
||||
}
|
||||
|
||||
export const html = () => {
|
||||
const htmlElement = document.querySelector('html')
|
||||
assertTruthy(htmlElement, `Expected <html> element to exist`)
|
||||
return htmlElement
|
||||
}
|
||||
|
||||
export const getCurrentTheme = () => {
|
||||
const savedTheme = Cookies.get(STORAGE_NAME)
|
||||
const currentTheme =
|
||||
find(ALLOWED_THEMES, (v) => savedTheme === v) || DEFAULT_THEME
|
||||
return currentTheme
|
||||
}
|
||||
|
||||
export const currentIcon = () => {
|
||||
return THEME_ICONS[getCurrentTheme()]
|
||||
}
|
||||
|
||||
export const setCurrentTheme = (themeName: ThemeNames) => {
|
||||
html().setAttribute(THEME_ATTRIBUTE, themeName)
|
||||
const theme = document.querySelector<HTMLLinkElement>('#hljs-link')
|
||||
if (theme) {
|
||||
theme.href = HLJS_THEMES[themeName]
|
||||
}
|
||||
console.log(theme, themeName)
|
||||
Cookies.set(STORAGE_NAME, themeName)
|
||||
}
|
||||
Reference in New Issue
Block a user