mirror of
https://github.com/willchen96/mike.git
synced 2026-06-24 21:38:06 +02:00
522 lines
26 KiB
TypeScript
522 lines
26 KiB
TypeScript
"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,
|
||
);
|
||
}
|