feat(ui): cozy polish — primitives, motion, empty states

Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled
call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy
Home palette, just consistently applied.

Primitives
- Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob
- Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass()
  helper for <a> link-as-CTA cases
- Checkbox: rounded square with animated check-draw + indeterminate
- Select: native <select> with Cozy chevron + matched radius
- Slider: gradient track, terracotta-bordered knob, aria-valuetext
- Input + Field: documented in CLAUDE.md for future use
- 9 buttonClass unit tests

Migrations
- 23 <input type=checkbox> → <Switch> (boolean settings)
- 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics)
- ~28 <select> → <Select>
- 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom)
- ~25 hand-rolled buttons → <Button> / buttonClass()

Surface polish
- Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft
  (resolves the Phase-5 tradeoff from the Cozy migration memo)
- BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a
  sample button; hue/sat use Slider; bg/cardSize use Select
- AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow
  amber breathing) and .status-offline (single attention flash) now applied
- AppForm collapse triggers: rotating chevron + aria-expanded
- Empty states for /boards and /apps: inline SVGs using --room-* tokens
  (peach/sky/sage/butter) instead of generic Lucide icons
- Login Remember Me: showcase Switch (first-impression surface)

Motion (src/app.css)
- New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps)
- New cozy-expand for accordion sections (healthcheck, integration, wallpaper)
- All motion respects prefers-reduced-motion

CLAUDE.md
- New project guide with a mandatory Frontend reuse table — every primitive
  documented with "never use raw <input type=checkbox>/<select>/<range>" and
  "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules

Verification
- npm run check: 0 errors, 0 warnings, 5831 files
- npm test: 301 passing
- npm run lint: 0 errors (19 pre-existing warnings unchanged)
- npm run build: ✔ done

Branch is feat/cozy-polish, ready to PR against master.
This commit is contained in:
2026-05-28 14:39:53 +03:00
parent 555ac9ea63
commit f087551454
49 changed files with 1317 additions and 613 deletions
+56
View File
@@ -0,0 +1,56 @@
# web-app-launcher — project guide for Claude
SvelteKit 2 + Svelte 5 (runes) + Tailwind 4 + Prisma + Vitest. Cozy Home design system (warm cream / dusk, terracotta accent, Fraunces + Figtree, soft shadows). Token contract lives in `src/app.css`.
## Frontend
### Basic-component reuse — MANDATORY
When you need any of the following, **use the existing primitive from `src/lib/components/ui/`. Do not hand-roll a new Tailwind class string for a control that already has a primitive.**
| Need | Primitive | Why |
|---|---|---|
| Boolean on/off setting | `Switch.svelte` | Pill toggle, `role="switch"`, AA contrast, terracotta track when on. Default for any "enable X" / "show Y" / "is default" field. **Never use `<input type="checkbox">` for booleans.** |
| Multi-select item in a list | `Checkbox.svelte` | Rounded square with animated check-draw. Only use when the control is truly "pick any number of these," not a single boolean. |
| Dropdown of fixed options | `Select.svelte` | Styled chevron, matches Cozy input radius. Wraps native `<select>`. **Do not use raw `<select>`.** |
| Single-line text / number / email / url / password | `Input.svelte` | Standard rounded-xl, focus ring, invalid state. **Do not repeat the `w-full rounded-xl border border-input bg-background px-3 py-2 ...` string anywhere.** |
| Number in a range (refresh interval, hue, blur, etc.) | `Slider.svelte` | Cozy gradient track, terracotta-bordered knob, value tooltip, `aria-valuetext`. **Do not use raw `<input type="range">`.** |
| Action button (submit, save, cancel, link-as-CTA) | `Button.svelte` | Variants `primary | secondary | outline | ghost | destructive`, sizes `sm | md | lg | icon`, built-in `loading` spinner, press-squash. **Do not repeat `rounded-xl bg-primary px-4 py-2 ...` strings.** |
| Label + hint + error wrapper around a control | `Field.svelte` | Consolidates `<label> + control + <p class="text-xs text-destructive">`. |
| Confirm-before-destructive | `ConfirmDialog.svelte` | Already exists. Use it. |
| Entity / icon / tag picker | `EntityPicker`, `MultiEntityPicker`, `IconPickerButton`, `TagsInput` | Already exist. Reuse. |
### Process
1. Before writing any form control in a `.svelte` file, **scan `src/lib/components/ui/` first**. If a matching primitive exists, import and use it.
2. If you find yourself copying a Tailwind class string verbatim from another file, **stop**: that's the trigger to extract a primitive (or expand an existing one).
3. If you genuinely need a new primitive, add it to `src/lib/components/ui/`, give it a `class?: string` prop merged via `cn()`, document it in this table, and migrate at least two call sites in the same PR so it's not dead code.
4. Tokens (`--primary`, `--card`, `--room-*`, `--shadow-soft`, etc.) are defined once in `src/app.css`. Never hardcode hex/HSL — read from the token.
### Cozy spec quick reminders
- Hero cards: `rounded-[1.4rem]` + `shadow-[var(--shadow-soft)]`. Dense panels: `rounded-xl`. **Never** `rounded-lg` on a section wrapper.
- Headings (`h1`, `h2`, `h3`) automatically get Fraunces via base layer — no need to add `font-display` unless overriding non-heading text.
- Focus uses `focus-visible:ring-2 focus-visible:ring-primary/30` — primitives already do this; mirror it on anything hand-rolled.
- Motion is gentle and present: prefer `cozy-rise` / `cozy-expand` from `app.css` over generic Tailwind animations. All motion classes already respect `prefers-reduced-motion`.
## Backend
- Auth: session cookie + optional OAuth. Roles: `admin` / user / guest. Always check role at the route load function, not the component.
- Validation: Zod schemas live in `src/lib/utils/validators.ts`. Reuse the same schema on client (superForms) and server.
- DB: Prisma. Never query the DB directly from a route — go through `src/lib/server/services/*Service.ts`.
## Testing
- Vitest, Node environment, no DOM (existing pattern). Component tests use the module-scope helpers (e.g., `buttonClass` in `Button.svelte`) rather than rendering — keep that convention.
- Run before committing: `npm run check && npm run lint && npm test && npm run build`.
## Commands
```bash
npm run dev # vite dev on :5181
npm run check # svelte-check (TS + Svelte)
npm run lint # eslint
npm test # vitest run
npm run build # production build
```
+82 -1
View File
@@ -221,11 +221,46 @@
}
}
@keyframes status-breathe {
0%,
100% {
opacity: 0.85;
}
50% {
opacity: 1;
}
}
@keyframes status-flash {
0% {
transform: scale(1);
opacity: 0.6;
}
30% {
transform: scale(1.25);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.status-online {
animation: status-pulse 2s ease-in-out infinite;
color: var(--status-online);
}
.status-degraded {
animation: status-breathe 2.6s ease-in-out infinite;
color: var(--status-degraded);
}
.status-offline {
animation: status-flash 0.6s ease-out 1;
color: var(--status-offline);
}
/* ===== Card Style Variants ===== */
.card-solid {
background: var(--card);
@@ -330,6 +365,47 @@
}
}
/* ===== Cozy entrance reveal ===== */
@keyframes cozy-rise {
0% {
opacity: 0;
transform: translateY(12px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.cozy-rise {
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* For staggered grid reveals — set --i as 0,1,2,... per item */
.cozy-rise-stagger {
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: calc(var(--i, 0) * 55ms);
}
/* ===== Cozy accordion (height slide for show/hide) ===== */
@keyframes cozy-expand {
0% {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
}
100% {
opacity: 1;
transform: translateY(0);
max-height: 1200px;
}
}
.cozy-expand {
overflow: hidden;
animation: cozy-expand 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
/* ===== Cozy greeting wave ===== */
@keyframes cozy-wave {
0%,
@@ -359,7 +435,12 @@
@media (prefers-reduced-motion: reduce) {
.cozy-wave,
.status-online {
.status-online,
.status-degraded,
.status-offline,
.cozy-rise,
.cozy-rise-stagger,
.cozy-expand {
animation: none;
}
.card-hover:hover {
+164 -37
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { untrack } from 'svelte';
import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface BackupInfo {
filename: string;
size: number;
createdAt: string;
format: 'tar.gz' | 'db';
}
interface BackupSchedule {
@@ -14,6 +18,14 @@
backupMaxCount: number;
}
interface SchedulerStats {
successCount: number;
failureCount: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
}
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
let backups: BackupInfo[] = $state([]);
@@ -22,6 +34,13 @@
backupCronExpression: '0 3 * * *',
backupMaxCount: 10
});
let stats: SchedulerStats = $state({
successCount: 0,
failureCount: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null
});
let creating = $state(false);
let savingSchedule = $state(false);
@@ -29,6 +48,9 @@
let deletingFilename: string | null = $state(null);
let confirmRestore: string | null = $state(null);
let confirmDelete: string | null = $state(null);
let confirmSchemaMismatch = $state(false);
let pendingSchemaMismatchFile: string | null = $state(null);
let pendingSchemaMismatchMessage = $state('');
let statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state('');
let customCron = $state('');
@@ -48,7 +70,8 @@
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatDate(iso: string): string {
@@ -69,6 +92,7 @@
if (result.success) {
backups = result.data.backups;
schedule = result.data.schedule;
if (result.data.stats) stats = result.data.stats;
cronPreset = detectPreset(schedule.backupCronExpression);
if (cronPreset === 'custom') {
customCron = schedule.backupCronExpression;
@@ -111,23 +135,62 @@
document.body.removeChild(a);
}
async function performRestore(filename: string, allowSchemaMismatch: boolean): Promise<void> {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowSchemaMismatch })
});
if (response.status === 409) {
const errBody = await response.json().catch(() => ({}));
pendingSchemaMismatchFile = filename;
pendingSchemaMismatchMessage = errBody.error || $t('admin.backup_restore_schema_mismatch');
confirmSchemaMismatch = true;
return;
}
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
if (result.data?.forceLogout) {
setTimeout(() => {
window.location.href = '/login';
}, 2500);
}
}
async function handleRestore(filename: string) {
clearStatus();
confirmRestore = null;
restoringFilename = filename;
try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST'
});
const result = await response.json();
await performRestore(filename, false);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
} finally {
restoringFilename = null;
}
}
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
async function handleSchemaMismatchConfirm() {
const filename = pendingSchemaMismatchFile;
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
pendingSchemaMismatchMessage = '';
if (!filename) return;
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
clearStatus();
restoringFilename = filename;
try {
await performRestore(filename, true);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
@@ -195,26 +258,20 @@
}
}
// Load backups on mount (untrack to avoid infinite re-trigger)
$effect(() => {
untrack(() => loadBackups());
});
</script>
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-1 text-lg font-semibold text-card-foreground">{$t('admin.backup_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
<!-- Create Backup -->
<div class="mb-6">
<button
type="button"
onclick={handleCreate}
disabled={creating}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<Button onclick={handleCreate} disabled={creating} loading={creating}>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button>
</Button>
</div>
<!-- Backup List -->
@@ -229,6 +286,7 @@
<thead>
<tr class="border-b border-border text-xs uppercase text-muted-foreground">
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_filename')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_format')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_size')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</th>
<th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
@@ -238,6 +296,20 @@
{#each backups as backup (backup.filename)}
<tr class="border-b border-border/50">
<td class="py-2.5 pr-4 font-mono text-xs text-foreground">{backup.filename}</td>
<td class="py-2.5 pr-4 text-xs text-muted-foreground">
{#if backup.format === 'tar.gz'}
<span class="rounded-md bg-status-online/10 px-2 py-0.5 text-status-online-ink">
{$t('admin.backup_format_full')}
</span>
{:else}
<span
class="rounded-md bg-status-degraded/10 px-2 py-0.5 text-status-degraded-ink"
title={$t('admin.backup_format_legacy_tooltip')}
>
{$t('admin.backup_format_legacy')}
</span>
{/if}
</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatBytes(backup.size)}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
<td class="py-2.5">
@@ -281,6 +353,7 @@
<!-- Restore Confirmation Dialog -->
{#if confirmRestore}
{@const target = backups.find((b) => b.filename === confirmRestore)}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
@@ -289,6 +362,14 @@
<p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_restore_confirm')}
</p>
{#if target?.format === 'db'}
<p class="mb-3 rounded-md border border-status-degraded/30 bg-status-degraded/10 p-3 text-xs text-status-degraded-ink">
{$t('admin.backup_restore_legacy_warning')}
</p>
{/if}
<p class="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300">
{$t('admin.backup_restore_logout_warning')}
</p>
<p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
<div class="flex justify-end gap-3">
<button
@@ -301,7 +382,8 @@
<button
type="button"
onclick={() => confirmRestore && handleRestore(confirmRestore)}
class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5" style="background: var(--status-degraded);"
class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5"
style="background: var(--status-degraded);"
>
{$t('admin.backup_restore')}
</button>
@@ -310,6 +392,40 @@
</div>
{/if}
<!-- Schema-mismatch follow-up confirmation -->
{#if confirmSchemaMismatch}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
{$t('admin.backup_restore_schema_mismatch_title')}
</h3>
<p class="mb-3 text-sm text-muted-foreground">
{$t('admin.backup_restore_schema_mismatch_intro')}
</p>
<pre class="mb-4 max-h-32 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-[10px] text-foreground">{pendingSchemaMismatchMessage}</pre>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={() => {
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
}}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onclick={handleSchemaMismatchConfirm}
class="rounded-xl bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground hover:bg-destructive/90"
>
{$t('admin.backup_restore_schema_mismatch_force')}
</button>
</div>
</div>
</div>
{/if}
<!-- Delete Confirmation Dialog -->
{#if confirmDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
@@ -350,11 +466,10 @@
<div class="space-y-4">
<!-- Enable toggle -->
<label class="flex items-center gap-3">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-3">
<Switch
bind:checked={schedule.backupEnabled}
class="h-4 w-4 rounded border-border text-primary focus-visible:ring-primary/30"
ariaLabel={$t('admin.backup_schedule_enabled')}
/>
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label>
@@ -365,16 +480,12 @@
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_cron')}
</label>
<select
id="cron-preset"
bind:value={cronPreset}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
>
<Select id="cron-preset" bind:value={cronPreset} class="sm:w-auto">
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_daily')}</option>
<option value="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
</select>
</Select>
</div>
{#if cronPreset === 'custom'}
@@ -402,16 +513,32 @@
class="w-24 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<!-- Scheduler stats -->
<div class="rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<div class="grid grid-cols-2 gap-1">
<span>{$t('admin.backup_stats_success_count')}</span>
<span class="text-right font-mono text-foreground">{stats.successCount}</span>
<span>{$t('admin.backup_stats_failure_count')}</span>
<span class="text-right font-mono {stats.failureCount > 0 ? 'text-destructive' : 'text-foreground'}">{stats.failureCount}</span>
{#if stats.lastSuccessAt}
<span>{$t('admin.backup_stats_last_success')}</span>
<span class="text-right font-mono text-foreground">{formatDate(stats.lastSuccessAt)}</span>
{/if}
{#if stats.lastFailureAt}
<span>{$t('admin.backup_stats_last_failure')}</span>
<span class="text-right font-mono text-destructive">{formatDate(stats.lastFailureAt)}</span>
{/if}
</div>
{#if stats.lastFailureReason}
<p class="mt-2 break-words font-mono text-[10px] text-destructive">{stats.lastFailureReason}</p>
{/if}
</div>
{/if}
<button
type="button"
onclick={handleSaveSchedule}
disabled={savingSchedule}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<Button onclick={handleSaveSchedule} disabled={savingSchedule} loading={savingSchedule}>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button>
</Button>
</div>
</div>
+14 -15
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface DiscoveredService {
name: string;
@@ -137,20 +139,19 @@
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
</script>
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
<!-- Scan Button -->
<div class="mb-6">
<button
type="button"
<Button
onclick={handleScan}
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
loading={scanning}
>
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button>
</Button>
</div>
<!-- Scan Errors -->
@@ -169,12 +170,12 @@
<thead>
<tr class="border-b border-border">
<th class="px-2 py-2 text-left">
<input
type="checkbox"
<Checkbox
checked={selected.size === selectableCount && selectableCount > 0}
indeterminate={selected.size > 0 && selected.size < selectableCount}
onchange={toggleSelectAll}
disabled={selectableCount === 0}
class="h-4 w-4 rounded border-input"
ariaLabel={$t('admin.discovery_select_all') ?? 'Select all'}
/>
</th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
@@ -187,12 +188,11 @@
{#each services as service, i (service.url)}
<tr class="border-b border-border/50 hover:bg-muted/50">
<td class="px-2 py-2">
<input
type="checkbox"
<Checkbox
checked={selected.has(i)}
onchange={() => toggleSelect(i)}
disabled={service.alreadyRegistered}
class="h-4 w-4 rounded border-input"
ariaLabel={`Select ${service.name}`}
/>
</td>
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
@@ -227,14 +227,13 @@
<!-- Approve button -->
{#if selectableCount > 0}
<div class="mt-4">
<button
type="button"
<Button
onclick={handleApprove}
disabled={approving || selected.size === 0}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
loading={approving}
>
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button>
</Button>
</div>
{/if}
{/if}
+8 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { enhance } from '$app/forms';
import Switch from '$lib/components/ui/Switch.svelte';
interface GroupWithCount {
id: string;
@@ -64,8 +65,13 @@
placeholder={$t('common.description')}
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
/>
<label class="flex items-center gap-1 text-xs text-foreground">
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
<label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
<Switch
name="isDefault"
bind:checked={editIsDefault}
size="sm"
ariaLabel={$t('admin.default_column')}
/>
{$t('admin.default_column')}
</label>
<button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button>
@@ -129,7 +129,7 @@
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<div class="rounded-xl border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<div>
+27 -27
View File
@@ -4,6 +4,9 @@
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
let {
form: formData,
@@ -46,32 +49,34 @@
<form method="POST" action="?/update" use:enhance class="space-y-8">
<!-- Authentication -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.authentication')}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label>
<select
<Select
id="authMode"
name="authMode"
bind:value={$form.authMode}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="local">{$t('admin.auth_local')}</option>
<option value="oauth">{$t('admin.auth_oauth')}</option>
<option value="both">{$t('admin.auth_both')}</option>
</select>
</Select>
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
</div>
<div class="flex items-center gap-2 pt-6">
<input
<div class="flex items-center gap-3 pt-6">
<Switch
id="registrationEnabled"
name="registrationEnabled"
type="checkbox"
bind:checked={$form.registrationEnabled}
class="h-4 w-4 rounded border-input"
ariaLabelledby="registrationEnabledLabel"
/>
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
<label
id="registrationEnabledLabel"
for="registrationEnabled"
class="cursor-pointer text-sm font-medium text-foreground"
>
{$t('admin.registration_enabled')}
</label>
</div>
@@ -79,7 +84,7 @@
</section>
<!-- OAuth Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.oauth_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">
{$t('admin.oauth_description')}
@@ -120,14 +125,14 @@
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
</div>
<div class="sm:col-span-2">
<button
type="button"
<Button
variant="outline"
onclick={testOAuthConnection}
disabled={oauthTesting}
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
loading={oauthTesting}
>
{oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
</button>
</Button>
{#if oauthTestResult}
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-status-online-ink dark:text-status-online-ink' : 'text-destructive'}">
{oauthTestResult}
@@ -138,20 +143,19 @@
</section>
<!-- Theme Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.theme_defaults')}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label>
<select
<Select
id="defaultTheme"
name="defaultTheme"
bind:value={$form.defaultTheme}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="dark">{$t('theme.dark')}</option>
<option value="light">{$t('theme.light')}</option>
</select>
</Select>
</div>
<div>
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label>
@@ -178,7 +182,7 @@
</section>
<!-- Healthcheck Defaults -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.healthcheck_defaults')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.healthcheck_defaults_description')}</p>
<div>
@@ -196,7 +200,7 @@
</section>
<!-- Service Discovery Configuration -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -226,7 +230,7 @@
</section>
<!-- System Custom CSS -->
<section class="rounded-lg border border-border bg-card p-6">
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.custom_css') ?? 'Custom CSS'}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.custom_css_description') ?? 'System-wide custom CSS applied to all pages. Scoped to .custom-css-scope to prevent breaking core UI.'}</p>
<input type="hidden" name="customCss" value={$form.customCss ?? ''} />
@@ -242,12 +246,8 @@
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
disabled={$delayed}
>
<Button type="submit" size="lg" disabled={$delayed} loading={$delayed}>
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
</button>
</Button>
</div>
</form>
+5 -13
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Tag {
id: string;
@@ -115,13 +116,9 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-card-foreground">Tag Management</h2>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? 'Cancel' : 'New Tag'}
</button>
</Button>
</div>
{#if error}
@@ -132,7 +129,7 @@
<!-- Create Form -->
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-4">
<div class="cozy-expand mb-6 rounded-xl border border-border bg-card p-4">
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3">
<div>
<label for="tag-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
@@ -157,12 +154,7 @@
<span class="text-xs text-muted-foreground">{newColor}</span>
</div>
</div>
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Tag
</button>
<Button type="submit">Create Tag</Button>
</form>
</div>
{/if}
+42 -20
View File
@@ -10,6 +10,8 @@
import TagsInput from '$lib/components/ui/TagsInput.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
type AppSchema = z.infer<typeof createAppSchema>;
@@ -219,22 +221,34 @@
<button
type="button"
onclick={() => (showAdvanced = !showAdvanced)}
class="text-sm text-muted-foreground hover:text-foreground"
aria-expanded={showAdvanced}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showAdvanced ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
</button>
{#if showAdvanced}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="healthcheckEnabled"
name="healthcheckEnabled"
type="checkbox"
bind:checked={$form.healthcheckEnabled}
class="rounded border-input"
ariaLabelledby="healthcheckEnabledLabel"
/>
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
<label id="healthcheckEnabledLabel" for="healthcheckEnabled" class="cursor-pointer text-sm text-card-foreground">
{$t('app.healthcheck_enabled')}
</label>
</div>
@@ -320,22 +334,34 @@
<button
type="button"
onclick={() => (showIntegration = !showIntegration)}
class="text-sm text-muted-foreground hover:text-foreground"
aria-expanded={showIntegration}
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
class="h-3.5 w-3.5 transition-transform duration-200 {showIntegration ? 'rotate-90' : ''}"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 4 10 8 6 12" />
</svg>
{showIntegration ? 'Hide' : 'Show'} Integration Settings
</button>
{#if showIntegration}
<div class="space-y-4 rounded-md border border-border p-4">
<div class="flex items-center gap-2">
<input
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-3">
<Switch
id="integrationEnabled"
name="integrationEnabled"
type="checkbox"
bind:checked={$form.integrationEnabled}
class="rounded border-input"
ariaLabelledby="integrationEnabledLabel"
/>
<label for="integrationEnabled" class="text-sm text-card-foreground">
<label id="integrationEnabledLabel" for="integrationEnabled" class="cursor-pointer text-sm text-card-foreground">
Enable Integration
</label>
</div>
@@ -409,16 +435,12 @@
{/if}
<div class="flex justify-end">
<button
type="submit"
disabled={$submitting}
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<Button type="submit" size="lg" disabled={$submitting} loading={$submitting}>
{#if $submitting}
{$t('app.saving')}
{:else}
{mode === 'edit' ? $t('app.update') : $t('app.save')}
{/if}
</button>
</Button>
</div>
</form>
+5 -2
View File
@@ -12,9 +12,9 @@
case 'online':
return { color: 'var(--status-online)', ink: 'var(--status-online-ink)', cssClass: 'status-online', textKey: 'status.online' };
case 'offline':
return { color: 'var(--status-offline)', ink: 'var(--status-offline-ink)', cssClass: '', textKey: 'status.offline' };
return { color: 'var(--status-offline)', ink: 'var(--status-offline-ink)', cssClass: 'status-offline', textKey: 'status.offline' };
case 'degraded':
return { color: 'var(--status-degraded)', ink: 'var(--status-degraded-ink)', cssClass: '', textKey: 'status.degraded' };
return { color: 'var(--status-degraded)', ink: 'var(--status-degraded-ink)', cssClass: 'status-degraded', textKey: 'status.degraded' };
default:
return { color: 'var(--status-unknown)', ink: 'var(--status-unknown-ink)', cssClass: '', textKey: 'status.unknown' };
}
@@ -24,10 +24,13 @@
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold"
style="color: {config.ink}; background: color-mix(in srgb, {config.color} 14%, transparent);"
role="status"
aria-live="polite"
>
<span
class="inline-block h-2 w-2 rounded-full {config.cssClass}"
style="background: {config.color};"
aria-hidden="true"
></span>
<span>{$t(config.textKey)}</span>
</span>
+3 -7
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import Button from '$lib/components/ui/Button.svelte';
interface LinkItem {
id: string;
@@ -192,12 +193,7 @@
</div>
<!-- Save Button -->
<button
type="button"
onclick={saveLinks}
disabled={saving}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button onclick={saveLinks} disabled={saving} loading={saving}>
{saving ? 'Saving...' : 'Save Links'}
</button>
</Button>
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
fields: IntegrationFieldDescriptor[];
@@ -23,16 +24,15 @@
{/if}
</label>
{#if field.type === 'boolean'}
<label class="flex items-center gap-2">
<input
<div class="flex items-center gap-3">
<Switch
id="{idPrefix}-{field.name}"
type="checkbox"
checked={!!values[field.name]}
onchange={(e) => onchange(field.name, e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
onchange={(checked) => onchange(field.name, checked)}
ariaLabel={field.label}
/>
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</label>
</div>
{:else if field.type === 'number'}
<input
id="{idPrefix}-{field.name}"
@@ -105,7 +105,7 @@
<div class="space-y-4">
<!-- Grant form -->
<div class="rounded-lg border border-border bg-card p-4">
<div class="rounded-xl border border-border bg-card p-4">
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('board.access_grant')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div>
@@ -3,6 +3,9 @@
import { editMode } from '$lib/stores/editMode.svelte.js';
import { fade, fly } from 'svelte/transition';
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface BoardData {
id: string;
@@ -124,50 +127,79 @@
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"></textarea>
</div>
<!-- Theme preview swatch -->
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/30 p-3">
<span
class="h-12 w-12 shrink-0 rounded-2xl shadow-[var(--shadow-soft)]"
style="background: hsl({themeHue} {themeSaturation}% 56%);"
aria-hidden="true"
></span>
<div class="flex-1">
<p class="text-xs font-medium text-foreground">{$t('board.theme_preview') ?? 'Theme preview'}</p>
<p class="font-mono text-xs text-muted-foreground">hsl({themeHue}°, {themeSaturation}%, 56%)</p>
</div>
<button
type="button"
class="rounded-xl px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-[var(--shadow-soft)]"
style="background: hsl({themeHue} {themeSaturation}% 56%);"
tabindex="-1"
aria-hidden="true"
>
{$t('common.sample') ?? 'Sample'}
</button>
</div>
<!-- Theme Hue -->
<div>
<label for="bp-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_hue') ?? 'Theme Hue'}</label>
<input id="bp-hue" type="range" min="0" max="360" bind:value={themeHue}
class="w-full accent-primary" />
<span class="text-xs text-muted-foreground">{themeHue}°</span>
<label for="bp-hue" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>{$t('board.theme_hue') ?? 'Theme Hue'}</span>
<span class="tabular-nums text-xs text-muted-foreground">{themeHue}°</span>
</label>
<Slider id="bp-hue" min={0} max={360} bind:value={themeHue} ariaLabel={$t('board.theme_hue') ?? 'Theme Hue'} />
</div>
<!-- Theme Saturation -->
<div>
<label for="bp-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_saturation') ?? 'Saturation'}</label>
<input id="bp-sat" type="range" min="0" max="100" bind:value={themeSaturation}
class="w-full accent-primary" />
<span class="text-xs text-muted-foreground">{themeSaturation}%</span>
<label for="bp-sat" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>{$t('board.theme_saturation') ?? 'Saturation'}</span>
<span class="tabular-nums text-xs text-muted-foreground">{themeSaturation}%</span>
</label>
<Slider id="bp-sat" min={0} max={100} bind:value={themeSaturation} ariaLabel={$t('board.theme_saturation') ?? 'Saturation'} />
</div>
<!-- Background Type -->
<div>
<label for="bp-bg" class="mb-1 block text-sm font-medium text-foreground">{$t('board.background') ?? 'Background'}</label>
<select id="bp-bg" bind:value={backgroundType}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30">
<Select id="bp-bg" bind:value={backgroundType}>
<option value="none">None</option>
<option value="mesh">Mesh Gradient</option>
<option value="particles">Particles</option>
<option value="aurora">Aurora</option>
<option value="wallpaper">Wallpaper</option>
</select>
</Select>
</div>
<!-- Wallpaper settings (conditional) -->
{#if backgroundType === 'wallpaper'}
<div class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
<div class="cozy-expand space-y-3 rounded-xl border border-border bg-background/50 p-3">
<div>
<label for="bp-wp-url" class="mb-1 block text-sm font-medium text-foreground">Wallpaper URL</label>
<input id="bp-wp-url" type="text" bind:value={wallpaperUrl} placeholder="https://..."
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" />
</div>
<div>
<label for="bp-wp-blur" class="mb-1 block text-sm font-medium text-foreground">Blur ({wallpaperBlur}px)</label>
<input id="bp-wp-blur" type="range" min="0" max="20" bind:value={wallpaperBlur} class="w-full accent-primary" />
<label for="bp-wp-blur" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>Blur</span>
<span class="tabular-nums text-xs text-muted-foreground">{wallpaperBlur}px</span>
</label>
<Slider id="bp-wp-blur" min={0} max={20} bind:value={wallpaperBlur} ariaLabel="Blur" />
</div>
<div>
<label for="bp-wp-overlay" class="mb-1 block text-sm font-medium text-foreground">Overlay ({Math.round(wallpaperOverlay * 100)}%)</label>
<input id="bp-wp-overlay" type="range" min="0" max="1" step="0.05" bind:value={wallpaperOverlay} class="w-full accent-primary" />
<label for="bp-wp-overlay" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<span>Overlay</span>
<span class="tabular-nums text-xs text-muted-foreground">{Math.round(wallpaperOverlay * 100)}%</span>
</label>
<Slider id="bp-wp-overlay" min={0} max={1} step={0.05} bind:value={wallpaperOverlay} ariaLabel="Overlay" />
</div>
</div>
{/if}
@@ -175,12 +207,11 @@
<!-- Card Size -->
<div>
<label for="bp-cardsize" class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label>
<select id="bp-cardsize" bind:value={cardSize}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30">
<Select id="bp-cardsize" bind:value={cardSize}>
<option value="compact">Compact</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</Select>
</div>
<!-- Custom CSS -->
@@ -194,19 +225,11 @@
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button
type="button"
onclick={onClose}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
<Button variant="outline" onclick={onClose}>
{$t('common.cancel') ?? 'Cancel'}
</button>
<button
type="button"
onclick={handleSave}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
</Button>
<Button variant="primary" onclick={handleSave}>
{$t('common.apply') ?? 'Apply'}
</button>
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { TargetType, PermissionLevel } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import {
loadBoardPermissions,
grantBoardPermission,
@@ -188,19 +189,18 @@
</div>
<!-- Guest access toggle -->
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3">
<label class="flex items-center gap-3 text-sm text-foreground">
<input
type="checkbox"
<div class="mb-4 rounded-xl border border-border bg-muted/30 p-3">
<div class="flex items-center gap-3 text-sm text-foreground">
<Switch
checked={isGuestAccessible}
onchange={(e) => onGuestToggle(e.currentTarget.checked)}
class="h-4 w-4 rounded border-input accent-primary"
onchange={onGuestToggle}
ariaLabel={$t('board.guest_accessible')}
/>
<div>
<span class="font-medium">{$t('board.guest_accessible')}</span>
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
</div>
</label>
</div>
</div>
<!-- Quick add permission -->
+2 -6
View File
@@ -8,6 +8,7 @@
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { goto } from '$app/navigation';
import { buttonClass } from '$lib/components/ui/Button.svelte';
interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
@@ -223,11 +224,6 @@
</DropdownMenu.Portal>
</DropdownMenu.Root>
{:else}
<a
href="/login"
class="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-colors hover:bg-primary/90"
>
{$t('auth.login')}
</a>
<a href="/login" class={buttonClass()}>{$t('auth.login')}</a>
{/if}
</header>
@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { Download, X } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import Button from '$lib/components/ui/Button.svelte';
const DISMISS_KEY = 'wal-install-prompt-dismissed';
@@ -83,13 +84,9 @@
</p>
</div>
<button
type="button"
onclick={install}
class="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Button onclick={install} class="shrink-0">
{$t('install.button')}
</button>
</Button>
<button
type="button"
@@ -1,6 +1,8 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ChannelData {
readonly id?: string;
@@ -112,7 +114,7 @@
}
</script>
<div class="rounded-lg border border-border bg-card p-6">
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
{channel ? 'Edit Channel' : 'Add Notification Channel'}
</h3>
@@ -271,14 +273,9 @@
{/if}
<!-- Enabled Toggle -->
<div class="flex items-center gap-2">
<input
id="channel-enabled"
type="checkbox"
bind:checked={enabled}
class="h-4 w-4 rounded border-input"
/>
<label for="channel-enabled" class="text-sm text-foreground">Enabled</label>
<div class="flex items-center gap-3">
<Switch id="channel-enabled" bind:checked={enabled} ariaLabelledby="channel-enabled-label" />
<label id="channel-enabled-label" for="channel-enabled" class="cursor-pointer text-sm text-foreground">Enabled</label>
</div>
<!-- Test Result -->
@@ -290,29 +287,17 @@
<!-- Actions -->
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Button type="submit">
{channel ? 'Update' : 'Create'} Channel
</button>
</Button>
{#if channel?.id}
<button
type="button"
onclick={sendTest}
disabled={testing}
class="rounded-xl border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
<Button variant="outline" onclick={sendTest} disabled={testing} loading={testing}>
{testing ? 'Sending...' : 'Send Test'}
</button>
</Button>
{/if}
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
<Button variant="ghost" onclick={onCancel}>
Cancel
</button>
</Button>
</div>
</form>
</div>
@@ -1,4 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -414,12 +416,7 @@
</button>
{/if}
<button
type="button"
onclick={handleNext}
disabled={loading}
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button size="lg" onclick={handleNext} disabled={loading} loading={loading}>
{#if loading}
Processing...
{:else if isLastStep}
@@ -429,7 +426,7 @@
{:else}
Next
{/if}
</button>
</Button>
</div>
</div>
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface Props {
onCancel: () => void;
@@ -8,7 +10,7 @@
let { onCancel }: Props = $props();
</script>
<div class="rounded-lg border border-border bg-card p-6">
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Generate API Token</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
@@ -31,15 +33,11 @@
<label for="token-scope" class="mb-1 block text-sm font-medium text-foreground">
Scope
</label>
<select
id="token-scope"
name="scope"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="token-scope" name="scope">
<option value="read">Read — View apps, boards, and status</option>
<option value="write">Write — Modify apps, boards, and settings</option>
<option value="admin">Admin — Full access including user management</option>
</select>
</Select>
</div>
<div>
@@ -56,19 +54,8 @@
</div>
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate Token
</button>
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<Button type="submit">Generate Token</Button>
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
</div>
</form>
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props {
value: string;
@@ -98,11 +99,11 @@
{/if}
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Switch
bind:checked={livePreview}
class="h-4 w-4 rounded border-input accent-primary"
size="sm"
ariaLabel={$t('settings.live_preview') ?? 'Live preview'}
/>
{$t('settings.live_preview') ?? 'Live preview'}
</label>
@@ -1,6 +1,7 @@
<script lang="ts">
import { t, locale as i18nLocale } from 'svelte-i18n';
import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js';
import Button from '$lib/components/ui/Button.svelte';
interface UserPreferences {
themeMode: string | null;
@@ -251,14 +252,9 @@
<!-- Save button -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={savePreferences}
disabled={saving}
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button size="lg" onclick={savePreferences} disabled={saving} loading={saving}>
{saving ? $t('settings.saving') : $t('settings.save')}
</button>
</Button>
{#if saved}
<span class="text-sm text-status-online-ink">{$t('settings.saved')}</span>
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts" module>
import { cn } from '$lib/utils/cn.js';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:bg-primary/90 hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)] active:translate-y-0 active:scale-[0.98]',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
outline:
'border border-border bg-card text-foreground shadow-[var(--shadow-soft)] hover:-translate-y-0.5 hover:border-primary/40 active:translate-y-0 active:scale-[0.98]',
ghost:
'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
destructive:
'bg-destructive text-destructive-foreground shadow-[var(--shadow-soft)] hover:bg-destructive/90 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]'
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2.5 text-sm',
icon: 'p-2'
};
export function buttonClass(opts: {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
extra?: string;
} = {}): string {
const variant = opts.variant ?? 'primary';
const size = opts.size ?? 'md';
return cn(
'inline-flex items-center justify-center gap-2 rounded-xl font-medium whitespace-nowrap transition-all duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-soft)]',
variantClasses[variant],
sizeClasses[size],
opts.fullWidth && 'w-full',
opts.extra
);
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
loading?: boolean;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
disabled,
type = 'button',
class: className = '',
children,
...rest
}: Props = $props();
</script>
<button
{type}
disabled={disabled || loading}
class={buttonClass({ variant, size, fullWidth, extra: className })}
{...rest}
>
{#if loading}
<span class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true"></span>
{/if}
{@render children()}
</button>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
checked?: boolean | undefined;
onchange?: (checked: boolean) => void;
disabled?: boolean;
id?: string;
name?: string;
value?: string;
ariaLabel?: string;
ariaLabelledby?: string;
indeterminate?: boolean;
class?: string;
}
let {
checked = $bindable(false),
onchange,
disabled = false,
id,
name,
value,
ariaLabel,
ariaLabelledby,
indeterminate = false,
class: className = ''
}: Props = $props();
function toggle() {
if (disabled) return;
checked = !checked;
onchange?.(checked);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ' ') {
e.preventDefault();
toggle();
}
}
</script>
<button
type="button"
role="checkbox"
aria-checked={indeterminate ? 'mixed' : checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{id}
{disabled}
onclick={toggle}
onkeydown={onKeydown}
class={cn(
'inline-flex h-[18px] w-[18px] shrink-0 cursor-pointer items-center justify-center rounded-md border transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
checked || indeterminate
? 'border-primary bg-primary text-primary-foreground shadow-[inset_0_1px_2px_rgba(0,0,0,0.15)]'
: 'border-input bg-background hover:border-primary/60',
className
)}
>
{#if indeterminate}
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
<line x1="4" y1="9" x2="14" y2="9" />
</svg>
{:else if checked}
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="3.5 9.5 7.5 13.5 14.5 5.5" class="check-draw" />
</svg>
{/if}
{#if name !== undefined}
<input type="hidden" {name} value={checked ? (value ?? 'on') : ''} />
{/if}
</button>
<style>
.check-draw {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: check-draw-in 180ms ease-out forwards;
}
@keyframes check-draw-in {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.check-draw {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils/cn.js';
interface Props {
label?: string;
labelFor?: string;
hint?: string;
error?: string;
required?: boolean;
class?: string;
children: Snippet;
}
let {
label,
labelFor,
hint,
error,
required = false,
class: className = '',
children
}: Props = $props();
</script>
<div class={cn('space-y-1.5', className)}>
{#if label}
<label for={labelFor} class="block text-sm font-medium text-foreground">
{label}
{#if required}<span class="text-destructive" aria-hidden="true">*</span>{/if}
</label>
{/if}
{@render children()}
{#if error}
<p class="text-xs text-destructive" role="alert">{error}</p>
{:else if hint}
<p class="text-xs text-muted-foreground">{hint}</p>
{/if}
</div>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts" module>
export const inputClass =
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50';
</script>
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLInputAttributes, 'class' | 'value'> {
value?: string | number;
class?: string;
invalid?: boolean;
}
let {
value = $bindable(''),
type = 'text',
invalid = false,
class: className = '',
...rest
}: Props = $props();
</script>
<input
{type}
bind:value
class={cn(inputClass, invalid && 'border-destructive focus:border-destructive', className)}
aria-invalid={invalid || undefined}
{...rest}
/>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts" module>
export const selectClass =
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 appearance-none bg-no-repeat bg-[right_0.75rem_center] bg-[length:0.85em] pr-9';
export const chevronBg =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 6 8 10 12 6'/></svg>\")";
</script>
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
import type { Snippet } from 'svelte';
import type { HTMLSelectAttributes } from 'svelte/elements';
interface Props extends Omit<HTMLSelectAttributes, 'class' | 'value' | 'children'> {
value?: string | number | undefined;
class?: string;
children: Snippet;
}
let {
value = $bindable<string | number | undefined>(''),
class: className = '',
children,
...rest
}: Props = $props();
</script>
<div class="relative">
<select
bind:value
class={cn(selectClass, className)}
style="background-image: {chevronBg};"
{...rest}
>
{@render children()}
</select>
</div>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
id?: string;
name?: string;
ariaLabel?: string;
ariaLabelledby?: string;
showValue?: boolean;
formatValue?: (v: number) => string;
class?: string;
oninput?: (value: number) => void;
onchange?: (value: number) => void;
}
let {
value = $bindable(0),
min = 0,
max = 100,
step = 1,
disabled = false,
id,
name,
ariaLabel,
ariaLabelledby,
showValue = false,
formatValue,
class: className = '',
oninput,
onchange
}: Props = $props();
const pct = $derived(((value - min) / (max - min)) * 100);
const displayValue = $derived(formatValue ? formatValue(value) : String(value));
function handleInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
value = Number(target.value);
oninput?.(value);
}
function handleChange(e: Event) {
const target = e.currentTarget as HTMLInputElement;
onchange?.(Number(target.value));
}
</script>
<div class={cn('cozy-slider relative w-full', className)} style="--pct: {pct}%;">
<input
type="range"
{id}
{name}
{min}
{max}
{step}
{disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-valuetext={displayValue}
{value}
oninput={handleInput}
onchange={handleChange}
class="cozy-slider-input"
/>
{#if showValue}
<span class="mt-1 inline-block text-xs tabular-nums text-muted-foreground">{displayValue}</span>
{/if}
</div>
<style>
.cozy-slider-input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 9999px;
background: linear-gradient(
to right,
var(--primary) 0%,
var(--primary) var(--pct, 0%),
color-mix(in srgb, var(--muted-foreground) 35%, transparent) var(--pct, 0%),
color-mix(in srgb, var(--muted-foreground) 35%, transparent) 100%
);
outline: none;
cursor: pointer;
transition: background 0.1s ease;
}
.cozy-slider-input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.cozy-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 9999px;
background: var(--card);
border: 2px solid var(--primary);
box-shadow:
0 2px 4px rgba(80, 50, 20, 0.3),
0 1px 2px rgba(80, 50, 20, 0.15);
cursor: pointer;
transition: transform 0.15s ease;
}
.cozy-slider-input::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.cozy-slider-input::-webkit-slider-thumb:active {
transform: scale(1.18);
}
.cozy-slider-input::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 9999px;
background: var(--card);
border: 2px solid var(--primary);
box-shadow:
0 2px 4px rgba(80, 50, 20, 0.3),
0 1px 2px rgba(80, 50, 20, 0.15);
cursor: pointer;
transition: transform 0.15s ease;
}
.cozy-slider-input::-moz-range-thumb:hover {
transform: scale(1.1);
}
.cozy-slider-input:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 40%, transparent);
outline-offset: 4px;
border-radius: 9999px;
}
@media (prefers-reduced-motion: reduce) {
.cozy-slider-input::-webkit-slider-thumb,
.cozy-slider-input::-moz-range-thumb {
transition: none;
}
.cozy-slider-input::-webkit-slider-thumb:hover,
.cozy-slider-input::-webkit-slider-thumb:active {
transform: none;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { cn } from '$lib/utils/cn.js';
interface Props {
checked?: boolean | undefined;
onchange?: (checked: boolean) => void;
disabled?: boolean;
id?: string;
name?: string;
label?: string;
ariaLabel?: string;
ariaLabelledby?: string;
size?: 'sm' | 'md';
class?: string;
}
let {
checked = $bindable(false),
onchange,
disabled = false,
id,
name,
label,
ariaLabel,
ariaLabelledby,
size = 'md',
class: className = ''
}: Props = $props();
function toggle() {
if (disabled) return;
checked = !checked;
onchange?.(checked);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
}
const trackBase =
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50';
const trackSize = $derived(size === 'sm' ? 'h-5 w-9' : 'h-6 w-11');
const knobSize = $derived(size === 'sm' ? 'h-4 w-4' : 'h-5 w-5');
const knobTranslate = $derived(
size === 'sm'
? checked
? 'translate-x-4'
: 'translate-x-0.5'
: checked
? 'translate-x-[1.375rem]'
: 'translate-x-0.5'
);
</script>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label}
aria-labelledby={ariaLabelledby}
{id}
{disabled}
onclick={toggle}
onkeydown={onKeydown}
class={cn(
trackBase,
trackSize,
checked ? 'bg-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.18)]' : 'bg-muted-foreground/35',
className
)}
>
<span
aria-hidden="true"
class={cn(
'pointer-events-none inline-block transform rounded-full bg-white shadow-[0_2px_4px_rgba(80,50,20,0.35),0_1px_2px_rgba(80,50,20,0.18)] ring-0 transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
knobSize,
knobTranslate
)}
></span>
{#if name !== undefined}
<input type="hidden" {name} value={checked ? 'on' : ''} />
{/if}
</button>
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { buttonClass } from '../Button.svelte';
describe('buttonClass', () => {
it('returns primary md by default', () => {
const cls = buttonClass();
expect(cls).toContain('bg-primary');
expect(cls).toContain('px-4');
expect(cls).toContain('py-2');
expect(cls).toContain('text-sm');
});
it('applies secondary variant', () => {
const cls = buttonClass({ variant: 'secondary' });
expect(cls).toContain('bg-secondary');
expect(cls).not.toContain('bg-primary ');
});
it('applies destructive variant', () => {
const cls = buttonClass({ variant: 'destructive' });
expect(cls).toContain('bg-destructive');
});
it('applies sm size', () => {
const cls = buttonClass({ size: 'sm' });
expect(cls).toContain('px-3');
expect(cls).toContain('text-xs');
});
it('applies lg size', () => {
const cls = buttonClass({ size: 'lg' });
expect(cls).toContain('px-6');
});
it('adds fullWidth', () => {
const cls = buttonClass({ fullWidth: true });
expect(cls).toContain('w-full');
});
it('merges extra class', () => {
const cls = buttonClass({ extra: 'custom-class' });
expect(cls).toContain('custom-class');
});
it('always includes focus-visible ring', () => {
const cls = buttonClass();
expect(cls).toContain('focus-visible:ring-2');
expect(cls).toContain('focus-visible:ring-primary/30');
});
it('always includes disabled state', () => {
const cls = buttonClass();
expect(cls).toContain('disabled:cursor-not-allowed');
expect(cls).toContain('disabled:opacity-50');
});
});
@@ -4,6 +4,9 @@
import { tick } from 'svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface AppInfo {
id: string;
@@ -269,13 +272,12 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.format') ?? 'Format'}
<select bind:value={noteFormat} class={inputClass}>
<div class={labelClass}>{$t('widget.format') ?? 'Format'}</div>
<Select bind:value={noteFormat}>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
</select>
</label>
</Select>
</div>
{:else if widgetType === 'embed'}
@@ -285,9 +287,8 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
</label>
<div class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</div>
<Slider min={100} max={800} bind:value={embedHeight} ariaLabel="Height" />
</div>
<div>
<label class={labelClass}>Sandbox
@@ -318,16 +319,15 @@
</label>
</div>
<div>
<label class={labelClass}>{$t('widget.style') ?? 'Style'}
<select bind:value={clockStyle} class={inputClass}>
<div class={labelClass}>{$t('widget.style') ?? 'Style'}</div>
<Select bind:value={clockStyle}>
<option value="digital">Digital</option>
<option value="analog">Analog</option>
<option value="24h">24h</option>
</select>
</label>
</Select>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={clockShowWeather} size="sm" ariaLabel={$t('widget.show_weather') ?? 'Show Weather'} />
{$t('widget.show_weather') ?? 'Show Weather'}
</label>
{#if clockShowWeather}
@@ -352,18 +352,16 @@
</label>
</div>
<div>
<label class={labelClass}>Source Type
<select bind:value={sysStatsSourceType} class={inputClass}>
<div class={labelClass}>Source Type</div>
<Select bind:value={sysStatsSourceType}>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom</option>
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</div>
<Slider min={5} max={300} bind:value={sysStatsRefreshInterval} ariaLabel="Refresh interval" />
</div>
{:else if widgetType === 'rss'}
@@ -373,12 +371,11 @@
</label>
</div>
<div>
<label class={labelClass}>Max Items ({rssMaxItems})
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</label>
<div class={labelClass}>Max Items ({rssMaxItems})</div>
<Slider min={1} max={50} bind:value={rssMaxItems} ariaLabel="Max items" />
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={rssShowSummary} size="sm" ariaLabel="Show Summary" />
Show Summary
</label>
@@ -398,9 +395,8 @@
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
</div>
<div>
<label class={labelClass}>Days Ahead ({calendarDaysAhead})
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
</label>
<div class={labelClass}>Days Ahead ({calendarDaysAhead})</div>
<Slider min={1} max={30} bind:value={calendarDaysAhead} ariaLabel="Days ahead" />
</div>
{:else if widgetType === 'markdown'}
@@ -417,13 +413,12 @@
</label>
</div>
<div>
<label class={labelClass}>Source
<select bind:value={metricSource} class={inputClass}>
<div class={labelClass}>Source</div>
<Select bind:value={metricSource}>
<option value="static">Static</option>
<option value="json">JSON Endpoint</option>
<option value="prometheus">Prometheus</option>
</select>
</label>
</Select>
</div>
{#if metricSource === 'static'}
<div>
@@ -461,9 +456,8 @@
</label>
</div>
<div>
<label class={labelClass}>Refresh ({metricRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({metricRefreshInterval}s)</div>
<Slider min={5} max={300} bind:value={metricRefreshInterval} ariaLabel="Refresh interval" />
</div>
</div>
@@ -482,8 +476,8 @@
{/each}
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
</div>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch bind:checked={linkGroupCollapsible} size="sm" ariaLabel="Collapsible" />
Collapsible
</label>
@@ -494,39 +488,35 @@
</label>
</div>
<div>
<label class={labelClass}>Type
<select bind:value={cameraType} class={inputClass}>
<div class={labelClass}>Type</div>
<Select bind:value={cameraType}>
<option value="image">Image</option>
<option value="mjpeg">MJPEG</option>
<option value="hls">HLS</option>
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({cameraRefreshInterval}s)</div>
<Slider min={1} max={60} bind:value={cameraRefreshInterval} ariaLabel="Refresh interval" />
</div>
<div>
<label class={labelClass}>Aspect Ratio
<select bind:value={cameraAspectRatio} class={inputClass}>
<div class={labelClass}>Aspect Ratio</div>
<Select bind:value={cameraAspectRatio}>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
</select>
</label>
</Select>
</div>
{:else if widgetType === 'integration'}
<div>
<label class={labelClass}>{$t('widget.app') ?? 'App'}
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<Select bind:value={integrationAppId}>
<option value="">Select app...</option>
{#each apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
</label>
</Select>
</div>
<div>
<label class={labelClass}>Endpoint ID
@@ -534,9 +524,8 @@
</label>
</div>
<div>
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
</label>
<div class={labelClass}>Refresh ({integrationRefreshInterval}s)</div>
<Slider min={10} max={600} bind:value={integrationRefreshInterval} ariaLabel="Refresh interval" />
</div>
{/if}
</div>
@@ -9,6 +9,10 @@
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Slider from '$lib/components/ui/Slider.svelte';
import Select from '$lib/components/ui/Select.svelte';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
interface Props {
sectionId: string;
@@ -507,12 +511,11 @@
<span class="mb-1 block text-sm font-medium text-foreground">Select Apps</span>
<div class="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-input bg-background p-2">
{#each apps as app (app.id)}
<label class="flex items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<Checkbox
checked={statusAppIds.includes(app.id)}
onchange={() => toggleStatusApp(app.id)}
class="h-4 w-4 rounded border-input accent-primary"
ariaLabel={app.name}
/>
{app.name}
</label>
@@ -549,12 +552,8 @@
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={clockShowWeather}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={clockShowWeather} ariaLabel="Show Weather" />
Show Weather
</label>
</div>
@@ -599,26 +598,24 @@
</div>
<div>
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
<select
<Select
id="sys-type-{sectionId}"
bind:value={sysStatsSourceType}
class={inputClass}
>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom JSON</option>
</select>
</Select>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
<div class="flex flex-wrap gap-2">
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)}
<label class="flex items-center gap-1.5 rounded-xl border border-input px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-input px-2.5 py-1 text-sm text-foreground hover:bg-accent">
<Checkbox
checked={sysStatsMetrics.includes(metric)}
onchange={() => toggleSysStatsMetric(metric)}
class="h-3.5 w-3.5 rounded border-input accent-primary"
ariaLabel={metric}
/>
<span class="capitalize">{metric}</span>
</label>
@@ -629,14 +626,13 @@
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh Interval: {sysStatsRefreshInterval}s
</label>
<input
<Slider
id="sys-refresh-{sectionId}"
type="range"
bind:value={sysStatsRefreshInterval}
min="5"
max="300"
step="5"
class="w-full accent-primary"
min={5}
max={300}
step={5}
ariaLabel="Refresh interval"
/>
</div>
</div>
@@ -658,23 +654,18 @@
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Max Items: {rssMaxItems}
</label>
<input
<Slider
id="rss-max-{sectionId}"
type="range"
bind:value={rssMaxItems}
min="3"
max="30"
step="1"
class="w-full accent-primary"
min={3}
max={30}
step={1}
ariaLabel="Max items"
/>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={rssShowSummary}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={rssShowSummary} ariaLabel="Show Summaries" />
Show Summaries
</label>
</div>
@@ -733,14 +724,13 @@
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Days Ahead: {calendarDaysAhead}
</label>
<input
<Slider
id="cal-days-{sectionId}"
type="range"
bind:value={calendarDaysAhead}
min="1"
max="30"
step="1"
class="w-full accent-primary"
min={1}
max={30}
step={1}
ariaLabel="Days ahead"
/>
</div>
</div>
@@ -855,14 +845,13 @@
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {metricRefreshInterval}s
</label>
<input
<Slider
id="metric-refresh-{sectionId}"
type="range"
bind:value={metricRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
min={10}
max={600}
step={10}
ariaLabel="Refresh interval"
/>
</div>
</div>
@@ -916,12 +905,8 @@
</button>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={linkGroupCollapsible}
class="h-4 w-4 rounded border-input accent-primary"
/>
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<Switch bind:checked={linkGroupCollapsible} ariaLabel="Collapsible" />
Collapsible
</label>
</div>
@@ -942,43 +927,40 @@
</div>
<div>
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
<select
<Select
id="cam-type-{sectionId}"
bind:value={cameraType}
class={inputClass}
>
<option value="image">Snapshot (Image)</option>
<option value="mjpeg">MJPEG Stream</option>
<option value="hls">HLS Stream</option>
</select>
</Select>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {cameraRefreshInterval}s
</label>
<input
<Slider
id="cam-refresh-{sectionId}"
type="range"
bind:value={cameraRefreshInterval}
min="1"
max="120"
step="1"
class="w-full accent-primary"
min={1}
max={120}
step={1}
ariaLabel="Refresh interval"
/>
</div>
<div>
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
<select
<Select
id="cam-ratio-{sectionId}"
bind:value={cameraAspectRatio}
class={inputClass}
>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
<option value="21/9">21:9</option>
</select>
</Select>
</div>
</div>
</div>
@@ -990,44 +972,41 @@
{#if integrationApps.length === 0}
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
{:else}
<select
<Select
id="int-app-{sectionId}"
bind:value={integrationAppId}
class={inputClass}
>
<option value="">Select an app...</option>
{#each integrationApps as app (app.id)}
<option value={app.id}>{app.name} ({app.integrationType})</option>
{/each}
</select>
</Select>
{/if}
</div>
{#if integrationAppId && integrationEndpoints.length > 0}
<div>
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
<select
<Select
id="int-endpoint-{sectionId}"
bind:value={integrationEndpointId}
class={inputClass}
>
<option value="">Select endpoint...</option>
{#each integrationEndpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {integrationRefreshInterval}s
</label>
<input
<Slider
id="int-refresh-{sectionId}"
type="range"
bind:value={integrationRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
min={10}
max={600}
step={10}
ariaLabel="Refresh interval"
/>
</div>
{/if}
+3 -10
View File
@@ -3,6 +3,7 @@
import { t } from 'svelte-i18n';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
@@ -46,17 +47,9 @@
{/if}
{/snippet}
{#snippet actions()}
<a
href="/"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('error.back_to_dashboard')}
</a>
<a href="/" class={buttonClass()}>{$t('error.back_to_dashboard')}</a>
{#if status === 401}
<a
href="/login"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<a href="/login" class={buttonClass({ variant: 'outline' })}>
{$t('auth.login_submit')}
</a>
{/if}
+4 -12
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -41,26 +42,17 @@
{$t('home.welcome', { values: { name: data.user.displayName } })}
</p>
<div class="mt-8 flex items-center justify-center gap-3">
<a
href="/boards"
class="rounded-2xl bg-primary px-5 py-3 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<a href="/boards" class={buttonClass({ size: 'lg' })}>
{$t('home.view_boards')}
</a>
<a
href="/apps"
class="rounded-2xl border border-border bg-card px-5 py-3 text-sm font-semibold text-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:border-primary/40"
>
<a href="/apps" class={buttonClass({ variant: 'outline', size: 'lg' })}>
{$t('home.browse_apps')}
</a>
</div>
{:else}
<h1 class="font-display text-4xl font-semibold text-foreground">{$t('app_title')}</h1>
<div class="mt-8">
<a
href="/login"
class="rounded-2xl bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<a href="/login" class={buttonClass({ size: 'lg' })}>
{$t('auth.login')}
</a>
</div>
+3 -10
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
@@ -24,16 +25,8 @@
<ErrorState {status} {title} {hint}>
{#snippet actions()}
<a
href="/"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('error.back_to_dashboard')}
</a>
<a
href="/admin/users"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<a href="/" class={buttonClass()}>{$t('error.back_to_dashboard')}</a>
<a href="/admin/users" class={buttonClass({ variant: 'outline' })}>
{$t('admin.users') ?? 'Admin users'}
</a>
{/snippet}
+10 -18
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js';
import GroupTable from '$lib/components/admin/GroupTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -25,17 +27,13 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.group_management')}</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? $t('common.cancel') : $t('admin.create_group')}
</button>
</Button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<div class="mb-6 rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.new_group')}</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -61,26 +59,20 @@
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<div class="flex items-center gap-2">
<input
<div class="flex items-center gap-3">
<Switch
id="isDefault"
name="isDefault"
type="checkbox"
bind:checked={$form.isDefault}
class="h-4 w-4 rounded border-input"
ariaLabelledby="isDefaultLabel"
/>
<label for="isDefault" class="text-sm font-medium text-foreground">{$t('admin.default_group_hint')}</label>
<label id="isDefaultLabel" for="isDefault" class="cursor-pointer text-sm font-medium text-foreground">{$t('admin.default_group_hint')}</label>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('admin.create_group')}
</button>
<Button type="submit">{$t('admin.create_group')}</Button>
</form>
</div>
{/if}
+9 -33
View File
@@ -2,6 +2,8 @@
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
let { data }: { data: PageData } = $props();
@@ -95,13 +97,7 @@
</p>
</div>
{#if !showForm}
<button
type="button"
onclick={() => (showForm = true)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate invite
</button>
<Button onclick={() => (showForm = true)}>Generate invite</Button>
{/if}
</div>
@@ -123,13 +119,7 @@
>
{createdUrl}
</code>
<button
type="button"
onclick={() => createdUrl && copyUrl(createdUrl)}
class="rounded-xl bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Copy
</button>
<Button size="sm" onclick={() => createdUrl && copyUrl(createdUrl)}>Copy</Button>
</div>
<button
type="button"
@@ -161,14 +151,10 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label for="inv-role" class="mb-1 block text-sm font-medium text-card-foreground">Role</label>
<select
id="inv-role"
bind:value={role}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="inv-role" bind:value={role}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Select>
</div>
<div>
<label for="inv-expiry" class="mb-1 block text-sm font-medium text-card-foreground">
@@ -185,20 +171,10 @@
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showForm = false)}
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button variant="outline" onclick={() => (showForm = false)}>Cancel</Button>
<Button type="submit" disabled={creating} loading={creating}>
{creating ? 'Creating…' : 'Create invite'}
</button>
</Button>
</div>
</form>
{/if}
@@ -2,6 +2,7 @@
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
@@ -105,13 +106,9 @@
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
disabled={issuing}
class="w-full rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:w-auto"
>
<Button type="submit" disabled={issuing} loading={issuing} class="sm:w-auto" fullWidth>
{issuing ? 'Issuing…' : 'Issue link'}
</button>
</Button>
</form>
<!-- Success: a real link to copy -->
+8 -20
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js';
import UserTable from '$lib/components/admin/UserTable.svelte';
import { superForm } from 'sveltekit-superforms/client';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
let { data }: { data: PageData } = $props();
@@ -25,17 +27,13 @@
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.user_management')}</h1>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<Button onclick={() => (showCreateForm = !showCreateForm)}>
{showCreateForm ? $t('common.cancel') : $t('admin.create_user')}
</button>
</Button>
</div>
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-6">
<div class="mb-6 rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.new_user')}</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -76,26 +74,16 @@
</div>
<div>
<label for="role" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.role_column')}</label>
<select
id="role"
name="role"
bind:value={$form.role}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<Select id="role" name="role" bind:value={$form.role}>
<option value="user">{$t('admin.role_user')}</option>
<option value="admin">{$t('admin.role_admin')}</option>
</select>
</Select>
</div>
</div>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
<button
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('admin.create_user')}
</button>
<Button type="submit">{$t('admin.create_user')}</Button>
</form>
</div>
{/if}
+27 -31
View File
@@ -4,6 +4,7 @@
import AppCard from '$lib/components/app/AppCard.svelte';
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -33,13 +34,9 @@
{$t('app.apps_registered', { values: { count: data.apps.length } })}
</p>
</div>
<button
type="button"
onclick={() => (showForm = !showForm)}
class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<Button size="lg" onclick={() => (showForm = !showForm)}>
{showForm ? $t('common.cancel') : $t('app.add')}
</button>
</Button>
</div>
{#if showForm}
@@ -70,40 +67,39 @@
{#if data.apps.length === 0}
<div class="flex flex-col items-center rounded-[1.4rem] border-2 border-dashed border-border bg-card/40 px-6 py-16 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-[1.3rem] bg-primary/10">
<svg
class="h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<svg
class="mb-5 h-24 w-28"
viewBox="0 0 112 96"
fill="none"
aria-hidden="true"
>
<!-- tile grid: 4 rounded squares in room palette -->
<rect x="14" y="14" width="36" height="36" rx="10" style="fill: var(--room-peach); opacity: 0.9;" />
<rect x="58" y="14" width="36" height="36" rx="10" style="fill: var(--room-sky); opacity: 0.9;" />
<rect x="14" y="54" width="36" height="36" rx="10" style="fill: var(--room-butter); opacity: 0.92;" />
<rect x="58" y="54" width="36" height="36" rx="10" style="fill: var(--room-sage); opacity: 0.9;" />
<!-- glints -->
<circle cx="24" cy="24" r="3" fill="white" opacity="0.7" />
<circle cx="68" cy="24" r="3" fill="white" opacity="0.55" />
<circle cx="24" cy="64" r="3" fill="white" opacity="0.6" />
<circle cx="68" cy="64" r="3" fill="white" opacity="0.55" />
</svg>
<p class="text-lg font-medium text-foreground">{$t('app.no_apps')}</p>
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('app.no_apps_hint')}</p>
<button
type="button"
onclick={() => (showForm = true)}
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<Button size="lg" onclick={() => (showForm = true)} class="mt-4">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{$t('app.add')}
</button>
</Button>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each data.apps as app (app.id)}
<AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} />
{#each data.apps as app, i (app.id)}
<div class="cozy-rise-stagger" style="--i: {Math.min(i, 12)};">
<AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} />
</div>
{/each}
</div>
{/if}
+3 -13
View File
@@ -4,6 +4,7 @@
import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import { page } from '$app/stores';
import Button, { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -35,19 +36,8 @@
{$t('app.quick_add_success')}
</p>
<div class="mt-3 flex gap-3">
<a
href="/apps"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{$t('app.quick_add_view_apps')}
</a>
<button
type="button"
onclick={closeWindow}
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
>
{$t('app.quick_add_close')}
</button>
<a href="/apps" class={buttonClass()}>{$t('app.quick_add_view_apps')}</a>
<Button variant="outline" onclick={closeWindow}>{$t('app.quick_add_close')}</Button>
</div>
</div>
{:else}
+3 -10
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status);
const message = $derived($page.error?.message ?? '');
@@ -25,16 +26,8 @@
<ErrorState {status} {title} {hint}>
{#snippet actions()}
<a
href="/"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('nav.home') ?? 'Home'}
</a>
<a
href="/boards"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<a href="/" class={buttonClass()}>{$t('nav.home') ?? 'Home'}</a>
<a href="/boards" class={buttonClass({ variant: 'outline' })}>
{$t('boards.title') ?? $t('board.title') ?? 'All boards'}
</a>
{/snippet}
+26 -27
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import BoardCard from '$lib/components/board/BoardCard.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
</script>
@@ -21,10 +22,7 @@
</div>
{#if !data.isGuest && data.user?.role === 'admin'}
<a
href="/boards/new"
class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<a href="/boards/new" class={buttonClass({ size: 'lg' })}>
{$t('board.new')}
</a>
{/if}
@@ -32,22 +30,24 @@
{#if data.boards.length === 0}
<div class="flex flex-col items-center rounded-[1.4rem] border-2 border-dashed border-border bg-card/40 px-6 py-16 text-center">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-[1.3rem] bg-primary/10">
<svg
class="h-8 w-8 text-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
</div>
<svg
class="mb-5 h-24 w-28"
viewBox="0 0 112 96"
fill="none"
aria-hidden="true"
>
<!-- back room (sky) -->
<rect x="8" y="14" width="56" height="52" rx="12" style="fill: var(--room-sky); opacity: 0.85;" />
<rect x="16" y="22" width="14" height="14" rx="3" fill="white" opacity="0.65" />
<rect x="34" y="22" width="14" height="14" rx="3" fill="white" opacity="0.45" />
<!-- middle room (peach) -->
<rect x="30" y="32" width="62" height="56" rx="14" style="fill: var(--room-peach); opacity: 0.92;" />
<rect x="40" y="44" width="18" height="18" rx="4" fill="white" opacity="0.7" />
<rect x="62" y="44" width="18" height="18" rx="4" fill="white" opacity="0.55" />
<rect x="40" y="66" width="40" height="6" rx="2" fill="white" opacity="0.45" />
<!-- accent (sage chimney) -->
<rect x="74" y="10" width="14" height="22" rx="4" style="fill: var(--room-sage); opacity: 0.9;" />
</svg>
<p class="text-lg font-medium text-foreground">{$t('board.no_boards')}</p>
{#if data.isGuest}
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('board.sign_in_more')}</p>
@@ -55,11 +55,8 @@
<p class="mt-1 max-w-sm text-sm text-muted-foreground">
Create your first board to organize apps into a custom dashboard.
</p>
<a
href="/boards/new"
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)]"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<a href="/boards/new" class={buttonClass({ size: 'lg', extra: 'mt-4' })}>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -69,8 +66,10 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.boards as board (board.id)}
<BoardCard {board} />
{#each data.boards as board, i (board.id)}
<div class="cozy-rise-stagger" style="--i: {i};">
<BoardCard {board} />
</div>
{/each}
</div>
{/if}
+10 -12
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js';
import { superForm } from 'sveltekit-superforms';
import TemplatePicker from '$lib/components/board/TemplatePicker.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button, { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form);
@@ -71,29 +73,25 @@
<TemplatePicker onSelect={handleTemplateSelect} />
<input type="hidden" name="templateId" value={selectedTemplateId ?? ''} />
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" name="isDefault" bind:checked={$form.isDefault} class="rounded" />
<div class="flex flex-wrap items-center gap-6">
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch name="isDefault" bind:checked={$form.isDefault} ariaLabel={$t('board.default_board')} />
{$t('board.default_board')}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" name="isGuestAccessible" bind:checked={$form.isGuestAccessible} class="rounded" />
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<Switch name="isGuestAccessible" bind:checked={$form.isGuestAccessible} ariaLabel={$t('board.guest_accessible')} />
{$t('board.guest_accessible')}
</label>
</div>
<div class="flex justify-end gap-3 pt-2">
<a href="/boards" class="rounded-lg border border-border px-4 py-2 text-sm text-foreground hover:bg-accent">
<a href="/boards" class={buttonClass({ variant: 'outline' })}>
{$t('common.cancel')}
</a>
<button
type="submit"
disabled={$submitting}
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<Button type="submit" disabled={$submitting} loading={$submitting}>
{$submitting ? $t('board.creating') : $t('board.create')}
</button>
</Button>
</div>
</form>
</div>
+3 -6
View File
@@ -3,6 +3,7 @@
import { enhance } from '$app/forms';
import { KeyRound } from 'lucide-svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ActionResult {
error?: string;
@@ -80,13 +81,9 @@
<p class="mb-3 text-sm text-destructive">{form.error}</p>
{/if}
<button
type="submit"
disabled={submitting}
class="w-full rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button type="submit" fullWidth disabled={submitting} loading={submitting}>
{submitting ? '…' : ($t('auth.forgot_password_submit') ?? 'Request reset link')}
</button>
</Button>
</form>
<p class="mt-4 text-center text-xs text-muted-foreground">
+3 -6
View File
@@ -3,6 +3,7 @@
import { enhance } from '$app/forms';
import { TicketCheck } from 'lucide-svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ActionResult {
error?: string;
@@ -62,13 +63,9 @@
<p class="mb-3 text-sm text-destructive">{form.error}</p>
{/if}
<button
type="submit"
disabled={submitting}
class="mb-2 w-full rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<Button type="submit" fullWidth disabled={submitting} loading={submitting} class="mb-2">
{submitting ? '…' : ($t('auth.invite_continue') ?? 'Continue')}
</button>
</Button>
</form>
<p class="mt-4 text-center text-xs text-muted-foreground">
+9 -18
View File
@@ -3,6 +3,8 @@
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Switch from '$lib/components/ui/Switch.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
@@ -106,12 +108,12 @@
</div>
<div class="flex items-center justify-between gap-4">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
<label class="flex cursor-pointer items-center gap-3 text-sm text-muted-foreground">
<Switch
name="rememberMe"
bind:checked={$form.rememberMe}
class="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-primary/30"
size="sm"
ariaLabel={$t('auth.remember_me')}
/>
<span>{$t('auth.remember_me')}</span>
</label>
@@ -120,20 +122,9 @@
</a>
</div>
<button
type="submit"
disabled={$submitting}
class="w-full rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
<span class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
{$t('auth.login_submitting')}
</span>
{:else}
{$t('auth.login_submit')}
{/if}
</button>
<Button type="submit" size="lg" fullWidth disabled={$submitting} loading={$submitting}>
{$submitting ? $t('auth.login_submitting') : $t('auth.login_submit')}
</Button>
</form>
{/if}
@@ -165,7 +165,7 @@
{:else}
<div class="space-y-3">
{#each channels as channel (channel.id)}
<div class="flex items-center justify-between rounded-lg border border-border bg-card p-4">
<div class="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-md bg-muted text-xs font-bold text-muted-foreground">
{channelTypeLabel(channel.type).charAt(0)}
+1 -1
View File
@@ -177,7 +177,7 @@
<h2 class="mb-4 text-lg font-semibold text-foreground">Recent Incidents</h2>
<div class="space-y-2">
{#each data.incidents as incident (`${incident.appId}-${incident.startedAt}`)}
<div class="rounded-lg border border-border bg-card/50 p-3">
<div class="rounded-xl border border-border bg-card/50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>