INIT
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m11s

This commit is contained in:
eewing
2026-02-18 15:17:47 -06:00
parent e74c106f85
commit ec317eb17c
11532 changed files with 1631690 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { browser } from '$app/environment';
import { enhance } from '$app/forms';
import { invalidate } from '$app/navigation';
import { onMount } from 'svelte';
import './layout.css';
import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
let { data, children }: {
data: { user: { id: string; email?: string; name?: string; avatar?: string; darkmode: boolean } | null };
children: import('svelte').Snippet;
} = $props();
let darkmode = $state(false);
let onDarkmodeChange: (checked: boolean) => void = () => {};
$effect(() => {
darkmode = data.user?.darkmode ?? false;
});
$effect(() => {
if (browser && data.user !== undefined) {
document.documentElement.classList.toggle('dark', darkmode);
}
});
onMount(() => {
onDarkmodeChange = async (checked: boolean) => {
darkmode = checked;
try {
const res = await fetch('/api/darkmode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ darkmode: checked })
});
if (res.ok) await invalidate('layout');
} catch {
darkmode = !checked;
}
};
});
</script>
<div class="app-shell">
{#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"
onclick={(e) => e.preventDefault()}
>
<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.Content align="end" class="w-56">
<div
class="relative flex cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-none select-none"
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onDarkmodeChange(!darkmode);
}
}}
onclick={(e) => e.stopPropagation()}
>
<Label for="darkmode-switch" class="flex-1 cursor-pointer">Dark mode</Label>
<Switch
id="darkmode-switch"
checked={darkmode}
onCheckedChange={(v) => v !== undefined && onDarkmodeChange(!!v)}
/>
</div>
<DropdownMenu.Separator />
<form method="POST" action="/logout" use:enhance class="contents">
<button
type="submit"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
Log out
</button>
</form>
</DropdownMenu.Content>
</DropdownMenu.Root>
</header>
{/if}
<div class="page-view">
{@render children?.()}
</div>
</div>
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import { browser } from '$app/environment';
import { Button } from '$lib/components/ui/button';
let { data }: { data: Record<string, unknown> } = $props();
</script>
{#if browser}
{#await import('./HomeWithDialog.svelte')}
<main class="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-4 sm:gap-6 sm:p-6">
<p class="text-muted-foreground">Loading…</p>
</main>
{:then { default: HomeWithDialog }}
<HomeWithDialog data={data} />
{/await}
{:else}
<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="text-2xl font-semibold">Base</h1>
<div class="flex w-full max-w-xs flex-col gap-3">
<a href="https://prism.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 w-full min-h-16" variant="outline">Prism</Button>
</a>
<a href="https://tasgrid.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">TasGrid</Button>
</a>
<a href="https://stackq.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">Stackq</Button>
</a>
<a href="https://hrm.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">HRM</Button>
</a>
</div>
</main>
{/if}
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
let { data: _data }: { data: Record<string, unknown> } = $props();
// `_data` is accepted to mirror Stackq's pattern (unused for now).
</script>
<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">Base</h1>
<div class="flex w-full max-w-xs shrink-0 flex-col gap-3">
<a href="https://prism.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">Prism</Button>
</a>
<a href="https://tasgrid.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">TasGrid</Button>
</a>
<a href="https://stackq.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">Stackq</Button>
</a>
<a href="https://hrm.ccllc.pro" class="block" target="_blank" rel="noopener noreferrer">
<Button class="h-16 min-h-16 w-full" variant="outline">HRM</Button>
</a>
</div>
</main>
+22
View File
@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
let body: { darkmode?: boolean };
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON' }, { status: 400 });
}
const darkmode = !!body.darkmode;
try {
await locals.pb.collection('users').update(locals.user.id, { darkmode });
await locals.pb.collection('users').authRefresh();
} catch (e) {
return json({ error: 'Update failed' }, { status: 500 });
}
return json({ darkmode });
};
+168
View File
@@ -0,0 +1,168 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--radius: 0.5rem;
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
}
@layer base {
* {
@apply border-border;
}
html {
/* Match device text scaling (prevent iOS from inflating text) */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
/* Small font on mobile; readable on desktop */
font-size: 12px;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
@media (min-width: 768px) {
html {
font-size: 14px;
}
}
body {
@apply bg-background text-foreground;
box-sizing: border-box;
width: 100%;
height: 100dvh;
max-height: 100dvh;
margin: 0;
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);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
/* Center the app shell on all viewports */
display: flex;
flex-direction: column;
align-items: center;
}
/* Narrow on mobile (iPhone 16 Pro); wider on desktop for better scaling */
.app-shell {
width: 100%;
max-width: 402px;
height: 100%;
min-height: 0;
flex: 1 1 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (min-width: 768px) {
.app-shell {
max-width: 56rem; /* 896px comfortable items list on desktop */
}
}
/* 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) {
button:not([class*="size-"]) {
min-height: 36px;
}
}
}
/* Use on icon-only links for 44px touch target */
.touch-target {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
}
/* Slightly larger inputs on small screens for readability (still small) */
@media (max-width: 640px) {
input[type="text"],
input[type="search"],
input[type="number"],
input[type="email"],
input[type="password"],
select,
textarea {
font-size: 14px;
}
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, locals }) => {
if (locals.user) {
throw redirect(303, url.searchParams.get('redirectTo') ?? '/');
}
return {
redirectTo: url.searchParams.get('redirectTo') ?? '',
error: null
};
};
export const actions: Actions = {
default: async ({ request, locals, url }) => {
const formData = await request.formData();
const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '');
const redirectTo = String(formData.get('redirectTo') ?? '').trim() || '/';
if (!email || !password) {
return {
redirectTo,
error: 'Email and password are required.'
};
}
try {
await locals.pb.collection('users').authWithPassword(email, password);
} catch (err) {
const message = err && typeof err === 'object' && 'message' in err
? String((err as { message: string }).message)
: 'Invalid email or password.';
return {
redirectTo,
error: message
};
}
throw redirect(303, redirectTo);
}
};
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Card from '$lib/components/ui/card';
let { data, formAction }: {
data: { redirectTo?: string; form?: { error?: string; redirectTo?: string } };
formAction: string;
} = $props();
const redirectTo = $derived(data.form?.redirectTo ?? data.redirectTo ?? '');
const error = $derived(data.form?.error);
</script>
<div class="app-shell max-w-sm mx-auto w-full">
<Card.Root>
<Card.Header>
<Card.Title>Sign in</Card.Title>
<Card.Description>Enter your email and password.</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action={formAction}
use:enhance
class="flex flex-col gap-4"
>
<input type="hidden" name="redirectTo" value={redirectTo} />
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
autocomplete="email"
/>
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
autocomplete="current-password"
/>
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<Button type="submit" class="w-full">Sign in</Button>
</form>
</Card.Content>
</Card.Root>
</div>
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ locals }) => {
locals.pb.authStore.clear();
throw redirect(303, '/login');
}
};
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
let { formAction }: { formAction: string } = $props();
</script>
<div class="app-shell flex items-center justify-center min-h-[200px]">
<form method="POST" action={formAction} use:enhance>
<Button type="submit">Log out</Button>
</form>
</div>