feat(scripts): פיצול דף-הסקריפטים לפי תת-נושאים (#11)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

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>
This commit is contained in:
2026-06-16 19:05:05 +00:00
parent 2ccc55d35a
commit 4c52a42587
2 changed files with 222 additions and 109 deletions

View File

@@ -1,8 +1,9 @@
"use client";
import { useMemo } from "react";
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";
@@ -34,6 +35,7 @@ type ScriptRow = {
name: string;
role: string;
status: ScriptStatus;
group: string;
};
const STATUS_LABEL: Record<ScriptStatus, string> = {
@@ -53,15 +55,22 @@ const STATUS_TONE: Record<ScriptStatus, { wrap: string; dot: string }> = {
// "חד-פעמי" / "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 with different shapes; we read the first two columns of each
* (name + role) and derive status from the section + scheduling note.
* 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();
@@ -69,6 +78,11 @@ function parseScripts(md: string): ScriptRow[] {
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;
@@ -95,11 +109,30 @@ function parseScripts(md: string): ScriptRow[] {
const role =
section === "active" ? cells[2] ?? cells[1] ?? "" : cells[1] ?? "";
rows.push({ name, role: stripMd(role), status });
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, "");
@@ -117,6 +150,109 @@ function StatusChip({ status }: { status: ScriptStatus }) {
);
}
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"],
@@ -127,6 +263,7 @@ export default function ScriptsPage() {
() => (data?.content ? parseScripts(data.content) : []),
[data],
);
const groups = useMemo(() => groupScripts(rows), [rows]);
const lastModified =
data?.last_modified != null
@@ -169,80 +306,22 @@ export default function ScriptsPage() {
שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"}
</Card>
) : (
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<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.5">
שם הסקריפט
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
תפקיד
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
סטטוס
</TableHead>
<TableHead className="text-end text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
פעולה
</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>
<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-5 py-3 border-t border-rule text-xs text-ink-muted">
<p className="px-1 pt-1 text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}
</p>
) : null}
</Card>
</div>
)}
</section>
</AppShell>