mirror of
https://github.com/willchen96/mike.git
synced 2026-06-18 21:15:13 +02:00
376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { forwardRef, useImperativeHandle, useRef } from "react";
|
|
import { Loader2, Plus, Table2, Upload } from "lucide-react";
|
|
import type {
|
|
ColumnConfig,
|
|
Document,
|
|
TabularCell,
|
|
} from "../shared/types";
|
|
import { TabularCell as TabularCellComponent } from "./TabularCell";
|
|
import { TREditColumnMenu } from "./TREditColumnMenu";
|
|
|
|
const SKELETON_COLS = 4;
|
|
const SKELETON_ROWS = 5;
|
|
|
|
const COL_W = "w-[300px] shrink-0";
|
|
const DOC_COL_W = "w-[332px] shrink-0";
|
|
|
|
// Pixel widths matching the CSS constants above
|
|
const DOC_COL_W_PX = 332;
|
|
const DATA_COL_W_PX = 300;
|
|
const STICKY_LEFT_PX = DOC_COL_W_PX;
|
|
|
|
export interface TRTableHandle {
|
|
scrollToCell: (colIdx: number, rowIdx: number) => void;
|
|
}
|
|
|
|
interface Props {
|
|
loading: boolean;
|
|
columns: ColumnConfig[];
|
|
documents: Document[];
|
|
cells: TabularCell[];
|
|
savingColumn: boolean;
|
|
savingColumnsConfig: boolean;
|
|
selectedDocIds: string[];
|
|
uploadingFilenames?: string[];
|
|
dragOverFiles?: boolean;
|
|
highlightedCell?: { colIdx: number; rowIdx: number } | null;
|
|
onSelectionChange: (ids: string[]) => void;
|
|
onExpand: (cell: TabularCell) => void;
|
|
onCitationClick: (cell: TabularCell, page: number, quote: string) => void;
|
|
onUpdateColumn: (col: ColumnConfig) => void;
|
|
onDeleteColumn: (colIndex: number) => void;
|
|
onAddColumn: () => void;
|
|
onAddDocuments: () => void;
|
|
}
|
|
|
|
export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|
{
|
|
loading,
|
|
columns,
|
|
documents,
|
|
cells,
|
|
savingColumn,
|
|
savingColumnsConfig,
|
|
selectedDocIds,
|
|
uploadingFilenames = [],
|
|
dragOverFiles = false,
|
|
highlightedCell,
|
|
onSelectionChange,
|
|
onExpand,
|
|
onCitationClick,
|
|
onUpdateColumn,
|
|
onDeleteColumn,
|
|
onAddColumn,
|
|
onAddDocuments,
|
|
},
|
|
ref,
|
|
) {
|
|
const stickyCellBg = "bg-[#fafbfc]";
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
|
const totalContentWidth =
|
|
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
scrollToCell(colIdx: number, rowIdx: number) {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
// Vertical: find actual row via DOM (handles variable row heights)
|
|
const allRows = container.querySelectorAll<HTMLElement>(
|
|
":scope > div.flex.min-w-full",
|
|
);
|
|
const targetRow = allRows[rowIdx];
|
|
if (targetRow) {
|
|
container.scrollTo({
|
|
top: Math.max(0, targetRow.offsetTop - 40),
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
|
|
// Horizontal: fixed column widths — center the target column in view
|
|
const targetScrollLeft =
|
|
STICKY_LEFT_PX +
|
|
colIdx * DATA_COL_W_PX -
|
|
container.clientWidth / 2 +
|
|
DATA_COL_W_PX / 2;
|
|
container.scrollLeft = Math.max(0, targetScrollLeft);
|
|
},
|
|
}));
|
|
|
|
function getCell(docId: string, colIdx: number) {
|
|
return cells.find(
|
|
(c) => c.document_id === docId && c.column_index === colIdx,
|
|
);
|
|
}
|
|
|
|
const allSelected =
|
|
documents.length > 0 &&
|
|
documents.every((d) => selectedDocIds.includes(d.id));
|
|
const someSelected =
|
|
!allSelected && documents.some((d) => selectedDocIds.includes(d.id));
|
|
|
|
function toggleAll() {
|
|
if (allSelected) {
|
|
onSelectionChange([]);
|
|
} else {
|
|
onSelectionChange(documents.map((d) => d.id));
|
|
}
|
|
}
|
|
|
|
function toggleDoc(id: string) {
|
|
if (selectedDocIds.includes(id)) {
|
|
onSelectionChange(selectedDocIds.filter((x) => x !== id));
|
|
} else {
|
|
onSelectionChange([...selectedDocIds, id]);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex-1 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex border-b border-gray-200">
|
|
<div
|
|
className={`${DOC_COL_W} flex items-center gap-4 border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
|
|
>
|
|
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
|
<span>Document</span>
|
|
</div>
|
|
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={`${COL_W} border-r border-gray-200 p-2`}
|
|
>
|
|
<div className="h-4 w-28 rounded bg-gray-100 animate-pulse" />
|
|
</div>
|
|
))}
|
|
<div className="flex-1" />
|
|
</div>
|
|
{/* Rows */}
|
|
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
|
|
<div
|
|
key={row}
|
|
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "" : "bg-gray-50/50"}`}
|
|
>
|
|
<div className={`${DOC_COL_W} flex items-center gap-4 py-2 pl-4 pr-2`}>
|
|
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
|
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
|
|
</div>
|
|
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
|
|
<div key={col} className={`${COL_W} p-2`}>
|
|
<div className="h-4 rounded bg-gray-100 animate-pulse" />
|
|
</div>
|
|
))}
|
|
<div className="flex-1" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (
|
|
columns.length === 0 &&
|
|
documents.length === 0 &&
|
|
uploadingFilenames.length === 0
|
|
) {
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<div className="flex items-center border-b border-gray-200">
|
|
<div
|
|
className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`}
|
|
>
|
|
Document
|
|
</div>
|
|
<div className="flex-1" />
|
|
</div>
|
|
<div className="relative flex min-h-0 flex-1">
|
|
{dragOverFiles && (
|
|
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
|
|
)}
|
|
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
|
|
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
|
|
<p className="text-2xl font-medium font-serif text-gray-900">
|
|
Tabular Review
|
|
</p>
|
|
<p className="mt-1 text-xs text-gray-400 text-left">
|
|
Add columns and documents to get started.
|
|
</p>
|
|
<div className="mt-4 flex items-center gap-2">
|
|
<button
|
|
onClick={onAddColumn}
|
|
className="inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 shadow-md"
|
|
>
|
|
+ Add Columns
|
|
</button>
|
|
<button
|
|
onClick={onAddDocuments}
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors shadow-sm"
|
|
>
|
|
<Upload className="h-3.5 w-3.5" />
|
|
Add Documents
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-1 flex-col overflow-auto"
|
|
ref={scrollContainerRef}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={`sticky top-0 z-20 flex h-8 ${stickyCellBg}`}
|
|
style={{ minWidth: totalContentWidth }}
|
|
>
|
|
<div
|
|
className={`sticky left-0 z-30 ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = someSelected;
|
|
}}
|
|
onChange={toggleAll}
|
|
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
|
/>
|
|
<span>Document</span>
|
|
</div>
|
|
{columns.map((col) => (
|
|
<div
|
|
key={col.index}
|
|
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="truncate">{col.name}</span>
|
|
<TREditColumnMenu
|
|
column={col}
|
|
disabled={savingColumn || savingColumnsConfig}
|
|
onSave={onUpdateColumn}
|
|
onDelete={onDeleteColumn}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8">
|
|
<button
|
|
onClick={onAddColumn}
|
|
disabled={savingColumn || savingColumnsConfig}
|
|
className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rows */}
|
|
<div className="relative min-h-0 flex-1">
|
|
{dragOverFiles && (
|
|
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
|
|
)}
|
|
{uploadingFilenames.map((filename) => (
|
|
<div
|
|
key={`uploading-${filename}`}
|
|
className="flex"
|
|
style={{ minWidth: totalContentWidth }}
|
|
>
|
|
<div
|
|
className={`sticky left-0 z-[60] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-400 flex items-center gap-4`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
disabled
|
|
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
|
|
/>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
|
<span className="line-clamp-1" title={filename}>
|
|
{filename}
|
|
</span>
|
|
</div>
|
|
{sortedColumns.map((col) => (
|
|
<div
|
|
key={col.index}
|
|
className={`${COL_W} border-b border-r border-gray-200 p-2`}
|
|
>
|
|
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse" />
|
|
</div>
|
|
))}
|
|
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
|
|
</div>
|
|
))}
|
|
{documents.map((doc, docIdx) => {
|
|
const baseRowBg =
|
|
docIdx % 2 === 0 ? stickyCellBg : "bg-gray-50";
|
|
const rowBg = selectedDocIds.includes(doc.id)
|
|
? "bg-gray-100"
|
|
: baseRowBg;
|
|
return (
|
|
<div
|
|
key={doc.id}
|
|
className={`flex ${rowBg}`}
|
|
style={{ minWidth: totalContentWidth }}
|
|
>
|
|
<div
|
|
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center gap-4 ${rowBg}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedDocIds.includes(doc.id)}
|
|
onChange={() => toggleDoc(doc.id)}
|
|
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
|
/>
|
|
<span
|
|
className="line-clamp-1"
|
|
title={doc.filename}
|
|
>
|
|
{doc.filename}
|
|
</span>
|
|
</div>
|
|
{columns.map((col) => {
|
|
const cell = getCell(doc.id, col.index);
|
|
const colPos = sortedColumns.findIndex(
|
|
(c) => c.index === col.index,
|
|
);
|
|
const isHighlighted =
|
|
highlightedCell?.colIdx === colPos &&
|
|
highlightedCell?.rowIdx === docIdx;
|
|
return (
|
|
<div
|
|
key={col.index}
|
|
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
|
|
>
|
|
{cell && (
|
|
<TabularCellComponent
|
|
cell={cell}
|
|
column={col}
|
|
onExpand={() => onExpand(cell)}
|
|
onCitationClick={(
|
|
page,
|
|
quote,
|
|
) =>
|
|
onCitationClick(
|
|
cell,
|
|
page,
|
|
quote,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|