Files
legal-ai/web-ui/src/app/scripts/page.tsx
Chaim 4c52a42587
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(scripts): פיצול דף-הסקריפטים לפי תת-נושאים (#11)
scripts/SCRIPTS.md: הסקשן "סקריפטים פעילים" קובץ ל-7 כותרות-משנה ### לפי
תת-נושא (סוכנים-Paperclip / אחזור-embeddings / אחסון-DB / הלכות-פאנל-וסגנון /
תיקים-ומספור / פסיקה-קורפוס-ויומונים / תשתית-CI). כל 79 השורות נשמרו מילה-במילה
(רק סודרו תחת כותרות). הקטגוריזציה אנושית ומתוחזקת בקובץ-המקור.

web-ui /scripts: ה-parser מזהה כותרות ### בתוך הסקשן הפעיל ומקבץ לפיהן;
ארכיון/נמחקו כקבוצות נפרדות. הטבלה האחת הוחלפה בבלוקים מתקפלים (ScriptGroup)
עם כותרת-קלף + מונה, לפי מוקאפ 16-scripts.

מאושר דרך שער-העיצוב (Claude Design 16-scripts).

Invariant: G2 — SCRIPTS.md נשאר מקור-האמת היחיד לקטלוג; הקיבוץ נגזר מהכותרות
שבו, לא ממסלול-קטגוריזציה מקביל בקוד.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:05:05 +00:00

330 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronLeft } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { fetchScriptsCatalog } from "@/lib/api/scripts";
/*
* /scripts — catalog of everything under scripts/, rendered as the
* approved IA-redesign table (name mono · role · status chip · run/source
* ghost button).
*
* The single source of truth is still `scripts/SCRIPTS.md` (CLAUDE.md mandates
* updating it on every script change), served verbatim by
* GET /api/scripts/catalog. We parse its markdown tables into structured rows
* for display — editing remains git/Gitea only, so the per-row "מקור" button
* deep-links to the file in Gitea rather than inventing a run-from-UI mutation.
*/
type ScriptStatus = "active" | "once" | "archive" | "deleted";
type ScriptRow = {
name: string;
role: string;
status: ScriptStatus;
group: string;
};
const STATUS_LABEL: Record<ScriptStatus, string> = {
active: "פעיל",
once: "חד-פעמי",
archive: "ארכיון",
deleted: "נמחק",
};
const STATUS_TONE: Record<ScriptStatus, { wrap: string; dot: string }> = {
active: { wrap: "bg-success-bg text-success", dot: "bg-success" },
once: { wrap: "bg-info-bg text-info", dot: "bg-info" },
archive: { wrap: "bg-rule-soft text-ink-muted", dot: "bg-ink-muted" },
deleted: { wrap: "bg-danger-bg text-danger", dot: "bg-danger" },
};
// "חד-פעמי" / "one-shot" markers inside the Scheduled column of an active row.
const ONCE_RE = /חד-?פעמי|one-?shot|בוצע/;
// Archived/deleted rows get their own collapsible groups; active rows are
// grouped by the `### <sub-topic>` headers maintained in SCRIPTS.md (#11).
const ARCHIVE_GROUP = "ארכיון";
const DELETED_GROUP = "נמחקו";
/**
* Parse SCRIPTS.md markdown tables into typed rows. The file has three
* sections; the active section is further split into `### <sub-topic>` blocks.
* We read the first two columns (name + role), derive status from the section +
* scheduling note, and carry the sub-topic header as the row's display group.
*/
function parseScripts(md: string): ScriptRow[] {
const lines = md.split("\n");
const rows: ScriptRow[] = [];
let section: ScriptStatus = "active";
let category = "";
for (const raw of lines) {
const line = raw.trim();
if (line.startsWith("## ")) {
if (line.includes(".archive") || line.includes("הושלמו")) section = "archive";
else if (line.includes("נמחק")) section = "deleted";
else section = "active";
category = "";
continue;
}
if (line.startsWith("### ")) {
category = line.slice(4).trim();
continue;
}
if (!line.startsWith("|")) continue;
// skip header + separator rows
const cells = line
.split("|")
.slice(1, -1)
.map((c) => c.trim());
if (cells.length < 2) continue;
if (/^-+$/.test(cells[0].replace(/[-:]/g, "-"))) continue; // separator
if (cells[0] === "Script") continue; // header
if (!cells[0]) continue;
const name = cells[0].replace(/`/g, "");
if (!name) continue;
let status: ScriptStatus = section;
if (section === "active") {
const scheduled = cells[3] ?? "";
status = ONCE_RE.test(scheduled) ? "once" : "active";
}
// role: active = Purpose (col 2), archive = Original Purpose (col 1),
// deleted = Reason (col 1).
const role =
section === "active" ? cells[2] ?? cells[1] ?? "" : cells[1] ?? "";
const group =
section === "archive" ? ARCHIVE_GROUP
: section === "deleted" ? DELETED_GROUP
: category || "כללי";
rows.push({ name, role: stripMd(role), status, group });
}
return rows;
}
/** Group rows by their display group, preserving first-seen order. */
function groupScripts(rows: ScriptRow[]): { title: string; rows: ScriptRow[] }[] {
const order: string[] = [];
const byGroup = new Map<string, ScriptRow[]>();
for (const r of rows) {
if (!byGroup.has(r.group)) {
byGroup.set(r.group, []);
order.push(r.group);
}
byGroup.get(r.group)!.push(r);
}
return order.map((title) => ({ title, rows: byGroup.get(title)! }));
}
// Strip bold/inline-code markdown so the role reads as plain text in a cell.
function stripMd(s: string): string {
return s.replace(/\*\*/g, "").replace(/`/g, "");
}
function StatusChip({ status }: { status: ScriptStatus }) {
const tone = STATUS_TONE[status];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-[3px] text-[0.75rem] font-semibold ${tone.wrap}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} aria-hidden />
{STATUS_LABEL[status]}
</span>
);
}
function ScriptTable({ rows, giteaBase }: { rows: ScriptRow[]; giteaBase: string | null }) {
return (
<Table>
<TableHeader>
<TableRow className="bg-parchment hover:bg-parchment border-rule">
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
שם הסקריפט
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
תפקיד
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
סטטוס
</TableHead>
<TableHead className="text-end text-[0.75rem] font-semibold text-ink-muted px-5 py-3">
פעולה
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((s) => {
const disabled = s.status === "archive" || s.status === "deleted";
const href = giteaBase
? `${giteaBase.replace(/\/$/, "")}/${s.name}`
: null;
return (
<TableRow
key={s.name}
className="border-rule-soft hover:bg-gold-wash align-middle"
>
<TableCell className="px-5 py-3.5">
<code
className="font-mono text-[0.81rem] font-semibold text-navy"
dir="ltr"
>
{s.name}
</code>
</TableCell>
<TableCell className="px-5 py-3.5 text-ink-soft text-[0.84rem] leading-snug max-w-xl whitespace-normal">
<span className="line-clamp-2">{s.role}</span>
</TableCell>
<TableCell className="px-5 py-3.5">
<StatusChip status={s.status} />
</TableCell>
<TableCell className="px-5 py-3.5 text-end">
{disabled || !href ? (
<button
type="button"
disabled
className="rounded-lg border border-rule-soft px-4 py-1.5 text-[0.81rem] font-semibold text-ink-muted cursor-default"
>
מקור
</button>
) : (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg border border-rule px-4 py-1.5 text-[0.81rem] font-semibold text-gold-deep hover:bg-gold-wash hover:border-gold transition-colors"
>
מקור
</a>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
/** Collapsible sub-topic block (#11, mockup 16): parchment header with a
* chevron + title + count, and the group's table beneath. */
function ScriptGroup({
title, rows, giteaBase, defaultOpen,
}: { title: string; rows: ScriptRow[]; giteaBase: string | null; defaultOpen: boolean }) {
const [open, setOpen] = useState(defaultOpen);
return (
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className={`flex w-full items-center gap-2.5 bg-parchment px-5 py-3 text-start transition-colors hover:bg-gold-wash/50 ${
open ? "border-b border-rule" : ""
}`}
>
{open ? (
<ChevronDown className="size-4 text-ink-muted" aria-hidden />
) : (
<ChevronLeft className="size-4 text-ink-muted" aria-hidden />
)}
<h2 className="m-0 text-[0.95rem] font-semibold text-navy">{title}</h2>
<span className="ms-auto rounded-full bg-rule-soft px-2.5 py-0.5 text-[0.78rem] text-ink-muted tabular-nums">
{rows.length}
</span>
</button>
{open ? <ScriptTable rows={rows} giteaBase={giteaBase} /> : null}
</Card>
);
}
export default function ScriptsPage() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["scripts-catalog"],
queryFn: ({ signal }) => fetchScriptsCatalog(signal),
});
const rows = useMemo(
() => (data?.content ? parseScripts(data.content) : []),
[data],
);
const groups = useMemo(() => groupScripts(rows), [rows]);
const lastModified =
data?.last_modified != null
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
const giteaBase = data?.gitea_url ?? null;
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">סקריפטים</h1>
<p className="text-sm text-ink-muted mt-1 max-w-2xl">
סקריפטי-תחזוקה ותפעול. מקור-האמת הוא{" "}
<code className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem]">
scripts/SCRIPTS.md
</code>{" "}
עריכה דרך git, לא מכאן.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{isLoading ? (
<Card className="bg-surface border-rule px-6 py-5 text-sm text-ink-muted">
טוען קטלוג
</Card>
) : isError ? (
<Card className="bg-danger-bg border-danger/40 px-6 py-5 text-sm text-danger">
שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"}
</Card>
) : (
<div className="space-y-3.5">
{groups.map((g) => (
<ScriptGroup
key={g.title}
title={g.title}
rows={g.rows}
giteaBase={giteaBase}
defaultOpen={g.title !== ARCHIVE_GROUP && g.title !== DELETED_GROUP}
/>
))}
{lastModified ? (
<p className="px-1 pt-1 text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}
</p>
) : null}
</div>
)}
</section>
</AppShell>
);
}