Files
web-app-launcher/src/lib/components/app/AppCard.svelte
T
alexei.dolgolyov d479726fe3
CI / lint-and-check (push) Failing after 5m3s
CI / test (push) Has been skipped
CI / docker-build (push) Has been skipped
feat: add app edit page with pre-populated form
Add /apps/[id]/edit route that loads existing app data into the form,
allowing users to update app properties. Adds edit pencil button to
AppCard (visible on hover) and i18n keys for both EN and RU.
2026-03-25 22:42:20 +03:00

150 lines
4.1 KiB
Svelte

<script lang="ts">
import { t } from 'svelte-i18n';
import { onMount } from 'svelte';
import AppHealthBadge from './AppHealthBadge.svelte';
import SparklineChart from './SparklineChart.svelte';
interface AppWithStatus {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
category: string | null;
statuses: Array<{ status: string; responseTime: number | null; checkedAt: string | Date }>;
}
interface StatusPoint {
status: string;
checkedAt: string;
}
interface Props {
app: AppWithStatus;
}
let { app }: Props = $props();
let historyData: StatusPoint[] = $state([]);
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true);
const currentStatus = $derived(app.statuses?.[0]?.status ?? 'unknown');
onMount(async () => {
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;
}
});
const iconDisplay = $derived.by(() => {
if (!app.icon) return null;
switch (app.iconType) {
case 'emoji':
return { kind: 'emoji' as const, value: app.icon };
case 'url':
return { kind: 'image' as const, src: app.icon };
case 'simple':
return {
kind: 'image' as const,
src: `https://cdn.simpleicons.org/${app.icon.toLowerCase()}`
};
default:
return { kind: 'text' as const, value: app.icon };
}
});
</script>
<div
role="link"
tabindex="0"
onclick={() => window.open(app.url, '_blank', 'noopener,noreferrer')}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') window.open(app.url, '_blank', 'noopener,noreferrer'); }}
class="card-hover group flex cursor-pointer flex-col rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50"
title={app.description ?? app.name}
>
<div class="mb-3 flex items-start justify-between">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-lg text-muted-foreground"
>
{#if iconDisplay?.kind === 'emoji'}
<span class="text-xl">{iconDisplay.value}</span>
{:else if iconDisplay?.kind === 'image'}
<img
src={iconDisplay.src}
alt="{app.name} icon"
class="h-6 w-6 rounded object-contain"
/>
{:else if iconDisplay?.kind === 'text'}
<span class="text-xs font-medium">{iconDisplay.value}</span>
{:else}
<span class="text-xs font-bold">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="flex items-center gap-1.5">
<a
href="/apps/{app.id}/edit"
onclick={(e: MouseEvent) => e.stopPropagation()}
class="rounded-md p-1 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
title={$t('app.edit')}
>
<svg
class="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
</a>
<AppHealthBadge status={currentStatus} />
</div>
</div>
<h3 class="truncate text-sm font-semibold text-card-foreground transition-colors group-hover:text-primary">
{app.name}
</h3>
{#if app.description}
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
{/if}
<!-- Sparkline -->
{#if historyLoading}
<div class="mt-2 h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="mt-2 flex items-center gap-1.5">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
{/if}
</div>
{/if}
{#if app.category}
<span
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{app.category}
</span>
{/if}
</div>