Files
web-app-launcher/src/routes/settings/notifications/+page.svelte
T
alexei.dolgolyov f087551454 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.
2026-05-28 14:39:53 +03:00

205 lines
5.8 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import NotificationChannelForm from '$lib/components/notifications/NotificationChannelForm.svelte';
import NotificationHistory from '$lib/components/notifications/NotificationHistory.svelte';
interface Channel {
id: string;
type: string;
config: string;
enabled: boolean;
createdAt: string;
}
let channels = $state<Channel[]>([]);
let loading = $state(true);
let showForm = $state(false);
let editingChannel = $state<Channel | null>(null);
let activeTab = $state<'channels' | 'history'>('channels');
let error = $state<string | null>(null);
onMount(async () => {
await loadChannels();
});
async function loadChannels() {
loading = true;
try {
const res = await fetch('/api/notifications/channels');
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
channels = json.data;
}
}
} catch {
error = 'Failed to load notification channels';
} finally {
loading = false;
}
}
async function handleSave(data: { type: string; config: string; enabled: boolean }) {
error = null;
try {
if (editingChannel) {
const res = await fetch(`/api/notifications/channels/${editingChannel.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
error = 'Failed to update channel';
return;
}
} else {
const res = await fetch('/api/notifications/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
error = 'Failed to create channel';
return;
}
}
showForm = false;
editingChannel = null;
await loadChannels();
} catch {
error = 'Network error saving channel';
}
}
async function deleteChannel(channelId: string) {
try {
const res = await fetch(`/api/notifications/channels/${channelId}`, {
method: 'DELETE'
});
if (res.ok) {
await loadChannels();
}
} catch {
error = 'Failed to delete channel';
}
}
function channelTypeLabel(type: string): string {
switch (type) {
case 'discord': return 'Discord';
case 'slack': return 'Slack';
case 'telegram': return 'Telegram';
case 'http': return 'HTTP Webhook';
default: return type;
}
}
</script>
<svelte:head>
<title>Notification Settings | {$t('app_name')}</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-8">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">Notifications</h1>
</div>
<!-- Tabs -->
<div class="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
<button
type="button"
onclick={() => (activeTab = 'channels')}
class="rounded-md px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'channels'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
Channels
</button>
<button
type="button"
onclick={() => (activeTab = 'history')}
class="rounded-md px-4 py-1.5 text-sm font-medium transition-colors {activeTab === 'history'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}"
>
History
</button>
</div>
{#if error}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
{#if activeTab === 'channels'}
<!-- Channel Form -->
{#if showForm || editingChannel}
<NotificationChannelForm
channel={editingChannel}
onSave={handleSave}
onCancel={() => { showForm = false; editingChannel = null; }}
/>
{:else}
<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"
>
Add Channel
</button>
{/if}
<!-- Channel List -->
{#if loading}
<div class="py-8 text-center text-muted-foreground">Loading channels...</div>
{:else if channels.length === 0 && !showForm}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">No notification channels configured</p>
<p class="mt-1 text-xs text-muted-foreground">
Add a channel to receive alerts when your services go up or down
</p>
</div>
{:else}
<div class="space-y-3">
{#each channels as channel (channel.id)}
<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)}
</span>
<div>
<p class="text-sm font-medium text-foreground">{channelTypeLabel(channel.type)}</p>
<p class="text-xs text-muted-foreground">
{channel.enabled ? 'Enabled' : 'Disabled'}
&middot; Created {new Date(channel.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => { editingChannel = channel; showForm = false; }}
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Edit
</button>
<button
type="button"
onclick={() => deleteChannel(channel.id)}
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<NotificationHistory />
{/if}
</div>