Replaces the read-only stats dashboard with a three-pane wiki: a navigator tree (group by folder/type/tag/entity), a reader/editor with bare-slug auto-linking and revision history + diff, and a context rail for backlinks and related notes. Adds a Cleanup mode for the drift tools (duplicates, tag manager, orphans, trash) and a Cmd+K quick-open. Also: - Adds a real landing view (hero count, KPI row, Nomos-share meter, recently-updated, busiest tags) in place of the old "Select a note" empty state, and extends the design pass across the tree/reader/rail (kind icons instead of repeated text badges, accent-bar selection, constrained prose measure). - Guards every note-selection path behind a confirm when there's an unsaved edit in progress, so switching notes can no longer silently discard a draft. - Extracts the markdown-rendering CSS duplicated across ChatThread, EntityDetailContent, and the new WikiReader into a shared .markdown-body class in app.css, with ChatThread keeping only its decorative deltas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
571 lines
22 KiB
Svelte
571 lines
22 KiB
Svelte
<script lang="ts">
|
|
// Maintenance view for the knowledge base's own drift — duplicates, tag
|
|
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
|
|
// rather than folded into the main three-pane view because none of this
|
|
// is "browse a note," it's "audit the collection," and mixing the two
|
|
// would clutter the read/edit flow with tools most visits don't need.
|
|
//
|
|
// Every action here (merge, rename, restore) is deliberately one click
|
|
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
|
|
// own doc comment on why title-similarity clustering can't be trusted as
|
|
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
|
|
// being genuinely distinct documents).
|
|
import {
|
|
fetchKnowledgeDuplicates,
|
|
fetchKnowledgeTags,
|
|
fetchKnowledgeOrphans,
|
|
fetchKnowledgeTrash,
|
|
renameKnowledgeTag,
|
|
mergeKnowledge,
|
|
restoreKnowledge,
|
|
KnowledgeApiError,
|
|
type KnowledgeDuplicateCluster,
|
|
type KnowledgeTag,
|
|
type KnowledgeOrphan,
|
|
type KnowledgeTrashItem
|
|
} from '$lib/api'
|
|
import * as Tabs from '$lib/components/ui/tabs'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import { Input } from '$lib/components/ui/input'
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
import Spinner from '$lib/components/Spinner.svelte'
|
|
import { toast } from 'svelte-sonner'
|
|
import { kindMeta } from './kinds'
|
|
import { relativeTime } from '$lib/utils'
|
|
import CopyIcon from '@lucide/svelte/icons/copy'
|
|
import TagIcon from '@lucide/svelte/icons/tag'
|
|
import GhostIcon from '@lucide/svelte/icons/ghost'
|
|
import Trash2Icon from '@lucide/svelte/icons/trash-2'
|
|
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
|
|
import CheckIcon from '@lucide/svelte/icons/check'
|
|
|
|
let {
|
|
onSelect,
|
|
onChanged
|
|
}: {
|
|
onSelect: (slug: string) => void
|
|
// Fired after any mutation (merge, tag rename, restore) so the parent's
|
|
// note list — which this view reads a filtered copy of, indirectly —
|
|
// stays in sync.
|
|
onChanged: () => void
|
|
} = $props()
|
|
|
|
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
|
|
|
|
// Shared across every loader/action below: the read helpers in api.ts now
|
|
// throw KnowledgeApiError on a failed request instead of quietly returning
|
|
// an empty list, so a real outage can't be mistaken for "nothing to
|
|
// clean up" — see api.ts's comment on listKnowledge for the same fix
|
|
// applied to the main note list.
|
|
function errMsg(e: unknown): string {
|
|
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
|
|
}
|
|
|
|
// ─── Duplicates ───────────────────────────────────────────────────────
|
|
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
|
|
let duplicatesError = $state('')
|
|
// Per cluster (indexed by the cluster's first member slug — stable across
|
|
// a reload since clusters are keyed by content, not array position):
|
|
// which slug is the merge target and which sources are checked.
|
|
let mergeTarget = $state<Record<string, string>>({})
|
|
let mergeSources = $state<Record<string, Set<string>>>({})
|
|
let merging = $state<string | null>(null)
|
|
let mergeError = $state('')
|
|
|
|
async function loadDuplicates(): Promise<void> {
|
|
clusters = null
|
|
duplicatesError = ''
|
|
try {
|
|
const result = await fetchKnowledgeDuplicates()
|
|
clusters = result
|
|
const targets: Record<string, string> = {}
|
|
const sources: Record<string, Set<string>> = {}
|
|
for (const c of result) {
|
|
const key = c.members[0].slug
|
|
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
|
|
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
|
|
}
|
|
mergeTarget = targets
|
|
mergeSources = sources
|
|
} catch (e) {
|
|
duplicatesError = errMsg(e)
|
|
clusters = [] // stop the spinner — the error message above explains the empty state
|
|
}
|
|
}
|
|
|
|
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
|
|
// `sources` since it used to be excluded as "the target"), so recompute
|
|
// the whole source set relative to the new target rather than leaving it
|
|
// stale — otherwise the old target silently drops out of the merge
|
|
// instead of folding in like every other member.
|
|
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
|
|
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
|
|
mergeSources = {
|
|
...mergeSources,
|
|
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
|
|
}
|
|
}
|
|
|
|
function toggleSource(clusterKey: string, slug: string): void {
|
|
const set = new Set(mergeSources[clusterKey])
|
|
if (set.has(slug)) set.delete(slug)
|
|
else set.add(slug)
|
|
mergeSources = { ...mergeSources, [clusterKey]: set }
|
|
}
|
|
|
|
async function doMerge(clusterKey: string): Promise<void> {
|
|
const target = mergeTarget[clusterKey]
|
|
const sources = [...(mergeSources[clusterKey] ?? [])]
|
|
if (!target || sources.length === 0) return
|
|
merging = clusterKey
|
|
mergeError = ''
|
|
try {
|
|
const result = await mergeKnowledge(target, sources)
|
|
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
|
|
onChanged()
|
|
await loadDuplicates()
|
|
} catch (e) {
|
|
mergeError = errMsg(e)
|
|
} finally {
|
|
merging = null
|
|
}
|
|
}
|
|
|
|
// ─── Tags ─────────────────────────────────────────────────────────────
|
|
let tags = $state<KnowledgeTag[] | null>(null)
|
|
let tagsError = $state('')
|
|
let renaming = $state<string | null>(null)
|
|
let renameDraft = $state('')
|
|
let renameBusy = $state(false)
|
|
|
|
async function loadTags(): Promise<void> {
|
|
tags = null
|
|
tagsError = ''
|
|
try {
|
|
tags = await fetchKnowledgeTags()
|
|
} catch (e) {
|
|
tagsError = errMsg(e)
|
|
tags = []
|
|
}
|
|
}
|
|
|
|
async function normalize(t: KnowledgeTag): Promise<void> {
|
|
renameBusy = true
|
|
try {
|
|
const n = await renameKnowledgeTag(t.variants, t.tag)
|
|
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
|
|
onChanged()
|
|
await loadTags()
|
|
} catch (e) {
|
|
toast.error(errMsg(e))
|
|
} finally {
|
|
renameBusy = false
|
|
}
|
|
}
|
|
|
|
function startRename(t: KnowledgeTag): void {
|
|
renaming = t.tag
|
|
renameDraft = t.tag
|
|
}
|
|
|
|
async function confirmRename(t: KnowledgeTag): Promise<void> {
|
|
const to = renameDraft.trim().toLowerCase()
|
|
if (!to || to === t.tag) {
|
|
renaming = null
|
|
return
|
|
}
|
|
renameBusy = true
|
|
try {
|
|
const n = await renameKnowledgeTag(t.variants, to)
|
|
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
|
|
onChanged()
|
|
await loadTags()
|
|
renaming = null
|
|
} catch (e) {
|
|
// Leave the rename input open on failure — the operator's typed value
|
|
// (and their reason for changing it) shouldn't vanish along with the
|
|
// error, forcing them to retype it to try again.
|
|
toast.error(errMsg(e))
|
|
} finally {
|
|
renameBusy = false
|
|
}
|
|
}
|
|
|
|
// ─── Orphans ──────────────────────────────────────────────────────────
|
|
let orphans = $state<KnowledgeOrphan[] | null>(null)
|
|
let orphanCounts = $state<Record<string, number>>({})
|
|
let orphansError = $state('')
|
|
|
|
async function loadOrphans(): Promise<void> {
|
|
orphans = null
|
|
orphansError = ''
|
|
try {
|
|
const result = await fetchKnowledgeOrphans()
|
|
orphans = result.items
|
|
orphanCounts = result.counts
|
|
} catch (e) {
|
|
orphansError = errMsg(e)
|
|
orphans = []
|
|
}
|
|
}
|
|
|
|
// ─── Trash ────────────────────────────────────────────────────────────
|
|
let trash = $state<KnowledgeTrashItem[] | null>(null)
|
|
let trashError = $state('')
|
|
let restoring = $state<string | null>(null)
|
|
|
|
async function loadTrash(): Promise<void> {
|
|
trash = null
|
|
trashError = ''
|
|
try {
|
|
trash = await fetchKnowledgeTrash()
|
|
} catch (e) {
|
|
trashError = errMsg(e)
|
|
trash = []
|
|
}
|
|
}
|
|
|
|
async function doRestore(slug: string): Promise<void> {
|
|
restoring = slug
|
|
try {
|
|
await restoreKnowledge(slug)
|
|
toast.success('Note restored')
|
|
onChanged()
|
|
await loadTrash()
|
|
} catch (e) {
|
|
toast.error(errMsg(e))
|
|
} finally {
|
|
restoring = null
|
|
}
|
|
}
|
|
|
|
function activate(t: typeof tab): void {
|
|
tab = t
|
|
if (t === 'duplicates' && clusters === null) loadDuplicates()
|
|
else if (t === 'tags' && tags === null) loadTags()
|
|
else if (t === 'orphans' && orphans === null) loadOrphans()
|
|
else if (t === 'trash' && trash === null) loadTrash()
|
|
}
|
|
|
|
// Initial tab's data.
|
|
loadDuplicates()
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col gap-2">
|
|
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
|
|
<Tabs.List class="h-8 w-fit">
|
|
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
|
|
<CopyIcon class="size-3.5" /> Duplicates
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
|
|
<TagIcon class="size-3.5" /> Tags
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
|
|
<GhostIcon class="size-3.5" /> Orphans
|
|
</Tabs.Trigger>
|
|
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
|
|
<Trash2Icon class="size-3.5" /> Trash
|
|
</Tabs.Trigger>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
|
<p class="mb-2 text-xs text-muted-foreground">
|
|
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
|
|
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
|
|
</p>
|
|
{#if mergeError}
|
|
<p
|
|
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
|
|
>
|
|
{mergeError}
|
|
</p>
|
|
{/if}
|
|
{#if duplicatesError}
|
|
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
|
{duplicatesError}
|
|
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
|
|
>Retry</Button
|
|
>
|
|
</p>
|
|
{/if}
|
|
{#if clusters === null}
|
|
<div class="flex justify-center py-8"><Spinner /></div>
|
|
{:else if clusters.length === 0}
|
|
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
|
|
{:else}
|
|
<div class="flex flex-col gap-3">
|
|
{#each clusters as c (c.members[0].slug)}
|
|
{@const key = c.members[0].slug}
|
|
{@const sourceCount = mergeSources[key]?.size ?? 0}
|
|
<div class="overflow-hidden rounded-lg border">
|
|
<div
|
|
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-2 text-xs">
|
|
<span class="font-medium">{c.members.length} similar notes</span>
|
|
<!-- Similarity as a meter rather than only a number: it's a
|
|
ratio, and the bar makes a 93% pileup visibly different
|
|
from a borderline 61% at a glance down a long list. -->
|
|
<span
|
|
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
|
|
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
|
|
>
|
|
<span
|
|
class="block h-full rounded-full bg-primary"
|
|
style="width: {c.top_similarity * 100}%"
|
|
></span>
|
|
</span>
|
|
<span class="shrink-0 tabular-nums text-muted-foreground"
|
|
>{(c.top_similarity * 100).toFixed(0)}%</span
|
|
>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
class="h-6 shrink-0 gap-1 text-xs"
|
|
disabled={merging === key || sourceCount === 0}
|
|
onclick={() => doMerge(key)}
|
|
>
|
|
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
|
|
</Button>
|
|
</div>
|
|
<!-- Two bare inputs per row read as "…what do these do?", so
|
|
name them once per cluster. An inline legend rather than
|
|
column headers: the controls are 14px wide and the words
|
|
are not, so headers sized to the columns just collide. -->
|
|
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
|
|
<span class="flex items-center gap-1">
|
|
<span
|
|
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
|
|
aria-hidden="true"
|
|
></span> keep as target
|
|
</span>
|
|
<span class="flex items-center gap-1">
|
|
<span
|
|
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
|
|
aria-hidden="true"
|
|
></span> fold into it
|
|
</span>
|
|
</p>
|
|
<div class="flex flex-col p-1">
|
|
{#each c.members as m (m.slug)}
|
|
{@const isTarget = mergeTarget[key] === m.slug}
|
|
{@const Icon = kindMeta(m.kind).icon}
|
|
<label
|
|
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
|
|
? 'bg-primary/5'
|
|
: 'hover:bg-muted/40'}"
|
|
>
|
|
<input
|
|
type="radio"
|
|
class="size-3.5 shrink-0 accent-[var(--primary)]"
|
|
name="target-{key}"
|
|
aria-label="Keep "{m.title}" as the merge target"
|
|
checked={isTarget}
|
|
onchange={() =>
|
|
setMergeTarget(
|
|
key,
|
|
m.slug,
|
|
c.members.map((mm) => mm.slug)
|
|
)}
|
|
/>
|
|
<input
|
|
type="checkbox"
|
|
class="size-3.5 shrink-0 accent-[var(--primary)]"
|
|
aria-label="Fold "{m.title}" into the target"
|
|
disabled={isTarget}
|
|
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
|
|
onchange={() => toggleSource(key, m.slug)}
|
|
/>
|
|
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
|
|
<button
|
|
type="button"
|
|
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
|
|
? 'font-medium text-primary'
|
|
: ''}"
|
|
onclick={() => onSelect(m.slug)}
|
|
>
|
|
{m.title}
|
|
</button>
|
|
<span class="shrink-0 text-[10px] text-muted-foreground"
|
|
>{relativeTime(m.updated_at)}</span
|
|
>
|
|
{#if isTarget}
|
|
<Badge
|
|
variant="outline"
|
|
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
|
|
>
|
|
{/if}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
|
{#if tagsError}
|
|
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
|
{tagsError}
|
|
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
|
|
</p>
|
|
{/if}
|
|
{#if tags === null}
|
|
<div class="flex justify-center py-8"><Spinner /></div>
|
|
{:else}
|
|
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
|
|
<table class="w-full text-xs">
|
|
<thead>
|
|
<tr class="border-b text-left text-muted-foreground">
|
|
<th class="py-1 font-normal">Tag</th>
|
|
<th class="py-1 font-normal" colspan="2">Uses</th>
|
|
<th class="py-1 font-normal">Variants</th>
|
|
<th class="py-1"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each tags as t (t.tag)}
|
|
<tr class="border-b border-border/50">
|
|
<td class="py-1 pr-2">
|
|
{#if renaming === t.tag}
|
|
<div class="flex items-center gap-1">
|
|
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
|
|
<Button
|
|
size="sm"
|
|
class="h-6 px-1.5"
|
|
disabled={renameBusy}
|
|
onclick={() => confirmRename(t)}
|
|
>
|
|
<CheckIcon class="size-3" />
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
class="font-mono hover:underline"
|
|
onclick={() => startRename(t)}
|
|
>
|
|
{t.tag}
|
|
</button>
|
|
{/if}
|
|
</td>
|
|
<!-- tabular-nums here (unlike the overview's standalone
|
|
figures): these are a column that has to line up. -->
|
|
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
|
|
<td class="w-24 py-1 pr-3">
|
|
<!-- Magnitude, so: one hue, length-encoded, scaled to the
|
|
most-used tag. Recessive by design — it's a reading aid
|
|
down the column, not the subject of the table. -->
|
|
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
|
|
<span
|
|
class="block h-full rounded-full bg-primary/60"
|
|
style="width: {(t.uses / maxUses) * 100}%"
|
|
></span>
|
|
</span>
|
|
</td>
|
|
<td class="py-1 pr-2">
|
|
{#if t.split}
|
|
<span class="text-destructive">{t.variants.join(', ')}</span>
|
|
{:else}
|
|
<span class="text-muted-foreground">—</span>
|
|
{/if}
|
|
</td>
|
|
<td class="py-1 text-right">
|
|
{#if t.split}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-6 text-xs"
|
|
disabled={renameBusy}
|
|
onclick={() => normalize(t)}
|
|
>
|
|
Normalize
|
|
</Button>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
|
<p class="mb-2 text-xs text-muted-foreground">
|
|
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
|
|
navigation paths and easy to lose track of.
|
|
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
|
|
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
|
|
0} stale)
|
|
{/if}
|
|
</p>
|
|
{#if orphansError}
|
|
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
|
{orphansError}
|
|
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
|
|
>Retry</Button
|
|
>
|
|
</p>
|
|
{/if}
|
|
{#if orphans === null}
|
|
<div class="flex justify-center py-8"><Spinner /></div>
|
|
{:else if orphans.length === 0}
|
|
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
|
|
{:else}
|
|
<div class="flex flex-col gap-0.5">
|
|
{#each orphans as o (o.slug)}
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
|
|
onclick={() => onSelect(o.slug)}
|
|
>
|
|
<span class="min-w-0 flex-1 truncate">{o.title}</span>
|
|
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
|
|
>{r}</Badge
|
|
>{/each}
|
|
<span class="shrink-0 text-[10px] text-muted-foreground"
|
|
>{relativeTime(o.updated_at)}</span
|
|
>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Tabs.Content>
|
|
|
|
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
|
|
{#if trashError}
|
|
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
|
|
{trashError}
|
|
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
|
|
</p>
|
|
{/if}
|
|
{#if trash === null}
|
|
<div class="flex justify-center py-8"><Spinner /></div>
|
|
{:else if trash.length === 0}
|
|
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
|
|
{:else}
|
|
<div class="flex flex-col gap-0.5">
|
|
{#each trash as t (t.slug)}
|
|
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
|
|
<span class="min-w-0 flex-1 truncate">{t.title}</span>
|
|
<span class="shrink-0 text-[10px] text-muted-foreground">
|
|
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
class="h-6 shrink-0 gap-1 text-xs"
|
|
disabled={restoring === t.slug}
|
|
onclick={() => doRestore(t.slug)}
|
|
>
|
|
<RotateCcwIcon class="size-3" /> Restore
|
|
</Button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Tabs.Content>
|
|
</Tabs.Root>
|
|
</div>
|