mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { FolderOpen, ChevronDown } from "lucide-react";
|
|
import {
|
|
listProjects,
|
|
updateProject,
|
|
deleteProject,
|
|
} from "@/app/lib/mikeApi";
|
|
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import type { Project } from "@/app/components/shared/types";
|
|
import { NewProjectModal } from "./NewProjectModal";
|
|
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
|
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
|
import {
|
|
RowActionMenuItems,
|
|
RowActions,
|
|
} from "@/app/components/shared/RowActions";
|
|
import { PageHeader } from "@/app/components/shared/PageHeader";
|
|
import {
|
|
TABLE_CHECKBOX_CLASS,
|
|
TABLE_STICKY_CELL_BG,
|
|
SkeletonDot,
|
|
SkeletonLine,
|
|
TableBody,
|
|
TableCell,
|
|
TableEmptyState,
|
|
TableHeaderCell,
|
|
TableHeaderRow,
|
|
TablePrimaryCell,
|
|
TableRow,
|
|
TableScrollArea,
|
|
TableStickyCell,
|
|
} from "@/app/components/shared/TablePrimitive";
|
|
|
|
function formatDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function getProjectOwnerLabel(project: Project, currentUserId?: string | null) {
|
|
if (project.is_owner ?? project.user_id === currentUserId) return "Me";
|
|
return (
|
|
project.owner_display_name?.trim() ||
|
|
project.owner_email?.trim() ||
|
|
"Shared"
|
|
);
|
|
}
|
|
|
|
type ProjectFilter = "all" | "mine" | "shared-with-me";
|
|
|
|
export function ProjectsOverview() {
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [detailsProject, setDetailsProject] = useState<Project | null>(null);
|
|
const [activeFilter, setActiveFilter] = useState<ProjectFilter>("all");
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [actionsOpen, setActionsOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
|
const actionsRef = useRef<HTMLDivElement>(null);
|
|
const router = useRouter();
|
|
const { user, isAuthenticated, authLoading } = useAuth();
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadProjects() {
|
|
await Promise.resolve();
|
|
if (cancelled) return;
|
|
if (authLoading) {
|
|
setLoading(true);
|
|
return;
|
|
}
|
|
if (!isAuthenticated) {
|
|
setProjects([]);
|
|
setLoadError(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setLoadError(null);
|
|
try {
|
|
const loaded = await listProjects();
|
|
if (!cancelled) setProjects(loaded);
|
|
} catch (err) {
|
|
console.error("[projects] failed to load projects", err);
|
|
if (!cancelled) {
|
|
setProjects([]);
|
|
setLoadError("Could not load projects.");
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
|
|
void loadProjects();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [authLoading, isAuthenticated, user?.id]);
|
|
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (
|
|
actionsRef.current &&
|
|
!actionsRef.current.contains(e.target as Node)
|
|
)
|
|
setActionsOpen(false);
|
|
}
|
|
if (actionsOpen) document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [actionsOpen]);
|
|
|
|
const q = search.toLowerCase();
|
|
const filtered = (
|
|
activeFilter === "all"
|
|
? projects
|
|
: activeFilter === "mine"
|
|
? projects.filter((p) => p.is_owner ?? p.user_id === user?.id)
|
|
: projects.filter((p) => !(p.is_owner ?? p.user_id === user?.id))
|
|
).filter(
|
|
(p) =>
|
|
!q ||
|
|
p.name.toLowerCase().includes(q) ||
|
|
(p.cm_number ?? "").toLowerCase().includes(q) ||
|
|
(p.practice ?? "").toLowerCase().includes(q),
|
|
);
|
|
|
|
const allSelected =
|
|
filtered.length > 0 &&
|
|
filtered.every((p) => selectedIds.includes(p.id));
|
|
const someSelected =
|
|
!allSelected && filtered.some((p) => selectedIds.includes(p.id));
|
|
|
|
function toggleAll() {
|
|
if (allSelected) {
|
|
setSelectedIds([]);
|
|
} else {
|
|
setSelectedIds(filtered.map((p) => p.id));
|
|
}
|
|
}
|
|
|
|
function toggleOne(id: string) {
|
|
setSelectedIds((prev) =>
|
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
|
);
|
|
}
|
|
|
|
const filters: { id: ProjectFilter; label: string }[] = [
|
|
{ id: "all", label: "All" },
|
|
{ id: "mine", label: "Mine" },
|
|
{ id: "shared-with-me", label: "Shared with me" },
|
|
];
|
|
|
|
async function handleProjectDetailsSave(values: {
|
|
name: string;
|
|
cmNumber: string;
|
|
practice: string;
|
|
}) {
|
|
if (!detailsProject) return;
|
|
if (
|
|
detailsProject.is_owner === false ||
|
|
(user?.id && detailsProject.user_id !== user.id)
|
|
) {
|
|
setOwnerOnlyAction("edit project details");
|
|
return;
|
|
}
|
|
const name = values.name.trim();
|
|
const cmNumber = values.cmNumber.trim();
|
|
const practice = values.practice.trim();
|
|
if (!name) return;
|
|
const updated = await updateProject(detailsProject.id, {
|
|
name,
|
|
cm_number: cmNumber,
|
|
practice: practice || null,
|
|
});
|
|
setProjects((prev) =>
|
|
prev.map((project) =>
|
|
project.id === updated.id ? { ...project, ...updated } : project,
|
|
),
|
|
);
|
|
setDetailsProject((current) =>
|
|
current?.id === updated.id ? { ...current, ...updated } : current,
|
|
);
|
|
}
|
|
|
|
async function handleDeleteSelected() {
|
|
const ids = [...selectedIds];
|
|
setActionsOpen(false);
|
|
// Only the project owner can delete; the per-row delete is hidden
|
|
// for shared projects but the bulk action can still pick them up
|
|
// if a user toggled them across filters. Filter and warn.
|
|
const owned = ids.filter((id) => {
|
|
const p = projects.find((pp) => pp.id === id);
|
|
return !p || (p.is_owner ?? p.user_id === user?.id);
|
|
});
|
|
const blocked = ids.length - owned.length;
|
|
setSelectedIds([]);
|
|
await Promise.all(owned.map((id) => deleteProject(id).catch(() => {})));
|
|
setProjects((prev) => prev.filter((p) => !owned.includes(p.id)));
|
|
if (blocked > 0) {
|
|
setOwnerOnlyAction(
|
|
`delete ${blocked} of the selected projects — only the project owner can delete a project`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const toolbarActions = (
|
|
<>
|
|
{selectedIds.length > 0 && (
|
|
<div ref={actionsRef} className="relative">
|
|
<button
|
|
onClick={() => setActionsOpen((v) => !v)}
|
|
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
|
>
|
|
Actions
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
</button>
|
|
{actionsOpen && (
|
|
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
|
|
<button
|
|
onClick={handleDeleteSelected}
|
|
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
|
{/* Page header */}
|
|
<PageHeader
|
|
loading={loading}
|
|
actions={[
|
|
{
|
|
type: "search",
|
|
value: search,
|
|
onChange: setSearch,
|
|
placeholder: "Search projects…",
|
|
},
|
|
{
|
|
type: "new",
|
|
onClick: () => setModalOpen(true),
|
|
title: "New project",
|
|
},
|
|
]}
|
|
>
|
|
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
|
Projects
|
|
</h1>
|
|
</PageHeader>
|
|
|
|
<TableToolbar
|
|
items={filters}
|
|
active={activeFilter}
|
|
onChange={(nextFilter) => {
|
|
setActiveFilter(nextFilter);
|
|
setSelectedIds([]);
|
|
}}
|
|
actions={toolbarActions}
|
|
/>
|
|
|
|
{/* Table */}
|
|
<TableScrollArea
|
|
header={
|
|
<TableHeaderRow>
|
|
<TableStickyCell header>
|
|
{loading ? (
|
|
<SkeletonDot />
|
|
) : (
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
ref={(el) => {
|
|
if (el) el.indeterminate = someSelected;
|
|
}}
|
|
onChange={toggleAll}
|
|
className={TABLE_CHECKBOX_CLASS}
|
|
/>
|
|
)}
|
|
<span>Name</span>
|
|
</TableStickyCell>
|
|
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
|
|
<TableHeaderCell className="w-36">Practice</TableHeaderCell>
|
|
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
|
|
<TableHeaderCell className="w-24">Files</TableHeaderCell>
|
|
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
|
|
<TableHeaderCell className="w-36">
|
|
Tabular Reviews
|
|
</TableHeaderCell>
|
|
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
|
<TableHeaderCell className="w-8" />
|
|
</TableHeaderRow>
|
|
}
|
|
>
|
|
|
|
{loading ? (
|
|
<TableBody>
|
|
{[1, 2, 3].map((i) => (
|
|
<TableRow
|
|
key={i}
|
|
interactive={false}
|
|
>
|
|
<TableStickyCell
|
|
hover={false}
|
|
bgClassName="bg-transparent"
|
|
>
|
|
<SkeletonDot />
|
|
<SkeletonLine className="h-3.5 w-48" />
|
|
</TableStickyCell>
|
|
<TableCell className="ml-auto w-32">
|
|
<SkeletonLine className="w-20" />
|
|
</TableCell>
|
|
<TableCell className="w-36">
|
|
<SkeletonLine className="w-20" />
|
|
</TableCell>
|
|
<TableCell className="w-32">
|
|
<SkeletonLine className="w-16" />
|
|
</TableCell>
|
|
<TableCell className="w-24">
|
|
<SkeletonLine className="w-8" />
|
|
</TableCell>
|
|
<TableCell className="w-24">
|
|
<SkeletonLine className="w-8" />
|
|
</TableCell>
|
|
<TableCell className="w-36">
|
|
<SkeletonLine className="w-8" />
|
|
</TableCell>
|
|
<TableCell className="w-32">
|
|
<SkeletonLine className="w-20" />
|
|
</TableCell>
|
|
<TableCell className="w-8" />
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
) : loadError ? (
|
|
<TableEmptyState>
|
|
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
|
|
<p className="text-2xl font-medium font-serif text-gray-900">
|
|
Projects
|
|
</p>
|
|
<p className="mt-1 text-xs text-red-500 max-w-xs">
|
|
{loadError}
|
|
</p>
|
|
</TableEmptyState>
|
|
) : filtered.length === 0 ? (
|
|
<TableEmptyState>
|
|
{activeFilter === "all" || activeFilter === "mine" ? (
|
|
<>
|
|
<FolderOpen className="h-8 w-8 text-gray-300 mb-4" />
|
|
<p className="text-2xl font-medium font-serif text-gray-900">
|
|
Projects
|
|
</p>
|
|
<p className="mt-1 text-xs text-gray-400 max-w-xs">
|
|
Upload documents into projects and to
|
|
commence chats and tabular reviews with
|
|
them.
|
|
</p>
|
|
<button
|
|
onClick={() => setModalOpen(true)}
|
|
className="mt-4 inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white hover:bg-gray-700 transition-colors shadow-md"
|
|
>
|
|
+ Create New
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-gray-400">
|
|
No {activeFilter} projects
|
|
</p>
|
|
)}
|
|
</TableEmptyState>
|
|
) : (
|
|
<TableBody>
|
|
{filtered.map((project) => {
|
|
const rowBg = selectedIds.includes(project.id)
|
|
? "bg-gray-50"
|
|
: TABLE_STICKY_CELL_BG;
|
|
return (
|
|
<TableRow
|
|
key={project.id}
|
|
rightClickDropdown={
|
|
(project.is_owner ??
|
|
project.user_id === user?.id)
|
|
? (close) => (
|
|
<RowActionMenuItems
|
|
onClose={close}
|
|
onEditDetails={() => {
|
|
setDetailsProject(project);
|
|
}}
|
|
onDelete={async () => {
|
|
await deleteProject(
|
|
project.id,
|
|
);
|
|
setProjects((prev) =>
|
|
prev.filter(
|
|
(p) =>
|
|
p.id !==
|
|
project.id,
|
|
),
|
|
);
|
|
}}
|
|
/>
|
|
)
|
|
: undefined
|
|
}
|
|
onClick={() => {
|
|
router.push(`/projects/${project.id}`);
|
|
}}
|
|
>
|
|
{/* Project Name */}
|
|
<TablePrimaryCell
|
|
bgClassName={rowBg}
|
|
selected={selectedIds.includes(project.id)}
|
|
onSelectionChange={() =>
|
|
toggleOne(project.id)
|
|
}
|
|
label={project.name}
|
|
/>
|
|
|
|
<TableCell className="ml-auto w-32">
|
|
{project.cm_number ?? (
|
|
<span className="text-gray-300">
|
|
—
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="w-36">
|
|
{project.practice ?? (
|
|
<span className="text-gray-300">
|
|
—
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="w-32">
|
|
{getProjectOwnerLabel(project, user?.id)}
|
|
</TableCell>
|
|
<TableCell className="w-24">
|
|
{project.document_count ?? 0}
|
|
</TableCell>
|
|
<TableCell className="w-24">
|
|
{project.chat_count ?? 0}
|
|
</TableCell>
|
|
<TableCell className="w-36">
|
|
{project.review_count ?? 0}
|
|
</TableCell>
|
|
<TableCell className="w-32">
|
|
{formatDate(project.created_at)}
|
|
</TableCell>
|
|
|
|
<div
|
|
className="w-8 shrink-0 flex justify-end"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{(project.is_owner ??
|
|
project.user_id === user?.id) && (
|
|
<RowActions
|
|
onEditDetails={() => {
|
|
setDetailsProject(project);
|
|
}}
|
|
onDelete={async () => {
|
|
await deleteProject(project.id);
|
|
setProjects((prev) =>
|
|
prev.filter(
|
|
(p) =>
|
|
p.id !== project.id,
|
|
),
|
|
);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
)}
|
|
</TableScrollArea>
|
|
|
|
<NewProjectModal
|
|
open={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
onCreated={(p) => {
|
|
setProjects((prev) => [p, ...prev]);
|
|
router.push(`/projects/${p.id}`);
|
|
}}
|
|
/>
|
|
|
|
<ProjectDetailsModal
|
|
open={!!detailsProject}
|
|
project={detailsProject}
|
|
canEdit={
|
|
!!detailsProject &&
|
|
detailsProject.is_owner !== false &&
|
|
(!user?.id || detailsProject.user_id === user.id)
|
|
}
|
|
onClose={() => setDetailsProject(null)}
|
|
onSave={handleProjectDetailsSave}
|
|
/>
|
|
|
|
<OwnerOnlyPopup
|
|
open={!!ownerOnlyAction}
|
|
action={ownerOnlyAction ?? undefined}
|
|
onClose={() => setOwnerOnlyAction(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|