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.
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m29s

This commit is contained in:
eewing
2026-02-18 14:16:38 -06:00
parent a3db68e8fc
commit cfff9de0e6
574 changed files with 21605 additions and 1702 deletions
@@ -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}
/>
+13
View File
@@ -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,
};
+7
View File
@@ -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>
+16
View File
@@ -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
View File
@@ -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>
+19 -1
View File
@@ -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 };
}
},
};
+2 -5
View File
@@ -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}
+5 -8
View File
@@ -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
View File
@@ -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) {