Enhance SvelteKit project by adding new API routes for dark mode toggling, updating existing route parameters, and improving type definitions. Refactor component imports and optimize asset handling for better performance and maintainability.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./switch.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Switch as SwitchPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
checked = $bindable(false),
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<SwitchPrimitive.Root
|
||||
bind:ref
|
||||
bind:checked
|
||||
data-slot="switch"
|
||||
class={cn(
|
||||
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
class={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
const user = locals.user as (Record<string, unknown> & { id: string; darkmode?: boolean }) | null;
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
darkmode: !!user.darkmode,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
};
|
||||
+102
-1
@@ -1,14 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import './layout.css';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let { data, children }: { data: { user: { id: string; email?: string; name?: string; darkmode: boolean } | null }; children?: import('svelte').Snippet } = $props();
|
||||
|
||||
let darkmode = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
darkmode = data.user?.darkmode ?? false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (browser && data.user !== undefined) {
|
||||
document.documentElement.classList.toggle('dark', darkmode);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
import('virtual:pwa-register').then(({ registerSW }) => {
|
||||
registerSW({ immediate: true });
|
||||
});
|
||||
});
|
||||
|
||||
function submitDarkmodeForm(formEl: HTMLFormElement) {
|
||||
const input = formEl.querySelector('input[name="darkmode"]');
|
||||
if (input && input instanceof HTMLInputElement) {
|
||||
input.value = darkmode ? 'true' : 'false';
|
||||
}
|
||||
formEl.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-shell">
|
||||
<slot />
|
||||
{#if data.user}
|
||||
<header
|
||||
class="fixed right-0 top-0 z-50 flex items-center gap-2 p-2 pr-[max(0.5rem,env(safe-area-inset-right))] pt-[max(0.5rem,env(safe-area-inset-top))]"
|
||||
aria-label="User menu"
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="border-0 bg-transparent hover:bg-muted inline-flex size-10 shrink-0 items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Avatar.Root class="size-10 rounded-full">
|
||||
<Avatar.Image src={data.user.avatar as string | undefined} alt="" />
|
||||
<Avatar.Fallback class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full text-sm font-medium">
|
||||
{data.user.name
|
||||
? String(data.user.name).slice(0, 2).toUpperCase()
|
||||
: data.user.email
|
||||
? String(data.user.email).slice(0, 2).toUpperCase()
|
||||
: '?'}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content align="end" class="w-56">
|
||||
<form
|
||||
id="darkmode-form"
|
||||
method="POST"
|
||||
action="/?/setDarkmode"
|
||||
use:enhance={() => {
|
||||
invalidate('layout');
|
||||
return async () => {};
|
||||
}}
|
||||
class="contents"
|
||||
>
|
||||
<input type="hidden" name="darkmode" value={darkmode ? 'true' : 'false'} />
|
||||
<DropdownMenu.Item
|
||||
class="cursor-default focus:bg-transparent"
|
||||
onpointerdown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Label for="dark-mode-switch" class="flex flex-1 cursor-pointer items-center justify-between font-normal">
|
||||
<span>Dark mode</span>
|
||||
<Switch
|
||||
id="dark-mode-switch"
|
||||
bind:checked={darkmode}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
darkmode = !darkmode;
|
||||
const f = document.getElementById('darkmode-form');
|
||||
if (f) submitDarkmodeForm(f as HTMLFormElement);
|
||||
}
|
||||
}}
|
||||
onclick={() => {
|
||||
setTimeout(() => {
|
||||
const f = document.getElementById('darkmode-form');
|
||||
if (f) submitDarkmodeForm(f as HTMLFormElement);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</DropdownMenu.Item>
|
||||
</form>
|
||||
<DropdownMenu.Separator />
|
||||
<a href="/logout">
|
||||
<DropdownMenu.Item>Log out</DropdownMenu.Item>
|
||||
</a>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="page-view">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
import type { StackqItem } from "$lib/types/items";
|
||||
|
||||
const COLLECTION = "Stackq_Items";
|
||||
@@ -12,3 +12,21 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const itemsForParent = list.map((i) => ({ id: i.id, Item: i.Item, SKU: i.SKU }));
|
||||
return { items: list, itemsForParent };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
setDarkmode: async ({ request, locals }) => {
|
||||
const user = locals.user as { id: string } | null;
|
||||
if (!user) return { success: false };
|
||||
|
||||
const formData = await request.formData();
|
||||
const darkmode = formData.get("darkmode") === "true";
|
||||
|
||||
try {
|
||||
const record = await locals.pb.collection("users").update(user.id, { darkmode });
|
||||
locals.pb.authStore.save(locals.pb.authStore.token, record);
|
||||
return { success: true, darkmode };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
{#if browser}
|
||||
{#await import("./HomeWithDialog.svelte")}
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
|
||||
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6">
|
||||
<p class="text-muted-foreground">Loading…</p>
|
||||
</main>
|
||||
{:then { default: HomeWithDialog }}
|
||||
<HomeWithDialog data={data} />
|
||||
{/await}
|
||||
{:else}
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
|
||||
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6">
|
||||
<h1 class="text-2xl font-semibold">Stackq</h1>
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<a href="/items" class="block">
|
||||
@@ -38,9 +38,6 @@
|
||||
<a href="/accounts" class="block">
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">Accounts</Button>
|
||||
</a>
|
||||
<a href="/logout" class="block">
|
||||
<Button class="h-12 min-h-12 w-full" variant="outline">Log out</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-6 sm:p-8">
|
||||
<h1 class="w-full max-w-xs text-center text-2xl font-semibold">Stackq</h1>
|
||||
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-4 sm:gap-6 sm:p-6">
|
||||
<h1 class="w-full max-w-xs shrink-0 text-center text-2xl font-semibold">Stackq</h1>
|
||||
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<div class="flex w-full max-w-xs shrink-0 flex-col gap-3">
|
||||
<Button class="h-16 min-h-16 w-full" onclick={() => (scanDialogOpen = true)}>Scan</Button>
|
||||
<a href="/transactions/new" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">New transaction</Button>
|
||||
@@ -45,9 +45,9 @@
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline" onclick={() => (dialogOpen = true)}>New Item</Button>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xs border-t pt-4"></div>
|
||||
<div class="w-full max-w-xs shrink-0 border-t pt-4"></div>
|
||||
|
||||
<div class="flex w-full max-w-xs flex-col gap-2">
|
||||
<div class="flex w-full max-w-xs shrink-0 flex-col gap-2">
|
||||
<a href="/items" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">Items</Button>
|
||||
</a>
|
||||
@@ -60,9 +60,6 @@
|
||||
<a href="/accounts" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">Accounts</Button>
|
||||
</a>
|
||||
<a href="/logout" class="block">
|
||||
<Button class="h-16 min-h-16 w-full" variant="outline">Log out</Button>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
+21
-6
@@ -109,16 +109,19 @@
|
||||
/* Small font, tuned for ~iPhone 16 Pro (402px viewport) */
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
/* Safe area for notched devices / iOS status bar; min 12px when env is 0 */
|
||||
padding-top: max(env(safe-area-inset-top, 0px), 12px);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
@@ -129,12 +132,24 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
/* Constrain width to iPhone 16 Pro viewport (402px) so layout matches on all devices */
|
||||
/* Constrain width to iPhone 16 Pro viewport (402px); fill height without causing scroll */
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
max-width: 402px;
|
||||
min-height: 100dvh;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Page content area fills shell and avoids outer scroll when possible */
|
||||
.app-shell .page-view {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Compact tap targets so UI is denser */
|
||||
@media (pointer: coarse) {
|
||||
|
||||
Reference in New Issue
Block a user