mike/frontend/src/app/components/shared/AddDocumentsModal.tsx

321 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-29 19:49:06 +02:00
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X, Upload, Search, Loader2 } from "lucide-react";
import {
uploadStandaloneDocument,
uploadProjectDocument,
addDocumentToProject,
deleteDocument,
} from "@/app/lib/mikeApi";
import type { MikeDocument } from "./types";
import { FileDirectory } from "./FileDirectory";
import { useDirectoryData, invalidateDirectoryCache } from "./useDirectoryData";
import { OwnerOnlyModal } from "./OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
export { invalidateDirectoryCache };
interface Props {
open: boolean;
onClose: () => void;
onSelect: (documents: MikeDocument[], projectId?: string) => void;
breadcrumb: string[];
allowMultiple?: boolean;
projectId?: string;
}
export function AddDocumentsModal({
open,
onClose,
onSelect,
breadcrumb,
allowMultiple = true,
projectId,
}: Props) {
const { loading, standaloneDocuments, projects } = useDirectoryData(open);
const { user } = useAuth();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [uploading, setUploading] = useState(false);
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
2026-04-29 19:49:06 +02:00
const [search, setSearch] = useState("");
const [extraUploadedDocs, setExtraUploadedDocs] = useState<MikeDocument[]>([]);
// IDs deleted in this session — hidden locally since `useDirectoryData`'s
// cached state won't re-fetch until the modal reopens.
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setSearch("");
setSelectedIds(new Set());
setExtraUploadedDocs([]);
setDeletedIds(new Set());
setUploadingFilenames([]);
2026-04-29 19:49:06 +02:00
}, [open]);
if (!open) return null;
const q = search.toLowerCase().trim();
const allStandalone = [
...extraUploadedDocs.filter(
(u) => !standaloneDocuments.some((d) => d.id === u.id),
),
...standaloneDocuments,
].filter((d) => !deletedIds.has(d.id));
const filteredStandalone = q
? allStandalone.filter((d) => d.filename.toLowerCase().includes(q))
: allStandalone;
const filteredProjects = projects
.filter((p) => p.id !== projectId)
.map((p) => ({
...p,
documents: (p.documents || []).filter(
(d) =>
!deletedIds.has(d.id) &&
(!q || d.filename.toLowerCase().includes(q)),
),
}))
.filter(
(p) =>
!q ||
p.name.toLowerCase().includes(q) ||
p.documents.length > 0,
);
const allDocs = [
...allStandalone,
...projects.flatMap((p) => p.documents || []),
];
async function handleConfirm() {
const selected = allDocs.filter((d) => selectedIds.has(d.id));
if (projectId) {
const toAssign = selected.filter((d) => d.project_id !== projectId);
const alreadyHere = selected.filter(
(d) => d.project_id === projectId,
);
if (toAssign.length > 0) {
setUploading(true);
try {
const assigned = await Promise.all(
toAssign.map((d) =>
addDocumentToProject(projectId, d.id),
),
);
onSelect([...alreadyHere, ...assigned], projectId);
} catch (err) {
console.error("Failed to assign documents:", err);
} finally {
setUploading(false);
}
} else {
onSelect(alreadyHere, projectId);
}
onClose();
return;
}
const projectIds = new Set(
selected.map((d) => d.project_id).filter(Boolean),
);
const singleProjectId =
projectIds.size === 1 ? [...projectIds][0]! : undefined;
onSelect(selected, singleProjectId);
onClose();
}
async function handleDelete(ids: string[]) {
// Server only allows the doc creator to delete. Filter to owned
// and warn for the rest.
const docsById = new Map<string, MikeDocument>();
for (const d of [
...standaloneDocuments,
...extraUploadedDocs,
...projects.flatMap((p) => p.documents ?? []),
]) {
docsById.set(d.id, d);
}
const owned = ids.filter((id) => {
const d = docsById.get(id);
return !d || !d.user_id || !user?.id || d.user_id === user.id;
});
const blocked = ids.length - owned.length;
if (owned.length === 0 && blocked > 0) {
setOwnerOnlyAction(
"delete these documents — only the document creator can delete a document",
);
return;
}
const idSet = new Set(owned);
try {
await Promise.all(owned.map((id) => deleteDocument(id)));
} catch (err) {
console.error("Delete failed:", err);
return;
}
invalidateDirectoryCache();
setExtraUploadedDocs((prev) => prev.filter((d) => !idSet.has(d.id)));
setDeletedIds((prev) => {
const next = new Set(prev);
owned.forEach((id) => next.add(id));
return next;
});
if (blocked > 0) {
setOwnerOnlyAction(
`delete ${blocked} of the selected documents — only the document creator can delete a document`,
);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setUploadingFilenames(files.map((file) => file.name));
2026-04-29 19:49:06 +02:00
setUploading(true);
try {
const uploaded = await Promise.all(
files.map((f) =>
projectId
? uploadProjectDocument(projectId, f)
: uploadStandaloneDocument(f),
),
);
invalidateDirectoryCache();
setExtraUploadedDocs((prev) => [...uploaded, ...prev]);
uploaded.forEach((d) =>
setSelectedIds((prev) => new Set([...prev, d.id])),
);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setUploading(false);
setUploadingFilenames([]);
2026-04-29 19:49:06 +02:00
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div className="flex items-center gap-1.5 text-xs text-gray-400">
{breadcrumb.map((segment, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <span></span>}
{segment}
</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>
{/* Search bar */}
<div className="px-4 pt-1 pb-2">
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
autoFocus
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* File browser */}
<div className="flex-1 overflow-y-auto px-4 pb-2">
<FileDirectory
standaloneDocs={filteredStandalone}
directoryProjects={filteredProjects}
loading={loading}
selectedIds={selectedIds}
onChange={setSelectedIds}
allowMultiple={allowMultiple}
forceExpanded={!!q}
emptyMessage={
q ? "No matches found" : "No documents yet"
}
onDelete={handleDelete}
uploadingFilenames={uploadingFilenames}
2026-04-29 19:49:06 +02:00
/>
</div>
{/* Footer */}
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.doc"
multiple
className="hidden"
onChange={handleUpload}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<span className="text-xs text-gray-400">
{selectedIds.size} selected
</span>
)}
<button
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedIds.size === 0 || uploading}
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
>
{uploading ? "Saving…" : "Confirm"}
</button>
</div>
</div>
</div>
<OwnerOnlyModal
open={!!ownerOnlyAction}
action={ownerOnlyAction ?? undefined}
onClose={() => setOwnerOnlyAction(null)}
/>
</div>,
document.body,
);
}