mirror of
https://github.com/willchen96/mike.git
synced 2026-06-28 21:49:37 +02:00
Refactor ProjectPageParts and ProjectPageHeader components for improved loading states and skeleton UI. Update Modal and PageHeader components to support loading states. Enhance RenameableTitle for better caret positioning. Adjust DisplayWorkflowModal to utilize the new Modal component structure. Update WorkflowList to include loading indicators and improve sticky header behavior.
This commit is contained in:
parent
444d1d38e4
commit
1fa0554ea5
49 changed files with 3623 additions and 1587 deletions
|
|
@ -1195,11 +1195,11 @@ function MarkdownContent({
|
|||
onClick={() =>
|
||||
onCitationClick?.(annotation)
|
||||
}
|
||||
data-citation-ref={idx + 1}
|
||||
data-citation-ref={annotation.ref}
|
||||
className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`}
|
||||
title={tooltipText}
|
||||
>
|
||||
{idx + 1}
|
||||
{annotation.ref}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1380,7 +1380,7 @@ function ensureTerminalPeriod(value: string): string {
|
|||
function buildCitationAppendix(citations: CitationAnnotation[]) {
|
||||
if (citations.length === 0) return { html: "", text: "" };
|
||||
let previousSourceKey: string | null = null;
|
||||
const entries = citations.map((annotation, index) => {
|
||||
const entries = citations.map((annotation) => {
|
||||
const sourceKey = citationSourceKey(annotation);
|
||||
const label =
|
||||
sourceKey === previousSourceKey
|
||||
|
|
@ -1388,7 +1388,7 @@ function buildCitationAppendix(citations: CitationAnnotation[]) {
|
|||
: citationSourceLabel(annotation);
|
||||
previousSourceKey = sourceKey;
|
||||
return {
|
||||
number: index + 1,
|
||||
number: annotation.ref,
|
||||
label,
|
||||
quote: displayCitationQuote(annotation).trim(),
|
||||
};
|
||||
|
|
@ -1484,7 +1484,7 @@ function CitationsBlock({
|
|||
}
|
||||
title={`${formatCitationPage(annotation)}: "${displayCitationQuote(annotation)}"`}
|
||||
>
|
||||
{index + 1}
|
||||
{annotation.ref}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,30 +30,25 @@ export function AssistantWorkflowModal({
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [rightVisible, setRightVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) {
|
||||
setRightVisible(false);
|
||||
return;
|
||||
}
|
||||
const frame = requestAnimationFrame(() => setRightVisible(true));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelected(null);
|
||||
setSearch("");
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const builtins = BUILT_IN_WORKFLOWS.filter(
|
||||
(w) => w.type === "assistant",
|
||||
);
|
||||
setWorkflows(builtins);
|
||||
setLoading(true);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setWorkflows(builtins);
|
||||
setLoading(true);
|
||||
if (initialWorkflowId) {
|
||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
||||
if (match) setSelected(match);
|
||||
}
|
||||
});
|
||||
listWorkflows("assistant")
|
||||
.then((custom) => {
|
||||
if (cancelled) return;
|
||||
const all = [...builtins, ...custom];
|
||||
setWorkflows(all);
|
||||
if (initialWorkflowId) {
|
||||
|
|
@ -62,17 +57,19 @@ export function AssistantWorkflowModal({
|
|||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (initialWorkflowId) {
|
||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
||||
if (match) setSelected(match);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
// Pre-select from builtins immediately if possible
|
||||
if (initialWorkflowId) {
|
||||
const match = builtins.find((w) => w.id === initialWorkflowId);
|
||||
if (match) setSelected(match);
|
||||
}
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [open, initialWorkflowId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
|
@ -81,10 +78,16 @@ export function AssistantWorkflowModal({
|
|||
? workflows.filter((w) => w.title.toLowerCase().includes(search.toLowerCase()))
|
||||
: workflows;
|
||||
|
||||
function handleClose() {
|
||||
setSelected(null);
|
||||
setSearch("");
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleUse() {
|
||||
if (!selected) return;
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const breadcrumbs = projectName
|
||||
|
|
@ -99,7 +102,7 @@ export function AssistantWorkflowModal({
|
|||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
size={selected ? "xl" : "lg"}
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryAction={{
|
||||
|
|
@ -110,13 +113,13 @@ export function AssistantWorkflowModal({
|
|||
}}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
|
||||
{/* Left panel — workflow list */}
|
||||
<div
|
||||
className={`overflow-y-auto ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
||||
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="pt-3 pb-2 shrink-0">
|
||||
<div className="px-2 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
|
|
@ -135,11 +138,11 @@ export function AssistantWorkflowModal({
|
|||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-px pt-1">
|
||||
<div className="space-y-1 px-2 pb-2 pt-1">
|
||||
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between gap-3 py-3 border-b border-gray-50"
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
||||
|
|
@ -154,35 +157,37 @@ export function AssistantWorkflowModal({
|
|||
{search ? "No matches found" : "No assistant workflows found"}
|
||||
</p>
|
||||
) : (
|
||||
filteredWorkflows.map((wf) => (
|
||||
<button
|
||||
key={wf.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev?.id === wf.id ? null : wf,
|
||||
)
|
||||
}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-xs text-left transition-colors border-b border-gray-50 ${
|
||||
selected?.id === wf.id
|
||||
? "bg-gray-50"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate text-gray-800">
|
||||
{wf.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{wf.is_system ? "Built-in" : "Custom"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
<div className="overflow-y-auto">
|
||||
{filteredWorkflows.map((wf) => (
|
||||
<button
|
||||
key={wf.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelected((prev) =>
|
||||
prev?.id === wf.id ? null : wf,
|
||||
)
|
||||
}
|
||||
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${
|
||||
selected?.id === wf.id
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate text-gray-800">
|
||||
{wf.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{wf.is_system ? "Built-in" : "Custom"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel — prompt preview */}
|
||||
{selected && (
|
||||
<div className={`flex-1 border-l border-gray-100 flex flex-col overflow-hidden px-3 pb-3 transition-opacity duration-200 ${rightVisible ? "opacity-100" : "opacity-0"}`}>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between py-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Workflow Prompt
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ export interface ModelOption {
|
|||
}
|
||||
|
||||
export const MODELS: ModelOption[] = [
|
||||
{ id: "claude-fable-5", label: "Claude Fable 5", group: "Anthropic" },
|
||||
{ id: "claude-opus-4-8", label: "Claude Opus 4.8", group: "Anthropic" },
|
||||
{ id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" },
|
||||
{ id: "gemini-3.5-flash", label: "Gemini 3.5 Flash", group: "Google" },
|
||||
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
|
||||
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
|
||||
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ export function UserMessage({ content, files, workflow }: Props) {
|
|||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 pl-2 pr-2.5 py-0.5 rounded-full text-xs text-white shadow border border-black bg-black"
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
{isPdf
|
||||
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-400" />
|
||||
: <File className="h-2.5 w-2.5 shrink-0 text-blue-400" />
|
||||
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
||||
: <File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="max-w-[140px] truncate">{f.filename}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
|||
import type { Document } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatBytes, formatDate } from "./ProjectPageParts";
|
||||
import { formatBytes } from "./ProjectPageParts";
|
||||
|
||||
const MIN_DOC_COLUMN_WIDTH = 420;
|
||||
const DEFAULT_DOC_COLUMN_WIDTH = 620;
|
||||
|
|
@ -27,7 +27,7 @@ const DEFAULT_DATA_COLUMN_WIDTH = 340;
|
|||
const RESIZER_WIDTH = 6;
|
||||
const MAX_PANEL_WIDTH = 1180;
|
||||
const primaryGlassButtonClass =
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-blue-800/35 bg-blue-700/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(30,64,175,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(30,64,175,0.18)] backdrop-blur-xl transition-all hover:bg-blue-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
|
||||
const dangerGlassButtonClass =
|
||||
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-red-700/35 bg-red-600/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl transition-all hover:bg-red-600 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
|
||||
|
||||
|
|
@ -57,6 +57,14 @@ interface DocumentSidePanelProps {
|
|||
file: File,
|
||||
filename: string,
|
||||
) => Promise<void>;
|
||||
onReplaceVersion: (
|
||||
docId: string,
|
||||
versionId: string,
|
||||
file: File,
|
||||
filename: string,
|
||||
) => Promise<void> | void;
|
||||
canDelete?: boolean;
|
||||
onOwnerOnlyAction?: (action: string) => void;
|
||||
onDelete: (doc: Document) => Promise<void> | void;
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +81,9 @@ export function DocumentSidePanel({
|
|||
onRenameVersion,
|
||||
onDeleteVersion,
|
||||
onUploadNewVersion,
|
||||
onReplaceVersion,
|
||||
canDelete = true,
|
||||
onOwnerOnlyAction,
|
||||
onDelete,
|
||||
}: DocumentSidePanelProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
|
@ -86,6 +97,13 @@ export function DocumentSidePanel({
|
|||
const [deletingVersionId, setDeletingVersionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [replaceTargetVersion, setReplaceTargetVersion] =
|
||||
useState<DocumentVersion | null>(null);
|
||||
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
||||
const [replaceConfirmOpen, setReplaceConfirmOpen] = useState(false);
|
||||
const [replacingVersionId, setReplacingVersionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [deletingDocument, setDeletingDocument] = useState(false);
|
||||
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
|
||||
useState(false);
|
||||
|
|
@ -98,8 +116,13 @@ export function DocumentSidePanel({
|
|||
const [panelWidth, setPanelWidth] = useState(
|
||||
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
|
||||
);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobilePane, setMobilePane] = useState<"document" | "details">(
|
||||
"document",
|
||||
);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartDataWidth = useRef(DEFAULT_DATA_COLUMN_WIDTH);
|
||||
const dragStartPanelWidth = useRef(
|
||||
|
|
@ -111,6 +134,7 @@ export function DocumentSidePanel({
|
|||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
function handleWindowResize() {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
setPanelWidth((width) => clampPanelWidth(width, dataColumnWidth));
|
||||
}
|
||||
handleWindowResize();
|
||||
|
|
@ -129,14 +153,21 @@ export function DocumentSidePanel({
|
|||
setNameDraft("");
|
||||
setNameError(null);
|
||||
setExtensionWarningOpen(false);
|
||||
setReplaceTargetVersion(null);
|
||||
setReplaceFile(null);
|
||||
setReplaceConfirmOpen(false);
|
||||
setMobilePane("document");
|
||||
}, [doc?.id, versionId, currentVersionId]);
|
||||
|
||||
if (!mounted || !doc) return null;
|
||||
|
||||
const activeDoc = doc;
|
||||
const documentId = activeDoc.id;
|
||||
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
|
||||
const newVersionAccept = ".pdf,.docx,.doc";
|
||||
const orderedVersions = [...versions].reverse();
|
||||
const activeVersionCount = versions.filter(
|
||||
(version) => version.deleted_at == null,
|
||||
).length;
|
||||
const selectedVersion =
|
||||
versions.find((version) => version.id === versionId) ??
|
||||
versions.find((version) => version.id === currentVersionId) ??
|
||||
|
|
@ -158,8 +189,19 @@ export function DocumentSidePanel({
|
|||
: selectedVersion.page_count;
|
||||
const selectedVersionNumber =
|
||||
selectedVersion?.version_number ?? doc.active_version_number ?? null;
|
||||
const selectedVersionTag =
|
||||
selectedVersionNumber != null ? `V${selectedVersionNumber}` : null;
|
||||
const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at;
|
||||
const selectedExtension = filenameExtension(selectedFilename);
|
||||
const replaceFileType = replaceTargetVersion
|
||||
? fileTypeForVersion(replaceTargetVersion, selectedFileType)
|
||||
: selectedFileType;
|
||||
const replaceVersionAccept =
|
||||
replaceFileType === "pdf" ? ".pdf" : ".docx,.doc";
|
||||
const ownerLabel =
|
||||
doc.owner_display_name?.trim() ||
|
||||
doc.owner_email?.trim() ||
|
||||
"—";
|
||||
|
||||
async function handleSaveName() {
|
||||
if (!selectedVersionId) return;
|
||||
|
|
@ -208,6 +250,10 @@ export function DocumentSidePanel({
|
|||
}
|
||||
|
||||
async function handleDeleteVersion(versionIdToDelete: string) {
|
||||
if (!canDelete) {
|
||||
onOwnerOnlyAction?.("delete this document version");
|
||||
return;
|
||||
}
|
||||
setDeletingVersionId(versionIdToDelete);
|
||||
try {
|
||||
await onDeleteVersion(documentId, versionIdToDelete);
|
||||
|
|
@ -218,6 +264,46 @@ export function DocumentSidePanel({
|
|||
}
|
||||
}
|
||||
|
||||
function requestReplaceVersion(version: DocumentVersion) {
|
||||
setUploadError(null);
|
||||
setReplaceTargetVersion(version);
|
||||
setReplaceFile(null);
|
||||
window.setTimeout(() => replaceFileInputRef.current?.click(), 0);
|
||||
}
|
||||
|
||||
function handleReplaceFileInputChange(
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
e.target.value = "";
|
||||
if (!file || !replaceTargetVersion) return;
|
||||
setReplaceFile(file);
|
||||
setReplaceConfirmOpen(true);
|
||||
}
|
||||
|
||||
async function handleConfirmReplaceVersion() {
|
||||
if (!replaceTargetVersion || !replaceFile) return;
|
||||
const targetId = replaceTargetVersion.id;
|
||||
setReplacingVersionId(targetId);
|
||||
setUploadError(null);
|
||||
try {
|
||||
await onReplaceVersion(
|
||||
documentId,
|
||||
targetId,
|
||||
replaceFile,
|
||||
replaceFile.name,
|
||||
);
|
||||
setReplaceConfirmOpen(false);
|
||||
setReplaceTargetVersion(null);
|
||||
setReplaceFile(null);
|
||||
} catch (err) {
|
||||
console.error("replace version failed", err);
|
||||
setUploadError("Could not replace this version.");
|
||||
} finally {
|
||||
setReplacingVersionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDocument() {
|
||||
if (deleteDocumentStatus === "deleting") return;
|
||||
setDeleteDocumentStatus("deleting");
|
||||
|
|
@ -239,6 +325,10 @@ export function DocumentSidePanel({
|
|||
}
|
||||
|
||||
function requestDeleteDocument() {
|
||||
if (!canDelete) {
|
||||
onOwnerOnlyAction?.("delete this document");
|
||||
return;
|
||||
}
|
||||
if (versions.length > 1) {
|
||||
setDeleteDocumentStatus("idle");
|
||||
setConfirmDeleteDocumentOpen(true);
|
||||
|
|
@ -313,27 +403,53 @@ export function DocumentSidePanel({
|
|||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[190] flex flex-col",
|
||||
"inset-y-3 right-3 rounded-2xl border border-white/70 bg-white/72 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
|
||||
"inset-3 md:left-auto rounded-2xl border border-white/70 bg-gray-50/80 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
|
||||
)}
|
||||
style={{ width: panelWidth }}
|
||||
style={isMobile ? undefined : { width: panelWidth }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={handlePanelResizeMouseDown}
|
||||
className="absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60"
|
||||
className="absolute inset-y-0 left-0 z-20 hidden w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60 md:block"
|
||||
title="Resize document view"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center justify-between px-4",
|
||||
"border-b border-white/60 bg-white/35",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-700">
|
||||
<div className="flex min-h-11 shrink-0 items-center justify-between gap-3 px-4 py-2 md:h-11 md:py-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{selectedVersionTag && (
|
||||
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white/75 px-2 text-[10px] font-semibold text-gray-600">
|
||||
{selectedVersionTag}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 truncate text-sm font-medium text-gray-700">
|
||||
{selectedFilename}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<div className="flex h-7 items-center rounded-full bg-gray-200/70 p-0.5 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobilePane("document")}
|
||||
className={cn(
|
||||
"h-6 rounded-full px-2 text-[11px] font-medium transition-colors",
|
||||
mobilePane === "document"
|
||||
? "bg-white text-gray-900 shadow-[0_1px_3px_rgba(15,23,42,0.08)]"
|
||||
: "text-gray-500 hover:text-gray-800",
|
||||
)}
|
||||
>
|
||||
Document
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobilePane("details")}
|
||||
className={cn(
|
||||
"h-6 rounded-full px-2 text-[11px] font-medium transition-colors",
|
||||
mobilePane === "details"
|
||||
? "bg-white text-gray-900 shadow-[0_1px_3px_rgba(15,23,42,0.08)]"
|
||||
: "text-gray-500 hover:text-gray-800",
|
||||
)}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
|
@ -348,27 +464,31 @@ export function DocumentSidePanel({
|
|||
<div
|
||||
className="grid min-h-0 flex-1"
|
||||
style={{
|
||||
gridTemplateColumns: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
|
||||
gridTemplateColumns: isMobile
|
||||
? "minmax(0, 1fr)"
|
||||
: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 pb-3 pl-3",
|
||||
"bg-white/20",
|
||||
"min-h-0 min-w-0 p-3 pt-0 md:flex md:pr-0",
|
||||
mobilePane === "document" ? "flex" : "hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
"rounded-xl border border-white/60 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
|
||||
"rounded-xl border border-gray-200 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<DocView
|
||||
key={selectedVersionId ?? "current"}
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: selectedVersionId,
|
||||
}}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -376,7 +496,7 @@ export function DocumentSidePanel({
|
|||
<div
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
className={cn(
|
||||
"relative cursor-col-resize transition-colors",
|
||||
"relative hidden cursor-col-resize transition-colors md:block",
|
||||
"bg-white/25 hover:bg-blue-400/60",
|
||||
)}
|
||||
title="Resize document panel"
|
||||
|
|
@ -384,16 +504,12 @@ export function DocumentSidePanel({
|
|||
|
||||
<aside
|
||||
className={cn(
|
||||
"mb-3 ml-2 mr-3 flex min-h-0 flex-col overflow-hidden rounded-xl",
|
||||
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
|
||||
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl md:ml-2 md:mr-3",
|
||||
mobilePane === "details" ? "flex" : "hidden md:flex",
|
||||
"border border-white/70 bg-white shadow-[0_3px_9px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-4 py-3",
|
||||
"border-b border-white/60",
|
||||
)}
|
||||
>
|
||||
<div className={cn("shrink-0 p-4")}>
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Name
|
||||
|
|
@ -490,25 +606,30 @@ export function DocumentSidePanel({
|
|||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow label="Owner" value={ownerLabel} />
|
||||
<DataRow
|
||||
label="Uploaded"
|
||||
value={
|
||||
selectedUploadedAt
|
||||
? formatDate(selectedUploadedAt)
|
||||
? formatDateTime(
|
||||
selectedUploadedAt,
|
||||
)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={
|
||||
selectedPageCount != null
|
||||
? String(selectedPageCount)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{selectedPageCount != null && (
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={String(selectedPageCount)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pt-0">
|
||||
<div className="mb-2 text-xs font-medium text-gray-900">
|
||||
Versions
|
||||
</div>
|
||||
|
|
@ -520,9 +641,16 @@ export function DocumentSidePanel({
|
|||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
{versionsLoading && versions.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading versions
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({
|
||||
length: versionSkeletonCount(
|
||||
doc.active_version_number,
|
||||
),
|
||||
}).map((_, index) => (
|
||||
<VersionUploadSkeleton
|
||||
key={`version-skeleton-${index}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : orderedVersions.length === 0 ? (
|
||||
<div className="py-2 text-xs text-gray-400">
|
||||
|
|
@ -530,6 +658,9 @@ export function DocumentSidePanel({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{uploading && (
|
||||
<VersionUploadSkeleton />
|
||||
)}
|
||||
{orderedVersions.map((version) => {
|
||||
const title =
|
||||
versionTitleFor(version);
|
||||
|
|
@ -538,9 +669,14 @@ export function DocumentSidePanel({
|
|||
const selected =
|
||||
selectedVersionId ===
|
||||
version.id;
|
||||
const deleted =
|
||||
version.deleted_at != null;
|
||||
const versionDeleting =
|
||||
deletingVersionId ===
|
||||
version.id;
|
||||
const versionReplacing =
|
||||
replacingVersionId ===
|
||||
version.id;
|
||||
const fileType = fileTypeForVersion(
|
||||
version,
|
||||
doc.file_type,
|
||||
|
|
@ -554,25 +690,34 @@ export function DocumentSidePanel({
|
|||
key={version.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
if (deleted) return;
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (deleted) return;
|
||||
if (
|
||||
event.key !==
|
||||
"Enter" &&
|
||||
event.key !== " "
|
||||
) return;
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="group relative flex w-full cursor-pointer flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white"
|
||||
aria-disabled={deleted}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white",
|
||||
deleted
|
||||
? "cursor-not-allowed opacity-55"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
|
||||
|
|
@ -588,8 +733,10 @@ export function DocumentSidePanel({
|
|||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] font-semibold tracking-normal",
|
||||
typeLabel ===
|
||||
"PDF"
|
||||
deleted
|
||||
? "text-gray-300"
|
||||
: typeLabel ===
|
||||
"PDF"
|
||||
? "text-red-600"
|
||||
: "text-blue-600",
|
||||
)}
|
||||
|
|
@ -611,51 +758,99 @@ export function DocumentSidePanel({
|
|||
<div
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
|
||||
selected
|
||||
deleted ||
|
||||
selected
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onDownloadVersion(
|
||||
doc.id,
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label={`Download ${title}`}
|
||||
title="Download version"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteVersion(
|
||||
version.id,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
versions.length <=
|
||||
1 ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Delete ${title}`}
|
||||
title="Delete version"
|
||||
>
|
||||
{versionDeleting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
{deleted ? (
|
||||
<span className="text-[11px] font-medium text-gray-800">
|
||||
Deleted
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
requestReplaceVersion(
|
||||
version,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
replacingVersionId !=
|
||||
null ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Replace ${title}`}
|
||||
title="Replace version file"
|
||||
>
|
||||
{versionReplacing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
void onDownloadVersion(
|
||||
doc.id,
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label={`Download ${title}`}
|
||||
title="Download version"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteVersion(
|
||||
version.id,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
(canDelete &&
|
||||
activeVersionCount <=
|
||||
1) ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
!canDelete &&
|
||||
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
|
||||
)}
|
||||
aria-label={`Delete ${title}`}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete version"
|
||||
: "Only the document owner can delete versions"
|
||||
}
|
||||
>
|
||||
{versionDeleting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -677,21 +872,37 @@ export function DocumentSidePanel({
|
|||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between px-4 py-3",
|
||||
"border-t border-white/60 bg-white/25",
|
||||
"bg-white/25",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
accept={newVersionAccept}
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<input
|
||||
ref={replaceFileInputRef}
|
||||
type="file"
|
||||
accept={replaceVersionAccept}
|
||||
className="hidden"
|
||||
onChange={handleReplaceFileInputChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestDeleteDocument}
|
||||
disabled={deletingDocument}
|
||||
className={dangerGlassButtonClass}
|
||||
className={cn(
|
||||
dangerGlassButtonClass,
|
||||
!canDelete &&
|
||||
"cursor-not-allowed opacity-45 hover:bg-red-600/90 active:scale-100",
|
||||
)}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete document"
|
||||
: "Only the document owner can delete this document"
|
||||
}
|
||||
>
|
||||
{deletingDocument ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
|
|
@ -725,6 +936,23 @@ export function DocumentSidePanel({
|
|||
: "File extensions cannot be changed here."
|
||||
}
|
||||
/>
|
||||
<ConfirmPopup
|
||||
open={replaceConfirmOpen}
|
||||
title="Replace version?"
|
||||
message={`This will wipe ${versionTitleFor(replaceTargetVersion)} and replace it with ${replaceFile?.name ?? "the selected file"}. Save as a new version instead if you want to keep both copies.`}
|
||||
confirmLabel="Replace"
|
||||
confirmStatus={
|
||||
replacingVersionId != null ? "loading" : "idle"
|
||||
}
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => {
|
||||
if (replacingVersionId != null) return;
|
||||
setReplaceConfirmOpen(false);
|
||||
setReplaceTargetVersion(null);
|
||||
setReplaceFile(null);
|
||||
}}
|
||||
onConfirm={() => void handleConfirmReplaceVersion()}
|
||||
/>
|
||||
<ConfirmPopup
|
||||
open={confirmDeleteDocumentOpen}
|
||||
title="Delete document?"
|
||||
|
|
@ -759,6 +987,32 @@ function DataRow({ label, value }: { label: string; value: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function VersionUploadSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)]">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="h-3 w-20 rounded-full bg-gray-200" />
|
||||
<div className="h-3 w-9 rounded-full bg-blue-100" />
|
||||
</div>
|
||||
<div className="h-2.5 w-4/5 rounded-full bg-gray-200" />
|
||||
<div className="h-2.5 w-2/5 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function versionSkeletonCount(activeVersionNumber: number | null | undefined) {
|
||||
if (
|
||||
typeof activeVersionNumber === "number" &&
|
||||
Number.isFinite(activeVersionNumber) &&
|
||||
activeVersionNumber > 0
|
||||
) {
|
||||
return Math.min(activeVersionNumber, 8);
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
function clampPanelWidth(width: number, dataColumnWidth: number) {
|
||||
const minWidth = MIN_DOC_COLUMN_WIDTH + RESIZER_WIDTH + dataColumnWidth;
|
||||
const maxWidth =
|
||||
|
|
@ -768,7 +1022,8 @@ function clampPanelWidth(width: number, dataColumnWidth: number) {
|
|||
return Math.min(maxWidth, Math.max(minWidth, width));
|
||||
}
|
||||
|
||||
function versionTitleFor(version: DocumentVersion) {
|
||||
function versionTitleFor(version: DocumentVersion | null) {
|
||||
if (!version) return "this version";
|
||||
if (
|
||||
typeof version.version_number === "number" &&
|
||||
version.version_number >= 1
|
||||
|
|
@ -805,3 +1060,13 @@ function hasExtensionChange(previous: string, next: string) {
|
|||
previousExtension.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import {
|
||||
getProject,
|
||||
deleteProject,
|
||||
deleteDocument,
|
||||
createTabularReview,
|
||||
updateProject,
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
renameProjectDocument,
|
||||
listDocumentVersions,
|
||||
uploadDocumentVersion,
|
||||
replaceDocumentVersionFile,
|
||||
copyDocumentVersionFromDocument,
|
||||
deleteDocumentVersion,
|
||||
uploadProjectDocument,
|
||||
|
|
@ -76,7 +78,6 @@ import {
|
|||
formatBytes,
|
||||
formatDate,
|
||||
ProjectPageHeader,
|
||||
ProjectPageSkeleton,
|
||||
treeNameCellStyle,
|
||||
type ProjectContextMenu,
|
||||
type ProjectTab,
|
||||
|
|
@ -108,6 +109,157 @@ function apiErrorDetail(error: unknown): string | null {
|
|||
return error.message || null;
|
||||
}
|
||||
|
||||
function ProjectTableLoading({
|
||||
tab,
|
||||
stickyCellBg,
|
||||
}: {
|
||||
tab: ProjectTab;
|
||||
stickyCellBg: string;
|
||||
}) {
|
||||
if (tab === "assistant") {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<span>Chats</span>
|
||||
</div>
|
||||
<div className="ml-auto w-32 shrink-0 text-left">
|
||||
Created
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div
|
||||
className="h-3.5 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${44 + i * 7}px` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-32 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === "reviews") {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="ml-auto w-24 shrink-0 text-left">
|
||||
Columns
|
||||
</div>
|
||||
<div className="w-24 shrink-0 text-left">Documents</div>
|
||||
<div className="w-32 shrink-0 text-left">Created</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div
|
||||
className="h-3.5 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${180 + i * 18}px` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-24 shrink-0">
|
||||
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0 text-left">Type</div>
|
||||
<div className="w-24 shrink-0 text-left">Size</div>
|
||||
<div className="w-20 shrink-0 text-left">Version</div>
|
||||
<div className="w-32 shrink-0 text-left">Created</div>
|
||||
<div className="w-32 shrink-0 text-left">Updated</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div
|
||||
className="h-3.5 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${210 + i * 16}px` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0">
|
||||
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0">
|
||||
<div className="h-3 w-5 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [folders, setFolders] = useState<ProjectFolder[]>([]);
|
||||
|
|
@ -248,16 +400,31 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
async function replaceVersionFile(
|
||||
docId: string,
|
||||
versionId: string,
|
||||
file: File,
|
||||
filename: string,
|
||||
) {
|
||||
await replaceDocumentVersionFile(docId, versionId, file, filename);
|
||||
const res = await refreshDocumentVersionState(docId);
|
||||
const replaced = res.versions.find(
|
||||
(version) => version.id === versionId,
|
||||
);
|
||||
if (replaced) {
|
||||
setViewingDocVersion({
|
||||
id: replaced.id,
|
||||
label: replaced.filename?.trim() || "Version",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDocumentVersionState(docId: string) {
|
||||
// Refresh project so doc.active_version_number and filename advance.
|
||||
const updated = await getProject(projectId);
|
||||
setProject(updated);
|
||||
// Re-fetch versions for this doc (invalidate cache first).
|
||||
setVersionsByDocId((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(docId);
|
||||
return next;
|
||||
});
|
||||
// Re-fetch versions while keeping the previous rows visible until the
|
||||
// updated list arrives.
|
||||
const res = await listDocumentVersions(docId);
|
||||
setVersionsByDocId((prev) => {
|
||||
const next = new Map(prev);
|
||||
|
|
@ -318,11 +485,14 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
try {
|
||||
await deleteDocumentVersion(docId, versionId);
|
||||
const res = await refreshDocumentVersionState(docId);
|
||||
const activeVersions = res.versions.filter(
|
||||
(version) => version.deleted_at == null,
|
||||
);
|
||||
const nextVersion =
|
||||
res.versions.find(
|
||||
activeVersions.find(
|
||||
(version) => version.id === res.current_version_id,
|
||||
) ??
|
||||
res.versions[res.versions.length - 1] ??
|
||||
activeVersions[activeVersions.length - 1] ??
|
||||
null;
|
||||
setViewingDocVersion(
|
||||
nextVersion
|
||||
|
|
@ -385,6 +555,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [deletingDocIds, setDeletingDocIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [documentUploadWarning, setDocumentUploadWarning] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
|
@ -413,6 +586,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
const [pendingDeleteFolderStatus, setPendingDeleteFolderStatus] = useState<
|
||||
"idle" | "deleting" | "deleted"
|
||||
>("idle");
|
||||
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
|
||||
useState(false);
|
||||
const [deleteProjectStatus, setDeleteProjectStatus] = useState<
|
||||
"idle" | "deleting" | "deleted"
|
||||
>("idle");
|
||||
|
||||
// Actions dropdown
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
|
|
@ -860,16 +1038,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
setOwnerOnlyAction("delete this document");
|
||||
return;
|
||||
}
|
||||
await deleteDocument(docId);
|
||||
setProject((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
documents:
|
||||
prev.documents?.filter((d) => d.id !== docId) || [],
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setDeletingDocIds((prev) => new Set([...prev, docId]));
|
||||
try {
|
||||
await deleteDocument(docId);
|
||||
setProject((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
documents:
|
||||
prev.documents?.filter((d) => d.id !== docId) ||
|
||||
[],
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
} finally {
|
||||
setDeletingDocIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(docId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function requestRemoveDoc(doc: Document) {
|
||||
|
|
@ -877,6 +1065,14 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
setOwnerOnlyAction("delete this document");
|
||||
return;
|
||||
}
|
||||
const versionCount =
|
||||
versionsByDocId.get(doc.id)?.versions.length ??
|
||||
currentVersionNumber(doc) ??
|
||||
1;
|
||||
if (versionCount <= 1) {
|
||||
void handleRemoveDoc(doc.id);
|
||||
return;
|
||||
}
|
||||
setPendingDeleteStatus("idle");
|
||||
setPendingDeleteDoc(doc);
|
||||
}
|
||||
|
|
@ -949,6 +1145,48 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
await updateProject(projectId, { name: newName });
|
||||
}
|
||||
|
||||
async function handleCmNumberCommit(newCmNumber: string) {
|
||||
if (project && project.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this project's CM number");
|
||||
return;
|
||||
}
|
||||
const trimmed = newCmNumber.trim();
|
||||
if (trimmed === (project?.cm_number ?? "")) return;
|
||||
setProject((prev) =>
|
||||
prev ? { ...prev, cm_number: trimmed || null } : prev,
|
||||
);
|
||||
const updated = await updateProject(projectId, {
|
||||
cm_number: trimmed,
|
||||
});
|
||||
setProject((prev) =>
|
||||
prev ? { ...prev, cm_number: updated.cm_number } : prev,
|
||||
);
|
||||
}
|
||||
|
||||
function requestProjectDelete() {
|
||||
if (project?.is_owner === false) {
|
||||
setOwnerOnlyAction("delete this project");
|
||||
return;
|
||||
}
|
||||
setDeleteProjectStatus("idle");
|
||||
setDeleteProjectConfirmOpen(true);
|
||||
}
|
||||
|
||||
async function confirmProjectDelete() {
|
||||
if (deleteProjectStatus === "deleting") return;
|
||||
setDeleteProjectStatus("deleting");
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteProjectStatus("deleted");
|
||||
setTimeout(() => {
|
||||
router.push("/projects");
|
||||
}, 250);
|
||||
} catch (err) {
|
||||
setDeleteProjectStatus("idle");
|
||||
console.error("Failed to delete project", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitChatRename(chatId: string) {
|
||||
const trimmed = renameChatValue.trim();
|
||||
setRenamingChatId(null);
|
||||
|
|
@ -1168,6 +1406,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
return doc.active_version_number ?? doc.latest_version_number ?? null;
|
||||
}
|
||||
|
||||
function isSharedDocument(doc: Document | null | undefined): boolean {
|
||||
return !!(doc?.user_id && user?.id && doc.user_id !== user.id);
|
||||
}
|
||||
|
||||
async function handleDropProjectFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
const { supported, unsupported } =
|
||||
|
|
@ -1414,10 +1656,22 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
function renderUploadingDocumentRows(depth: number) {
|
||||
return uploadingDroppedFilenames.map((filename) => (
|
||||
function renderDocumentActivityRow({
|
||||
key,
|
||||
filename,
|
||||
fileType,
|
||||
depth,
|
||||
statusLabel,
|
||||
}: {
|
||||
key: string;
|
||||
filename: string;
|
||||
fileType: string | null;
|
||||
depth: number;
|
||||
statusLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
key={`uploading-doc-${filename}`}
|
||||
key={key}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
>
|
||||
<div
|
||||
|
|
@ -1425,31 +1679,40 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="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-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
|
||||
<DocIcon fileType={fileType} />
|
||||
<span className="text-sm text-gray-400 truncate">
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300 uppercase truncate">
|
||||
{filename.includes(".")
|
||||
? filename.split(".").pop()
|
||||
: "file"}
|
||||
{fileType ??
|
||||
(filename.includes(".")
|
||||
? filename.split(".").pop()
|
||||
: "file")}
|
||||
</div>
|
||||
<div className="w-24 shrink-0 text-sm text-gray-300">
|
||||
Uploading
|
||||
{statusLabel}
|
||||
</div>
|
||||
<div className="w-20 shrink-0 text-sm text-gray-300">—</div>
|
||||
<div className="w-32 shrink-0 text-sm text-gray-300">—</div>
|
||||
<div className="w-32 shrink-0 text-sm text-gray-300">—</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
function renderUploadingDocumentRows(depth: number) {
|
||||
return uploadingDroppedFilenames.map((filename) =>
|
||||
renderDocumentActivityRow({
|
||||
key: `uploading-doc-${filename}`,
|
||||
filename,
|
||||
fileType: null,
|
||||
depth,
|
||||
statusLabel: "Uploading",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderLevel(parentId: string | null, depth: number) {
|
||||
|
|
@ -1477,6 +1740,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
const isUploadingVersion = uploadingVersionDocIds.has(
|
||||
doc.id,
|
||||
);
|
||||
const isDeletingDoc = deletingDocIds.has(doc.id);
|
||||
if (isDeletingDoc) {
|
||||
return renderDocumentActivityRow({
|
||||
key: `deleting-doc-${doc.id}`,
|
||||
filename: doc.filename,
|
||||
fileType: doc.file_type,
|
||||
depth,
|
||||
statusLabel: "Deleting...",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div key={`doc-${doc.id}`}>
|
||||
<div
|
||||
|
|
@ -1535,39 +1808,41 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(
|
||||
doc.id,
|
||||
)}
|
||||
onChange={() =>
|
||||
setSelectedDocIds(
|
||||
(prev) =>
|
||||
prev.includes(
|
||||
doc.id,
|
||||
)
|
||||
? prev.filter(
|
||||
(
|
||||
x,
|
||||
) =>
|
||||
x !==
|
||||
doc.id,
|
||||
)
|
||||
: [
|
||||
...prev,
|
||||
doc.id,
|
||||
],
|
||||
)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
{isProcessing ||
|
||||
isUploadingVersion ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
) : isError ? (
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(
|
||||
doc.id,
|
||||
)}
|
||||
onChange={() =>
|
||||
setSelectedDocIds(
|
||||
(prev) =>
|
||||
prev.includes(
|
||||
doc.id,
|
||||
)
|
||||
? prev.filter(
|
||||
(
|
||||
x,
|
||||
) =>
|
||||
x !==
|
||||
doc.id,
|
||||
)
|
||||
: [
|
||||
...prev,
|
||||
doc.id,
|
||||
],
|
||||
)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
)}
|
||||
{isError ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<DocIcon
|
||||
|
|
@ -1738,6 +2013,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
doc,
|
||||
)
|
||||
}
|
||||
deleteDisabled={isSharedDocument(
|
||||
doc,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1749,7 +2027,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
<DocVersionHistory
|
||||
docId={doc.id}
|
||||
filename={docName}
|
||||
fileType={doc.file_type}
|
||||
activeVersionNumber={versionNumber}
|
||||
loading={loadingVersionDocIds.has(doc.id)}
|
||||
versions={
|
||||
|
|
@ -1944,9 +2221,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────
|
||||
|
||||
if (loading) return <ProjectPageSkeleton />;
|
||||
|
||||
if (!project) {
|
||||
if (!loading && !project) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-gray-400">Project not found</p>
|
||||
|
|
@ -1954,12 +2229,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const docs = project.documents || [];
|
||||
const docs = project?.documents || [];
|
||||
const sidePanelDoc = viewingDoc
|
||||
? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc)
|
||||
: null;
|
||||
const versionUploadAccept =
|
||||
versionUploadTargetDoc?.file_type === "pdf" ? ".pdf" : ".docx,.doc";
|
||||
const versionUploadAccept = ".pdf,.docx,.doc";
|
||||
const q = search.toLowerCase();
|
||||
const filteredDocs = q
|
||||
? docs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
|
|
@ -2057,17 +2331,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (loading) return;
|
||||
setCreatingFolderIn(null);
|
||||
setNewFolderName("");
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Add Subfolder</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddDocsOpen(true)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:text-gray-300"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Add Documents</span>
|
||||
|
|
@ -2102,7 +2379,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
</div>
|
||||
) : undefined;
|
||||
const pendingDeleteDocVersionCount = pendingDeleteDoc
|
||||
? currentVersionNumber(pendingDeleteDoc)
|
||||
? (versionsByDocId.get(pendingDeleteDoc.id)?.versions.length ??
|
||||
currentVersionNumber(pendingDeleteDoc) ??
|
||||
1)
|
||||
: 0;
|
||||
const pendingDeleteDocMessage = pendingDeleteDoc ? (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -2234,13 +2513,16 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
/>
|
||||
<ProjectPageHeader
|
||||
project={project}
|
||||
tab={tab}
|
||||
search={search}
|
||||
creatingChat={creatingChat}
|
||||
creatingReview={creatingReview}
|
||||
docsCount={docs.length}
|
||||
isOwner={project?.is_owner !== false}
|
||||
onBackToProjects={() => router.push("/projects")}
|
||||
onTitleCommit={handleTitleCommit}
|
||||
onRenameProject={handleTitleCommit}
|
||||
onRenameCmNumber={handleCmNumberCommit}
|
||||
onOwnerOnly={setOwnerOnlyAction}
|
||||
onDeleteProject={requestProjectDelete}
|
||||
onSearchChange={setSearch}
|
||||
onOpenPeople={() => setPeopleModalOpen(true)}
|
||||
onNewChat={handleNewChat}
|
||||
|
|
@ -2261,6 +2543,13 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
{/* Table content */}
|
||||
<div className="w-full flex-1 min-h-0 overflow-x-auto">
|
||||
<div className="min-w-max flex min-h-full flex-col">
|
||||
{loading ? (
|
||||
<ProjectTableLoading
|
||||
tab={tab}
|
||||
stickyCellBg={stickyCellBg}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Tab: Documents */}
|
||||
{tab === "documents" && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
|
|
@ -2437,6 +2726,24 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
uploadingVersionDocIds.has(
|
||||
doc.id,
|
||||
);
|
||||
const isDeletingDoc =
|
||||
deletingDocIds.has(
|
||||
doc.id,
|
||||
);
|
||||
if (isDeletingDoc) {
|
||||
return renderDocumentActivityRow(
|
||||
{
|
||||
key: `deleting-doc-${doc.id}`,
|
||||
filename:
|
||||
doc.filename,
|
||||
fileType:
|
||||
doc.file_type,
|
||||
depth: 0,
|
||||
statusLabel:
|
||||
"Deleting...",
|
||||
},
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={doc.id}>
|
||||
<div
|
||||
|
|
@ -2520,43 +2827,45 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : selectedDocIds.includes(doc.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : "group-hover:bg-gray-100"}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(
|
||||
doc.id,
|
||||
)}
|
||||
onChange={() =>
|
||||
setSelectedDocIds(
|
||||
(
|
||||
prev,
|
||||
) =>
|
||||
prev.includes(
|
||||
doc.id,
|
||||
)
|
||||
? prev.filter(
|
||||
(
|
||||
x,
|
||||
) =>
|
||||
x !==
|
||||
doc.id,
|
||||
)
|
||||
: [
|
||||
...prev,
|
||||
doc.id,
|
||||
],
|
||||
)
|
||||
}
|
||||
onClick={(
|
||||
e,
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
{isProcessing ||
|
||||
isUploadingVersion ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
) : isError ? (
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(
|
||||
doc.id,
|
||||
)}
|
||||
onChange={() =>
|
||||
setSelectedDocIds(
|
||||
(
|
||||
prev,
|
||||
) =>
|
||||
prev.includes(
|
||||
doc.id,
|
||||
)
|
||||
? prev.filter(
|
||||
(
|
||||
x,
|
||||
) =>
|
||||
x !==
|
||||
doc.id,
|
||||
)
|
||||
: [
|
||||
...prev,
|
||||
doc.id,
|
||||
],
|
||||
)
|
||||
}
|
||||
onClick={(
|
||||
e,
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
)}
|
||||
{isError ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<DocIcon
|
||||
|
|
@ -2741,6 +3050,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
doc,
|
||||
)
|
||||
}
|
||||
deleteDisabled={isSharedDocument(
|
||||
doc,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2753,9 +3065,6 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
filename={
|
||||
docName
|
||||
}
|
||||
fileType={
|
||||
doc.file_type
|
||||
}
|
||||
activeVersionNumber={
|
||||
versionNumber
|
||||
}
|
||||
|
|
@ -2907,6 +3216,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
menuDoc,
|
||||
)
|
||||
}
|
||||
deleteDisabled={isSharedDocument(
|
||||
menuDoc,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RowActionMenuItems
|
||||
|
|
@ -3035,21 +3347,27 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
setRenameReviewValue={setRenameReviewValue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddDocumentsModal
|
||||
open={addDocsOpen}
|
||||
onClose={() => setAddDocsOpen(false)}
|
||||
onSelect={handleDocsSelected}
|
||||
breadcrumb={[
|
||||
"Projects",
|
||||
project.name +
|
||||
(project.cm_number ? ` (${project.cm_number})` : ""),
|
||||
"Add Documents",
|
||||
]}
|
||||
projectId={projectId}
|
||||
/>
|
||||
{project && (
|
||||
<AddDocumentsModal
|
||||
open={addDocsOpen}
|
||||
onClose={() => setAddDocsOpen(false)}
|
||||
onSelect={handleDocsSelected}
|
||||
breadcrumb={[
|
||||
"Projects",
|
||||
project.name +
|
||||
(project.cm_number
|
||||
? ` (${project.cm_number})`
|
||||
: ""),
|
||||
"Add Documents",
|
||||
]}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentSidePanel
|
||||
doc={sidePanelDoc}
|
||||
|
|
@ -3083,6 +3401,9 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
onRenameVersion={handleRenameVersion}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onUploadNewVersion={submitNewVersion}
|
||||
onReplaceVersion={replaceVersionFile}
|
||||
canDelete={!isSharedDocument(sidePanelDoc)}
|
||||
onOwnerOnlyAction={setOwnerOnlyAction}
|
||||
onDelete={async (doc) => {
|
||||
await handleRemoveDoc(doc.id);
|
||||
}}
|
||||
|
|
@ -3105,41 +3426,64 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
|
||||
<PeopleModal
|
||||
open={peopleModalOpen}
|
||||
onClose={() => setPeopleModalOpen(false)}
|
||||
resource={project}
|
||||
fetchPeople={getProjectPeople}
|
||||
currentUserEmail={user?.email ?? null}
|
||||
breadcrumb={[
|
||||
"Projects",
|
||||
project
|
||||
? project.name +
|
||||
(project.cm_number ? ` (${project.cm_number})` : "")
|
||||
: "",
|
||||
"People",
|
||||
]}
|
||||
// Only owners may modify the member list. Without this prop
|
||||
// PeopleModal renders read-only — non-owners can still see
|
||||
// who has access but the add/remove controls are hidden.
|
||||
onSharedWithChange={
|
||||
project.is_owner === false
|
||||
? undefined
|
||||
: async (next) => {
|
||||
const updated = await updateProject(projectId, {
|
||||
shared_with: next,
|
||||
});
|
||||
setProject((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
shared_with: updated.shared_with,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
<ConfirmPopup
|
||||
open={deleteProjectConfirmOpen}
|
||||
title="Delete project?"
|
||||
message="This will permanently delete the project and its related documents, chats, and tabular reviews."
|
||||
confirmLabel="Delete"
|
||||
confirmStatus={
|
||||
deleteProjectStatus === "deleting"
|
||||
? "loading"
|
||||
: deleteProjectStatus === "deleted"
|
||||
? "complete"
|
||||
: "idle"
|
||||
}
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => {
|
||||
if (deleteProjectStatus === "deleting") return;
|
||||
setDeleteProjectConfirmOpen(false);
|
||||
setDeleteProjectStatus("idle");
|
||||
}}
|
||||
onConfirm={() => void confirmProjectDelete()}
|
||||
/>
|
||||
|
||||
{project && (
|
||||
<PeopleModal
|
||||
open={peopleModalOpen}
|
||||
onClose={() => setPeopleModalOpen(false)}
|
||||
resource={project}
|
||||
fetchPeople={getProjectPeople}
|
||||
currentUserEmail={user?.email ?? null}
|
||||
breadcrumb={[
|
||||
"Projects",
|
||||
project.name +
|
||||
(project.cm_number
|
||||
? ` (${project.cm_number})`
|
||||
: ""),
|
||||
"People",
|
||||
]}
|
||||
// Only owners may modify the member list. Without this prop
|
||||
// PeopleModal renders read-only — non-owners can still see
|
||||
// who has access but the add/remove controls are hidden.
|
||||
onSharedWithChange={
|
||||
project.is_owner === false
|
||||
? undefined
|
||||
: async (next) => {
|
||||
const updated = await updateProject(projectId, {
|
||||
shared_with: next,
|
||||
});
|
||||
setProject((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
shared_with: updated.shared_with,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { type CSSProperties, useState } from "react";
|
||||
import { type CSSProperties, type KeyboardEvent, useState } from "react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
File,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Pencil,
|
||||
Table2,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||
|
||||
export type ProjectTab = "documents" | "assistant" | "reviews";
|
||||
|
||||
|
|
@ -55,7 +57,14 @@ export function formatDate(iso: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function DocIcon({ fileType }: { fileType: string | null }) {
|
||||
export function DocIcon({
|
||||
fileType,
|
||||
muted = false,
|
||||
}: {
|
||||
fileType: string | null;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
if (muted) return <File className="h-4 w-4 text-gray-300 shrink-0" />;
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
|
||||
if (fileType === "docx" || fileType === "doc")
|
||||
|
|
@ -66,7 +75,6 @@ export function DocIcon({ fileType }: { fileType: string | null }) {
|
|||
export function DocVersionHistory({
|
||||
docId,
|
||||
filename,
|
||||
fileType,
|
||||
activeVersionNumber,
|
||||
currentVersionId,
|
||||
loading,
|
||||
|
|
@ -79,7 +87,6 @@ export function DocVersionHistory({
|
|||
}: {
|
||||
docId: string;
|
||||
filename: string;
|
||||
fileType: string | null;
|
||||
activeVersionNumber: number | null;
|
||||
currentVersionId: string | null;
|
||||
loading: boolean;
|
||||
|
|
@ -182,6 +189,8 @@ export function DocVersionHistory({
|
|||
return (
|
||||
<>
|
||||
{ordered.map((v) => {
|
||||
const versionFileType = v.file_type ?? null;
|
||||
const isDeleted = v.deleted_at != null;
|
||||
const numberLabel =
|
||||
typeof v.version_number === "number" &&
|
||||
v.version_number >= 1
|
||||
|
|
@ -190,6 +199,7 @@ export function DocVersionHistory({
|
|||
? "Original"
|
||||
: "—";
|
||||
const displayLabel = v.filename?.trim() || numberLabel;
|
||||
const downloadFilename = v.filename?.trim() || filename;
|
||||
const dt = new Date(v.created_at);
|
||||
const dateLabel = Number.isNaN(dt.valueOf())
|
||||
? ""
|
||||
|
|
@ -201,28 +211,42 @@ export function DocVersionHistory({
|
|||
minute: "2-digit",
|
||||
});
|
||||
const isEditing = editingVersionId === v.id;
|
||||
const rowBg = "bg-gray-100";
|
||||
const rowBg = isDeleted ? "bg-gray-50" : "bg-gray-100";
|
||||
const hoverBg = isDeleted ? "hover:bg-gray-50" : "hover:bg-gray-200";
|
||||
return (
|
||||
<div
|
||||
key={`ver-${docId}-${v.id}`}
|
||||
onClick={() => {
|
||||
if (isEditing) return;
|
||||
if (isEditing || isDeleted) return;
|
||||
onOpenVersion?.(v.id, displayLabel);
|
||||
}}
|
||||
className={`group flex h-10 cursor-pointer items-center pr-8 text-sm text-gray-500 transition-colors hover:bg-gray-200 ${rowBg}`}
|
||||
className={`group flex h-10 items-center pr-8 text-sm transition-colors ${rowBg} ${hoverBg} ${
|
||||
isDeleted
|
||||
? "cursor-default text-gray-300"
|
||||
: "cursor-pointer text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-200`}
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${
|
||||
isDeleted ? "group-hover:bg-gray-50" : "group-hover:bg-gray-200"
|
||||
}`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex h-2.5 w-2.5 shrink-0 items-center justify-center">
|
||||
<CornerDownRight
|
||||
className="h-3.5 w-3.5 text-gray-400"
|
||||
className={`h-3.5 w-3.5 ${
|
||||
isDeleted
|
||||
? "text-gray-300"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<DocIcon fileType={fileType} />
|
||||
<DocIcon
|
||||
fileType={versionFileType}
|
||||
muted={isDeleted}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
|
@ -243,22 +267,47 @@ export function DocVersionHistory({
|
|||
className="min-w-0 flex-1 border-b border-gray-300 bg-transparent text-sm text-gray-800 outline-none focus:border-gray-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate text-sm text-gray-700">
|
||||
<span
|
||||
className={`truncate text-sm ${
|
||||
isDeleted
|
||||
? "text-gray-300"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{isDeleted && (
|
||||
<span className="font-medium text-gray-500">
|
||||
[Deleted]{" "}
|
||||
</span>
|
||||
)}
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0 truncate text-xs uppercase text-gray-500">
|
||||
{fileType ?? <span className="text-gray-300">—</span>}
|
||||
<div
|
||||
className={`ml-auto w-20 shrink-0 truncate text-xs uppercase ${
|
||||
isDeleted ? "text-gray-300" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{versionFileType ?? (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24 shrink-0 truncate text-sm text-gray-400">
|
||||
—
|
||||
</div>
|
||||
<div className="w-20 shrink-0 truncate pl-1 text-sm text-gray-500">
|
||||
<div
|
||||
className={`w-20 shrink-0 truncate pl-1 text-sm ${
|
||||
isDeleted ? "text-gray-300" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{numberLabel}
|
||||
</div>
|
||||
<div className="w-32 shrink-0 truncate text-sm text-gray-500">
|
||||
<div
|
||||
className={`w-32 shrink-0 truncate text-sm ${
|
||||
isDeleted ? "text-gray-300" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{dateLabel ? formatDate(v.created_at) : <span className="text-gray-300">—</span>}
|
||||
</div>
|
||||
<div className="w-32 shrink-0 truncate text-sm text-gray-400">
|
||||
|
|
@ -268,20 +317,28 @@ export function DocVersionHistory({
|
|||
className="w-8 shrink-0 flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActions
|
||||
onRename={
|
||||
onRenameVersion
|
||||
? () => {
|
||||
setEditingVersionId(v.id);
|
||||
setEditingValue(v.filename ?? "");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
renameLabel="Rename version"
|
||||
onDownload={() =>
|
||||
onDownloadVersion(docId, v.id, filename)
|
||||
}
|
||||
/>
|
||||
{!isDeleted && (
|
||||
<RowActions
|
||||
onRename={
|
||||
onRenameVersion
|
||||
? () => {
|
||||
setEditingVersionId(v.id);
|
||||
setEditingValue(
|
||||
v.filename ?? "",
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
renameLabel="Rename version"
|
||||
onDownload={() =>
|
||||
onDownloadVersion(
|
||||
docId,
|
||||
v.id,
|
||||
downloadFilename,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -290,116 +347,125 @@ export function DocVersionHistory({
|
|||
);
|
||||
}
|
||||
|
||||
export function ProjectPageSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PageHeader
|
||||
align="start"
|
||||
actionGap="lg"
|
||||
breadcrumbs={[
|
||||
{ label: "Projects" },
|
||||
{ loading: true, skeletonClassName: "w-40" },
|
||||
]}
|
||||
actionGroups={[
|
||||
[
|
||||
{
|
||||
disabled: true,
|
||||
iconOnly: true,
|
||||
title: "Search",
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
iconOnly: true,
|
||||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
disabled: true,
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
label: <span className="hidden sm:inline">New Chat</span>,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
icon: <Table2 className="h-4 w-4" />,
|
||||
label: <span className="hidden sm:inline">New Review</span>,
|
||||
},
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
|
||||
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0">
|
||||
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-56 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0">
|
||||
<div className="h-3 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectPageHeader({
|
||||
project,
|
||||
tab,
|
||||
search,
|
||||
creatingChat,
|
||||
creatingReview,
|
||||
docsCount,
|
||||
isOwner,
|
||||
onBackToProjects,
|
||||
onTitleCommit,
|
||||
onRenameProject,
|
||||
onRenameCmNumber,
|
||||
onOwnerOnly,
|
||||
onDeleteProject,
|
||||
onSearchChange,
|
||||
onOpenPeople,
|
||||
onNewChat,
|
||||
onNewReview,
|
||||
}: {
|
||||
project: Project;
|
||||
tab: ProjectTab;
|
||||
project: Project | null;
|
||||
search: string;
|
||||
creatingChat: boolean;
|
||||
creatingReview: boolean;
|
||||
docsCount: number;
|
||||
isOwner: boolean;
|
||||
onBackToProjects: () => void;
|
||||
onTitleCommit: (newName: string) => void | Promise<void>;
|
||||
onRenameProject: (name: string) => void;
|
||||
onRenameCmNumber: (cmNumber: string) => void;
|
||||
onOwnerOnly: (action: string) => void;
|
||||
onDeleteProject: () => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onOpenPeople: () => void;
|
||||
onNewChat: () => void;
|
||||
onNewReview: () => void;
|
||||
}) {
|
||||
const [editingField, setEditingField] = useState<"name" | "cm" | null>(
|
||||
null,
|
||||
);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const startEdit = (field: "name" | "cm") => {
|
||||
if (!project) return;
|
||||
if (!isOwner) {
|
||||
onOwnerOnly(
|
||||
field === "name"
|
||||
? "rename this project"
|
||||
: "rename this project's CM number",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDraft(field === "name" ? project.name : project.cm_number ?? "");
|
||||
setEditingField(field);
|
||||
};
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editingField) return;
|
||||
const value = draft.trim();
|
||||
if (editingField === "name") onRenameProject(value);
|
||||
else onRenameCmNumber(value);
|
||||
setEditingField(null);
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditingField(null);
|
||||
}
|
||||
};
|
||||
|
||||
const editInputClassName =
|
||||
"min-w-0 cursor-text border-0 border-b border-gray-200 bg-transparent font-serif text-2xl font-medium outline-none transition-colors focus:border-gray-300";
|
||||
|
||||
const titleLabel = !project ? undefined : editingField === "name" ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
size={Math.max(draft.length + 1, 3)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
className={`${editInputClassName} text-gray-900`}
|
||||
aria-label="Rename project"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => startEdit("name")}
|
||||
className="inline-block cursor-text"
|
||||
title="Rename"
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
const cmSuffix = !project ? null : editingField === "cm" ? (
|
||||
<span className="ml-1 inline-flex items-center text-gray-400">
|
||||
(#
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
size={Math.max(draft.length + 1, 3)}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
className={`${editInputClassName} text-gray-400`}
|
||||
aria-label="Rename CM number"
|
||||
/>
|
||||
)
|
||||
</span>
|
||||
) : project.cm_number ? (
|
||||
<span
|
||||
onClick={() => startEdit("cm")}
|
||||
className="ml-1 inline-block cursor-text text-gray-400"
|
||||
title="Rename CM"
|
||||
>
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
|
|
@ -409,17 +475,16 @@ export function ProjectPageHeader({
|
|||
title: "Back to Projects",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<RenameableTitle
|
||||
value={project.name}
|
||||
onCommit={onTitleCommit}
|
||||
/>
|
||||
),
|
||||
suffix: project.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null,
|
||||
...(project
|
||||
? {
|
||||
label: titleLabel,
|
||||
suffix: cmSuffix,
|
||||
cursor: "text",
|
||||
}
|
||||
: {
|
||||
loading: true,
|
||||
skeletonClassName: "w-40",
|
||||
}),
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
|
|
@ -438,34 +503,69 @@ export function ProjectPageHeader({
|
|||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
onClick: onNewChat,
|
||||
disabled: creatingChat,
|
||||
icon: creatingChat ? (
|
||||
type: "custom",
|
||||
render: (
|
||||
<HeaderActionsMenu
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
icon: Pencil,
|
||||
onSelect: () => startEdit("name"),
|
||||
},
|
||||
{
|
||||
label: "Rename CM",
|
||||
icon: Hash,
|
||||
onSelect: () => startEdit("cm"),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
onSelect: onDeleteProject,
|
||||
variant: "danger",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
{
|
||||
gap: "xs",
|
||||
actions: [
|
||||
{
|
||||
onClick: onNewChat,
|
||||
disabled: creatingChat,
|
||||
icon: creatingChat ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
),
|
||||
label: <span className="hidden sm:inline">New Chat</span>,
|
||||
},
|
||||
{
|
||||
onClick: onNewReview,
|
||||
disabled: docsCount === 0 || creatingReview,
|
||||
icon: creatingReview ? (
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
New Chat
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
onClick: onNewReview,
|
||||
disabled: docsCount === 0 || creatingReview,
|
||||
icon: creatingReview ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Table2 className="h-4 w-4" />
|
||||
),
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
New Review
|
||||
</span>
|
||||
),
|
||||
tooltip: docsCount === 0 ? "Upload a document first" : null,
|
||||
},
|
||||
],
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
New Review
|
||||
</span>
|
||||
),
|
||||
tooltip:
|
||||
docsCount === 0
|
||||
? "Upload a document first"
|
||||
: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ export function ProjectsOverview() {
|
|||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Page header */}
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
actions={[
|
||||
{
|
||||
type: "search",
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
onClick={onToggle}
|
||||
className={cn(
|
||||
"h-9 w-9 p-2.5 items-center flex transition-colors",
|
||||
"rounded-xl hover:bg-gray-100",
|
||||
"rounded-md hover:bg-gray-100",
|
||||
)}
|
||||
title={isOpen ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -536,8 +536,11 @@ export function DocView({
|
|||
return (
|
||||
<DocxView
|
||||
documentId={doc.document_id}
|
||||
versionId={doc.version_id ?? null}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
rounded={rounded}
|
||||
bordered={bordered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
69
frontend/src/app/components/shared/HeaderActionsMenu.tsx
Normal file
69
frontend/src/app/components/shared/HeaderActionsMenu.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type HeaderActionsMenuItem = {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
};
|
||||
|
||||
export function HeaderActionsMenu({
|
||||
items,
|
||||
title = "Actions",
|
||||
}: {
|
||||
items: HeaderActionsMenuItem[];
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-600 transition-all",
|
||||
"hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
|
||||
)}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-[160] w-48 bg-white">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.label}
|
||||
disabled={item.disabled}
|
||||
variant={
|
||||
item.variant === "danger"
|
||||
? "destructive"
|
||||
: "default"
|
||||
}
|
||||
onSelect={item.onSelect}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs",
|
||||
item.variant === "danger" &&
|
||||
"text-red-600 focus:bg-red-50 focus:text-red-700",
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5" />}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,13 +117,13 @@ export function Modal({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-1 pb-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
{children}
|
||||
</div>
|
||||
{hasFooter && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-4",
|
||||
"flex items-center gap-3 p-3",
|
||||
secondaryAction || footerInfo
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
|
|
@ -186,7 +186,7 @@ function ModalActionButton({
|
|||
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "secondary" && "text-gray-600 hover:text-gray-950",
|
||||
fallbackVariant === "secondary" &&
|
||||
"rounded-full border border-gray-200/80 bg-gray-100/70 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.78),inset_0_-3px_8px_rgba(148,163,184,0.14)] backdrop-blur-xl hover:bg-gray-100",
|
||||
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "danger" &&
|
||||
"rounded-full border border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface PageHeaderBreadcrumb {
|
|||
label?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
onClick?: () => void;
|
||||
cursor?: "text";
|
||||
loading?: boolean;
|
||||
skeletonClassName?: string;
|
||||
title?: string;
|
||||
|
|
@ -30,7 +31,6 @@ type PageHeaderButtonAction = {
|
|||
title?: string;
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
tooltip?: ReactNode;
|
||||
};
|
||||
|
||||
|
|
@ -70,18 +70,28 @@ export type PageHeaderAction =
|
|||
| PageHeaderCustomAction
|
||||
| ReactNode;
|
||||
|
||||
type PageHeaderActionGap = "xs" | "sm" | "md" | "lg";
|
||||
type PageHeaderActionGroup =
|
||||
| PageHeaderAction[]
|
||||
| {
|
||||
actions: PageHeaderAction[];
|
||||
gap?: PageHeaderActionGap;
|
||||
};
|
||||
|
||||
interface PageHeaderProps {
|
||||
children?: ReactNode;
|
||||
actions?: PageHeaderAction[];
|
||||
actionGroups?: PageHeaderAction[][];
|
||||
actionGroups?: PageHeaderActionGroup[];
|
||||
align?: "center" | "start";
|
||||
shrink?: boolean;
|
||||
className?: string;
|
||||
actionGap?: "sm" | "md" | "lg";
|
||||
actionGap?: PageHeaderActionGap;
|
||||
breadcrumbs?: PageHeaderBreadcrumb[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const actionGapClassName = {
|
||||
xs: "gap-1",
|
||||
sm: "gap-2.5",
|
||||
md: "gap-2.5",
|
||||
lg: "gap-2.5",
|
||||
|
|
@ -96,18 +106,24 @@ export function PageHeader({
|
|||
className,
|
||||
actionGap = "sm",
|
||||
breadcrumbs,
|
||||
loading = false,
|
||||
}: PageHeaderProps) {
|
||||
const headerContent = breadcrumbs?.length ? (
|
||||
<PageHeaderBreadcrumbs items={breadcrumbs} />
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const actionsDisabled =
|
||||
loading || !!breadcrumbs?.some((item) => item.loading);
|
||||
const actionItems = actions?.filter(Boolean) ?? [];
|
||||
const groupedActionItems =
|
||||
const groupedActionItems = (
|
||||
actionGroups
|
||||
?.map((group) => group.filter(Boolean))
|
||||
.filter((group) => group.length > 0) ??
|
||||
(actionItems.length > 0 ? [actionItems] : []);
|
||||
?.map((group) => normalizeActionGroup(group, actionGap))
|
||||
.filter((group) => group.actions.length > 0) ??
|
||||
(actionItems.length > 0
|
||||
? [{ actions: actionItems, gap: actionGap }]
|
||||
: [])
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -128,13 +144,16 @@ export function PageHeader({
|
|||
key={groupIndex}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
actionGapClassName[actionGap],
|
||||
actionGapClassName[group.gap],
|
||||
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
{group.map((action, index) => (
|
||||
{group.actions.map((action, index) => (
|
||||
<Fragment key={index}>
|
||||
<PageHeaderActionRenderer action={action} />
|
||||
<PageHeaderActionRenderer
|
||||
action={action}
|
||||
disabled={actionsDisabled}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -145,21 +164,80 @@ export function PageHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) {
|
||||
if (!isPageHeaderActionObject(action)) return <>{action}</>;
|
||||
function normalizeActionGroup(
|
||||
group: PageHeaderActionGroup,
|
||||
fallbackGap: PageHeaderActionGap,
|
||||
) {
|
||||
if (Array.isArray(group)) {
|
||||
return {
|
||||
actions: group.filter(Boolean),
|
||||
gap: fallbackGap,
|
||||
};
|
||||
}
|
||||
return {
|
||||
actions: group.actions.filter(Boolean),
|
||||
gap: group.gap ?? fallbackGap,
|
||||
};
|
||||
}
|
||||
|
||||
function PageHeaderActionRenderer({
|
||||
action,
|
||||
disabled,
|
||||
}: {
|
||||
action: PageHeaderAction;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
if (!isPageHeaderActionObject(action)) {
|
||||
return disabled ? (
|
||||
<span className="inline-flex h-7 items-center opacity-40 pointer-events-none">
|
||||
{action}
|
||||
</span>
|
||||
) : (
|
||||
<>{action}</>
|
||||
);
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case "search":
|
||||
return <PageHeaderSearchActionControl action={action} />;
|
||||
return (
|
||||
<PageHeaderSearchActionControl
|
||||
action={action}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case "delete":
|
||||
return <PageHeaderDeleteActionControl action={action} />;
|
||||
return (
|
||||
<PageHeaderDeleteActionControl
|
||||
action={action}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case "new":
|
||||
return <PageHeaderNewActionControl action={action} />;
|
||||
return (
|
||||
<PageHeaderNewActionControl
|
||||
action={action}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
case "custom":
|
||||
return <>{action.render}</>;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center",
|
||||
disabled && "pointer-events-none opacity-40",
|
||||
)}
|
||||
>
|
||||
{action.render}
|
||||
</span>
|
||||
);
|
||||
case "button":
|
||||
default:
|
||||
return <PageHeaderButtonActionControl action={action} />;
|
||||
return (
|
||||
<PageHeaderButtonActionControl
|
||||
action={action}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,20 +249,21 @@ function isPageHeaderActionObject(
|
|||
|
||||
function PageHeaderButtonActionControl({
|
||||
action,
|
||||
disabled,
|
||||
}: {
|
||||
action: PageHeaderButtonAction;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const iconOnly = action.iconOnly ?? !action.label;
|
||||
return (
|
||||
<div className={action.tooltip ? "relative group" : undefined}>
|
||||
<PageHeaderActionButton
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
disabled={disabled || action.disabled}
|
||||
title={action.title}
|
||||
aria-label={action.title}
|
||||
variant={action.variant}
|
||||
iconOnly={iconOnly}
|
||||
className={action.className}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
|
|
@ -200,14 +279,16 @@ function PageHeaderButtonActionControl({
|
|||
|
||||
function PageHeaderNewActionControl({
|
||||
action,
|
||||
disabled,
|
||||
}: {
|
||||
action: PageHeaderNewAction;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const title = action.title ?? "New";
|
||||
return (
|
||||
<PageHeaderActionButton
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
disabled={disabled || action.disabled || action.loading}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
iconOnly
|
||||
|
|
@ -223,14 +304,16 @@ function PageHeaderNewActionControl({
|
|||
|
||||
function PageHeaderDeleteActionControl({
|
||||
action,
|
||||
disabled,
|
||||
}: {
|
||||
action: PageHeaderDeleteAction;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const title = action.title ?? "Delete";
|
||||
return (
|
||||
<PageHeaderActionButton
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
disabled={disabled || action.disabled || action.loading}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
iconOnly
|
||||
|
|
@ -247,8 +330,10 @@ function PageHeaderDeleteActionControl({
|
|||
|
||||
function PageHeaderSearchActionControl({
|
||||
action,
|
||||
disabled,
|
||||
}: {
|
||||
action: PageHeaderSearchAction;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -280,6 +365,7 @@ function PageHeaderSearchActionControl({
|
|||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={action.value}
|
||||
|
|
@ -290,6 +376,7 @@ function PageHeaderSearchActionControl({
|
|||
) : (
|
||||
<PageHeaderActionButton
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
iconOnly
|
||||
title={placeholder}
|
||||
aria-label={placeholder}
|
||||
|
|
@ -301,7 +388,10 @@ function PageHeaderSearchActionControl({
|
|||
);
|
||||
}
|
||||
|
||||
type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
type PageHeaderActionButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"className"
|
||||
> & {
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
|
@ -333,7 +423,6 @@ function pageHeaderActionControlClassName({
|
|||
|
||||
function PageHeaderActionButton({
|
||||
children,
|
||||
className,
|
||||
variant = "default",
|
||||
iconOnly = false,
|
||||
disabled,
|
||||
|
|
@ -346,7 +435,6 @@ function PageHeaderActionButton({
|
|||
variant,
|
||||
iconOnly,
|
||||
disabled,
|
||||
className,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
|
|
@ -411,13 +499,21 @@ function BreadcrumbItem({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
item.cursor === "text" && "cursor-text",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{showSuffix && item.suffix}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
"min-w-0 truncate transition-colors",
|
||||
item.cursor === "text" && "cursor-text",
|
||||
current
|
||||
? "text-gray-900"
|
||||
: item.onClick
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ interface Props {
|
|||
suffix?: React.ReactNode;
|
||||
}
|
||||
|
||||
type CaretDocument = Document & {
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offset: number } | null;
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
};
|
||||
|
||||
export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
|
@ -15,10 +23,14 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
|||
const escaped = useRef(false);
|
||||
|
||||
function startEditing(e: React.MouseEvent) {
|
||||
const doc = document as any;
|
||||
const doc = document as CaretDocument;
|
||||
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
|
||||
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
|
||||
caretPos.current = caret ? caret.offset : range ? range.startOffset : null;
|
||||
caretPos.current = caret
|
||||
? caret.offset
|
||||
: range
|
||||
? range.startOffset
|
||||
: null;
|
||||
escaped.current = false;
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
|
|
@ -61,7 +73,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
|||
|
||||
return (
|
||||
<span
|
||||
className="text-gray-900 cursor-text hover:text-gray-600 transition-colors"
|
||||
className="inline-block cursor-text text-gray-900 transition-colors hover:text-gray-600"
|
||||
onClick={startEditing}
|
||||
>
|
||||
{value}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface Props {
|
|||
onUploadNewVersion?: () => void;
|
||||
onNewSubfolder?: () => void;
|
||||
deleting?: boolean;
|
||||
deleteDisabled?: boolean;
|
||||
onRename?: () => void;
|
||||
onUpdateCmNumber?: () => void;
|
||||
newSubfolderLabel?: string;
|
||||
|
|
@ -47,6 +48,7 @@ export function RowActionMenuItems({
|
|||
onUploadNewVersion,
|
||||
onNewSubfolder,
|
||||
deleting,
|
||||
deleteDisabled = false,
|
||||
onRename,
|
||||
onUpdateCmNumber,
|
||||
newSubfolderLabel = "New subfolder",
|
||||
|
|
@ -141,7 +143,12 @@ export function RowActionMenuItems({
|
|||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
|
||||
aria-disabled={deleteDisabled}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
|
||||
deleteDisabled
|
||||
? "cursor-not-allowed opacity-40 hover:bg-transparent"
|
||||
: "hover:bg-red-50"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleteLabel}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
|||
|
||||
if (!open || !doc) return null;
|
||||
|
||||
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
|
||||
const accept = ".pdf,.docx,.doc";
|
||||
|
||||
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface Document {
|
|||
project_id: string | null;
|
||||
folder_id?: string | null;
|
||||
filename: string;
|
||||
owner_email?: string | null;
|
||||
owner_display_name?: string | null;
|
||||
file_type: string | null; // pdf | docx | doc
|
||||
storage_path: string | null;
|
||||
pdf_storage_path: string | null;
|
||||
|
|
|
|||
155
frontend/src/app/components/tabular/ApplyWorkflowPresetModal.tsx
Normal file
155
frontend/src/app/components/tabular/ApplyWorkflowPresetModal.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Loader2, Search } from "lucide-react";
|
||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import type { Workflow } from "@/app/components/shared/types";
|
||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
applying?: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (workflow: Workflow) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function ApplyWorkflowPresetModal({
|
||||
open,
|
||||
applying = false,
|
||||
onClose,
|
||||
onApply,
|
||||
}: Props) {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
|
||||
(workflow) => workflow.type === "tabular",
|
||||
);
|
||||
setLoading(true);
|
||||
setSearch("");
|
||||
setSelectedWorkflowId(null);
|
||||
listWorkflows("tabular")
|
||||
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
|
||||
.catch(() => setWorkflows(builtinTabular))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const filteredWorkflows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return workflows;
|
||||
return workflows.filter((workflow) =>
|
||||
[workflow.title, workflow.practice ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [search, workflows]);
|
||||
|
||||
const selectedWorkflow =
|
||||
workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
|
||||
const canApply =
|
||||
!!selectedWorkflow &&
|
||||
!applying &&
|
||||
!loading &&
|
||||
!!selectedWorkflow.columns_config?.length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Apply preset workflow"
|
||||
size="md"
|
||||
primaryAction={{
|
||||
label: applying ? "Applying..." : "Apply",
|
||||
onClick: () => {
|
||||
if (selectedWorkflow) void onApply(selectedWorkflow);
|
||||
},
|
||||
disabled: !canApply,
|
||||
icon: applying ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : undefined,
|
||||
}}
|
||||
cancelAction={{ label: "Cancel", onClick: onClose }}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Choose a tabular review workflow. Applying it will replace
|
||||
the current review columns with the workflow preset.
|
||||
</p>
|
||||
<div className="flex h-9 items-center gap-2 rounded-xl bg-gray-100 px-3">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search workflows..."
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-1.5">
|
||||
{loading ? (
|
||||
<div className="space-y-2 p-1">
|
||||
{[1, 2, 3, 4].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-14 animate-pulse rounded-xl bg-white"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : filteredWorkflows.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-gray-400">
|
||||
No workflows found
|
||||
</div>
|
||||
) : (
|
||||
filteredWorkflows.map((workflow) => {
|
||||
const selected = workflow.id === selectedWorkflowId;
|
||||
const columnCount =
|
||||
workflow.columns_config?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedWorkflowId(workflow.id)
|
||||
}
|
||||
disabled={columnCount === 0}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-white text-gray-950 shadow-[0_1px_4px_rgba(15,23,42,0.06)]"
|
||||
: "text-gray-700 hover:bg-white/75",
|
||||
columnCount === 0 &&
|
||||
"cursor-not-allowed opacity-45",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{workflow.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-gray-400">
|
||||
{workflow.practice ?? "Tabular"} ·{" "}
|
||||
{columnCount}{" "}
|
||||
{columnCount === 1
|
||||
? "column"
|
||||
: "columns"}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -472,7 +472,7 @@ function TRAssistantMessage({
|
|||
title={`${cit.col_name} · ${cit.doc_name.replace(/\.[^.]+$/, "")}`}
|
||||
className="mx-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-[10px] font-medium bg-gray-100 text-gray-900 hover:bg-gray-200 transition-colors align-super font-serif"
|
||||
>
|
||||
{idx + 1}
|
||||
{cit.ref}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,24 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
Play,
|
||||
ChevronDown,
|
||||
MessageSquare,
|
||||
Download,
|
||||
Users,
|
||||
Upload,
|
||||
X,
|
||||
Pencil,
|
||||
Trash2,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
clearTabularCells,
|
||||
deleteTabularReview,
|
||||
getTabularReview,
|
||||
getProject,
|
||||
getTabularReviewPeople,
|
||||
|
|
@ -20,14 +34,17 @@ import type {
|
|||
Project,
|
||||
TabularCell,
|
||||
TabularReview,
|
||||
Workflow,
|
||||
} from "../shared/types";
|
||||
import { AddColumnModal } from "./AddColumnModal";
|
||||
import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal";
|
||||
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
||||
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
|
||||
import { PeopleModal } from "../shared/PeopleModal";
|
||||
import { OwnerOnlyModal } from "../shared/OwnerOnlyModal";
|
||||
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
|
||||
import { RenameableTitle } from "../shared/RenameableTitle";
|
||||
import { ConfirmPopup } from "../shared/ConfirmPopup";
|
||||
import { HeaderActionsMenu } from "../shared/HeaderActionsMenu";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import {
|
||||
|
|
@ -62,6 +79,14 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
const [addColOpen, setAddColOpen] = useState(false);
|
||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||
const [workflowPresetModalOpen, setWorkflowPresetModalOpen] =
|
||||
useState(false);
|
||||
const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false);
|
||||
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
|
||||
useState(false);
|
||||
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
|
||||
"idle" | "deleting" | "deleted"
|
||||
>("idle");
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
const { user } = useAuth();
|
||||
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
|
||||
|
|
@ -516,10 +541,97 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
|
||||
async function handleTitleCommit(newTitle: string) {
|
||||
if (!newTitle || newTitle === review?.title) return;
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this tabular review");
|
||||
return;
|
||||
}
|
||||
setReview((prev) => (prev ? { ...prev, title: newTitle } : prev));
|
||||
await updateTabularReview(reviewId, { title: newTitle });
|
||||
}
|
||||
|
||||
function requestReviewRename() {
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this tabular review");
|
||||
return;
|
||||
}
|
||||
const nextTitle = window.prompt(
|
||||
"Rename tabular review",
|
||||
review?.title ?? "Untitled Review",
|
||||
);
|
||||
const trimmed = nextTitle?.trim();
|
||||
if (!trimmed) return;
|
||||
void handleTitleCommit(trimmed);
|
||||
}
|
||||
|
||||
function requestReviewDelete() {
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("delete this tabular review");
|
||||
return;
|
||||
}
|
||||
setDeleteReviewStatus("idle");
|
||||
setDeleteReviewConfirmOpen(true);
|
||||
}
|
||||
|
||||
async function confirmReviewDelete() {
|
||||
if (deleteReviewStatus === "deleting") return;
|
||||
setDeleteReviewStatus("deleting");
|
||||
try {
|
||||
await deleteTabularReview(reviewId);
|
||||
setDeleteReviewStatus("deleted");
|
||||
setTimeout(() => {
|
||||
router.push(
|
||||
projectId
|
||||
? `/projects/${projectId}?tab=reviews`
|
||||
: "/tabular-reviews",
|
||||
);
|
||||
}, 250);
|
||||
} catch (err) {
|
||||
setDeleteReviewStatus("idle");
|
||||
console.error("Failed to delete tabular review", err);
|
||||
}
|
||||
}
|
||||
|
||||
function requestWorkflowPreset() {
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("apply a preset workflow");
|
||||
return;
|
||||
}
|
||||
setWorkflowPresetModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleApplyWorkflowPreset(workflow: Workflow) {
|
||||
if (!workflow.columns_config?.length) return;
|
||||
const nextColumns = workflow.columns_config.map((column, index) => ({
|
||||
...column,
|
||||
index,
|
||||
}));
|
||||
const previousColumns = columns;
|
||||
const previousCells = cells;
|
||||
setApplyingWorkflowPreset(true);
|
||||
setColumns(nextColumns);
|
||||
setCells([]);
|
||||
try {
|
||||
await saveColumnsConfig(nextColumns);
|
||||
if (documents.length > 0) {
|
||||
try {
|
||||
await clearTabularCells(
|
||||
reviewId,
|
||||
documents.map((document) => document.id),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to clear old tabular cells", err);
|
||||
}
|
||||
}
|
||||
setWorkflowPresetModalOpen(false);
|
||||
} catch (err) {
|
||||
setColumns(previousColumns);
|
||||
setCells(previousCells);
|
||||
console.error("Failed to apply workflow preset", err);
|
||||
} finally {
|
||||
setApplyingWorkflowPreset(false);
|
||||
}
|
||||
}
|
||||
|
||||
const q = search.toLowerCase();
|
||||
const filteredDocuments = q
|
||||
? documents.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
|
|
@ -573,75 +685,94 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
skeletonClassName: "w-40",
|
||||
}
|
||||
: {
|
||||
label: (
|
||||
<RenameableTitle
|
||||
value={review?.title || "Untitled Review"}
|
||||
onCommit={handleTitleCommit}
|
||||
/>
|
||||
),
|
||||
label: review?.title || "Untitled Review",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
!loading
|
||||
? [
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: "Search documents…",
|
||||
},
|
||||
!projectId
|
||||
? {
|
||||
onClick: () =>
|
||||
setPeopleModalOpen(true),
|
||||
disabled: loading,
|
||||
iconOnly: true,
|
||||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
onClick: () =>
|
||||
exportTabularReviewToExcel({
|
||||
reviewTitle:
|
||||
review?.title ||
|
||||
"Tabular Review",
|
||||
columns,
|
||||
documents,
|
||||
cells,
|
||||
}),
|
||||
disabled:
|
||||
columns.length === 0 ||
|
||||
documents.length === 0,
|
||||
title: "Export to Excel",
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
Export
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
onClick: handleGenerate,
|
||||
disabled:
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig,
|
||||
icon: generating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
),
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
{generating ? "Running…" : "Run"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
actionGroups={[
|
||||
[
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: "Search documents…",
|
||||
},
|
||||
!projectId
|
||||
? {
|
||||
onClick: () => setPeopleModalOpen(true),
|
||||
disabled: loading,
|
||||
iconOnly: true,
|
||||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
type: "custom",
|
||||
render: (
|
||||
<HeaderActionsMenu
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
icon: Pencil,
|
||||
onSelect: requestReviewRename,
|
||||
},
|
||||
{
|
||||
label: "Apply preset workflow",
|
||||
icon: WandSparkles,
|
||||
onSelect:
|
||||
requestWorkflowPreset,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
onSelect: requestReviewDelete,
|
||||
variant: "danger",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
onClick: () =>
|
||||
exportTabularReviewToExcel({
|
||||
reviewTitle:
|
||||
review?.title || "Tabular Review",
|
||||
columns,
|
||||
documents,
|
||||
cells,
|
||||
}),
|
||||
disabled:
|
||||
columns.length === 0 ||
|
||||
documents.length === 0,
|
||||
title: "Export to Excel",
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
Export
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
onClick: handleGenerate,
|
||||
disabled:
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig,
|
||||
icon: generating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
),
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
{generating ? "Running…" : "Run"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
|
|
@ -926,6 +1057,37 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}
|
||||
/>
|
||||
|
||||
<ApplyWorkflowPresetModal
|
||||
open={workflowPresetModalOpen}
|
||||
applying={applyingWorkflowPreset}
|
||||
onClose={() => {
|
||||
if (applyingWorkflowPreset) return;
|
||||
setWorkflowPresetModalOpen(false);
|
||||
}}
|
||||
onApply={handleApplyWorkflowPreset}
|
||||
/>
|
||||
|
||||
<ConfirmPopup
|
||||
open={deleteReviewConfirmOpen}
|
||||
title="Delete tabular review?"
|
||||
message="This will permanently delete the tabular review and its generated cells."
|
||||
confirmLabel="Delete"
|
||||
confirmStatus={
|
||||
deleteReviewStatus === "deleting"
|
||||
? "loading"
|
||||
: deleteReviewStatus === "deleted"
|
||||
? "complete"
|
||||
: "idle"
|
||||
}
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => {
|
||||
if (deleteReviewStatus === "deleting") return;
|
||||
setDeleteReviewConfirmOpen(false);
|
||||
setDeleteReviewStatus("idle");
|
||||
}}
|
||||
onConfirm={() => void confirmReviewDelete()}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
ChevronDown,
|
||||
Folder,
|
||||
|
|
@ -23,6 +22,7 @@ import { useDirectoryData } from "../shared/useDirectoryData";
|
|||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import type { Project } from "../shared/types";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
|
|
@ -177,7 +177,7 @@ function MarkdownBody({ content }: { content: string }) {
|
|||
// ---------------------------------------------------------------------------
|
||||
function AssistantPanel({ workflow }: { workflow: Workflow }) {
|
||||
return (
|
||||
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="py-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Workflow Prompt
|
||||
|
|
@ -202,7 +202,7 @@ function TabularPanel({ workflow }: { workflow: Workflow }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="py-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">Columns</p>
|
||||
</div>
|
||||
|
|
@ -450,60 +450,86 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
p.documents.length > 0,
|
||||
);
|
||||
|
||||
const breadcrumbs =
|
||||
screen === "select"
|
||||
? ["Workflows", "Select workflow"]
|
||||
: [
|
||||
<button
|
||||
key="workflows"
|
||||
type="button"
|
||||
onClick={() => setScreen("select")}
|
||||
className="transition-colors hover:text-gray-700"
|
||||
>
|
||||
Workflows
|
||||
</button>,
|
||||
wf.title,
|
||||
wf.type === "assistant" ? "New Chat" : "New Review",
|
||||
];
|
||||
|
||||
const selectPageAction = () => {
|
||||
router.push(`/workflows/${wf.id}`);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div
|
||||
className={`w-full rounded-2xl bg-white shadow-2xl flex flex-col h-[600px] transition-all duration-200 ${screen === "select" ? "max-w-4xl" : "max-w-2xl"}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{screen === "select" ? (
|
||||
<>
|
||||
<span>Workflows</span>
|
||||
<span>›</span>
|
||||
<span>Select workflow</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setScreen("select")}
|
||||
className="hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Workflows
|
||||
</button>
|
||||
<span>›</span>
|
||||
<span className="truncate max-w-[160px]">
|
||||
{wf.title}
|
||||
</span>
|
||||
<span>›</span>
|
||||
<span>
|
||||
{wf.type === "assistant"
|
||||
? "New Chat"
|
||||
: "New Review"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<Modal
|
||||
open={!!workflow}
|
||||
onClose={handleClose}
|
||||
size={screen === "select" ? "xl" : "lg"}
|
||||
breadcrumbs={breadcrumbs}
|
||||
secondaryAction={
|
||||
screen === "select"
|
||||
? {
|
||||
label: wf.is_system ? "View Page" : "Edit",
|
||||
onClick: selectPageAction,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footerStatus={
|
||||
screen === "configure" &&
|
||||
(wf.type === "assistant"
|
||||
? !inProject && selectedDocIds.size > 0
|
||||
: selectedDocIds.size > 0) ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedDocIds.size} selected
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={
|
||||
screen === "select"
|
||||
? {
|
||||
label: "Use",
|
||||
onClick: () => setScreen("configure"),
|
||||
}
|
||||
: wf.type === "assistant"
|
||||
? {
|
||||
label: saving ? "Starting…" : "Start Chat",
|
||||
onClick: handleStartChat,
|
||||
disabled:
|
||||
saving || (inProject && !selectedProjectId),
|
||||
}
|
||||
: {
|
||||
label: saving ? "Creating…" : "Create Review",
|
||||
onClick: handleCreateReview,
|
||||
disabled:
|
||||
saving ||
|
||||
selectedDocIds.size === 0 ||
|
||||
(inProject && !selectedProjectId),
|
||||
}
|
||||
}
|
||||
cancelAction={false}
|
||||
>
|
||||
|
||||
{/* ── SELECT SCREEN ── */}
|
||||
{screen === "select" && (
|
||||
<>
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
|
||||
{/* Left: workflow list */}
|
||||
<div className="w-80 shrink-0 flex flex-col border-t border-gray-200">
|
||||
<div className="w-80 shrink-0 flex flex-col overflow-hidden">
|
||||
{/* Search */}
|
||||
<div className="px-3 py-2 shrink-0 border-b border-gray-100">
|
||||
<div className="px-2 py-3 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
|
|
@ -533,7 +559,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
ref={isSelected ? selectedRowRef : null}
|
||||
type="button"
|
||||
onClick={() => setSelected(wfItem)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-xs text-left border-b border-gray-200 transition-colors ${isSelected ? "bg-gray-100" : "hover:bg-gray-50"}`}
|
||||
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${isSelected ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50"}`}
|
||||
>
|
||||
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
|
||||
{wfItem.title}
|
||||
|
|
@ -551,46 +577,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
) : (
|
||||
<TabularPanel key={wf.id} workflow={wf} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
|
||||
{wf.is_system ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/workflows/${wf.id}`);
|
||||
handleClose();
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
View Page
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/workflows/${wf.id}`);
|
||||
handleClose();
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setScreen("configure")}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
|
||||
{screen === "configure" && wf.type === "assistant" && (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* Add-on prompt */}
|
||||
<div className="px-5 pb-3 shrink-0">
|
||||
<div className="pb-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||
Message (optional)
|
||||
</p>
|
||||
|
|
@ -606,7 +600,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Toggle row */}
|
||||
<div className="px-5 py-3 flex flex-col gap-2 shrink-0">
|
||||
<div className="py-3 flex flex-col gap-2 shrink-0">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
Create in a project
|
||||
</span>
|
||||
|
|
@ -623,12 +617,12 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
|
||||
{inProject ? (
|
||||
<>
|
||||
<div className="px-5 pt-1 pb-1 shrink-0">
|
||||
<div className="pt-1 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select project
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<div className="pb-2 shrink-0">
|
||||
<SimpleProjectPicker
|
||||
projects={projects}
|
||||
selectedId={selectedProjectId}
|
||||
|
|
@ -638,14 +632,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-5 pt-1 pb-1 shrink-0">
|
||||
<div className="pt-1 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pt-1.5 pb-1 shrink-0">
|
||||
<div className="pt-1.5 pb-1 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
|
|
@ -671,7 +665,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
</div>
|
||||
|
||||
{/* File browser */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
<FileDirectory
|
||||
standaloneDocs={filteredStandalone}
|
||||
directoryProjects={
|
||||
|
|
@ -691,33 +685,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-gray-400">
|
||||
{!inProject && selectedDocIds.size > 0
|
||||
? `${selectedDocIds.size} selected`
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleStartChat}
|
||||
disabled={
|
||||
saving || (inProject && !selectedProjectId)
|
||||
}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Starting…" : "Start Chat"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TABULAR CONFIGURE SCREEN ── */}
|
||||
{screen === "configure" && wf.type === "tabular" && (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* Toggle stacked */}
|
||||
<div className="px-5 pb-3 flex flex-col gap-2 shrink-0">
|
||||
<div className="pb-3 flex flex-col gap-2 shrink-0">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
Create in a project
|
||||
</span>
|
||||
|
|
@ -735,12 +710,12 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
{/* Project section */}
|
||||
{inProject && (
|
||||
<>
|
||||
<div className="px-5 pt-1 pb-1 shrink-0">
|
||||
<div className="pt-1 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select Project
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<div className="pb-2 shrink-0">
|
||||
<SimpleProjectPicker
|
||||
projects={projects}
|
||||
selectedId={selectedProjectId}
|
||||
|
|
@ -757,14 +732,14 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
)}
|
||||
|
||||
{/* Documents section */}
|
||||
<div className="px-5 pt-3 pb-1 shrink-0">
|
||||
<div className="pt-3 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select Documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pt-1.5 pb-1 shrink-0">
|
||||
<div className="pt-1.5 pb-1 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
|
|
@ -788,7 +763,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
</div>
|
||||
|
||||
{/* File browser */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
<FileDirectory
|
||||
standaloneDocs={
|
||||
inProject
|
||||
|
|
@ -812,30 +787,8 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-5 py-3 flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedDocIds.size > 0
|
||||
? `${selectedDocIds.size} selected`
|
||||
: ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCreateReview}
|
||||
disabled={
|
||||
saving ||
|
||||
selectedDocIds.size === 0 ||
|
||||
(inProject && !selectedProjectId)
|
||||
}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Creating…" : "Create Review"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ export function WorkflowList() {
|
|||
{/* Page header */}
|
||||
<PageHeader
|
||||
shrink
|
||||
loading={loading}
|
||||
actions={[
|
||||
{
|
||||
type: "search",
|
||||
|
|
@ -421,11 +422,13 @@ export function WorkflowList() {
|
|||
key={i}
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} py-2 pl-4 pr-2`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-28 shrink-0">
|
||||
<div className="ml-auto w-28 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-40 shrink-0">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue