היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH.
מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות".
Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה:
- `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/
טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude)
ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal.
- `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}`
מגיש מכל תיקייה עם media-type לפי סיומת.
Frontend:
- `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl.
- `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה
ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות".
אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד).
Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
useCaseFiles,
|
||
useCaseFileText,
|
||
caseFileUrl,
|
||
type CaseFileFolder,
|
||
} from "@/lib/api/case-files";
|
||
import { Markdown } from "@/components/ui/markdown";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { formatDateTime } from "@/lib/format-date";
|
||
import {
|
||
Folder,
|
||
FileText,
|
||
File as FileIcon,
|
||
Download,
|
||
ChevronDown,
|
||
Loader2,
|
||
FolderOpen,
|
||
} from "lucide-react";
|
||
|
||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||
|
||
const FOLDER_LABELS: Record<string, string> = {
|
||
research: "מחקר וטיוטות",
|
||
originals: "מסמכי מקור",
|
||
extracted: "טקסט מחולץ (OCR)",
|
||
proofread: "לאחר הגהה",
|
||
drafts: "טיוטות DOCX",
|
||
backup: "גיבויים",
|
||
};
|
||
|
||
// Extensions the inline viewer can render (everything else → download only).
|
||
const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]);
|
||
|
||
function fileExt(filename: string): string {
|
||
return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
|
||
}
|
||
|
||
function folderLabel(f: CaseFileFolder): string {
|
||
return FOLDER_LABELS[f.name] ?? f.name;
|
||
}
|
||
|
||
function fmtSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
const EXT_TONE: Record<string, string> = {
|
||
md: "bg-teal-100 text-teal-700",
|
||
markdown: "bg-teal-100 text-teal-700",
|
||
txt: "bg-blue-100 text-blue-700",
|
||
json: "bg-blue-100 text-blue-700",
|
||
pdf: "bg-red-100 text-red-700",
|
||
docx: "bg-indigo-100 text-indigo-700",
|
||
doc: "bg-indigo-100 text-indigo-700",
|
||
};
|
||
function extTone(ext: string): string {
|
||
return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600";
|
||
}
|
||
|
||
type Selected = { folderKey: string; filename: string };
|
||
|
||
/* ── Component ───────────────────────────────────────────────── */
|
||
|
||
export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) {
|
||
const { data, isLoading, error } = useCaseFiles(caseNumber);
|
||
const folders = useMemo(() => data?.folders ?? [], [data?.folders]);
|
||
|
||
const totalFiles = folders.reduce((n, f) => n + f.files.length, 0);
|
||
|
||
// Default selection derived (not via an effect): a text file in research,
|
||
// else any text file. The chair's explicit pick overrides it.
|
||
const defaultSelected = useMemo<Selected | null>(() => {
|
||
const pick = (
|
||
pred: (f: CaseFileFolder, name: string) => boolean,
|
||
): Selected | null => {
|
||
for (const f of folders)
|
||
for (const file of f.files)
|
||
if (pred(f, file.filename))
|
||
return { folderKey: f.key, filename: file.filename };
|
||
return null;
|
||
};
|
||
return (
|
||
pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ??
|
||
pick((_f, name) => TEXT_EXT.has(fileExt(name)))
|
||
);
|
||
}, [folders]);
|
||
|
||
const [userSelected, setUserSelected] = useState<Selected | null>(null);
|
||
const selected = userSelected ?? defaultSelected;
|
||
|
||
// A folder is open if explicitly toggled; otherwise the folder of the current
|
||
// selection (or the first non-empty folder) is open by default.
|
||
const defaultOpenKey =
|
||
selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null;
|
||
const [openOverrides, setOpenOverrides] = useState<Map<string, boolean>>(
|
||
new Map(),
|
||
);
|
||
const isOpen = (key: string) =>
|
||
openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey;
|
||
|
||
const selExt = selected ? fileExt(selected.filename) : "";
|
||
const selIsText = TEXT_EXT.has(selExt);
|
||
const {
|
||
data: fileText,
|
||
isLoading: textLoading,
|
||
error: textError,
|
||
} = useCaseFileText(
|
||
caseNumber,
|
||
selIsText ? selected?.folderKey : undefined,
|
||
selIsText ? selected?.filename : undefined,
|
||
);
|
||
|
||
const toggleFolder = (key: string) => {
|
||
const next = !isOpen(key);
|
||
setOpenOverrides((prev) => new Map(prev).set(key, next));
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-10 text-ink-faint">
|
||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||
<span>טוען קבצים...</span>
|
||
</div>
|
||
);
|
||
}
|
||
if (error) {
|
||
return (
|
||
<div className="text-center py-10 text-red-500 text-sm">
|
||
שגיאה בטעינת קבצי-התיק
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="border border-rule rounded-xl overflow-hidden">
|
||
{/* header */}
|
||
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||
<FolderOpen className="w-4 h-4 text-gold-deep" />
|
||
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
|
||
<span className="text-[11px] text-ink-muted ms-auto">
|
||
כל תתי-התיקיות · {totalFiles} קבצים
|
||
</span>
|
||
</div>
|
||
|
||
<div className="grid md:grid-cols-[320px_1fr]">
|
||
{/* file tree */}
|
||
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
|
||
{folders.map((f) => {
|
||
const open = isOpen(f.key);
|
||
return (
|
||
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleFolder(f.key)}
|
||
aria-expanded={open}
|
||
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
|
||
>
|
||
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
|
||
<span className="text-[0.78rem] font-bold text-navy">
|
||
{folderLabel(f)}
|
||
</span>
|
||
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
|
||
{f.path}
|
||
</span>
|
||
<span
|
||
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
|
||
>
|
||
{f.files.length || "ריק"}
|
||
</span>
|
||
<ChevronDown
|
||
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||
/>
|
||
</button>
|
||
|
||
{open && f.files.length > 0 && (
|
||
<div className="pb-1 bg-parchment/50">
|
||
{f.files.map((file) => {
|
||
const ext = fileExt(file.filename);
|
||
const isSel =
|
||
selected?.folderKey === f.key &&
|
||
selected?.filename === file.filename;
|
||
const viewable = TEXT_EXT.has(ext);
|
||
return (
|
||
<div
|
||
key={file.filename}
|
||
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
|
||
onClick={() =>
|
||
viewable
|
||
? setUserSelected({
|
||
folderKey: f.key,
|
||
filename: file.filename,
|
||
})
|
||
: undefined
|
||
}
|
||
>
|
||
<span
|
||
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
|
||
>
|
||
{viewable ? (
|
||
<FileText className="w-3.5 h-3.5" />
|
||
) : (
|
||
<FileIcon className="w-3.5 h-3.5" />
|
||
)}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[11.5px] font-medium text-ink-soft truncate">
|
||
{file.filename}
|
||
</div>
|
||
<div className="text-[9.5px] text-ink-light">
|
||
{fmtSize(file.size)} ·{" "}
|
||
{formatDateTime(file.modified_at * 1000)}
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={caseFileUrl(caseNumber, f.key, file.filename)}
|
||
download={file.filename}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0"
|
||
title="הורדה"
|
||
>
|
||
<Download className="w-3.5 h-3.5" />
|
||
</a>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{folders.length === 0 && (
|
||
<div className="text-center py-10 text-ink-faint text-sm">
|
||
אין קבצים בתיק.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* viewer */}
|
||
<div className="min-w-0 flex flex-col">
|
||
{!selected ? (
|
||
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
|
||
<FileText className="w-8 h-8 mb-2 opacity-40" />
|
||
בחר קובץ טקסט מהרשימה לצפייה
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
|
||
<span
|
||
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
|
||
>
|
||
<FileText className="w-4 h-4" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-[12.5px] font-bold text-navy truncate">
|
||
{selected.filename}
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={caseFileUrl(
|
||
caseNumber,
|
||
selected.folderKey,
|
||
selected.filename,
|
||
)}
|
||
download={selected.filename}
|
||
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
|
||
>
|
||
<Download className="w-3.5 h-3.5" />
|
||
הורדה
|
||
</a>
|
||
</div>
|
||
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
|
||
{!selIsText ? (
|
||
<div className="text-center py-12 text-sm text-ink-muted">
|
||
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge> —
|
||
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
|
||
</div>
|
||
) : textLoading ? (
|
||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||
טוען תוכן...
|
||
</div>
|
||
) : textError ? (
|
||
<div className="text-center py-12 text-red-500 text-sm">
|
||
שגיאה בטעינת תוכן הקובץ
|
||
</div>
|
||
) : selExt === "md" || selExt === "markdown" ? (
|
||
<Markdown content={fileText ?? ""} />
|
||
) : (
|
||
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
|
||
{fileText}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|