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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user