mirror of
https://github.com/willchen96/mike.git
synced 2026-06-08 20:25:13 +02:00
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
"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[]>([]);
|
||
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([]);
|
||
}, [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));
|
||
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([]);
|
||
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}
|
||
/>
|
||
</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,
|
||
);
|
||
}
|