5dcadd1c20
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/.
403 lines
13 KiB
Svelte
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}
|