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/.
146 lines
3.9 KiB
Svelte
146 lines
3.9 KiB
Svelte
<script lang="ts">
|
|
interface AppData {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
icon: string | null;
|
|
iconType: string;
|
|
description: string | null;
|
|
statuses: Array<{ status: string; responseTime: number | null }>;
|
|
}
|
|
|
|
interface StatusConfig {
|
|
appIds: string[];
|
|
label?: string;
|
|
}
|
|
|
|
interface Props {
|
|
config: StatusConfig;
|
|
apps: AppData[];
|
|
}
|
|
|
|
let { config, apps }: Props = $props();
|
|
|
|
// Filter apps that match the configured appIds
|
|
const matchedApps = $derived(
|
|
config.appIds
|
|
.map((id) => apps.find((a) => a.id === id))
|
|
.filter((a): a is AppData => a !== undefined)
|
|
);
|
|
|
|
const statusCounts = $derived.by(() => {
|
|
const counts = { online: 0, offline: 0, degraded: 0, unknown: 0 };
|
|
for (const app of matchedApps) {
|
|
const status = app.statuses[0]?.status ?? 'unknown';
|
|
if (status in counts) {
|
|
counts[status as keyof typeof counts] += 1;
|
|
} else {
|
|
counts.unknown += 1;
|
|
}
|
|
}
|
|
return counts;
|
|
});
|
|
|
|
const total = $derived(matchedApps.length);
|
|
|
|
let expanded = $state(false);
|
|
</script>
|
|
|
|
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
|
<!-- Header -->
|
|
<button
|
|
type="button"
|
|
onclick={() => (expanded = !expanded)}
|
|
class="flex w-full items-center justify-between text-left"
|
|
>
|
|
<span class="text-sm font-medium text-foreground">
|
|
{config.label || 'Service Status'}
|
|
</span>
|
|
<span class="text-xs text-muted-foreground">{total} services</span>
|
|
</button>
|
|
|
|
<!-- Status bar -->
|
|
<div class="mt-3 flex gap-1">
|
|
{#if statusCounts.online > 0}
|
|
<div
|
|
class="h-2 rounded-full bg-status-online"
|
|
style="flex: {statusCounts.online}"
|
|
title="{statusCounts.online} online"
|
|
></div>
|
|
{/if}
|
|
{#if statusCounts.degraded > 0}
|
|
<div
|
|
class="h-2 rounded-full bg-status-degraded"
|
|
style="flex: {statusCounts.degraded}"
|
|
title="{statusCounts.degraded} degraded"
|
|
></div>
|
|
{/if}
|
|
{#if statusCounts.offline > 0}
|
|
<div
|
|
class="h-2 rounded-full bg-status-offline"
|
|
style="flex: {statusCounts.offline}"
|
|
title="{statusCounts.offline} offline"
|
|
></div>
|
|
{/if}
|
|
{#if statusCounts.unknown > 0}
|
|
<div
|
|
class="h-2 rounded-full bg-status-unknown"
|
|
style="flex: {statusCounts.unknown}"
|
|
title="{statusCounts.unknown} unknown"
|
|
></div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Summary counts -->
|
|
<div class="mt-2 flex flex-wrap gap-3 text-xs">
|
|
{#if statusCounts.online > 0}
|
|
<span class="flex items-center gap-1">
|
|
<span class="inline-block h-2 w-2 rounded-full bg-status-online"></span>
|
|
{statusCounts.online} online
|
|
</span>
|
|
{/if}
|
|
{#if statusCounts.degraded > 0}
|
|
<span class="flex items-center gap-1">
|
|
<span class="inline-block h-2 w-2 rounded-full bg-status-degraded"></span>
|
|
{statusCounts.degraded} degraded
|
|
</span>
|
|
{/if}
|
|
{#if statusCounts.offline > 0}
|
|
<span class="flex items-center gap-1">
|
|
<span class="inline-block h-2 w-2 rounded-full bg-status-offline"></span>
|
|
{statusCounts.offline} offline
|
|
</span>
|
|
{/if}
|
|
{#if statusCounts.unknown > 0}
|
|
<span class="flex items-center gap-1">
|
|
<span class="inline-block h-2 w-2 rounded-full bg-status-unknown"></span>
|
|
{statusCounts.unknown} unknown
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Expanded: individual app statuses -->
|
|
{#if expanded}
|
|
<div class="mt-3 space-y-1 border-t border-border pt-3">
|
|
{#each matchedApps as app (app.id)}
|
|
{@const status = app.statuses[0]?.status ?? 'unknown'}
|
|
{@const statusColor =
|
|
status === 'online'
|
|
? 'bg-status-online'
|
|
: status === 'offline'
|
|
? 'bg-status-offline'
|
|
: status === 'degraded'
|
|
? 'bg-status-degraded'
|
|
: 'bg-status-unknown'}
|
|
<div class="flex items-center justify-between text-xs">
|
|
<span class="text-foreground">{app.name}</span>
|
|
<span class="flex items-center gap-1">
|
|
<span class="inline-block h-2 w-2 rounded-full {statusColor}"></span>
|
|
<span class="text-muted-foreground">{status}</span>
|
|
</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|