feat: Phases 4-7 — Full Feature Expansion (26 features)

Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
This commit is contained in:
2026-03-25 14:18:10 +03:00
parent 8d7847889e
commit 1c0a7cb850
212 changed files with 15642 additions and 981 deletions
@@ -0,0 +1,204 @@
<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-md 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-lg border border-border bg-card p-4">
<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>