This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user