Add local repo contents

This commit is contained in:
willchen96 2026-04-29 19:49:06 +02:00
parent 65739ef1ce
commit d9690965b5
176 changed files with 68998 additions and 0 deletions

View 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,
);
}

View 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,
);
}

View 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,
);
}

View 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>
);
}

View 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">
&ldquo;{displayQuote}&rdquo;
{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>
);
}

View 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>
);
}

View 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,
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
);
}

View 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,
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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,
);
}

View 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>
);
}

View 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;
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// 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;
}

View 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[];
}

View 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 };
}