d479726fe3
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.
150 lines
4.1 KiB
Svelte
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>
|