Port the remaining read views from the vanilla UI to Next.js:
- /diagnostics — system health snapshot (DB connected, table counts,
active tasks, failed and stuck documents). Uses the existing
/api/system/diagnostics payload with a 10s refetchInterval so the
page self-updates while the user watches.
- /skills — Paperclip skill inventory with sync status (DB-only,
disk-only, synced, not-synced) as a card grid driven by
/api/admin/skills.
- /training — Dafna's style portrait as three tabs on one page:
* Report: corpus KPIs + CSS conic-gradient subject donut
(SubjectDonut ported from index.html renderHero) + horizontal
anatomy bars + top-12 signature phrases.
* Corpus: TanStack Table of style_corpus rows with an inline
delete mutation (useDeleteCorpusEntry invalidates both the
corpus list and the style report so KPIs update).
* Compare: two-decision selector backed by /api/training/compare,
side-by-side panels plus shared / only-A / only-B pattern
lists.
New API modules: lib/api/system.ts, lib/api/skills.ts,
lib/api/training.ts. All three use TanStack Query with staleTime
profiles tuned per endpoint (10s for diagnostics, 30s for skills,
60s for training reports).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { Plug, HardDrive, Database, FileText } from "lucide-react";
|
||
import { AppShell } from "@/components/app-shell";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { useSkills, type Skill } from "@/lib/api/skills";
|
||
|
||
function formatSize(bytes: number | null) {
|
||
if (bytes == null) return "—";
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function statusBadge(s: Skill) {
|
||
if (s.not_in_db) {
|
||
return <Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">לא סונכרן</Badge>;
|
||
}
|
||
if (s.db_markdown_chars > 0 && s.disk_exists) {
|
||
return <Badge variant="outline" className="bg-success-bg text-success border-success/40">מסונכרן</Badge>;
|
||
}
|
||
if (s.db_markdown_chars > 0) {
|
||
return <Badge variant="outline" className="bg-info-bg text-info border-info/40">DB בלבד</Badge>;
|
||
}
|
||
return <Badge variant="outline">לא ידוע</Badge>;
|
||
}
|
||
|
||
function SkillCard({ skill }: { skill: Skill }) {
|
||
const fileCount = skill.file_inventory?.length ?? 0;
|
||
return (
|
||
<Card className="bg-surface border-rule shadow-sm hover:shadow-md transition-shadow">
|
||
<CardContent className="px-5 py-4">
|
||
<div className="flex items-start justify-between gap-3 mb-2">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Plug className="w-4 h-4 text-gold-deep shrink-0" />
|
||
<div className="min-w-0">
|
||
<h3 className="text-navy font-semibold text-base mb-0 truncate">
|
||
{skill.name || skill.slug}
|
||
</h3>
|
||
<code className="text-[0.72rem] text-ink-muted tabular-nums">
|
||
{skill.slug}
|
||
</code>
|
||
</div>
|
||
</div>
|
||
{statusBadge(skill)}
|
||
</div>
|
||
<dl className="grid grid-cols-3 gap-2 text-[0.72rem] text-ink-muted mt-3">
|
||
<div className="flex items-center gap-1">
|
||
<FileText className="w-3 h-3" />
|
||
<span className="tabular-nums">{fileCount}</span>
|
||
<span>קבצים</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<Database className="w-3 h-3" />
|
||
<span className="tabular-nums">
|
||
{(skill.db_markdown_chars / 1000).toFixed(1)}K
|
||
</span>
|
||
<span>תווים</span>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<HardDrive className="w-3 h-3" />
|
||
<span className="tabular-nums">
|
||
{formatSize(skill.disk_skill_md_bytes)}
|
||
</span>
|
||
</div>
|
||
</dl>
|
||
{skill.updated_at && (
|
||
<p className="text-[0.7rem] text-ink-light mt-2">
|
||
עודכן: {new Date(skill.updated_at).toLocaleDateString("he-IL")}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export default function SkillsPage() {
|
||
const { data, isPending, error } = useSkills();
|
||
|
||
return (
|
||
<AppShell>
|
||
<section className="space-y-6">
|
||
<header>
|
||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||
<span aria-hidden> · </span>
|
||
<span className="text-navy">מיומנויות</span>
|
||
</nav>
|
||
<h1 className="text-navy mb-0">מיומנויות Paperclip</h1>
|
||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||
רשימת ה-skills המותקנים במערכת Paperclip ומצב הסנכרון שלהם בין ה-DB
|
||
לדיסק.
|
||
</p>
|
||
</header>
|
||
|
||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||
|
||
{error ? (
|
||
<Card className="bg-danger-bg border-danger/40">
|
||
<CardContent className="px-6 py-6 text-center text-danger">
|
||
{error.message}
|
||
</CardContent>
|
||
</Card>
|
||
) : isPending ? (
|
||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{[...Array(6)].map((_, i) => (
|
||
<Skeleton key={i} className="h-32 w-full rounded-lg" />
|
||
))}
|
||
</div>
|
||
) : data?.length === 0 ? (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||
<div className="text-gold text-3xl mb-2" aria-hidden>❦</div>
|
||
אין skills מותקנים
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{data?.map((s) => <SkillCard key={s.slug} skill={s} />)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</AppShell>
|
||
);
|
||
}
|