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
+169
View File
@@ -0,0 +1,169 @@
<script lang="ts">
interface Props {
url: string;
currentIcon: string;
currentName: string;
onApplyFavicon?: (faviconUrl: string) => void;
onApplyTitle?: (title: string) => void;
}
let { url, currentIcon, currentName, onApplyFavicon, onApplyTitle }: Props = $props();
let loading = $state(false);
let result = $state<{
status: number;
responseTime: number;
favicon: string | null;
title: string | null;
error: string | null;
} | null>(null);
const statusColor = $derived(() => {
if (!result) return '';
if (result.error) return 'text-destructive';
if (result.status >= 200 && result.status < 300) return 'text-green-500';
if (result.status >= 300 && result.status < 400) return 'text-yellow-500';
return 'text-destructive';
});
const canApplyFavicon = $derived(
result?.favicon && !currentIcon && onApplyFavicon
);
const canApplyTitle = $derived(
result?.title && !currentName && onApplyTitle
);
async function testConnection() {
if (!url) return;
loading = true;
result = null;
try {
const res = await fetch('/api/apps/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const json = await res.json();
if (json.success && json.data) {
result = json.data;
} else {
result = {
status: 0,
responseTime: 0,
favicon: null,
title: null,
error: json.error ?? 'Preview failed'
};
}
} catch {
result = {
status: 0,
responseTime: 0,
favicon: null,
title: null,
error: 'Failed to test connection'
};
} finally {
loading = false;
}
}
</script>
<div class="rounded-lg border border-border p-3">
<div class="flex items-center gap-2">
<button
type="button"
onclick={testConnection}
disabled={loading || !url}
class="rounded-md bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if loading}
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25" />
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" class="opacity-75" />
</svg>
Testing...
</span>
{:else}
Test Connection
{/if}
</button>
{#if !url}
<span class="text-xs text-muted-foreground">Enter a URL first</span>
{/if}
</div>
{#if result}
<div class="mt-3 space-y-2">
{#if result.error}
<div class="flex items-center gap-2 text-sm text-destructive">
<svg class="h-4 w-4 shrink-0" 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">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{result.error}</span>
</div>
{:else}
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="flex items-center gap-2">
<span class="text-muted-foreground">Status:</span>
<span class={statusColor()} class:font-medium={true}>
{result.status}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">Response:</span>
<span class="font-medium text-foreground">{result.responseTime}ms</span>
</div>
</div>
{#if result.title}
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Title:</span>
<span class="truncate text-foreground">{result.title}</span>
{#if canApplyTitle}
<button
type="button"
onclick={() => onApplyTitle?.(result!.title!)}
class="shrink-0 text-xs text-primary hover:underline"
>
Use as name
</button>
{/if}
</div>
{/if}
{#if result.favicon}
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Favicon:</span>
<img
src={result.favicon}
alt="Detected favicon"
class="h-4 w-4 shrink-0"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<span class="max-w-[200px] truncate text-xs text-muted-foreground">{result.favicon}</span>
{#if canApplyFavicon}
<button
type="button"
onclick={() => onApplyFavicon?.(result!.favicon!)}
class="shrink-0 text-xs text-primary hover:underline"
>
Use as icon
</button>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>