f087551454
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.
205 lines
5.8 KiB
Svelte
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'}
|
|
· 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>
|