Files
web-app-launcher/src/lib/components/widget/AppWidget.svelte
T
alexei.dolgolyov 5dcadd1c20 feat(ui): migrate entire UI to "Cozy Home" design
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a
swappable token bundle so other presets can be added later; dark mode and the
user-tunable accent hue are retained.

Foundation
- app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent
  (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus
  AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens
- Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts
  (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept
- h1/h2/h3 render in Fraunces via base layer

Chrome and surfaces
- Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites
- 29 widgets + integration renderers: cozy card shells, room-palette charts
- Default background is a static warm "cozy" glow (mesh demoted, rAF gated on
  prefers-reduced-motion)

System-wide
- Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning
  to status tokens, categorical to room palette, errors to destructive
- Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem];
  soft-shadow vocabulary only; focus-visible:ring-primary/30
- Forms, admin tables (now cozy cards), dialogs, popovers, auth screens

a11y: reduced-motion guards; darker status "ink" text for AA on cream.
Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color,
user-tunable).

Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors.
Design refs + system sheet in design-mockups/.
2026-05-27 23:04:47 +03:00

403 lines
13 KiB
Svelte

<script lang="ts">
import { onMount, getContext } 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;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
links?: AppLink[];
tags?: AppTag[];
}
interface StatusPoint {
status: string;
checkedAt: string;
}
interface Props {
app: AppData;
cardSize?: 'compact' | 'medium' | 'large';
}
let { app, cardSize = 'medium' }: Props = $props();
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
// Use pre-loaded history from context (set by board page) to avoid N+1 fetches
const appHistories = getContext<Record<string, { history: StatusPoint[]; uptimePercent: number }> | undefined>('appHistories');
const preloaded = appHistories?.[app.id];
let historyData: StatusPoint[] = $state(preloaded?.history ?? []);
let uptimePercent: number | null = $state(preloaded?.uptimePercent ?? null);
let historyLoading = $state(!preloaded);
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;
switch (app.iconType) {
case 'url':
return app.icon;
case 'simple': {
const slug = app.icon.toLowerCase().replace(/[^a-z0-9]/g, '');
return `https://cdn.simpleicons.org/${slug}`;
}
default:
return null;
}
});
// Fallback: fetch history only if not pre-loaded via context
onMount(async () => {
if (preloaded) return;
try {
const res = await fetch(`/api/apps/${app.id}/history`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
historyData = json.data.history ?? [];
uptimePercent = json.data.uptimePercent ?? null;
}
}
} catch {
// Silently fail — sparkline is non-critical
} finally {
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>
<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-2xl {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
data-app-widget
data-app-url={app.url}
oncontextmenu={handleContextMenu}
onclick={recordClick}
>
<div class="relative flex-shrink-0">
<div class="flex h-8 w-8 items-center justify-center rounded-xl 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}
</a>
<!-- 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}
{:else if cardSize === 'large'}
<!-- Large: icon + name + description + sparkline + tags + links -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card-hover group rounded-[1.4rem] {cardStyleClass} p-5 transition-colors hover:border-primary/50"
data-app-widget
data-app-url={app.url}
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-2xl 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 -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card-hover group rounded-[1.4rem] {cardStyleClass} p-4 transition-colors hover:border-primary/50"
data-app-widget
data-app-url={app.url}
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-2xl 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-2xl border border-border bg-popover p-1 shadow-[var(--shadow-soft)]"
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-xl px-3 py-2 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}