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="drawer-content">
<div class="flex items-center justify-between px-8 pt-1"> <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" /> <Logo hideLogoText={true} logoWidth="w-16" />
</a> </a>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,30 +1,26 @@
<script lang="ts"> <script lang="ts">
import Logo from '$components/Logo.svelte' 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 InstanceGeneratorWidget from '$components/login-register/InstanceGeneratorWidget.svelte'
import { isUserLoggedIn } from '$util/stores' import Dashboard from './dashboard/Dashboard.svelte'
</script> </script>
<svelte:head> <svelte:head>
<title>Home - PocketHost</title> <title>Home - PocketHost</title>
</svelte:head> </svelte:head>
<div>
<UserLoggedIn>
<Dashboard />
</UserLoggedIn>
<UserLoggedOut>
<div class="min-h-screen flex items-center justify-center"> <div class="min-h-screen flex items-center justify-center">
<div> <div>
<AuthStateGuard>
<Logo /> <Logo />
{#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}
<InstanceGeneratorWidget /> <InstanceGeneratorWidget />
{/if}
</AuthStateGuard>
</div> </div>
</div> </div>
</UserLoggedOut>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,9 @@
<script lang="ts"> <script lang="ts">
import CodeSample from '$components/CodeSample.svelte'
import Card from '$components/cards/Card.svelte' import Card from '$components/cards/Card.svelte'
import CardHeader from '$components/cards/CardHeader.svelte' import CardHeader from '$components/cards/CardHeader.svelte'
import CodeSample from '$components/CodeSample.svelte' import { LoggerService } from '@pockethost/common'
import { client } from '$src/pocketbase' import { forEach } from '@s-libs/micro-dash'
import {
createCleanupManager,
LoggerService,
type SaveSecretsPayload,
} from '@pockethost/common'
import { forEach, reduce } from '@s-libs/micro-dash'
import { onDestroy, onMount } from 'svelte'
import { instance } from '../store' import { instance } from '../store'
import Form from './Form.svelte' import Form from './Form.svelte'
import List from './List.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 // 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 // Keep track of which tab the user has selected
let activeTab = 0 let activeTab = 0
@ -28,44 +29,6 @@
} }
const { dbg } = LoggerService().create(`Secrets.svelte`) 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> </script>
<Card> <Card>

View File

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

View File

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

View File

@ -38,7 +38,7 @@ export const handleLogin = async (
await authViaEmail(email, password) await authViaEmail(email, password)
if (shouldRedirect) { if (shouldRedirect) {
await goto('/dashboard') await goto('/')
} }
} catch (error) { } catch (error) {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
@ -88,7 +88,7 @@ export const handleAccountConfirmation = async (
try { try {
await confirmVerification(token) await confirmVerification(token)
window.location.href = '/dashboard' window.location.href = '/'
} catch (error: any) { } catch (error: any) {
handleFormError(error, setError) handleFormError(error, setError)
} }
@ -136,7 +136,7 @@ export const handleUnauthenticatedPasswordResetConfirm = async (
try { try {
await requestPasswordResetConfirm(token, password) await requestPasswordResetConfirm(token, password)
await goto('/dashboard') await goto('/')
} catch (error: any) { } catch (error: any) {
handleFormError(error, setError) 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,25 +1,17 @@
import { browser } from '$app/environment'
import { client } from '$src/pocketbase' import { client } from '$src/pocketbase'
import type { AuthStoreProps } from '$src/pocketbase/PocketbaseClient'
import { import {
LoggerService, LoggerService,
type InstanceFields, type InstanceFields,
type InstanceId, type InstanceId,
} from '@pockethost/common' } from '@pockethost/common'
import { UnsubscribeFunc } from 'pocketbase'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import '../services' import '../services'
export const authStoreState = writable<AuthStoreProps>({
isValid: false,
model: null,
token: '',
})
export const isUserLoggedIn = writable(false) export const isUserLoggedIn = writable(false)
export const isUserVerified = writable(false) export const isUserVerified = writable(false)
export const isAuthStateInitialized = writable(false) export const isAuthStateInitialized = writable(false)
if (browser) {
const { onAuthChange } = client() const { onAuthChange } = client()
/** /**
@ -28,20 +20,48 @@ if (browser) {
onAuthChange((authStoreProps) => { onAuthChange((authStoreProps) => {
const { dbg } = LoggerService() const { dbg } = LoggerService()
dbg(`onAuthChange in store`, { ...authStoreProps }) 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) isUserLoggedIn.set(authStoreProps.isValid)
isUserVerified.set(!!authStoreProps.model?.verified) isUserVerified.set(!!authStoreProps.model?.verified)
isAuthStateInitialized.set(true)
}) })
}
// This holds an array of all the user's instances and their data // This holds an array of all the user's instances and their data
export const globalInstancesStore = writable<{ export const globalInstancesStore = writable<{
[_: InstanceId]: InstanceFields [_: 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)
})