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
+320 -40
View File
@@ -1,7 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte';
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
import AnimatedStatusRing from '$lib/components/app/AnimatedStatusRing.svelte';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
import TagBadge from '$lib/components/app/TagBadge.svelte';
import { theme } from '$lib/stores/theme.svelte.js';
import { favorites } from '$lib/stores/favorites.svelte.js';
import { slide } from 'svelte/transition';
interface AppLink {
id: string;
label: string;
url: string;
icon: string | null;
order: number;
}
interface AppTag {
id: string;
name: string;
color: string | null;
}
interface AppData {
id: string;
@@ -11,6 +30,8 @@
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
links?: AppLink[];
tags?: AppTag[];
}
interface StatusPoint {
@@ -20,15 +41,23 @@
interface Props {
app: AppData;
cardSize?: 'compact' | 'medium' | 'large';
}
let { app }: Props = $props();
let { app, cardSize = 'medium' }: Props = $props();
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
let historyData: StatusPoint[] = $state([]);
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true);
let linksExpanded = $state(false);
let showContextMenu = $state(false);
let contextMenuPos = $state({ x: 0, y: 0 });
const latestStatus = $derived(app.statuses[0]?.status ?? 'unknown');
const hasLinks = $derived(Array.isArray(app.links) && app.links.length > 0);
const hasTags = $derived(Array.isArray(app.tags) && app.tags.length > 0);
const iconSrc = $derived.by(() => {
if (!app.icon) return null;
@@ -61,48 +90,299 @@
historyLoading = false;
}
});
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenuPos = { x: e.clientX, y: e.clientY };
showContextMenu = true;
}
function handleWindowClick() {
showContextMenu = false;
}
function toggleFavorite() {
showContextMenu = false;
if (favorites.isFavorite(app.id)) {
favorites.remove(app.id);
} else {
favorites.add(app.id);
}
}
function recordClick() {
fetch('/api/recent-apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId: app.id })
}).catch(() => {});
}
function toggleLinks(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
linksExpanded = !linksExpanded;
}
</script>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50"
>
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
<img
src={iconSrc}
alt="{app.name} icon"
class="h-8 w-8 object-contain"
/>
{:else}
<span class="text-lg font-bold text-muted-foreground">
{app.name.charAt(0).toUpperCase()}
</span>
<svelte:window onclick={handleWindowClick} />
{#if cardSize === 'compact'}
<!-- Compact: icon + name only, inline layout -->
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="card-hover group flex items-center gap-2 rounded-lg {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
oncontextmenu={handleContextMenu}
onclick={recordClick}
>
<div class="relative flex-shrink-0">
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-base">{app.icon}</span>
{:else if iconSrc}
<img src={iconSrc} alt="{app.name} icon" class="h-5 w-5 object-contain" />
{:else}
<span class="text-xs font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AnimatedStatusRing status={latestStatus} size={32} animated />
</div>
<span class="truncate text-xs font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
{#if hasLinks}
<button
type="button"
onclick={toggleLinks}
class="ml-auto flex-shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
title="Show links"
>
<svg class="h-3 w-3 transition-transform" class:rotate-90={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/if}
</div>
</a>
<!-- Name -->
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
<!-- Status -->
<AppHealthBadge status={latestStatus} />
<!-- Sparkline -->
{#if historyLoading}
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
<!-- Expanded links for compact -->
{#if linksExpanded && hasLinks}
<div transition:slide={{ duration: 200 }} class="ml-10 space-y-0.5 pb-1">
{#each app.links ?? [] as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{link.label}
</a>
{/each}
</div>
{/if}
</a>
{:else if cardSize === 'large'}
<!-- Large: icon + name + description + sparkline + tags + links -->
<div
class="card-hover group rounded-xl {cardStyleClass} p-5 transition-colors hover:border-primary/50"
oncontextmenu={handleContextMenu}
>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="flex flex-col items-center gap-3 text-center"
onclick={recordClick}
>
<div class="relative">
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-3xl">{app.icon}</span>
{:else if iconSrc}
<img src={iconSrc} alt="{app.name} icon" class="h-10 w-10 object-contain" />
{:else}
<span class="text-xl font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AnimatedStatusRing status={latestStatus} size={64} animated />
</div>
<span class="w-full truncate text-base font-semibold text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
{#if app.description}
<p class="line-clamp-2 w-full text-xs text-muted-foreground">{app.description}</p>
{/if}
<AppHealthBadge status={latestStatus} />
{#if historyLoading}
<div class="h-5 w-24 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
</div>
{/if}
</a>
<!-- Tags -->
{#if hasTags}
<div class="mt-2 flex flex-wrap justify-center gap-1">
{#each app.tags ?? [] as tag (tag.id)}
<TagBadge name={tag.name} color={tag.color} size="sm" />
{/each}
</div>
{/if}
<!-- Expandable Links -->
{#if hasLinks}
<div class="mt-2 border-t border-border pt-2">
<button
type="button"
onclick={toggleLinks}
class="flex w-full items-center justify-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<svg class="h-3 w-3 transition-transform" class:rotate-180={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
{linksExpanded ? 'Hide' : `${app.links?.length ?? 0} more`} links
</button>
{#if linksExpanded}
<div transition:slide={{ duration: 200 }} class="mt-1.5 space-y-1">
{#each app.links ?? [] as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Medium (default): icon + name + status + sparkline on hover + links -->
<div
class="card-hover group rounded-xl {cardStyleClass} p-4 transition-colors hover:border-primary/50"
oncontextmenu={handleContextMenu}
>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="flex flex-col items-center gap-2 text-center"
onclick={recordClick}
>
<div class="relative">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
<img src={iconSrc} alt="{app.name} icon" class="h-8 w-8 object-contain" />
{:else}
<span class="text-lg font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AnimatedStatusRing status={latestStatus} size={48} animated />
</div>
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
<AppHealthBadge status={latestStatus} />
{#if historyLoading}
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
</div>
{/if}
</a>
<!-- Expandable Links -->
{#if hasLinks}
<div class="mt-2 border-t border-border pt-2">
<button
type="button"
onclick={toggleLinks}
class="flex w-full items-center justify-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<svg class="h-3 w-3 transition-transform" class:rotate-180={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
{linksExpanded ? 'Hide' : `${app.links?.length ?? 0} more`}
</button>
{#if linksExpanded}
<div transition:slide={{ duration: 200 }} class="mt-1 space-y-0.5">
{#each app.links ?? [] as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Context Menu -->
{#if showContextMenu}
<div
class="fixed z-50 rounded-lg border border-border bg-popover p-1 shadow-lg"
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
onclick={toggleFavorite}
>
{#if favorites.isFavorite(app.id)}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
Remove from favorites
{:else}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
Add to favorites
{/if}
</button>
</div>
{/if}