reusable snapshot header, field-label, and load-progress UI components

This commit is contained in:
jarek committed 2026-08-09 10:31:28 +02:00
1 parent 16451d95d7
commit c33c404259
5 files changed
+119

No files matched your search

@@ -0,0 +1,62 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import { Clock, Box, Layers } from 'lucide-svelte';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { getRepoTypeIcon } from '$lib/utils/backup';
import { formatDateTime, formatRelativeTime } from '$lib/stores/settings';
// Shared header line for the snapshot Browse and Restore dialogs, so both read the
// same: "<verb> <name> from <repo> <shortId> taken on <env> <timestamp>". A modal
// passes its own leading icon/verb and (for Restore) a trailing target-env clause via
// the `trailing` snippet; everything else - name, source repo, source env, id, time -
// is rendered identically here.
interface EnvRef { id: number; icon?: string | null }
interface Props {
// A lucide icon component. Typed loosely: lucide's component type doesn't line up
// with svelte's `Component` (same known mismatch PageHeader lives with).
icon: any;
verb: string;
name: string;
/** container | stack - picks the little box/layers glyph next to the name. */
nameType?: 'container' | 'stack';
/** Source repository this snapshot lives in (name + repo URL for the icon). */
destinationName?: string;
destinationRepository?: string;
/** Environment the snapshot was TAKEN on (null/absent for a local snapshot). */
sourceEnv?: EnvRef;
sourceEnvName?: string;
snapshotId?: string;
snapshotTime?: string | null;
/** Extra header content (e.g. Restore's "to <targetEnv>") rendered after the id. */
trailing?: import('svelte').Snippet;
}
let {
icon: Icon, verb, name, nameType = 'container',
destinationName, destinationRepository,
sourceEnv, sourceEnvName, snapshotId, snapshotTime, trailing
}: Props = $props();
</script>
<div class="flex items-center gap-2 flex-wrap text-lg font-semibold">
<Icon class="h-5 w-5" />
{verb}
{#if nameType === 'stack'}<Layers class="h-4 w-4 text-purple-500" />{:else}<Box class="h-4 w-4 text-blue-500" />{/if}
<span>{name}</span>
{#if destinationName}
{@const RepoIcon = getRepoTypeIcon(destinationRepository ?? '')}
<span class="text-muted-foreground">from</span>
<span class="flex items-center gap-1"><RepoIcon class="h-4 w-4 text-muted-foreground" />{destinationName}</span>
{/if}
<!-- Trailing clause (e.g. Restore's "to <targetEnv>") sits right after the source
repo, before the id/timestamp, so it reads "from <repo> to <env>". -->
{@render trailing?.()}
{#if snapshotId}<Badge variant="outline" class="font-mono text-[10px] font-normal">{snapshotId.slice(0, 8)}</Badge>{/if}
{#if snapshotTime}
<span class="flex items-center gap-1 text-xs font-normal text-muted-foreground">
<Clock class="h-3 w-3" />
taken{#if sourceEnvName}&nbsp;on <EnvironmentIcon icon={sourceEnv?.icon || 'globe'} envId={sourceEnv?.id ?? 0} class="h-3.5 w-3.5 text-amber-500" /><span class="text-amber-500 font-medium">{sourceEnvName}</span>{/if}
{formatDateTime(snapshotTime)}
<span class="opacity-60">({formatRelativeTime(snapshotTime)})</span>
</span>
{/if}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
// Form field label with a consistent required/optional marker: a red "*" glued to
// the label text for required fields, a muted "(optional)" suffix otherwise. Keeps
// every form's marker identical instead of each field hand-rolling its own span.
interface Props {
label: string;
forId?: string;
required?: boolean;
/** Show "(optional)" when not required. Off for forms that only mark required. */
showOptional?: boolean;
}
let { label, forId, required = false, showOptional = true }: Props = $props();
</script>
<Label for={forId}>
<span>{label}{#if required}<span class="text-destructive">&nbsp;*</span>{/if}</span>
{#if !required && showOptional}<span class="text-muted-foreground text-xs font-normal">(optional)</span>{/if}
</Label>
@@ -0,0 +1,7 @@
import Root from "./field-label.svelte";
export {
Root,
//
Root as FieldLabel,
};
@@ -0,0 +1,7 @@
import Root from "./snapshot-load-progress.svelte";
export {
Root,
//
Root as SnapshotLoadProgress,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
// Progress indicator for the per-destination snapshot sweep. Each backup repo is
// queried separately and a slow/unreachable one can take up to the backend's restic
// timeout (5 min), so we show "X of N repos" rather than an indeterminate spinner -
// the user sees it advancing instead of wondering if it hung. Shared across the
// Backups page and the container/stack snapshot panels.
interface Props {
done: number;
total: number;
/** Extra classes for the wrapper (sizing/spacing per call site). */
class?: string;
}
let { done, total, class: className = '' }: Props = $props();
</script>
<span class="flex items-center gap-1.5 text-xs text-muted-foreground {className}">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<!-- The "X of N repos" count only adds information with more than one repo; with a
single repo it reads oddly ("1 of 1"), so just say "Loading snapshots…". -->
Loading snapshots…{#if total > 1} {done} of {total} repos{/if}
</span>