mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-30 04:29:05 +01:00
feat(frontend): add v3 app shell with sidebar, rail and phone navigation
This commit is contained in:
@@ -2,15 +2,19 @@
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import EmailVerificationStateBox from '#lib/components/email-verification-state-box.svelte';
|
||||
import FormattedMessage from '#lib/components/formatted-message.svelte';
|
||||
import Sidebar from '#lib/components/sidebar.svelte';
|
||||
import AppSidebar from '#lib/components/shell/app-sidebar.svelte';
|
||||
import { navGroups } from '#lib/components/shell/nav-items.ts';
|
||||
import PhoneNav from '#lib/components/shell/phone-nav.svelte';
|
||||
import * as Alert from '#lib/components/ui/alert/index.ts';
|
||||
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||
import userStore from '#lib/stores/user-store.ts';
|
||||
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
import { LucideTriangleAlert } from '@lucide/svelte';
|
||||
import { PersistedState } from 'runed';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let {
|
||||
@@ -23,42 +27,40 @@
|
||||
|
||||
const { versionInformation, sqliteStorageWarning, user } = data;
|
||||
|
||||
type NavItem = {
|
||||
href?: string;
|
||||
label: string;
|
||||
children?: NavItem[];
|
||||
};
|
||||
const isAdmin = $derived(!!($userStore?.isAdmin || user?.isAdmin));
|
||||
const groups = $derived(
|
||||
navGroups({ isAdmin, verificationPending: isEmailVerificationPending(user, $appConfigStore) })
|
||||
);
|
||||
const collapsed = new PersistedState('sidebar-collapsed', false);
|
||||
|
||||
// A user who still has to verify their email can only reach their account until they do
|
||||
const items: NavItem[] = isEmailVerificationPending(user, $appConfigStore)
|
||||
? [{ href: '/settings/account', label: m.my_account() }]
|
||||
: [
|
||||
{ href: '/settings/account', label: m.my_account() },
|
||||
{ href: '/settings/apps', label: m.my_apps() },
|
||||
{ href: '/settings/audit-log', label: m.audit_log() }
|
||||
];
|
||||
// List pages run gutter to gutter, the other pages keep the centered column
|
||||
const listPage = $derived(
|
||||
[
|
||||
'/settings/admin/users',
|
||||
'/settings/admin/user-groups',
|
||||
'/settings/admin/oidc-clients',
|
||||
'/settings/admin/apis',
|
||||
'/settings/admin/api-keys',
|
||||
'/settings/audit-log',
|
||||
'/settings/audit-log/global'
|
||||
].includes(page.route.id ?? '')
|
||||
);
|
||||
|
||||
const adminChildren: NavItem[] = [
|
||||
{ href: '/settings/admin/users', label: m.users() },
|
||||
{ href: '/settings/admin/user-groups', label: m.user_groups() },
|
||||
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
|
||||
{ href: '/settings/admin/apis', label: m.apis() },
|
||||
{ href: '/settings/admin/api-keys', label: m.api_keys() },
|
||||
{
|
||||
href: '/settings/admin/application-configuration',
|
||||
label: m.application_configuration()
|
||||
}
|
||||
];
|
||||
let mainEl: HTMLElement;
|
||||
|
||||
if (user?.isAdmin || $userStore?.isAdmin) {
|
||||
items.push({ label: m.administration(), children: adminChildren });
|
||||
}
|
||||
// Navigating from the APIs or API keys pages, their tables' page updates included, re-checks their rows, so a first API or key has its row before you leave
|
||||
// SvelteKit passes a from without a url when the app starts
|
||||
afterNavigate(async ({ from }) => {
|
||||
if (from?.url?.pathname.startsWith('/settings/admin/api'))
|
||||
recheckedApiRows = await checkApiRows();
|
||||
});
|
||||
|
||||
// Only the content region crossfades between pages; the shell never moves (docs/design-system.md, Page transitions)
|
||||
onNavigate((navigation) => {
|
||||
if (
|
||||
navigation.shallow ||
|
||||
!document.startViewTransition ||
|
||||
$appConfigStore.disableAnimations ||
|
||||
document.documentElement.classList.contains('no-motion') ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
|
||||
!navigation.to?.url.pathname.startsWith('/settings/') ||
|
||||
navigation.from?.url.pathname === navigation.to.url.pathname
|
||||
@@ -76,28 +78,24 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div
|
||||
class="bg-muted/40 dark:bg-background flex min-h-[calc(100vh-86px)] w-full flex-col justify-between"
|
||||
>
|
||||
<main
|
||||
in:fade={{ duration: 200 }}
|
||||
class="mx-auto flex w-full max-w-[1720px] flex-col gap-x-8 gap-y-8 p-4 md:p-8 lg:flex-row"
|
||||
>
|
||||
<div class="w-full lg:w-[200px] lg:shrink-0 xl:w-[250px]">
|
||||
<div in:fly={{ x: -15, duration: 200 }} class="sticky top-6">
|
||||
<Sidebar
|
||||
{items}
|
||||
storageKey="sidebar-open:settings"
|
||||
isAdmin={$userStore?.isAdmin || user?.isAdmin}
|
||||
isUpToDate={versionInformation?.isUpToDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content flex w-full flex-col gap-4 overflow-hidden px-2 pb-2">
|
||||
{#if sqliteStorageWarning && ($userStore?.isAdmin || user?.isAdmin)}
|
||||
<Alert.Root variant="destructive">
|
||||
<Tooltip.Provider>
|
||||
<div class="bg-surface flex min-h-dvh">
|
||||
<AppSidebar {groups} {versionInformation} {showUpdate} bind:collapsed={collapsed.current} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<PhoneNav
|
||||
{groups}
|
||||
version="Pocket ID {versionInformation.currentVersion}"
|
||||
newestVersion={showUpdate ? versionInformation.newestVersion : null}
|
||||
/>
|
||||
<main
|
||||
bind:this={mainEl}
|
||||
class={cn(
|
||||
'settings-content mx-auto flex w-full flex-col gap-4 px-4 pt-6 pb-[calc(var(--clear-end-phone)+env(safe-area-inset-bottom))] sm:px-8 sm:pt-10 sm:pb-20 xl:px-10',
|
||||
!listPage && 'max-w-[calc(var(--col-content)+5rem)]'
|
||||
)}
|
||||
>
|
||||
{#if sqliteStorageWarning && isAdmin}
|
||||
<Alert.Root variant="warning">
|
||||
<LucideTriangleAlert />
|
||||
<Alert.Description>
|
||||
<FormattedMessage message={m.sqlite_storage_warning} />
|
||||
@@ -106,18 +104,7 @@
|
||||
{/if}
|
||||
<EmailVerificationStateBox />
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<div class="animate-fade-in flex flex-col items-center" style="animation-delay: 400ms;">
|
||||
<p class="text-muted-foreground py-3 text-xs">
|
||||
{m.powered_by()}
|
||||
<a
|
||||
class="text-foreground transition-all hover:underline"
|
||||
href="https://github.com/pocket-id/pocket-id"
|
||||
target="_blank">Pocket ID</a
|
||||
>
|
||||
({versionInformation.currentVersion})
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { checkApiRows } from '#lib/components/shell/nav-items.ts';
|
||||
import StorageService from '#lib/services/storage-service.ts';
|
||||
import VersionService from '#lib/services/version-service.ts';
|
||||
import WebAuthnService from '#lib/services/webauthn-service.ts';
|
||||
@@ -5,17 +6,21 @@ import type { AppVersionInformation } from '#lib/types/application-configuration
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load: LayoutLoad = async ({ url }) => {
|
||||
export const load: LayoutLoad = async ({ url, parent, untrack }) => {
|
||||
const versionService = new VersionService();
|
||||
const storageService = new StorageService();
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
const currentVersion = versionService.getCurrentVersion();
|
||||
|
||||
const [newestVersion, sqliteStorageWarning, passkeys] = await Promise.all([
|
||||
const [newestVersion, sqliteStorageWarning, passkeys, apiRows] = await Promise.all([
|
||||
versionService.getNewestVersion().catch(() => null),
|
||||
storageService.getSqliteStorageWarning().catch(() => false),
|
||||
webauthnService.listCredentials()
|
||||
webauthnService.listCredentials(),
|
||||
// A tracked parent() would rerun this load on every navigation, because the root load reads the pathname
|
||||
untrack(() => parent()).then(({ user }) =>
|
||||
user?.isAdmin ? checkApiRows() : { apis: false, apiKeys: false }
|
||||
)
|
||||
]);
|
||||
|
||||
// If newestVersion is empty, it means the check is disabled or failed.
|
||||
@@ -39,6 +44,7 @@ export const load: LayoutLoad = async ({ url }) => {
|
||||
return {
|
||||
versionInformation,
|
||||
sqliteStorageWarning,
|
||||
passkeys
|
||||
passkeys,
|
||||
apiRows
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user