feat(web): redesign Knowledge as an editable wiki
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>
This commit is contained in:
570
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal file
570
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal file
@@ -0,0 +1,570 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user