mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Merge pull request #216 from Open-Legal-Products/more-ui-updates
feat: refine liquid surfaces and document review panels
This commit is contained in:
commit
81590ea137
27 changed files with 1070 additions and 586 deletions
|
|
@ -23,7 +23,6 @@ import { useAuth } from "@/app/contexts/AuthContext";
|
|||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
TABLE_STICKY_CELL_BG,
|
||||
SkeletonDot,
|
||||
SkeletonLine,
|
||||
TableBody,
|
||||
|
|
@ -519,12 +518,10 @@ export default function TabularReviewsPage() {
|
|||
const projectName = review.project_id
|
||||
? projectNameById.get(review.project_id)
|
||||
: null;
|
||||
const rowBg = selectedIds.includes(review.id)
|
||||
? "bg-gray-50"
|
||||
: TABLE_STICKY_CELL_BG;
|
||||
return (
|
||||
<TableRow
|
||||
key={review.id}
|
||||
selected={selectedIds.includes(review.id)}
|
||||
rightClickDropdown={(close, menuProps) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
|
|
@ -563,7 +560,6 @@ export default function TabularReviewsPage() {
|
|||
}}
|
||||
>
|
||||
<TablePrimaryCell
|
||||
bgClassName={rowBg}
|
||||
selected={selectedIds.includes(
|
||||
review.id,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -95,17 +95,17 @@ export function CitationQuotesHeader({
|
|||
citationText ??
|
||||
""
|
||||
}
|
||||
className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
|
||||
className="rounded-full bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
|
||||
showText
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${
|
||||
className={`relative flex h-6 items-center justify-start gap-1 rounded-full bg-gray-200 p-1 ${
|
||||
hasMultipleQuotes ? "w-16" : "w-11"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-all ${
|
||||
!isExpanded
|
||||
? "left-1"
|
||||
: hasMultipleQuotes &&
|
||||
|
|
@ -117,7 +117,7 @@ export function CitationQuotesHeader({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded-full ${
|
||||
!isExpanded
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
|
|
@ -132,7 +132,7 @@ export function CitationQuotesHeader({
|
|||
setIsExpanded(true);
|
||||
setViewMode("single");
|
||||
}}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded-full ${
|
||||
isExpanded && viewMode === "single"
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
|
|
@ -148,7 +148,7 @@ export function CitationQuotesHeader({
|
|||
setIsExpanded(true);
|
||||
setViewMode("list");
|
||||
}}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded-full ${
|
||||
isExpanded && viewMode === "list"
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function EventBlock({
|
|||
? "bg-green-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
|
||||
: dotColor === "red"
|
||||
? "bg-red-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
|
||||
: "bg-gray-300 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.35)]";
|
||||
: "bg-gray-500 shadow-[0_1px_3px_rgba(15,23,42,0.15)]";
|
||||
return (
|
||||
<div className="flex items-start text-sm font-serif text-gray-500 relative">
|
||||
{showConnector && <EventConnector />}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ import {
|
|||
} from "@/app/components/projects/ProjectPageParts";
|
||||
import { DocumentSidePanel } from "@/app/components/shared/DocumentSidePanel";
|
||||
import { LibrarySkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_GROUP_HOVER_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
TableFilters,
|
||||
|
|
@ -271,9 +276,6 @@ export function DocTable({
|
|||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const stickyCellBg = "bg-app-surface";
|
||||
const activeRowBg = "bg-app-surface-active";
|
||||
const surfaceHoverBg = "hover:bg-app-surface-hover";
|
||||
const surfaceGroupHoverBg = "group-hover:bg-app-surface-hover";
|
||||
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
|
||||
const [viewingDocVersion, setViewingDocVersion] = useState<{
|
||||
id: string;
|
||||
|
|
@ -1434,6 +1436,7 @@ export function DocTable({
|
|||
const isUploadingVersion = uploadingVersionDocIds.has(
|
||||
doc.id,
|
||||
);
|
||||
const isSelected = selectedDocIds.includes(doc.id);
|
||||
const isDeletingDoc = deletingDocIds.has(doc.id);
|
||||
if (isDeletingDoc) {
|
||||
return renderDocumentActivityRow({
|
||||
|
|
@ -1447,6 +1450,7 @@ export function DocTable({
|
|||
return (
|
||||
<div key={`doc-${doc.id}`}>
|
||||
<div
|
||||
data-document-row
|
||||
draggable={renamingDocumentId !== doc.id}
|
||||
onDragStart={(e) => {
|
||||
if (renamingDocumentId === doc.id) {
|
||||
|
|
@ -1487,18 +1491,18 @@ export function DocTable({
|
|||
showFolderActions: false,
|
||||
});
|
||||
}}
|
||||
className={`group flex h-10 min-w-max items-center pr-8 ${surfaceHoverBg} cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
|
||||
className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
{(() => {
|
||||
const rowBg = isVersionDragOver
|
||||
? "bg-blue-50"
|
||||
: selectedDocIds.includes(doc.id)
|
||||
? activeRowBg
|
||||
: isSelected
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: stickyCellBg;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : surfaceGroupHoverBg}`}
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver || isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
|
@ -1632,7 +1636,7 @@ export function DocTable({
|
|||
doc.id,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-app-surface-hover transition-colors"
|
||||
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span>
|
||||
{versionNumber}
|
||||
|
|
@ -1815,10 +1819,10 @@ export function DocTable({
|
|||
showFolderActions: true,
|
||||
});
|
||||
}}
|
||||
className={`group flex h-10 min-w-max items-center pr-8 ${surfaceHoverBg} cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
|
||||
className={`group flex h-10 min-w-max items-center pr-8 ${APP_SURFACE_HOVER_CLASS} cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} py-2 pl-4 pr-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : stickyCellBg} transition-colors ${dragOverFolderId === folder.id ? "" : surfaceGroupHoverBg}`}
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} py-2 pl-4 pr-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : stickyCellBg} transition-colors ${dragOverFolderId === folder.id ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
|
@ -2557,6 +2561,10 @@ export function DocTable({
|
|||
uploadingVersionDocIds.has(
|
||||
doc.id,
|
||||
);
|
||||
const isSelected =
|
||||
selectedDocIds.includes(
|
||||
doc.id,
|
||||
);
|
||||
const isDeletingDoc =
|
||||
deletingDocIds.has(
|
||||
doc.id,
|
||||
|
|
@ -2578,6 +2586,7 @@ export function DocTable({
|
|||
return (
|
||||
<div key={doc.id}>
|
||||
<div
|
||||
data-document-row
|
||||
draggable={
|
||||
renamingDocumentId !==
|
||||
doc.id
|
||||
|
|
@ -2652,10 +2661,10 @@ export function DocTable({
|
|||
},
|
||||
);
|
||||
}}
|
||||
className={`group flex h-10 min-w-max items-center pr-8 ${surfaceHoverBg} cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
|
||||
className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : selectedDocIds.includes(doc.id) ? activeRowBg : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : surfaceGroupHoverBg}`}
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : isSelected ? APP_SURFACE_ACTIVE_CLASS : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver || isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isProcessing ||
|
||||
|
|
@ -2806,7 +2815,7 @@ export function DocTable({
|
|||
doc.id,
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-app-surface-hover transition-colors"
|
||||
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { SearchBar } from "@/app/components/ui/search-bar";
|
|||
import { ClosedProjectSvgIcon } from "@/app/components/shared/FolderSvgIcon";
|
||||
import type { Project } from "../shared/types";
|
||||
import { Modal } from "./Modal";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
type PrimaryAction = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
|
|
@ -101,7 +105,7 @@ export function ProjectPickerModal({
|
|||
isSelected ? null : project.id,
|
||||
)
|
||||
}
|
||||
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-100/70"}`}
|
||||
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from "@/app/components/shared/RowActions";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
TABLE_STICKY_CELL_BG,
|
||||
SkeletonDot,
|
||||
SkeletonLine,
|
||||
TableBody,
|
||||
|
|
@ -249,6 +248,7 @@ export function ProjectAssistantTable({
|
|||
{visibleChats.map((chat) => (
|
||||
<TableRow
|
||||
key={chat.id}
|
||||
selected={selectedChatIds.includes(chat.id)}
|
||||
rightClickDropdown={(close, menuProps) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
|
|
@ -276,11 +276,6 @@ export function ProjectAssistantTable({
|
|||
className="pr-8 md:pr-8"
|
||||
>
|
||||
<TablePrimaryCell
|
||||
bgClassName={
|
||||
selectedChatIds.includes(chat.id)
|
||||
? "bg-gray-50"
|
||||
: TABLE_STICKY_CELL_BG
|
||||
}
|
||||
selected={selectedChatIds.includes(chat.id)}
|
||||
onSelectionChange={() =>
|
||||
setSelectedChatIds((prev) =>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from "@/app/components/documents/DocTable";
|
||||
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
|
||||
import { ProjectSectionToolbar, useProjectWorkspace } from "./ProjectWorkspace";
|
||||
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
|
|
@ -139,7 +140,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
setActionsOpen(false);
|
||||
void selectionActions.onDownload();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors hover:bg-app-surface-hover"
|
||||
className={`w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
|
|
@ -149,7 +150,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
setActionsOpen(false);
|
||||
void selectionActions.onRemoveFromFolder();
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors hover:bg-app-surface-hover"
|
||||
className={`w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
Remove from subfolder
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
} from "@/app/components/shared/RowActions";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
TABLE_STICKY_CELL_BG,
|
||||
SkeletonDot,
|
||||
SkeletonLine,
|
||||
TableBody,
|
||||
|
|
@ -255,6 +254,7 @@ export function ProjectReviewsTable({
|
|||
{visibleReviews.map((review) => (
|
||||
<TableRow
|
||||
key={review.id}
|
||||
selected={selectedReviewIds.includes(review.id)}
|
||||
rightClickDropdown={(close, menuProps) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
|
|
@ -278,11 +278,6 @@ export function ProjectReviewsTable({
|
|||
className="pr-8 md:pr-8"
|
||||
>
|
||||
<TablePrimaryCell
|
||||
bgClassName={
|
||||
selectedReviewIds.includes(review.id)
|
||||
? "bg-gray-50"
|
||||
: TABLE_STICKY_CELL_BG
|
||||
}
|
||||
selected={selectedReviewIds.includes(review.id)}
|
||||
onSelectionChange={() =>
|
||||
setSelectedReviewIds((prev) =>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
} from "@/app/components/shared/FolderSvgIcon";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
TABLE_STICKY_CELL_BG,
|
||||
SkeletonDot,
|
||||
SkeletonLine,
|
||||
TableBody,
|
||||
|
|
@ -660,12 +659,10 @@ export function ProjectsOverview() {
|
|||
) : (
|
||||
<TableBody>
|
||||
{filtered.map((project) => {
|
||||
const rowBg = selectedIds.includes(project.id)
|
||||
? "bg-gray-50"
|
||||
: TABLE_STICKY_CELL_BG;
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
selected={selectedIds.includes(project.id)}
|
||||
rightClickDropdown={
|
||||
(project.is_owner ??
|
||||
project.user_id === user?.id)
|
||||
|
|
@ -698,7 +695,6 @@ export function ProjectsOverview() {
|
|||
>
|
||||
{/* Project Name */}
|
||||
<TablePrimaryCell
|
||||
bgClassName={rowBg}
|
||||
selected={selectedIds.includes(project.id)}
|
||||
onSelectionChange={() =>
|
||||
toggleOne(project.id)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ import {
|
|||
import { listProjects } from "@/app/lib/mikeApi";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/assistant", label: "Assistant", icon: ChatSkeuoIcon },
|
||||
|
|
@ -176,7 +180,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"h-9 w-9 p-2.5 items-center flex transition-colors",
|
||||
"rounded-md hover:bg-gray-100",
|
||||
"rounded-md",
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
)}
|
||||
title={isOpen ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
|
|
@ -201,8 +206,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
className={cn(
|
||||
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
|
||||
isActive
|
||||
? "bg-app-surface-active text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
? `${APP_SURFACE_ACTIVE_CLASS} text-gray-900`
|
||||
: `text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
|
||||
!isOpen ? "hidden md:flex" : "flex",
|
||||
)}
|
||||
>
|
||||
|
|
@ -301,8 +306,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
className={cn(
|
||||
"flex h-8 w-full items-center gap-2 rounded-md px-2.5 py-1 text-left text-xs transition-colors",
|
||||
isActive
|
||||
? "bg-app-surface-active text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
? `${APP_SURFACE_ACTIVE_CLASS} text-gray-900`
|
||||
: `text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
|
||||
)}
|
||||
>
|
||||
<ProjectSvgIcon
|
||||
|
|
@ -409,7 +414,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
onClick={loadMoreChats}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
|
||||
"hover:bg-gray-100",
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
)}
|
||||
>
|
||||
Load more
|
||||
|
|
@ -436,8 +441,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
"rounded-xl border-white/60",
|
||||
!isOpen ? "hidden md:flex" : "",
|
||||
pathname === "/account" || isDropdownOpen
|
||||
? "bg-app-surface-active"
|
||||
: "hover:bg-gray-100",
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: APP_SURFACE_HOVER_CLASS,
|
||||
)}
|
||||
title={!isOpen ? user.email : undefined}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
|
|
@ -164,6 +165,56 @@ export function DocumentSidePanel({
|
|||
setMobilePane("document");
|
||||
}, [doc?.id, versionId, currentVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !doc) return;
|
||||
|
||||
const handleOutsidePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
!(target instanceof Node) ||
|
||||
panelRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event
|
||||
.composedPath()
|
||||
.some(
|
||||
(node) =>
|
||||
node instanceof HTMLElement &&
|
||||
node.hasAttribute("data-document-row"),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
extensionWarningOpen ||
|
||||
replaceConfirmOpen ||
|
||||
confirmDeleteDocumentOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handleOutsidePointerDown);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
"pointerdown",
|
||||
handleOutsidePointerDown,
|
||||
);
|
||||
}, [
|
||||
confirmDeleteDocumentOpen,
|
||||
doc,
|
||||
extensionWarningOpen,
|
||||
mounted,
|
||||
onClose,
|
||||
replaceConfirmOpen,
|
||||
]);
|
||||
|
||||
if (!mounted || !doc) return null;
|
||||
|
||||
const activeDoc = doc;
|
||||
|
|
@ -517,357 +568,337 @@ export function DocumentSidePanel({
|
|||
|
||||
<aside
|
||||
className={cn(
|
||||
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl",
|
||||
"mx-5 mt-2 min-h-0 flex-col",
|
||||
mobilePane === "details" ? "flex" : "hidden md:flex",
|
||||
"border border-white/70 bg-white shadow-[0_3px_9px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
<div className={cn("shrink-0 p-4")}>
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Name
|
||||
</div>
|
||||
{editingName ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
setNameDraft(e.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSaveName();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingName(false);
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleSaveName()
|
||||
}
|
||||
disabled={savingName}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Save name"
|
||||
>
|
||||
{savingName ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{nameError && (
|
||||
<div className="text-xs text-red-600">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800">
|
||||
{selectedFilename}
|
||||
</div>
|
||||
{selectedVersionId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(selectedFilename);
|
||||
setEditingName(true);
|
||||
setNameError(null);
|
||||
}}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 shrink-0">
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Document Data
|
||||
Name
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-100/70 px-3 py-3">
|
||||
{editingName ? (
|
||||
<div className="space-y-1.5">
|
||||
<DataRow
|
||||
label="Type"
|
||||
value={selectedFileType ?? "—"}
|
||||
/>
|
||||
<DataRow
|
||||
label="Size"
|
||||
value={
|
||||
selectedSizeBytes != null
|
||||
? formatBytes(selectedSizeBytes)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Version"
|
||||
value={
|
||||
selectedVersionNumber != null
|
||||
? String(selectedVersionNumber)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow label="Owner" value={ownerLabel} />
|
||||
<DataRow
|
||||
label="Uploaded"
|
||||
value={
|
||||
selectedUploadedAt
|
||||
? formatDateTime(selectedUploadedAt)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={
|
||||
selectedPageCount != null
|
||||
? String(selectedPageCount)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pt-0">
|
||||
<div className="mb-2 text-xs font-medium text-gray-900">
|
||||
Versions
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
|
||||
"bg-gray-100 px-2",
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
{versionsLoading && versions.length === 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({
|
||||
length: versionSkeletonCount(
|
||||
doc.active_version_number,
|
||||
),
|
||||
}).map((_, index) => (
|
||||
<VersionUploadSkeleton
|
||||
key={`version-skeleton-${index}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : orderedVersions.length === 0 ? (
|
||||
<div className="py-2 text-xs text-gray-400">
|
||||
No version history.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{uploading && <VersionUploadSkeleton />}
|
||||
{orderedVersions.map((version) => {
|
||||
const title =
|
||||
versionTitleFor(version);
|
||||
const filename =
|
||||
versionFilenameFor(version);
|
||||
const selected =
|
||||
selectedVersionId ===
|
||||
version.id;
|
||||
const deleted =
|
||||
version.deleted_at != null;
|
||||
const versionDeleting =
|
||||
deletingVersionId ===
|
||||
version.id;
|
||||
const versionReplacing =
|
||||
replacingVersionId ===
|
||||
version.id;
|
||||
const fileType = fileTypeForVersion(
|
||||
version,
|
||||
doc.file_type,
|
||||
);
|
||||
const typeLabel =
|
||||
fileType === "pdf"
|
||||
? "PDF"
|
||||
: "DOCX";
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (deleted) return;
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (deleted) return;
|
||||
if (
|
||||
event.key !==
|
||||
"Enter" &&
|
||||
event.key !== " "
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
aria-disabled={deleted}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white",
|
||||
deleted
|
||||
? "cursor-not-allowed opacity-55"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-xs font-medium text-gray-800",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] font-semibold tracking-normal",
|
||||
deleted
|
||||
? "text-gray-300"
|
||||
: typeLabel ===
|
||||
"PDF"
|
||||
? "text-red-600"
|
||||
: "text-blue-600",
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{filename}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-[11px] text-gray-400">
|
||||
{version.created_at
|
||||
? new Date(
|
||||
version.created_at,
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
|
||||
deleted ||
|
||||
selected
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
{deleted ? (
|
||||
<span className="text-[11px] font-medium text-gray-800">
|
||||
Deleted
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
requestReplaceVersion(
|
||||
version,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
replacingVersionId !=
|
||||
null ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Replace ${title}`}
|
||||
title="Replace version file"
|
||||
>
|
||||
{versionReplacing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
void onDownloadVersion(
|
||||
doc.id,
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label={`Download ${title}`}
|
||||
title="Download version"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteVersion(
|
||||
version.id,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
(canDelete &&
|
||||
activeVersionCount <=
|
||||
1) ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
!canDelete &&
|
||||
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
|
||||
)}
|
||||
aria-label={`Delete ${title}`}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete version"
|
||||
: "Only the document owner can delete versions"
|
||||
}
|
||||
>
|
||||
{versionDeleting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
setNameDraft(e.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSaveName();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingName(false);
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveName()}
|
||||
disabled={savingName}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Save name"
|
||||
>
|
||||
{savingName ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{nameError && (
|
||||
<div className="text-xs text-red-600">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800">
|
||||
{selectedFilename}
|
||||
</div>
|
||||
{selectedVersionId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(selectedFilename);
|
||||
setEditingName(true);
|
||||
setNameError(null);
|
||||
}}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 shrink-0 text-xs font-medium text-gray-900">
|
||||
Document Data
|
||||
</div>
|
||||
<div className="shrink-0 rounded-xl py-2">
|
||||
<div className="space-y-1.5">
|
||||
<DataRow
|
||||
label="Type"
|
||||
value={selectedFileType ?? "—"}
|
||||
/>
|
||||
<DataRow
|
||||
label="Size"
|
||||
value={
|
||||
selectedSizeBytes != null
|
||||
? formatBytes(selectedSizeBytes)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Version"
|
||||
value={
|
||||
selectedVersionNumber != null
|
||||
? String(selectedVersionNumber)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow label="Owner" value={ownerLabel} />
|
||||
<DataRow
|
||||
label="Uploaded"
|
||||
value={
|
||||
selectedUploadedAt
|
||||
? formatDateTime(selectedUploadedAt)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={
|
||||
selectedPageCount != null
|
||||
? String(selectedPageCount)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 shrink-0 text-xs font-medium text-gray-900">
|
||||
Versions
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
|
||||
"bg-[#eceef1] px-2",
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2">
|
||||
{versionsLoading && versions.length === 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({
|
||||
length: versionSkeletonCount(
|
||||
doc.active_version_number,
|
||||
),
|
||||
}).map((_, index) => (
|
||||
<VersionUploadSkeleton
|
||||
key={`version-skeleton-${index}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : orderedVersions.length === 0 ? (
|
||||
<div className="py-2 text-xs text-gray-400">
|
||||
No version history.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{uploading && <VersionUploadSkeleton />}
|
||||
{orderedVersions.map((version) => {
|
||||
const title = versionTitleFor(version);
|
||||
const filename =
|
||||
versionFilenameFor(version);
|
||||
const selected =
|
||||
selectedVersionId === version.id;
|
||||
const deleted =
|
||||
version.deleted_at != null;
|
||||
const versionDeleting =
|
||||
deletingVersionId === version.id;
|
||||
const versionReplacing =
|
||||
replacingVersionId === version.id;
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (deleted) return;
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (deleted) return;
|
||||
if (
|
||||
event.key !== "Enter" &&
|
||||
event.key !== " "
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
aria-disabled={deleted}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white",
|
||||
deleted
|
||||
? "cursor-not-allowed opacity-55"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<FileTypeIcon
|
||||
fileType={filename}
|
||||
className="h-3 w-3 shrink-0"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-xs font-medium text-gray-950",
|
||||
)}
|
||||
>
|
||||
{filename}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 text-[11px] font-normal",
|
||||
deleted
|
||||
? "text-gray-300"
|
||||
: selected
|
||||
? "text-blue-500"
|
||||
: "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{selected &&
|
||||
!deleted && (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-[11px] text-gray-400">
|
||||
{version.created_at
|
||||
? new Date(
|
||||
version.created_at,
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
|
||||
deleted || selected
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
{deleted ? (
|
||||
<span className="text-[11px] font-medium text-gray-800">
|
||||
Deleted
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
requestReplaceVersion(
|
||||
version,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
replacingVersionId !=
|
||||
null ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={`Replace ${title}`}
|
||||
title="Replace version file"
|
||||
>
|
||||
{versionReplacing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
void onDownloadVersion(
|
||||
doc.id,
|
||||
version.id,
|
||||
filename,
|
||||
);
|
||||
}}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
aria-label={`Download ${title}`}
|
||||
title="Download version"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(
|
||||
event,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteVersion(
|
||||
version.id,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
(canDelete &&
|
||||
activeVersionCount <=
|
||||
1) ||
|
||||
deletingVersionId !=
|
||||
null
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
!canDelete &&
|
||||
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
|
||||
)}
|
||||
aria-label={`Delete ${title}`}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete version"
|
||||
: "Only the document owner can delete versions"
|
||||
}
|
||||
>
|
||||
{versionDeleting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -880,7 +911,7 @@ export function DocumentSidePanel({
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between px-4 py-3",
|
||||
"flex shrink-0 items-center justify-between py-3",
|
||||
"bg-white/25",
|
||||
)}
|
||||
>
|
||||
|
|
@ -990,7 +1021,7 @@ export function DocumentSidePanel({
|
|||
function DataRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-xs">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<span className="text-gray-600">{label}</span>
|
||||
<span className="truncate text-gray-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1001,10 +1032,15 @@ function VersionUploadSkeleton() {
|
|||
<div className="rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)]">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="h-3 w-20 rounded-full bg-gray-200" />
|
||||
<div className="h-3 w-9 rounded-full bg-blue-100" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div className="h-3 w-3 shrink-0 rounded bg-gray-200" />
|
||||
<div className="h-3 w-3/5 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="h-3 w-3 rounded bg-gray-200" />
|
||||
<div className="h-[11px] w-12 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2.5 w-4/5 rounded-full bg-gray-200" />
|
||||
<div className="h-2.5 w-2/5 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import { SearchBar } from "@/app/components/ui/search-bar";
|
|||
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
|
||||
import { SkeletonLine } from "./TablePrimitive";
|
||||
import { useDirectoryData, type DirectoryTab } from "./useDirectoryData";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
const DIRECTORY_GRID_CLASS =
|
||||
"grid grid-cols-[14px_14px_minmax(0,1fr)_48px_84px_64px] items-center gap-2";
|
||||
|
|
@ -294,7 +298,9 @@ export function FileDirectory({
|
|||
onClick={() => toggle(doc)}
|
||||
style={{ paddingLeft: indentedRowPadding(depth) }}
|
||||
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left ${
|
||||
selected ? "bg-gray-100" : "hover:bg-gray-100/70"
|
||||
selected
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: APP_SURFACE_HOVER_CLASS
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
|
|
@ -344,7 +350,7 @@ export function FileDirectory({
|
|||
type="button"
|
||||
onClick={() => toggleLibraryFolder(folder.id)}
|
||||
style={{ paddingLeft: indentedRowPadding(depth) }}
|
||||
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left hover:bg-gray-100/70`}
|
||||
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span
|
||||
role="checkbox"
|
||||
|
|
@ -597,7 +603,7 @@ export function FileDirectory({
|
|||
onClick={() =>
|
||||
toggleFolder(project.id)
|
||||
}
|
||||
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70`}
|
||||
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} px-2 py-2 text-xs transition-all text-left ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span
|
||||
role="checkbox"
|
||||
|
|
@ -672,8 +678,8 @@ export function FileDirectory({
|
|||
}
|
||||
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pl-7 pr-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: APP_SURFACE_HOVER_CLASS
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
LiquidDropdownItem,
|
||||
} from "@/app/components/ui/liquid-dropdown";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
|
||||
export type HeaderActionsMenuItem = {
|
||||
label: string;
|
||||
|
|
@ -33,7 +34,8 @@ export function HeaderActionsMenu({
|
|||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-600 transition-all",
|
||||
"hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
"hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
|
||||
)}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import { createPortal } from "react-dom";
|
|||
import { ChevronLeft, Loader2, Plus, Search } from "lucide-react";
|
||||
import { usePageChrome } from "@/app/contexts/PageChromeContext";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
APP_SURFACE_PRESSED_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
export interface PageHeaderBreadcrumb {
|
||||
label?: ReactNode;
|
||||
|
|
@ -308,7 +313,7 @@ function PageHeaderSearchActionControl({
|
|||
className:
|
||||
"cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700",
|
||||
}),
|
||||
"w-56 bg-app-surface-active sm:w-80",
|
||||
`w-56 sm:w-80 ${APP_SURFACE_ACTIVE_CLASS}`,
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
|
|
@ -356,7 +361,9 @@ function pageHeaderActionControlClassName({
|
|||
className,
|
||||
}: PageHeaderActionControlClassNameOptions = {}) {
|
||||
return cn(
|
||||
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-app-surface-hover active:bg-app-surface-active disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
|
||||
"flex h-7 items-center justify-center rounded-full text-sm transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
APP_SURFACE_PRESSED_CLASS,
|
||||
iconOnly
|
||||
? "w-7"
|
||||
: "w-7 gap-1.5 px-0 sm:w-auto sm:px-3",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
LiquidDropdownSurface,
|
||||
} from "@/app/components/ui/liquid-dropdown";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
|
||||
export { CLOSE_ROW_ACTIONS_EVENT, closeRowActionMenus };
|
||||
|
||||
|
|
@ -254,7 +255,7 @@ export function RowActions(props: Props) {
|
|||
<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-app-surface-hover transition-colors leading-none"
|
||||
className={`flex items-center justify-center w-6 h-6 rounded text-gray-700 hover:text-gray-900 transition-colors leading-none ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span className="tracking-widest text-xs">···</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
|||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { ChatSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
interface Props {
|
||||
chat: Chat;
|
||||
|
|
@ -55,8 +59,8 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
|
|||
className={cn(
|
||||
"group relative flex h-8 w-full items-center rounded-md transition-colors",
|
||||
isActive
|
||||
? "bg-gray-200/60 pr-1"
|
||||
: "pr-3 hover:bg-gray-100 hover:pr-1",
|
||||
? `${APP_SURFACE_ACTIVE_CLASS} pr-1`
|
||||
: `pr-3 ${APP_SURFACE_HOVER_CLASS} hover:pr-1`,
|
||||
)}
|
||||
>
|
||||
{isRenaming ? (
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ import {
|
|||
LiquidDropdownContent,
|
||||
LiquidDropdownItem,
|
||||
} from "@/app/components/ui/liquid-dropdown";
|
||||
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_GROUP_HOVER_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
LIQUID_TABLE_SURFACE_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
export const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
|
||||
|
||||
|
|
@ -84,8 +89,8 @@ export function TableFilters<T extends string>({
|
|||
title={selected?.label ?? label}
|
||||
className={`flex h-[18px] w-[22px] items-center justify-center rounded-sm transition-colors ${
|
||||
value
|
||||
? "text-gray-700 hover:bg-app-surface-hover hover:text-gray-900"
|
||||
: "text-gray-400 hover:bg-app-surface-hover hover:text-gray-700"
|
||||
? `text-gray-700 ${APP_SURFACE_HOVER_CLASS} hover:text-gray-900`
|
||||
: `text-gray-400 ${APP_SURFACE_HOVER_CLASS} hover:text-gray-700`
|
||||
}`}
|
||||
>
|
||||
<ChevronDown
|
||||
|
|
@ -219,11 +224,13 @@ export function TableRow({
|
|||
children,
|
||||
className,
|
||||
interactive = true,
|
||||
selected = false,
|
||||
onContextMenu,
|
||||
rightClickDropdown,
|
||||
...props
|
||||
}: DivProps & {
|
||||
interactive?: boolean;
|
||||
selected?: boolean;
|
||||
rightClickDropdown?:
|
||||
| ReactNode
|
||||
| ((close: () => void, menuProps: DivProps) => ReactNode);
|
||||
|
|
@ -274,7 +281,11 @@ export function TableRow({
|
|||
<div
|
||||
className={cn(
|
||||
"group flex h-10 min-w-max items-center pr-3 transition-colors",
|
||||
interactive && "cursor-pointer hover:bg-app-surface-hover",
|
||||
interactive && "cursor-pointer",
|
||||
interactive &&
|
||||
!selected &&
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
selected && APP_SURFACE_ACTIVE_CLASS,
|
||||
className,
|
||||
)}
|
||||
onContextMenu={handleContextMenu}
|
||||
|
|
@ -326,7 +337,7 @@ export function TableStickyCell({
|
|||
header
|
||||
? "z-[80] items-center self-stretch"
|
||||
: "py-2 transition-colors",
|
||||
!header && hover && "group-hover:bg-app-surface-hover",
|
||||
!header && hover && APP_SURFACE_GROUP_HOVER_CLASS,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
@ -389,8 +400,11 @@ export function TablePrimaryCell({
|
|||
return (
|
||||
<TableStickyCell
|
||||
widthClassName={widthClassName}
|
||||
bgClassName={bgClassName}
|
||||
bgClassName={
|
||||
selected ? APP_SURFACE_ACTIVE_CLASS : bgClassName
|
||||
}
|
||||
className={className}
|
||||
hover={!selected}
|
||||
>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ import {
|
|||
type ModelProvider,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
LIQUID_PANEL_SURFACE_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
import {
|
||||
LiquidDropdownButton,
|
||||
LiquidDropdownSurface,
|
||||
|
|
@ -637,7 +641,7 @@ function HistoryDropdown({
|
|||
setRenamingChatId(null);
|
||||
}}
|
||||
onBlur={() => commitRename(chat.id)}
|
||||
className="w-full rounded-lg bg-app-surface-active px-2 py-1.5 text-xs text-gray-700 outline-none"
|
||||
className={`w-full rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none ${APP_SURFACE_ACTIVE_CLASS}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -669,7 +673,7 @@ function HistoryDropdown({
|
|||
}}
|
||||
title="Chat options"
|
||||
className={cn(
|
||||
"absolute right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-app-surface-hover hover:text-gray-700",
|
||||
`absolute right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition-colors hover:text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
|
||||
menu?.chatId === chat.id
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100",
|
||||
|
|
@ -741,8 +745,7 @@ function findLastContentIndex(events: AssistantEvent[]): number {
|
|||
|
||||
const HEADER_PILL_CLASS =
|
||||
"flex shrink-0 items-center gap-1 rounded-full border border-white/70 bg-app-surface px-1 py-0.5 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl";
|
||||
const HEADER_PILL_BUTTON_CLASS =
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-app-surface-hover hover:text-gray-900";
|
||||
const HEADER_PILL_BUTTON_CLASS = `flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:text-gray-900 ${APP_SURFACE_HOVER_CLASS}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
|
|
@ -1795,7 +1798,7 @@ export function TRChatPanel({
|
|||
<button
|
||||
onClick={() => setHistoryOpen((v) => !v)}
|
||||
title="Chat history"
|
||||
className="flex h-5 min-w-0 items-center gap-1 rounded-full px-1.5 text-gray-700 transition-colors hover:bg-app-surface-hover"
|
||||
className={`flex h-5 min-w-0 items-center gap-1 rounded-full px-1.5 text-gray-700 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<span className="min-w-0 truncate text-xs font-medium">
|
||||
{currentChatTitle ?? "New chat"}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
|
@ -8,23 +14,27 @@ import {
|
|||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
PanelLeft,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
Document,
|
||||
TabularCell,
|
||||
} from "../shared/types";
|
||||
import type { ColumnConfig, Document, TabularCell } from "../shared/types";
|
||||
import { isSpreadsheetFilename } from "../shared/types";
|
||||
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
|
||||
import { getPillClass } from "./pillUtils";
|
||||
import { PdfView } from "../shared/views/PdfView";
|
||||
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
|
||||
import { DocxView } from "../shared/views/DocxView";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
import { CitationQuotesHeader } from "../assistant/CitationQuotesHeader";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
import {
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
APP_SURFACE_PRESSED_CLASS,
|
||||
LIQUID_PANEL_SURFACE_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
function isDocxDocument(d: {
|
||||
file_type?: string | null;
|
||||
|
|
@ -39,10 +49,11 @@ function isDocxDocument(d: {
|
|||
interface Props {
|
||||
cell: TabularCell;
|
||||
document: Document;
|
||||
documents: Document[];
|
||||
column: ColumnConfig;
|
||||
columns: ColumnConfig[];
|
||||
onClose: () => void;
|
||||
onNavigate: (columnIndex: number) => void;
|
||||
onNavigate: (documentId: string, columnIndex: number) => void;
|
||||
onRegenerate?: () => Promise<void>;
|
||||
/** If true, open the document panel immediately */
|
||||
displayDocument?: boolean;
|
||||
|
|
@ -50,8 +61,22 @@ interface Props {
|
|||
citationQuote?: string;
|
||||
/** Page to scroll to when opening document panel */
|
||||
citationPage?: number;
|
||||
/** Spreadsheet worksheet containing the cited cell */
|
||||
citationSheet?: string;
|
||||
/** Spreadsheet A1 cell address or range */
|
||||
citationCell?: string;
|
||||
/** One-based citation number shown in the cell content */
|
||||
citationRef?: number;
|
||||
}
|
||||
|
||||
type TRPanelCitation = {
|
||||
quote: string;
|
||||
page?: number;
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
citationRef?: number;
|
||||
};
|
||||
|
||||
const FLAG_BADGE: Record<string, string> = {
|
||||
green: "bg-emerald-600 backdrop-blur-md border border-emerald-300/20 text-white shadow-md",
|
||||
grey: "bg-slate-500 backdrop-blur-md border border-slate-300/20 text-white shadow-md",
|
||||
|
|
@ -59,6 +84,11 @@ const FLAG_BADGE: Record<string, string> = {
|
|||
red: "bg-red-600 backdrop-blur-md border border-red-300/20 text-white shadow-md",
|
||||
};
|
||||
|
||||
const MIN_DOCUMENT_PANE_WIDTH = 420;
|
||||
const DEFAULT_DOCUMENT_PANE_WIDTH = 600;
|
||||
const MAX_DOCUMENT_PANE_WIDTH = 1000;
|
||||
const INFO_PANE_WIDTH = 300;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TRSidePanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -66,6 +96,7 @@ const FLAG_BADGE: Record<string, string> = {
|
|||
export function TRSidePanel({
|
||||
cell,
|
||||
document: doc,
|
||||
documents,
|
||||
column,
|
||||
columns,
|
||||
onClose,
|
||||
|
|
@ -74,25 +105,47 @@ export function TRSidePanel({
|
|||
displayDocument = false,
|
||||
citationQuote,
|
||||
citationPage,
|
||||
citationSheet,
|
||||
citationCell,
|
||||
citationRef,
|
||||
}: Props) {
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const currentPos = sortedColumns.findIndex((c) => c.index === column.index);
|
||||
const prevColumn = currentPos > 0 ? sortedColumns[currentPos - 1] : null;
|
||||
const previousColumn =
|
||||
currentPos > 0 ? sortedColumns[currentPos - 1] : null;
|
||||
const nextColumn =
|
||||
currentPos < sortedColumns.length - 1
|
||||
currentPos >= 0 && currentPos < sortedColumns.length - 1
|
||||
? sortedColumns[currentPos + 1]
|
||||
: null;
|
||||
const currentDocumentPos = documents.findIndex(
|
||||
(candidate) => candidate.id === doc.id,
|
||||
);
|
||||
const previousDocument =
|
||||
currentDocumentPos > 0 ? documents[currentDocumentPos - 1] : null;
|
||||
const nextDocument =
|
||||
currentDocumentPos >= 0 && currentDocumentPos < documents.length - 1
|
||||
? documents[currentDocumentPos + 1]
|
||||
: null;
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const quoteParagraphRef = useRef<HTMLParagraphElement>(null);
|
||||
const [documentPaneOpen, setDocumentPaneOpen] = useState(displayDocument);
|
||||
const [documentPaneWidth, setDocumentPaneWidth] = useState(
|
||||
DEFAULT_DOCUMENT_PANE_WIDTH,
|
||||
);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const resizePointerId = useRef<number | null>(null);
|
||||
const resizeStartX = useRef(0);
|
||||
const resizeStartWidth = useRef(DEFAULT_DOCUMENT_PANE_WIDTH);
|
||||
|
||||
// Internal state — initialised from props, also toggled by badge clicks inside the panel
|
||||
const [docCitation, setDocCitation] = useState<
|
||||
{ quote: string; page: number } | undefined
|
||||
>(
|
||||
const [docCitation, setDocCitation] = useState<TRPanelCitation | undefined>(
|
||||
displayDocument && citationQuote
|
||||
? { quote: citationQuote, page: citationPage ?? 1 }
|
||||
? {
|
||||
quote: citationQuote,
|
||||
page: citationPage,
|
||||
sheet: citationSheet,
|
||||
cell: citationCell,
|
||||
citationRef,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
|
|
@ -100,17 +153,100 @@ export function TRSidePanel({
|
|||
useEffect(() => {
|
||||
setDocCitation(
|
||||
displayDocument && citationQuote
|
||||
? { quote: citationQuote, page: citationPage ?? 1 }
|
||||
? {
|
||||
quote: citationQuote,
|
||||
page: citationPage,
|
||||
sheet: citationSheet,
|
||||
cell: citationCell,
|
||||
citationRef,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setQuoteExpanded(false);
|
||||
}, [cell.id, displayDocument, citationQuote, citationPage]);
|
||||
setDocumentPaneOpen(displayDocument);
|
||||
}, [
|
||||
cell.id,
|
||||
displayDocument,
|
||||
citationCell,
|
||||
citationPage,
|
||||
citationQuote,
|
||||
citationRef,
|
||||
citationSheet,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = quoteParagraphRef.current;
|
||||
if (!el || quoteExpanded) return;
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}, [docCitation?.quote, quoteExpanded]);
|
||||
const handleOutsidePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
!(target instanceof Node) ||
|
||||
panelRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handleOutsidePointerDown);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
"pointerdown",
|
||||
handleOutsidePointerDown,
|
||||
);
|
||||
}, [onClose]);
|
||||
|
||||
function handleDocumentResizePointerDown(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) {
|
||||
event.preventDefault();
|
||||
resizePointerId.current = event.pointerId;
|
||||
resizeStartX.current = event.clientX;
|
||||
resizeStartWidth.current = documentPaneWidth;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
function handleDocumentResizePointerMove(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) {
|
||||
if (resizePointerId.current !== event.pointerId) return;
|
||||
|
||||
const viewportMax = window.innerWidth - INFO_PANE_WIDTH - 2 * 12 - 24;
|
||||
const maxWidth = Math.max(
|
||||
MIN_DOCUMENT_PANE_WIDTH,
|
||||
Math.min(MAX_DOCUMENT_PANE_WIDTH, viewportMax),
|
||||
);
|
||||
const nextWidth =
|
||||
resizeStartWidth.current + (resizeStartX.current - event.clientX);
|
||||
|
||||
setDocumentPaneWidth(
|
||||
Math.min(maxWidth, Math.max(MIN_DOCUMENT_PANE_WIDTH, nextWidth)),
|
||||
);
|
||||
}
|
||||
|
||||
function handleDocumentResizePointerEnd(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) {
|
||||
if (resizePointerId.current !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
resizePointerId.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
function handleCitationOpen(citation: TRPanelCitation) {
|
||||
setDocCitation(citation);
|
||||
setDocumentPaneOpen(true);
|
||||
}
|
||||
|
||||
const { processed: summaryText, citations: summaryCitations } =
|
||||
preprocessCitations(cell.content?.summary ?? "");
|
||||
|
|
@ -119,74 +255,97 @@ export function TRSidePanel({
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-100 flex flex-row",
|
||||
LIQUID_PANEL_SURFACE_CLASS,
|
||||
"right-3 top-3 bottom-3 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Document panel — left, 600px */}
|
||||
{docCitation !== undefined && (
|
||||
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3 pb-3">
|
||||
{/* Resizable document panel — left */}
|
||||
{documentPaneOpen && (
|
||||
<div
|
||||
className="relative flex shrink-0 flex-col border-r border-white/30 px-3 pb-3"
|
||||
style={{ width: documentPaneWidth }}
|
||||
>
|
||||
<div
|
||||
onPointerDown={handleDocumentResizePointerDown}
|
||||
onPointerMove={handleDocumentResizePointerMove}
|
||||
onPointerUp={handleDocumentResizePointerEnd}
|
||||
onPointerCancel={handleDocumentResizePointerEnd}
|
||||
className="absolute inset-y-0 left-0 z-20 w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-blue-400/60"
|
||||
title="Resize document pane"
|
||||
/>
|
||||
{/* Doc header */}
|
||||
<div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30">
|
||||
<p
|
||||
className="flex-1 truncate text-sm font-semibold font-sans text-slate-700 font-serif"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDocCitation(undefined)}
|
||||
className="shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-white/40 hover:text-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex min-h-11 shrink-0 items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileTypeIcon
|
||||
fileType={doc.file_type ?? doc.filename}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 truncate text-sm font-medium text-gray-700"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quote row */}
|
||||
{docCitation.quote && (
|
||||
<div className="py-2 shrink-0">
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
isTruncated || quoteExpanded
|
||||
? setQuoteExpanded((v) => !v)
|
||||
: undefined
|
||||
}
|
||||
className={`flex w-full items-start gap-1 text-left ${!(isTruncated || quoteExpanded) ? "cursor-default" : ""}`}
|
||||
>
|
||||
<p
|
||||
ref={quoteParagraphRef}
|
||||
className={`flex-1 text-sm text-gray-600 ${quoteExpanded ? "" : "truncate"}`}
|
||||
>
|
||||
"{docCitation.quote}"
|
||||
</p>
|
||||
{(isTruncated || quoteExpanded) && (
|
||||
<ChevronDown
|
||||
className={`mt-0.5 h-3 w-3 shrink-0 text-gray-500 transition-transform ${quoteExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{docCitation?.quote && (
|
||||
<div className="-mx-3 shrink-0 py-2">
|
||||
<CitationQuotesHeader
|
||||
quotes={[
|
||||
{
|
||||
id: citationKey(cell.id, docCitation),
|
||||
quote: docCitation.quote,
|
||||
inlineDetail:
|
||||
formatCitationLocation(docCitation),
|
||||
citationText: `${doc.filename}, ${formatCitationLocation(docCitation)}`,
|
||||
},
|
||||
]}
|
||||
activeQuoteId={citationKey(
|
||||
cell.id,
|
||||
docCitation,
|
||||
)}
|
||||
citationRef={docCitation.citationRef}
|
||||
citationText={`${doc.filename}, ${formatCitationLocation(docCitation)}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDocxDocument(doc) && !doc.pdf_storage_path ? (
|
||||
<DocxView
|
||||
documentId={doc.id}
|
||||
quotes={[
|
||||
{
|
||||
page: docCitation.page,
|
||||
quote: docCitation.quote,
|
||||
},
|
||||
]}
|
||||
quotes={
|
||||
docCitation
|
||||
? [
|
||||
{
|
||||
page: docCitation.page,
|
||||
quote: docCitation.quote,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : isSpreadsheetFilename(doc.filename ?? "") ? (
|
||||
<SpreadsheetView documentId={doc.id} />
|
||||
<SpreadsheetView
|
||||
documentId={doc.id}
|
||||
highlightCells={
|
||||
docCitation?.sheet || docCitation?.cell
|
||||
? [
|
||||
{
|
||||
sheet: docCitation.sheet,
|
||||
cell: docCitation.cell,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<PdfView
|
||||
doc={{ document_id: doc.id }}
|
||||
quote={docCitation.quote}
|
||||
fallbackPage={docCitation.page}
|
||||
quote={docCitation?.quote}
|
||||
fallbackPage={docCitation?.page}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -195,32 +354,28 @@ export function TRSidePanel({
|
|||
{/* Info column — right, 300px fixed */}
|
||||
<div className="flex w-[300px] shrink-0 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 pt-3 pb-1 shrink-0 border-b border-white/30">
|
||||
<div className="flex items-center gap-1 mr-auto">
|
||||
<button
|
||||
onClick={() =>
|
||||
prevColumn && onNavigate(prevColumn.index)
|
||||
}
|
||||
disabled={!prevColumn}
|
||||
title={prevColumn ? prevColumn.name : undefined}
|
||||
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs text-slate-600 font-sans tabular-nums">
|
||||
{currentPos + 1} / {sortedColumns.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
nextColumn && onNavigate(nextColumn.index)
|
||||
}
|
||||
disabled={!nextColumn}
|
||||
title={nextColumn ? nextColumn.name : undefined}
|
||||
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex min-h-11 shrink-0 items-center justify-end gap-1.5 border-b border-white/30 px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocumentPaneOpen((open) => !open)}
|
||||
className={cn(
|
||||
"mr-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/75 hover:text-gray-700",
|
||||
documentPaneOpen && "bg-white/55 text-gray-700",
|
||||
)}
|
||||
aria-label={
|
||||
documentPaneOpen
|
||||
? "Collapse document pane"
|
||||
: "Expand document pane"
|
||||
}
|
||||
title={
|
||||
documentPaneOpen
|
||||
? "Collapse document pane"
|
||||
: "Expand document pane"
|
||||
}
|
||||
aria-pressed={documentPaneOpen}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
|
|
@ -243,31 +398,51 @@ export function TRSidePanel({
|
|||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Analysis panel */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="pb-2 px-5">
|
||||
{/* Column name */}
|
||||
<div className="mb-1">
|
||||
<span className="text-lg font-semibold text-slate-900">
|
||||
{column.name}
|
||||
</span>
|
||||
{/* Document field */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Document
|
||||
</div>
|
||||
<div className="flex min-h-6 items-center gap-1.5">
|
||||
<FileTypeIcon
|
||||
fileType={doc.file_type ?? doc.filename}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column field */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Column
|
||||
</div>
|
||||
<div className="min-h-6 truncate text-xs leading-6 text-gray-800">
|
||||
{column.name}
|
||||
</div>
|
||||
</div>
|
||||
{/* Document name */}
|
||||
<p className="text-xs mb-4">
|
||||
{doc.filename}
|
||||
</p>
|
||||
|
||||
{/* Flag section */}
|
||||
{cell.content?.flag && (
|
||||
<div className="mb-5">
|
||||
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-900">
|
||||
Flag
|
||||
</h4>
|
||||
<span
|
||||
|
|
@ -281,13 +456,13 @@ export function TRSidePanel({
|
|||
|
||||
{/* Results */}
|
||||
<div className="mb-6">
|
||||
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-900">
|
||||
Results
|
||||
</h4>
|
||||
<div className="text-xs leading-relaxed text-slate-600">
|
||||
<MarkdownContent
|
||||
citations={summaryCitations}
|
||||
onCitationClick={setDocCitation}
|
||||
onCitationClick={handleCitationOpen}
|
||||
column={column}
|
||||
>
|
||||
{summaryText || "—"}
|
||||
|
|
@ -298,13 +473,13 @@ export function TRSidePanel({
|
|||
{/* Reasoning */}
|
||||
{cell.content?.reasoning && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
|
||||
<h4 className="mb-2 text-xs font-medium text-gray-900">
|
||||
Reasoning
|
||||
</h4>
|
||||
<div className="text-xs leading-relaxed text-slate-600">
|
||||
<MarkdownContent
|
||||
citations={reasoningCitations}
|
||||
onCitationClick={setDocCitation}
|
||||
onCitationClick={handleCitationOpen}
|
||||
citationOffset={summaryCitations.length}
|
||||
column={column}
|
||||
inline
|
||||
|
|
@ -316,15 +491,116 @@ export function TRSidePanel({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-center bg-white/25 pb-7 pt-1">
|
||||
<div className="grid grid-cols-3 grid-rows-3 gap-0.5">
|
||||
<CellNavigatorButton
|
||||
className="col-start-2 row-start-1"
|
||||
label="Previous document"
|
||||
title={previousDocument?.filename}
|
||||
disabled={!previousDocument}
|
||||
onClick={() =>
|
||||
previousDocument &&
|
||||
onNavigate(previousDocument.id, column.index)
|
||||
}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</CellNavigatorButton>
|
||||
<CellNavigatorButton
|
||||
className="col-start-1 row-start-2"
|
||||
label="Previous column"
|
||||
title={previousColumn?.name}
|
||||
disabled={!previousColumn}
|
||||
onClick={() =>
|
||||
previousColumn &&
|
||||
onNavigate(doc.id, previousColumn.index)
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</CellNavigatorButton>
|
||||
<div className="col-start-2 row-start-2 h-7 w-7 rounded-md bg-white/35" />
|
||||
<CellNavigatorButton
|
||||
className="col-start-3 row-start-2"
|
||||
label="Next column"
|
||||
title={nextColumn?.name}
|
||||
disabled={!nextColumn}
|
||||
onClick={() =>
|
||||
nextColumn &&
|
||||
onNavigate(doc.id, nextColumn.index)
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</CellNavigatorButton>
|
||||
<CellNavigatorButton
|
||||
className="col-start-2 row-start-3"
|
||||
label="Next document"
|
||||
title={nextDocument?.filename}
|
||||
disabled={!nextDocument}
|
||||
onClick={() =>
|
||||
nextDocument &&
|
||||
onNavigate(nextDocument.id, column.index)
|
||||
}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</CellNavigatorButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellNavigatorButton({
|
||||
label,
|
||||
title,
|
||||
disabled,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
title?: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={title ? `${label}: ${title}` : label}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-gray-600 transition-colors disabled:cursor-default disabled:opacity-25",
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
APP_SURFACE_PRESSED_CLASS,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatCitationLocation(citation: ParsedCitation): string {
|
||||
if (citation.sheet && citation.cell) {
|
||||
return `${citation.sheet}, cell ${citation.cell}`;
|
||||
}
|
||||
return `Page ${citation.page ?? 1}`;
|
||||
}
|
||||
|
||||
function citationKey(cellId: string, citation: ParsedCitation): string {
|
||||
const location = citation.sheet
|
||||
? `${citation.sheet}:${citation.cell ?? ""}`
|
||||
: `page:${citation.page ?? 1}`;
|
||||
return `tr-cell:${cellId}:${location}`;
|
||||
}
|
||||
|
||||
function CitationBadge({
|
||||
index,
|
||||
citation,
|
||||
|
|
@ -332,16 +608,24 @@ function CitationBadge({
|
|||
}: {
|
||||
index: number;
|
||||
citation: ParsedCitation;
|
||||
onClick: (c: { quote: string; page: number }) => void;
|
||||
onClick: (citation: TRPanelCitation) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-page={citation.page}
|
||||
data-sheet={citation.sheet}
|
||||
data-cell={citation.cell}
|
||||
data-quote={citation.quote}
|
||||
title={`Page ${citation.page}: "${citation.quote}"`}
|
||||
title={`${formatCitationLocation(citation)}: "${citation.quote}"`}
|
||||
onClick={() =>
|
||||
onClick({ quote: citation.quote, page: citation.page })
|
||||
onClick({
|
||||
quote: citation.quote,
|
||||
page: citation.page,
|
||||
sheet: citation.sheet,
|
||||
cell: citation.cell,
|
||||
citationRef: index + 1,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center justify-center rounded-full bg-gray-200 w-3.5 h-3.5 text-[9px] font-medium text-gray-700 align-super cursor-pointer hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
|
|
@ -360,7 +644,7 @@ function MarkdownContent({
|
|||
}: {
|
||||
children: string;
|
||||
citations: ParsedCitation[];
|
||||
onCitationClick: (c: { quote: string; page: number }) => void;
|
||||
onCitationClick: (citation: TRPanelCitation) => void;
|
||||
inline?: boolean;
|
||||
citationOffset?: number;
|
||||
column?: ColumnConfig;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ import {
|
|||
} from "../shared/TablePrimitive";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_GROUP_HOVER_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
const SKELETON_COLS = 4;
|
||||
const SKELETON_ROWS = 5;
|
||||
|
|
@ -30,7 +35,6 @@ const COL_W = "w-[300px] shrink-0";
|
|||
const DOC_COL_W = "w-[332px] shrink-0";
|
||||
const TR_STICKY_CELL_BG = "bg-app-surface";
|
||||
const TR_HEADER_BG = "bg-app-surface";
|
||||
const TR_ACTIVE_ROW_BG = "bg-app-surface-active";
|
||||
|
||||
// Pixel widths matching the CSS constants above
|
||||
const DOC_COL_W_PX = 332;
|
||||
|
|
@ -54,7 +58,14 @@ interface Props {
|
|||
highlightedCell?: { colIdx: number; rowIdx: number } | null;
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onExpand: (cell: TabularCell) => void;
|
||||
onCitationClick: (cell: TabularCell, page: number, quote: string) => void;
|
||||
onCitationClick: (
|
||||
cell: TabularCell,
|
||||
page: number | undefined,
|
||||
quote: string,
|
||||
citationRef: number,
|
||||
sheet?: string,
|
||||
citationCell?: string,
|
||||
) => void;
|
||||
onUpdateColumn: (col: ColumnConfig) => void;
|
||||
onDeleteColumn: (colIndex: number) => void;
|
||||
onAddColumn: () => void;
|
||||
|
|
@ -186,7 +197,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
|
||||
<div
|
||||
key={row}
|
||||
className="flex h-10"
|
||||
className="flex h-8"
|
||||
style={{ minWidth: skeletonContentWidth }}
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${DOC_COL_W} ${TR_STICKY_CELL_BG} flex items-center border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
|
||||
|
|
@ -324,7 +335,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
{uploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="flex h-10"
|
||||
className="flex h-8"
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
|
|
@ -352,20 +363,21 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
</div>
|
||||
))}
|
||||
{documents.map((doc, docIdx) => {
|
||||
const rowBg = selectedDocIds.includes(doc.id)
|
||||
? TR_ACTIVE_ROW_BG
|
||||
: "bg-transparent";
|
||||
const stickyRowBg = selectedDocIds.includes(doc.id)
|
||||
? TR_ACTIVE_ROW_BG
|
||||
const isSelected = selectedDocIds.includes(doc.id);
|
||||
const rowBg = isSelected
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: APP_SURFACE_HOVER_CLASS;
|
||||
const stickyRowBg = isSelected
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: TR_STICKY_CELL_BG;
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`flex ${rowBg}`}
|
||||
className={`group flex transition-colors ${rowBg}`}
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center ${stickyRowBg}`}
|
||||
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center transition-colors ${stickyRowBg} ${isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -402,11 +414,17 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
onCitationClick={(
|
||||
page,
|
||||
quote,
|
||||
citationRef,
|
||||
sheet,
|
||||
citationCell,
|
||||
) =>
|
||||
onCitationClick(
|
||||
cell,
|
||||
page,
|
||||
quote,
|
||||
citationRef,
|
||||
sheet,
|
||||
citationCell,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,13 @@ interface Props {
|
|||
column?: ColumnConfig;
|
||||
closeSignal?: number;
|
||||
onExpand: () => void;
|
||||
onCitationClick?: (page: number, quote: string) => void;
|
||||
onCitationClick?: (
|
||||
page: number | undefined,
|
||||
quote: string,
|
||||
citationRef: number,
|
||||
sheet?: string,
|
||||
cell?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const FLAG_STYLES = {
|
||||
|
|
@ -63,7 +69,13 @@ function CellMarkdown({
|
|||
citations: ParsedCitation[];
|
||||
pills: string[];
|
||||
column?: ColumnConfig;
|
||||
onCitationClick?: (page: number, quote: string) => void;
|
||||
onCitationClick?: (
|
||||
page: number | undefined,
|
||||
quote: string,
|
||||
citationRef: number,
|
||||
sheet?: string,
|
||||
cell?: string,
|
||||
) => void;
|
||||
onExpand: () => void;
|
||||
inline?: boolean;
|
||||
}) {
|
||||
|
|
@ -108,13 +120,16 @@ function CellMarkdown({
|
|||
if (citation) {
|
||||
return (
|
||||
<span
|
||||
title={`Page ${citation.page}: "${citation.quote}"`}
|
||||
title={`${formatCitationLocation(citation)}: "${citation.quote}"`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onCitationClick) {
|
||||
onCitationClick(
|
||||
citation.page,
|
||||
citation.quote,
|
||||
idx + 1,
|
||||
citation.sheet,
|
||||
citation.cell,
|
||||
);
|
||||
} else {
|
||||
onExpand();
|
||||
|
|
@ -156,6 +171,13 @@ function CellMarkdown({
|
|||
);
|
||||
}
|
||||
|
||||
function formatCitationLocation(citation: ParsedCitation): string {
|
||||
if (citation.sheet && citation.cell) {
|
||||
return `${citation.sheet}!${citation.cell}`;
|
||||
}
|
||||
return `Page ${citation.page ?? 1}`;
|
||||
}
|
||||
|
||||
export function TabularCell({
|
||||
cell,
|
||||
column,
|
||||
|
|
@ -210,9 +232,15 @@ export function TabularCell({
|
|||
const firstLine = processed.split("\n").find((l) => l.trim()) ?? processed;
|
||||
const collapsedDisplay = firstLine.replace(/^[-*•]\s+/, "");
|
||||
|
||||
function handleCitationClickInOverlay(page: number, quote: string) {
|
||||
function handleCitationClickInOverlay(
|
||||
page: number | undefined,
|
||||
quote: string,
|
||||
citationRef: number,
|
||||
sheet?: string,
|
||||
citationCell?: string,
|
||||
) {
|
||||
setInlineExpanded(false);
|
||||
onCitationClick?.(page, quote);
|
||||
onCitationClick?.(page, quote, citationRef, sheet, citationCell);
|
||||
}
|
||||
|
||||
function handleSeeDetails() {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,13 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
const { user } = useAuth();
|
||||
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
|
||||
const [expandedCellCitation, setExpandedCellCitation] = useState<
|
||||
{ quote: string; page: number } | undefined
|
||||
{
|
||||
quote: string;
|
||||
page?: number;
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
citationRef: number;
|
||||
} | undefined
|
||||
>(undefined);
|
||||
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
|
|
@ -981,9 +987,22 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
setExpandedCell(cell);
|
||||
setExpandedCellCitation(undefined);
|
||||
}}
|
||||
onCitationClick={(cell, page, quote) => {
|
||||
onCitationClick={(
|
||||
cell,
|
||||
page,
|
||||
quote,
|
||||
citationRef,
|
||||
sheet,
|
||||
citationCell,
|
||||
) => {
|
||||
setExpandedCell(cell);
|
||||
setExpandedCellCitation({ quote, page });
|
||||
setExpandedCellCitation({
|
||||
quote,
|
||||
page,
|
||||
sheet,
|
||||
cell: citationCell,
|
||||
citationRef,
|
||||
});
|
||||
}}
|
||||
onUpdateColumn={handleUpdateColumn}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
|
|
@ -1025,18 +1044,19 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
<TRSidePanel
|
||||
cell={expandedCell}
|
||||
document={expandedDoc}
|
||||
documents={filteredDocuments}
|
||||
column={expandedCol}
|
||||
columns={columns}
|
||||
onClose={() => {
|
||||
setExpandedCell(null);
|
||||
setExpandedCellCitation(undefined);
|
||||
}}
|
||||
onNavigate={(columnIndex) => {
|
||||
onNavigate={(documentId, columnIndex) => {
|
||||
const nextCell = cells.find(
|
||||
(c) =>
|
||||
c.document_id ===
|
||||
expandedCell.document_id &&
|
||||
c.column_index === columnIndex,
|
||||
(candidate) =>
|
||||
candidate.document_id ===
|
||||
documentId &&
|
||||
candidate.column_index === columnIndex,
|
||||
);
|
||||
if (nextCell) {
|
||||
setExpandedCell(nextCell);
|
||||
|
|
@ -1052,6 +1072,9 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
displayDocument={expandedCellCitation !== undefined}
|
||||
citationQuote={expandedCellCitation?.quote}
|
||||
citationPage={expandedCellCitation?.page}
|
||||
citationSheet={expandedCellCitation?.sheet}
|
||||
citationCell={expandedCellCitation?.cell}
|
||||
citationRef={expandedCellCitation?.citationRef}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client";
|
||||
|
||||
const PAGE_CITATION_RE = /\[\[page:(\d+)\|\|(?:quote:)?((?:[^\[\]]|\[[^\]]*\])+)\]\]/gi;
|
||||
const INLINE_METADATA_RE = /\[\[((?:[^\[\]]|\[[^\]]*\])+)\]\]/g;
|
||||
|
||||
export interface ParsedCitation {
|
||||
page: number;
|
||||
page?: number;
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
|
|
@ -16,11 +18,59 @@ export function preprocessCitations(text: string): {
|
|||
citations: ParsedCitation[];
|
||||
} {
|
||||
const citations: ParsedCitation[] = [];
|
||||
PAGE_CITATION_RE.lastIndex = 0;
|
||||
const processed = text.replace(PAGE_CITATION_RE, (_, page, quote) => {
|
||||
const idx = citations.length;
|
||||
citations.push({ page: parseInt(page, 10), quote: quote.trim() });
|
||||
return `§${idx}§`;
|
||||
});
|
||||
INLINE_METADATA_RE.lastIndex = 0;
|
||||
const processed = text.replace(
|
||||
INLINE_METADATA_RE,
|
||||
(fullMarker, rawMetadata: string) => {
|
||||
const pageCitation = parsePageCitation(rawMetadata);
|
||||
const spreadsheetCitation = parseSpreadsheetCitation(rawMetadata);
|
||||
const citation = pageCitation ?? spreadsheetCitation;
|
||||
if (!citation) return fullMarker;
|
||||
|
||||
const idx = citations.length;
|
||||
citations.push(citation);
|
||||
return `§${idx}§`;
|
||||
},
|
||||
);
|
||||
return { processed, citations };
|
||||
}
|
||||
|
||||
function parsePageCitation(metadata: string): ParsedCitation | null {
|
||||
const match = metadata.match(/^page:(\d+)\|\|(?:quote:)?([\s\S]+)$/i);
|
||||
if (!match) return null;
|
||||
return {
|
||||
page: parseInt(match[1], 10),
|
||||
quote: match[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpreadsheetCitation(metadata: string): ParsedCitation | null {
|
||||
if (!metadata.toLowerCase().startsWith("sheet:")) return null;
|
||||
|
||||
const quoteSeparator = metadata.search(/\|\|quote:/i);
|
||||
if (quoteSeparator < 0) return null;
|
||||
|
||||
const locatorMetadata = metadata.slice(0, quoteSeparator);
|
||||
const quote = metadata
|
||||
.slice(quoteSeparator)
|
||||
.replace(/^\|\|quote:/i, "")
|
||||
.trim();
|
||||
if (!quote) return null;
|
||||
|
||||
const fields = new Map<string, string>();
|
||||
for (const part of locatorMetadata.split("||")) {
|
||||
const separator = part.indexOf(":");
|
||||
if (separator < 0) continue;
|
||||
const key = part.slice(0, separator).trim().toLowerCase();
|
||||
const value = part.slice(separator + 1).trim();
|
||||
if (value) fields.set(key, value);
|
||||
}
|
||||
|
||||
const sheet = fields.get("sheet");
|
||||
const row = fields.get("row");
|
||||
const column = fields.get("col") ?? fields.get("column");
|
||||
const cell = fields.get("cell") ?? (column && row ? `${column}${row}` : undefined);
|
||||
if (!sheet || !cell) return null;
|
||||
|
||||
return { sheet, cell, quote };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import {
|
|||
DropdownMenuRadioItem,
|
||||
} from "@/app/components/ui/dropdown-menu";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
|
||||
|
||||
const LIQUID_DROPDOWN_CLASS =
|
||||
"rounded-2xl border border-white/70 bg-app-surface shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl";
|
||||
|
||||
const LIQUID_DROPDOWN_ITEM_CLASS =
|
||||
"cursor-pointer text-xs text-gray-600 transition-colors hover:bg-app-surface-hover focus:bg-app-surface-hover focus:text-gray-800";
|
||||
`cursor-pointer text-xs text-gray-600 transition-colors ${APP_SURFACE_HOVER_CLASS} focus:bg-app-surface-hover focus:text-gray-800`;
|
||||
|
||||
export function LiquidDropdownContent({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
export const LIQUID_TABLE_SURFACE_CLASS =
|
||||
"rounded-2xl border-y border-white/70 bg-app-surface shadow-[0_10px_30px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
|
||||
|
||||
export const APP_SURFACE_HOVER_CLASS = "hover:bg-app-surface-hover";
|
||||
export const APP_SURFACE_ACTIVE_CLASS = "bg-app-surface-active";
|
||||
export const APP_SURFACE_PRESSED_CLASS = "active:bg-app-surface-active";
|
||||
export const APP_SURFACE_GROUP_HOVER_CLASS =
|
||||
"group-hover:bg-app-surface-hover";
|
||||
|
||||
export const LIQUID_PANEL_SURFACE_CLASS =
|
||||
"rounded-2xl border border-white/70 bg-white/20 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl";
|
||||
"rounded-2xl border border-white/70 bg-white/50 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl";
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import {
|
|||
import { workflowDetailPath } from "./workflowRoutes";
|
||||
import {
|
||||
TABLE_CHECKBOX_CLASS,
|
||||
TABLE_STICKY_CELL_BG,
|
||||
SkeletonDot,
|
||||
SkeletonLine,
|
||||
TableBody,
|
||||
|
|
@ -626,12 +625,10 @@ export function WorkflowList() {
|
|||
<TableBody>
|
||||
{filtered.map((wf) => {
|
||||
const isHiddenSystem = hiddenSystemIds.includes(wf.id);
|
||||
const rowBg = selectedIds.includes(wf.id)
|
||||
? "bg-gray-50"
|
||||
: TABLE_STICKY_CELL_BG;
|
||||
return (
|
||||
<TableRow
|
||||
key={wf.id}
|
||||
selected={selectedIds.includes(wf.id)}
|
||||
className={isHiddenSystem ? "opacity-45" : undefined}
|
||||
rightClickDropdown={
|
||||
wf.is_system
|
||||
|
|
@ -685,7 +682,6 @@ export function WorkflowList() {
|
|||
onClick={() => setSelected(wf)}
|
||||
>
|
||||
<TablePrimaryCell
|
||||
bgClassName={rowBg}
|
||||
selected={selectedIds.includes(wf.id)}
|
||||
onSelectionChange={() => toggleOne(wf.id)}
|
||||
label={wf.metadata.title}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import {
|
|||
formatLabel,
|
||||
} from "../tabular/columnFormat";
|
||||
import { TAG_COLORS } from "../tabular/pillUtils";
|
||||
import {
|
||||
APP_SURFACE_ACTIVE_CLASS,
|
||||
APP_SURFACE_HOVER_CLASS,
|
||||
} from "@/app/components/ui/liquid-surface";
|
||||
|
||||
type WorkflowPreviewMode = "auto" | "prompt" | "columns";
|
||||
type MobilePickerPane = "list" | "details";
|
||||
|
|
@ -157,8 +161,8 @@ export function WorkflowPickerContent({
|
|||
}
|
||||
className={`flex min-w-0 w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-all ${
|
||||
isSelected
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "hover:bg-gray-100/70"
|
||||
? `${APP_SURFACE_ACTIVE_CLASS} text-gray-900`
|
||||
: APP_SURFACE_HOVER_CLASS
|
||||
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
|
||||
>
|
||||
<span
|
||||
|
|
@ -234,7 +238,7 @@ function WorkflowPreview({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100/70 hover:text-gray-600"
|
||||
className={`rounded-md p-1 text-gray-400 transition-colors hover:text-gray-600 ${APP_SURFACE_HOVER_CLASS}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -372,8 +376,8 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
|||
}
|
||||
className={`flex min-w-0 w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left text-xs transition-all ${
|
||||
isExpanded
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
? APP_SURFACE_ACTIVE_CLASS
|
||||
: APP_SURFACE_HOVER_CLASS
|
||||
}`}
|
||||
>
|
||||
<FormatIcon
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue