enh: overhaul dashboard nav and state management

This commit is contained in:
Ben Allfree 2023-10-18 10:44:13 -07:00
parent a4ca2985f6
commit c2fc64d66e
17 changed files with 201 additions and 260 deletions

View File

@ -7,7 +7,7 @@
<div class="drawer-content">
<div class="flex items-center justify-between px-8 pt-1">
<a href="/dashboard" class="flex gap-2 items-center justify-center">
<a href="/" class="flex gap-2 items-center justify-center">
<Logo hideLogoText={true} logoWidth="w-16" />
</a>

View File

@ -3,9 +3,11 @@
import Logo from '$components/Logo.svelte'
import MediaQuery from '$components/MediaQuery.svelte'
import { DOCS_URL } from '$src/env'
import InstancesGuard from '$src/routes/InstancesGuard.svelte'
import { handleLogoutAndRedirect } from '$util/database'
import { globalInstancesStore } from '$util/stores'
import { values } from '@s-libs/micro-dash'
import UserLoggedIn from './helpers/UserLoggedIn.svelte'
const linkClasses =
'font-medium text-xl text-base-content btn btn-ghost capitalize justify-start'
@ -22,47 +24,48 @@
<aside class="p-4 min-w-[250px] flex flex-col h-screen">
<MediaQuery query="(min-width: 1280px)" let:matches>
{#if matches}
<a href="/dashboard" class="flex gap-2 items-center justify-center">
<a href="/" class="flex gap-2 items-center justify-center">
<Logo hideLogoText={true} logoWidth="w-20" />
</a>
{/if}
</MediaQuery>
<div class="flex flex-col gap-2 mb-auto">
<a on:click={handleClick} href="/dashboard" class={linkClasses}>
<a on:click={handleClick} href="/" class={linkClasses}>
<i
class="fa-regular fa-table-columns {$page.url.pathname ===
'/dashboard' && 'text-primary'}"
class="fa-regular fa-table-columns {$page.url.pathname === '/' &&
'text-primary'}"
></i> Dashboard
</a>
<div class="pl-8 flex flex-col gap-4 mb-4">
{#each values($globalInstancesStore) as app}
<a
href={`/app/instances/${app.id}`}
on:click={handleClick}
class={subLinkClasses}
>
{#if app.maintenance}
<i class="fa-regular fa-triangle-person-digging text-warning"></i>
{:else}
<i
class="fa-regular fa-server {$page.url.pathname ===
`/app/instances/${app.id}` && 'text-primary'}"
></i>
{/if}
<InstancesGuard>
<div class="pl-8 flex flex-col gap-4 mb-4">
{#each values($globalInstancesStore) as app}
<a
href={`/app/instances/${app.id}`}
on:click={handleClick}
class={subLinkClasses}
>
{#if app.maintenance}
<i class="fa-regular fa-triangle-person-digging text-warning"></i>
{:else}
<i
class="fa-regular fa-server {$page.url.pathname ===
`/app/instances/${app.id}` && 'text-primary'}"
></i>
{/if}
{app.subdomain}
{app.subdomain}
</a>
{/each}
<a href="/app/new" on:click={handleClick} class={addNewAppClasses}>
<i
class="fa-regular fa-plus {$page.url.pathname === `/app/new` &&
'text-primary'}"
></i> Create A New App
</a>
{/each}
<a href="/app/new" on:click={handleClick} class={addNewAppClasses}>
<i
class="fa-regular fa-plus {$page.url.pathname === `/app/new` &&
'text-primary'}"
></i> Create A New App
</a>
</div>
</div>
</InstancesGuard>
<a
href="https://discord.gg/nVTxCMEcGT"
@ -99,8 +102,13 @@
></i></a
>
<button type="button" class={linkClasses} on:click={handleLogoutAndRedirect}
><i class="fa-regular fa-arrow-up-left-from-circle"></i> Logout</button
>
<UserLoggedIn>
<button
type="button"
class={linkClasses}
on:click={handleLogoutAndRedirect}
><i class="fa-regular fa-arrow-up-left-from-circle"></i> Logout</button
>
</UserLoggedIn>
</div>
</aside>

View File

@ -1,4 +1,3 @@
import { browser } from '$app/environment'
import { MOTHERSHIP_URL } from '$src/env'
import { LoggerService } from '@pockethost/common'
import {
@ -9,7 +8,6 @@ import {
export const client = (() => {
let clientInstance: PocketbaseClient | undefined
return () => {
if (!browser) throw new Error(`PocketBase client not supported in SSR`)
if (clientInstance) return clientInstance
const { info } = LoggerService()
info(`Initializing pocketbase client`)

View File

@ -2,30 +2,28 @@
import MediaQuery from '$components/MediaQuery.svelte'
import MobileNavDrawer from '$components/MobileNavDrawer.svelte'
import Navbar from '$components/Navbar.svelte'
import AuthStateGuard from '$components/helpers/AuthStateGuard.svelte'
import Meta from '$components/helpers/Meta.svelte'
import Protect from '$components/helpers/Protect.svelte'
import UserLoggedIn from '$components/helpers/UserLoggedIn.svelte'
import '../app.css'
import '../services'
import { getInstances } from '$util/getInstances'
import { isUserLoggedIn } from '$util/stores'
getInstances()
</script>
<Meta />
{#if $isUserLoggedIn}
<AuthStateGuard>
<div class="layout xl:flex">
<MediaQuery query="(min-width: 1280px)" let:matches>
{#if matches}
<Navbar />
{:else}
<MobileNavDrawer>
<UserLoggedIn>
<MediaQuery query="(min-width: 1280px)" let:matches>
{#if matches}
<Navbar />
</MobileNavDrawer>
{/if}
</MediaQuery>
{:else}
<MobileNavDrawer>
<Navbar />
</MobileNavDrawer>
{/if}
</MediaQuery>
</UserLoggedIn>
<div class="lg:p-4 lg:pt-0 xl:pt-4 min-h-screen grow">
<div
@ -35,10 +33,4 @@
</div>
</div>
</div>
{/if}
{#if !$isUserLoggedIn}
<div>
<slot />
</div>
{/if}
</AuthStateGuard>

View File

@ -0,0 +1,2 @@
const ssr = false
export { ssr }

View File

@ -1,30 +1,26 @@
<script lang="ts">
import Logo from '$components/Logo.svelte'
import AuthStateGuard from '$components/helpers/AuthStateGuard.svelte'
import UserLoggedIn from '$components/helpers/UserLoggedIn.svelte'
import UserLoggedOut from '$components/helpers/UserLoggedOut.svelte'
import InstanceGeneratorWidget from '$components/login-register/InstanceGeneratorWidget.svelte'
import { isUserLoggedIn } from '$util/stores'
import Dashboard from './dashboard/Dashboard.svelte'
</script>
<svelte:head>
<title>Home - PocketHost</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center">
<div>
<AuthStateGuard>
<Logo />
<div>
<UserLoggedIn>
<Dashboard />
</UserLoggedIn>
{#if $isUserLoggedIn}
<div class="">
<a href="/dashboard" class="btn btn-primary"
>Go to Your Dashboard <i class="bi bi-arrow-right-short" /></a
>
</div>
{/if}
{#if !$isUserLoggedIn}
<UserLoggedOut>
<div class="min-h-screen flex items-center justify-center">
<div>
<Logo />
<InstanceGeneratorWidget />
{/if}
</AuthStateGuard>
</div>
</div>
</div>
</UserLoggedOut>
</div>

View File

@ -1,20 +1,23 @@
<script lang="ts">
<script>
import { page } from '$app/stores'
import AuthStateGuard from '$components/helpers/AuthStateGuard.svelte'
import { getSingleInstance } from '$util/getInstances'
import { assertTruthy } from '@pockethost/common'
import { globalInstancesStore } from '$util/stores'
import { assert } from '@pockethost/common'
import { instance } from './store'
// Run anytime the page params changes
let isReady = false
$: {
const { instanceId } = $page.params
assertTruthy(instanceId)
getSingleInstance(instanceId)
assert(instanceId)
const _instance = $globalInstancesStore[instanceId]
if (_instance) {
instance.set(_instance)
}
isReady = !!_instance
}
</script>
<AuthStateGuard>
{#if $instance}
<slot />
{/if}
</AuthStateGuard>
{#if isReady}
<slot />
{:else}
<div>Instance not found</div>
{/if}

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/stores'
import { INSTANCE_ADMIN_URL } from '$src/env'
import { assertExists } from '@pockethost/common'
import { slide } from 'svelte/transition'
@ -13,6 +14,11 @@
import UsageChart from './UsageChart.svelte'
import { instance } from './store'
const { instanceId } = $page.params
console.log(instanceId)
let isReady = false
$: ({ status, version, secondsThisMonth } = $instance)
assertExists($instance, `Expected instance here`)

View File

@ -2,15 +2,16 @@
import Card from '$components/cards/Card.svelte'
import CardHeader from '$components/cards/CardHeader.svelte'
import { client } from '$src/pocketbase'
import { mkCleanup } from '$util/componentCleanup'
import {
LoggerService,
createCleanupManager,
Unsubscribe,
type InstanceLogFields,
type RecordId,
} from '@pockethost/common'
import { values } from '@s-libs/micro-dash'
import { onDestroy, onMount } from 'svelte'
import { writable } from 'svelte/store'
import { onMount } from 'svelte'
import { derived, writable } from 'svelte/store'
import { instance } from './store'
const { dbg, trace } = LoggerService().create(`Logging.svelte`)
@ -46,28 +47,32 @@
const logs = writable<{ [_: RecordId]: InstanceLogFields }>({})
let logsArray: InstanceLogFields[] = []
const cm = createCleanupManager()
const onDestroy = mkCleanup()
const instanceId = derived(instance, (instance) => instance.id)
onMount(async () => {
dbg(`Watching instance log`)
let unwatch: Unsubscribe | undefined
const unsub = instanceId.subscribe((id) => {
dbg(`Watching instance log ${id}`)
unwatch?.()
logs.set({})
unwatch = client().watchInstanceLog(id, (newLog) => {
trace(`Got new log`, newLog)
const unsub = client().watchInstanceLog(id, (newLog) => {
trace(`Got new log`, newLog)
logs.update((currentLogs) => {
return { ...currentLogs, [newLog.id]: newLog }
})
logs.update((currentLogs) => {
return { ...currentLogs, [newLog.id]: newLog }
logsArray = values($logs)
.sort((a, b) => (a.created > b.created ? 1 : -1))
.slice(0, 1000)
.reverse()
})
logsArray = values($logs)
.sort((a, b) => (a.created > b.created ? 1 : -1))
.slice(0, 1000)
.reverse()
})
cm.add(unsub)
onDestroy(unsub)
onDestroy(() => unwatch?.())
})
onDestroy(cm.shutdown)
</script>
<Card>

View File

@ -1,7 +1,10 @@
<script lang="ts">
import { SECRET_KEY_REGEX } from '@pockethost/common'
import { items } from './stores.js'
import { client } from '$src/pocketbase/index.js'
import { SECRET_KEY_REGEX, SaveSecretsPayload } from '@pockethost/common'
import { reduce } from '@s-libs/micro-dash'
import { slide } from 'svelte/transition'
import { instance } from '../store.js'
import { items } from './stores.js'
// Keep track of the new key and value to be added
let secretKey: string = ''
@ -40,6 +43,18 @@
// Save to the database
items.upsert({ name: secretKey, value: secretValue })
await client().saveSecrets({
instanceId: $instance.id,
secrets: reduce(
$items,
(c, v) => {
const { name, value } = v
c[name] = value
return c
},
{} as SaveSecretsPayload['secrets'],
),
})
// Reset the values when the POST is done
secretKey = ''

View File

@ -1,15 +1,9 @@
<script lang="ts">
import CodeSample from '$components/CodeSample.svelte'
import Card from '$components/cards/Card.svelte'
import CardHeader from '$components/cards/CardHeader.svelte'
import CodeSample from '$components/CodeSample.svelte'
import { client } from '$src/pocketbase'
import {
createCleanupManager,
LoggerService,
type SaveSecretsPayload,
} from '@pockethost/common'
import { forEach, reduce } from '@s-libs/micro-dash'
import { onDestroy, onMount } from 'svelte'
import { LoggerService } from '@pockethost/common'
import { forEach } from '@s-libs/micro-dash'
import { instance } from '../store'
import Form from './Form.svelte'
import List from './List.svelte'
@ -17,7 +11,14 @@
// TODO: Hot Reload is causing an infinite loop in the network tab for some reason. Wasn't able to figure out why
$: ({ id, secrets } = $instance)
$: {
const { id, secrets } = $instance
items.clear()
forEach(secrets || {}, (value, name) => {
items.upsert({ name, value })
})
}
// Keep track of which tab the user has selected
let activeTab = 0
@ -28,44 +29,6 @@
}
const { dbg } = LoggerService().create(`Secrets.svelte`)
const cm = createCleanupManager()
onMount(() => {
items.clear()
forEach(secrets || {}, (value, name) => {
items.upsert({ name, value })
})
let initial = false
const unsub = items.subscribe(async (secrets) => {
if (!initial) {
initial = true
return
}
dbg(`Got change`, secrets)
await client().saveSecrets({
instanceId: id,
secrets: reduce(
secrets,
(c, v) => {
const { name, value } = v
c[name] = value
return c
},
{} as SaveSecretsPayload['secrets'],
),
})
})
cm.add(unsub)
})
onDestroy(cm.shutdown)
</script>
<Card>

View File

@ -75,7 +75,7 @@
{/if}
<div class="flex items-center justify-center gap-4">
<a href="/dashboard" class="btn">Cancel</a>
<a href="/" class="btn">Cancel</a>
<button class="btn btn-primary" disabled={isFormButtonDisabled}>
Create <i class="bi bi-arrow-right-short" />

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/stores'
import AlertBar from '$components/AlertBar.svelte'
import { handleAccountConfirmation } from '$util/database'
@ -9,15 +8,13 @@
// Check for a token in the URL
$: {
if (browser) {
token = $page?.url?.searchParams?.get('token')
token = $page?.url?.searchParams?.get('token')
if (token) {
handleLoad()
} else {
// No token was found in the URL
formError = 'Invalid link'
}
if (token) {
handleLoad()
} else {
// No token was found in the URL
formError = 'Invalid link'
}
}

View File

@ -38,7 +38,7 @@ export const handleLogin = async (
await authViaEmail(email, password)
if (shouldRedirect) {
await goto('/dashboard')
await goto('/')
}
} catch (error) {
if (!(error instanceof Error)) {
@ -88,7 +88,7 @@ export const handleAccountConfirmation = async (
try {
await confirmVerification(token)
window.location.href = '/dashboard'
window.location.href = '/'
} catch (error: any) {
handleFormError(error, setError)
}
@ -136,7 +136,7 @@ export const handleUnauthenticatedPasswordResetConfirm = async (
try {
await requestPasswordResetConfirm(token, password)
await goto('/dashboard')
await goto('/')
} catch (error: any) {
handleFormError(error, setError)
}

View File

@ -1,64 +0,0 @@
import { browser } from '$app/environment'
import { client } from '$src/pocketbase'
import { instance } from '$src/routes/app/instances/[instanceId]/store'
import { globalInstancesStore, isUserLoggedIn } from '$util/stores'
import {
LoggerService,
assertExists,
createCleanupManager,
type InstanceFields,
} from '@pockethost/common'
import { onDestroy, onMount } from 'svelte'
export const getInstances = async () => {
const { error } = LoggerService()
const cm = createCleanupManager()
onMount(async () => {
const unsub = isUserLoggedIn.subscribe(async (isLoggedIn) => {
if (!isLoggedIn) return
const { getAllInstancesById } = client()
const instances = await getAllInstancesById()
globalInstancesStore.set(instances)
const unsub = await client()
.client.collection('instances')
.subscribe<InstanceFields>('*', (data) => {
globalInstancesStore.update((instances) => {
instances[data.record.id] = data.record
return instances
})
})
cm.add(unsub)
})
cm.add(unsub)
})
// Stop listening to the db if this component unmounts
onDestroy(() => {
cm.shutdown().catch(console.error)
})
}
export const getSingleInstance = async (instanceId: string) => {
const cm = createCleanupManager()
// Only run this on the browser
if (browser) {
const { dbg, error } = LoggerService().create(`layout.svelte`)
const { watchInstanceById } = client()
watchInstanceById(instanceId, (r) => {
dbg(`Handling instance update`, r)
const { action, record } = r
assertExists(record, `Expected instance here`)
// Update the page state with the instance information
instance.set(record)
})
.then(cm.add)
.catch(error)
}
}

View File

@ -1,47 +1,67 @@
import { browser } from '$app/environment'
import { client } from '$src/pocketbase'
import type { AuthStoreProps } from '$src/pocketbase/PocketbaseClient'
import {
LoggerService,
type InstanceFields,
type InstanceId,
} from '@pockethost/common'
import { UnsubscribeFunc } from 'pocketbase'
import { writable } from 'svelte/store'
import '../services'
export const authStoreState = writable<AuthStoreProps>({
isValid: false,
model: null,
token: '',
})
export const isUserLoggedIn = writable(false)
export const isUserVerified = writable(false)
export const isAuthStateInitialized = writable(false)
if (browser) {
const { onAuthChange } = client()
const { onAuthChange } = client()
/**
* Listen for auth change events. When we get at least one, the auth state is initialized.
*/
onAuthChange((authStoreProps) => {
const { dbg } = LoggerService()
dbg(`onAuthChange in store`, { ...authStoreProps })
authStoreState.set(authStoreProps)
isAuthStateInitialized.set(true)
})
// Update derived stores when authStore changes
authStoreState.subscribe((authStoreProps) => {
const { dbg } = LoggerService()
dbg(`subscriber change`, authStoreProps)
isUserLoggedIn.set(authStoreProps.isValid)
isUserVerified.set(!!authStoreProps.model?.verified)
})
}
/**
* Listen for auth change events. When we get at least one, the auth state is initialized.
*/
onAuthChange((authStoreProps) => {
const { dbg } = LoggerService()
dbg(`onAuthChange in store`, { ...authStoreProps })
isUserLoggedIn.set(authStoreProps.isValid)
isUserVerified.set(!!authStoreProps.model?.verified)
isAuthStateInitialized.set(true)
})
// This holds an array of all the user's instances and their data
export const globalInstancesStore = writable<{
[_: InstanceId]: InstanceFields
}>({})
export const globalInstancesStoreReady = writable(false)
/**
* Listen for instances
*/
isUserLoggedIn.subscribe(async (isLoggedIn) => {
let unsub: UnsubscribeFunc | undefined
if (!isLoggedIn) {
globalInstancesStore.set({})
globalInstancesStoreReady.set(false)
unsub?.()
.then(() => {
unsub = undefined
})
.catch(console.error)
return
}
const { getAllInstancesById } = client()
const instances = await getAllInstancesById()
globalInstancesStore.set(instances)
globalInstancesStoreReady.set(true)
client()
.client.collection('instances')
.subscribe<InstanceFields>('*', (data) => {
globalInstancesStore.update((instances) => {
instances[data.record.id] = data.record
return instances
})
})
.then((u) => (unsub = u))
.catch(console.error)
})