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,175 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { notifications } from '$lib/stores/notifications.svelte.js';
let showDropdown = $state(false);
onMount(() => {
notifications.load();
notifications.startPolling();
});
onDestroy(() => {
notifications.stopPolling();
});
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.notification-bell-container')) {
showDropdown = false;
}
}
function formatTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function eventLabel(event: string): string {
switch (event) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Offline';
case 'app_degraded':
return 'Degraded';
default:
return event;
}
}
function eventColor(event: string): string {
switch (event) {
case 'app_online':
return 'text-green-500';
case 'app_offline':
return 'text-red-500';
case 'app_degraded':
return 'text-yellow-500';
default:
return 'text-muted-foreground';
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="notification-bell-container relative">
<button
type="button"
onclick={() => (showDropdown = !showDropdown)}
class="relative inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Notifications"
aria-label="Notifications"
>
<!-- Bell Icon -->
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<!-- Unread Badge -->
{#if notifications.hasUnread}
<span
class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground"
>
{notifications.unreadCount > 99 ? '99+' : notifications.unreadCount}
</span>
{/if}
</button>
{#if showDropdown}
<div
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<h3 class="text-sm font-semibold text-popover-foreground">Notifications</h3>
{#if notifications.hasUnread}
<button
type="button"
class="text-xs text-primary transition-colors hover:text-primary/80"
onclick={() => notifications.markAllAsRead()}
>
Mark all as read
</button>
{/if}
</div>
<!-- Notification List -->
<div class="max-h-80 overflow-y-auto">
{#if notifications.items.length === 0}
<div class="p-6 text-center">
<p class="text-sm text-muted-foreground">No notifications yet</p>
</div>
{:else}
{#each notifications.items as notification (notification.id)}
<button
type="button"
class="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50 {notification.readAt === null ? 'bg-primary/5' : ''}"
onclick={() => {
if (notification.readAt === null) {
notifications.markAsRead(notification.id);
}
}}
>
<!-- Unread dot -->
<span class="mt-1.5 flex-shrink-0">
{#if notification.readAt === null}
<span class="inline-block h-2 w-2 rounded-full bg-primary"></span>
{:else}
<span class="inline-block h-2 w-2"></span>
{/if}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium {eventColor(notification.event)}">
{eventLabel(notification.event)}
</span>
{#if notification.app}
<span class="truncate text-xs text-muted-foreground">
{notification.app.name}
</span>
{/if}
</div>
<p class="mt-0.5 line-clamp-2 text-xs text-popover-foreground">
{notification.message}
</p>
<p class="mt-1 text-[10px] text-muted-foreground">
{formatTime(notification.sentAt)}
</p>
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="border-t border-border p-2">
<a
href="/settings/notifications"
class="block rounded-md px-3 py-1.5 text-center text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onclick={() => (showDropdown = false)}
>
Manage notifications
</a>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,257 @@
<script lang="ts">
import { NotificationType } from '$lib/utils/constants.js';
interface ChannelData {
readonly id?: string;
readonly type: string;
readonly config: string;
readonly enabled: boolean;
}
interface Props {
channel?: ChannelData | null;
onSave: (data: { type: string; config: string; enabled: boolean }) => void;
onCancel: () => void;
}
let { channel = null, onSave, onCancel }: Props = $props();
let channelType = $state(channel?.type ?? NotificationType.DISCORD);
let enabled = $state(channel?.enabled ?? true);
let testing = $state(false);
let testResult = $state<string | null>(null);
// Dynamic config fields
let discordWebhookUrl = $state('');
let slackWebhookUrl = $state('');
let telegramBotToken = $state('');
let telegramChatId = $state('');
let httpUrl = $state('');
let httpMethod = $state('POST');
// Parse existing config
if (channel?.config) {
try {
const parsed = JSON.parse(channel.config);
switch (channel.type) {
case 'discord':
discordWebhookUrl = parsed.webhookUrl ?? '';
break;
case 'slack':
slackWebhookUrl = parsed.webhookUrl ?? '';
break;
case 'telegram':
telegramBotToken = parsed.botToken ?? '';
telegramChatId = parsed.chatId ?? '';
break;
case 'http':
httpUrl = parsed.url ?? '';
httpMethod = parsed.method ?? 'POST';
break;
}
} catch {
// Invalid config
}
}
function buildConfig(): string {
switch (channelType) {
case 'discord':
return JSON.stringify({ webhookUrl: discordWebhookUrl });
case 'slack':
return JSON.stringify({ webhookUrl: slackWebhookUrl });
case 'telegram':
return JSON.stringify({ botToken: telegramBotToken, chatId: telegramChatId });
case 'http':
return JSON.stringify({ url: httpUrl, method: httpMethod });
default:
return '{}';
}
}
function handleSubmit() {
onSave({
type: channelType,
config: buildConfig(),
enabled
});
}
async function sendTest() {
if (!channel?.id) return;
testing = true;
testResult = null;
try {
const res = await fetch(`/api/notifications/channels/${channel.id}/test`, {
method: 'POST'
});
if (res.ok) {
testResult = 'Test notification sent successfully!';
} else {
const json = await res.json();
testResult = `Failed: ${json.error ?? 'Unknown error'}`;
}
} catch {
testResult = 'Failed: Network error';
} finally {
testing = false;
}
}
</script>
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
{channel ? 'Edit Channel' : 'Add Notification Channel'}
</h3>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<!-- Channel Type -->
<div>
<label for="channel-type" class="mb-1 block text-sm font-medium text-foreground">
Channel Type
</label>
<select
id="channel-type"
bind:value={channelType}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="telegram">Telegram</option>
<option value="http">HTTP Webhook</option>
</select>
</div>
<!-- Dynamic Fields -->
{#if channelType === 'discord'}
<div>
<label for="discord-url" class="mb-1 block text-sm font-medium text-foreground">
Webhook URL
</label>
<input
id="discord-url"
type="url"
bind:value={discordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
{:else if channelType === 'slack'}
<div>
<label for="slack-url" class="mb-1 block text-sm font-medium text-foreground">
Webhook URL
</label>
<input
id="slack-url"
type="url"
bind:value={slackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
{:else if channelType === 'telegram'}
<div>
<label for="tg-token" class="mb-1 block text-sm font-medium text-foreground">
Bot Token
</label>
<input
id="tg-token"
type="text"
bind:value={telegramBotToken}
placeholder="123456:ABC-DEF..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
<div>
<label for="tg-chat" class="mb-1 block text-sm font-medium text-foreground">
Chat ID
</label>
<input
id="tg-chat"
type="text"
bind:value={telegramChatId}
placeholder="-1001234567890"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
{:else if channelType === 'http'}
<div>
<label for="http-url" class="mb-1 block text-sm font-medium text-foreground">
URL
</label>
<input
id="http-url"
type="url"
bind:value={httpUrl}
placeholder="https://example.com/webhook"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
<div>
<label for="http-method" class="mb-1 block text-sm font-medium text-foreground">
Method
</label>
<select
id="http-method"
bind:value={httpMethod}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
{/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>
<!-- Test Result -->
{#if testResult}
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-green-500'}">
{testResult}
</p>
{/if}
<!-- Actions -->
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{channel ? 'Update' : 'Create'} Channel
</button>
{#if channel?.id}
<button
type="button"
onclick={sendTest}
disabled={testing}
class="rounded-md border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
{testing ? 'Sending...' : 'Send Test'}
</button>
{/if}
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</div>
</form>
</div>
@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount } from 'svelte';
interface NotificationItem {
readonly id: string;
readonly appId: string | null;
readonly event: string;
readonly message: string;
readonly sentAt: string;
readonly readAt: string | null;
readonly app?: {
readonly name: string;
} | null;
}
let allNotifications = $state<NotificationItem[]>([]);
let loading = $state(true);
let currentPage = $state(1);
let hasMore = $state(false);
let filterEvent = $state('');
let filterAppId = $state('');
const PAGE_SIZE = 20;
async function loadNotifications(page: number = 1) {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE)
});
if (filterEvent) params.set('event', filterEvent);
if (filterAppId) params.set('appId', filterAppId);
const res = await fetch(`/api/notifications?${params.toString()}`);
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
allNotifications = json.data;
hasMore = json.data.length === PAGE_SIZE;
}
}
} catch {
// Silently fail
} finally {
loading = false;
}
}
onMount(() => {
loadNotifications();
});
function changePage(delta: number) {
currentPage = Math.max(1, currentPage + delta);
loadNotifications(currentPage);
}
function applyFilters() {
currentPage = 1;
loadNotifications(1);
}
function eventLabel(event: string): string {
switch (event) {
case 'app_online': return 'Online';
case 'app_offline': return 'Offline';
case 'app_degraded': return 'Degraded';
default: return event;
}
}
function eventBadgeClass(event: string): string {
switch (event) {
case 'app_online': return 'bg-green-500/10 text-green-500';
case 'app_offline': return 'bg-red-500/10 text-red-500';
case 'app_degraded': return 'bg-yellow-500/10 text-yellow-500';
default: return 'bg-muted text-muted-foreground';
}
}
</script>
<div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<select
bind:value={filterEvent}
onchange={applyFilters}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
<option value="">All Events</option>
<option value="app_online">Online</option>
<option value="app_offline">Offline</option>
<option value="app_degraded">Degraded</option>
</select>
</div>
<!-- Table -->
{#if loading}
<div class="py-12 text-center text-muted-foreground">Loading...</div>
{:else if allNotifications.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<p class="text-muted-foreground">No notifications found</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Time</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Event</th>
<th class="px-4 py-3 font-medium text-muted-foreground">App</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Message</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{#each allNotifications as notification (notification.id)}
<tr class="border-b border-border last:border-0">
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted-foreground">
{new Date(notification.sentAt).toLocaleString()}
</td>
<td class="px-4 py-3">
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {eventBadgeClass(notification.event)}">
{eventLabel(notification.event)}
</span>
</td>
<td class="px-4 py-3 text-sm text-foreground">
{notification.app?.name ?? '—'}
</td>
<td class="max-w-xs truncate px-4 py-3 text-sm text-foreground">
{notification.message}
</td>
<td class="px-4 py-3">
{#if notification.readAt}
<span class="text-xs text-muted-foreground">Read</span>
{:else}
<span class="text-xs font-medium text-primary">Unread</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between">
<button
type="button"
disabled={currentPage === 1}
onclick={() => changePage(-1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Previous
</button>
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
<button
type="button"
disabled={!hasMore}
onclick={() => changePage(1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Next
</button>
</div>
{/if}
</div>