mike/frontend/src/app/components/tabular/AddColumnModal.tsx
2026-04-29 19:49:06 +02:00

522 lines
26 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 { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Plus, X } from "lucide-react";
import type { ColumnConfig, ColumnFormat } from "../shared/types";
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
import { TAG_COLORS } from "./pillUtils";
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface ColumnDraft {
name: string;
prompt: string;
format: ColumnFormat;
tags: string[];
tagInput: string;
}
const EMPTY_DRAFT: ColumnDraft = {
name: "",
prompt: "",
format: "text",
tags: [],
tagInput: "",
};
interface Props {
open: boolean;
existingCount: number;
onClose: () => void;
onAdd: (cols: ColumnConfig[]) => void;
editingColumn?: ColumnConfig;
onSave?: (col: ColumnConfig) => void;
onDelete?: () => void;
}
export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) {
const isEditing = !!editingColumn;
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]);
const [generatingIndices, setGeneratingIndices] = useState<number[]>([]);
const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>(
null,
);
const presetsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
if (editingColumn) {
setColumns([{
name: editingColumn.name,
prompt: editingColumn.prompt,
format: editingColumn.format ?? "text",
tags: editingColumn.tags ?? [],
tagInput: "",
}]);
} else {
setColumns([{ ...EMPTY_DRAFT }]);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (presetsOpenIndex === null) return;
function handleClickOutside(e: MouseEvent) {
if (
presetsRef.current &&
!presetsRef.current.contains(e.target as Node)
) {
setPresetsOpenIndex(null);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [presetsOpenIndex]);
if (!open) return null;
function resetForm() {
setColumns([{ ...EMPTY_DRAFT }]);
setGeneratingIndices([]);
}
function handleClose() {
resetForm();
onClose();
}
function updateColumn(index: number, patch: Partial<ColumnDraft>) {
setColumns((prev) =>
prev.map((col, i) => (i === index ? { ...col, ...patch } : col)),
);
}
function addAnotherColumn() {
setColumns((prev) => [...prev, { ...EMPTY_DRAFT }]);
}
function removeColumn(index: number) {
setColumns((prev) =>
prev.length === 1
? [{ ...EMPTY_DRAFT }]
: prev.filter((_, i) => i !== index),
);
}
function commitTag(index: number) {
setColumns((prev) => {
const col = prev[index]!;
const tag = col.tagInput.trim();
if (!tag || col.tags.includes(tag)) {
return prev.map((c, i) =>
i === index ? { ...c, tagInput: "" } : c,
);
}
return prev.map((c, i) =>
i === index
? { ...c, tags: [...c.tags, tag], tagInput: "" }
: c,
);
});
}
function handleTagKeyDown(
e: React.KeyboardEvent<HTMLInputElement>,
index: number,
) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitTag(index);
} else if (
e.key === "Backspace" &&
columns[index]!.tagInput === "" &&
columns[index]!.tags.length > 0
) {
updateColumn(index, {
tags: columns[index]!.tags.slice(0, -1),
});
}
}
async function autoGeneratePrompt(index: number) {
const title = columns[index]?.name?.trim() ?? "";
if (!title) return;
setGeneratingIndices((prev) => [...prev, index]);
try {
const col = columns[index]!;
const { prompt } = await generateTabularColumnPrompt(title, {
format: col.format,
tags: col.format === "tag" ? col.tags : undefined,
});
updateColumn(index, { prompt });
} finally {
setGeneratingIndices((prev) => prev.filter((v) => v !== index));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (columns.some((col) => !col.name.trim() || !col.prompt.trim()))
return;
if (isEditing && onSave && editingColumn) {
const col = columns[0]!;
onSave({
index: editingColumn.index,
name: col.name.trim(),
prompt: col.prompt.trim(),
format: col.format,
tags: col.format === "tag" ? col.tags : undefined,
});
} else {
onAdd(
columns.map((col, i) => ({
index: existingCount + i,
name: col.name.trim(),
prompt: col.prompt.trim(),
format: col.format,
tags: col.format === "tag" ? col.tags : undefined,
})),
);
}
resetForm();
onClose();
}
return createPortal(
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-2">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<span>Tabular Review</span>
<span></span>
<span>{isEditing ? "Edit column" : "New column"}</span>
</div>
<button
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col min-h-0 flex-1"
>
{/* Body */}
<div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1">
{columns.map((column, index) => (
<div
key={index}
className="rounded-xl border border-gray-200 p-4"
>
{/* Name row */}
<div className="flex items-start gap-2">
{/* Input + preset dropdown anchored to this wrapper */}
<div
className="relative flex flex-1 items-start"
ref={
presetsOpenIndex === index
? presetsRef
: null
}
>
<input
type="text"
value={column.name}
onChange={(e) => {
const name = e.target.value;
const preset =
getPresetConfig(name);
updateColumn(index, {
name,
...(preset
? {
prompt: preset.prompt,
format: preset.format,
tags:
preset.tags ??
[],
tagInput: "",
}
: {}),
});
}}
placeholder="Column name"
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
autoFocus={index === 0}
/>
<button
type="button"
onClick={() =>
setPresetsOpenIndex(
presetsOpenIndex === index
? null
: index,
)
}
title="Column presets"
className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${presetsOpenIndex === index ? "rotate-180" : ""}`}
/>
</button>
{presetsOpenIndex === index && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-64">
<button
type="button"
onClick={() => {
updateColumn(index, { ...EMPTY_DRAFT });
setPresetsOpenIndex(null);
}}
className="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 transition-colors border-b border-gray-100"
>
No Preset
</button>
{PROMPT_PRESETS.map(
(preset) => (
<button
key={preset.name}
type="button"
onClick={() => {
updateColumn(
index,
{
name: preset.name,
prompt: preset.prompt,
format: preset.format,
tags:
preset.tags ??
[],
tagInput:
"",
},
);
setPresetsOpenIndex(
null,
);
}}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
{preset.name}
</button>
),
)}
</div>
)}
</div>
{columns.length > 1 && (
<button
type="button"
onClick={() => removeColumn(index)}
className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Format */}
<div className="mt-4">
<label className="text-sm font-medium text-gray-500">
Format
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
<span className="flex items-center gap-2">
{(() => {
const Icon = formatIcon(
column.format,
);
return (
<Icon className="h-3.5 w-3.5 text-gray-400" />
);
})()}
{formatLabel(column.format)}
</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="z-[200]"
>
<DropdownMenuRadioGroup
value={column.format}
onValueChange={(v) =>
updateColumn(index, {
format: v as ColumnFormat,
tags: [],
tagInput: "",
})
}
>
{FORMAT_OPTIONS.map((o) => (
<DropdownMenuRadioItem
key={o.value}
value={o.value}
>
<o.icon className="h-3.5 w-3.5 text-gray-400" />
{o.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tag input */}
{column.format === "tag" && (
<div className="mt-3">
<label className="text-sm font-medium text-gray-500">
Tags
</label>
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
{column.tags.map((tag, tagIdx) => (
<span
key={tag}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
>
{tag}
<button
type="button"
onClick={() =>
updateColumn(
index,
{
tags: column.tags.filter(
(t) =>
t !==
tag,
),
},
)
}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<input
type="text"
value={column.tagInput}
onChange={(e) =>
updateColumn(index, {
tagInput:
e.target.value,
})
}
onKeyDown={(e) =>
handleTagKeyDown(e, index)
}
onBlur={() => commitTag(index)}
placeholder="Add tag…"
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
/>
</div>
<p className="mt-1 text-xs text-gray-400">
Press Enter or comma to add a tag.
</p>
</div>
)}
{/* Prompt */}
<div className="mt-4 flex items-center justify-between">
<label className="text-sm font-medium text-gray-500">
Prompt
</label>
<button
type="button"
onClick={() =>
autoGeneratePrompt(index)
}
disabled={
!column.name.trim() ||
generatingIndices.includes(index)
}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-900 disabled:text-gray-300"
>
{generatingIndices.includes(index) ? (
<span className="h-4 w-4 rounded-full border-2 border-gray-300 border-t-gray-600 animate-spin block" />
) : (
<Plus className="h-4 w-4" />
)}
Auto-Generate Prompt
</button>
</div>
<textarea
rows={6}
value={column.prompt}
onChange={(e) =>
updateColumn(index, {
prompt: e.target.value,
})
}
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
/>
</div>
))}
{!isEditing && (
<button
type="button"
onClick={addAnotherColumn}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-900"
>
<Plus className="h-4 w-4" />
Add another column
</button>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<div>
{isEditing && onDelete && (
<button
type="button"
onClick={onDelete}
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
>
Delete
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={columns.some(
(col) => !col.name.trim() || !col.prompt.trim(),
)}
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
>
{isEditing ? "Save changes" : "Add columns"}
</button>
</div>
</div>
</form>
</div>
</div>,
document.body,
);
}