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 { .status-online {
animation: status-pulse 2s ease-in-out infinite; animation: status-pulse 2s ease-in-out infinite;
color: var(--status-online); 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 Style Variants ===== */
.card-solid { .card-solid {
background: var(--card); 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 ===== */ /* ===== Cozy greeting wave ===== */
@keyframes cozy-wave { @keyframes cozy-wave {
0%, 0%,
@@ -359,7 +435,12 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.cozy-wave, .cozy-wave,
.status-online { .status-online,
.status-degraded,
.status-offline,
.cozy-rise,
.cozy-rise-stagger,
.cozy-expand {
animation: none; animation: none;
} }
.card-hover:hover { .card-hover:hover {
+164 -37
View File
@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { t } from 'svelte-i18n'; 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 { interface BackupInfo {
filename: string; filename: string;
size: number; size: number;
createdAt: string; createdAt: string;
format: 'tar.gz' | 'db';
} }
interface BackupSchedule { interface BackupSchedule {
@@ -14,6 +18,14 @@
backupMaxCount: number; backupMaxCount: number;
} }
interface SchedulerStats {
successCount: number;
failureCount: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastFailureReason: string | null;
}
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom'; type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
let backups: BackupInfo[] = $state([]); let backups: BackupInfo[] = $state([]);
@@ -22,6 +34,13 @@
backupCronExpression: '0 3 * * *', backupCronExpression: '0 3 * * *',
backupMaxCount: 10 backupMaxCount: 10
}); });
let stats: SchedulerStats = $state({
successCount: 0,
failureCount: 0,
lastSuccessAt: null,
lastFailureAt: null,
lastFailureReason: null
});
let creating = $state(false); let creating = $state(false);
let savingSchedule = $state(false); let savingSchedule = $state(false);
@@ -29,6 +48,9 @@
let deletingFilename: string | null = $state(null); let deletingFilename: string | null = $state(null);
let confirmRestore: string | null = $state(null); let confirmRestore: string | null = $state(null);
let confirmDelete: 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 statusMessage = $state('');
let statusType: 'success' | 'error' | '' = $state(''); let statusType: 'success' | 'error' | '' = $state('');
let customCron = $state(''); let customCron = $state('');
@@ -48,7 +70,8 @@
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 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 { function formatDate(iso: string): string {
@@ -69,6 +92,7 @@
if (result.success) { if (result.success) {
backups = result.data.backups; backups = result.data.backups;
schedule = result.data.schedule; schedule = result.data.schedule;
if (result.data.stats) stats = result.data.stats;
cronPreset = detectPreset(schedule.backupCronExpression); cronPreset = detectPreset(schedule.backupCronExpression);
if (cronPreset === 'custom') { if (cronPreset === 'custom') {
customCron = schedule.backupCronExpression; customCron = schedule.backupCronExpression;
@@ -111,23 +135,62 @@
document.body.removeChild(a); 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) { async function handleRestore(filename: string) {
clearStatus(); clearStatus();
confirmRestore = null; confirmRestore = null;
restoringFilename = filename; restoringFilename = filename;
try { try {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, { await performRestore(filename, false);
method: 'POST' } catch (err) {
}); statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
const result = await response.json(); statusType = 'error';
} finally {
restoringFilename = null;
}
}
if (!response.ok || !result.success) { async function handleSchemaMismatchConfirm() {
throw new Error(result.error || 'Failed to restore backup'); const filename = pendingSchemaMismatchFile;
} confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
pendingSchemaMismatchMessage = '';
if (!filename) return;
statusMessage = $t('admin.backup_restore_success'); clearStatus();
statusType = 'success'; restoringFilename = filename;
try {
await performRestore(filename, true);
} catch (err) { } catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup'; statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error'; statusType = 'error';
@@ -195,26 +258,20 @@
} }
} }
// Load backups on mount (untrack to avoid infinite re-trigger)
$effect(() => { $effect(() => {
untrack(() => loadBackups()); untrack(() => loadBackups());
}); });
</script> </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> <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> <p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
<!-- Create Backup --> <!-- Create Backup -->
<div class="mb-6"> <div class="mb-6">
<button <Button onclick={handleCreate} disabled={creating} loading={creating}>
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"
>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')} {creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button> </Button>
</div> </div>
<!-- Backup List --> <!-- Backup List -->
@@ -229,6 +286,7 @@
<thead> <thead>
<tr class="border-b border-border text-xs uppercase text-muted-foreground"> <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_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_size')}</th>
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</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> <th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
@@ -238,6 +296,20 @@
{#each backups as backup (backup.filename)} {#each backups as backup (backup.filename)}
<tr class="border-b border-border/50"> <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 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">{formatBytes(backup.size)}</td>
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td> <td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
<td class="py-2.5"> <td class="py-2.5">
@@ -281,6 +353,7 @@
<!-- Restore Confirmation Dialog --> <!-- Restore Confirmation Dialog -->
{#if confirmRestore} {#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="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)]"> <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"> <h3 class="mb-2 text-lg font-semibold text-card-foreground">
@@ -289,6 +362,14 @@
<p class="mb-4 text-sm text-muted-foreground"> <p class="mb-4 text-sm text-muted-foreground">
{$t('admin.backup_restore_confirm')} {$t('admin.backup_restore_confirm')}
</p> </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> <p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@@ -301,7 +382,8 @@
<button <button
type="button" type="button"
onclick={() => confirmRestore && handleRestore(confirmRestore)} 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')} {$t('admin.backup_restore')}
</button> </button>
@@ -310,6 +392,40 @@
</div> </div>
{/if} {/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 --> <!-- Delete Confirmation Dialog -->
{#if confirmDelete} {#if confirmDelete}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
@@ -350,11 +466,10 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Enable toggle --> <!-- Enable toggle -->
<label class="flex items-center gap-3"> <label class="flex cursor-pointer items-center gap-3">
<input <Switch
type="checkbox"
bind:checked={schedule.backupEnabled} 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> <span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label> </label>
@@ -365,16 +480,12 @@
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground"> <label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
{$t('admin.backup_schedule_cron')} {$t('admin.backup_schedule_cron')}
</label> </label>
<select <Select id="cron-preset" bind:value={cronPreset} class="sm:w-auto">
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"
>
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option> <option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_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="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option> <option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
</select> </Select>
</div> </div>
{#if cronPreset === 'custom'} {#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" class="w-24 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/> />
</div> </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} {/if}
<button <Button onclick={handleSaveSchedule} disabled={savingSchedule} loading={savingSchedule}>
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"
>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')} {savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button> </Button>
</div> </div>
</div> </div>
+14 -15
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface DiscoveredService { interface DiscoveredService {
name: string; name: string;
@@ -137,20 +139,19 @@
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length); const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
</script> </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> <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> <p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
<!-- Scan Button --> <!-- Scan Button -->
<div class="mb-6"> <div class="mb-6">
<button <Button
type="button"
onclick={handleScan} onclick={handleScan}
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)} 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')} {scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button> </Button>
</div> </div>
<!-- Scan Errors --> <!-- Scan Errors -->
@@ -169,12 +170,12 @@
<thead> <thead>
<tr class="border-b border-border"> <tr class="border-b border-border">
<th class="px-2 py-2 text-left"> <th class="px-2 py-2 text-left">
<input <Checkbox
type="checkbox"
checked={selected.size === selectableCount && selectableCount > 0} checked={selected.size === selectableCount && selectableCount > 0}
indeterminate={selected.size > 0 && selected.size < selectableCount}
onchange={toggleSelectAll} onchange={toggleSelectAll}
disabled={selectableCount === 0} disabled={selectableCount === 0}
class="h-4 w-4 rounded border-input" ariaLabel={$t('admin.discovery_select_all') ?? 'Select all'}
/> />
</th> </th>
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</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)} {#each services as service, i (service.url)}
<tr class="border-b border-border/50 hover:bg-muted/50"> <tr class="border-b border-border/50 hover:bg-muted/50">
<td class="px-2 py-2"> <td class="px-2 py-2">
<input <Checkbox
type="checkbox"
checked={selected.has(i)} checked={selected.has(i)}
onchange={() => toggleSelect(i)} onchange={() => toggleSelect(i)}
disabled={service.alreadyRegistered} disabled={service.alreadyRegistered}
class="h-4 w-4 rounded border-input" ariaLabel={`Select ${service.name}`}
/> />
</td> </td>
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td> <td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
@@ -227,14 +227,13 @@
<!-- Approve button --> <!-- Approve button -->
{#if selectableCount > 0} {#if selectableCount > 0}
<div class="mt-4"> <div class="mt-4">
<button <Button
type="button"
onclick={handleApprove} onclick={handleApprove}
disabled={approving || selected.size === 0} 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}) {approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button> </Button>
</div> </div>
{/if} {/if}
{/if} {/if}
+8 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import Switch from '$lib/components/ui/Switch.svelte';
interface GroupWithCount { interface GroupWithCount {
id: string; id: string;
@@ -64,8 +65,13 @@
placeholder={$t('common.description')} placeholder={$t('common.description')}
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground" 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"> <label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" /> <Switch
name="isDefault"
bind:checked={editIsDefault}
size="sm"
ariaLabel={$t('admin.default_column')}
/>
{$t('admin.default_column')} {$t('admin.default_column')}
</label> </label>
<button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button> <button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button>
@@ -129,7 +129,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Grant form --> <!-- 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> <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 class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<div> <div>
+27 -27
View File
@@ -4,6 +4,9 @@
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js'; import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod'; import type { z } from 'zod';
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte'; 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 { let {
form: formData, form: formData,
@@ -46,32 +49,34 @@
<form method="POST" action="?/update" use:enhance class="space-y-8"> <form method="POST" action="?/update" use:enhance class="space-y-8">
<!-- Authentication --> <!-- 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> <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 class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label> <label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label>
<select <Select
id="authMode" id="authMode"
name="authMode" name="authMode"
bind:value={$form.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="local">{$t('admin.auth_local')}</option>
<option value="oauth">{$t('admin.auth_oauth')}</option> <option value="oauth">{$t('admin.auth_oauth')}</option>
<option value="both">{$t('admin.auth_both')}</option> <option value="both">{$t('admin.auth_both')}</option>
</select> </Select>
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if} {#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
</div> </div>
<div class="flex items-center gap-2 pt-6"> <div class="flex items-center gap-3 pt-6">
<input <Switch
id="registrationEnabled" id="registrationEnabled"
name="registrationEnabled" name="registrationEnabled"
type="checkbox"
bind:checked={$form.registrationEnabled} 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')} {$t('admin.registration_enabled')}
</label> </label>
</div> </div>
@@ -79,7 +84,7 @@
</section> </section>
<!-- OAuth Configuration --> <!-- 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> <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"> <p class="mb-4 text-xs text-muted-foreground">
{$t('admin.oauth_description')} {$t('admin.oauth_description')}
@@ -120,14 +125,14 @@
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if} {#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
</div> </div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<button <Button
type="button" variant="outline"
onclick={testOAuthConnection} onclick={testOAuthConnection}
disabled={oauthTesting} 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')} {oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
</button> </Button>
{#if oauthTestResult} {#if oauthTestResult}
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-status-online-ink dark:text-status-online-ink' : 'text-destructive'}"> <span class="ml-3 text-sm {oauthTestSuccess ? 'text-status-online-ink dark:text-status-online-ink' : 'text-destructive'}">
{oauthTestResult} {oauthTestResult}
@@ -138,20 +143,19 @@
</section> </section>
<!-- Theme Defaults --> <!-- 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> <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 class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label> <label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label>
<select <Select
id="defaultTheme" id="defaultTheme"
name="defaultTheme" name="defaultTheme"
bind:value={$form.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="dark">{$t('theme.dark')}</option>
<option value="light">{$t('theme.light')}</option> <option value="light">{$t('theme.light')}</option>
</select> </Select>
</div> </div>
<div> <div>
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label> <label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label>
@@ -178,7 +182,7 @@
</section> </section>
<!-- Healthcheck Defaults --> <!-- 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> <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> <p class="mb-4 text-xs text-muted-foreground">{$t('admin.healthcheck_defaults_description')}</p>
<div> <div>
@@ -196,7 +200,7 @@
</section> </section>
<!-- Service Discovery Configuration --> <!-- 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> <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> <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"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -226,7 +230,7 @@
</section> </section>
<!-- System Custom CSS --> <!-- 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> <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> <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 ?? ''} /> <input type="hidden" name="customCss" value={$form.customCss ?? ''} />
@@ -242,12 +246,8 @@
{/if} {/if}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <Button type="submit" size="lg" disabled={$delayed} loading={$delayed}>
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}
>
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')} {$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
</button> </Button>
</div> </div>
</form> </form>
+5 -13
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Tag { interface Tag {
id: string; id: string;
@@ -115,13 +116,9 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-card-foreground">Tag Management</h2> <h2 class="text-xl font-bold text-card-foreground">Tag Management</h2>
<button <Button onclick={() => (showCreateForm = !showCreateForm)}>
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"
>
{showCreateForm ? 'Cancel' : 'New Tag'} {showCreateForm ? 'Cancel' : 'New Tag'}
</button> </Button>
</div> </div>
{#if error} {#if error}
@@ -132,7 +129,7 @@
<!-- Create Form --> <!-- Create Form -->
{#if showCreateForm} {#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"> <form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3">
<div> <div>
<label for="tag-name" class="mb-1 block text-sm font-medium text-foreground">Name</label> <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> <span class="text-xs text-muted-foreground">{newColor}</span>
</div> </div>
</div> </div>
<button <Button type="submit">Create Tag</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>
</form> </form>
</div> </div>
{/if} {/if}
+42 -20
View File
@@ -10,6 +10,8 @@
import TagsInput from '$lib/components/ui/TagsInput.svelte'; import TagsInput from '$lib/components/ui/TagsInput.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte'; import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } 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>; type AppSchema = z.infer<typeof createAppSchema>;
@@ -219,22 +221,34 @@
<button <button
type="button" type="button"
onclick={() => (showAdvanced = !showAdvanced)} 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')} {showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
</button> </button>
{#if showAdvanced} {#if showAdvanced}
<div class="space-y-4 rounded-md border border-border p-4"> <div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<input <Switch
id="healthcheckEnabled" id="healthcheckEnabled"
name="healthcheckEnabled" name="healthcheckEnabled"
type="checkbox"
bind:checked={$form.healthcheckEnabled} 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')} {$t('app.healthcheck_enabled')}
</label> </label>
</div> </div>
@@ -320,22 +334,34 @@
<button <button
type="button" type="button"
onclick={() => (showIntegration = !showIntegration)} 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 {showIntegration ? 'Hide' : 'Show'} Integration Settings
</button> </button>
{#if showIntegration} {#if showIntegration}
<div class="space-y-4 rounded-md border border-border p-4"> <div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<input <Switch
id="integrationEnabled" id="integrationEnabled"
name="integrationEnabled" name="integrationEnabled"
type="checkbox"
bind:checked={$form.integrationEnabled} 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 Enable Integration
</label> </label>
</div> </div>
@@ -409,16 +435,12 @@
{/if} {/if}
<div class="flex justify-end"> <div class="flex justify-end">
<button <Button type="submit" size="lg" disabled={$submitting} loading={$submitting}>
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"
>
{#if $submitting} {#if $submitting}
{$t('app.saving')} {$t('app.saving')}
{:else} {:else}
{mode === 'edit' ? $t('app.update') : $t('app.save')} {mode === 'edit' ? $t('app.update') : $t('app.save')}
{/if} {/if}
</button> </Button>
</div> </div>
</form> </form>
+5 -2
View File
@@ -12,9 +12,9 @@
case 'online': case 'online':
return { color: 'var(--status-online)', ink: 'var(--status-online-ink)', cssClass: 'status-online', textKey: 'status.online' }; return { color: 'var(--status-online)', ink: 'var(--status-online-ink)', cssClass: 'status-online', textKey: 'status.online' };
case 'offline': 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': 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: default:
return { color: 'var(--status-unknown)', ink: 'var(--status-unknown-ink)', cssClass: '', textKey: 'status.unknown' }; return { color: 'var(--status-unknown)', ink: 'var(--status-unknown-ink)', cssClass: '', textKey: 'status.unknown' };
} }
@@ -24,10 +24,13 @@
<span <span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold" 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);" style="color: {config.ink}; background: color-mix(in srgb, {config.color} 14%, transparent);"
role="status"
aria-live="polite"
> >
<span <span
class="inline-block h-2 w-2 rounded-full {config.cssClass}" class="inline-block h-2 w-2 rounded-full {config.cssClass}"
style="background: {config.color};" style="background: {config.color};"
aria-hidden="true"
></span> ></span>
<span>{$t(config.textKey)}</span> <span>{$t(config.textKey)}</span>
</span> </span>
+3 -7
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { dndzone } from 'svelte-dnd-action'; import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import Button from '$lib/components/ui/Button.svelte';
interface LinkItem { interface LinkItem {
id: string; id: string;
@@ -192,12 +193,7 @@
</div> </div>
<!-- Save Button --> <!-- Save Button -->
<button <Button onclick={saveLinks} disabled={saving} loading={saving}>
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"
>
{saving ? 'Saving...' : 'Save Links'} {saving ? 'Saving...' : 'Save Links'}
</button> </Button>
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js'; import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props { interface Props {
fields: IntegrationFieldDescriptor[]; fields: IntegrationFieldDescriptor[];
@@ -23,16 +24,15 @@
{/if} {/if}
</label> </label>
{#if field.type === 'boolean'} {#if field.type === 'boolean'}
<label class="flex items-center gap-2"> <div class="flex items-center gap-3">
<input <Switch
id="{idPrefix}-{field.name}" id="{idPrefix}-{field.name}"
type="checkbox"
checked={!!values[field.name]} checked={!!values[field.name]}
onchange={(e) => onchange(field.name, e.currentTarget.checked)} onchange={(checked) => onchange(field.name, checked)}
class="h-4 w-4 rounded border-input accent-primary" ariaLabel={field.label}
/> />
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span> <span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
</label> </div>
{:else if field.type === 'number'} {:else if field.type === 'number'}
<input <input
id="{idPrefix}-{field.name}" id="{idPrefix}-{field.name}"
@@ -105,7 +105,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Grant form --> <!-- 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> <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 class="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div> <div>
@@ -3,6 +3,9 @@
import { editMode } from '$lib/stores/editMode.svelte.js'; import { editMode } from '$lib/stores/editMode.svelte.js';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte'; 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 { interface BoardData {
id: string; 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> 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> </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 --> <!-- Theme Hue -->
<div> <div>
<label for="bp-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_hue') ?? 'Theme Hue'}</label> <label for="bp-hue" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<input id="bp-hue" type="range" min="0" max="360" bind:value={themeHue} <span>{$t('board.theme_hue') ?? 'Theme Hue'}</span>
class="w-full accent-primary" /> <span class="tabular-nums text-xs text-muted-foreground">{themeHue}°</span>
<span class="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> </div>
<!-- Theme Saturation --> <!-- Theme Saturation -->
<div> <div>
<label for="bp-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_saturation') ?? 'Saturation'}</label> <label for="bp-sat" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<input id="bp-sat" type="range" min="0" max="100" bind:value={themeSaturation} <span>{$t('board.theme_saturation') ?? 'Saturation'}</span>
class="w-full accent-primary" /> <span class="tabular-nums text-xs text-muted-foreground">{themeSaturation}%</span>
<span class="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> </div>
<!-- Background Type --> <!-- Background Type -->
<div> <div>
<label for="bp-bg" class="mb-1 block text-sm font-medium text-foreground">{$t('board.background') ?? 'Background'}</label> <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} <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">
<option value="none">None</option> <option value="none">None</option>
<option value="mesh">Mesh Gradient</option> <option value="mesh">Mesh Gradient</option>
<option value="particles">Particles</option> <option value="particles">Particles</option>
<option value="aurora">Aurora</option> <option value="aurora">Aurora</option>
<option value="wallpaper">Wallpaper</option> <option value="wallpaper">Wallpaper</option>
</select> </Select>
</div> </div>
<!-- Wallpaper settings (conditional) --> <!-- Wallpaper settings (conditional) -->
{#if backgroundType === 'wallpaper'} {#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> <div>
<label for="bp-wp-url" class="mb-1 block text-sm font-medium text-foreground">Wallpaper URL</label> <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://..." <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" /> 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>
<div> <div>
<label for="bp-wp-blur" class="mb-1 block text-sm font-medium text-foreground">Blur ({wallpaperBlur}px)</label> <label for="bp-wp-blur" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<input id="bp-wp-blur" type="range" min="0" max="20" bind:value={wallpaperBlur} class="w-full accent-primary" /> <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>
<div> <div>
<label for="bp-wp-overlay" class="mb-1 block text-sm font-medium text-foreground">Overlay ({Math.round(wallpaperOverlay * 100)}%)</label> <label for="bp-wp-overlay" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
<input id="bp-wp-overlay" type="range" min="0" max="1" step="0.05" bind:value={wallpaperOverlay} class="w-full accent-primary" /> <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>
</div> </div>
{/if} {/if}
@@ -175,12 +207,11 @@
<!-- Card Size --> <!-- Card Size -->
<div> <div>
<label for="bp-cardsize" class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label> <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} <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">
<option value="compact">Compact</option> <option value="compact">Compact</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>
<option value="large">Large</option> <option value="large">Large</option>
</select> </Select>
</div> </div>
<!-- Custom CSS --> <!-- Custom CSS -->
@@ -194,19 +225,11 @@
<!-- Footer --> <!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3"> <div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
<button <Button variant="outline" onclick={onClose}>
type="button"
onclick={onClose}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
{$t('common.cancel') ?? 'Cancel'} {$t('common.cancel') ?? 'Cancel'}
</button> </Button>
<button <Button variant="primary" onclick={handleSave}>
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"
>
{$t('common.apply') ?? 'Apply'} {$t('common.apply') ?? 'Apply'}
</button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { TargetType, PermissionLevel } from '$lib/utils/constants.js'; import { TargetType, PermissionLevel } from '$lib/utils/constants.js';
import Switch from '$lib/components/ui/Switch.svelte';
import { import {
loadBoardPermissions, loadBoardPermissions,
grantBoardPermission, grantBoardPermission,
@@ -188,19 +189,18 @@
</div> </div>
<!-- Guest access toggle --> <!-- Guest access toggle -->
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3"> <div class="mb-4 rounded-xl border border-border bg-muted/30 p-3">
<label class="flex items-center gap-3 text-sm text-foreground"> <div class="flex items-center gap-3 text-sm text-foreground">
<input <Switch
type="checkbox"
checked={isGuestAccessible} checked={isGuestAccessible}
onchange={(e) => onGuestToggle(e.currentTarget.checked)} onchange={onGuestToggle}
class="h-4 w-4 rounded border-input accent-primary" ariaLabel={$t('board.guest_accessible')}
/> />
<div> <div>
<span class="font-medium">{$t('board.guest_accessible')}</span> <span class="font-medium">{$t('board.guest_accessible')}</span>
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p> <p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
</div> </div>
</label> </div>
</div> </div>
<!-- Quick add permission --> <!-- Quick add permission -->
+2 -6
View File
@@ -8,6 +8,7 @@
import { ui } from '$lib/stores/ui.svelte.js'; import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js'; import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { buttonClass } from '$lib/components/ui/Button.svelte';
interface Props { interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null; user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
@@ -223,11 +224,6 @@
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu.Root> </DropdownMenu.Root>
{:else} {:else}
<a <a href="/login" class={buttonClass()}>{$t('auth.login')}</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>
{/if} {/if}
</header> </header>
@@ -3,6 +3,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { Download, X } from 'lucide-svelte'; import { Download, X } from 'lucide-svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import Button from '$lib/components/ui/Button.svelte';
const DISMISS_KEY = 'wal-install-prompt-dismissed'; const DISMISS_KEY = 'wal-install-prompt-dismissed';
@@ -83,13 +84,9 @@
</p> </p>
</div> </div>
<button <Button onclick={install} class="shrink-0">
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"
>
{$t('install.button')} {$t('install.button')}
</button> </Button>
<button <button
type="button" type="button"
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte'; import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js'; 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 { interface ChannelData {
readonly id?: string; readonly id?: string;
@@ -112,7 +114,7 @@
} }
</script> </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"> <h3 class="mb-4 text-lg font-semibold text-card-foreground">
{channel ? 'Edit Channel' : 'Add Notification Channel'} {channel ? 'Edit Channel' : 'Add Notification Channel'}
</h3> </h3>
@@ -271,14 +273,9 @@
{/if} {/if}
<!-- Enabled Toggle --> <!-- Enabled Toggle -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<input <Switch id="channel-enabled" bind:checked={enabled} ariaLabelledby="channel-enabled-label" />
id="channel-enabled" <label id="channel-enabled-label" for="channel-enabled" class="cursor-pointer text-sm text-foreground">Enabled</label>
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> </div>
<!-- Test Result --> <!-- Test Result -->
@@ -290,29 +287,17 @@
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <Button type="submit">
type="submit"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{channel ? 'Update' : 'Create'} Channel {channel ? 'Update' : 'Create'} Channel
</button> </Button>
{#if channel?.id} {#if channel?.id}
<button <Button variant="outline" onclick={sendTest} disabled={testing} loading={testing}>
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"
>
{testing ? 'Sending...' : 'Send Test'} {testing ? 'Sending...' : 'Send Test'}
</button> </Button>
{/if} {/if}
<button <Button variant="ghost" onclick={onCancel}>
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel Cancel
</button> </Button>
</div> </div>
</form> </form>
</div> </div>
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const; const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number]; type Step = (typeof STEPS)[number];
@@ -414,12 +416,7 @@
</button> </button>
{/if} {/if}
<button <Button size="lg" onclick={handleNext} disabled={loading} loading={loading}>
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"
>
{#if loading} {#if loading}
Processing... Processing...
{:else if isLastStep} {:else if isLastStep}
@@ -429,7 +426,7 @@
{:else} {:else}
Next Next
{/if} {/if}
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
interface Props { interface Props {
onCancel: () => void; onCancel: () => void;
@@ -8,7 +10,7 @@
let { onCancel }: Props = $props(); let { onCancel }: Props = $props();
</script> </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> <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"> <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"> <label for="token-scope" class="mb-1 block text-sm font-medium text-foreground">
Scope Scope
</label> </label>
<select <Select id="token-scope" name="scope">
id="token-scope"
name="scope"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="read">Read — View apps, boards, and status</option> <option value="read">Read — View apps, boards, and status</option>
<option value="write">Write — Modify apps, boards, and settings</option> <option value="write">Write — Modify apps, boards, and settings</option>
<option value="admin">Admin — Full access including user management</option> <option value="admin">Admin — Full access including user management</option>
</select> </Select>
</div> </div>
<div> <div>
@@ -56,19 +54,8 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <Button type="submit">Generate Token</Button>
type="submit" <Button variant="ghost" onclick={onCancel}>Cancel</Button>
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>
</div> </div>
</form> </form>
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import Switch from '$lib/components/ui/Switch.svelte';
interface Props { interface Props {
value: string; value: string;
@@ -98,11 +99,11 @@
{/if} {/if}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-muted-foreground"> <label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<input <Switch
type="checkbox"
bind:checked={livePreview} 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'} {$t('settings.live_preview') ?? 'Live preview'}
</label> </label>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { t, locale as i18nLocale } from 'svelte-i18n'; import { t, locale as i18nLocale } from 'svelte-i18n';
import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js'; import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js';
import Button from '$lib/components/ui/Button.svelte';
interface UserPreferences { interface UserPreferences {
themeMode: string | null; themeMode: string | null;
@@ -251,14 +252,9 @@
<!-- Save button --> <!-- Save button -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <Button size="lg" onclick={savePreferences} disabled={saving} loading={saving}>
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"
>
{saving ? $t('settings.saving') : $t('settings.save')} {saving ? $t('settings.saving') : $t('settings.save')}
</button> </Button>
{#if saved} {#if saved}
<span class="text-sm text-status-online-ink">{$t('settings.saved')}</span> <span class="text-sm text-status-online-ink">{$t('settings.saved')}</span>
{/if} {/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 { tick } from 'svelte';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte'; import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.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 { interface AppInfo {
id: string; id: string;
@@ -269,13 +272,12 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>{$t('widget.format') ?? 'Format'} <div class={labelClass}>{$t('widget.format') ?? 'Format'}</div>
<select bind:value={noteFormat} class={inputClass}> <Select bind:value={noteFormat}>
<option value="markdown">Markdown</option> <option value="markdown">Markdown</option>
<option value="text">Plain Text</option> <option value="text">Plain Text</option>
<option value="html">HTML</option> <option value="html">HTML</option>
</select> </Select>
</label>
</div> </div>
{:else if widgetType === 'embed'} {:else if widgetType === 'embed'}
@@ -285,9 +287,8 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px) <div class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</div>
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" /> <Slider min={100} max={800} bind:value={embedHeight} ariaLabel="Height" />
</label>
</div> </div>
<div> <div>
<label class={labelClass}>Sandbox <label class={labelClass}>Sandbox
@@ -318,16 +319,15 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>{$t('widget.style') ?? 'Style'} <div class={labelClass}>{$t('widget.style') ?? 'Style'}</div>
<select bind:value={clockStyle} class={inputClass}> <Select bind:value={clockStyle}>
<option value="digital">Digital</option> <option value="digital">Digital</option>
<option value="analog">Analog</option> <option value="analog">Analog</option>
<option value="24h">24h</option> <option value="24h">24h</option>
</select> </Select>
</label>
</div> </div>
<label class="flex items-center gap-2 text-sm text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" /> <Switch bind:checked={clockShowWeather} size="sm" ariaLabel={$t('widget.show_weather') ?? 'Show Weather'} />
{$t('widget.show_weather') ?? 'Show Weather'} {$t('widget.show_weather') ?? 'Show Weather'}
</label> </label>
{#if clockShowWeather} {#if clockShowWeather}
@@ -352,18 +352,16 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>Source Type <div class={labelClass}>Source Type</div>
<select bind:value={sysStatsSourceType} class={inputClass}> <Select bind:value={sysStatsSourceType}>
<option value="glances">Glances</option> <option value="glances">Glances</option>
<option value="prometheus">Prometheus</option> <option value="prometheus">Prometheus</option>
<option value="custom">Custom</option> <option value="custom">Custom</option>
</select> </Select>
</label>
</div> </div>
<div> <div>
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s) <div class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</div>
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" /> <Slider min={5} max={300} bind:value={sysStatsRefreshInterval} ariaLabel="Refresh interval" />
</label>
</div> </div>
{:else if widgetType === 'rss'} {:else if widgetType === 'rss'}
@@ -373,12 +371,11 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>Max Items ({rssMaxItems}) <div class={labelClass}>Max Items ({rssMaxItems})</div>
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" /> <Slider min={1} max={50} bind:value={rssMaxItems} ariaLabel="Max items" />
</label>
</div> </div>
<label class="flex items-center gap-2 text-sm text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" /> <Switch bind:checked={rssShowSummary} size="sm" ariaLabel="Show Summary" />
Show Summary Show Summary
</label> </label>
@@ -398,9 +395,8 @@
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button> <button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
</div> </div>
<div> <div>
<label class={labelClass}>Days Ahead ({calendarDaysAhead}) <div class={labelClass}>Days Ahead ({calendarDaysAhead})</div>
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" /> <Slider min={1} max={30} bind:value={calendarDaysAhead} ariaLabel="Days ahead" />
</label>
</div> </div>
{:else if widgetType === 'markdown'} {:else if widgetType === 'markdown'}
@@ -417,13 +413,12 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>Source <div class={labelClass}>Source</div>
<select bind:value={metricSource} class={inputClass}> <Select bind:value={metricSource}>
<option value="static">Static</option> <option value="static">Static</option>
<option value="json">JSON Endpoint</option> <option value="json">JSON Endpoint</option>
<option value="prometheus">Prometheus</option> <option value="prometheus">Prometheus</option>
</select> </Select>
</label>
</div> </div>
{#if metricSource === 'static'} {#if metricSource === 'static'}
<div> <div>
@@ -461,9 +456,8 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>Refresh ({metricRefreshInterval}s) <div class={labelClass}>Refresh ({metricRefreshInterval}s)</div>
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" /> <Slider min={5} max={300} bind:value={metricRefreshInterval} ariaLabel="Refresh interval" />
</label>
</div> </div>
</div> </div>
@@ -482,8 +476,8 @@
{/each} {/each}
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button> <button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
</div> </div>
<label class="flex items-center gap-2 text-sm text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" /> <Switch bind:checked={linkGroupCollapsible} size="sm" ariaLabel="Collapsible" />
Collapsible Collapsible
</label> </label>
@@ -494,39 +488,35 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>Type <div class={labelClass}>Type</div>
<select bind:value={cameraType} class={inputClass}> <Select bind:value={cameraType}>
<option value="image">Image</option> <option value="image">Image</option>
<option value="mjpeg">MJPEG</option> <option value="mjpeg">MJPEG</option>
<option value="hls">HLS</option> <option value="hls">HLS</option>
</select> </Select>
</label>
</div> </div>
<div> <div>
<label class={labelClass}>Refresh ({cameraRefreshInterval}s) <div class={labelClass}>Refresh ({cameraRefreshInterval}s)</div>
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" /> <Slider min={1} max={60} bind:value={cameraRefreshInterval} ariaLabel="Refresh interval" />
</label>
</div> </div>
<div> <div>
<label class={labelClass}>Aspect Ratio <div class={labelClass}>Aspect Ratio</div>
<select bind:value={cameraAspectRatio} class={inputClass}> <Select bind:value={cameraAspectRatio}>
<option value="16/9">16:9</option> <option value="16/9">16:9</option>
<option value="4/3">4:3</option> <option value="4/3">4:3</option>
<option value="1/1">1:1</option> <option value="1/1">1:1</option>
</select> </Select>
</label>
</div> </div>
{:else if widgetType === 'integration'} {:else if widgetType === 'integration'}
<div> <div>
<label class={labelClass}>{$t('widget.app') ?? 'App'} <div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}> <Select bind:value={integrationAppId}>
<option value="">Select app...</option> <option value="">Select app...</option>
{#each apps as app (app.id)} {#each apps as app (app.id)}
<option value={app.id}>{app.name}</option> <option value={app.id}>{app.name}</option>
{/each} {/each}
</select> </Select>
</label>
</div> </div>
<div> <div>
<label class={labelClass}>Endpoint ID <label class={labelClass}>Endpoint ID
@@ -534,9 +524,8 @@
</label> </label>
</div> </div>
<div> <div>
<label class={labelClass}>Refresh ({integrationRefreshInterval}s) <div class={labelClass}>Refresh ({integrationRefreshInterval}s)</div>
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" /> <Slider min={10} max={600} bind:value={integrationRefreshInterval} ariaLabel="Refresh interval" />
</label>
</div> </div>
{/if} {/if}
</div> </div>
@@ -9,6 +9,10 @@
import EntityPicker from '$lib/components/ui/EntityPicker.svelte'; import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte'; import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.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 { interface Props {
sectionId: string; sectionId: string;
@@ -507,12 +511,11 @@
<span class="mb-1 block text-sm font-medium text-foreground">Select Apps</span> <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"> <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)} {#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"> <label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
<input <Checkbox
type="checkbox"
checked={statusAppIds.includes(app.id)} checked={statusAppIds.includes(app.id)}
onchange={() => toggleStatusApp(app.id)} onchange={() => toggleStatusApp(app.id)}
class="h-4 w-4 rounded border-input accent-primary" ariaLabel={app.name}
/> />
{app.name} {app.name}
</label> </label>
@@ -549,12 +552,8 @@
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p> <p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
</div> </div>
<div> <div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<input <Switch bind:checked={clockShowWeather} ariaLabel="Show Weather" />
type="checkbox"
bind:checked={clockShowWeather}
class="h-4 w-4 rounded border-input accent-primary"
/>
Show Weather Show Weather
</label> </label>
</div> </div>
@@ -599,26 +598,24 @@
</div> </div>
<div> <div>
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label> <label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
<select <Select
id="sys-type-{sectionId}" id="sys-type-{sectionId}"
bind:value={sysStatsSourceType} bind:value={sysStatsSourceType}
class={inputClass}
> >
<option value="glances">Glances</option> <option value="glances">Glances</option>
<option value="prometheus">Prometheus</option> <option value="prometheus">Prometheus</option>
<option value="custom">Custom JSON</option> <option value="custom">Custom JSON</option>
</select> </Select>
</div> </div>
<div> <div>
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span> <span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)} {#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"> <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">
<input <Checkbox
type="checkbox"
checked={sysStatsMetrics.includes(metric)} checked={sysStatsMetrics.includes(metric)}
onchange={() => toggleSysStatsMetric(metric)} onchange={() => toggleSysStatsMetric(metric)}
class="h-3.5 w-3.5 rounded border-input accent-primary" ariaLabel={metric}
/> />
<span class="capitalize">{metric}</span> <span class="capitalize">{metric}</span>
</label> </label>
@@ -629,14 +626,13 @@
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground"> <label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh Interval: {sysStatsRefreshInterval}s Refresh Interval: {sysStatsRefreshInterval}s
</label> </label>
<input <Slider
id="sys-refresh-{sectionId}" id="sys-refresh-{sectionId}"
type="range"
bind:value={sysStatsRefreshInterval} bind:value={sysStatsRefreshInterval}
min="5" min={5}
max="300" max={300}
step="5" step={5}
class="w-full accent-primary" ariaLabel="Refresh interval"
/> />
</div> </div>
</div> </div>
@@ -658,23 +654,18 @@
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground"> <label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Max Items: {rssMaxItems} Max Items: {rssMaxItems}
</label> </label>
<input <Slider
id="rss-max-{sectionId}" id="rss-max-{sectionId}"
type="range"
bind:value={rssMaxItems} bind:value={rssMaxItems}
min="3" min={3}
max="30" max={30}
step="1" step={1}
class="w-full accent-primary" ariaLabel="Max items"
/> />
</div> </div>
<div> <div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<input <Switch bind:checked={rssShowSummary} ariaLabel="Show Summaries" />
type="checkbox"
bind:checked={rssShowSummary}
class="h-4 w-4 rounded border-input accent-primary"
/>
Show Summaries Show Summaries
</label> </label>
</div> </div>
@@ -733,14 +724,13 @@
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground"> <label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Days Ahead: {calendarDaysAhead} Days Ahead: {calendarDaysAhead}
</label> </label>
<input <Slider
id="cal-days-{sectionId}" id="cal-days-{sectionId}"
type="range"
bind:value={calendarDaysAhead} bind:value={calendarDaysAhead}
min="1" min={1}
max="30" max={30}
step="1" step={1}
class="w-full accent-primary" ariaLabel="Days ahead"
/> />
</div> </div>
</div> </div>
@@ -855,14 +845,13 @@
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground"> <label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {metricRefreshInterval}s Refresh: {metricRefreshInterval}s
</label> </label>
<input <Slider
id="metric-refresh-{sectionId}" id="metric-refresh-{sectionId}"
type="range"
bind:value={metricRefreshInterval} bind:value={metricRefreshInterval}
min="10" min={10}
max="600" max={600}
step="10" step={10}
class="w-full accent-primary" ariaLabel="Refresh interval"
/> />
</div> </div>
</div> </div>
@@ -916,12 +905,8 @@
</button> </button>
</div> </div>
<div> <div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
<input <Switch bind:checked={linkGroupCollapsible} ariaLabel="Collapsible" />
type="checkbox"
bind:checked={linkGroupCollapsible}
class="h-4 w-4 rounded border-input accent-primary"
/>
Collapsible Collapsible
</label> </label>
</div> </div>
@@ -942,43 +927,40 @@
</div> </div>
<div> <div>
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label> <label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
<select <Select
id="cam-type-{sectionId}" id="cam-type-{sectionId}"
bind:value={cameraType} bind:value={cameraType}
class={inputClass}
> >
<option value="image">Snapshot (Image)</option> <option value="image">Snapshot (Image)</option>
<option value="mjpeg">MJPEG Stream</option> <option value="mjpeg">MJPEG Stream</option>
<option value="hls">HLS Stream</option> <option value="hls">HLS Stream</option>
</select> </Select>
</div> </div>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<div> <div>
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground"> <label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {cameraRefreshInterval}s Refresh: {cameraRefreshInterval}s
</label> </label>
<input <Slider
id="cam-refresh-{sectionId}" id="cam-refresh-{sectionId}"
type="range"
bind:value={cameraRefreshInterval} bind:value={cameraRefreshInterval}
min="1" min={1}
max="120" max={120}
step="1" step={1}
class="w-full accent-primary" ariaLabel="Refresh interval"
/> />
</div> </div>
<div> <div>
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label> <label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
<select <Select
id="cam-ratio-{sectionId}" id="cam-ratio-{sectionId}"
bind:value={cameraAspectRatio} bind:value={cameraAspectRatio}
class={inputClass}
> >
<option value="16/9">16:9</option> <option value="16/9">16:9</option>
<option value="4/3">4:3</option> <option value="4/3">4:3</option>
<option value="1/1">1:1</option> <option value="1/1">1:1</option>
<option value="21/9">21:9</option> <option value="21/9">21:9</option>
</select> </Select>
</div> </div>
</div> </div>
</div> </div>
@@ -990,44 +972,41 @@
{#if integrationApps.length === 0} {#if integrationApps.length === 0}
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p> <p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
{:else} {:else}
<select <Select
id="int-app-{sectionId}" id="int-app-{sectionId}"
bind:value={integrationAppId} bind:value={integrationAppId}
class={inputClass}
> >
<option value="">Select an app...</option> <option value="">Select an app...</option>
{#each integrationApps as app (app.id)} {#each integrationApps as app (app.id)}
<option value={app.id}>{app.name} ({app.integrationType})</option> <option value={app.id}>{app.name} ({app.integrationType})</option>
{/each} {/each}
</select> </Select>
{/if} {/if}
</div> </div>
{#if integrationAppId && integrationEndpoints.length > 0} {#if integrationAppId && integrationEndpoints.length > 0}
<div> <div>
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label> <label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
<select <Select
id="int-endpoint-{sectionId}" id="int-endpoint-{sectionId}"
bind:value={integrationEndpointId} bind:value={integrationEndpointId}
class={inputClass}
> >
<option value="">Select endpoint...</option> <option value="">Select endpoint...</option>
{#each integrationEndpoints as ep (ep.id)} {#each integrationEndpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option> <option value={ep.id}>{ep.name}</option>
{/each} {/each}
</select> </Select>
</div> </div>
<div> <div>
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground"> <label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {integrationRefreshInterval}s Refresh: {integrationRefreshInterval}s
</label> </label>
<input <Slider
id="int-refresh-{sectionId}" id="int-refresh-{sectionId}"
type="range"
bind:value={integrationRefreshInterval} bind:value={integrationRefreshInterval}
min="10" min={10}
max="600" max={600}
step="10" step={10}
class="w-full accent-primary" ariaLabel="Refresh interval"
/> />
</div> </div>
{/if} {/if}
+3 -10
View File
@@ -3,6 +3,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import ErrorState from '$lib/components/ui/ErrorState.svelte'; import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status); const status = $derived($page.status);
const message = $derived($page.error?.message ?? ''); const message = $derived($page.error?.message ?? '');
@@ -46,17 +47,9 @@
{/if} {/if}
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<a <a href="/" class={buttonClass()}>{$t('error.back_to_dashboard')}</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>
{#if status === 401} {#if status === 401}
<a <a href="/login" class={buttonClass({ variant: 'outline' })}>
href="/login"
class="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
{$t('auth.login_submit')} {$t('auth.login_submit')}
</a> </a>
{/if} {/if}
+4 -12
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
import { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -41,26 +42,17 @@
{$t('home.welcome', { values: { name: data.user.displayName } })} {$t('home.welcome', { values: { name: data.user.displayName } })}
</p> </p>
<div class="mt-8 flex items-center justify-center gap-3"> <div class="mt-8 flex items-center justify-center gap-3">
<a <a href="/boards" class={buttonClass({ size: 'lg' })}>
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)]"
>
{$t('home.view_boards')} {$t('home.view_boards')}
</a> </a>
<a <a href="/apps" class={buttonClass({ variant: 'outline', size: 'lg' })}>
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"
>
{$t('home.browse_apps')} {$t('home.browse_apps')}
</a> </a>
</div> </div>
{:else} {:else}
<h1 class="font-display text-4xl font-semibold text-foreground">{$t('app_title')}</h1> <h1 class="font-display text-4xl font-semibold text-foreground">{$t('app_title')}</h1>
<div class="mt-8"> <div class="mt-8">
<a <a href="/login" class={buttonClass({ size: 'lg' })}>
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)]"
>
{$t('auth.login')} {$t('auth.login')}
</a> </a>
</div> </div>
+3 -10
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte'; import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status); const status = $derived($page.status);
const message = $derived($page.error?.message ?? ''); const message = $derived($page.error?.message ?? '');
@@ -24,16 +25,8 @@
<ErrorState {status} {title} {hint}> <ErrorState {status} {title} {hint}>
{#snippet actions()} {#snippet actions()}
<a <a href="/" class={buttonClass()}>{$t('error.back_to_dashboard')}</a>
href="/" <a href="/admin/users" class={buttonClass({ variant: 'outline' })}>
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"
>
{$t('admin.users') ?? 'Admin users'} {$t('admin.users') ?? 'Admin users'}
</a> </a>
{/snippet} {/snippet}
+10 -18
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
import GroupTable from '$lib/components/admin/GroupTable.svelte'; import GroupTable from '$lib/components/admin/GroupTable.svelte';
import { superForm } from 'sveltekit-superforms/client'; 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(); let { data }: { data: PageData } = $props();
@@ -25,17 +27,13 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.group_management')}</h1> <h1 class="text-2xl font-bold text-card-foreground">{$t('admin.group_management')}</h1>
<button <Button onclick={() => (showCreateForm = !showCreateForm)}>
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"
>
{showCreateForm ? $t('common.cancel') : $t('admin.create_group')} {showCreateForm ? $t('common.cancel') : $t('admin.create_group')}
</button> </Button>
</div> </div>
{#if showCreateForm} {#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> <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"> <form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <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" class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
/> />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-3">
<input <Switch
id="isDefault" id="isDefault"
name="isDefault" name="isDefault"
type="checkbox"
bind:checked={$form.isDefault} 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>
</div> </div>
{#if $errors._errors} {#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p> <p class="text-sm text-destructive">{$errors._errors}</p>
{/if} {/if}
<button <Button type="submit">{$t('admin.create_group')}</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>
</form> </form>
</div> </div>
{/if} {/if}
+9 -33
View File
@@ -2,6 +2,8 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types.js'; 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(); let { data }: { data: PageData } = $props();
@@ -95,13 +97,7 @@
</p> </p>
</div> </div>
{#if !showForm} {#if !showForm}
<button <Button onclick={() => (showForm = true)}>Generate invite</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>
{/if} {/if}
</div> </div>
@@ -123,13 +119,7 @@
> >
{createdUrl} {createdUrl}
</code> </code>
<button <Button size="sm" onclick={() => createdUrl && copyUrl(createdUrl)}>Copy</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>
</div> </div>
<button <button
type="button" type="button"
@@ -161,14 +151,10 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label for="inv-role" class="mb-1 block text-sm font-medium text-card-foreground">Role</label> <label for="inv-role" class="mb-1 block text-sm font-medium text-card-foreground">Role</label>
<select <Select id="inv-role" bind:value={role}>
id="inv-role"
bind:value={role}
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="user">User</option> <option value="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </Select>
</div> </div>
<div> <div>
<label for="inv-expiry" class="mb-1 block text-sm font-medium text-card-foreground"> <label for="inv-expiry" class="mb-1 block text-sm font-medium text-card-foreground">
@@ -185,20 +171,10 @@
</div> </div>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <Button variant="outline" onclick={() => (showForm = false)}>Cancel</Button>
type="button" <Button type="submit" disabled={creating} loading={creating}>
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"
>
{creating ? 'Creating…' : 'Create invite'} {creating ? 'Creating…' : 'Create invite'}
</button> </Button>
</div> </div>
</form> </form>
{/if} {/if}
@@ -2,6 +2,7 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props(); 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" class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm"
/> />
</label> </label>
<button <Button type="submit" disabled={issuing} loading={issuing} class="sm:w-auto" fullWidth>
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"
>
{issuing ? 'Issuing…' : 'Issue link'} {issuing ? 'Issuing…' : 'Issue link'}
</button> </Button>
</form> </form>
<!-- Success: a real link to copy --> <!-- Success: a real link to copy -->
+8 -20
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
import UserTable from '$lib/components/admin/UserTable.svelte'; import UserTable from '$lib/components/admin/UserTable.svelte';
import { superForm } from 'sveltekit-superforms/client'; 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(); let { data }: { data: PageData } = $props();
@@ -25,17 +27,13 @@
<div> <div>
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-card-foreground">{$t('admin.user_management')}</h1> <h1 class="text-2xl font-bold text-card-foreground">{$t('admin.user_management')}</h1>
<button <Button onclick={() => (showCreateForm = !showCreateForm)}>
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"
>
{showCreateForm ? $t('common.cancel') : $t('admin.create_user')} {showCreateForm ? $t('common.cancel') : $t('admin.create_user')}
</button> </Button>
</div> </div>
{#if showCreateForm} {#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> <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"> <form method="POST" action="?/create" use:enhance class="space-y-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -76,26 +74,16 @@
</div> </div>
<div> <div>
<label for="role" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.role_column')}</label> <label for="role" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.role_column')}</label>
<select <Select id="role" name="role" bind:value={$form.role}>
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"
>
<option value="user">{$t('admin.role_user')}</option> <option value="user">{$t('admin.role_user')}</option>
<option value="admin">{$t('admin.role_admin')}</option> <option value="admin">{$t('admin.role_admin')}</option>
</select> </Select>
</div> </div>
</div> </div>
{#if $errors._errors} {#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p> <p class="text-sm text-destructive">{$errors._errors}</p>
{/if} {/if}
<button <Button type="submit">{$t('admin.create_user')}</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>
</form> </form>
</div> </div>
{/if} {/if}
+27 -31
View File
@@ -4,6 +4,7 @@
import AppCard from '$lib/components/app/AppCard.svelte'; import AppCard from '$lib/components/app/AppCard.svelte';
import AppForm from '$lib/components/app/AppForm.svelte'; import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js'; import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -33,13 +34,9 @@
{$t('app.apps_registered', { values: { count: data.apps.length } })} {$t('app.apps_registered', { values: { count: data.apps.length } })}
</p> </p>
</div> </div>
<button <Button size="lg" onclick={() => (showForm = !showForm)}>
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"
>
{showForm ? $t('common.cancel') : $t('app.add')} {showForm ? $t('common.cancel') : $t('app.add')}
</button> </Button>
</div> </div>
{#if showForm} {#if showForm}
@@ -70,40 +67,39 @@
{#if data.apps.length === 0} {#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="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
<svg class="mb-5 h-24 w-28"
class="h-8 w-8 text-primary" viewBox="0 0 112 96"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" aria-hidden="true"
fill="none" >
stroke="currentColor" <!-- tile grid: 4 rounded squares in room palette -->
stroke-width="1.5" <rect x="14" y="14" width="36" height="36" rx="10" style="fill: var(--room-peach); opacity: 0.9;" />
stroke-linecap="round" <rect x="58" y="14" width="36" height="36" rx="10" style="fill: var(--room-sky); opacity: 0.9;" />
stroke-linejoin="round" <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;" />
<circle cx="12" cy="12" r="10" /> <!-- glints -->
<line x1="2" y1="12" x2="22" y2="12" /> <circle cx="24" cy="24" r="3" fill="white" opacity="0.7" />
<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" /> <circle cx="68" cy="24" r="3" fill="white" opacity="0.55" />
</svg> <circle cx="24" cy="64" r="3" fill="white" opacity="0.6" />
</div> <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="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> <p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('app.no_apps_hint')}</p>
<button <Button size="lg" onclick={() => (showForm = true)} class="mt-4">
type="button" <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">
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">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
{$t('app.add')} {$t('app.add')}
</button> </Button>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <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)} {#each data.apps as app, i (app.id)}
<AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} /> <div class="cozy-rise-stagger" style="--i: {Math.min(i, 12)};">
<AppCard {app} preloadedHistory={data.appHistories[app.id] ?? null} />
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
+3 -13
View File
@@ -4,6 +4,7 @@
import AppForm from '$lib/components/app/AppForm.svelte'; import AppForm from '$lib/components/app/AppForm.svelte';
import { broadcastDataChange } from '$lib/utils/broadcastSync.js'; import { broadcastDataChange } from '$lib/utils/broadcastSync.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Button, { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -35,19 +36,8 @@
{$t('app.quick_add_success')} {$t('app.quick_add_success')}
</p> </p>
<div class="mt-3 flex gap-3"> <div class="mt-3 flex gap-3">
<a <a href="/apps" class={buttonClass()}>{$t('app.quick_add_view_apps')}</a>
href="/apps" <Button variant="outline" onclick={closeWindow}>{$t('app.quick_add_close')}</Button>
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>
</div> </div>
</div> </div>
{:else} {:else}
+3 -10
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import ErrorState from '$lib/components/ui/ErrorState.svelte'; import ErrorState from '$lib/components/ui/ErrorState.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
const status = $derived($page.status); const status = $derived($page.status);
const message = $derived($page.error?.message ?? ''); const message = $derived($page.error?.message ?? '');
@@ -25,16 +26,8 @@
<ErrorState {status} {title} {hint}> <ErrorState {status} {title} {hint}>
{#snippet actions()} {#snippet actions()}
<a <a href="/" class={buttonClass()}>{$t('nav.home') ?? 'Home'}</a>
href="/" <a href="/boards" class={buttonClass({ variant: 'outline' })}>
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"
>
{$t('boards.title') ?? $t('board.title') ?? 'All boards'} {$t('boards.title') ?? $t('board.title') ?? 'All boards'}
</a> </a>
{/snippet} {/snippet}
+26 -27
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
import BoardCard from '$lib/components/board/BoardCard.svelte'; import BoardCard from '$lib/components/board/BoardCard.svelte';
import { buttonClass } from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
</script> </script>
@@ -21,10 +22,7 @@
</div> </div>
{#if !data.isGuest && data.user?.role === 'admin'} {#if !data.isGuest && data.user?.role === 'admin'}
<a <a href="/boards/new" class={buttonClass({ size: 'lg' })}>
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)]"
>
{$t('board.new')} {$t('board.new')}
</a> </a>
{/if} {/if}
@@ -32,22 +30,24 @@
{#if data.boards.length === 0} {#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="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
<svg class="mb-5 h-24 w-28"
class="h-8 w-8 text-primary" viewBox="0 0 112 96"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" aria-hidden="true"
fill="none" >
stroke="currentColor" <!-- back room (sky) -->
stroke-width="1.5" <rect x="8" y="14" width="56" height="52" rx="12" style="fill: var(--room-sky); opacity: 0.85;" />
stroke-linecap="round" <rect x="16" y="22" width="14" height="14" rx="3" fill="white" opacity="0.65" />
stroke-linejoin="round" <rect x="34" y="22" width="14" height="14" rx="3" fill="white" opacity="0.45" />
> <!-- middle room (peach) -->
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" /> <rect x="30" y="32" width="62" height="56" rx="14" style="fill: var(--room-peach); opacity: 0.92;" />
<line x1="3" y1="9" x2="21" y2="9" /> <rect x="40" y="44" width="18" height="18" rx="4" fill="white" opacity="0.7" />
<line x1="9" y1="21" x2="9" y2="9" /> <rect x="62" y="44" width="18" height="18" rx="4" fill="white" opacity="0.55" />
</svg> <rect x="40" y="66" width="40" height="6" rx="2" fill="white" opacity="0.45" />
</div> <!-- 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> <p class="text-lg font-medium text-foreground">{$t('board.no_boards')}</p>
{#if data.isGuest} {#if data.isGuest}
<p class="mt-1 max-w-sm text-sm text-muted-foreground">{$t('board.sign_in_more')}</p> <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"> <p class="mt-1 max-w-sm text-sm text-muted-foreground">
Create your first board to organize apps into a custom dashboard. Create your first board to organize apps into a custom dashboard.
</p> </p>
<a <a href="/boards/new" class={buttonClass({ size: 'lg', extra: 'mt-4' })}>
href="/boards/new" <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">
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">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -69,8 +66,10 @@
</div> </div>
{:else} {:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.boards as board (board.id)} {#each data.boards as board, i (board.id)}
<BoardCard {board} /> <div class="cozy-rise-stagger" style="--i: {i};">
<BoardCard {board} />
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
+10 -12
View File
@@ -3,6 +3,8 @@
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import TemplatePicker from '$lib/components/board/TemplatePicker.svelte'; 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(); let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form); const { form, errors, enhance, submitting } = superForm(data.form);
@@ -71,29 +73,25 @@
<TemplatePicker onSelect={handleTemplateSelect} /> <TemplatePicker onSelect={handleTemplateSelect} />
<input type="hidden" name="templateId" value={selectedTemplateId ?? ''} /> <input type="hidden" name="templateId" value={selectedTemplateId ?? ''} />
<div class="flex items-center gap-4"> <div class="flex flex-wrap items-center gap-6">
<label class="flex items-center gap-2 text-sm text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<input type="checkbox" name="isDefault" bind:checked={$form.isDefault} class="rounded" /> <Switch name="isDefault" bind:checked={$form.isDefault} ariaLabel={$t('board.default_board')} />
{$t('board.default_board')} {$t('board.default_board')}
</label> </label>
<label class="flex items-center gap-2 text-sm text-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
<input type="checkbox" name="isGuestAccessible" bind:checked={$form.isGuestAccessible} class="rounded" /> <Switch name="isGuestAccessible" bind:checked={$form.isGuestAccessible} ariaLabel={$t('board.guest_accessible')} />
{$t('board.guest_accessible')} {$t('board.guest_accessible')}
</label> </label>
</div> </div>
<div class="flex justify-end gap-3 pt-2"> <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')} {$t('common.cancel')}
</a> </a>
<button <Button type="submit" disabled={$submitting} loading={$submitting}>
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"
>
{$submitting ? $t('board.creating') : $t('board.create')} {$submitting ? $t('board.creating') : $t('board.create')}
</button> </Button>
</div> </div>
</form> </form>
</div> </div>
+3 -6
View File
@@ -3,6 +3,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { KeyRound } from 'lucide-svelte'; import { KeyRound } from 'lucide-svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ActionResult { interface ActionResult {
error?: string; error?: string;
@@ -80,13 +81,9 @@
<p class="mb-3 text-sm text-destructive">{form.error}</p> <p class="mb-3 text-sm text-destructive">{form.error}</p>
{/if} {/if}
<button <Button type="submit" fullWidth disabled={submitting} loading={submitting}>
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"
>
{submitting ? '…' : ($t('auth.forgot_password_submit') ?? 'Request reset link')} {submitting ? '…' : ($t('auth.forgot_password_submit') ?? 'Request reset link')}
</button> </Button>
</form> </form>
<p class="mt-4 text-center text-xs text-muted-foreground"> <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 { enhance } from '$app/forms';
import { TicketCheck } from 'lucide-svelte'; import { TicketCheck } from 'lucide-svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface ActionResult { interface ActionResult {
error?: string; error?: string;
@@ -62,13 +63,9 @@
<p class="mb-3 text-sm text-destructive">{form.error}</p> <p class="mb-3 text-sm text-destructive">{form.error}</p>
{/if} {/if}
<button <Button type="submit" fullWidth disabled={submitting} loading={submitting} class="mb-2">
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"
>
{submitting ? '…' : ($t('auth.invite_continue') ?? 'Continue')} {submitting ? '…' : ($t('auth.invite_continue') ?? 'Continue')}
</button> </Button>
</form> </form>
<p class="mt-4 text-center text-xs text-muted-foreground"> <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 { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js'; import type { PageData } from './$types.js';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte'; 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(); let { data }: { data: PageData } = $props();
@@ -106,12 +108,12 @@
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<label class="flex items-center gap-2 text-sm text-muted-foreground"> <label class="flex cursor-pointer items-center gap-3 text-sm text-muted-foreground">
<input <Switch
type="checkbox"
name="rememberMe" name="rememberMe"
bind:checked={$form.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> <span>{$t('auth.remember_me')}</span>
</label> </label>
@@ -120,20 +122,9 @@
</a> </a>
</div> </div>
<button <Button type="submit" size="lg" fullWidth disabled={$submitting} loading={$submitting}>
type="submit" {$submitting ? $t('auth.login_submitting') : $t('auth.login_submit')}
disabled={$submitting} </Button>
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>
</form> </form>
{/if} {/if}
@@ -165,7 +165,7 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each channels as channel (channel.id)} {#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"> <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"> <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)} {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> <h2 class="mb-4 text-lg font-semibold text-foreground">Recent Incidents</h2>
<div class="space-y-2"> <div class="space-y-2">
{#each data.incidents as incident (`${incident.appId}-${incident.startedAt}`)} {#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 justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span> <span class="inline-block h-2 w-2 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>