mirror of
https://github.com/willchen96/mike.git
synced 2026-06-18 21:15:13 +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,
|
||||
);
|
||||
}
|
||||
299
frontend/src/app/components/shared/AddProjectDocsModal.tsx
Normal file
299
frontend/src/app/components/shared/AddProjectDocsModal.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
import { DocFileIcon } from "./FileDirectory";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (documents: MikeDocument[]) => void;
|
||||
breadcrumb: string[];
|
||||
projectId: string;
|
||||
/** Docs already in the target list — rendered checked + disabled. */
|
||||
excludeDocIds?: Set<string>;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function AddProjectDocsModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
breadcrumb,
|
||||
projectId,
|
||||
excludeDocIds,
|
||||
allowMultiple = true,
|
||||
}: Props) {
|
||||
const [docs, setDocs] = useState<MikeDocument[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
setSelectedIds(new Set());
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getProject(projectId)
|
||||
.then((p) => {
|
||||
if (!cancelled) setDocs(p.documents ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDocs([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, projectId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? docs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
: docs;
|
||||
|
||||
const isExcluded = (id: string) => !!excludeDocIds?.has(id);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (isExcluded(id)) return;
|
||||
if (!allowMultiple) {
|
||||
setSelectedIds(new Set([id]));
|
||||
return;
|
||||
}
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const selected = docs.filter((d) => selectedIds.has(d.id));
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
}
|
||||
|
||||
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) => uploadProjectDocument(projectId, f)),
|
||||
);
|
||||
setDocs((prev) => [...uploaded, ...prev]);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
uploaded.forEach((d) => next.add(d.id));
|
||||
return next;
|
||||
});
|
||||
} 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 */}
|
||||
<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 list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
{loading ? (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-200 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{q ? "No matches found" : "No documents in this project"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{filtered.map((doc) => {
|
||||
const excluded = isExcluded(doc.id);
|
||||
const checked =
|
||||
excluded || selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
disabled={excluded}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs text-left transition-colors ${
|
||||
excluded
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: checked
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
checked
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon
|
||||
fileType={doc.file_type}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
checked
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
{excluded && (
|
||||
<span className="text-[10px] text-gray-400 shrink-0">
|
||||
Already added
|
||||
</span>
|
||||
)}
|
||||
<VersionChip
|
||||
n={doc.latest_version_number}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
78
frontend/src/app/components/shared/ApiKeyMissingModal.tsx
Normal file
78
frontend/src/app/components/shared/ApiKeyMissingModal.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
provider: ModelProvider | null;
|
||||
/** Optional override for the body sentence. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ApiKeyMissingModal({ open, onClose, provider, message }: Props) {
|
||||
const router = useRouter();
|
||||
if (!open) return null;
|
||||
|
||||
const providerName = provider ? providerLabel(provider) : "this provider";
|
||||
const body =
|
||||
message ??
|
||||
`You haven't added a ${providerName} API key yet. Add one in your account settings to use this model.`;
|
||||
|
||||
const handleGoToAccount = () => {
|
||||
onClose();
|
||||
router.push("/account/models");
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
API key required
|
||||
</h2>
|
||||
</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>
|
||||
|
||||
<div className="px-5 pb-2 pt-1">
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToAccount}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Go to account settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
311
frontend/src/app/components/shared/AppSidebar.tsx
Normal file
311
frontend/src/app/components/shared/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
PanelLeft,
|
||||
MessageSquare,
|
||||
FolderOpen,
|
||||
Table2,
|
||||
Library,
|
||||
User,
|
||||
ChevronsUpDown,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
|
||||
import { listProjects } from "@/app/lib/mikeApi";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
|
||||
{ href: "/projects", label: "Projects", icon: FolderOpen },
|
||||
{ href: "/tabular-reviews", label: "Tabular Review", icon: Table2 },
|
||||
{ href: "/workflows", label: "Workflows", icon: Library },
|
||||
];
|
||||
|
||||
interface AppSidebarProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const { profile } = useUserProfile();
|
||||
const { chats, currentChatId, setCurrentChatId } = useChatHistoryContext();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [historyCollapsed, setHistoryCollapsed] = useState(false);
|
||||
const [projectNames, setProjectNames] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
listProjects()
|
||||
.then((projects) => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of projects) map[p.id] = p.name;
|
||||
setProjectNames(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setShouldAnimate(true);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setIsDropdownOpen(false);
|
||||
if (isDropdownOpen) {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
}
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/assistant/chat/")) {
|
||||
const chatId = pathname.split("/").pop() ?? null;
|
||||
setCurrentChatId(chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectChatMatch = pathname.match(
|
||||
/^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/,
|
||||
);
|
||||
if (projectChatMatch) {
|
||||
setCurrentChatId(projectChatMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/assistant") {
|
||||
setCurrentChatId(null);
|
||||
}
|
||||
}, [pathname, setCurrentChatId]);
|
||||
|
||||
const getUserInitials = (email: string) => {
|
||||
if (profile?.displayName)
|
||||
return profile.displayName.charAt(0).toUpperCase();
|
||||
return email.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const getDisplayName = () => {
|
||||
if (!profile) return "";
|
||||
return profile.displayName || user?.email?.split("@")[0] || "";
|
||||
};
|
||||
|
||||
const getUserTier = () => {
|
||||
if (!profile) return "";
|
||||
return profile.tier || "Free";
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
isOpen
|
||||
? "w-64 h-dvh bg-gray-50 border-r"
|
||||
: "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent"
|
||||
} border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-99 overflow-visible`}
|
||||
>
|
||||
{/* Toggle + Logo */}
|
||||
<div
|
||||
className={`mb-3 items-center justify-between px-2.5 py-2 ${
|
||||
!isOpen ? "hidden md:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="px-2.5">
|
||||
<Link
|
||||
href="/assistant"
|
||||
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<MikeIcon size={22} />
|
||||
<span
|
||||
className={`text-2xl font-light font-serif ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
Mike
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="h-9 w-9 p-2.5 items-center flex hover:bg-gray-100 rounded-md transition-colors"
|
||||
title={isOpen ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const isActive =
|
||||
pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<div key={href} className="py-1 px-2.5">
|
||||
<button
|
||||
onClick={() => router.push(href)}
|
||||
title={!isOpen ? label : ""}
|
||||
className={`w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left ${
|
||||
isActive
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "hover:bg-gray-100 text-gray-700"
|
||||
} ${!isOpen ? "hidden md:flex" : "flex"}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isActive ? "text-gray-900" : "text-black"
|
||||
}`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Assistant History */}
|
||||
{isOpen && pathname.startsWith("/assistant") && (
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col">
|
||||
<button
|
||||
onClick={() => setHistoryCollapsed((v) => !v)}
|
||||
className={`mb-2 px-5 flex items-center justify-between text-xs font-semibold text-gray-500 hover:text-gray-700 transition-colors ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
<span>Assistant History</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${historyCollapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-y-auto flex-1 ${historyCollapsed ? "hidden" : ""}`}
|
||||
>
|
||||
{!chats ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[40, 60, 50, 70, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-gray-200 rounded animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div
|
||||
className={`text-xs text-gray-500 py-2 px-5 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
No chats yet
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
{chats.map((chat) => (
|
||||
<SidebarChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={currentChatId === chat.id}
|
||||
projectName={
|
||||
chat.project_id
|
||||
? projectNames[chat.project_id]
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
setCurrentChatId(chat.id);
|
||||
router.push(
|
||||
chat.project_id
|
||||
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
|
||||
: `/assistant/chat/${chat.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="mt-auto">
|
||||
{user && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className={`flex items-center transition-colors w-full px-3.5 py-4 border-t border-gray-200 ${
|
||||
!isOpen ? "hidden md:flex" : ""
|
||||
} ${
|
||||
pathname === "/account" || isDropdownOpen
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100"
|
||||
}`}
|
||||
title={!isOpen ? user.email : undefined}
|
||||
>
|
||||
<div className="h-7 w-7 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
|
||||
{getUserInitials(user.email)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 leading-none">
|
||||
{getDisplayName()}
|
||||
</div>
|
||||
<div className="text-[12px] text-gray-500 leading-none">
|
||||
{getUserTier()}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute bottom-full left-0 m-1 bg-white rounded-lg shadow-lg border border-gray-200 p-1 z-50 w-62 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/account");
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 rounded-md"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Account Settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
505
frontend/src/app/components/shared/DocPanel.tsx
Normal file
505
frontend/src/app/components/shared/DocPanel.tsx
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { applyOptimisticResolution } from "../assistant/EditCard";
|
||||
import { DocView } from "./DocView";
|
||||
import { DocxView } from "./DocxView";
|
||||
import {
|
||||
displayCitationQuote,
|
||||
expandCitationToEntries,
|
||||
formatCitationPage,
|
||||
} from "./types";
|
||||
import type {
|
||||
CitationQuote,
|
||||
MikeCitationAnnotation,
|
||||
MikeEditAnnotation,
|
||||
} from "./types";
|
||||
|
||||
function isDocxFilename(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
return ext === "docx" || ext === "doc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated-union describing what the panel is showing above the viewer.
|
||||
* - "document": no header card, no label — just the viewer.
|
||||
* - "citation": "Citation Quote" card with the quoted text and page ref.
|
||||
* - "edit": "Tracked Change" card with the diff + Accept/Reject.
|
||||
*/
|
||||
export type DocPanelMode =
|
||||
| { kind: "document" }
|
||||
| { kind: "citation"; citation: MikeCitationAnnotation }
|
||||
| {
|
||||
kind: "edit";
|
||||
edit: MikeEditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject request for this exact edit is in
|
||||
* flight. Scoped per-edit (not per-document) so sibling edits on
|
||||
* the same doc stay clickable.
|
||||
*/
|
||||
isEditReloading?: boolean;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
mode: DocPanelMode;
|
||||
/** Spinner on the Download button while an accept/reject is in flight. */
|
||||
isReloading?: boolean;
|
||||
warning?: string | null;
|
||||
onWarningDismiss?: () => void;
|
||||
initialScrollTop?: number | null;
|
||||
onScrollChange?: (scrollTop: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified side-panel body for the assistant. Renders a single document
|
||||
* with optionally a citation quote OR a tracked change highlighted above
|
||||
* the viewer. No selector UI — caller picks the one thing to show; if the
|
||||
* user wants a different citation/edit, the panel gets a new tab.
|
||||
*/
|
||||
export function DocPanel({
|
||||
documentId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
mode,
|
||||
isReloading = false,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
onScrollChange,
|
||||
}: Props) {
|
||||
// Pick the viewer from the filename only, not from mode. Switching
|
||||
// headers (citation ↔ edit ↔ document) for the same document must
|
||||
// not unmount and remount the body — otherwise the user sees a full
|
||||
// re-fetch every time they toggle. Tracked-change rendering still
|
||||
// only lives in DocxView, which is fine because edits are DOCX-only.
|
||||
const useDocxView = isDocxFilename(filename);
|
||||
|
||||
const quotes: CitationQuote[] | undefined = useMemo(() => {
|
||||
if (mode.kind !== "citation") return undefined;
|
||||
return expandCitationToEntries(mode.citation);
|
||||
}, [mode]);
|
||||
|
||||
const highlightEdit = useMemo(() => {
|
||||
if (mode.kind !== "edit") return null;
|
||||
return {
|
||||
key: `${mode.edit.edit_id}`,
|
||||
inserted_text: mode.edit.inserted_text,
|
||||
deleted_text: mode.edit.deleted_text,
|
||||
ins_w_id: mode.edit.ins_w_id ?? null,
|
||||
del_w_id: mode.edit.del_w_id ?? null,
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-3 pb-3">
|
||||
{mode.kind === "citation" ? (
|
||||
<CitationHeader
|
||||
citation={mode.citation}
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
) : mode.kind === "edit" ? (
|
||||
<TrackedChangeHeader
|
||||
mode={mode}
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-gray-700">
|
||||
{filename}
|
||||
</span>
|
||||
{versionNumber && versionNumber > 0 && (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
V{versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useDocxView ? (
|
||||
<DocxView
|
||||
documentId={documentId}
|
||||
versionId={versionId ?? undefined}
|
||||
quotes={quotes}
|
||||
highlightEdit={highlightEdit}
|
||||
warning={warning ?? null}
|
||||
onWarningDismiss={onWarningDismiss}
|
||||
initialScrollTop={initialScrollTop ?? null}
|
||||
onScrollChange={onScrollChange}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
doc={{
|
||||
document_id: documentId,
|
||||
version_id: versionId,
|
||||
}}
|
||||
quotes={quotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs font-medium text-gray-700">{children}</p>;
|
||||
}
|
||||
|
||||
function CitationHeader({
|
||||
citation,
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
citation: MikeCitationAnnotation;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
const displayQuote = displayCitationQuote(citation);
|
||||
const pagesLabel = formatCitationPage(citation);
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Citation</SectionLabel>
|
||||
<div className="ml-auto shrink-0">
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<p className="text-sm font-serif text-gray-600">
|
||||
“{displayQuote}”
|
||||
{pagesLabel && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
({pagesLabel})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackedChangeHeader({
|
||||
mode,
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
mode: Extract<DocPanelMode, { kind: "edit" }>;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode;
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Tracked Change</SectionLabel>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<EditResolveButtons
|
||||
edit={edit}
|
||||
isReloading={isEditReloading}
|
||||
onResolveStart={onResolveStart}
|
||||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{edit.reason && (
|
||||
<p className="mb-2 text-xs text-gray-500">{edit.reason}</p>
|
||||
)}
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<div className="text-sm leading-relaxed font-serif">
|
||||
{edit.inserted_text && (
|
||||
<span className="text-green-700">
|
||||
{edit.inserted_text}
|
||||
</span>
|
||||
)}
|
||||
{edit.deleted_text && (
|
||||
<span className="text-red-600 line-through">
|
||||
{edit.deleted_text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accept / Reject controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditResolveButtons({
|
||||
edit,
|
||||
isReloading,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
edit: MikeEditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject for any edit on this document is in
|
||||
* flight (triggered from here, the inline EditCard, the bulk bar, or
|
||||
* elsewhere). Disables both buttons so the user can't double-submit
|
||||
* while a resolution is racing to change the status.
|
||||
*/
|
||||
isReloading?: boolean;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<"pending" | "accepted" | "rejected">(
|
||||
edit.status,
|
||||
);
|
||||
// Sync with the prop when this edit is resolved elsewhere (bulk
|
||||
// accept/reject, inline per-edit card, another open panel for the same
|
||||
// edit). Skips while our own request is in flight so we don't flicker.
|
||||
useEffect(() => {
|
||||
if (busy) return;
|
||||
setStatus(edit.status);
|
||||
}, [edit.status, edit.edit_id, busy]);
|
||||
const resolved = status !== "pending";
|
||||
|
||||
const handle = useCallback(
|
||||
async (verb: "accept" | "reject") => {
|
||||
if (busy || resolved) return;
|
||||
setBusy(true);
|
||||
onResolveStart?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
verb,
|
||||
});
|
||||
// Optimistically mutate the DOM in the open viewer so the
|
||||
// change reflects immediately. Revert if the backend errors.
|
||||
let revert: (() => void) | null = null;
|
||||
try {
|
||||
revert = applyOptimisticResolution(edit, verb);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[DocPanel] optimistic update threw",
|
||||
e,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ??
|
||||
"http://localhost:3001";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
status?: "accepted" | "rejected";
|
||||
version_id: string | null;
|
||||
download_url: string | null;
|
||||
};
|
||||
const nextStatus =
|
||||
data.status ??
|
||||
(verb === "accept" ? "accepted" : "rejected");
|
||||
setStatus(nextStatus);
|
||||
onResolved?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
status: nextStatus,
|
||||
versionId: data.version_id,
|
||||
downloadUrl: data.download_url,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[DocPanel] resolve failed", e);
|
||||
try {
|
||||
revert?.();
|
||||
} catch (revertErr) {
|
||||
console.error(
|
||||
"[DocPanel] revert threw",
|
||||
revertErr,
|
||||
);
|
||||
}
|
||||
onError?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
versionId: edit.version_id ?? null,
|
||||
message:
|
||||
verb === "accept"
|
||||
? "Couldn't save accept — please retry."
|
||||
: "Couldn't save reject — please retry.",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, resolved, edit, onResolveStart, onResolved, onError],
|
||||
);
|
||||
|
||||
const inFlight = busy || !!isReloading;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handle("accept")}
|
||||
disabled={inFlight || resolved}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-900 bg-gray-900 px-2 py-1.5 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "accepted" ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handle("reject")}
|
||||
disabled={inFlight || resolved}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "rejected" ? "Rejected" : "Reject"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DownloadButton({
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (busy || isReloading) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${documentId}/docx${qs}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const spinning = busy || isReloading;
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={spinning}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{spinning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Download
|
||||
</button>
|
||||
);
|
||||
}
|
||||
596
frontend/src/app/components/shared/DocView.tsx
Normal file
596
frontend/src/app/components/shared/DocView.tsx
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
|
||||
import { DocxView } from "./DocxView";
|
||||
import type { CitationQuote } from "./types";
|
||||
import {
|
||||
clearHighlights,
|
||||
getPdfJs,
|
||||
highlightQuote,
|
||||
STANDARD_FONT_DATA_URL,
|
||||
} from "./highlightQuote";
|
||||
|
||||
interface Props {
|
||||
doc: { document_id: string; version_id?: string | null } | null;
|
||||
/** Preferred: one or more (page, quote) pairs to highlight. */
|
||||
quotes?: CitationQuote[];
|
||||
/** Back-compat single-quote API. Ignored if `quotes` is provided. */
|
||||
quote?: string;
|
||||
fallbackPage?: number;
|
||||
rounded?: boolean;
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
type QuoteEntry = { page?: number; quote: string };
|
||||
|
||||
const SIDE_PADDING = 20;
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 3.0;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
type RenderedPage = {
|
||||
page: import("pdfjs-dist").PDFPageProxy;
|
||||
viewport: import("pdfjs-dist").PageViewport;
|
||||
wrapper: HTMLDivElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
textDivs: HTMLElement[];
|
||||
};
|
||||
|
||||
export function DocView({
|
||||
doc,
|
||||
quotes,
|
||||
quote,
|
||||
fallbackPage,
|
||||
rounded = true,
|
||||
bordered = true,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pdfDocRef = useRef<import("pdfjs-dist").PDFDocumentProxy | null>(
|
||||
null,
|
||||
);
|
||||
const renderedPagesRef = useRef<RenderedPage[]>([]);
|
||||
const quoteListRef = useRef<QuoteEntry[]>([]);
|
||||
const zoomRef = useRef(1.0);
|
||||
const currentPageRef = useRef(1);
|
||||
|
||||
const quoteList: QuoteEntry[] = useMemo(() => {
|
||||
if (quotes?.length)
|
||||
return quotes.map((q) => ({ page: q.page, quote: q.quote }));
|
||||
if (quote) return [{ page: fallbackPage, quote }];
|
||||
return [];
|
||||
}, [quotes, quote, fallbackPage]);
|
||||
|
||||
// Stable string key so effects can depend on quote-list identity
|
||||
const quoteKey = quoteList
|
||||
.map((q) => `${q.page ?? ""}:${q.quote}`)
|
||||
.join("|");
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [zoom, setZoom] = useState(1.0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
|
||||
const { result, loading, error } = useFetchSingleDoc(
|
||||
doc?.document_id ?? null,
|
||||
doc?.version_id ?? null,
|
||||
);
|
||||
|
||||
// /display returned DOCX bytes — the active version has no PDF
|
||||
// rendition, so fall back to docx-preview (still applies citation
|
||||
// highlighting via the same `quotes` API).
|
||||
const fallbackToDocx = result?.type === "docx";
|
||||
|
||||
// Track container width via ResizeObserver so re-renders fire on resize
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
setContainerWidth(entries[0]?.contentRect.width ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Track current page via scroll position
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const pages = renderedPagesRef.current;
|
||||
if (!pages.length) return;
|
||||
const scrollCenter = scrollEl.scrollTop + scrollEl.clientHeight / 2;
|
||||
let closest = 0;
|
||||
let closestDist = Infinity;
|
||||
pages.forEach((p, i) => {
|
||||
const pageCenter =
|
||||
p.wrapper.offsetTop + p.wrapper.clientHeight / 2;
|
||||
const dist = Math.abs(pageCenter - scrollCenter);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = i;
|
||||
}
|
||||
});
|
||||
currentPageRef.current = closest + 1;
|
||||
setCurrentPage(closest + 1);
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Highlights all entries in `list` across the already-rendered pages.
|
||||
// Returns the 1-based page number of the first successfully highlighted entry, or null.
|
||||
const applyHighlights = useCallback(
|
||||
async (list: QuoteEntry[]): Promise<number | null> => {
|
||||
// Clear any prior highlights across all pages
|
||||
for (const p of renderedPagesRef.current)
|
||||
clearHighlights(p.textDivs);
|
||||
|
||||
let firstHitPage: number | null = null;
|
||||
for (const entry of list) {
|
||||
let hitPage: number | null = null;
|
||||
|
||||
if (entry.page) {
|
||||
const target = renderedPagesRef.current[entry.page - 1];
|
||||
if (target) {
|
||||
const found = await highlightQuote(
|
||||
target.textDivs,
|
||||
entry.quote,
|
||||
);
|
||||
if (found) hitPage = entry.page;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to scanning all pages for this quote
|
||||
if (hitPage === null) {
|
||||
console.warn(
|
||||
`Quote not found on hinted page, scanning all pages: "${entry.quote.slice(0, 60)}..."`,
|
||||
);
|
||||
for (let i = 0; i < renderedPagesRef.current.length; i++) {
|
||||
const p = renderedPagesRef.current[i];
|
||||
const found = await highlightQuote(
|
||||
p.textDivs,
|
||||
entry.quote,
|
||||
);
|
||||
if (found) {
|
||||
hitPage = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitPage !== null && firstHitPage === null) {
|
||||
firstHitPage = hitPage;
|
||||
}
|
||||
}
|
||||
return firstHitPage;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderPDF = useCallback(
|
||||
async (
|
||||
doc: import("pdfjs-dist").PDFDocumentProxy,
|
||||
list: QuoteEntry[],
|
||||
scrollToPage?: number,
|
||||
) => {
|
||||
if (!containerRef.current) return;
|
||||
containerRef.current.innerHTML = "";
|
||||
renderedPagesRef.current = [];
|
||||
const lib = await getPdfJs();
|
||||
lib.TextLayer.cleanup();
|
||||
|
||||
setNumPages(doc.numPages);
|
||||
setCurrentPage(1);
|
||||
currentPageRef.current = 1;
|
||||
|
||||
const hasCitation = list.length > 0;
|
||||
if (hasCitation && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.style.opacity = "0";
|
||||
}
|
||||
|
||||
const reveal = () => {
|
||||
if (scrollContainerRef.current)
|
||||
scrollContainerRef.current.style.opacity = "1";
|
||||
};
|
||||
|
||||
const panelW = containerRef.current.clientWidth;
|
||||
const firstPage = await doc.getPage(1);
|
||||
const naturalWidth = firstPage.getViewport({ scale: 1 }).width;
|
||||
const baseScale = Math.max(
|
||||
0.5,
|
||||
(panelW - SIDE_PADDING) / naturalWidth,
|
||||
);
|
||||
const scale = baseScale * zoomRef.current;
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.margin = "0 auto 8px";
|
||||
wrapper.style.width = "fit-content";
|
||||
wrapper.className = "shadow-md";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.display = "block";
|
||||
wrapper.appendChild(canvas);
|
||||
containerRef.current?.appendChild(wrapper);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) continue;
|
||||
|
||||
const task = page.render({ canvasContext: ctx, viewport });
|
||||
try {
|
||||
await task.promise;
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
(e as { name?: string })?.name !==
|
||||
"RenderingCancelledException"
|
||||
) {
|
||||
console.error("PDF render error", e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const textLayerDiv = document.createElement("div");
|
||||
textLayerDiv.className = "pdf-text-layer";
|
||||
textLayerDiv.style.position = "absolute";
|
||||
textLayerDiv.style.left = "0";
|
||||
textLayerDiv.style.top = "0";
|
||||
textLayerDiv.style.width = `${viewport.width}px`;
|
||||
textLayerDiv.style.height = `${viewport.height}px`;
|
||||
textLayerDiv.style.setProperty("--scale-factor", String(scale));
|
||||
wrapper.appendChild(textLayerDiv);
|
||||
|
||||
const textLayer = new lib.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: textLayerDiv,
|
||||
viewport,
|
||||
});
|
||||
await textLayer.render();
|
||||
const textDivs = textLayer.textDivs;
|
||||
|
||||
renderedPagesRef.current.push({
|
||||
page,
|
||||
viewport,
|
||||
wrapper,
|
||||
canvas,
|
||||
textDivs,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply highlights across all entries, then scroll to the first hit.
|
||||
let targetPage: number | null = null;
|
||||
if (list.length) {
|
||||
targetPage = await applyHighlights(list);
|
||||
if (targetPage === null) {
|
||||
// Fallback: scroll to the first entry's page hint, even without a highlight
|
||||
const hint = list.find((e) => e.page)?.page ?? null;
|
||||
targetPage = hint;
|
||||
}
|
||||
}
|
||||
if (targetPage && targetPage >= 1) {
|
||||
scrollToHighlightOnPage(targetPage);
|
||||
} else if (!hasCitation && scrollToPage && scrollToPage > 1) {
|
||||
// Restore scroll position after zoom re-render
|
||||
const pageEntry = renderedPagesRef.current[scrollToPage - 1];
|
||||
if (pageEntry)
|
||||
pageEntry.wrapper.scrollIntoView({
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
|
||||
reveal();
|
||||
},
|
||||
[applyHighlights],
|
||||
);
|
||||
|
||||
// Scroll so the first highlight on `pageNum` lands at the vertical center
|
||||
// of the viewer. We compute the scroll position explicitly on the scroll
|
||||
// container — calling `scrollIntoView` on a child of the absolutely-
|
||||
// positioned text layer can scroll just the overlay while leaving the
|
||||
// canvas untouched, which is why we don't use it here.
|
||||
function scrollToHighlightOnPage(pageNum: number) {
|
||||
const pageEntry = renderedPagesRef.current[pageNum - 1];
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
if (!pageEntry || !scrollEl) return;
|
||||
|
||||
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
|
||||
".pdf-text-highlight",
|
||||
);
|
||||
if (highlightEl) {
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const highlightRect = highlightEl.getBoundingClientRect();
|
||||
const offsetWithinContainer = highlightRect.top - containerRect.top;
|
||||
const targetTop =
|
||||
scrollEl.scrollTop +
|
||||
offsetWithinContainer -
|
||||
scrollEl.clientHeight / 2 +
|
||||
highlightRect.height / 2;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
} else {
|
||||
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const targetTop =
|
||||
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rehighlightQuotes = useCallback(
|
||||
async (list: QuoteEntry[]) => {
|
||||
const targetPage = await applyHighlights(list);
|
||||
const scrollPage =
|
||||
targetPage ?? list.find((e) => e.page)?.page ?? null;
|
||||
if (scrollPage && scrollPage >= 1) {
|
||||
scrollToHighlightOnPage(scrollPage);
|
||||
}
|
||||
},
|
||||
[applyHighlights],
|
||||
);
|
||||
|
||||
// Trackpad pinch-to-zoom (wheel + ctrlKey)
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaMode === 0 ? e.deltaY / 300 : e.deltaY * 0.1;
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round(zoomRef.current * Math.exp(-delta) * 100) / 100,
|
||||
),
|
||||
);
|
||||
if (next === zoomRef.current) return;
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener("wheel", handleWheel);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [renderPDF]);
|
||||
|
||||
// Touch pinch-to-zoom
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
let initialDist = 0;
|
||||
let initialZoom = 1.0;
|
||||
|
||||
function getTouchDist(touches: TouchList) {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
initialDist = getTouchDist(e.touches);
|
||||
initialZoom = zoomRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 2 || initialDist === 0) return;
|
||||
e.preventDefault();
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round(
|
||||
initialZoom *
|
||||
(getTouchDist(e.touches) / initialDist) *
|
||||
100,
|
||||
) / 100,
|
||||
),
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
if (e.touches.length < 2 && initialDist > 0) {
|
||||
initialDist = 0;
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
el.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", handleTouchStart);
|
||||
el.removeEventListener("touchmove", handleTouchMove);
|
||||
el.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}, [renderPDF]);
|
||||
|
||||
// Clean up PDF.js static font-measurement canvases on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
getPdfJs().then((lib) => lib.TextLayer.cleanup());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render PDF when fetch result arrives
|
||||
useEffect(() => {
|
||||
if (!result || result.type !== "pdf") return;
|
||||
pdfDocRef.current = null;
|
||||
renderedPagesRef.current = [];
|
||||
quoteListRef.current = quoteList;
|
||||
zoomRef.current = 1.0;
|
||||
setZoom(1.0);
|
||||
setNumPages(0);
|
||||
const list = quoteList;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const lib = await getPdfJs();
|
||||
if (cancelled) return;
|
||||
const pdfDoc = await lib.getDocument({
|
||||
data: new Uint8Array(result.buffer),
|
||||
standardFontDataUrl: STANDARD_FONT_DATA_URL,
|
||||
}).promise;
|
||||
if (cancelled) return;
|
||||
pdfDocRef.current = pdfDoc;
|
||||
await renderPDF(pdfDoc, list);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [result, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-render at new scale when container is resized (debounced 150ms)
|
||||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(pdfDocRef.current, quoteListRef.current);
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-highlight when quotes change without full re-render
|
||||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
quoteListRef.current = quoteList;
|
||||
if (quoteList.length === 0) return;
|
||||
rehighlightQuotes(quoteList);
|
||||
}, [quoteKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleZoomIn() {
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.round((zoomRef.current + ZOOM_STEP) * 100) / 100,
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
const next = Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round((zoomRef.current - ZOOM_STEP) * 100) / 100,
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackToDocx && doc?.document_id) {
|
||||
return (
|
||||
<DocxView
|
||||
documentId={doc.document_id}
|
||||
quotes={quotes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 px-3 pt-5 pb-3"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<MikeIcon spin mike size={28} />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
{numPages > 0 && (
|
||||
<>
|
||||
{/* Page counter — bottom left */}
|
||||
<div className="absolute bottom-4 left-4 pointer-events-none">
|
||||
<span className="flex items-center px-3 py-1.5 rounded-full text-xs font-medium tabular-nums text-gray-700 bg-white/25 backdrop-blur-md border border-white/30 shadow-md">
|
||||
{currentPage}/{numPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — bottom right */}
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-px rounded-full bg-white/25 backdrop-blur-md border border-white/30 shadow-md px-1 py-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-600 tabular-nums w-9 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/app/components/shared/DocViewModal.tsx
Normal file
101
frontend/src/app/components/shared/DocViewModal.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Trash2, X } from "lucide-react";
|
||||
import { DocView } from "./DocView";
|
||||
import { getDocumentUrl } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
|
||||
interface Props {
|
||||
doc: MikeDocument | null;
|
||||
/** Optional specific version to display. Only honoured for DOCX. */
|
||||
versionId?: string | null;
|
||||
/** Optional label suffix for the header (e.g. "V3"). */
|
||||
versionLabel?: string | null;
|
||||
onClose: () => void;
|
||||
onDelete?: (doc: MikeDocument) => void;
|
||||
}
|
||||
|
||||
export function DocViewModal({
|
||||
doc,
|
||||
versionId,
|
||||
versionLabel,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!doc || !mounted) return null;
|
||||
|
||||
async function handleDownload() {
|
||||
if (!doc) return;
|
||||
const { url, filename } = await getDocumentUrl(doc.id, versionId ?? null);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-black/40"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col bg-white rounded-xl shadow-2xl w-[800px] max-w-[90vw] h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0">
|
||||
<span className="text-base font-medium font-serif text-gray-800 truncate pr-4">
|
||||
{doc.filename}
|
||||
{versionLabel && (
|
||||
<span className="ml-2 text-xs font-normal text-gray-500">
|
||||
{versionLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { onDelete(doc); onClose(); }}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DocView serves PDF when available and falls back to
|
||||
docx-preview internally if the active version has no
|
||||
PDF rendition. Passing no versionId tells the backend
|
||||
to resolve the latest tracked-changes version. */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden px-3 pb-3">
|
||||
<DocView
|
||||
key={versionId ?? "current"}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: versionId ?? null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
86
frontend/src/app/components/shared/DocumentCard.tsx
Normal file
86
frontend/src/app/components/shared/DocumentCard.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, File, X, AlertCircle, Loader2 } from "lucide-react";
|
||||
import type { MikeDocument } from "./types";
|
||||
|
||||
interface Props {
|
||||
document: MikeDocument;
|
||||
onRemove?: (id: string) => void;
|
||||
onClick?: (doc: MikeDocument) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
function FileIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf") {
|
||||
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
|
||||
}
|
||||
if (fileType === "docx" || fileType === "doc") {
|
||||
return <File className="h-4 w-4 text-blue-600 shrink-0" />;
|
||||
}
|
||||
return <File className="h-4 w-4 text-gray-500 shrink-0" />;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function DocumentCard({ document, onRemove, onClick, selected }: Props) {
|
||||
const isError = document.status === "error";
|
||||
const isProcessing = document.status === "pending" || document.status === "processing";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(document)}
|
||||
className={[
|
||||
"flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition-colors",
|
||||
onClick ? "cursor-pointer" : "",
|
||||
selected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: isError
|
||||
? "border-red-200 bg-red-50"
|
||||
: "border-gray-200 bg-white hover:border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<FileIcon fileType={document.file_type} />
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-gray-800" title={document.filename}>
|
||||
{document.filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{isProcessing
|
||||
? "Processing…"
|
||||
: isError
|
||||
? "Upload failed"
|
||||
: [
|
||||
document.size_bytes != null ? formatBytes(document.size_bytes) : null,
|
||||
document.page_count ? `${document.page_count}p` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onRemove && !isProcessing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(document.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Remove document"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
509
frontend/src/app/components/shared/DocxView.tsx
Normal file
509
frontend/src/app/components/shared/DocxView.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
highlightDocxQuote,
|
||||
} from "./highlightDocxQuote";
|
||||
import type { CitationQuote } from "./types";
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
versionId?: string | null;
|
||||
/**
|
||||
* Called once the document has been rendered to the DOM. Handy for
|
||||
* scrolling to a particular tracked change after a re-render.
|
||||
*/
|
||||
onReady?: () => void;
|
||||
/**
|
||||
* Tracked-change to scroll to + briefly flash after each render. The
|
||||
* `key` is used to re-trigger scrolling when the same edit is clicked
|
||||
* twice in a row.
|
||||
*/
|
||||
highlightEdit?: {
|
||||
key: string;
|
||||
inserted_text?: string;
|
||||
deleted_text?: string;
|
||||
/**
|
||||
* Numeric w:id values of the <w:ins>/<w:del> wrappers in
|
||||
* document.xml. Preferred over text matching — uniquely identifies
|
||||
* the right DOM element even when multiple edits share identical
|
||||
* inserted/deleted text. `docx-preview` drops these during parsing,
|
||||
* so we re-tag each rendered <ins>/<del> with data-w-id after load.
|
||||
*/
|
||||
ins_w_id?: string | null;
|
||||
del_w_id?: string | null;
|
||||
} | null;
|
||||
/**
|
||||
* Forces a byte re-fetch when it changes, even if documentId/versionId
|
||||
* are stable. Used after accept/reject: the backend overwrites bytes at
|
||||
* the same storage path (no new version row), so the hook has no other
|
||||
* signal that the file changed.
|
||||
*/
|
||||
refetchKey?: number;
|
||||
/**
|
||||
* Citation quotes to highlight in the rendered output. The first match
|
||||
* is scrolled into view. Page numbers are ignored — DOCX has no explicit
|
||||
* pagination the renderer can match against.
|
||||
*/
|
||||
quotes?: CitationQuote[];
|
||||
/**
|
||||
* Warning banner copy rendered in the top-left of the viewer. Used
|
||||
* for non-blocking errors (e.g. "Accept failed — reverted").
|
||||
*/
|
||||
warning?: string | null;
|
||||
/**
|
||||
* Called when the user dismisses the warning banner.
|
||||
*/
|
||||
onWarningDismiss?: () => void;
|
||||
/**
|
||||
* Scroll position to restore after the first render — used by parents
|
||||
* that track per-tab scroll and want to re-enter at the same spot.
|
||||
* Null/undefined means "no override" (preserve the pre-render scroll).
|
||||
*/
|
||||
initialScrollTop?: number | null;
|
||||
/**
|
||||
* Fires on scroll (throttled by rAF) so the parent can persist the
|
||||
* current scrollTop against its tab state.
|
||||
*/
|
||||
onScrollChange?: (scrollTop: number) => void;
|
||||
rounded?: boolean;
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
function findEditElement(
|
||||
root: HTMLElement,
|
||||
tag: "ins" | "del",
|
||||
opts: { w_id?: string | null; text?: string },
|
||||
): HTMLElement | null {
|
||||
if (opts.w_id) {
|
||||
const byId = root.querySelector(
|
||||
`${tag}[data-w-id="${CSS.escape(opts.w_id)}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (byId) return byId;
|
||||
}
|
||||
const text = opts.text ?? "";
|
||||
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
|
||||
const target = normalize(text);
|
||||
if (!target) return null;
|
||||
const candidates = Array.from(root.querySelectorAll(tag)) as HTMLElement[];
|
||||
return (
|
||||
candidates.find((el) => normalize(el.textContent ?? "") === target) ??
|
||||
candidates.find((el) =>
|
||||
normalize(el.textContent ?? "").includes(target),
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToHighlight(
|
||||
container: HTMLElement,
|
||||
scrollEl: HTMLElement,
|
||||
edit: {
|
||||
inserted_text?: string;
|
||||
deleted_text?: string;
|
||||
ins_w_id?: string | null;
|
||||
del_w_id?: string | null;
|
||||
},
|
||||
) {
|
||||
const insEl = findEditElement(container, "ins", {
|
||||
w_id: edit.ins_w_id,
|
||||
text: edit.inserted_text,
|
||||
});
|
||||
const delEl = findEditElement(container, "del", {
|
||||
w_id: edit.del_w_id,
|
||||
text: edit.deleted_text,
|
||||
});
|
||||
const anchor = insEl ?? delEl;
|
||||
if (!anchor) return;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = anchor.getBoundingClientRect();
|
||||
const offset = targetRect.top - scrollRect.top + scrollEl.scrollTop - 80;
|
||||
scrollEl.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
|
||||
|
||||
const flashed = [insEl, delEl].filter((el): el is HTMLElement => !!el);
|
||||
flashed.forEach((el) => el.classList.add("docx-edit-flash"));
|
||||
window.setTimeout(() => {
|
||||
flashed.forEach((el) => el.classList.remove("docx-edit-flash"));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the ordered list of w:ids for every w:ins/w:del in the current
|
||||
* version and tag each rendered <ins>/<del> with data-w-id. The backend
|
||||
* returns ids in document order, and docx-preview emits <ins>/<del>
|
||||
* in the same order, so we can align by index.
|
||||
*/
|
||||
async function tagWIdsOnRenderedDom(
|
||||
container: HTMLElement,
|
||||
documentId: string,
|
||||
versionId: string | null | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${documentId}/tracked-change-ids${qs}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn(
|
||||
"[DocxView] tracked-change-ids fetch failed",
|
||||
resp.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
ids: { kind: "ins" | "del"; w_id: string }[];
|
||||
};
|
||||
const domEls = Array.from(
|
||||
container.querySelectorAll("ins, del"),
|
||||
) as HTMLElement[];
|
||||
const ids = data.ids ?? [];
|
||||
let tagged = 0;
|
||||
let mismatched = 0;
|
||||
for (let i = 0; i < Math.min(domEls.length, ids.length); i++) {
|
||||
const el = domEls[i];
|
||||
const info = ids[i];
|
||||
if (el.tagName.toLowerCase() !== info.kind) {
|
||||
mismatched++;
|
||||
continue;
|
||||
}
|
||||
el.setAttribute("data-w-id", info.w_id);
|
||||
tagged++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[DocxView] tagWIdsOnRenderedDom failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a .docx in the browser using `docx-preview`. Tracked changes
|
||||
* (`w:ins` / `w:del`) show up automatically with coloured strike/underline
|
||||
* styling. Scroll position is preserved across re-renders so Accept/Reject
|
||||
* doesn't jump the user back to the top.
|
||||
*/
|
||||
export function DocxView({
|
||||
documentId,
|
||||
versionId,
|
||||
onReady,
|
||||
highlightEdit,
|
||||
refetchKey,
|
||||
quotes,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
onScrollChange,
|
||||
rounded = true,
|
||||
bordered = true,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const renderKeyRef = useRef(0);
|
||||
// Ref-stabilize onReady and highlightEdit so the render effect only
|
||||
// re-fires when `bytes` actually change. Without this, any parent
|
||||
// re-render (e.g. clicking a new highlight) creates a new onReady
|
||||
// identity, triggers a full re-render, and snaps scroll back to top.
|
||||
const onReadyRef = useRef(onReady);
|
||||
onReadyRef.current = onReady;
|
||||
const highlightEditRef = useRef(highlightEdit);
|
||||
highlightEditRef.current = highlightEdit;
|
||||
const quotesRef = useRef(quotes);
|
||||
quotesRef.current = quotes;
|
||||
const initialScrollTopRef = useRef(initialScrollTop ?? null);
|
||||
initialScrollTopRef.current = initialScrollTop ?? null;
|
||||
const onScrollChangeRef = useRef(onScrollChange);
|
||||
onScrollChangeRef.current = onScrollChange;
|
||||
|
||||
// Stable key for the quote list so the re-highlight effect re-fires
|
||||
// only when the actual text/order of quotes changes.
|
||||
const quoteKey = useMemo(
|
||||
() => (quotes ?? []).map((q) => q.quote).join("||"),
|
||||
[quotes],
|
||||
);
|
||||
|
||||
const { bytes, loading, error } = useFetchDocxBytes(
|
||||
documentId,
|
||||
versionId,
|
||||
refetchKey,
|
||||
);
|
||||
|
||||
/**
|
||||
* Highlight every quote in `list` inside the rendered DOM and scroll
|
||||
* the first match into view. Returns true if any match was found.
|
||||
*/
|
||||
const applyQuoteHighlights = (
|
||||
containerEl: HTMLElement,
|
||||
scrollEl: HTMLElement,
|
||||
list: CitationQuote[] | undefined,
|
||||
): boolean => {
|
||||
clearDocxQuoteHighlights(containerEl);
|
||||
if (!list || list.length === 0) return false;
|
||||
|
||||
let firstMatch: HTMLElement | null = null;
|
||||
for (const q of list) {
|
||||
const match = highlightDocxQuote(containerEl, q.quote);
|
||||
if (match && !firstMatch) firstMatch = match;
|
||||
}
|
||||
if (!firstMatch) return false;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = firstMatch.getBoundingClientRect();
|
||||
const offset =
|
||||
targetRect.top -
|
||||
scrollRect.top +
|
||||
scrollEl.scrollTop -
|
||||
scrollEl.clientHeight / 2 +
|
||||
targetRect.height / 2;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, offset),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* docx-preview renders pages at their natural Word page width (e.g.
|
||||
* ~816px for US Letter). When the side-panel is narrower than that,
|
||||
* the page overflows horizontally. Apply CSS `zoom` on each
|
||||
* section.docx so the document shrinks to fit — `zoom` (unlike
|
||||
* `transform: scale`) also shrinks the layout box, so the scroll
|
||||
* container's scrollHeight adapts. We zoom each page rather than the
|
||||
* wrapper because docx-preview injects flex styles on `.docx-wrapper`
|
||||
* that can interfere with wrapper-level zoom.
|
||||
*/
|
||||
const applyDocxScale = () => {
|
||||
const containerEl = containerRef.current;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!containerEl || !scrollEl) return;
|
||||
const wrapper = containerEl.querySelector<HTMLElement>(".docx-wrapper");
|
||||
if (!wrapper) return;
|
||||
const sections = Array.from(
|
||||
wrapper.querySelectorAll<HTMLElement>("section.docx"),
|
||||
);
|
||||
if (sections.length === 0) return;
|
||||
// Reset zoom on every page before measuring so offsetWidth reports
|
||||
// each page's natural width (pages can have different widths — e.g.
|
||||
// mixed portrait/landscape sections).
|
||||
sections.forEach((s) => {
|
||||
s.style.zoom = "1";
|
||||
});
|
||||
// Use the scroll container's content box (clientWidth - padding)
|
||||
// as the available width.
|
||||
const styles = window.getComputedStyle(scrollEl);
|
||||
const padX =
|
||||
(parseFloat(styles.paddingLeft) || 0) +
|
||||
(parseFloat(styles.paddingRight) || 0);
|
||||
const available = scrollEl.clientWidth - padX;
|
||||
if (available <= 0) return;
|
||||
// Scale each page independently against its own natural width so
|
||||
// landscape/custom-size pages still fit without distorting the
|
||||
// page dividers.
|
||||
sections.forEach((s) => {
|
||||
const w = s.offsetWidth;
|
||||
if (!w) return;
|
||||
const scale = Math.min(1, available / w);
|
||||
s.style.zoom = String(scale);
|
||||
});
|
||||
};
|
||||
|
||||
// Observe the scroll container (which tracks the side panel's width)
|
||||
// and re-scale whenever it resizes. Also observe the docx container so
|
||||
// we re-scale once docx-preview finishes inserting pages.
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
if (!scrollEl || !containerEl) return;
|
||||
let raf = 0;
|
||||
const schedule = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => applyDocxScale());
|
||||
};
|
||||
const ro = new ResizeObserver(schedule);
|
||||
ro.observe(scrollEl);
|
||||
ro.observe(containerEl);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!bytes || !containerRef.current || !scrollRef.current) return;
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
|
||||
console.log("[DocxView] render effect fired", {
|
||||
documentId,
|
||||
versionId,
|
||||
refetchKey,
|
||||
bytesLen: bytes.byteLength,
|
||||
});
|
||||
|
||||
// Remember scroll position across re-renders so Accept/Reject stays put.
|
||||
lastScrollTopRef.current = scrollEl.scrollTop;
|
||||
const thisRender = ++renderKeyRef.current;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { renderAsync } = await import("docx-preview");
|
||||
if (cancelled) return;
|
||||
containerEl.innerHTML = "";
|
||||
await renderAsync(bytes, containerEl, undefined, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
renderChanges: true,
|
||||
experimental: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
await tagWIdsOnRenderedDom(
|
||||
containerEl,
|
||||
documentId,
|
||||
versionId ?? null,
|
||||
);
|
||||
if (cancelled) return;
|
||||
// Scale to fit before scrolling so offsets are computed
|
||||
// against the post-zoom layout.
|
||||
applyDocxScale();
|
||||
requestAnimationFrame(() => {
|
||||
if (
|
||||
!scrollRef.current ||
|
||||
thisRender !== renderKeyRef.current
|
||||
)
|
||||
return;
|
||||
const pendingHighlight = highlightEditRef.current;
|
||||
const pendingQuotes = quotesRef.current;
|
||||
const pendingInitialScroll = initialScrollTopRef.current;
|
||||
if (pendingHighlight) {
|
||||
scrollToHighlight(
|
||||
containerEl,
|
||||
scrollRef.current,
|
||||
pendingHighlight,
|
||||
);
|
||||
// Highlight quotes too, but don't override the edit scroll
|
||||
if (pendingQuotes?.length) {
|
||||
for (const q of pendingQuotes)
|
||||
highlightDocxQuote(containerEl, q.quote);
|
||||
}
|
||||
} else if (
|
||||
pendingQuotes &&
|
||||
applyQuoteHighlights(
|
||||
containerEl,
|
||||
scrollRef.current,
|
||||
pendingQuotes,
|
||||
)
|
||||
) {
|
||||
// scrolled inside applyQuoteHighlights
|
||||
} else if (typeof pendingInitialScroll === "number") {
|
||||
scrollRef.current.scrollTop = pendingInitialScroll;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = lastScrollTopRef.current;
|
||||
}
|
||||
onReadyRef.current?.();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("docx-preview render failed", e);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bytes]);
|
||||
|
||||
// Re-scroll/highlight if the target edit changes without a re-render
|
||||
// (e.g. same doc, different edit clicked).
|
||||
useEffect(() => {
|
||||
if (!highlightEdit || !containerRef.current || !scrollRef.current)
|
||||
return;
|
||||
scrollToHighlight(
|
||||
containerRef.current,
|
||||
scrollRef.current,
|
||||
highlightEdit,
|
||||
);
|
||||
}, [highlightEdit?.key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-apply quote highlights when the quote list changes without a full
|
||||
// re-render (e.g. clicking a different citation on the same doc).
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !scrollRef.current) return;
|
||||
applyQuoteHighlights(
|
||||
containerRef.current,
|
||||
scrollRef.current,
|
||||
quotesRef.current,
|
||||
);
|
||||
}, [quoteKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire onScrollChange (rAF-throttled) so parents can persist scroll
|
||||
// per-tab. We still maintain lastScrollTopRef locally for same-mount
|
||||
// re-renders (Accept/Reject preserving scroll within one view).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
let scheduled = false;
|
||||
const onScroll = () => {
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
onScrollChangeRef.current?.(el.scrollTop);
|
||||
});
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`}
|
||||
>
|
||||
{warning && (
|
||||
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 shadow-sm">
|
||||
<span>{warning}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWarningDismiss?.()}
|
||||
className="text-amber-600 hover:text-amber-900"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 px-5 pt-5 pb-3 docx-view-scroll"
|
||||
data-document-id={documentId}
|
||||
data-version-id={versionId ?? ""}
|
||||
>
|
||||
{loading && !bytes && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<MikeIcon spin mike size={28} />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="docx-view-container" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
frontend/src/app/components/shared/EmailPillInput.tsx
Normal file
117
frontend/src/app/components/shared/EmailPillInput.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface Props {
|
||||
emails: string[];
|
||||
onChange: (emails: string[]) => void;
|
||||
validate?: (email: string) => Promise<string | null>;
|
||||
onValidatingChange?: (validating: boolean) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function EmailPillInput({
|
||||
emails,
|
||||
onChange,
|
||||
validate,
|
||||
onValidatingChange,
|
||||
placeholder = "Add by email…",
|
||||
autoFocus = false,
|
||||
}: Props) {
|
||||
const [input, setInput] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function setValidatingState(v: boolean) {
|
||||
setValidating(v);
|
||||
onValidatingChange?.(v);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addEmail();
|
||||
} else if (e.key === "Backspace" && !input && emails.length > 0) {
|
||||
onChange(emails.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
async function addEmail() {
|
||||
const email = input.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
if (emails.includes(email)) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
if (validate) {
|
||||
setValidatingState(true);
|
||||
setError(null);
|
||||
try {
|
||||
const err = await validate(email);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError("Could not verify email. Try again.");
|
||||
return;
|
||||
} finally {
|
||||
setValidatingState(false);
|
||||
}
|
||||
}
|
||||
onChange([...emails, email]);
|
||||
setInput("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex flex-wrap gap-1.5 rounded-lg border bg-gray-50 px-3 py-2 min-h-[40px] transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus-within:border-red-400"
|
||||
: "border-gray-200 focus-within:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{emails.map((email) => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-200 px-2.5 py-0.5 text-xs text-gray-700"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(emails.filter((e) => e !== email))}
|
||||
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="email"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addEmail}
|
||||
placeholder={emails.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[160px] bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
|
||||
{validating && <p className="mt-1.5 text-xs text-gray-400">Checking…</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
frontend/src/app/components/shared/FileDirectory.tsx
Normal file
334
frontend/src/app/components/shared/FileDirectory.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { MikeDocument, MikeProject } from "./types";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function DocFileIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-3.5 w-3.5 text-red-500 shrink-0" />;
|
||||
return <File className="h-3.5 w-3.5 text-blue-500 shrink-0" />;
|
||||
}
|
||||
|
||||
interface FileDirectoryProps {
|
||||
standaloneDocs: MikeDocument[];
|
||||
directoryProjects: MikeProject[];
|
||||
loading: boolean;
|
||||
selectedIds: Set<string>;
|
||||
onChange: (ids: Set<string>) => void;
|
||||
allowMultiple?: boolean;
|
||||
forceExpanded?: boolean;
|
||||
emptyMessage?: string;
|
||||
heading?: string;
|
||||
onDelete?: (ids: string[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function FileDirectory({
|
||||
standaloneDocs,
|
||||
directoryProjects,
|
||||
loading,
|
||||
selectedIds,
|
||||
onChange,
|
||||
allowMultiple = true,
|
||||
forceExpanded = false,
|
||||
emptyMessage = "No documents yet",
|
||||
heading = "Documents",
|
||||
onDelete,
|
||||
}: FileDirectoryProps) {
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
async function handleDelete() {
|
||||
if (!onDelete || selectedCount === 0 || deleting) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(ids);
|
||||
const next = new Set(selectedIds);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
onChange(next);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allDocs = [
|
||||
...standaloneDocs,
|
||||
...directoryProjects.flatMap((p) => p.documents ?? []),
|
||||
];
|
||||
|
||||
const allStandaloneSelected =
|
||||
standaloneDocs.length > 0 &&
|
||||
standaloneDocs.every((d) => selectedIds.has(d.id));
|
||||
|
||||
function toggle(docId: string) {
|
||||
if (!allowMultiple) {
|
||||
onChange(new Set([docId]));
|
||||
return;
|
||||
}
|
||||
const next = new Set(selectedIds);
|
||||
next.has(docId) ? next.delete(docId) : next.add(docId);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allStandaloneSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
standaloneDocs.forEach((d) => next.delete(d.id));
|
||||
onChange(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
standaloneDocs.forEach((d) => next.add(d.id));
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(projectId: string) {
|
||||
if (forceExpanded) return;
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(projectId) ? next.delete(projectId) : next.add(projectId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{/* Documents header skeleton */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="h-3 w-20 rounded bg-gray-200 animate-pulse" />
|
||||
<div className="h-3 w-12 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
{/* File rows skeleton */}
|
||||
<div>
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-200 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (allDocs.length === 0 && directoryProjects.length === 0) {
|
||||
return (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div>
|
||||
{(standaloneDocs.length > 0 ||
|
||||
(onDelete && selectedCount > 0)) && (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
{heading}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{onDelete && selectedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{standaloneDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{allStandaloneSelected
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{standaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs transition-colors text-left ${
|
||||
selected ? "bg-gray-100" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
selected ? "text-gray-900" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip n={doc.latest_version_number} />
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{standaloneDocs.length > 0 && directoryProjects.length > 0 && (
|
||||
<div className="border-t border-gray-100 py-2 px-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{directoryProjects.map((project) => {
|
||||
const isExpanded =
|
||||
forceExpanded || expandedProjects.has(project.id);
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(project.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs hover:bg-gray-50 transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className="flex-1 truncate font-medium text-gray-700">
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{docs.length}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{docs.length === 0 ? (
|
||||
<p className="pl-7 py-1 text-xs text-gray-400">
|
||||
Empty
|
||||
</p>
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
const selected = selectedIds.has(
|
||||
doc.id,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() =>
|
||||
toggle(doc.id)
|
||||
}
|
||||
className={`w-full flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-colors text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon
|
||||
fileType={doc.file_type}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 truncate min-w-0 ${
|
||||
selected
|
||||
? "text-gray-900 font-medium"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={doc.latest_version_number}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(
|
||||
doc.created_at,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/app/components/shared/HeaderSearchBtn.tsx
Normal file
57
frontend/src/app/components/shared/HeaderSearchBtn.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
onChange("");
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open, onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex items-center">
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-1.5 shadow-sm z-10 w-72">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setOpen(false); onChange(""); }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
frontend/src/app/components/shared/OwnerOnlyModal.tsx
Normal file
93
frontend/src/app/components/shared/OwnerOnlyModal.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { Lock, X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Short headline above the body, e.g. "Owner-only action". */
|
||||
title?: string;
|
||||
/** Sentence describing what the user tried to do. */
|
||||
action?: string;
|
||||
/** Email of the project/resource owner, shown so the user knows who to ask. */
|
||||
ownerEmail?: string | null;
|
||||
/** Override the default message entirely. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight "you don't have permission" modal shown when a non-owner
|
||||
* attempts an owner-only action (manage people, rename, delete, …) on a
|
||||
* shared project. Replaces the silent 404 the backend would otherwise
|
||||
* return so the user understands why the action didn't go through.
|
||||
*/
|
||||
export function OwnerOnlyModal({
|
||||
open,
|
||||
onClose,
|
||||
title = "Owner-only action",
|
||||
action,
|
||||
ownerEmail,
|
||||
message,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
const body =
|
||||
message ??
|
||||
(action
|
||||
? `Only the project owner can ${action}.`
|
||||
: "Only the project owner can perform this action.");
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-amber-600" />
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
</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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-2 pt-1">
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{body}
|
||||
</p>
|
||||
{ownerEmail && (
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Ask{" "}
|
||||
<span className="text-gray-600">{ownerEmail}</span>{" "}
|
||||
if you need access.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
364
frontend/src/app/components/shared/PeopleModal.tsx
Normal file
364
frontend/src/app/components/shared/PeopleModal.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, User, UserPlus, Loader2, Plus } from "lucide-react";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
|
||||
/**
|
||||
* Any resource the modal can manage members for — projects today, tabular
|
||||
* reviews now, anything else with a `shared_with` email list later.
|
||||
*/
|
||||
export interface SharedResource {
|
||||
id: string;
|
||||
shared_with?: string[] | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The thing being shared (project, review, …). */
|
||||
resource: SharedResource | null;
|
||||
/**
|
||||
* Resolve the owner + members roster for the given resource. Different
|
||||
* resource types hit different endpoints (`/projects/:id/people`,
|
||||
* `/tabular-review/:id/people`, …) so the caller passes the appropriate
|
||||
* fetcher.
|
||||
*/
|
||||
fetchPeople: (id: string) => Promise<ProjectPeople>;
|
||||
/** Currently signed-in user's email — gets the "You" tag if it matches. */
|
||||
currentUserEmail?: string | null;
|
||||
breadcrumb: string[];
|
||||
/**
|
||||
* Persist a new shared_with list. Parent should PATCH the resource and
|
||||
* sync its local state on success. Throw to surface an error inline.
|
||||
*/
|
||||
onSharedWithChange?: (sharedWith: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type RosterRow = {
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
role: "owner" | "member";
|
||||
};
|
||||
|
||||
/**
|
||||
* Roster of every Mike member with access to the project, with controls to
|
||||
* add/remove members. Mirrors AddDocumentsModal's frame.
|
||||
*/
|
||||
export function PeopleModal({
|
||||
open,
|
||||
onClose,
|
||||
resource,
|
||||
fetchPeople,
|
||||
currentUserEmail,
|
||||
breadcrumb,
|
||||
onSharedWithChange,
|
||||
}: Props) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [busy, setBusy] = useState<"add" | "remove" | null>(null);
|
||||
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Server-resolved roster: owner email/display_name + members'
|
||||
// display_names. We keep `resource.shared_with` as the source of truth
|
||||
// for membership and just merge display_names from this fetch.
|
||||
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||
|
||||
const resourceId = resource?.id ?? null;
|
||||
const sharedWith: string[] = Array.isArray(resource?.shared_with)
|
||||
? (resource!.shared_with as string[])
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setNewEmail("");
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
}, [open]);
|
||||
|
||||
// Re-fetch roster whenever the modal opens or membership changes —
|
||||
// keyed by the joined shared_with list so add/remove triggers a refresh.
|
||||
const sharedKey = sharedWith
|
||||
.map((e) => e.toLowerCase())
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !resourceId) return;
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
fetchPeople(resourceId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPeople(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep stale data; modal still works on emails alone */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, resourceId, sharedKey, fetchPeople]);
|
||||
|
||||
if (!open || !resource) return null;
|
||||
|
||||
const memberDisplayByEmail = new Map<string, string | null>();
|
||||
for (const m of people?.members ?? []) {
|
||||
memberDisplayByEmail.set(m.email.toLowerCase(), m.display_name);
|
||||
}
|
||||
const ownerEmail = people?.owner.email ?? null;
|
||||
const ownerDisplayName = people?.owner.display_name ?? null;
|
||||
|
||||
const roster: RosterRow[] = [];
|
||||
if (ownerEmail) {
|
||||
roster.push({
|
||||
email: ownerEmail,
|
||||
display_name: ownerDisplayName,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
for (const email of sharedWith) {
|
||||
const lower = email.toLowerCase();
|
||||
if (ownerEmail && lower === ownerEmail.toLowerCase()) continue;
|
||||
roster.push({
|
||||
email,
|
||||
display_name: memberDisplayByEmail.get(lower) ?? null,
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedNewEmail = newEmail.trim().toLowerCase();
|
||||
const isValidEmail = EMAIL_RE.test(trimmedNewEmail);
|
||||
const sharedLower = sharedWith.map((e) => e.toLowerCase());
|
||||
const alreadyShared = sharedLower.includes(trimmedNewEmail);
|
||||
const isOwnerEmail =
|
||||
!!ownerEmail && trimmedNewEmail === ownerEmail.toLowerCase();
|
||||
const canAdd =
|
||||
isValidEmail && !alreadyShared && !isOwnerEmail && busy === null;
|
||||
|
||||
async function handleAdd() {
|
||||
if (!canAdd || !onSharedWithChange) return;
|
||||
setBusy("add");
|
||||
setError(null);
|
||||
try {
|
||||
const next = [...sharedWith, trimmedNewEmail];
|
||||
await onSharedWithChange(next);
|
||||
setNewEmail("");
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Couldn't add the member. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(email: string) {
|
||||
if (!onSharedWithChange || busy !== null) return;
|
||||
setBusy("remove");
|
||||
setRemovingEmail(email);
|
||||
setError(null);
|
||||
try {
|
||||
const next = sharedWith.filter(
|
||||
(e) => e.toLowerCase() !== email.toLowerCase(),
|
||||
);
|
||||
await onSharedWithChange(next);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Couldn't remove the member. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Add-member row */}
|
||||
{onSharedWithChange && (
|
||||
<div className="px-4 pt-1 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Add by email…"
|
||||
value={newEmail}
|
||||
onChange={(e) =>
|
||||
setNewEmail(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleAdd();
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!canAdd}
|
||||
title="Add member"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-900 bg-gray-900 p-2 text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy === "add" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{alreadyShared && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
{trimmedNewEmail} already has access.
|
||||
</p>
|
||||
)}
|
||||
{isOwnerEmail && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
{trimmedNewEmail} is the owner.
|
||||
</p>
|
||||
)}
|
||||
{trimmedNewEmail &&
|
||||
!isValidEmail &&
|
||||
!alreadyShared &&
|
||||
!isOwnerEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
Enter a valid email.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section heading */}
|
||||
<div className="px-4 pt-3 pb-1 flex items-center gap-2">
|
||||
<h3 className="text-xs font-medium text-gray-500">
|
||||
People with Access
|
||||
</h3>
|
||||
{peopleLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Member list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
{roster.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
No one has access yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
||||
{roster.map((entry) => {
|
||||
const isYou =
|
||||
!!currentUserEmail &&
|
||||
entry.email.toLowerCase() ===
|
||||
currentUserEmail.toLowerCase();
|
||||
const isRemoving =
|
||||
busy === "remove" &&
|
||||
removingEmail === entry.email;
|
||||
const primary =
|
||||
entry.display_name?.trim() || entry.email;
|
||||
const showSecondary =
|
||||
!!entry.display_name?.trim() &&
|
||||
primary !== entry.email;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.role}-${entry.email}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800">
|
||||
{primary}
|
||||
{isYou && (
|
||||
<span className="ml-1.5 text-xs text-gray-400">
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
{entry.role === "owner" && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-400">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{showSecondary && (
|
||||
<p className="truncate text-xs text-gray-400">
|
||||
{entry.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.role === "member" &&
|
||||
onSharedWithChange && (
|
||||
<button
|
||||
onClick={() =>
|
||||
void handleRemove(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
disabled={busy !== null}
|
||||
title="Remove access"
|
||||
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isRemoving && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 text-[11px] text-gray-400">
|
||||
{roster.length === 0
|
||||
? "No one has access yet."
|
||||
: `${roster.length} ${
|
||||
roster.length === 1 ? "person" : "people"
|
||||
} with access.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
74
frontend/src/app/components/shared/PreResponseWrapper.tsx
Normal file
74
frontend/src/app/components/shared/PreResponseWrapper.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function PreResponseWrapper({
|
||||
children,
|
||||
stepCount,
|
||||
shouldMinimize,
|
||||
isStreaming,
|
||||
compact = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
stepCount: number;
|
||||
shouldMinimize: boolean;
|
||||
isStreaming: boolean;
|
||||
/** Tighter typography + child gap for narrow side panels (e.g. TR chat). */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(!shouldMinimize);
|
||||
// Once content has streamed in (shouldMinimize=true even once), stay
|
||||
// minimized even if a later render briefly evaluates shouldMinimize=false.
|
||||
// Without this latch, the wrapper visibly pops open when isStreaming
|
||||
// flips off at the end of the response.
|
||||
const hasMinimizedRef = useRef(shouldMinimize);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldMinimize) hasMinimizedRef.current = true;
|
||||
if (userToggled) return;
|
||||
setIsOpen(!shouldMinimize && !hasMinimizedRef.current);
|
||||
}, [shouldMinimize, userToggled]);
|
||||
|
||||
const stepWord = `step${stepCount === 1 ? "" : "s"}`;
|
||||
const label = isStreaming
|
||||
? "Working"
|
||||
: `Completed in ${stepCount} ${stepWord}`;
|
||||
|
||||
const buttonTextClass = compact ? "text-xs" : "text-sm";
|
||||
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setIsOpen((v) => !v);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between font-serif text-gray-500 hover:text-gray-700 transition-colors ${buttonTextClass}`}
|
||||
>
|
||||
<span className="flex items-baseline min-w-0">
|
||||
<span className="truncate">{label}</span>
|
||||
{isStreaming && (
|
||||
<span className="inline-flex ml-1 shrink-0 items-baseline">
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0s]" />
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0.2s]" />
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 animate-[bounce_1.4s_infinite_0.4s]" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={`mt-3 flex flex-col ${childrenGapClass}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
frontend/src/app/components/shared/ProjectPicker.tsx
Normal file
91
frontend/src/app/components/shared/ProjectPicker.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Folder, Search, X } from "lucide-react";
|
||||
import type { MikeProject } from "./types";
|
||||
|
||||
interface Props {
|
||||
projects: MikeProject[];
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q ? projects.filter((p) => p.name.toLowerCase().includes(q)) : projects;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 projects…"
|
||||
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>
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
{loading ? (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center px-2 py-2">
|
||||
<div className="h-3 w-14 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
{[65, 45, 80, 55, 70].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-2">
|
||||
<div className="h-3.5 w-3.5 rounded-full border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div className="h-3 rounded bg-gray-200 animate-pulse" style={{ width: `${w}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{q ? "No matches found" : "No projects yet"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">Projects</p>
|
||||
</div>
|
||||
<div className="space-y-px">
|
||||
{filtered.map((project) => {
|
||||
const isSelected = selectedId === project.id;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => onSelect(isSelected ? null : project.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs transition-colors text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-50"}`}
|
||||
>
|
||||
<span className={`shrink-0 h-3.5 w-3.5 rounded-full border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}>
|
||||
{isSelected && <span className="h-1.5 w-1.5 rounded-full bg-white" />}
|
||||
</span>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">(#{project.cm_number})</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-400">{project.document_count ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
frontend/src/app/components/shared/RenameableTitle.tsx
Normal file
71
frontend/src/app/components/shared/RenameableTitle.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onCommit: (newValue: string) => void;
|
||||
suffix?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const caretPos = useRef<number | null>(null);
|
||||
const escaped = useRef(false);
|
||||
|
||||
function startEditing(e: React.MouseEvent) {
|
||||
const doc = document as any;
|
||||
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
|
||||
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
|
||||
caretPos.current = caret ? caret.offset : range ? range.startOffset : null;
|
||||
escaped.current = false;
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (escaped.current) {
|
||||
escaped.current = false;
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
onCommit(draft.trim());
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (caretPos.current !== null) {
|
||||
el.setSelectionRange(caretPos.current, caretPos.current);
|
||||
}
|
||||
}}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
if (e.key === "Escape") {
|
||||
escaped.current = true;
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="text-gray-900 bg-transparent outline-none min-w-0"
|
||||
style={{ width: `${draft.length + 1}ch` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="text-gray-900 cursor-text hover:text-gray-600 transition-colors"
|
||||
onClick={startEditing}
|
||||
>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
147
frontend/src/app/components/shared/RowActions.tsx
Normal file
147
frontend/src/app/components/shared/RowActions.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Eye, EyeOff, FolderMinus, Hash, History, Pencil, Trash2, Upload } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
onDelete?: () => void;
|
||||
onHide?: () => void;
|
||||
onUnhide?: () => void;
|
||||
onDownload?: () => void;
|
||||
onRemoveFromFolder?: () => void;
|
||||
onShowAllVersions?: () => void;
|
||||
onUploadNewVersion?: () => void;
|
||||
deleting?: boolean;
|
||||
onRename?: () => void;
|
||||
onUpdateCmNumber?: () => void;
|
||||
}
|
||||
|
||||
export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFromFolder, onShowAllVersions, onUploadNewVersion, deleting, onRename, onUpdateCmNumber }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick() {
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleToggle(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!open && btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
setCoords({
|
||||
top: rect.bottom + 4,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={handleToggle}
|
||||
className="flex items-center justify-center w-6 h-6 rounded text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-colors leading-none"
|
||||
>
|
||||
<span className="tracking-widest text-xs">···</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{ position: "fixed", top: coords.top, right: coords.right }}
|
||||
className="z-50 w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onRename(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onUpdateCmNumber && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onUpdateCmNumber(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
Edit CM No.
|
||||
</button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onDownload(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{onShowAllVersions && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onShowAllVersions(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<History className="h-3.5 w-3.5 shrink-0" />
|
||||
Show all versions
|
||||
</button>
|
||||
)}
|
||||
{onUploadNewVersion && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onUploadNewVersion(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 shrink-0" />
|
||||
Upload new version
|
||||
</button>
|
||||
)}
|
||||
{onRemoveFromFolder && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onRemoveFromFolder(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
|
||||
Remove from subfolder
|
||||
</button>
|
||||
)}
|
||||
{onUnhide && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onUnhide(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Unhide
|
||||
</button>
|
||||
)}
|
||||
{onHide && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onHide(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onDelete(); }}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
frontend/src/app/components/shared/SidebarChatItem.tsx
Normal file
154
frontend/src/app/components/shared/SidebarChatItem.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { MoreHorizontal, Pencil, Trash2, Check, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import type { MikeChat } from "@/app/components/shared/types";
|
||||
|
||||
interface Props {
|
||||
chat: MikeChat;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props) {
|
||||
const { renameChat, deleteChat } = useChatHistoryContext();
|
||||
const { user } = useAuth();
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(chat.title ?? "");
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
// Sidebar can show collaborator chats from projects the user owns;
|
||||
// rename/delete are still creator-only on the backend, so guard here.
|
||||
const isChatOwner = !!user?.id && chat.user_id === user.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) editInputRef.current?.focus();
|
||||
}, [isRenaming]);
|
||||
|
||||
const handleRenameSave = async () => {
|
||||
const trimmed = editTitle.trim();
|
||||
if (trimmed) await renameChat(chat.id, trimmed);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleRenameCancel = () => {
|
||||
setIsRenaming(false);
|
||||
setEditTitle(chat.title ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center w-full h-9 rounded-md transition-colors ${
|
||||
isActive ? "bg-gray-100" : "hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center w-full px-2 py-1">
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleRenameSave();
|
||||
if (e.key === "Escape") handleRenameCancel();
|
||||
}}
|
||||
className="flex-1 bg-white shadow-inner rounded px-1 py-0.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleRenameSave()}
|
||||
className="ml-1.5 py-2 hover:bg-gray-200 rounded text-green-600"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRenameCancel}
|
||||
className="ml-1 py-2 hover:bg-gray-200 rounded text-red-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onMouseEnter={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const overflow = el.scrollWidth - el.clientWidth;
|
||||
if (overflow > 0) el.scrollTo({ left: overflow, behavior: "smooth" });
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.scrollTo({ left: 0, behavior: "smooth" });
|
||||
}}
|
||||
className={`flex-1 min-w-0 text-left px-3 py-2 text-xs overflow-x-hidden whitespace-nowrap scrollbar-none ${
|
||||
isActive ? "text-gray-900" : "text-gray-700"
|
||||
}`}
|
||||
title={projectName ? `${projectName}: ${chat.title ?? "Untitled chat"}` : (chat.title ?? "Untitled chat")}
|
||||
>
|
||||
{projectName && (
|
||||
<span className="text-gray-400 font-normal">{projectName}: </span>
|
||||
)}
|
||||
{chat.title ?? "Untitled chat"}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`p-1 mr-1 text-gray-500 transition-opacity hover:text-gray-900 ${
|
||||
isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="z-101">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isChatOwner) {
|
||||
setOwnerOnlyAction("rename this chat");
|
||||
return;
|
||||
}
|
||||
setEditTitle(chat.title ?? "");
|
||||
setIsRenaming(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!isChatOwner) {
|
||||
setOwnerOnlyAction("delete this chat");
|
||||
return;
|
||||
}
|
||||
void deleteChat(chat.id);
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<OwnerOnlyModal
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/app/components/shared/ToolbarTabs.tsx
Normal file
44
frontend/src/app/components/shared/ToolbarTabs.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
|
||||
interface Tab<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props<T extends string> {
|
||||
tabs: Tab<T>[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
/** Optional content rendered on the right side of the toolbar */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolbarTabs<T extends string>({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
actions,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="flex items-center h-10 px-8 border-b border-gray-200">
|
||||
<div className="flex-1 flex items-center gap-5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`text-xs transition-colors ${
|
||||
active === tab.id
|
||||
? "font-medium text-gray-700"
|
||||
: "font-normal text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-1">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
frontend/src/app/components/shared/UploadNewVersionModal.tsx
Normal file
158
frontend/src/app/components/shared/UploadNewVersionModal.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Upload } from "lucide-react";
|
||||
import { listDocumentVersions } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
doc: MikeDocument | null;
|
||||
onSubmit: (file: File, displayName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [stagedFile, setStagedFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !doc) return;
|
||||
setName(doc.filename);
|
||||
setStagedFile(null);
|
||||
setSubmitting(false);
|
||||
setCurrentVersion(null);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { current_version_id, versions } =
|
||||
await listDocumentVersions(doc.id);
|
||||
const current = versions.find(
|
||||
(v) => v.id === current_version_id,
|
||||
);
|
||||
const initial =
|
||||
(current?.display_name && current.display_name.trim()) ||
|
||||
doc.filename;
|
||||
if (!cancelled) {
|
||||
setName(initial);
|
||||
setCurrentVersion(current?.version_number ?? null);
|
||||
}
|
||||
} catch {
|
||||
/* keep fallback */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, doc]);
|
||||
|
||||
if (!open || !doc) return null;
|
||||
|
||||
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
|
||||
|
||||
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setStagedFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!stagedFile || submitting || !doc) return;
|
||||
const finalName = name.trim() || doc.filename;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(stagedFile, finalName);
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-md rounded-2xl bg-white shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="text-xs text-gray-400">
|
||||
Upload new version · {doc.filename}
|
||||
</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>
|
||||
|
||||
{/* Name input */}
|
||||
<div className="px-5 pb-4">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
New version name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Version name"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Current Version:{" "}
|
||||
<span className="text-gray-700 font-medium">
|
||||
{currentVersion ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
{stagedFile && (
|
||||
<div className="mt-2 text-xs text-gray-500 truncate">
|
||||
New Version File:{" "}
|
||||
<span className="text-gray-700">
|
||||
{stagedFile.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</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={accept}
|
||||
className="hidden"
|
||||
onChange={handleFilePick}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting}
|
||||
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"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{stagedFile ? "Change file" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!stagedFile || submitting}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
13
frontend/src/app/components/shared/VersionChip.tsx
Normal file
13
frontend/src/app/components/shared/VersionChip.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Small "V3" badge for document rows/listings, rendered when the doc has
|
||||
* at least one assistant-edit version. Matches the chip in the side
|
||||
* panel's edit-tab header.
|
||||
*/
|
||||
export function VersionChip({ n }: { n: number | null | undefined }) {
|
||||
if (typeof n !== "number" || !Number.isFinite(n) || n < 1) return null;
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1 py-0.5 text-[10px] font-medium text-gray-500">
|
||||
V{n}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
127
frontend/src/app/components/shared/highlightDocxQuote.ts
Normal file
127
frontend/src/app/components/shared/highlightDocxQuote.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
const HIGHLIGHT_CLASS = "docx-text-highlight";
|
||||
|
||||
function onlyLetters(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function toOrigPos(text: string, strippedPos: number): number {
|
||||
let count = 0;
|
||||
for (let k = 0; k < text.length; k++) {
|
||||
if (/[a-zA-Z0-9]/.test(text[k])) {
|
||||
if (count === strippedPos) return k;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
function collectTextNodes(root: HTMLElement): Text[] {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node: Node) {
|
||||
const p = node.parentElement;
|
||||
if (!p) return NodeFilter.FILTER_REJECT;
|
||||
const tag = p.tagName;
|
||||
if (tag === "STYLE" || tag === "SCRIPT")
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const out: Text[] = [];
|
||||
let cur = walker.nextNode() as Text | null;
|
||||
while (cur) {
|
||||
out.push(cur);
|
||||
cur = walker.nextNode() as Text | null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function clearDocxQuoteHighlights(root: HTMLElement): void {
|
||||
root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((span) => {
|
||||
const parent = span.parentNode;
|
||||
if (!parent) return;
|
||||
while (span.firstChild) parent.insertBefore(span.firstChild, span);
|
||||
parent.removeChild(span);
|
||||
});
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the given quote text inside `root` (a docx-preview output).
|
||||
* Quote is split on ellipsis variants; each segment is located via
|
||||
* letters-only substring matching, so whitespace/punctuation differences
|
||||
* between the LLM's quote and the rendered text don't break matching.
|
||||
*
|
||||
* Returns the first highlight span if any match was found, for
|
||||
* scroll-into-view by the caller.
|
||||
*/
|
||||
export function highlightDocxQuote(
|
||||
root: HTMLElement,
|
||||
quote: string,
|
||||
): HTMLElement | null {
|
||||
clearDocxQuoteHighlights(root);
|
||||
if (!quote) return null;
|
||||
const segments = quote
|
||||
.split(/\.{3}|…/)
|
||||
.map(onlyLetters)
|
||||
.filter((s) => s.length > 0);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const textNodes = collectTextNodes(root);
|
||||
const nodeStartInFull: number[] = [];
|
||||
const nodeStrippedLen: number[] = [];
|
||||
let fullStripped = "";
|
||||
for (const node of textNodes) {
|
||||
const stripped = onlyLetters(node.data);
|
||||
nodeStartInFull.push(fullStripped.length);
|
||||
nodeStrippedLen.push(stripped.length);
|
||||
fullStripped += stripped;
|
||||
}
|
||||
|
||||
type Range = { nodeIdx: number; origStart: number; origEnd: number };
|
||||
const ranges: Range[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const searchKey = segment.slice(0, 30);
|
||||
const matchPos = fullStripped.indexOf(searchKey);
|
||||
if (matchPos < 0) continue;
|
||||
const matchEnd = matchPos + segment.length;
|
||||
|
||||
for (let i = 0; i < textNodes.length; i++) {
|
||||
const start = nodeStartInFull[i];
|
||||
const end = start + nodeStrippedLen[i];
|
||||
if (matchPos >= end || matchEnd <= start) continue;
|
||||
|
||||
const localStart = Math.max(0, matchPos - start);
|
||||
const localEnd = Math.min(nodeStrippedLen[i], matchEnd - start);
|
||||
const text = textNodes[i].data;
|
||||
const origStart = toOrigPos(text, localStart);
|
||||
const origEnd = toOrigPos(text, localEnd);
|
||||
if (origStart >= origEnd) continue;
|
||||
ranges.push({ nodeIdx: i, origStart, origEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) return null;
|
||||
|
||||
// Apply in reverse document order so splits don't shift earlier ranges.
|
||||
ranges.sort((a, b) => {
|
||||
if (a.nodeIdx !== b.nodeIdx) return b.nodeIdx - a.nodeIdx;
|
||||
return b.origStart - a.origStart;
|
||||
});
|
||||
|
||||
const spans: HTMLElement[] = [];
|
||||
for (const r of ranges) {
|
||||
const node = textNodes[r.nodeIdx];
|
||||
const mid = node.splitText(r.origStart);
|
||||
mid.splitText(r.origEnd - r.origStart);
|
||||
const span = document.createElement("span");
|
||||
span.className = HIGHLIGHT_CLASS;
|
||||
mid.parentNode?.insertBefore(span, mid);
|
||||
span.appendChild(mid);
|
||||
spans.push(span);
|
||||
}
|
||||
|
||||
// Because we processed ranges in reverse order, the earliest-in-document
|
||||
// highlight is the last one we pushed.
|
||||
return spans[spans.length - 1] ?? null;
|
||||
}
|
||||
124
frontend/src/app/components/shared/highlightQuote.ts
Normal file
124
frontend/src/app/components/shared/highlightQuote.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
|
||||
|
||||
export async function getPdfJs() {
|
||||
if (pdfjsLib) return pdfjsLib;
|
||||
pdfjsLib = await import("pdfjs-dist");
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
return pdfjsLib;
|
||||
}
|
||||
|
||||
export const STANDARD_FONT_DATA_URL =
|
||||
"https://unpkg.com/pdfjs-dist@4.10.38/standard_fonts/";
|
||||
|
||||
const HIGHLIGHT_CLASS = "pdf-text-highlight";
|
||||
const ORIGINAL_TEXT_ATTR = "data-original-text";
|
||||
|
||||
// Strip everything except alphanumerics (a-z A-Z 0-9) for robust matching
|
||||
function onlyLetters(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Given a position in the stripped (letters-only) version of `original`,
|
||||
// return the corresponding index in `original`.
|
||||
function strippedPosToOriginal(original: string, strippedPos: number): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
if (/[a-zA-Z0-9]/.test(original[i])) {
|
||||
if (count === strippedPos) return i;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return original.length;
|
||||
}
|
||||
|
||||
export function clearHighlights(textDivs: HTMLElement[]) {
|
||||
for (const div of textDivs) {
|
||||
if (div.hasAttribute(ORIGINAL_TEXT_ATTR)) {
|
||||
div.textContent = div.getAttribute(ORIGINAL_TEXT_ATTR)!;
|
||||
div.removeAttribute(ORIGINAL_TEXT_ATTR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function highlightQuote(
|
||||
textDivs: HTMLElement[],
|
||||
quote: string,
|
||||
): Promise<boolean> {
|
||||
clearHighlights(textDivs);
|
||||
|
||||
// Split on ellipsis variants to highlight each segment separately
|
||||
const segments = quote
|
||||
.split(/\.{3}|…/)
|
||||
.map((s) => onlyLetters(s))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
// Build the stripped full text and track each div's start position within it.
|
||||
// Also keep original div texts for display.
|
||||
const divOrigTexts: string[] = []; // original text for innerHTML slicing
|
||||
const divStripped: string[] = []; // letters-only version for matching
|
||||
const divStartInFull: number[] = []; // start index in fullStripped
|
||||
let fullStripped = "";
|
||||
|
||||
for (let i = 0; i < textDivs.length; i++) {
|
||||
const orig = textDivs[i].textContent ?? "";
|
||||
divOrigTexts.push(orig);
|
||||
const stripped = onlyLetters(orig);
|
||||
divStripped.push(stripped);
|
||||
divStartInFull.push(fullStripped.length);
|
||||
fullStripped += stripped;
|
||||
}
|
||||
|
||||
// Map: divIndex -> [strippedLocalStart, strippedLocalEnd]
|
||||
const divHighlightRanges = new Map<number, [number, number]>();
|
||||
|
||||
for (const segment of segments) {
|
||||
const searchKey = segment.slice(0, 30);
|
||||
const matchPos = fullStripped.indexOf(searchKey);
|
||||
if (matchPos === -1) {
|
||||
continue;
|
||||
}
|
||||
const matchEnd = matchPos + segment.length;
|
||||
|
||||
for (let i = 0; i < textDivs.length; i++) {
|
||||
const divStart = divStartInFull[i];
|
||||
const divEnd = divStart + divStripped[i].length;
|
||||
if (matchPos >= divEnd || matchEnd <= divStart) continue;
|
||||
|
||||
const localStart = Math.max(0, matchPos - divStart);
|
||||
const localEnd = Math.min(
|
||||
divStripped[i].length,
|
||||
matchEnd - divStart,
|
||||
);
|
||||
divHighlightRanges.set(i, [localStart, localEnd]);
|
||||
}
|
||||
}
|
||||
|
||||
if (divHighlightRanges.size === 0) return false;
|
||||
|
||||
for (const [idx, [strStart, strEnd]] of divHighlightRanges) {
|
||||
const div = textDivs[idx];
|
||||
const orig = divOrigTexts[idx];
|
||||
|
||||
// Map stripped positions back to original character positions
|
||||
const origStart = strippedPosToOriginal(orig, strStart);
|
||||
const origEnd = strippedPosToOriginal(orig, strEnd);
|
||||
|
||||
div.setAttribute(ORIGINAL_TEXT_ATTR, orig);
|
||||
div.innerHTML =
|
||||
escapeHtml(orig.slice(0, origStart)) +
|
||||
`<span class="${HIGHLIGHT_CLASS}">${escapeHtml(orig.slice(origStart, origEnd))}</span>` +
|
||||
escapeHtml(orig.slice(origEnd));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
301
frontend/src/app/components/shared/types.ts
Normal file
301
frontend/src/app/components/shared/types.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Shared TypeScript types for Mike AI legal assistant
|
||||
|
||||
export interface MikeFolder {
|
||||
id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
parent_folder_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MikeProject {
|
||||
id: string;
|
||||
user_id: string;
|
||||
is_owner?: boolean;
|
||||
name: string;
|
||||
cm_number: string | null;
|
||||
shared_with: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
documents?: MikeDocument[];
|
||||
folders?: MikeFolder[];
|
||||
document_count?: number;
|
||||
chat_count?: number;
|
||||
review_count?: number;
|
||||
}
|
||||
|
||||
export interface MikeDocument {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
project_id: string | null;
|
||||
folder_id?: string | null;
|
||||
filename: string;
|
||||
file_type: string | null; // pdf | docx | doc
|
||||
storage_path: string | null;
|
||||
pdf_storage_path: string | null;
|
||||
size_bytes: number | null;
|
||||
page_count: number | null;
|
||||
structure_tree: StructureNode[] | null;
|
||||
status: "pending" | "processing" | "ready" | "error";
|
||||
created_at: string | null;
|
||||
updated_at?: string | null;
|
||||
/** Max version_number across assistant_edit rows, null if doc is unedited. */
|
||||
latest_version_number?: number | null;
|
||||
}
|
||||
|
||||
export interface StructureNode {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
page_number: number | null;
|
||||
children: StructureNode[];
|
||||
}
|
||||
|
||||
export interface MikeChat {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
user_id: string;
|
||||
title: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MikeEditAnnotation {
|
||||
type?: "edit_data";
|
||||
kind?: "edit";
|
||||
edit_id: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
/** Per-document monotonic Vn for the edit's target version. */
|
||||
version_number?: number | null;
|
||||
change_id: string;
|
||||
del_w_id?: string;
|
||||
ins_w_id?: string;
|
||||
deleted_text: string;
|
||||
inserted_text: string;
|
||||
context_before?: string;
|
||||
context_after?: string;
|
||||
reason?: string;
|
||||
status: "pending" | "accepted" | "rejected";
|
||||
}
|
||||
|
||||
export type AssistantEvent =
|
||||
| { type: "reasoning"; text: string; isStreaming?: boolean }
|
||||
| {
|
||||
type: "tool_call_start";
|
||||
name: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "thinking"; isStreaming?: boolean }
|
||||
| {
|
||||
type: "doc_read";
|
||||
filename: string;
|
||||
document_id?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "doc_find";
|
||||
filename: string;
|
||||
query: string;
|
||||
total_matches: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "doc_created";
|
||||
filename: string;
|
||||
download_url: string;
|
||||
/** Set when the generated doc is persisted as a first-class document. */
|
||||
document_id?: string;
|
||||
version_id?: string;
|
||||
version_number?: number | null;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "doc_download"; filename: string; download_url: string }
|
||||
| {
|
||||
type: "doc_replicated";
|
||||
/** Source document filename. */
|
||||
filename: string;
|
||||
/** How many copies were produced in this single tool call. */
|
||||
count: number;
|
||||
/** One entry per new copy. Empty while streaming. */
|
||||
copies?: {
|
||||
new_filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
}[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "workflow_applied"; workflow_id: string; title: string }
|
||||
| {
|
||||
type: "doc_edited";
|
||||
filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
/** Per-document monotonic Vn written at emit time. */
|
||||
version_number?: number | null;
|
||||
download_url: string;
|
||||
annotations: MikeEditAnnotation[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "content"; text: string; isStreaming?: boolean };
|
||||
|
||||
export interface MikeMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
files?: { filename: string; document_id?: string }[];
|
||||
workflow?: { id: string; title: string };
|
||||
model?: string;
|
||||
annotations?: MikeCitationAnnotation[];
|
||||
events?: AssistantEvent[];
|
||||
/** Set when streaming failed; rendered as a red error block. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CitationQuote {
|
||||
page: number;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A citation emitted by the assistant. Single-page citations have a numeric
|
||||
* `page` and a plain `quote`. A citation that spans a page break (one
|
||||
* continuous sentence cut by a page boundary) has `page` as a range string
|
||||
* like "41-42" and a `quote` containing the `[[PAGE_BREAK]]` sentinel at the
|
||||
* break point (text before is on page 41, text after is on page 42).
|
||||
*/
|
||||
export interface MikeCitationAnnotation {
|
||||
type: "citation_data";
|
||||
ref: number;
|
||||
doc_id: string;
|
||||
document_id: string;
|
||||
version_id?: string | null;
|
||||
version_number?: number | null;
|
||||
filename: string;
|
||||
page: number | string;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]";
|
||||
|
||||
/**
|
||||
* Expand a citation into one or more (page, quote) entries suitable for
|
||||
* highlighting in the PDF viewer. A single-page citation yields one entry; a
|
||||
* cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two.
|
||||
*/
|
||||
export function expandCitationToEntries(
|
||||
a: MikeCitationAnnotation,
|
||||
): CitationQuote[] {
|
||||
const rangeMatch =
|
||||
typeof a.page === "string"
|
||||
? a.page.match(/^(\d+)\s*-\s*(\d+)$/)
|
||||
: null;
|
||||
if (rangeMatch && a.quote.includes(PAGE_BREAK_SENTINEL)) {
|
||||
const startPage = parseInt(rangeMatch[1], 10);
|
||||
const endPage = parseInt(rangeMatch[2], 10);
|
||||
const [before, after] = a.quote.split(PAGE_BREAK_SENTINEL);
|
||||
return [
|
||||
{ page: startPage, quote: before.trim() },
|
||||
{ page: endPage, quote: after.trim() },
|
||||
].filter((e) => e.quote.length > 0);
|
||||
}
|
||||
const pageNum =
|
||||
typeof a.page === "number" ? a.page : parseInt(String(a.page), 10);
|
||||
if (!Number.isFinite(pageNum)) return [];
|
||||
return [{ page: pageNum, quote: a.quote }];
|
||||
}
|
||||
|
||||
/** Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42". */
|
||||
export function formatCitationPage(a: MikeCitationAnnotation): string {
|
||||
if (typeof a.page === "string") return `Page ${a.page}`;
|
||||
return `Page ${a.page}`;
|
||||
}
|
||||
|
||||
/** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */
|
||||
export function displayCitationQuote(a: MikeCitationAnnotation): string {
|
||||
return a.quote.replaceAll(PAGE_BREAK_SENTINEL, "...");
|
||||
}
|
||||
|
||||
// Tabular Review
|
||||
|
||||
export type ColumnFormat =
|
||||
| "text"
|
||||
| "bulleted_list"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "yes_no"
|
||||
| "date"
|
||||
| "tag"
|
||||
| "percentage"
|
||||
| "monetary_amount";
|
||||
|
||||
export interface ColumnConfig {
|
||||
index: number;
|
||||
name: string;
|
||||
prompt: string;
|
||||
format?: ColumnFormat;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TabularReview {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
user_id: string;
|
||||
title: string | null;
|
||||
columns_config: ColumnConfig[] | null;
|
||||
workflow_id: string | null;
|
||||
practice?: string | null;
|
||||
/** Per-review email list. Used so standalone (project_id null) reviews can be shared directly. */
|
||||
shared_with?: string[];
|
||||
/** Server-set: true when the requesting user is the review's creator. */
|
||||
is_owner?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
document_count?: number;
|
||||
}
|
||||
|
||||
export interface TabularCell {
|
||||
id: string;
|
||||
review_id: string;
|
||||
document_id: string;
|
||||
column_index: number;
|
||||
content: {
|
||||
summary: string;
|
||||
flag?: "green" | "grey" | "yellow" | "red";
|
||||
reasoning?: string;
|
||||
} | null;
|
||||
status: "pending" | "generating" | "done" | "error";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Workflows
|
||||
|
||||
export interface MikeWorkflow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
title: string;
|
||||
type: "assistant" | "tabular";
|
||||
prompt_md: string | null;
|
||||
columns_config: ColumnConfig[] | null;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
practice?: string | null;
|
||||
shared_by_name?: string | null;
|
||||
allow_edit?: boolean;
|
||||
is_owner?: boolean;
|
||||
}
|
||||
|
||||
// API helpers
|
||||
|
||||
export interface MikeChatDetailOut {
|
||||
chat: MikeChat;
|
||||
messages: MikeMessage[];
|
||||
}
|
||||
|
||||
export interface TabularReviewDetailOut {
|
||||
review: TabularReview;
|
||||
cells: TabularCell[];
|
||||
documents: MikeDocument[];
|
||||
}
|
||||
63
frontend/src/app/components/shared/useDirectoryData.ts
Normal file
63
frontend/src/app/components/shared/useDirectoryData.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getProject, listProjects, listStandaloneDocuments } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument, MikeProject } from "./types";
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface DirectoryCache {
|
||||
standaloneDocuments: MikeDocument[];
|
||||
projects: MikeProject[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
let cache: DirectoryCache | null = null;
|
||||
|
||||
export function invalidateDirectoryCache() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export function useDirectoryData(enabled: boolean) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [standaloneDocuments, setStandaloneDocuments] = useState<MikeDocument[]>([]);
|
||||
const [projects, setProjects] = useState<MikeProject[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
|
||||
setStandaloneDocuments(cache.standaloneDocuments);
|
||||
setProjects(cache.projects);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
Promise.all([listProjects(), listStandaloneDocuments()])
|
||||
.then(([ps, ds]) => {
|
||||
const sorted = [...ds].sort((a, b) =>
|
||||
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
|
||||
);
|
||||
return Promise.all(ps.map((p) => getProject(p.id))).then(
|
||||
(fullProjects) => {
|
||||
cache = {
|
||||
standaloneDocuments: sorted,
|
||||
projects: fullProjects,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
setStandaloneDocuments(sorted);
|
||||
setProjects(fullProjects);
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setStandaloneDocuments([]);
|
||||
setProjects([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [enabled]);
|
||||
|
||||
return { loading, standaloneDocuments, projects };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue