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