mirror of
https://github.com/willchen96/mike.git
synced 2026-06-24 21:38:06 +02:00
Add local repo contents
This commit is contained in:
parent
65739ef1ce
commit
d9690965b5
176 changed files with 68998 additions and 0 deletions
522
frontend/src/app/components/tabular/AddColumnModal.tsx
Normal file
522
frontend/src/app/components/tabular/AddColumnModal.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
"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,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue