mirror of
https://github.com/willchen96/mike.git
synced 2026-06-08 20:25:13 +02:00
Update document UI, tabular reviews, and storage caching
This commit is contained in:
parent
2bbb628891
commit
4f3384334a
26 changed files with 856 additions and 341 deletions
|
|
@ -20,6 +20,12 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
|
|||
- why
|
||||
- testing
|
||||
|
||||
## Security
|
||||
|
||||
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
|
||||
|
||||
We will aim to respond promptly and coordinate a disclosure timeline with you.
|
||||
|
||||
## Local Development
|
||||
|
||||
Backend:
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@ create table if not exists public.tabular_reviews (
|
|||
user_id text not null,
|
||||
title text,
|
||||
columns_config jsonb,
|
||||
document_ids jsonb,
|
||||
workflow_id uuid references public.workflows(id) on delete set null,
|
||||
practice text,
|
||||
shared_with jsonb not null default '[]'::jsonb,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { promisify } from "util";
|
||||
import JSZip from "jszip";
|
||||
|
||||
let _convert:
|
||||
|
|
@ -8,7 +7,26 @@ let _convert:
|
|||
async function getConvert() {
|
||||
if (!_convert) {
|
||||
const libre = await import("libreoffice-convert");
|
||||
_convert = promisify(libre.default.convert.bind(libre.default));
|
||||
const convert = libre.default.convert.bind(libre.default) as (
|
||||
buf: Buffer,
|
||||
ext: string,
|
||||
filter: undefined,
|
||||
callback?: (err: Error | null, result: Buffer) => void,
|
||||
) => Promise<Buffer> | void;
|
||||
_convert = (buf, ext, filter) =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const maybePromise = convert(buf, ext, filter, (err, result) => {
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
});
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
maybePromise.then(resolve, reject);
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
return _convert;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,16 +17,21 @@ import {
|
|||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
let cachedClient: S3Client | undefined;
|
||||
|
||||
function getClient(): S3Client {
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.R2_ENDPOINT_URL!,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
if (!cachedClient) {
|
||||
cachedClient = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.R2_ENDPOINT_URL!,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
|
||||
|
|
@ -37,6 +42,14 @@ export const storageEnabled = Boolean(
|
|||
process.env.R2_SECRET_ACCESS_KEY,
|
||||
);
|
||||
|
||||
function requireStorageConfig(): void {
|
||||
if (!storageEnabled) {
|
||||
throw new Error(
|
||||
"R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -46,6 +59,7 @@ export async function uploadFile(
|
|||
content: ArrayBuffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
requireStorageConfig();
|
||||
const client = getClient();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ async function getAccessibleChat(
|
|||
chatRouter.get("/", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const db = createServerSupabase();
|
||||
const requestedLimit = Number.parseInt(String(req.query.limit ?? ""), 10);
|
||||
const limit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(requestedLimit, 1), 100)
|
||||
: null;
|
||||
|
||||
const { data: ownProjects, error: projErr } = await db
|
||||
.from("projects")
|
||||
|
|
@ -156,11 +160,15 @@ chatRouter.get("/", requireAuth, async (req, res) => {
|
|||
? `user_id.eq.${userId},project_id.in.(${ownProjectIds.join(",")})`
|
||||
: `user_id.eq.${userId}`;
|
||||
|
||||
const { data, error } = await db
|
||||
let query = db
|
||||
.from("chats")
|
||||
.select("*")
|
||||
.or(filter)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (limit) query = query.limit(limit);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
res.json(data ?? []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -165,6 +165,15 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
|
|||
// Fetch distinct document counts per review
|
||||
const reviewIds = reviews.map((r) => (r as { id: string }).id);
|
||||
let docCounts: Record<string, number> = {};
|
||||
const reviewsWithExplicitDocs = new Set<string>();
|
||||
for (const review of reviews) {
|
||||
const id = (review as { id: string }).id;
|
||||
if (Array.isArray(review.document_ids)) {
|
||||
const explicitDocIds = review.document_ids;
|
||||
reviewsWithExplicitDocs.add(id);
|
||||
docCounts[id] = new Set(explicitDocIds).size;
|
||||
}
|
||||
}
|
||||
if (reviewIds.length > 0) {
|
||||
const { data: cells } = await db
|
||||
.from("tabular_cells")
|
||||
|
|
@ -176,8 +185,10 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
|
|||
const key = `${cell.review_id}:${cell.document_id}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
docCounts[cell.review_id] =
|
||||
(docCounts[cell.review_id] ?? 0) + 1;
|
||||
if (!reviewsWithExplicitDocs.has(cell.review_id)) {
|
||||
docCounts[cell.review_id] =
|
||||
(docCounts[cell.review_id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -229,6 +240,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => {
|
|||
user_id: userId,
|
||||
title: title ?? null,
|
||||
columns_config,
|
||||
document_ids: allowedDocumentIds,
|
||||
project_id: project_id ?? null,
|
||||
workflow_id: workflow_id ?? null,
|
||||
})
|
||||
|
|
@ -345,17 +357,19 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
|
|||
.from("tabular_cells")
|
||||
.select("*")
|
||||
.eq("review_id", reviewId);
|
||||
const docIds = [...new Set((cells ?? []).map((c) => c.document_id))];
|
||||
const cellDocIds = [...new Set((cells ?? []).map((c) => c.document_id))];
|
||||
const hasExplicitDocIds = Array.isArray(review.document_ids);
|
||||
const explicitDocIds = hasExplicitDocIds
|
||||
? (review.document_ids as string[])
|
||||
: [];
|
||||
const docIds =
|
||||
hasExplicitDocIds
|
||||
? explicitDocIds
|
||||
: cellDocIds;
|
||||
const docsResult =
|
||||
docIds.length > 0
|
||||
? await db.from("documents").select("*").in("id", docIds)
|
||||
: review.project_id
|
||||
? await db
|
||||
.from("documents")
|
||||
.select("*")
|
||||
.eq("project_id", review.project_id)
|
||||
.order("created_at", { ascending: true })
|
||||
: { data: [] as Record<string, unknown>[] };
|
||||
: { data: [] as Record<string, unknown>[] };
|
||||
|
||||
res.json({
|
||||
review: { ...review, is_owner: access.isOwner },
|
||||
|
|
@ -517,6 +531,7 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
|||
detail: updateError?.message ?? "Failed to update review",
|
||||
});
|
||||
|
||||
let persistedDocumentIds: string[] | undefined;
|
||||
if (
|
||||
Array.isArray(req.body.columns_config) ||
|
||||
Array.isArray(req.body.document_ids)
|
||||
|
|
@ -577,13 +592,21 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
|||
(existingCells ?? []).map((cell) => cell.document_id),
|
||||
),
|
||||
];
|
||||
if (documentIds.length === 0 && existingReview.project_id) {
|
||||
const { data: projectDocs } = await db
|
||||
.from("documents")
|
||||
.select("id")
|
||||
.eq("project_id", existingReview.project_id);
|
||||
documentIds = (projectDocs ?? []).map((doc) => doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(req.body.document_ids)) {
|
||||
persistedDocumentIds = documentIds;
|
||||
const { error: documentIdsError } = await db
|
||||
.from("tabular_reviews")
|
||||
.update({
|
||||
document_ids: documentIds,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", reviewId);
|
||||
if (documentIdsError)
|
||||
return void res.status(500).json({
|
||||
detail: documentIdsError.message,
|
||||
});
|
||||
}
|
||||
|
||||
const activeColumns = Array.isArray(req.body.columns_config)
|
||||
|
|
@ -614,7 +637,10 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
res.json(updatedReview);
|
||||
res.json({
|
||||
...updatedReview,
|
||||
...(persistedDocumentIds ? { document_ids: persistedDocumentIds } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /tabular-review/:reviewId
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Menu } from "lucide-react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
|
||||
import { SidebarContext } from "@/app/contexts/SidebarContext";
|
||||
|
|
@ -77,7 +77,12 @@ export default function MikeLayout({
|
|||
return (
|
||||
<ChatHistoryProvider>
|
||||
<SidebarContext.Provider
|
||||
value={{ setSidebarOpen: (open) => { setIsSidebarOpen(open); setIsSidebarOpenDesktop(open); } }}
|
||||
value={{
|
||||
setSidebarOpen: (open) => {
|
||||
setIsSidebarOpen(open);
|
||||
setIsSidebarOpenDesktop(open);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="h-dvh bg-white flex flex-col">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
|
@ -87,12 +92,14 @@ export default function MikeLayout({
|
|||
/>
|
||||
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
|
||||
{/* Mobile header */}
|
||||
<div className="flex md:hidden items-center gap-3 px-4 py-3 border-b border-gray-100 shrink-0">
|
||||
<div className="flex md:hidden items-center gap-3 px-4 pt-3 pb-1 shrink-0">
|
||||
<button
|
||||
onClick={handleSidebarToggle}
|
||||
className="flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100 text-gray-500 transition-colors"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
|
||||
title="Open sidebar"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ function isDocxTab(filename: string) {
|
|||
return ext === "docx" || ext === "doc";
|
||||
}
|
||||
|
||||
const ICON_SIZE = 30;
|
||||
const ICON_SIZE = 28;
|
||||
const GAP = 14;
|
||||
const EXPLORER_MIN = 160;
|
||||
const EXPLORER_DEFAULT = 280;
|
||||
|
|
@ -92,17 +92,18 @@ const CHAT_MIN = 320;
|
|||
const CHAT_DEFAULT = 420;
|
||||
|
||||
function AssistantGreeting({ username }: { username: string }) {
|
||||
const { profile } = useUserProfile();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [iconOffset, setIconOffset] = useState(0);
|
||||
const [textOffset, setTextOffset] = useState(0);
|
||||
const textRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!textRef.current) return;
|
||||
if (!profile || !textRef.current) return;
|
||||
const h1Width = textRef.current.offsetWidth;
|
||||
setIconOffset((h1Width + GAP) / 2);
|
||||
setTextOffset((ICON_SIZE + GAP) / 2);
|
||||
}, [username]);
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconOffset) return;
|
||||
|
|
@ -112,7 +113,7 @@ function AssistantGreeting({ username }: { username: string }) {
|
|||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="relative flex items-center justify-center h-[30px]">
|
||||
<div className="relative flex items-center justify-center h-[28px]">
|
||||
<div
|
||||
className="absolute h-[30px]"
|
||||
style={{
|
||||
|
|
@ -128,7 +129,7 @@ function AssistantGreeting({ username }: { username: string }) {
|
|||
</div>
|
||||
<h1
|
||||
ref={textRef}
|
||||
className="absolute text-2xl font-serif font-light text-gray-900 whitespace-nowrap"
|
||||
className="absolute text-3xl font-serif font-light text-gray-900 whitespace-nowrap"
|
||||
style={{
|
||||
left: "50%",
|
||||
transform: loaded
|
||||
|
|
@ -309,9 +310,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
`created=${created.sort().join(",")}`,
|
||||
`replicated=${replicated.sort().join(",")}`,
|
||||
`edited=${Object.entries(editedPerDoc)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.sort()
|
||||
.join(",")}`,
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.sort()
|
||||
.join(",")}`,
|
||||
].join("|");
|
||||
}, [messages]);
|
||||
|
||||
|
|
@ -1007,8 +1008,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
<div
|
||||
key={tab.documentId}
|
||||
ref={(el) => {
|
||||
tabItemRefs.current[tab.documentId] =
|
||||
el;
|
||||
tabItemRefs.current[
|
||||
tab.documentId
|
||||
] = el;
|
||||
}}
|
||||
onClick={() =>
|
||||
switchTab(tab.documentId)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const CHECK_W = "w-8 shrink-0";
|
|||
const NAME_COL_W = "w-[300px] shrink-0";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "all", label: "All Reviews" },
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "in-project", label: "In Project" },
|
||||
{ id: "standalone", label: "Standalone" },
|
||||
];
|
||||
|
|
@ -239,7 +239,7 @@ export default function TabularReviewsPage() {
|
|||
);
|
||||
|
||||
const toolbarActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<>
|
||||
{selectedIds.length > 0 && (
|
||||
<div ref={actionsRef} className="relative">
|
||||
<button
|
||||
|
|
@ -262,13 +262,13 @@ export default function TabularReviewsPage() {
|
|||
</div>
|
||||
)}
|
||||
{projectFilterButton}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between px-8 py-4">
|
||||
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
|
||||
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Tabular Reviews
|
||||
</h1>
|
||||
|
|
@ -298,7 +298,7 @@ export default function TabularReviewsPage() {
|
|||
{/* Table */}
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div className="min-w-max">
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
{!loading && (
|
||||
<input
|
||||
|
|
@ -327,7 +327,7 @@ export default function TabularReviewsPage() {
|
|||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
|
|
@ -395,7 +395,7 @@ export default function TabularReviewsPage() {
|
|||
: `/tabular-reviews/${review.id}`,
|
||||
);
|
||||
}}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
|
|
@ -412,7 +412,7 @@ export default function TabularReviewsPage() {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
|
||||
{renamingId === review.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -238,19 +238,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
{onProjectsClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProjectsClick}
|
||||
aria-label="Open projects"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
Projects
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!hideWorkflowButton && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -268,6 +255,19 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onProjectsClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProjectsClick}
|
||||
aria-label="Open projects"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
Projects
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -118,11 +118,7 @@ export function ProjectAssistantTab({
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${
|
||||
selectedChatIds.includes(chat.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white"
|
||||
} group-hover:bg-gray-50`}
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
|
||||
>
|
||||
{renamingChatId === chat.id ? (
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
renameProjectDocument,
|
||||
listDocumentVersions,
|
||||
uploadDocumentVersion,
|
||||
uploadProjectDocument,
|
||||
renameDocumentVersion,
|
||||
getProjectPeople,
|
||||
type MikeDocumentVersion,
|
||||
|
|
@ -50,7 +51,10 @@ import {
|
|||
RowActionMenuItems,
|
||||
RowActions,
|
||||
} from "@/app/components/shared/RowActions";
|
||||
import { AddDocumentsModal } from "@/app/components/shared/AddDocumentsModal";
|
||||
import {
|
||||
AddDocumentsModal,
|
||||
invalidateDirectoryCache,
|
||||
} from "@/app/components/shared/AddDocumentsModal";
|
||||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
|
@ -266,6 +270,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
const newFolderInputRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
||||
const [dragOverRoot, setDragOverRoot] = useState(false);
|
||||
const [dragOverFileRoot, setDragOverFileRoot] = useState(false);
|
||||
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
|
||||
// Actions dropdown
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
|
|
@ -332,6 +340,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
function handleDragEnd() {
|
||||
setDragOverFolderId(null);
|
||||
setDragOverRoot(false);
|
||||
setDragOverFileRoot(false);
|
||||
}
|
||||
document.addEventListener("dragend", handleDragEnd);
|
||||
return () => document.removeEventListener("dragend", handleDragEnd);
|
||||
|
|
@ -707,6 +716,26 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
function hasFilePayload(dt: DataTransfer): boolean {
|
||||
return Array.from(dt.types).includes("Files");
|
||||
}
|
||||
|
||||
async function handleDropProjectFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
setUploadingDroppedFilenames(files.map((file) => file.name));
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
files.map((file) => uploadProjectDocument(projectId, file)),
|
||||
);
|
||||
invalidateDirectoryCache();
|
||||
handleDocsSelected(uploaded);
|
||||
} catch (err) {
|
||||
console.error("Project document drop upload failed", err);
|
||||
} finally {
|
||||
setUploadingDroppedFilenames([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDropOnFolder(targetFolderId: string | null, dt: DataTransfer) {
|
||||
if (!hasMovePayload(dt)) return;
|
||||
const docId = dt.getData("application/mike-doc");
|
||||
|
|
@ -778,6 +807,47 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
function renderUploadingDocumentRows(depth: number) {
|
||||
return uploadingDroppedFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-doc-${filename}`}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`}
|
||||
style={treeControlCellStyle(depth)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
<span className="text-sm text-gray-400 truncate">
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0 text-xs text-gray-300 uppercase truncate">
|
||||
{filename.includes(".") ? filename.split(".").pop() : "file"}
|
||||
</div>
|
||||
<div className="w-24 shrink-0 text-sm text-gray-300">
|
||||
Uploading
|
||||
</div>
|
||||
<div className="w-20 shrink-0 text-sm text-gray-300">—</div>
|
||||
<div className="w-32 shrink-0 text-sm text-gray-300">—</div>
|
||||
<div className="w-32 shrink-0 text-sm text-gray-300">—</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function renderLevel(parentId: string | null, depth: number) {
|
||||
const childFolders = folders
|
||||
.filter((f) => f.parent_folder_id === parentId)
|
||||
|
|
@ -786,6 +856,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{parentId === null && renderUploadingDocumentRows(depth)}
|
||||
{/* Files first */}
|
||||
{childDocs.map((doc) => {
|
||||
const isProcessing = doc.status === "pending" || doc.status === "processing";
|
||||
|
|
@ -848,7 +919,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
|
||||
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
|
|
@ -1150,20 +1221,20 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
) : null;
|
||||
|
||||
const toolbarActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-5">
|
||||
{actionsDropdown}
|
||||
{tab === "documents" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setCreatingFolderIn(null); setNewFolderName(""); }}
|
||||
className="flex items-center gap-1 text-xs px-3 font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Add Subfolder
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddDocsOpen(true)}
|
||||
className="flex items-center gap-1 text-xs px-3 font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
|
|
@ -1239,13 +1310,42 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Blue ring wraps everything below the header when root-dropping */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
<div
|
||||
className="flex-1 flex flex-col min-h-0 relative"
|
||||
onDragOver={(e) => {
|
||||
if (!hasFilePayload(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragOverFileRoot(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setDragOverFileRoot(false);
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!hasFilePayload(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverFileRoot(false);
|
||||
setDragOverRoot(false);
|
||||
setDragOverFolderId(null);
|
||||
void handleDropProjectFiles(
|
||||
Array.from(e.dataTransfer.files),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{dragOverRoot && dragOverFolderId === null && (
|
||||
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-[80]" />
|
||||
)}
|
||||
{dragOverFileRoot && (
|
||||
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{docs.length === 0 && folders.length === 0 ? (
|
||||
{docs.length === 0 &&
|
||||
folders.length === 0 &&
|
||||
uploadingDroppedFilenames.length === 0 ? (
|
||||
<div
|
||||
onClick={() => setAddDocsOpen(true)}
|
||||
className="flex-1 flex cursor-pointer flex-col items-center justify-center py-24 text-center"
|
||||
|
|
@ -1282,15 +1382,17 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
>
|
||||
{/* Search: flat list; no search: folder tree */}
|
||||
{q ? (
|
||||
filteredDocs.map((doc) => {
|
||||
const isProcessing = doc.status === "pending" || doc.status === "processing";
|
||||
const isError = doc.status === "error";
|
||||
const isVersionsOpen = expandedVersionDocIds.has(doc.id);
|
||||
const hasVersions =
|
||||
typeof doc.latest_version_number === "number" &&
|
||||
doc.latest_version_number >= 1;
|
||||
return (
|
||||
<div key={doc.id}>
|
||||
<>
|
||||
{renderUploadingDocumentRows(0)}
|
||||
{filteredDocs.map((doc) => {
|
||||
const isProcessing = doc.status === "pending" || doc.status === "processing";
|
||||
const isError = doc.status === "error";
|
||||
const isVersionsOpen = expandedVersionDocIds.has(doc.id);
|
||||
const hasVersions =
|
||||
typeof doc.latest_version_number === "number" &&
|
||||
doc.latest_version_number >= 1;
|
||||
return (
|
||||
<div key={doc.id}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setViewingDocVersion(null);
|
||||
|
|
@ -1318,7 +1420,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} p-2 ${selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}>
|
||||
<div className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? <Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" /> : isError ? <AlertCircle className="h-4 w-4 text-red-500 shrink-0" /> : <DocIcon fileType={doc.file_type} />}
|
||||
{renamingDocumentId === doc.id ? (
|
||||
|
|
@ -1423,9 +1525,10 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
renderLevel(null, 0)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ function treeControlWidth(depth: number) {
|
|||
return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1);
|
||||
}
|
||||
|
||||
export function treeControlCellStyle(
|
||||
depth: number,
|
||||
): CSSProperties | undefined {
|
||||
export function treeControlCellStyle(depth: number): CSSProperties | undefined {
|
||||
if (depth <= 0) return undefined;
|
||||
const width = treeControlWidth(depth);
|
||||
return {
|
||||
|
|
@ -157,7 +155,8 @@ export function DocVersionHistory({
|
|||
<>
|
||||
{ordered.map((v) => {
|
||||
const numberLabel =
|
||||
typeof v.version_number === "number" && v.version_number >= 1
|
||||
typeof v.version_number === "number" &&
|
||||
v.version_number >= 1
|
||||
? `${v.version_number}`
|
||||
: v.source === "upload"
|
||||
? "Original"
|
||||
|
|
@ -182,7 +181,7 @@ export function DocVersionHistory({
|
|||
if (isEditing) return;
|
||||
onOpenVersion?.(v.id, displayLabel);
|
||||
}}
|
||||
className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
|
||||
className="group flex items-center h-9 pr-3 md:pr-10 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`}
|
||||
|
|
@ -193,7 +192,9 @@ export function DocVersionHistory({
|
|||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-gray-400">↳</span>
|
||||
<span className="shrink-0 text-gray-400">
|
||||
↳
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
|
@ -223,7 +224,9 @@ export function DocVersionHistory({
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingVersionId(v.id);
|
||||
setEditingValue(v.display_name ?? "");
|
||||
setEditingValue(
|
||||
v.display_name ?? "",
|
||||
);
|
||||
}}
|
||||
title="Rename version"
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition"
|
||||
|
|
@ -234,7 +237,9 @@ export function DocVersionHistory({
|
|||
<span className="text-gray-400 truncate">
|
||||
{dateLabel}
|
||||
</span>
|
||||
<span className="text-gray-300 shrink-0">·</span>
|
||||
<span className="text-gray-300 shrink-0">
|
||||
·
|
||||
</span>
|
||||
<span className="text-gray-400 truncate">
|
||||
{v.source}
|
||||
</span>
|
||||
|
|
@ -265,23 +270,29 @@ export function DocVersionHistory({
|
|||
export function ProjectPageSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
<div className="flex items-start justify-between px-8 py-4">
|
||||
<div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
<span className="text-gray-400">Projects</span>
|
||||
<span className="text-gray-300">›</span>
|
||||
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-11 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-28 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-10 px-8 border-b border-gray-200 gap-5">
|
||||
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200">
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
|
|
@ -297,7 +308,7 @@ export function ProjectPageSkeleton() {
|
|||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
|
|
@ -346,7 +357,7 @@ export function ProjectPageHeader({
|
|||
onNewReview: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between px-8 py-4">
|
||||
<div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
<button
|
||||
|
|
@ -393,7 +404,7 @@ export function ProjectPageHeader({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<HeaderSearchBtn
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
|
|
@ -410,7 +421,7 @@ export function ProjectPageHeader({
|
|||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => !creatingChat && onNewChat()}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
|
||||
className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
|
||||
!creatingChat
|
||||
? "text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
: "text-gray-300 cursor-default"
|
||||
|
|
@ -429,7 +440,7 @@ export function ProjectPageHeader({
|
|||
onClick={() =>
|
||||
docsCount > 0 && !creatingReview && onNewReview()
|
||||
}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
|
||||
className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
|
||||
docsCount > 0
|
||||
? "text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
: "text-gray-300 cursor-default"
|
||||
|
|
|
|||
|
|
@ -129,11 +129,7 @@ export function ProjectReviewsTab({
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${
|
||||
selectedReviewIds.includes(review.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white"
|
||||
} group-hover:bg-gray-50`}
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
|
||||
>
|
||||
{renamingReviewId === review.id ? (
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export function ProjectsOverview() {
|
|||
}
|
||||
|
||||
const toolbarActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<>
|
||||
{selectedIds.length > 0 && (
|
||||
<div ref={actionsRef} className="relative">
|
||||
<button
|
||||
|
|
@ -199,13 +199,13 @@ export function ProjectsOverview() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between px-8 py-4">
|
||||
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
|
||||
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Projects
|
||||
</h1>
|
||||
|
|
@ -235,7 +235,7 @@ export function ProjectsOverview() {
|
|||
<div className="w-full overflow-x-auto">
|
||||
<div className="min-w-max">
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
{!loading && (
|
||||
<input
|
||||
|
|
@ -267,7 +267,7 @@ export function ProjectsOverview() {
|
|||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
|
|
@ -341,7 +341,7 @@ export function ProjectsOverview() {
|
|||
if (renamingId === project.id) return;
|
||||
router.push(`/projects/${project.id}`);
|
||||
}}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
|
|
@ -358,7 +358,7 @@ export function ProjectsOverview() {
|
|||
</div>
|
||||
|
||||
{/* Project Name */}
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
|
||||
{renamingId === project.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function AddDocumentsModal({
|
|||
const { user } = useAuth();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [extraUploadedDocs, setExtraUploadedDocs] = useState<MikeDocument[]>([]);
|
||||
// IDs deleted in this session — hidden locally since `useDirectoryData`'s
|
||||
|
|
@ -52,6 +53,7 @@ export function AddDocumentsModal({
|
|||
setSelectedIds(new Set());
|
||||
setExtraUploadedDocs([]);
|
||||
setDeletedIds(new Set());
|
||||
setUploadingFilenames([]);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
|
@ -175,6 +177,7 @@ export function AddDocumentsModal({
|
|||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setUploadingFilenames(files.map((file) => file.name));
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
|
|
@ -193,6 +196,7 @@ export function AddDocumentsModal({
|
|||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadingFilenames([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -255,6 +259,7 @@ export function AddDocumentsModal({
|
|||
q ? "No matches found" : "No documents yet"
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
uploadingFilenames={uploadingFilenames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import Link from "next/link";
|
|||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
|
||||
import { listProjects } from "@/app/lib/mikeApi";
|
||||
import type { MikeProject } from "@/app/components/shared/types";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
|
||||
|
|
@ -35,15 +36,25 @@ interface AppSidebarProps {
|
|||
export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const { profile } = useUserProfile();
|
||||
const { chats, currentChatId, setCurrentChatId } = useChatHistoryContext();
|
||||
const {
|
||||
chats,
|
||||
currentChatId,
|
||||
hasMoreChats,
|
||||
loadMoreChats,
|
||||
setCurrentChatId,
|
||||
} = useChatHistoryContext();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [projectsCollapsed, setProjectsCollapsed] = useState(false);
|
||||
const [historyCollapsed, setHistoryCollapsed] = useState(false);
|
||||
const [projectNames, setProjectNames] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [recentProjects, setRecentProjects] = useState<MikeProject[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
|
@ -52,8 +63,20 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
const map: Record<string, string> = {};
|
||||
for (const p of projects) map[p.id] = p.name;
|
||||
setProjectNames(map);
|
||||
setRecentProjects(
|
||||
[...projects]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.updated_at || b.created_at) -
|
||||
Date.parse(a.updated_at || a.created_at),
|
||||
)
|
||||
.slice(0, 5),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
setProjectNames({});
|
||||
setRecentProjects([]);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -112,12 +135,12 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
className={`${
|
||||
isOpen
|
||||
? "w-64 h-dvh bg-gray-50 border-r"
|
||||
: "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent"
|
||||
} border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-99 overflow-visible`}
|
||||
: "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent pointer-events-none md:pointer-events-auto"
|
||||
} border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-[99] overflow-visible`}
|
||||
>
|
||||
{/* Toggle + Logo */}
|
||||
<div
|
||||
className={`mb-3 items-center justify-between px-2.5 py-2 ${
|
||||
className={`items-center justify-between px-2.5 py-3 ${
|
||||
!isOpen ? "hidden md:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -152,7 +175,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
const isActive =
|
||||
pathname === href || pathname.startsWith(href + "/");
|
||||
return (
|
||||
<div key={href} className="py-1 px-2.5">
|
||||
<div key={href} className="py-0.5 px-2.5">
|
||||
<button
|
||||
onClick={() => router.push(href)}
|
||||
title={!isOpen ? label : ""}
|
||||
|
|
@ -181,74 +204,182 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Assistant History */}
|
||||
{isOpen && pathname.startsWith("/assistant") && (
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col">
|
||||
<button
|
||||
onClick={() => setHistoryCollapsed((v) => !v)}
|
||||
className={`mb-2 px-5 flex items-center justify-between text-xs font-semibold text-gray-500 hover:text-gray-700 transition-colors ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
<span>Assistant History</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${historyCollapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-y-auto flex-1 ${historyCollapsed ? "hidden" : ""}`}
|
||||
>
|
||||
{!chats ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[40, 60, 50, 70, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-gray-200 rounded animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
|
||||
{/* Recent Projects */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setProjectsCollapsed((v) => !v)}
|
||||
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
<span>Recent Projects</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
projectsCollapsed ? "-rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{!projectsCollapsed && (
|
||||
<>
|
||||
{!recentProjects ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[50, 65, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-gray-200 rounded animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div
|
||||
className={`text-xs text-gray-500 py-2 px-5 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
No chats yet
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
{chats.map((chat) => (
|
||||
<SidebarChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={currentChatId === chat.id}
|
||||
projectName={
|
||||
chat.project_id
|
||||
? projectNames[chat.project_id]
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
setCurrentChatId(chat.id);
|
||||
router.push(
|
||||
chat.project_id
|
||||
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
|
||||
: `/assistant/chat/${chat.id}`,
|
||||
) : recentProjects.length === 0 ? (
|
||||
<div
|
||||
className={`px-5 py-2 text-xs text-gray-500 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
No projects yet
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{recentProjects.map((project) => {
|
||||
const isActive =
|
||||
pathname ===
|
||||
`/projects/${project.id}` ||
|
||||
pathname.startsWith(
|
||||
`/projects/${project.id}/`,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${project.id}`,
|
||||
)
|
||||
}
|
||||
title={project.name}
|
||||
className={`flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors ${
|
||||
isActive
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-500" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assistant History */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
onClick={() => setHistoryCollapsed((v) => !v)}
|
||||
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
<span>Assistant History</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
historyCollapsed ? "-rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-y-auto flex-1 ${
|
||||
historyCollapsed ? "hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{!chats ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[40, 60, 50, 70, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-gray-200 rounded animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div
|
||||
className={`text-xs text-gray-500 py-2 px-5 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
No chats yet
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{chats.map((chat) => (
|
||||
<SidebarChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={
|
||||
currentChatId === chat.id
|
||||
}
|
||||
projectName={
|
||||
chat.project_id
|
||||
? projectNames[
|
||||
chat.project_id
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
setCurrentChatId(chat.id);
|
||||
router.push(
|
||||
chat.project_id
|
||||
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
|
||||
: `/assistant/chat/${chat.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreChats && (
|
||||
<div className="px-2.5 pt-1">
|
||||
<button
|
||||
onClick={loadMoreChats}
|
||||
className="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:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FileText,
|
||||
Folder,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { MikeDocument, MikeProject } from "./types";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
|
|
@ -39,6 +40,7 @@ interface FileDirectoryProps {
|
|||
emptyMessage?: string;
|
||||
heading?: string;
|
||||
onDelete?: (ids: string[]) => void | Promise<void>;
|
||||
uploadingFilenames?: string[];
|
||||
}
|
||||
|
||||
export function FileDirectory({
|
||||
|
|
@ -52,6 +54,7 @@ export function FileDirectory({
|
|||
emptyMessage = "No documents yet",
|
||||
heading = "Documents",
|
||||
onDelete,
|
||||
uploadingFilenames = [],
|
||||
}: FileDirectoryProps) {
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
new Set(),
|
||||
|
|
@ -142,7 +145,11 @@ export function FileDirectory({
|
|||
);
|
||||
}
|
||||
|
||||
if (allDocs.length === 0 && directoryProjects.length === 0) {
|
||||
if (
|
||||
allDocs.length === 0 &&
|
||||
directoryProjects.length === 0 &&
|
||||
uploadingFilenames.length === 0
|
||||
) {
|
||||
return (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
|
|
@ -154,6 +161,7 @@ export function FileDirectory({
|
|||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div>
|
||||
{(standaloneDocs.length > 0 ||
|
||||
uploadingFilenames.length > 0 ||
|
||||
(onDelete && selectedCount > 0)) && (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
|
|
@ -185,6 +193,21 @@ export function FileDirectory({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
|
||||
>
|
||||
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
<span className="flex-1 truncate text-gray-400">
|
||||
{filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-300">
|
||||
Uploading
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{standaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }:
|
|||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors"
|
||||
className="flex h-8 w-8 items-center justify-center text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function ToolbarTabs<T extends string>({
|
|||
actions,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="flex items-center h-10 px-8 border-b border-gray-200">
|
||||
<div className="flex items-center h-10 px-4 border-b border-gray-200 md:px-10">
|
||||
<div className="flex-1 flex items-center gap-5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
|
|
@ -37,7 +37,7 @@ export function ToolbarTabs<T extends string>({
|
|||
))}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-1">{actions}</div>
|
||||
<div className="flex items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export interface TabularReview {
|
|||
user_id: string;
|
||||
title: string | null;
|
||||
columns_config: ColumnConfig[] | null;
|
||||
document_ids?: string[] | null;
|
||||
workflow_id: string | null;
|
||||
practice?: string | null;
|
||||
/** Per-review email list. Used so standalone (project_id null) reviews can be shared directly. */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { Plus, Table2 } from "lucide-react";
|
||||
import { Loader2, Plus, Table2, Upload } from "lucide-react";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import { TabularCell as TabularCellComponent } from "./TabularCell";
|
||||
import { TREditColumnMenu } from "./TREditColumnMenu";
|
||||
|
|
@ -30,6 +30,8 @@ interface Props {
|
|||
savingColumn: boolean;
|
||||
savingColumnsConfig: boolean;
|
||||
selectedDocIds: string[];
|
||||
uploadingFilenames?: string[];
|
||||
dragOverFiles?: boolean;
|
||||
highlightedCell?: { colIdx: number; rowIdx: number } | null;
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onExpand: (cell: TabularCell) => void;
|
||||
|
|
@ -49,6 +51,8 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
savingColumn,
|
||||
savingColumnsConfig,
|
||||
selectedDocIds,
|
||||
uploadingFilenames = [],
|
||||
dragOverFiles = false,
|
||||
highlightedCell,
|
||||
onSelectionChange,
|
||||
onExpand,
|
||||
|
|
@ -165,7 +169,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
);
|
||||
}
|
||||
|
||||
if (columns.length === 0 && documents.length === 0) {
|
||||
if (
|
||||
columns.length === 0 &&
|
||||
documents.length === 0 &&
|
||||
uploadingFilenames.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center border-b border-gray-200">
|
||||
|
|
@ -177,28 +185,33 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
|
||||
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
|
||||
<p className="text-2xl font-medium font-serif text-gray-900">
|
||||
Tabular Review
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400 text-left">
|
||||
Add columns and documents to get started.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 shadow-md"
|
||||
>
|
||||
+ Add Columns
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddDocuments}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
</button>
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{dragOverFiles && (
|
||||
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
|
||||
)}
|
||||
<div className="flex flex-1 flex-col items-start justify-center w-full max-w-xs mx-auto">
|
||||
<Table2 className="h-8 w-8 text-gray-300 mb-4" />
|
||||
<p className="text-2xl font-medium font-serif text-gray-900">
|
||||
Tabular Review
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400 text-left">
|
||||
Add columns and documents to get started.
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-gray-700 shadow-md"
|
||||
>
|
||||
+ Add Columns
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddDocuments}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 transition-colors shadow-sm"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -206,7 +219,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto" ref={scrollContainerRef}>
|
||||
<div
|
||||
className="flex flex-1 flex-col overflow-auto"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="sticky top-0 z-20 flex bg-white h-8"
|
||||
|
|
@ -258,69 +274,114 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{documents.map((doc, docIdx) => {
|
||||
const rowBg = selectedDocIds.includes(doc.id)
|
||||
? "bg-gray-100"
|
||||
: docIdx % 2 === 0
|
||||
? "bg-white"
|
||||
: "bg-gray-50";
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{dragOverFiles && (
|
||||
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
|
||||
)}
|
||||
{uploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`flex ${rowBg}`}
|
||||
key={`uploading-${filename}`}
|
||||
className="flex bg-white"
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center bg-white`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(doc.id)}
|
||||
onChange={() => toggleDoc(doc.id)}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
disabled
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${rowBg}`}
|
||||
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-400 flex items-center gap-2 bg-white`}
|
||||
>
|
||||
<span className="line-clamp-1" title={doc.filename}>
|
||||
{doc.filename}
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
<span className="line-clamp-1" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
{columns.map((col) => {
|
||||
const cell = getCell(doc.id, col.index);
|
||||
const colPos = sortedColumns.findIndex(
|
||||
(c) => c.index === col.index,
|
||||
);
|
||||
const isHighlighted =
|
||||
highlightedCell?.colIdx === colPos &&
|
||||
highlightedCell?.rowIdx === docIdx;
|
||||
return (
|
||||
<div
|
||||
key={col.index}
|
||||
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
|
||||
>
|
||||
{cell && (
|
||||
<TabularCellComponent
|
||||
cell={cell}
|
||||
column={col}
|
||||
onExpand={() => onExpand(cell)}
|
||||
onCitationClick={(page, quote) =>
|
||||
onCitationClick(
|
||||
cell,
|
||||
page,
|
||||
quote,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sortedColumns.map((col) => (
|
||||
<div
|
||||
key={col.index}
|
||||
className={`${COL_W} border-b border-r border-gray-200 p-2`}
|
||||
>
|
||||
<div className="h-4 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{documents.map((doc, docIdx) => {
|
||||
const baseRowBg =
|
||||
docIdx % 2 === 0 ? "bg-white" : "bg-gray-50";
|
||||
const rowBg = selectedDocIds.includes(doc.id)
|
||||
? "bg-gray-100"
|
||||
: baseRowBg;
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`flex ${rowBg}`}
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDocIds.includes(doc.id)}
|
||||
onChange={() => toggleDoc(doc.id)}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${baseRowBg}`}
|
||||
>
|
||||
<span
|
||||
className="line-clamp-1"
|
||||
title={doc.filename}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
</div>
|
||||
{columns.map((col) => {
|
||||
const cell = getCell(doc.id, col.index);
|
||||
const colPos = sortedColumns.findIndex(
|
||||
(c) => c.index === col.index,
|
||||
);
|
||||
const isHighlighted =
|
||||
highlightedCell?.colIdx === colPos &&
|
||||
highlightedCell?.rowIdx === docIdx;
|
||||
return (
|
||||
<div
|
||||
key={col.index}
|
||||
className={`${COL_W} border-b border-r border-gray-200 transition-colors ${isHighlighted ? "bg-blue-200" : ""}`}
|
||||
>
|
||||
{cell && (
|
||||
<TabularCellComponent
|
||||
cell={cell}
|
||||
column={col}
|
||||
onExpand={() => onExpand(cell)}
|
||||
onCitationClick={(
|
||||
page,
|
||||
quote,
|
||||
) =>
|
||||
onCitationClick(
|
||||
cell,
|
||||
page,
|
||||
quote,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1 border-b border-gray-200 min-h-8 min-w-8" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users } from "lucide-react";
|
||||
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload } from "lucide-react";
|
||||
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
|
||||
|
||||
import {
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
regenerateTabularCell,
|
||||
streamTabularGeneration,
|
||||
updateTabularReview,
|
||||
uploadReviewDocument,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
|
|
@ -70,6 +71,10 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [dragOverReviewFiles, setDragOverReviewFiles] = useState(false);
|
||||
const [uploadingDroppedFilenames, setUploadingDroppedFilenames] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const searchParams = useSearchParams();
|
||||
const initialChatParamRef = useRef<string | null>(
|
||||
searchParams.get("chat"),
|
||||
|
|
@ -188,6 +193,33 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
function hasFilePayload(dt: DataTransfer): boolean {
|
||||
return Array.from(dt.types).includes("Files");
|
||||
}
|
||||
|
||||
async function handleDropReviewFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
setUploadingDroppedFilenames(files.map((file) => file.name));
|
||||
try {
|
||||
const uploaded: MikeDocument[] = [];
|
||||
const documentIds = documents.map((document) => document.id);
|
||||
for (const file of files) {
|
||||
const document = await uploadReviewDocument(reviewId, file, {
|
||||
projectId,
|
||||
documentIds,
|
||||
columnsConfig: columns,
|
||||
});
|
||||
uploaded.push(document);
|
||||
documentIds.push(document.id);
|
||||
}
|
||||
await handleAddDocuments(uploaded);
|
||||
} catch (err) {
|
||||
console.error("Tabular review document drop upload failed", err);
|
||||
} finally {
|
||||
setUploadingDroppedFilenames([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateCell(docId: string, colIndex: number) {
|
||||
if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) {
|
||||
setApiKeyModalProvider(getModelProvider(tabularModel));
|
||||
|
|
@ -441,19 +473,30 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}
|
||||
|
||||
async function handleDeleteDocuments() {
|
||||
const idsToDelete = [...selectedDocIds];
|
||||
if (idsToDelete.length === 0) return;
|
||||
const previousDocuments = documents;
|
||||
const previousCells = cells;
|
||||
const remaining = documents.filter(
|
||||
(d) => !selectedDocIds.includes(d.id),
|
||||
(d) => !idsToDelete.includes(d.id),
|
||||
);
|
||||
setDocuments(remaining);
|
||||
setCells((prev) =>
|
||||
prev.filter((c) => !selectedDocIds.includes(c.document_id)),
|
||||
prev.filter((c) => !idsToDelete.includes(c.document_id)),
|
||||
);
|
||||
setSelectedDocIds([]);
|
||||
setActionsOpen(false);
|
||||
await updateTabularReview(reviewId, {
|
||||
document_ids: remaining.map((d) => d.id),
|
||||
columns_config: columns,
|
||||
});
|
||||
try {
|
||||
await updateTabularReview(reviewId, {
|
||||
document_ids: remaining.map((d) => d.id),
|
||||
columns_config: columns,
|
||||
});
|
||||
} catch (err) {
|
||||
setDocuments(previousDocuments);
|
||||
setCells(previousCells);
|
||||
setSelectedDocIds(idsToDelete);
|
||||
console.error("Failed to delete tabular review documents", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearResults() {
|
||||
|
|
@ -486,7 +529,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
<div className="flex h-full overflow-hidden bg-white">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white px-8 py-4 flex items-start justify-between shrink-0 gap-4">
|
||||
<div className="mb-1 bg-white px-4 py-3 md:px-10 flex items-start justify-between shrink-0 gap-4">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
{projectId && (
|
||||
<>
|
||||
|
|
@ -614,7 +657,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center h-10 px-8 border-b border-gray-200 gap-4">
|
||||
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!chatOpen) setSidebarOpen(false);
|
||||
|
|
@ -631,8 +674,14 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Assistant in Tabular Review
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
{selectedDocIds.length > 0 && (
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="h-3 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
</>
|
||||
) : null}
|
||||
{!loading && selectedDocIds.length > 0 && (
|
||||
<div ref={actionsRef} className="relative">
|
||||
<button
|
||||
onClick={() => setActionsOpen((v) => !v)}
|
||||
|
|
@ -659,32 +708,34 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAddDocsOpen(true)}
|
||||
disabled={loading || savingColumnsConfig}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
||||
loading || savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddColOpen(true)}
|
||||
disabled={
|
||||
loading || savingColumn || savingColumnsConfig
|
||||
}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
||||
loading || savingColumn || savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Columns
|
||||
</button>
|
||||
{!loading && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setAddDocsOpen(true)}
|
||||
disabled={savingColumnsConfig}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
||||
savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Add Documents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAddColOpen(true)}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
className={`flex items-center gap-1 text-xs font-medium transition-colors ${
|
||||
savingColumn || savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Columns
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -706,30 +757,60 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
onChatIdChange={setSelectedChatId}
|
||||
/>
|
||||
)}
|
||||
<TRTable
|
||||
ref={tableRef}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
documents={filteredDocuments}
|
||||
cells={cells}
|
||||
highlightedCell={highlightedCell}
|
||||
savingColumn={savingColumn}
|
||||
savingColumnsConfig={savingColumnsConfig}
|
||||
selectedDocIds={selectedDocIds}
|
||||
onSelectionChange={setSelectedDocIds}
|
||||
onExpand={(cell) => {
|
||||
setExpandedCell(cell);
|
||||
setExpandedCellCitation(undefined);
|
||||
<div
|
||||
className="relative flex flex-1 overflow-hidden"
|
||||
onDragOver={(e) => {
|
||||
if (!hasFilePayload(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragOverReviewFiles(true);
|
||||
}}
|
||||
onCitationClick={(cell, page, quote) => {
|
||||
setExpandedCell(cell);
|
||||
setExpandedCellCitation({ quote, page });
|
||||
onDragLeave={(e) => {
|
||||
if (
|
||||
!e.currentTarget.contains(
|
||||
e.relatedTarget as Node,
|
||||
)
|
||||
) {
|
||||
setDragOverReviewFiles(false);
|
||||
}
|
||||
}}
|
||||
onUpdateColumn={handleUpdateColumn}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onAddColumn={() => setAddColOpen(true)}
|
||||
onAddDocuments={() => setAddDocsOpen(true)}
|
||||
/>
|
||||
onDrop={(e) => {
|
||||
if (!hasFilePayload(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverReviewFiles(false);
|
||||
void handleDropReviewFiles(
|
||||
Array.from(e.dataTransfer.files),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TRTable
|
||||
ref={tableRef}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
documents={filteredDocuments}
|
||||
cells={cells}
|
||||
highlightedCell={highlightedCell}
|
||||
savingColumn={savingColumn}
|
||||
savingColumnsConfig={savingColumnsConfig}
|
||||
selectedDocIds={selectedDocIds}
|
||||
uploadingFilenames={uploadingDroppedFilenames}
|
||||
dragOverFiles={dragOverReviewFiles}
|
||||
onSelectionChange={setSelectedDocIds}
|
||||
onExpand={(cell) => {
|
||||
setExpandedCell(cell);
|
||||
setExpandedCellCitation(undefined);
|
||||
}}
|
||||
onCitationClick={(cell, page, quote) => {
|
||||
setExpandedCell(cell);
|
||||
setExpandedCellCitation({ quote, page });
|
||||
}}
|
||||
onUpdateColumn={handleUpdateColumn}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onAddColumn={() => setAddColOpen(true)}
|
||||
onAddDocuments={() => setAddDocsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const CHECK_W = "w-8 shrink-0";
|
|||
const NAME_COL_W = "w-[300px] shrink-0";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "all", label: "All Workflows" },
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "builtin", label: "Built-in" },
|
||||
{ id: "custom", label: "Custom" },
|
||||
{ id: "hidden", label: "Hidden" },
|
||||
|
|
@ -319,7 +319,7 @@ export function WorkflowList() {
|
|||
);
|
||||
|
||||
const toolbarActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<>
|
||||
{selectedIds.length > 0 && (
|
||||
<div ref={actionsRef} className="relative">
|
||||
<button
|
||||
|
|
@ -350,15 +350,17 @@ export function WorkflowList() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{typeFilterButton}
|
||||
{practiceFilterButton}
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
{typeFilterButton}
|
||||
{practiceFilterButton}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden bg-white">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between px-8 py-4 shrink-0">
|
||||
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10 shrink-0">
|
||||
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Workflows
|
||||
</h1>
|
||||
|
|
@ -388,7 +390,7 @@ export function WorkflowList() {
|
|||
<div className="flex-1 overflow-auto">
|
||||
<div className="min-w-max">
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
{!loading && (
|
||||
<input
|
||||
|
|
@ -416,7 +418,7 @@ export function WorkflowList() {
|
|||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
|
|
@ -489,7 +491,7 @@ export function WorkflowList() {
|
|||
<div
|
||||
key={wf.id}
|
||||
onClick={() => setSelected(wf)}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import type { MikeChat, MikeMessage } from "@/app/components/shared/types";
|
|||
|
||||
interface ChatHistoryContextType {
|
||||
chats: MikeChat[] | null;
|
||||
hasMoreChats: boolean;
|
||||
currentChatId: string | null;
|
||||
setCurrentChatId: (chatId: string | null) => void;
|
||||
loadChats: () => Promise<void>;
|
||||
loadMoreChats: () => void;
|
||||
saveChat: (projectId?: string) => Promise<string | null>;
|
||||
renameChat: (chatId: string, title: string) => Promise<void>;
|
||||
newChatMessages: MikeMessage[] | null;
|
||||
|
|
@ -39,9 +41,14 @@ const ChatHistoryContext = createContext<ChatHistoryContextType | undefined>(
|
|||
undefined,
|
||||
);
|
||||
|
||||
const INITIAL_CHAT_LIMIT = 20;
|
||||
const CHAT_LIMIT_INCREMENT = 10;
|
||||
|
||||
export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const [chats, setChats] = useState<MikeChat[] | null>(null);
|
||||
const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT);
|
||||
const [hasMoreChats, setHasMoreChats] = useState(false);
|
||||
const [currentChatId, setCurrentChatId] = useState<string | null>(null);
|
||||
const [newChatMessages, setNewChatMessages] = useState<
|
||||
MikeMessage[] | null
|
||||
|
|
@ -50,20 +57,25 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
|||
const loadChats = useCallback(async () => {
|
||||
if (!user) {
|
||||
setChats([]);
|
||||
setHasMoreChats(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await listChats();
|
||||
setChats(data);
|
||||
const data = await listChats({ limit: chatLimit + 1 });
|
||||
setChats(data.slice(0, chatLimit));
|
||||
setHasMoreChats(data.length > chatLimit);
|
||||
} catch {
|
||||
setChats([]);
|
||||
setHasMoreChats(false);
|
||||
}
|
||||
}, [user]);
|
||||
}, [chatLimit, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setChats([]);
|
||||
setChatLimit(INITIAL_CHAT_LIMIT);
|
||||
setHasMoreChats(false);
|
||||
setCurrentChatId(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -71,6 +83,10 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
|||
void loadChats();
|
||||
}, [user, loadChats]);
|
||||
|
||||
const loadMoreChats = useCallback(() => {
|
||||
setChatLimit((prev) => prev + CHAT_LIMIT_INCREMENT);
|
||||
}, []);
|
||||
|
||||
const replaceChatId = useCallback(
|
||||
(oldChatId: string, newChatId: string, title?: string) => {
|
||||
if (!oldChatId || !newChatId || oldChatId === newChatId) {
|
||||
|
|
@ -154,9 +170,11 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
|||
const value = useMemo(
|
||||
() => ({
|
||||
chats,
|
||||
hasMoreChats,
|
||||
currentChatId,
|
||||
setCurrentChatId,
|
||||
loadChats,
|
||||
loadMoreChats,
|
||||
saveChat,
|
||||
renameChat: renameChatFn,
|
||||
newChatMessages,
|
||||
|
|
@ -166,8 +184,10 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
|||
}),
|
||||
[
|
||||
chats,
|
||||
hasMoreChats,
|
||||
currentChatId,
|
||||
loadChats,
|
||||
loadMoreChats,
|
||||
saveChat,
|
||||
renameChatFn,
|
||||
newChatMessages,
|
||||
|
|
|
|||
|
|
@ -428,8 +428,11 @@ export async function createChat(payload?: {
|
|||
});
|
||||
}
|
||||
|
||||
export async function listChats(): Promise<MikeChat[]> {
|
||||
return apiRequest<MikeChat[]>("/chat");
|
||||
export async function listChats(options?: { limit?: number }): Promise<MikeChat[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
const query = params.toString();
|
||||
return apiRequest<MikeChat[]>(`/chat${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function listProjectChats(projectId: string): Promise<MikeChat[]> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue