mirror of
https://github.com/willchen96/mike.git
synced 2026-06-16 21:05:12 +02:00
Add local repo contents
This commit is contained in:
parent
65739ef1ce
commit
d9690965b5
176 changed files with 68998 additions and 0 deletions
315
frontend/src/app/components/shared/AddDocumentsModal.tsx
Normal file
315
frontend/src/app/components/shared/AddDocumentsModal.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"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 [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());
|
||||
}, [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;
|
||||
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);
|
||||
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}
|
||||
/>
|
||||
</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,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue