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:
willchen96 2026-06-11 21:50:58 +08:00
parent 444d1d38e4
commit 1fa0554ea5
49 changed files with 3623 additions and 1587 deletions

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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}