From 30e6ca857db72c4a535c0e6d37d228e66a553c50 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:20:22 +0000 Subject: [PATCH 01/32] Replace workspace RGL layout with plain CSS grid. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- next.config.ts | 2 - package.json | 1 - pnpm-lock.yaml | 57 -- src/app/api/workspaces/autogen/route.ts | 55 +- .../workspace-canvas/FolderCard.tsx | 36 +- .../WorkspaceCanvasDropzone.tsx | 7 +- .../workspace-canvas/WorkspaceCard.tsx | 17 +- .../workspace-canvas/WorkspaceContent.tsx | 41 - .../workspace-canvas/WorkspaceGrid.tsx | 787 +++--------------- .../workspace-canvas/WorkspaceHeader.tsx | 81 +- .../workspace-canvas/WorkspaceSection.tsx | 14 +- .../workspace/use-workspace-operations.ts | 102 +-- src/lib/ai/workers/workspace-worker.ts | 19 +- src/lib/uploads/uploaded-asset.ts | 16 +- .../workspace-state/grid-layout-helpers.ts | 390 --------- 15 files changed, 127 insertions(+), 1498 deletions(-) delete mode 100644 src/lib/workspace-state/grid-layout-helpers.ts diff --git a/next.config.ts b/next.config.ts index fe5a58719..1c2c19946 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,8 +4,6 @@ import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { reactCompiler: true, - // Transpile git-installed packages to ensure proper module resolution - transpilePackages: ["react-grid-layout"], images: { remotePatterns: [ { diff --git a/package.json b/package.json index 30deb2f69..b34941eb5 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,6 @@ "react-dom": "19.2.5", "react-dropzone": "^15.0.0", "react-email": "^6.0.0", - "react-grid-layout": "git+https://github.com/ThinkEx-OSS/thinkex-grid.git", "react-hotkeys-hook": "^5.2.4", "react-icons": "^5.5.0", "react-jsx-parser": "^2.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fea08bf7f..eae7355b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -365,9 +365,6 @@ importers: react-email: specifier: ^6.0.0 version: 6.0.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-grid-layout: - specifier: git+https://github.com/ThinkEx-OSS/thinkex-grid.git - version: https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-hotkeys-hook: specifier: ^5.2.4 version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -7156,9 +7153,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@4.0.3: - resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} - fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -9130,12 +9124,6 @@ packages: peerDependencies: react: ^19.2.5 - react-draggable@4.5.0: - resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-dropzone@15.0.0: resolution: {integrity: sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==} engines: {node: '>= 10.13'} @@ -9147,13 +9135,6 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - react-grid-layout@https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6: - resolution: {tarball: https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6} - version: 2.2.3 - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-hotkeys-hook@5.2.4: resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==} peerDependencies: @@ -9213,12 +9194,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-resizable@3.1.3: - resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} - peerDependencies: - react: '>= 16.3' - react-dom: '>= 16.3' - react-shiki@0.9.3: resolution: {integrity: sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg==} peerDependencies: @@ -9357,9 +9332,6 @@ packages: '@react-email/render': optional: true - resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -17450,8 +17422,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-equals@4.0.3: {} - fast-equals@5.4.0: {} fast-fifo@1.3.2: {} @@ -19827,13 +19797,6 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-draggable@4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - clsx: 2.1.1 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-dropzone@15.0.0(react@19.2.5): dependencies: attr-accept: 2.2.5 @@ -19872,17 +19835,6 @@ snapshots: - supports-color - utf-8-validate - react-grid-layout@https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - clsx: 2.1.1 - fast-equals: 4.0.3 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-resizable: 3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - resize-observer-polyfill: 1.5.1 - react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -19951,13 +19903,6 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-resizable@3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-shiki@0.9.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 @@ -20130,8 +20075,6 @@ snapshots: optionalDependencies: '@react-email/render': 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - resize-observer-polyfill@1.5.1: {} - resolve-alpn@1.2.1: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 9de67682b..5b2fac2c3 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -2,17 +2,15 @@ import { NextRequest } from "next/server"; import { streamText, generateText, Output } from "ai"; import { z } from "zod"; import { executeWebSearch } from "@/lib/ai/tools/web-search"; -import { randomUUID } from "crypto"; -import { desc, eq, sql } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { requireAuthWithUserInfo } from "@/lib/api/workspace-helpers"; import { db, workspaces } from "@/lib/db/client"; import { generateSlug } from "@/lib/workspace/slug"; import { workspaceWorker, type CreateItemParams } from "@/lib/ai/workers"; import { searchVideos } from "@/lib/youtube"; import { FirecrawlClient } from "@/lib/ai/utils/firecrawl"; -import { findNextAvailablePosition } from "@/lib/workspace-state/grid-layout-helpers"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -import type { Item, QuizQuestion } from "@/lib/workspace-state/types"; +import type { QuizQuestion } from "@/lib/workspace-state/types"; import { quizQuestionInputSchema } from "@/lib/workspace-state/item-data-schemas"; import { materializeQuizQuestion } from "@/lib/workspace-state/quiz-shuffle"; import { CANVAS_CARD_COLORS } from "@/lib/workspace-state/colors"; @@ -45,14 +43,6 @@ function truncateForLog(s: string, max = LOG_TRUNCATE): string { return s.slice(0, max) + "..."; } -/** Layout positions for autogen items (matches desired workspace arrangement) */ -const AUTOGEN_LAYOUTS = { - youtube: { x: 0, y: 0, w: 2, h: 7 }, - document: { x: 2, y: 0, w: 2, h: 9 }, - quiz: { x: 0, y: 7, w: 2, h: 13 }, - pdf: { w: 1, h: 4 }, - image: { w: 2, h: 8 }, -} as const; type FileUrlItem = { url: string; @@ -603,21 +593,6 @@ export async function POST(request: NextRequest) { workspaceId, }); - // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) - // Seed with known content-item positions so files are placed around them (matching pre-restructuring layout) - // Always reserve YouTube footprint so a later searched YouTube item (AUTOGEN_LAYOUTS.youtube) never overlaps early PDFs/images - const pdfItemLayouts: Pick[] = [ - { - type: "document", - layout: AUTOGEN_LAYOUTS.document as Item["layout"], - }, - { type: "quiz", layout: AUTOGEN_LAYOUTS.quiz as Item["layout"] }, - { - type: "youtube", - layout: AUTOGEN_LAYOUTS.youtube as Item["layout"], - }, - ]; - const documentAssets: UploadedAsset[] = documentFileUrls.map((pdf) => createUploadedAsset({ fileUrl: pdf.url, @@ -637,15 +612,9 @@ export async function POST(request: NextRequest) { }), ); + // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) const pdfCreateParams: CreateItemParams[] = []; for (const asset of documentAssets) { - const position = findNextAvailablePosition( - pdfItemLayouts as Item[], - "pdf", - 4, - AUTOGEN_LAYOUTS.pdf.w, - AUTOGEN_LAYOUTS.pdf.h, - ); const pdfItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "pdf") continue; @@ -654,20 +623,11 @@ export async function POST(request: NextRequest) { title: asset.name, itemType: "pdf", pdfData: itemDefinition.initialData as CreateItemParams["pdfData"], - layout: position, }); - pdfItemLayouts.push({ type: "pdf", layout: position }); } const imageCreateParams: CreateItemParams[] = []; for (const asset of imageAssets) { - const position = findNextAvailablePosition( - pdfItemLayouts as Item[], - "image", - 4, - AUTOGEN_LAYOUTS.image.w, - AUTOGEN_LAYOUTS.image.h, - ); const imgItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "image") continue; @@ -677,9 +637,7 @@ export async function POST(request: NextRequest) { itemType: "image", imageData: itemDefinition.initialData as CreateItemParams["imageData"], - layout: position, }); - pdfItemLayouts.push({ type: "image", layout: position }); } const fileCreateParams = [...pdfCreateParams, ...imageCreateParams]; @@ -921,12 +879,10 @@ export async function POST(request: NextRequest) { document: { title: output.document.title, content: output.document.content, - layout: AUTOGEN_LAYOUTS.document, }, quiz: { title: output.quiz.title, questions, - layout: AUTOGEN_LAYOUTS.quiz, }, }; }; @@ -945,7 +901,6 @@ export async function POST(request: NextRequest) { return { title: "YouTube Video", url: youtubeUrlFromLinks, - layout: AUTOGEN_LAYOUTS.youtube, }; } const videos = await searchVideos(youtubeSearchTerm, 3); @@ -958,7 +913,6 @@ export async function POST(request: NextRequest) { return { title: video.title, url: video.url, - layout: AUTOGEN_LAYOUTS.youtube, }; } catch (err) { logger.warn("[AUTOGEN] YouTube search failed", { @@ -984,13 +938,11 @@ export async function POST(request: NextRequest) { title: generatedDocument.title, content: generatedDocument.content, itemType: "document", - layout: generatedDocument.layout, }, { title: quizContent.title, itemType: "quiz", quizData: { questions: quizContent.questions }, - layout: quizContent.layout, }, ...(youtubeResult ? [ @@ -998,7 +950,6 @@ export async function POST(request: NextRequest) { title: youtubeResult.title, itemType: "youtube" as const, youtubeData: { url: youtubeResult.url }, - layout: youtubeResult.layout, }, ] : []), diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index e19b14563..876e70ff2 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -81,8 +81,6 @@ function FolderCardComponent({ const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isDragHover, setIsDragHover] = useState(false); - const [selectedCount, setSelectedCount] = useState(null); const [isEditingTitle, setIsEditingTitle] = useState(false); const [shouldAutoFocus, setShouldAutoFocus] = useState(false); @@ -113,26 +111,6 @@ function FolderCardComponent({ } }, [item.id, item.name]); - // Listen for drag hover events - useEffect(() => { - const handleDragHover = (e: Event) => { - const customEvent = e as CustomEvent; - const { folderId, isHovering, selectedCount: count } = customEvent.detail || {}; - if (folderId === item.id) { - setIsDragHover(isHovering); - setSelectedCount(count ?? null); - } else { - setIsDragHover(false); - setSelectedCount(null); - } - }; - - window.addEventListener('folder-drag-hover', handleDragHover); - return () => { - window.removeEventListener('folder-drag-hover', handleDragHover); - }; - }, [item.id]); - // Handle mouse down - track initial position for drag detection const handleMouseDown = useCallback((e: React.MouseEvent) => { // Don't track if clicking on interactive elements @@ -254,11 +232,9 @@ function FolderCardComponent({
- - {/* Drag hover overlay - shows when item is dragged over folder */} - {isDragHover && ( -
-
- Move items here ({(selectedCount ?? 1)} {(selectedCount ?? 1) === 1 ? 'item' : 'items'}) -
-
- )}
@@ -607,4 +574,3 @@ function FolderCardComponent({ } export const FolderCard = memo(FolderCardComponent); - diff --git a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx index 22c9e5237..de3c63a1d 100644 --- a/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx +++ b/src/components/workspace-canvas/WorkspaceCanvasDropzone.tsx @@ -5,9 +5,8 @@ import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceState } from "@/hooks/workspace/use-workspace-state"; import { useWorkspaceOperations } from "@/hooks/workspace/use-workspace-operations"; import { CgNotes } from "react-icons/cg"; -import { useCallback, useState, useRef, useEffect } from "react"; +import { useCallback, useState, useRef } from "react"; import { toast } from "sonner"; -import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation"; import { buildWorkspaceItemDefinitionsFromAssets, @@ -137,9 +136,7 @@ export function WorkspaceCanvasDropzone({ children }: WorkspaceCanvasDropzonePro try { const { uploads, failedFiles } = await uploadSelectedFiles(filteredFiles); if (uploads.length > 0) { - const itemDefinitions = buildWorkspaceItemDefinitionsFromAssets(uploads, { - imageLayout: DEFAULT_CARD_DIMENSIONS.image, - }); + const itemDefinitions = buildWorkspaceItemDefinitionsFromAssets(uploads); const createdIds = operations.createItems(itemDefinitions, { showSuccessToast: false, }); diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 781ccd844..4952a0b26 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -9,7 +9,6 @@ import { import type { Item, DocumentData } from "@/lib/workspace-state/types"; import type { ColorResult } from "react-color"; import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; -import { getLayoutForBreakpoint } from "@/lib/workspace-state/grid-layout-helpers"; import { ContextMenu, ContextMenuContent, @@ -82,10 +81,11 @@ function WorkspaceCard({ const toggleItemScrollLocked = useUIStore( (state) => state.toggleItemScrollLocked, ); - const layout = getLayoutForBreakpoint(item, "lg"); - - // Derive preview mode from the grid layout instead of observing DOM size. - const shouldShowPreview = (layout?.w ?? 1) > 1 && (layout?.h ?? 4) > 4; + const shouldShowPreview = + item.type === "document" || + item.type === "pdf" || + item.type === "quiz" || + item.type === "audio"; // Track minimal local drag detection (only if grid hasn't detected drag) const mouseDownRef = useRef<{ x: number; y: number } | null>(null); @@ -596,13 +596,6 @@ export const WorkspaceCardMemoized = memo( if (JSON.stringify(prevData) !== JSON.stringify(nextData)) return false; } - // Compare layout (use lg breakpoint for comparison) - const prevLayout = getLayoutForBreakpoint(prevProps.item, "lg"); - const nextLayout = getLayoutForBreakpoint(nextProps.item, "lg"); - if (prevLayout?.x !== nextLayout?.x) return false; - if (prevLayout?.y !== nextLayout?.y) return false; - if (prevLayout?.w !== nextLayout?.w) return false; - if (prevLayout?.h !== nextLayout?.h) return false; // NOTE: isSelected is now subscribed directly from the store, not a prop diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index 1cccec172..3614d0a89 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -5,7 +5,6 @@ import { Plus, Upload } from "lucide-react"; import { EmptyState } from "@/components/empty-state"; import type { Item, CardType } from "@/lib/workspace-state/types"; import { filterItemsByFolder } from "@/lib/workspace-state/search"; -import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; import { transcriptSegmentsQueryKey } from "@/hooks/workspace/use-transcript-segments"; import { AUDIO_COMPLETE_EVENT } from "@/lib/audio/poll-audio-processing"; import { WorkspaceGrid } from "./WorkspaceGrid"; @@ -27,15 +26,11 @@ interface WorkspaceContentProps { ) => string; updateItem: (itemId: string, updates: Partial) => void; deleteItem: (itemId: string) => void; - updateAllItems: (items: Item[]) => void; openWorkspaceItem: (itemId: string | null) => void; - scrollContainerRef?: React.RefObject; - onGridDragStateChange?: (isDragging: boolean) => void; workspaceName?: string; workspaceIcon?: string | null; workspaceColor?: string | null; onMoveItem?: (itemId: string, folderId: string | null) => void; - onMoveItems?: (itemIds: string[], folderId: string | null) => void; onOpenFolder?: (folderId: string) => void; onDeleteFolderWithContents?: (folderId: string) => void; onPDFUpload?: (files: File[]) => Promise; @@ -47,15 +42,11 @@ export default function WorkspaceContent({ addItem, updateItem, deleteItem, - updateAllItems, openWorkspaceItem, - scrollContainerRef: externalScrollContainerRef, - onGridDragStateChange, workspaceName, workspaceIcon, workspaceColor, onMoveItem, - onMoveItems, onOpenFolder, onDeleteFolderWithContents, onPDFUpload, @@ -63,19 +54,9 @@ export default function WorkspaceContent({ }: WorkspaceContentProps) { const queryClient = useQueryClient(); const workspaceId = useWorkspaceStore((state) => state.currentWorkspaceId); - - const localScrollContainerRef = useRef(null); - const scrollContainerRef = - externalScrollContainerRef || localScrollContainerRef; - - const { handleDragStart: onDragStart, handleDragStop: onDragStop } = - useAutoScroll(scrollContainerRef); - const { selectedCardIdsArray } = useSelectedCardIds(); - const activeFolderId = useUIStore((state) => state.activeFolderId); const setActiveFolderId = useUIStore((state) => state.setActiveFolderId); - const fileInputRef = useRef(null); const filteredItems = useMemo(() => { @@ -157,13 +138,6 @@ export default function WorkspaceContent({ [deleteItem], ); - const handleUpdateAllItems = useCallback( - (items: Item[]) => { - updateAllItems(items); - }, - [updateAllItems], - ); - const handleOpenModal = useCallback( (itemId: string) => { openWorkspaceItem(itemId); @@ -241,14 +215,6 @@ export default function WorkspaceContent({ [onPDFUpload], ); - const handleDragStart = useCallback(() => { - onDragStart(); - }, [onDragStart]); - - const handleDragStop = useCallback(() => { - onDragStop(); - }, [onDragStop]); - const isFiltering = activeFolderId !== null; if (viewState.length === 0 || (isFiltering && filteredItems.length === 0)) { @@ -330,20 +296,13 @@ export default function WorkspaceContent({ key={activeFolderId ?? "root"} items={filteredItems} allItems={viewState} - isFiltered={isFiltering} - isTemporaryFilter={false} - onDragStart={handleDragStart} - onDragStop={handleDragStop} onUpdateItem={handleUpdateItem} onDeleteItem={handleDeleteItem} - onUpdateAllItems={handleUpdateAllItems} onOpenModal={handleOpenModal} - onGridDragStateChange={onGridDragStateChange} workspaceName={workspaceName || "Workspace"} workspaceIcon={workspaceIcon} workspaceColor={workspaceColor} onMoveItem={onMoveItem} - onMoveItems={onMoveItems} onOpenFolder={handleOpenFolder} onDeleteFolderWithContents={onDeleteFolderWithContents} /> diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index ddfdc8e15..2ad05632a 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -1,625 +1,80 @@ -import { Responsive as ResponsiveGridLayout, type Layout, type LayoutItem, useContainerWidth } from "react-grid-layout"; -import { wrapCompactor, fastVerticalCompactor } from "react-grid-layout/extras"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { useMemo, useCallback, useRef, useEffect, useState } from "react"; +import { useCallback, useMemo } from "react"; import React from "react"; import type { Item } from "@/lib/workspace-state/types"; -import { itemsToLayout, generateMissingLayouts, updateItemsWithLayout } from "@/lib/workspace-state/grid-layout-helpers"; -import { isDescendantOf } from "@/lib/workspace-state/search"; import { WorkspaceCard } from "./WorkspaceCard"; import { FlashcardWorkspaceCard } from "./FlashcardWorkspaceCard"; import { FolderCard } from "./FolderCard"; -import { useUIStore } from "@/lib/stores/ui-store"; - -const GRID_BREAKPOINTS = { lg: 0 } as const; -const GRID_COLS = { lg: 4 } as const; -const GRID_MARGIN: [number, number] = [16, 16]; -const GRID_CONTAINER_PADDING: [number, number] = [16, 0]; -const GRID_RESIZE_HANDLES = [ - "s", - "w", - "e", - "n", - "se", - "sw", - "ne", - "nw", -] as Array<"s" | "w" | "e" | "n" | "se" | "sw" | "ne" | "nw">; interface WorkspaceGridProps { - items: Item[]; // Filtered items to display (includes folder-type items) - allItems: Item[]; // All items (unfiltered) for layout updates - isFiltered: boolean; // Whether currently in filtered mode - isTemporaryFilter?: boolean; // Whether in temporary filter mode (search) - prevents layout saves - - onDragStart: () => void; - onDragStop: (layout: LayoutItem[]) => void; + items: Item[]; + allItems: Item[]; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; - onUpdateAllItems: (items: Item[]) => void; onOpenModal: (itemId: string) => void; - onGridDragStateChange?: (isDragging: boolean) => void; workspaceName: string; workspaceIcon?: string | null; workspaceColor?: string | null; - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder - onMoveItems?: (itemIds: string[], folderId: string | null) => void; // Callback to move multiple items to folder (bulk move) - onOpenFolder?: (folderId: string) => void; // Callback when folder is clicked - onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside + onMoveItem?: (itemId: string, folderId: string | null) => void; + onOpenFolder?: (folderId: string) => void; + onDeleteFolderWithContents?: (folderId: string) => void; } -/** - * Grid layout component that manages the positioning and layout of workspace cards. - * Handles drag-and-drop, resizing, and layout recalculation. - */ +const GRID_ITEM_HEIGHTS: Record = { + pdf: "min-h-[22rem]", + flashcard: "min-h-[24rem]", + folder: "min-h-[14rem]", + youtube: "min-h-[22rem]", + quiz: "min-h-[28rem]", + image: "min-h-[22rem]", + audio: "min-h-[22rem]", + website: "min-h-[14rem]", + document: "min-h-[22rem]", +}; + function WorkspaceGridComponent({ items, allItems, - isFiltered, - isTemporaryFilter = false, - - onDragStart, - onDragStop, onUpdateItem, onDeleteItem, - onUpdateAllItems, onOpenModal, - onGridDragStateChange, workspaceName, workspaceIcon, workspaceColor, onMoveItem, - onMoveItems, onOpenFolder, onDeleteFolderWithContents, }: WorkspaceGridProps) { - const useWrapCompactor = useFeatureFlagEnabled("wrap-compactor"); - const compactor = useWrapCompactor ? wrapCompactor : fastVerticalCompactor; - - const layoutChangeTimeoutRef = useRef(null); - const hasUserInteractedRef = useRef(false); - const draggedItemIdRef = useRef(null); - const hoveredFolderIdRef = useRef(null); // '__root__' is sentinel for root drop - const clearCardSelection = useUIStore((state) => state.clearCardSelection); - - // Use container width hook for v2 API - const { width, containerRef, mounted } = useContainerWidth(); - - // Track post-mount state to enable transform transitions only after initial layout - const [hasMounted, setHasMounted] = useState(false); - useEffect(() => { - if (!mounted) return; - const raf = requestAnimationFrame(() => { - setHasMounted(true); - }); - return () => cancelAnimationFrame(raf); - }, [mounted]); - - // OPTIMIZED: Store layout in ref to avoid including it in callback dependencies - // This prevents handleDragStop from changing when layout changes, which causes ReactGridLayout re-renders - const layoutRef = useRef([]); - - // OPTIMIZED: Store allItems in ref to avoid callback dependencies - // This prevents callbacks from changing when these values change - const allItemsRef = useRef(allItems); - - // Update refs whenever values change - React.useEffect(() => { - allItemsRef.current = allItems; - }, [allItems]); - - // Generate layouts for items that don't have them. - const itemsWithLayout = useMemo(() => generateMissingLayouts(items, 4), [items]); - - // Display all items (no longer hiding items when panels are open) - const displayItems = itemsWithLayout; - - - // Note: Standard react-grid-layout handles bounds automatically. - // Custom constraints (Youtube height, etc) are handled in onResize callback. - - // Note: Layout is now computed in combinedLayout below to include folders - - // Debounced handler for live updates during drag/resize - // NOTE: We disable this and only save on drag stop to prevent unnecessary saves on clicks - // react-grid-layout fires onLayoutChange even on simple clicks, causing unwanted updates - const handleLayoutChange = useCallback(() => { - // Cancel any pending timeouts - we only save on drag stop now - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - layoutChangeTimeoutRef.current = null; - } - - // Don't save anything from onLayoutChange - handleDragStop handles all saves - // This prevents unwanted BULK-UPDATE events on simple clicks - return; - }, []); - - // Handle drag start - with RGL v2, this only fires after 3px movement (real drag, not click) - const handleDragStart = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { - const oldItem = args[1]; - const e = args[4]; - // Check if the click originated from a dropdown menu - if so, don't start drag - const target = e.target as HTMLElement; - if ( - target.closest('[data-slot="dropdown-menu-item"]') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[role="menuitem"]') - ) { - // Prevent drag by not setting draggedItemIdRef - return; - } - - // Extract item ID from the element - if (!oldItem) return; // Safety check for null oldItem - const itemId = oldItem.i; - draggedItemIdRef.current = itemId; - hoveredFolderIdRef.current = null; // Reset hover state - - hasUserInteractedRef.current = true; - onDragStart(); - onGridDragStateChange?.(true); - }, [onDragStart, onGridDragStateChange]); - - // Handle drag to detect folder hover based on cursor position - const handleDrag = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { - const e = args[4]; - const draggedItemId = draggedItemIdRef.current; - if (!draggedItemId || !e) return; - - - - const draggedItem = allItemsRef.current.find(i => i.id === draggedItemId); - if (!draggedItem) { - if (hoveredFolderIdRef.current !== null) { - hoveredFolderIdRef.current = null; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: null, isHovering: false } - })); - } - return; - } - - // Cast Event to MouseEvent to access clientX/clientY - const mouseEvent = e as MouseEvent; - const cursorX = mouseEvent.clientX; - const cursorY = mouseEvent.clientY; - - // Find all folder cards and check if cursor is within any folder's bounding box - // Use elementFromPoint first for more accurate detection, then fallback to bounding box - let hoveredFolder: string | null = null; - - // First, try elementFromPoint for more accurate detection - const elementAtPoint = document.elementFromPoint(cursorX, cursorY); - if (elementAtPoint) { - // Check if the element or its parents have data-folder-id - const folderElement = elementAtPoint.closest('[data-folder-id]') as HTMLElement; - if (folderElement) { - const folderId = folderElement.getAttribute('data-folder-id'); - if (folderId && folderId !== draggedItemId) { - const folderItem = allItemsRef.current.find(i => i.id === folderId && i.type === 'folder'); - if (folderItem) { - hoveredFolder = folderId; - } - } - } - } - - // Fallback to bounding box check if elementFromPoint didn't find anything - if (!hoveredFolder) { - // Get all folder items from all items - const folderItems = allItemsRef.current.filter(item => item.type === 'folder'); - - // Check each folder card's bounding box - for (const folderItem of folderItems) { - // Skip if this is the folder being dragged - if (folderItem.id === draggedItemId) continue; - - // Find the folder card element by its ID - const folderCardElement = document.querySelector(`[data-folder-id="${folderItem.id}"]`) as HTMLElement; - if (!folderCardElement) continue; - - // Get bounding box of the folder card - const rect = folderCardElement.getBoundingClientRect(); - - // Check if cursor is within the folder card's bounds - if ( - cursorX >= rect.left && - cursorX <= rect.right && - cursorY >= rect.top && - cursorY <= rect.bottom - ) { - // Validate before setting - check if already in this folder - if (draggedItem.folderId !== folderItem.id) { - hoveredFolder = folderItem.id; - break; - } - } - } - } - - // Check breadcrumb elements if no folder card is hovered - if (!hoveredFolder) { - // Find all breadcrumb target elements - const breadcrumbTargets = document.querySelectorAll('[data-breadcrumb-target]'); - - for (const target of breadcrumbTargets) { - // Skip if element is not visible - const rect = target.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) continue; - - // Check if cursor is within the breadcrumb element's bounds - if ( - cursorX >= rect.left && - cursorX <= rect.right && - cursorY >= rect.top && - cursorY <= rect.bottom - ) { - const targetType = target.getAttribute('data-breadcrumb-target'); - - if (targetType === 'root') { - // Workspace root drop - only valid if dragged item is in a folder - if (draggedItem.folderId) { - hoveredFolder = '__root__'; // Special value for root - break; - } - } else if (targetType === 'folder') { - const folderId = target.getAttribute('data-folder-id'); - if (folderId) { - // Validate the drop target - let isValidTarget = true; - - // Check if dragging folder onto itself - if (draggedItemId === folderId) { - isValidTarget = false; - } else if (draggedItem.type === 'folder') { - // Check for circular references - if (isDescendantOf(folderId, draggedItemId, allItemsRef.current)) { - isValidTarget = false; - } - } - - // Check if already in target folder - if (draggedItem.folderId === folderId) { - isValidTarget = false; - } - - if (isValidTarget) { - hoveredFolder = folderId; - break; - } - } - } - } - } - } - - // If dragging a folder onto another folder, check for circular references - // (This check is already done in breadcrumb validation, but needed here for folder cards) - if (draggedItem.type === 'folder' && hoveredFolder && hoveredFolder !== '__root__') { - // Prevent dropping folder onto itself - if (draggedItemId === hoveredFolder) { - hoveredFolder = null; - } else { - // Prevent dropping folder onto its descendant - if (isDescendantOf(hoveredFolder, draggedItemId, allItemsRef.current)) { - hoveredFolder = null; - } - } - } - - // Check if already in target location (for both folder cards and breadcrumbs) - if (hoveredFolder && hoveredFolder !== '__root__') { - if (draggedItem.folderId === hoveredFolder) { - hoveredFolder = null; - } - } else if (hoveredFolder === '__root__') { - // For root drops, only valid if item is currently in a folder - if (!draggedItem.folderId) { - hoveredFolder = null; - } - } - - // Calculate selected count - if dragged card is selected, count all selected, otherwise just 1 - const selectedCardIds = useUIStore.getState().selectedCardIds; - const isDraggedCardSelected = selectedCardIds.has(draggedItemId); - const selectedCount = isDraggedCardSelected ? selectedCardIds.size : 1; - - // Update hover state if changed - if (hoveredFolder !== hoveredFolderIdRef.current) { - hoveredFolderIdRef.current = hoveredFolder; - // Convert '__root__' sentinel to null for event (header expects null for root) - const eventFolderId = hoveredFolder === '__root__' ? null : hoveredFolder; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: eventFolderId, isHovering: hoveredFolder !== null, selectedCount } - })); - } else if (hoveredFolder !== null) { - // Even if folder hasn't changed, update selected count in case selection changed during drag - // Convert '__root__' sentinel to null for event (header expects null for root) - const eventFolderId = hoveredFolder === '__root__' ? null : hoveredFolder; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: eventFolderId, isHovering: true, selectedCount } - })); - } - }, []); - - // Handle resize start - track which item is being resized - const handleResizeStart = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { - const oldItem = args[1]; - hasUserInteractedRef.current = true; - // Track which item is being resized (same as drag) - if (!oldItem) return; - draggedItemIdRef.current = oldItem.i; - // Enable autoscroll during resize to help with grid expansion - onDragStart(); - onGridDragStateChange?.(true); - }, [onDragStart, onGridDragStateChange]); - - // Handle drag stop - with RGL v2, this only fires for actual drags (not clicks) - // Click handling is now done by individual card components via their onClick handlers - const handleDragStop = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { - const newLayout = args[0]; - const draggedItemId = draggedItemIdRef.current; - - // If no drag was started (e.g., click on dropdown), exit early - if (!draggedItemId) { - onDragStop([]); - onGridDragStateChange?.(false); - return; - } - - // Find the item - // Cancel any pending debounced update - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - } - - // Always call onDragStop to reset auto-scroll state - onDragStop(newLayout.length > 0 && !isFiltered ? [...newLayout] : []); - - // Don't save layout if in temporary filter mode (search) - if (isTemporaryFilter) { - hoveredFolderIdRef.current = null; - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - return; - } - - // Handle folder drop - if dropping on a folder or root, move the item (works for both items and folders) - const hoveredFolderId = hoveredFolderIdRef.current; - if (hoveredFolderId !== null && draggedItemId) { - // Check if dragged card is part of selection - const selectedCardIds = useUIStore.getState().selectedCardIds; - const isDraggedCardSelected = selectedCardIds.has(draggedItemId); - const cardsToMove = isDraggedCardSelected - ? Array.from(selectedCardIds) - : [draggedItemId]; - - // Convert '__root__' sentinel to null for root drops - const targetFolderId = hoveredFolderId === '__root__' ? null : hoveredFolderId; - - // Filter out invalid moves (already in folder, circular references, etc.) - const validCardsToMove: string[] = []; - for (const cardId of cardsToMove) { - const card = allItemsRef.current.find(i => i.id === cardId); - if (!card) continue; - - // Skip if already in target location - if (card.folderId === targetFolderId) continue; - - // For root drops, only valid if item is currently in a folder - if (targetFolderId === null && !card.folderId) { - continue; // Already at root - } - - // Check circular references for folders - if (card.type === 'folder' && targetFolderId !== null) { - if (cardId === targetFolderId || - isDescendantOf(targetFolderId, cardId, allItemsRef.current)) { - continue; // Skip invalid moves - } - } - - validCardsToMove.push(cardId); - } - - // Use bulk move if multiple cards, otherwise use single move - // Only proceed if we have at least one move handler - if (validCardsToMove.length > 0 && (onMoveItem || onMoveItems)) { - // Hide the card elements to prevent React Grid Layout from animating them back - // Only hide if we're actually going to move them (handlers exist) - for (const cardId of validCardsToMove) { - const cardElement = document.querySelector(`[id="item-${cardId}"]`)?.closest('.react-grid-item') as HTMLElement; - if (cardElement) { - cardElement.style.display = 'none'; - cardElement.style.visibility = 'hidden'; - cardElement.style.opacity = '0'; - cardElement.style.pointerEvents = 'none'; - } - } - - if (validCardsToMove.length === 1 && onMoveItem) { - // Single item move - onMoveItem(validCardsToMove[0], targetFolderId); - } else if (validCardsToMove.length > 1 && onMoveItems) { - // Bulk move - onMoveItems(validCardsToMove, targetFolderId); - } else if (onMoveItem) { - // Fallback to single move if bulk not available - validCardsToMove.forEach(cardId => onMoveItem(cardId, targetFolderId)); - } - - // Clear selection after successful move (only if we moved selected cards) - if (isDraggedCardSelected) { - clearCardSelection(); - } - } - - // Clear state and exit - hoveredFolderIdRef.current = null; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: null, isHovering: false } - })); - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - return; - } - - // Check if layout actually changed (position or size) - let layoutChanged = false; - const newItemLayout = newLayout.find(l => l.i === draggedItemId); - const currentItemLayout = layoutRef.current.find(l => l.i === draggedItemId); - - if (newItemLayout && currentItemLayout) { - layoutChanged = - newItemLayout.x !== currentItemLayout.x || - newItemLayout.y !== currentItemLayout.y || - newItemLayout.w !== currentItemLayout.w || - newItemLayout.h !== currentItemLayout.h; - } - - if (layoutChanged) { - const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout]); - onUpdateAllItems(updatedItems); - } - - // Clear hover state - if (hoveredFolderIdRef.current !== null) { - hoveredFolderIdRef.current = null; - window.dispatchEvent(new CustomEvent('folder-drag-hover', { - detail: { folderId: null, isHovering: false } - })); - } - - // Clear the dragged item reference - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - }, [clearCardSelection, onDragStop, isFiltered, isTemporaryFilter, onGridDragStateChange, onUpdateAllItems, onMoveItem, onMoveItems]); - - // Handle resize to enforce constraints - // Note cards can transition between compact (w=1, h=4) and expanded (w>=2, h>=9) modes - // based on EITHER width or height changes, allowing vertical-only resizing to trigger mode switches - const handleResize = useCallback((...args: [Layout, LayoutItem | null, LayoutItem | null, LayoutItem | null, Event, HTMLElement | null]) => { - const oldItem = args[1]; - const newItem = args[2]; - const placeholder = args[3]; - // Normal workspace mode: enforce custom constraints - if (!newItem || !oldItem) return; - const itemData = allItemsRef.current.find(i => i.id === newItem.i); - - if (itemData) { - if (itemData.type === 'image') { - // No aspect ratio snapping - free resize within min/max bounds - } else if (itemData.type === 'youtube') { - // At w=1: force h=4 (matches compact document/PDF cards). At w=2: force h=7 - if (newItem.w === 1) { - newItem.h = 4; - } else { - newItem.h = 7; - } - } else if (itemData.type === 'folder' || itemData.type === 'flashcard') { - // Folders and flashcards don't need minimum height enforcement - skip - } else if (itemData.type === 'document' || itemData.type === 'pdf' || itemData.type === 'quiz' || itemData.type === 'audio') { - // Note, Document, PDF, Quiz, and Audio (recording) cards: handle transitions between compact and expanded modes - // Note/Document/Audio cards: Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=9 - // PDF cards: Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=6 - // Quiz cards: Compact mode: w=1, h=4 | Expanded mode: w>=2, h>=13 - const wasCompact = oldItem.w === 1; - const widthChanged = oldItem.w !== newItem.w; - const minExpandedHeight = itemData.type === 'pdf' ? 6 : itemData.type === 'quiz' ? 13 : 9; - - // Check for mode transitions triggered by height-only resize - if (!widthChanged) { - if (wasCompact && newItem.h > 4) { - // Growing a compact card taller → expand to wide mode - newItem.w = 2; - } else if (!wasCompact && newItem.h < minExpandedHeight) { - // Shrinking a wide card shorter → collapse to compact mode - newItem.w = 1; - } - } - - // Apply constraints based on final width - if (newItem.w >= 2) { - newItem.h = Math.max(newItem.h, minExpandedHeight); - } else { - newItem.h = 4; - } - } - - // Sync placeholder if it exists - if (placeholder) { - placeholder.w = newItem.w; - placeholder.h = newItem.h; - } - - // Clamp position to prevent off-screen glitches when resizing from left/west or top/north handles. - // react-grid-layout can set negative x/y when dragging those edges; we clamp to grid bounds. - newItem.x = Math.max(0, Math.min(4 - newItem.w, newItem.x)); - newItem.y = Math.max(0, newItem.y); - if (placeholder) { - placeholder.x = newItem.x; - placeholder.y = newItem.y; - } - } - }, []); - - // Handle resize stop - save immediately - const handleResizeStop = useCallback((newLayout: Layout) => { - // Cancel any pending debounced update - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - } - - // Note: resize doesn't use autoscroll, but we call onDragStop anyway as a safety measure - // in case there's any edge case where drag state got stuck - onDragStop([]); - - // Don't save if in temporary filter mode (search), but folder views should save - if (isTemporaryFilter) { - onGridDragStateChange?.(false); - draggedItemIdRef.current = null; - return; - } - - // For resize, we always save since resize always changes layout - // Folders are now items with type: 'folder', so they're included in updateItemsWithLayout - - const updatedItems = updateItemsWithLayout(allItemsRef.current, [...newLayout]); - onUpdateAllItems(updatedItems); - - draggedItemIdRef.current = null; - onGridDragStateChange?.(false); - }, [isTemporaryFilter, onUpdateAllItems, onGridDragStateChange, onDragStop]); - - - // Handle item updates - no automatic height recalculation - // Height calculations are only used during initial card placement - // Users can manually resize cards as needed - // OPTIMIZED: Wrap parent callback to ensure stable reference - const handleUpdateItem = useCallback((itemId: string, updates: Partial) => { - onUpdateItem(itemId, updates); - }, [onUpdateItem]); + const handleUpdateItem = useCallback( + (itemId: string, updates: Partial) => { + onUpdateItem(itemId, updates); + }, + [onUpdateItem], + ); - // OPTIMIZED: Wrap all callbacks to ensure stable references - const handleDeleteItem = useCallback((itemId: string) => { - onDeleteItem(itemId); - }, [onDeleteItem]); + const handleDeleteItem = useCallback( + (itemId: string) => { + onDeleteItem(itemId); + }, + [onDeleteItem], + ); - const handleOpenModal = useCallback((itemId: string) => { - onOpenModal(itemId); - }, [onOpenModal]); + const handleOpenModal = useCallback( + (itemId: string) => { + onOpenModal(itemId); + }, + [onOpenModal], + ); - // Folder operation handler (folders are now items with type: 'folder') - const handleOpenFolder = useCallback((folderId: string) => { - onOpenFolder?.(folderId); - }, [onOpenFolder]); + const handleOpenFolder = useCallback( + (folderId: string) => { + onOpenFolder?.(folderId); + }, + [onOpenFolder], + ); - // Create a map of folder item counts (for folder-type items) const folderItemCounts = useMemo(() => { const counts = new Map(); - allItems.forEach(item => { + allItems.forEach((item) => { if (item.folderId) { counts.set(item.folderId, (counts.get(item.folderId) || 0) + 1); } @@ -627,46 +82,50 @@ function WorkspaceGridComponent({ return counts; }, [allItems]); - // Layout for all items (including folder-type items) - const combinedLayout = useMemo(() => { - return itemsToLayout(displayItems); - }, [displayItems]); + const children = useMemo(() => { + return items.map((item) => { + const wrapperClass = `min-w-0 ${GRID_ITEM_HEIGHTS[item.type]}`; + + if (item.type === "folder") { + return ( +
+ +
+ ); + } - useEffect(() => { - layoutRef.current = combinedLayout; - }, [combinedLayout]); + if (item.type === "flashcard") { + return ( +
+ +
+ ); + } - // Memoize children to take advantage of ResponsiveGridLayout's shouldComponentUpdate optimization - const children = useMemo(() => { - return displayItems.map((item) => ( -
- {item.type === 'folder' ? ( - - ) : item.type === 'flashcard' ? ( - - ) : ( + return ( +
- )} -
- )); +
+ ); + }); }, [ allItems, - displayItems, - handleUpdateItem, + folderItemCounts, handleDeleteItem, + handleOpenFolder, handleOpenModal, - onMoveItem, + handleUpdateItem, + items, onDeleteFolderWithContents, - handleOpenFolder, - folderItemCounts, - workspaceName, - workspaceIcon, + onMoveItem, workspaceColor, + workspaceIcon, + workspaceName, ]); - // Cleanup on unmount - ensure drag state is reset - useEffect(() => { - return () => { - if (layoutChangeTimeoutRef.current) { - clearTimeout(layoutChangeTimeoutRef.current); - } - // Ensure autoscroll state is cleaned up on unmount - // This handles edge cases where component unmounts during drag - onDragStop([]); - onGridDragStateChange?.(false); - }; - }, [onDragStop, onGridDragStateChange]); - - const layouts = useMemo(() => ({ - lg: combinedLayout, - }), [combinedLayout]); - return ( -
- - {mounted && ( - - {children} - - )} +
+
+ {children} +
); } diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index a89b19bb5..1069e4386 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -64,7 +64,6 @@ import { exportMarkdownToGoogleDoc, getGoogleOAuthClientId, } from "@/lib/exportToGoogleDocs"; -import { DEFAULT_CARD_DIMENSIONS } from "@/lib/workspace-state/grid-layout-helpers"; import { getFolderPath } from "@/lib/workspace-state/search"; import { CreateYouTubeDialog } from "@/components/modals/CreateYouTubeDialog"; import { CreateWebsiteDialog } from "@/components/modals/CreateWebsiteDialog"; @@ -87,8 +86,6 @@ const BREADCRUMB_ITEM_TEXT_CLASS = "min-w-0 max-w-[240px] truncate text-sidebar-foreground"; const BREADCRUMB_INTERACTIVE_CLASS = "cursor-pointer text-sidebar-foreground/75 hover:text-sidebar-foreground hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring/60"; -const BREADCRUMB_DRAG_TARGET_CLASS = - "bg-blue-500/10 text-sidebar-foreground ring-1 ring-inset ring-blue-500/50"; const BREADCRUMB_MENU_ITEM_CLASS = "flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer"; /** Shared shell for header toolbar controls — same border/hover as labeled actions; icon-only uses square hit target */ @@ -241,7 +238,6 @@ interface WorkspaceHeaderProps { type: CardType, name?: string, initialData?: Partial, - initialLayout?: { w: number; h: number }, ) => string; onPDFUpload?: (files: File[]) => Promise; // Callback for when items are created (for auto-scroll/selection) @@ -316,11 +312,6 @@ export function WorkspaceHeader({ const pathname = usePathname(); const isWorkspaceRoute = pathname.startsWith("/workspace"); - // Track drag hover state for breadcrumb elements - const [hoveredBreadcrumbTarget, setHoveredBreadcrumbTarget] = useState< - string | null - >(null); // 'root' or folderId - const isDraggingRef = useRef(false); const breadcrumbNavRef = useRef(null); const breadcrumbMeasureRefs = useRef>({}); const breadcrumbSeparatorMeasureRef = useRef(null); @@ -627,18 +618,13 @@ export function WorkspaceHeader({ ); const renderRootBreadcrumb = useCallback(() => { - const rootHighlightClass = - hoveredBreadcrumbTarget === "root" && BREADCRUMB_DRAG_TARGET_CLASS; - if (activeFolderId || activeOpenWorkspaceItem) { return (
diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 06b415c89..3325e611e 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -14,10 +14,6 @@ import { getRandomCardColor } from "@/lib/workspace-state/colors"; import { logger } from "@/lib/utils/logger"; import { mutators } from "@/lib/zero/mutators"; import { useUIStore } from "@/lib/stores/ui-store"; -import { - getLayoutForBreakpoint, - findNextAvailablePosition, -} from "@/lib/workspace-state/grid-layout-helpers"; import { hasDuplicateName, getNextUniqueDefaultName, @@ -106,14 +102,12 @@ export interface WorkspaceOperations { type: CardType, name?: string, initialData?: Partial, - initialLayout?: { w: number; h: number }, ) => string; createItems: ( items: Array<{ type: CardType; name?: string; initialData?: Partial; - initialLayout?: { w: number; h: number }; }>, options?: { showSuccessToast?: boolean }, ) => string[]; @@ -369,7 +363,6 @@ export function useWorkspaceOperations( type: CardType, name?: string, initialData?: Partial, - initialLayout?: { w: number; h: number }, ) => { const validTypes: CardType[] = [ "pdf", @@ -395,20 +388,6 @@ export function useWorkspaceOperations( ? { ...baseData, ...initialData } : baseData; - let layout: Item["layout"] = undefined; - if (initialLayout) { - const itemsInView = currentItemsRef.current.filter((item) => - activeFolderId ? item.folderId === activeFolderId : !item.folderId, - ); - const position = findNextAvailablePosition( - itemsInView, - validType, - 4, - initialLayout.w, - initialLayout.h, - ); - layout = { lg: position }; - } const item = sanitizeWorkspaceItemForPersistence({ id, @@ -418,7 +397,6 @@ export function useWorkspaceOperations( data: mergedData as ItemData, color: getRandomCardColor(), folderId: activeFolderId ?? undefined, - ...(layout && { layout }), }); if (workspaceIdRef.current) { @@ -434,7 +412,6 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, - layout: (item.layout as JsonObject | undefined) ?? null, }, }), ); @@ -451,7 +428,6 @@ export function useWorkspaceOperations( type: CardType; name?: string; initialData?: Partial; - initialLayout?: { w: number; h: number }; }>, options?: { showSuccessToast?: boolean }, ): string[] => { @@ -460,14 +436,10 @@ export function useWorkspaceOperations( } const activeFolderId = useUIStore.getState().activeFolderId; - const itemsInCurrentView = currentItemsRef.current.filter((item) => - activeFolderId ? item.folderId === activeFolderId : !item.folderId, - ); - const itemsForLayout = [...itemsInCurrentView]; const itemsSoFar: Item[] = []; const createdItems: Item[] = items - .map(({ type, name, initialData, initialLayout }) => { + .map(({ type, name, initialData }) => { const validTypes: CardType[] = [ "pdf", "flashcard", @@ -482,7 +454,7 @@ export function useWorkspaceOperations( const validType = validTypes.includes(type) ? type : "document"; const id = crypto.randomUUID(); const folderId = activeFolderId ?? null; - const allItemsSoFar = [...itemsInCurrentView, ...itemsSoFar]; + const allItemsSoFar = [...currentItemsRef.current, ...itemsSoFar]; const finalName = name || getNextUniqueDefaultName(allItemsSoFar, validType, folderId); @@ -499,28 +471,6 @@ export function useWorkspaceOperations( ? { ...baseData, ...initialData } : baseData; - let layout: Item["layout"] | undefined; - if (initialLayout) { - const position = findNextAvailablePosition( - itemsForLayout, - validType, - 4, - initialLayout.w, - initialLayout.h, - ); - - layout = { lg: position }; - itemsForLayout.push({ - id, - type: validType, - name: finalName, - subtitle: "", - data: baseData as ItemData, - color: getRandomCardColor(), - folderId: activeFolderId ?? undefined, - layout, - }); - } const newItem = sanitizeWorkspaceItemForPersistence({ id, @@ -530,7 +480,6 @@ export function useWorkspaceOperations( data: mergedData as ItemData, color: getRandomCardColor(), folderId: activeFolderId ?? undefined, - layout, }); itemsSoFar.push(newItem); return newItem; @@ -560,7 +509,6 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, - layout: (item.layout as JsonObject | undefined) ?? null, })), }), ); @@ -686,7 +634,6 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, - layout: (item.layout as JsonObject | undefined) ?? null, })), previousItemCount, }), @@ -695,49 +642,6 @@ export function useWorkspaceOperations( return; } - const layoutUpdates: Array<{ - id: string; - x: number; - y: number; - w: number; - h: number; - }> = []; - const currentItemsMap = new Map(latestState.map((item) => [item.id, item])); - - for (const item of items) { - const currentItem = currentItemsMap.get(item.id); - const currentLayout = currentItem - ? getLayoutForBreakpoint(currentItem, "lg") - : undefined; - const newLayout = getLayoutForBreakpoint(item, "lg"); - - if ( - newLayout && - (!currentLayout || - currentLayout.x !== newLayout.x || - currentLayout.y !== newLayout.y || - currentLayout.w !== newLayout.w || - currentLayout.h !== newLayout.h) - ) { - layoutUpdates.push({ - id: item.id, - x: newLayout.x, - y: newLayout.y, - w: newLayout.w, - h: newLayout.h, - }); - } - } - - if (layoutUpdates.length > 0) { - zeroRef.current.mutate( - mutators.item.updateMany({ - workspaceId: currentWorkspaceId, - layoutUpdates, - previousItemCount, - }), - ); - } }, []); const createFolder = useCallback( @@ -767,7 +671,6 @@ export function useWorkspaceOperations( data: folder.data as JsonObject, color: folder.color ?? null, folderId: folder.folderId ?? null, - layout: null, }, }), ); @@ -817,7 +720,6 @@ export function useWorkspaceOperations( data: folder.data as JsonObject, color: folder.color ?? null, folderId: folder.folderId ?? null, - layout: null, }, itemIds: safeItemIds, }), diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 558a8c249..722b971df 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -76,7 +76,6 @@ export type CreateItemParams = { }; sources?: Array<{ title: string; url: string; favicon?: string }>; folderId?: string; - layout?: { x: number; y: number; w: number; h: number }; }; /** @@ -178,7 +177,6 @@ async function buildItemFromCreateParams(p: CreateItemParams): Promise { data: itemData, color: getRandomCardColor(), folderId: p.folderId, - ...(p.layout && { layout: p.layout }), }; } @@ -304,8 +302,7 @@ export async function workspaceWorker( }>; folderId?: string; /** Optional layout { x, y, w, h } for the item (lg breakpoint) */ - layout?: { x: number; y: number; w: number; h: number }; - }, + }, ): Promise<{ success: boolean; message: string; @@ -991,13 +988,13 @@ export async function workspaceWorker( ); await deleteWorkspaceItem(params.workspaceId, params.itemId); - return { - success: true, - itemId: params.itemId, - message: existingItem - ? `Deleted \"${existingItem.name}\" successfully` - : "Deleted item successfully", - }; + return { + success: true, + itemId: params.itemId, + message: existingItem + ? `Deleted "${existingItem.name}" successfully` + : "Deleted item successfully", + }; } // Fallback for unhandled actions diff --git a/src/lib/uploads/uploaded-asset.ts b/src/lib/uploads/uploaded-asset.ts index 3787c6446..a7fe7b740 100644 --- a/src/lib/uploads/uploaded-asset.ts +++ b/src/lib/uploads/uploaded-asset.ts @@ -24,19 +24,16 @@ export type WorkspaceItemDefinition = type: "pdf"; name: string; initialData: Partial; - initialLayout?: { w: number; h: number }; } | { type: "image"; name: string; initialData: Partial; - initialLayout?: { w: number; h: number }; } | { type: "audio"; name: string; initialData: Partial; - initialLayout?: { w: number; h: number }; }; function getBaseName(filename: string): string { @@ -95,7 +92,6 @@ export function createUploadedAsset(params: { export function buildWorkspaceItemDefinitionFromAsset( asset: UploadedAsset, - options?: { imageLayout?: { w: number; h: number } } ): WorkspaceItemDefinition { if (asset.kind === "image") { return { @@ -107,7 +103,6 @@ export function buildWorkspaceItemDefinitionFromAsset( ocrStatus: "processing", ocrPages: [], }, - ...(options?.imageLayout ? { initialLayout: options.imageLayout } : {}), }; } @@ -140,20 +135,17 @@ export function buildWorkspaceItemDefinitionFromAsset( export function buildWorkspaceItemDefinitionsFromAssets( assets: UploadedAsset[], - options?: { imageLayout?: { w: number; h: number } } ): WorkspaceItemDefinition[] { - return assets.map((asset) => - buildWorkspaceItemDefinitionFromAsset(asset, options) - ); + return assets.map((asset) => buildWorkspaceItemDefinitionFromAsset(asset)); } export function buildOcrCandidatesFromAssets( assets: UploadedAsset[], - itemIds: Array + itemIds: Array, ): OcrCandidate[] { if (assets.length !== itemIds.length) { throw new Error( - `Expected assets and itemIds to align, received ${assets.length} assets and ${itemIds.length} itemIds` + `Expected assets and itemIds to align, received ${assets.length} assets and ${itemIds.length} itemIds`, ); } @@ -183,7 +175,7 @@ export function buildOcrCandidatesFromAssets( contentType: asset.contentType, fileSize: asset.fileSize, displayName: asset.displayName, - }) + }), ); if (sourceUrl) { diff --git a/src/lib/workspace-state/grid-layout-helpers.ts b/src/lib/workspace-state/grid-layout-helpers.ts deleted file mode 100644 index d442c9938..000000000 --- a/src/lib/workspace-state/grid-layout-helpers.ts +++ /dev/null @@ -1,390 +0,0 @@ -import type { LayoutItem } from "react-grid-layout"; -import type { Item, CardType, LayoutPosition, ResponsiveLayouts } from "./types"; - -/** - * Default dimensions for each card type in grid units - */ -export const DEFAULT_CARD_DIMENSIONS: Record = { - pdf: { w: 1, h: 4 }, - flashcard: { w: 2, h: 5 }, - folder: { w: 1, h: 4 }, - youtube: { w: 2, h: 7 }, - quiz: { w: 2, h: 13 }, - image: { w: 2, h: 5 }, - audio: { w: 2, h: 10 }, - website: { w: 1, h: 4 }, - document: { w: 1, h: 4 }, -}; - -/** - * Helper to check if a layout is in the old flat format or new responsive format. - * Old format: { x, y, w, h } - * New format: { lg?: {...} } - */ -function isLegacyLayout(layout: ResponsiveLayouts | LayoutPosition | undefined): layout is LayoutPosition { - if (!layout) return false; - // If it has 'x' property directly, it's the old flat format - return 'x' in layout && typeof layout.x === 'number'; -} - -/** - * Get the layout for the workspace grid from an item. - * Handles backwards compatibility with old flat layout format. - */ -export function getLayoutForBreakpoint(item: Item, breakpoint: 'lg' = 'lg'): LayoutPosition | undefined { - if (!item.layout) return undefined; - - if (isLegacyLayout(item.layout)) { - return item.layout; - } - - return item.layout[breakpoint]; -} - -export const DEFAULT_COLS = 4; - -/** - * Convert items to LayoutItem array for the workspace grid. - */ -export function itemsToLayout(items: Item[], breakpoint: 'lg' = 'lg'): LayoutItem[] { - return items.map((item) => { - const layout = getLayoutForBreakpoint(item, breakpoint); - - // YouTube: resizable smaller but not larger than 2x7; at w=1 force h=4 (matches compact text cards) - if (item.type === 'youtube') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, - minH: 4, - maxW: 2, - maxH: 7, - }; - } - - // Images: free resize within min/max bounds - if (item.type === 'image') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, // Allow narrow 1-column images - minH: 3, // Minimum height for 1-col images (fits 4:3 and 3:2 roughly) - maxW: 4, - maxH: 20, // Increased to support tall/square images - }; - } - - // Folders are anchor items - they act as obstacles but can be dragged/resized - if (item.type === 'folder') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, - minH: 4, - maxW: 4, - maxH: 25, - anchor: true, // Anchor items act as obstacles but can be moved - }; - } - - // Website cards are not resizable - fixed size, draggable only - if (item.type === 'website') { - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - isResizable: false, // Not resizable - }; - } - - // Default constraints for other card types - return { - i: item.id, - x: layout?.x ?? 0, - y: layout?.y ?? 0, - w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, - h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, - minW: 1, - minH: 4, - maxW: 4, - maxH: 25, - }; - }); -} - -export function findNextAvailablePosition( - existingItems: Item[], - newItemType: CardType, - cols: number = DEFAULT_COLS, - customW?: number, - customH?: number, - breakpoint: 'lg' = 'lg' -): { x: number; y: number; w: number; h: number } { - const validType = (newItemType in DEFAULT_CARD_DIMENSIONS) ? newItemType : 'document'; - const dimensions = DEFAULT_CARD_DIMENSIONS[validType]; - const w = customW ?? Math.min(dimensions.w, cols); - const h = customH ?? dimensions.h; - - if (existingItems.length === 0) { - return { x: 0, y: 0, w, h }; - } - - // Build an occupancy grid so we can detect gaps between items, not just column bottoms. - // Find the maximum Y (bottom edge) across all existing items. - let maxY = 0; - const occupiedRects: { x: number; y: number; w: number; h: number }[] = []; - - existingItems.forEach((item) => { - const layout = getLayoutForBreakpoint(item, breakpoint); - const ix = layout?.x ?? 0; - const iy = layout?.y ?? 0; - const iw = layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type]?.w ?? 1; - const ih = layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type]?.h ?? 4; - occupiedRects.push({ x: ix, y: iy, w: iw, h: ih }); - maxY = Math.max(maxY, iy + ih); - }); - - // Check if a w×h block at (startX, startY) overlaps any occupied rect - function isPositionFree(startX: number, startY: number): boolean { - for (const rect of occupiedRects) { - // Two rectangles overlap if they overlap on both axes - if ( - startX < rect.x + rect.w && - startX + w > rect.x && - startY < rect.y + rect.h && - startY + h > rect.y - ) { - return false; - } - } - return true; - } - - // Scan row-by-row, column-by-column for the first position that fits. - // This naturally fills gaps left by deleted or moved items. - // We scan up to maxY (to find gaps) and then one row beyond (guaranteed empty). - for (let y = 0; y <= maxY; y++) { - for (let x = 0; x <= cols - w; x++) { - if (isPositionFree(x, y)) { - return { x, y, w, h }; - } - } - } - - // Fallback: place below everything (should always be reached by the loop above at y=maxY) - return { x: 0, y: maxY, w, h }; -} - -/** - * Generate missing layouts for items that don't have them. - * Works with the workspace grid layout by default. - */ -export function generateMissingLayouts(items: Item[], cols: number = DEFAULT_COLS, breakpoint: 'lg' = 'lg'): Item[] { - const result: Item[] = []; - - items.forEach((item) => { - const existingLayout = getLayoutForBreakpoint(item, breakpoint); - - if (existingLayout) { - // Clamp width first, then compute x based on the clamped width - const clampedW = Math.min(existingLayout.w, cols); - const adjustedLayout = { - ...existingLayout, - w: clampedW, - x: Math.max(0, Math.min(existingLayout.x, cols - clampedW)), - }; - - // Preserve the responsive structure - const newLayouts: ResponsiveLayouts = isLegacyLayout(item.layout) - ? { [breakpoint]: adjustedLayout } - : { ...item.layout as ResponsiveLayouts, [breakpoint]: adjustedLayout }; - - result.push({ - ...item, - layout: newLayouts, - }); - } else { - const position = findNextAvailablePosition( - result, - item.type, - cols, - ); - - // For items without layout, create new responsive structure - const existingResponsive = isLegacyLayout(item.layout) - ? { lg: item.layout } - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - result.push({ - ...item, - layout: { ...existingResponsive, [breakpoint]: position }, - }); - } - }); - - return result; -} - -export function recompactLayout(items: Item[], cols: number): Item[] { - if (items.length === 0) return items; - - const sortedItems = [...items].sort((a, b) => { - const aLayout = getLayoutForBreakpoint(a, 'lg'); - const bLayout = getLayoutForBreakpoint(b, 'lg'); - const aY = aLayout?.y ?? 0; - const bY = bLayout?.y ?? 0; - if (aY !== bY) return aY - bY; - return (aLayout?.x ?? 0) - (bLayout?.x ?? 0); - }); - - const columnHeights = new Array(cols).fill(0); - - return sortedItems.map((item) => { - const existingLayout = getLayoutForBreakpoint(item, 'lg'); - // Use default dimensions as fallback to ensure quiz cards get height 13 - const h = existingLayout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type]?.h ?? 4; - const dimensions = existingLayout - ? { w: Math.min(existingLayout.w, cols), h } - : { w: DEFAULT_CARD_DIMENSIONS[item.type].w, h: DEFAULT_CARD_DIMENSIONS[item.type].h }; - - const w = Math.min(dimensions.w, cols); - - let bestColumn = 0; - let minHeight = columnHeights[0]; - - for (let col = 0; col <= cols - w; col++) { - let maxHeightInRange = columnHeights[col]; - for (let i = col; i < col + w; i++) { - maxHeightInRange = Math.max(maxHeightInRange, columnHeights[i] || 0); - } - - if (maxHeightInRange < minHeight) { - minHeight = maxHeightInRange; - bestColumn = col; - } - } - - const y = minHeight; - for (let i = bestColumn; i < bestColumn + w; i++) { - columnHeights[i] = y + dimensions.h; - } - - const newPosition: LayoutPosition = { - x: bestColumn, - y, - w, - h: dimensions.h, - }; - - // Preserve existing responsive layouts - const existingResponsive = isLegacyLayout(item.layout) - ? {} - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - return { - ...item, - layout: { ...existingResponsive, lg: newPosition }, - }; - }); -} - -export function hasLayoutChanged(items: Item[], newLayout: LayoutItem[], breakpoint: 'lg' = 'lg'): boolean { - const itemsMap = new Map(items.map((item) => [item.id, item])); - - return newLayout.some((newItem) => { - const currentItem = itemsMap.get(newItem.i); - const currentLayout = currentItem ? getLayoutForBreakpoint(currentItem, breakpoint) : undefined; - return ( - !currentItem || - !currentLayout || - currentLayout.x !== newItem.x || - currentLayout.y !== newItem.y || - currentLayout.w !== newItem.w || - currentLayout.h !== newItem.h - ); - }); -} - -/** - * Update items with new layout positions for the workspace grid. - */ -export function updateItemsWithLayout(items: Item[], layout: LayoutItem[], breakpoint: 'lg' = 'lg'): Item[] { - const itemLayoutMap = new Map(layout.map((l) => [l.i, l])); - - return items.map((item) => { - const newLayoutItem = itemLayoutMap.get(item.id); - if (newLayoutItem) { - const newPosition: LayoutPosition = { - x: newLayoutItem.x, - y: newLayoutItem.y, - w: newLayoutItem.w, - h: newLayoutItem.h, - }; - - return { - ...item, - layout: { [breakpoint]: newPosition }, - }; - } - return item; - }); -} - -export function moveItemToTopLayout(items: Item[], itemId: string): Item[] { - return items.map((item) => { - const existingLayout = getLayoutForBreakpoint(item, 'lg'); - if (item.id === itemId && existingLayout) { - const newPosition: LayoutPosition = { ...existingLayout, y: 0 }; - - const existingResponsive = isLegacyLayout(item.layout) - ? {} - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - return { - ...item, - layout: { ...existingResponsive, lg: newPosition }, - }; - } - return item; - }); -} - -export function moveItemToBottomLayout(items: Item[], itemId: string): Item[] { - const maxY = Math.max( - 0, - ...items.map((item) => { - const layout = getLayoutForBreakpoint(item, 'lg'); - return item.id !== itemId && layout - ? layout.y + layout.h - : 0; - }) - ); - - return items.map((item) => { - const existingLayout = getLayoutForBreakpoint(item, 'lg'); - if (item.id === itemId && existingLayout) { - const newPosition: LayoutPosition = { ...existingLayout, y: maxY }; - - const existingResponsive = isLegacyLayout(item.layout) - ? {} - : (item.layout as ResponsiveLayouts | undefined) ?? {}; - - return { - ...item, - layout: { ...existingResponsive, lg: newPosition }, - }; - } - return item; - }); -} \ No newline at end of file From 244984e463df7e6814eeaf0c4d4e09d15fadaa69 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 06:42:05 +0000 Subject: [PATCH 02/32] Remove workspace card preview rendering Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/WorkspaceCard.tsx | 145 ++---------------- .../workspace-canvas/WorkspaceCardContent.tsx | 145 ++---------------- .../WorkspaceCardTypeBadge.tsx | 22 +-- src/lib/ai/workers/workspace-worker.ts | 17 +- 4 files changed, 46 insertions(+), 283 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 4952a0b26..740a4efc7 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -8,7 +8,7 @@ import { } from "@/lib/workspace-state/colors"; import type { Item, DocumentData } from "@/lib/workspace-state/types"; import type { ColorResult } from "react-color"; -import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; +import { useUIStore } from "@/lib/stores/ui-store"; import { ContextMenu, ContextMenuContent, @@ -24,16 +24,14 @@ import { WorkspaceCardTypeBadge } from "./WorkspaceCardTypeBadge"; interface WorkspaceCardProps { item: Item; - allItems: Item[]; // All items for the move dialog tree + allItems: Item[]; workspaceName: string; workspaceIcon?: string | null; workspaceColor?: string | null; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; - // NOTE: isSelected is now subscribed directly from the store to prevent - // full grid re-renders when selection changes - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder + onMoveItem?: (itemId: string, folderId: string | null) => void; } /** @@ -52,55 +50,26 @@ function WorkspaceCard({ onMoveItem, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); - const documentMarkdownRaw = - item.type === "document" - ? ((item.data as DocumentData).markdown || "").trim() - : ""; - const documentPreviewText = - item.type === "document" - ? documentMarkdownRaw || "Start writing..." - : ""; - const documentAwaitingGeneration = - item.type === "document" && - item.name === "Update me" && - documentMarkdownRaw.length === 0; - - // Subscribe directly to this card's selection state from the store - // This prevents full grid re-renders when selection changes + const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - // No dynamic calculations needed - just overflow hidden const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); - // Get scroll lock state from Zustand store (persists across interactions) - const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); - const toggleItemScrollLocked = useUIStore( - (state) => state.toggleItemScrollLocked, - ); - const shouldShowPreview = - item.type === "document" || - item.type === "pdf" || - item.type === "quiz" || - item.type === "audio"; - // Track minimal local drag detection (only if grid hasn't detected drag) const mouseDownRef = useRef<{ x: number; y: number } | null>(null); const hasMovedRef = useRef(false); const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; // pixels - movement beyond this prevents click + const DRAG_THRESHOLD = 10; - // OPTIMIZED: Store handlers in refs so they can be added/removed dynamically - // This avoids adding 240+ listeners (120 cards * 2 listeners) on every render const handlersRef = useRef<{ handleGlobalMouseMove: ((e: MouseEvent) => void) | null; handleGlobalMouseUp: (() => void) | null; }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - // Cleanup listeners on unmount useEffect(() => { const handlers = handlersRef.current; return () => { @@ -113,16 +82,12 @@ function WorkspaceCard({ "mousemove", handlers.handleGlobalMouseMove, ); - document.removeEventListener( - "mouseup", - handlers.handleGlobalMouseUp, - ); + document.removeEventListener("mouseup", handlers.handleGlobalMouseUp); listenersActiveRef.current = false; } }; }, []); - // OPTIMIZED: Memoize ItemHeader callbacks to prevent inline function creation const handleNameChange = useCallback( (v: string) => { onUpdateItem(item.id, { name: v }); @@ -152,7 +117,6 @@ function WorkspaceCard({ setIsEditingTitle(false); }, []); - // Handle color change from color picker const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); @@ -196,10 +160,8 @@ function WorkspaceCard({ } }, [item.type, item.data]); - // Handle mouse down - track initial position for local movement detection const handleMouseDown = useCallback( (e: React.MouseEvent) => { - // Don't track if clicking on interactive elements or text inputs const target = e.target as HTMLElement; if ( target.closest("button") || @@ -208,15 +170,12 @@ function WorkspaceCard({ target.closest('[role="menuitem"]') || target.closest('[contenteditable="true"]') ) { - // Important: Stop propagation to prevent grid drag from starting e.stopPropagation(); return; } - // Check if clicking inside a text selection area (e.g., title textarea) const selection = window.getSelection(); if (selection && selection.toString().length > 0) { - // User is selecting text, don't start drag tracking e.stopPropagation(); return; } @@ -224,21 +183,17 @@ function WorkspaceCard({ mouseDownRef.current = { x: e.clientX, y: e.clientY }; hasMovedRef.current = false; - // OPTIMIZED: Only add global listeners when mouseDown occurs, not on every render if (!listenersActiveRef.current) { const handleGlobalMouseMove = (e: MouseEvent) => { if (!mouseDownRef.current) return; - // Calculate movement delta const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - // If drag already detected, don't cancel it - user is dragging if (hasMovedRef.current) { return; } - // Check if user is selecting text - if so, don't treat as drag const selection = window.getSelection(); if (selection && selection.toString().length > 0) { mouseDownRef.current = null; @@ -246,7 +201,6 @@ function WorkspaceCard({ return; } - // Check if movement exceeds threshold if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { hasMovedRef.current = true; } @@ -254,7 +208,6 @@ function WorkspaceCard({ const handleGlobalMouseUp = () => { mouseDownRef.current = null; - // Clean up listeners when mouse up if ( listenersActiveRef.current && handlersRef.current.handleGlobalMouseMove && @@ -284,36 +237,24 @@ function WorkspaceCard({ [DRAG_THRESHOLD], ); - // Handle mouse move on card - detect if user moved before releasing - // Note: This is a fallback - the global listener handles most cases const handleMouseMove = useCallback((e: React.MouseEvent) => { - // The global listener handles this, but we keep this for local element-specific checks if (!mouseDownRef.current) return; - // Only check for text input/selection if drag hasn't been detected yet - // This prevents starting a drag when user is trying to select text const target = e.target as HTMLElement; if ( target.closest("textarea") || target.closest("input") || target.closest('[contenteditable="true"]') ) { - // User is interacting with text input, cancel drag tracking mouseDownRef.current = null; hasMovedRef.current = false; - return; } }, []); - // Handle mouse up - clear the mouse down tracking - // Note: The global listener also handles this, but we keep this for local cleanup - const handleMouseUp = useCallback(() => { - // Don't clear here - let the global listener handle it to ensure consistency - }, []); + const handleMouseUp = useCallback(() => {}, []); const handleCardClick = useCallback( (e: React.MouseEvent) => { - // Check if click originated from dropdown menu const target = e.target as HTMLElement; if ( target.closest('[data-slot="dropdown-menu-item"]') || @@ -330,58 +271,39 @@ function WorkspaceCard({ return; } - // For flashcard cards, check if click is on the flashcard itself - // If so, let the flashcard handle it (for flipping) if (item.type === "flashcard") { - // Check if click is on the flashcard component or its children const flashcardElement = target.closest( '.flashcard-container, .flashcard, [class*="flashcard"]', ); if (flashcardElement) { - // Click is on flashcard - let it flip, don't open modal e.stopPropagation(); return; } } - // Check if user was selecting text - if so, allow normal text selection behavior const selection = window.getSelection(); if (selection && selection.toString().length > 0) { - // User selected text, don't open modal or prevent default return; } - // Shift+click toggles card selection if (e.shiftKey) { e.stopPropagation(); onToggleSelection(item.id); return; } - // Check if user moved mouse significantly (drag detected) or is editing title - // Store the value before resetting const wasDragging = hasMovedRef.current; - - // Reset the tracking immediately after checking hasMovedRef.current = false; - // Prevent opening modal if user was dragging or is editing title if (wasDragging || isEditingTitle) { e.preventDefault(); e.stopPropagation(); return; } - // Default: open item in the left-pane overlay onOpenModal(item.id); }, - [ - isEditingTitle, - item.id, - item.type, - onOpenModal, - onToggleSelection, - ], + [isEditingTitle, item.id, item.type, onOpenModal, onToggleSelection], ); const handleMove = useCallback( @@ -394,18 +316,7 @@ function WorkspaceCard({ ); const shouldUseFramelessLayout = - item.type === "youtube" || - item.type === "image" || - (item.type === "pdf" && shouldShowPreview); - const shouldShowScrollLockButton = - item.type !== "youtube" && - item.type !== "image" && - item.type !== "quiz" && - !(item.type === "document" && (!shouldShowPreview || documentAwaitingGeneration)) && - !(item.type === "pdf" && !shouldShowPreview) && - !(item.type === "audio" && !shouldShowPreview); - const useDarkFloatingControls = - item.type === "pdf" && shouldShowPreview; + item.type === "youtube" || item.type === "image"; return ( @@ -415,7 +326,6 @@ function WorkspaceCard({ id={`item-${item.id}`} data-youtube-card data-item-type={item.type} - data-has-preview={shouldShowPreview} className={`relative rounded-md scroll-mt-4 size-full flex flex-col overflow-hidden transition-all duration-200 cursor-pointer ${ shouldUseFramelessLayout ? "p-0" @@ -460,14 +370,14 @@ function WorkspaceCard({ > toggleItemScrollLocked(item.id)} + onToggleScrollLock={() => {}} onToggleSelection={() => onToggleSelection(item.id)} onOpenRename={() => setShowRenameDialog(true)} onOpenMove={() => setShowMoveDialog(true)} @@ -476,29 +386,16 @@ function WorkspaceCard({ onDelete={handleDelete} /> - + - onUpdateItem(item.id, { - data: updater(item.data) as Item["data"], - }) - } /> @@ -539,18 +436,15 @@ function WorkspaceCard({ ); } -// Memoize to prevent unnecessary re-renders export const WorkspaceCardMemoized = memo( WorkspaceCard, (prevProps, nextProps) => { - // Compare item properties if (prevProps.item.id !== nextProps.item.id) return false; if (prevProps.item.name !== nextProps.item.name) return false; if (prevProps.item.subtitle !== nextProps.item.subtitle) return false; if (prevProps.item.color !== nextProps.item.color) return false; if (prevProps.item.type !== nextProps.item.type) return false; - // Compare item data (for PDFs, flashcards, and YouTube) if (prevProps.item.type === "pdf" && nextProps.item.type === "pdf") { const prevData = prevProps.item.data; const nextData = nextProps.item.data; @@ -596,17 +490,8 @@ export const WorkspaceCardMemoized = memo( if (JSON.stringify(prevData) !== JSON.stringify(nextData)) return false; } - - // NOTE: isSelected is now subscribed directly from the store, not a prop - - // NOTE: We intentionally do NOT compare callback references (onUpdateItem, onDeleteItem, etc.) - // These are action handlers that don't affect the rendered output. - // React Compiler handles memoization, and checking refs here causes unnecessary re-renders - // when parent components re-render and create new callback instances. - - return true; // Props are equal, skip re-render + return true; }, ); -// Export both the memoized version and original for backwards compatibility export { WorkspaceCardMemoized as WorkspaceCard }; diff --git a/src/components/workspace-canvas/WorkspaceCardContent.tsx b/src/components/workspace-canvas/WorkspaceCardContent.tsx index 8a037b661..51a7d1c78 100644 --- a/src/components/workspace-canvas/WorkspaceCardContent.tsx +++ b/src/components/workspace-canvas/WorkspaceCardContent.tsx @@ -1,82 +1,57 @@ "use client"; -import { Loader2 } from "lucide-react"; import { Flashcard } from "react-quizlet-flashcard"; import "react-quizlet-flashcard/dist/index.css"; import { cn } from "@/lib/utils"; import type { Item, - DocumentData, FlashcardData, - PdfData, YouTubeData, } from "@/lib/workspace-state/types"; import ItemHeader from "@/components/workspace-canvas/ItemHeader"; -import { QuizContent } from "./QuizContent"; -import { ImageCardContent } from "./ImageCardContent"; -import { AudioCardContent } from "./AudioCardContent"; -import LazyAppPdfViewer from "@/components/pdf/LazyAppPdfViewer"; import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; -import { Skeleton } from "@/components/ui/skeleton"; import { extractYouTubePlaylistId, extractYouTubeVideoId, } from "@/lib/utils/youtube-url"; import { YouTubeCardContent } from "./YouTubeCardContent"; -import { SourcesDisplay } from "./SourcesDisplay"; +import { ImageCardContent } from "./ImageCardContent"; interface WorkspaceCardContentProps { item: Item; - shouldShowPreview: boolean; - isScrollLocked: boolean; - documentAwaitingGeneration: boolean; - documentPreviewText: string; resolvedTheme?: string; onNameChange: (value: string) => void; onNameCommit: (value: string) => void; onSubtitleChange: (value: string) => void; onTitleFocus: () => void; onTitleBlur: () => void; - onUpdateItemData: (updater: (prev: Item["data"]) => Item["data"]) => void; } export function WorkspaceCardContent({ item, - shouldShowPreview, - isScrollLocked, - documentAwaitingGeneration, - documentPreviewText, resolvedTheme, onNameChange, onNameCommit, onSubtitleChange, onTitleFocus, onTitleBlur, - onUpdateItemData, }: WorkspaceCardContentProps) { const isCompactTextCard = - (item.type === "pdf" || - item.type === "quiz" || - item.type === "audio" || - item.type === "document") && - !shouldShowPreview; - const shouldShowHeader = - item.type !== "youtube" && - item.type !== "image" && - !(item.type === "pdf" && shouldShowPreview) && - item.name !== "Update me"; - const documentSources = - item.type === "document" ? (item.data as DocumentData).sources : undefined; + item.type === "pdf" || + item.type === "quiz" || + item.type === "audio" || + item.type === "document"; + const shouldShowHeader = item.type !== "youtube" && item.type !== "image"; return ( <> -
- {shouldShowHeader && ( -
+ {shouldShowHeader && ( +
+
- - {item.type === "document" && - shouldShowPreview && - documentSources && - documentSources.length > 0 && ( -
- -
- )}
- )} -
- - {item.type === "pdf" && - shouldShowPreview && - (() => { - const pdfData = item.data as PdfData; - const isOcrProcessing = pdfData?.ocrStatus === "processing"; - const pdfPreviewUrl = pdfData.fileUrl; - - return ( -
- - {isOcrProcessing && pdfPreviewUrl && ( -
- - Reading... -
- )} -
- ); - })()} - - {item.type === "quiz" && shouldShowPreview && ( -
-
)} @@ -172,7 +95,8 @@ export function WorkspaceCardContent({
@@ -188,7 +112,8 @@ export function WorkspaceCardContent({
@@ -247,42 +172,6 @@ export function WorkspaceCardContent({ })()} {item.type === "image" && } - - {item.type === "audio" && shouldShowPreview && ( -
- -
- )} - - {item.type === "document" && - shouldShowPreview && - (documentAwaitingGeneration ? ( -
-
-
- Generating document... -
- - - -
- ) : ( -
- - {documentPreviewText} - -
- ))} ); } diff --git a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx index 66455531d..2976370d7 100644 --- a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx +++ b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx @@ -1,13 +1,6 @@ "use client"; -import { - Loader2, - File, - FileText, - Brain, - Mic, - Globe, -} from "lucide-react"; +import { Loader2, File, FileText, Brain, Mic, Globe } from "lucide-react"; import type { Item, PdfData, WebsiteData } from "@/lib/workspace-state/types"; import { getCardColorWithBlackMix, @@ -17,22 +10,19 @@ import { interface WorkspaceCardTypeBadgeProps { item: Item; - shouldShowPreview: boolean; resolvedTheme?: string; } export function WorkspaceCardTypeBadge({ item, - shouldShowPreview, resolvedTheme, }: WorkspaceCardTypeBadgeProps) { const showTypeBadge = - (item.type === "pdf" || - item.type === "quiz" || - item.type === "audio" || - item.type === "website" || - item.type === "document") && - !shouldShowPreview; + item.type === "pdf" || + item.type === "quiz" || + item.type === "audio" || + item.type === "website" || + item.type === "document"; if (!showTypeBadge) { return null; diff --git a/src/lib/ai/workers/workspace-worker.ts b/src/lib/ai/workers/workspace-worker.ts index 722b971df..482e7e303 100644 --- a/src/lib/ai/workers/workspace-worker.ts +++ b/src/lib/ai/workers/workspace-worker.ts @@ -301,8 +301,7 @@ export async function workspaceWorker( favicon?: string; }>; folderId?: string; - /** Optional layout { x, y, w, h } for the item (lg breakpoint) */ - }, + }, ): Promise<{ success: boolean; message: string; @@ -988,13 +987,13 @@ export async function workspaceWorker( ); await deleteWorkspaceItem(params.workspaceId, params.itemId); - return { - success: true, - itemId: params.itemId, - message: existingItem - ? `Deleted "${existingItem.name}" successfully` - : "Deleted item successfully", - }; + return { + success: true, + itemId: params.itemId, + message: existingItem + ? `Deleted "${existingItem.name}" successfully` + : "Deleted item successfully", + }; } // Fallback for unhandled actions From 19feb57d813b13356cb54ab78a7f914109db725e Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:01:50 +0000 Subject: [PATCH 03/32] Remove stale workspace drag and scroll-lock code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/app/globals.css | 389 ------------------ .../FlashcardWorkspaceCard.tsx | 170 ++------ .../workspace-canvas/FolderCard.tsx | 284 ++++++------- .../workspace-canvas/MarqueeSelector.tsx | 363 ++++++++-------- .../workspace-canvas/WorkspaceCard.tsx | 171 +------- .../workspace-canvas/WorkspaceCardActions.tsx | 44 -- .../workspace-canvas/WorkspaceSection.tsx | 2 - 7 files changed, 342 insertions(+), 1081 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f4e0508fb..ec4b8bb62 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -511,400 +511,11 @@ body.marquee-selecting iframe { } } -/* React Grid Layout Overrides */ -.react-grid-layout { - position: relative; -} - -/* No transition by default — prevents fly-in animation on mount */ -.react-grid-item { - transition: none !important; -} - -/* Enable smooth transitions only after initial layout is complete */ -.workspace-grid-mounted .react-grid-item { - transition: transform 250ms ease-out, - width 250ms ease-out, - height 250ms ease-out !important; -} - -/* Flashcard flip animation isolation - prevent grid transitions from affecting flip */ -.react-grid-item .flip-card, -.react-grid-item .flip-card * { - /* Ensure flip card manages its own transitions */ - transition: none; -} - -.react-grid-item .flip-card-inner { - /* Only the inner container should have the vertical flip transition */ - transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1) !important; - transform-style: preserve-3d !important; -} - -/* Prevent any inherited transforms from affecting flip card content */ -.react-grid-item .flip-card-front, -.react-grid-item .flip-card-back { - /* Content should not have any transitions that could cause flicker */ - transition: none !important; -} - -/* Ensure flip card content is isolated from parent compositing layers */ .flip-card-content { isolation: isolate; contain: layout style; } -/* Cursor styles for draggable grid items */ -.react-grid-item.react-draggable { - cursor: grab; -} - -.react-grid-item.react-draggable-dragging { - cursor: grabbing !important; - touch-action: none !important; - /* Prevent scrolling during drag on mobile */ -} - -.react-grid-item.react-draggable-dragging * { - cursor: grabbing !important; - touch-action: none !important; -} - -/* Improve resize handles for mobile */ -@media (max-width: 768px) { - .react-grid-item>.react-resizable-handle { - width: 25px; - height: 25px; - } - - .react-grid-item>.react-resizable-handle::after { - width: 14px; - height: 14px; - } -} - -.react-grid-item.react-grid-placeholder { - transition: all 200ms ease-out; -} - -.react-grid-item img { - pointer-events: none; - user-select: none; -} - -.react-grid-item>.react-resizable-handle { - position: absolute; - width: 25px; - height: 25px; -} - -.react-grid-item>.react-resizable-handle::after { - content: ""; - position: absolute; - right: 0px; - bottom: 0px; - width: 14px; - height: 14px; - border-right: 3px solid rgba(255, 255, 255, 1); - border-bottom: 3px solid rgba(255, 255, 255, 1); - border-radius: 0 0 10px 0; - opacity: 0; - transition: opacity 0.2s ease, border-color 0.2s ease; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle::after { - border-right-color: rgba(255, 255, 255, 1); - border-bottom-color: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-sw { - bottom: -3px; - left: -3px; - cursor: sw-resize; - transform: rotate(90deg); -} - -.react-grid-item>.react-resizable-handle-se { - bottom: -3px; - right: -3px; - cursor: se-resize; -} - -.react-grid-item>.react-resizable-handle-nw { - top: -3px; - left: -3px; - cursor: nw-resize; - transform: rotate(180deg); -} - -.react-grid-item>.react-resizable-handle-ne { - top: -3px; - right: -3px; - cursor: ne-resize; - transform: rotate(270deg); -} - -/* Edge handles (N, S, E, W) - span nearly full edge for large hit area */ -.react-grid-item>.react-resizable-handle-w, -.react-grid-item>.react-resizable-handle-e { - top: 30px; - height: calc(100% - 60px); - width: 15px; - cursor: ew-resize; - margin-top: 0; -} - -.react-grid-item>.react-resizable-handle-w { - left: -3px; -} - -.react-grid-item>.react-resizable-handle-e { - right: -3px; -} - -.react-grid-item>.react-resizable-handle-n, -.react-grid-item>.react-resizable-handle-s { - left: 30px; - width: calc(100% - 60px); - height: 15px; - cursor: ns-resize; - margin-left: 0; -} - -.react-grid-item>.react-resizable-handle-n { - top: -3px; -} - -.react-grid-item>.react-resizable-handle-s { - bottom: -3px; -} - -/* Make edge handles straight lines (horizontal for N/S, vertical for E/W) */ -.react-grid-item>.react-resizable-handle-n::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 20px; - height: 3px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - top: 0; - bottom: auto; - right: auto; - margin-top: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-s::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 20px; - height: 3px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - bottom: 0; - top: auto; - right: auto; - margin-top: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-e::after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - right: 0; - left: auto; - bottom: auto; - margin-left: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-w::after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - left: 0; - right: auto; - bottom: auto; - margin-left: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle-n::after, -.dark .react-grid-item>.react-resizable-handle-s::after { - background: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle-e::after, -.dark .react-grid-item>.react-resizable-handle-w::after { - background: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -/* Show resize handles on hover - corner handles */ -.react-grid-item:hover>.react-resizable-handle-se::after, -.react-grid-item:hover>.react-resizable-handle-sw::after, -.react-grid-item:hover>.react-resizable-handle-ne::after, -.react-grid-item:hover>.react-resizable-handle-nw::after { - opacity: 1; -} - -/* Show resize handles on hover - edge handles */ -.react-grid-item:hover>.react-resizable-handle-n::after, -.react-grid-item:hover>.react-resizable-handle-s::after, -.react-grid-item:hover>.react-resizable-handle-e::after, -.react-grid-item:hover>.react-resizable-handle-w::after { - opacity: 1; -} - -/* Placeholder styling during drag */ -.react-grid-item.react-grid-placeholder { - background: rgba(59, 130, 246, 0.15); - border: 2px dashed rgba(59, 130, 246, 0.5); - border-radius: 1rem; - opacity: 0.7; - transition: all 200ms ease-out !important; - z-index: 2; - user-select: none; -} - -.react-grid-item.resizing { - opacity: 0.9; - z-index: 100; -} - -.react-grid-item.static { - background: transparent; -} - -.react-grid-item .text, -.react-grid-item button { - user-select: none; -} - -/* Disable text selection during drag */ -.react-grid-item.react-draggable-dragging { - transition: none !important; - z-index: 1000; - opacity: 0.5; -} - -.react-grid-item.react-draggable-dragging * { - user-select: none; -} - -/* Ensure YouTube and PDF preview cards show opacity during drag */ -/* The data-item-type is on the article element inside the grid item */ -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"], -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] { - opacity: 0.5 !important; -} - -/* Ensure iframes and images in YouTube/PDF cards show opacity during drag */ -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"] iframe, -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"] img, -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] iframe, -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] img { - opacity: 0.5 !important; -} - -/* Disable transitions when manually resizing */ -.react-grid-item.resizing { - transition: none !important; -} - -/* Allow dragging beyond scroll boundaries */ -.react-grid-layout:has(.react-draggable-dragging) { - overflow: visible !important; -} - -/* Allow parent containers to overflow during drag */ -.layout:has(.react-draggable-dragging) { - overflow: visible !important; -} - -/* NOTE: Removed overly broad div selector that was breaking scroll containers */ -/* The grid layout itself handles overflow properly */ - -/* Reset overflow for the article content inside cards */ -/* Only apply scrolling to non-flex articles */ -.react-grid-item article:not(.flex) { - overflow-y: auto !important; - overflow-x: hidden !important; -} - -/* Card content layout with overflow handling */ -/* Flex containers handle overflow internally */ -.react-grid-item article.flex { - overflow: hidden !important; -} - -.react-grid-item article:not(.flex) { - display: block; - height: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -/* Custom scrollbar for cards */ -.react-grid-item article::-webkit-scrollbar { - width: 6px; -} - -.react-grid-item article::-webkit-scrollbar-track { - background: transparent; -} - -.react-grid-item article::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 3px; -} - -.dark .react-grid-item article::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); -} - -.react-grid-item article::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); -} - -.dark .react-grid-item article::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -/* Ensure tag containers respect boundaries */ -.react-grid-item article [class*="flex-wrap"] { - max-width: 100%; - word-break: break-word; -} - -/* Constrain textareas within cards */ -.react-grid-item article textarea { - max-height: 400px; - overflow-y: auto; -} - /* Word breaking with character fallback for workspace card item headers only */ .workspace-card-text { /* Try to break at word boundaries first */ diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx index 047d3105d..53ef0d5c8 100644 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx @@ -171,7 +171,6 @@ export function FlashcardWorkspaceCard({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isFlipped, setIsFlipped] = useState(false); const [isFlipping, setIsFlipping] = useState(false); - // Get scroll lock state from Zustand store (persists across interactions) const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); const toggleItemScrollLocked = useUIStore( (state) => state.toggleItemScrollLocked, @@ -212,45 +211,9 @@ export function FlashcardWorkspaceCard({ return cards[safeIndex] ?? EMPTY_FLASHCARD_PLACEHOLDER; }, [cards, currentIndex]); - // Flashcard flip animation duration (matches FlipCard component CSS) const FLIP_ANIMATION_DURATION = 600; - - // Tracking for flip debounce const lastFlipTimeRef = useRef(0); - // Track minimal local drag detection (same pattern as WorkspaceCard) - const mouseDownRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; // pixels - movement beyond this prevents flip - - // OPTIMIZED: Store handlers in refs so they can be added/removed dynamically - const handlersRef = useRef<{ - handleGlobalMouseMove: ((e: MouseEvent) => void) | null; - handleGlobalMouseUp: (() => void) | null; - }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - - // Cleanup listeners on unmount - useEffect(() => { - return () => { - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - } - }; - }, []); - const handleDelete = useCallback(() => { setShowDeleteDialog(true); }, []); @@ -276,13 +239,11 @@ export function FlashcardWorkspaceCard({ [item.id, onUpdateItem], ); - // Helper function to hide tabs during flip animation const startFlipAnimation = useCallback(() => { setIsFlipping(true); setTimeout(() => setIsFlipping(false), FLIP_ANIMATION_DURATION); }, []); - // Debounced flip logic const handleFlip = useCallback(() => { const now = Date.now(); if (now - lastFlipTimeRef.current < 200) return; @@ -291,118 +252,47 @@ export function FlashcardWorkspaceCard({ startFlipAnimation(); }, [startFlipAnimation]); - // Handle mouse down - track initial position for drag detection - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - // Don't track if clicking on interactive elements - const target = e.target as HTMLElement; - if ( - target.closest("button") || + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || target.closest(".flashcard-control-button") || - target.closest('[role="menuitem"]') - ) { - return; - } + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]') + ); + }, []); - // Check if clicking inside a text selection area - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { e.stopPropagation(); return; } - mouseDownRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; - - // Only add global listeners when mouseDown occurs - if (!listenersActiveRef.current) { - const handleGlobalMouseMove = (e: MouseEvent) => { - if (!mouseDownRef.current) return; - - // Calculate movement delta - const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - - if (hasMovedRef.current) { - return; - } - - // Check if user is selecting text - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - mouseDownRef.current = null; - hasMovedRef.current = false; - return; - } - - // Check if movement exceeds threshold (drag detected) - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }; - - const handleGlobalMouseUp = () => { - mouseDownRef.current = null; - // Clean up listeners - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - handlersRef.current.handleGlobalMouseMove = null; - handlersRef.current.handleGlobalMouseUp = null; - } - }; - - handlersRef.current.handleGlobalMouseMove = handleGlobalMouseMove; - handlersRef.current.handleGlobalMouseUp = handleGlobalMouseUp; - document.addEventListener("mousemove", handleGlobalMouseMove); - document.addEventListener("mouseup", handleGlobalMouseUp); - listenersActiveRef.current = true; - } - }, - [DRAG_THRESHOLD], - ); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - // If unlocked, we are in "content mode" - allow text selection/scrolling, disable flip - if (!isScrollLocked) return; - - // Also prevent flip if user was selecting text (fallback check) const selection = window.getSelection(); if (selection && selection.toString().length > 0) return; - // Shift+click toggles card selection if (e.shiftKey) { e.stopPropagation(); onToggleSelection(item.id); return; } - // Prevent flipping if user was dragging - const wasDragging = hasMovedRef.current; - hasMovedRef.current = false; // Reset immediately after checking - - if (wasDragging) { - e.preventDefault(); - e.stopPropagation(); - return; - } + if (!isScrollLocked) return; - // Safe to flip - user clicked without dragging handleFlip(); }, - [handleFlip, isScrollLocked, onToggleSelection, item.id], + [handleFlip, isInteractiveTarget, isScrollLocked, onToggleSelection, item.id], ); // Navigation Handlers @@ -486,15 +376,13 @@ export function FlashcardWorkspaceCard({ id={`item-${item.id}`} className="group size-full relative rounded-md" style={{}} - onMouseDown={handleMouseDown} onClick={handleClick} > {/* Floating Controls */}
- {/* Scroll Lock/Unlock Button */} - - {/* Next Button */} - {/* Card Counter */}
{currentIndex + 1} / {cards.length}
)} - {/* Flashcard Stack Container */}
- {/* Main Flashcard - takes up space minus the tabs */}
- {/* Check for template-created items awaiting generation */} {item.name === "Update me" && (!flashcardData.cards || flashcardData.cards.length === 0) ? ( - // Generating skeleton for template-created flashcards
)} - {/* Stack Tab 1 (directly below main card) - hidden during flip */}
- {/* Stack Tab 2 (bottom-most, slightly narrower) - hidden during flip */}
void; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; - onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move folder to another location + onDeleteFolderWithContents?: (folderId: string) => void; + onMoveItem?: (itemId: string, folderId: string | null) => void; } -/** - * FolderCard - A folder-shaped card that displays in the workspace grid - * Now uses Item type with type: 'folder' instead of separate Folder type - */ function FolderCardComponent({ item, itemCount, @@ -77,120 +86,99 @@ function FolderCardComponent({ }: FolderCardProps) { const [showColorPicker, setShowColorPicker] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deleteOption, setDeleteOption] = useState<'keep' | 'delete' | null>(null); + const [deleteOption, setDeleteOption] = useState<"keep" | "delete" | null>(null); const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [shouldAutoFocus, setShouldAutoFocus] = useState(false); - // Subscribe directly to this folder's selection state from the store - const isSelected = useUIStore( - (state) => state.selectedCardIds.has(item.id) - ); - const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - - // Track drag movement to prevent opening folder after drag - const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const DRAG_THRESHOLD = 5; // pixels + const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); + const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - const folderColor = item.color || "#6366F1"; // Default to indigo + const folderColor = item.color || "#6366F1"; - // Auto-focus and scroll into view for newly created folders (name is "New Folder") useEffect(() => { if (item.name === "New Folder") { setShouldAutoFocus(true); - // Scroll the folder card into view const element = document.getElementById(`item-${item.id}`); if (element) { setTimeout(() => { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.scrollIntoView({ behavior: "smooth", block: "center" }); }, 100); } } }, [item.id, item.name]); - // Handle mouse down - track initial position for drag detection - const handleMouseDown = useCallback((e: React.MouseEvent) => { - // Don't track if clicking on interactive elements - const target = e.target as HTMLElement; - if ( - target.closest('button') || - target.closest('input') || - target.closest('textarea') || - target.closest('[role="menuitem"]') - ) { - return; - } - mouseDownPosRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]'), + ); }, []); - // Handle mouse move - detect if user moved beyond threshold - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!mouseDownPosRef.current || hasMovedRef.current) return; - - const deltaX = Math.abs(e.clientX - mouseDownPosRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownPosRef.current.y); - - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }, []); + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { + return; + } - // Handle click - only open folder if it wasn't a drag - const handleClick = useCallback((e: React.MouseEvent) => { - // Don't open if clicking on interactive elements - const target = e.target as HTMLElement; - if ( - target.closest('button') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[data-slot="dialog-content"]') || - target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]') - ) { - return; - } + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + return; + } - // Shift+click toggles folder selection - if (e.shiftKey) { - e.stopPropagation(); - onToggleSelection(item.id); - return; - } + if (e.shiftKey) { + e.stopPropagation(); + onToggleSelection(item.id); + return; + } - // Don't open if user was dragging or is editing title - if (hasMovedRef.current || isEditingTitle) { - hasMovedRef.current = false; - mouseDownPosRef.current = null; - return; - } + if (isEditingTitle) { + return; + } - mouseDownPosRef.current = null; - onOpenFolder(item.id); - }, [item.id, onOpenFolder, onToggleSelection, isEditingTitle]); + onOpenFolder(item.id); + }, + [isEditingTitle, isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], + ); const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); setShowColorPicker(false); }, - [item.id, onUpdateItem] + [item.id, onUpdateItem], ); - // Handlers for inline title editing (like WorkspaceCard) - const handleNameChange = useCallback((v: string) => { - onUpdateItem(item.id, { name: v }); - }, [item.id, onUpdateItem]); + const handleNameChange = useCallback( + (v: string) => { + onUpdateItem(item.id, { name: v }); + }, + [item.id, onUpdateItem], + ); - const handleNameCommit = useCallback((v: string) => { - onUpdateItem(item.id, { name: v }); - }, [item.id, onUpdateItem]); + const handleNameCommit = useCallback( + (v: string) => { + onUpdateItem(item.id, { name: v }); + }, + [item.id, onUpdateItem], + ); const handleDelete = useCallback(() => { - if (deleteOption === 'delete' && onDeleteFolderWithContents) { + if (deleteOption === "delete" && onDeleteFolderWithContents) { onDeleteFolderWithContents(item.id); } else { onDeleteItem(item.id); @@ -199,12 +187,14 @@ function FolderCardComponent({ setDeleteOption(null); }, [item.id, onDeleteItem, onDeleteFolderWithContents, deleteOption]); - const handleRename = useCallback((newName: string) => { - onUpdateItem(item.id, { name: newName }); - toast.success("Folder renamed"); - }, [item.id, onUpdateItem]); + const handleRename = useCallback( + (newName: string) => { + onUpdateItem(item.id, { name: newName }); + toast.success("Folder renamed"); + }, + [item.id, onUpdateItem], + ); - // Reset delete option when dialog closes useEffect(() => { if (!showDeleteConfirm) { setDeleteOption(null); @@ -213,17 +203,17 @@ function FolderCardComponent({ const { resolvedTheme } = useTheme(); - // Calculate colors using the same utilities as WorkspaceCard - const bodyBgColor = getCardColorCSS(folderColor, 0.25); // Body is more transparent - const tabBgColor = getCardColorCSS(folderColor, 0.35); // Tab is slightly less transparent - const borderColor = isSelected ? 'rgba(255, 255, 255, 0.8)' : getCardAccentColor(folderColor, 0.5); - // Selection ring on outer wrapper (like normal cards) – avoids overflow-hidden clipping - // Light mode: match resize-handle style with layered dark shadow for visibility + const bodyBgColor = getCardColorCSS(folderColor, 0.25); + const tabBgColor = getCardColorCSS(folderColor, 0.35); + const borderColor = isSelected + ? "rgba(255, 255, 255, 0.8)" + : getCardAccentColor(folderColor, 0.5); const selectedRingStyle = isSelected ? { - boxShadow: resolvedTheme === 'dark' - ? '0 0 0 3px rgba(255, 255, 255, 0.8)' - : '0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.4)', + boxShadow: + resolvedTheme === "dark" + ? "0 0 0 3px rgba(255, 255, 255, 0.8)" + : "0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.4)", } : undefined; @@ -231,62 +221,61 @@ function FolderCardComponent({
- {/* Folder tab - top left, more transparent than body */}
- {/* Main folder body - starts where tab ends, less transparent */}
- {/* Selection Button */} - {/* Options Menu */}
- {/* Right-Click Context Menu */} setShowRenameDialog(true)}> @@ -569,7 +545,7 @@ function FolderCardComponent({ Delete Folder - + ); } diff --git a/src/components/workspace-canvas/MarqueeSelector.tsx b/src/components/workspace-canvas/MarqueeSelector.tsx index 292f5af39..3d1ae8fe8 100644 --- a/src/components/workspace-canvas/MarqueeSelector.tsx +++ b/src/components/workspace-canvas/MarqueeSelector.tsx @@ -1,27 +1,24 @@ "use client"; -import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import React, { useState, useCallback, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; -import { createRectFromPoints, getIntersectingCards, type Rectangle } from "@/lib/utils/marquee-utils"; +import { + createRectFromPoints, + getIntersectingCards, + type Rectangle, +} from "@/lib/utils/marquee-utils"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; interface MarqueeSelectorProps { scrollContainerRef: React.RefObject; cardIds: string[]; - isGridDragging: boolean; } -/** - * MarqueeSelector component for rectangular card selection - * Handles mouse events to create a selection rectangle and select intersecting cards - * Features autoscroll when selecting near container edges - */ export function MarqueeSelector({ scrollContainerRef, cardIds, - isGridDragging, }: MarqueeSelectorProps) { const [isSelecting, setIsSelecting] = useState(false); const [marqueeRect, setMarqueeRect] = useState(null); @@ -32,258 +29,236 @@ export function MarqueeSelector({ const isDraggingRef = useRef(false); const startPointRef = useRef({ x: 0, y: 0 }); - // Use autoscroll hook for smooth scrolling during selection - const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = useAutoScroll( - scrollContainerRef as React.RefObject - ); - - // Cancel marquee selection if grid starts dragging - useEffect(() => { - if (isGridDragging && isSelecting) { - setIsSelecting(false); - setMarqueeRect(null); - isDraggingRef.current = false; - stopAutoScroll(); // Stop autoscroll when canceling - } - }, [isGridDragging, isSelecting, stopAutoScroll]); + const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = + useAutoScroll(scrollContainerRef as React.RefObject); - // Add/remove class on body to disable pointer events on iframes during marquee selection useEffect(() => { if (isSelecting) { - document.body.classList.add('marquee-selecting'); + document.body.classList.add("marquee-selecting"); } else { - document.body.classList.remove('marquee-selecting'); + document.body.classList.remove("marquee-selecting"); } return () => { - document.body.classList.remove('marquee-selecting'); + document.body.classList.remove("marquee-selecting"); }; }, [isSelecting]); - // Helper to start marquee selection - const startMarqueeSelection = useCallback((e: MouseEvent) => { - const container = scrollContainerRef.current; - if (!container) return false; - - const containerRect = container.getBoundingClientRect(); - - // Check if click is within the container bounds - if ( - e.clientX < containerRect.left || - e.clientX > containerRect.right || - e.clientY < containerRect.top || - e.clientY > containerRect.bottom - ) { - return false; - } - - // Robust scrollbar detection: calculate actual scrollbar dimensions - // offsetWidth/Height includes scrollbar, clientWidth/Height excludes it - const scrollbarWidth = container.offsetWidth - container.clientWidth; - const scrollbarHeight = container.offsetHeight - container.clientHeight; - - // Check if clicking on scrollbar by comparing click position with client area - // For vertical scrollbar: check if click is to the right of the client area - const clickXRelativeToContainer = e.clientX - containerRect.left; - const isVerticalScrollbar = scrollbarWidth > 0 && clickXRelativeToContainer > container.clientWidth; + const startMarqueeSelection = useCallback( + (e: MouseEvent) => { + const container = scrollContainerRef.current; + if (!container) return false; + + const containerRect = container.getBoundingClientRect(); + if ( + e.clientX < containerRect.left || + e.clientX > containerRect.right || + e.clientY < containerRect.top || + e.clientY > containerRect.bottom + ) { + return false; + } - // For horizontal scrollbar: check if click is below the client area - const clickYRelativeToContainer = e.clientY - containerRect.top; - const isHorizontalScrollbar = scrollbarHeight > 0 && clickYRelativeToContainer > container.clientHeight; + const scrollbarWidth = container.offsetWidth - container.clientWidth; + const scrollbarHeight = container.offsetHeight - container.clientHeight; + const clickXRelativeToContainer = e.clientX - containerRect.left; + const clickYRelativeToContainer = e.clientY - containerRect.top; + const isVerticalScrollbar = + scrollbarWidth > 0 && clickXRelativeToContainer > container.clientWidth; + const isHorizontalScrollbar = + scrollbarHeight > 0 && clickYRelativeToContainer > container.clientHeight; + + if (isHorizontalScrollbar || isVerticalScrollbar) { + return false; + } - // Don't start marquee if clicking on scrollbar - if (isHorizontalScrollbar || isVerticalScrollbar) { - return false; - } + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; - - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; - - // Start autoscroll for marquee selection - startAutoScroll(); - - // Prevent text selection and other drag behaviors during marquee - e.preventDefault(); - e.stopPropagation(); - - return true; - }, [scrollContainerRef, startAutoScroll]); - - const handleMouseDown = useCallback((e: MouseEvent) => { - // Only start marquee on left click - if (e.button !== 0) return; - - // Don't start if clicking on a card or interactive element - const target = e.target as HTMLElement; - - // Clear native text selection when clicking on workspace background - // (MarqueeSelector captures background clicks before they reach WorkspaceSection) - window.getSelection()?.removeAllRanges(); - - // Check if clicking on any interactive or grid element - if ( - target.closest('article') || // Card itself - target.closest('button') || // Any button - target.closest('[role="button"]') || // Button role elements - target.closest('.react-grid-item') || // Grid item wrapper - target.classList.contains('drag-handle') || // Drag handle - target.tagName === 'INPUT' || // Input fields - target.tagName === 'TEXTAREA' // Text areas - ) { - return; - } + startPointRef.current = { x, y }; + setIsSelecting(true); + isDraggingRef.current = true; + startAutoScroll(); - startMarqueeSelection(e); - }, [startMarqueeSelection]); + e.preventDefault(); + e.stopPropagation(); - // Global shift+drag handler - activates marquee from ANYWHERE in the app when Shift is held - // Takes precedence over card dragging and other interactions - const handleGlobalShiftMouseDown = useCallback((e: MouseEvent) => { - // Only activate on left click with Shift held - if (e.button !== 0 || !e.shiftKey) return; + return true; + }, + [scrollContainerRef, startAutoScroll], + ); - // Don't interfere with text inputs - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } + const handleMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0) return; + + const target = e.target as HTMLElement; + window.getSelection()?.removeAllRanges(); + + if ( + target.closest('[id^="item-"]') || + target.closest("button") || + target.closest('[role="button"]') || + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } - const container = scrollContainerRef.current; - if (!container) return; + startMarqueeSelection(e); + }, + [startMarqueeSelection], + ); - const containerRect = container.getBoundingClientRect(); + const handleGlobalShiftMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0 || !e.shiftKey) return; + + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } - // Use raw mouse position - marquee can start from anywhere - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; + const container = scrollContainerRef.current; + if (!container) return; - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; + const containerRect = container.getBoundingClientRect(); + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - // Start autoscroll for marquee selection - startAutoScroll(); + startPointRef.current = { x, y }; + setIsSelecting(true); + isDraggingRef.current = true; + startAutoScroll(); - // Prevent text selection and other drag behaviors during marquee - e.preventDefault(); - e.stopPropagation(); - }, [scrollContainerRef, startAutoScroll]); + e.preventDefault(); + e.stopPropagation(); + }, + [scrollContainerRef, startAutoScroll], + ); - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!isDraggingRef.current || isGridDragging) return; + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current) return; - const container = scrollContainerRef.current; - if (!container) return; + const container = scrollContainerRef.current; + if (!container) return; - const containerRect = container.getBoundingClientRect(); - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; + const containerRect = container.getBoundingClientRect(); + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - // Calculate marquee rectangle - const rect = createRectFromPoints( - startPointRef.current.x, - startPointRef.current.y, - x, - y - ); - setMarqueeRect(rect); - }, [scrollContainerRef, isGridDragging]); + const rect = createRectFromPoints( + startPointRef.current.x, + startPointRef.current.y, + x, + y, + ); + setMarqueeRect(rect); + }, + [scrollContainerRef], + ); const handleMouseUp = useCallback(() => { if (!isDraggingRef.current) return; isDraggingRef.current = false; setIsSelecting(false); - - // Stop autoscroll when selection ends stopAutoScroll(); - // Get intersecting cards and add to selection if (marqueeRect && marqueeRect.width > 3 && marqueeRect.height > 3) { const intersecting = getIntersectingCards( marqueeRect, cardIds, - scrollContainerRef.current + scrollContainerRef.current, ); if (intersecting.length > 0) { - // Combine with existing selection (always add) const newSelection = [...Array.from(selectedCardIds), ...intersecting]; selectMultipleCards(newSelection); } } setMarqueeRect(null); - }, [marqueeRect, cardIds, scrollContainerRef, selectMultipleCards, selectedCardIds, stopAutoScroll]); + }, [ + cardIds, + marqueeRect, + scrollContainerRef, + selectMultipleCards, + selectedCardIds, + stopAutoScroll, + ]); - // Set up mouse event listeners on scroll container (normal marquee) useEffect(() => { const container = scrollContainerRef.current; if (!container) return; - container.addEventListener('mousedown', handleMouseDown); + container.addEventListener("mousedown", handleMouseDown); return () => { - container.removeEventListener('mousedown', handleMouseDown); + container.removeEventListener("mousedown", handleMouseDown); }; }, [handleMouseDown, scrollContainerRef]); - // Set up global shift+drag listener (takes precedence, captures phase) useEffect(() => { - // Use capture phase to intercept before other handlers (like card drag) - document.addEventListener('mousedown', handleGlobalShiftMouseDown, { capture: true }); + document.addEventListener("mousedown", handleGlobalShiftMouseDown, { + capture: true, + }); return () => { - document.removeEventListener('mousedown', handleGlobalShiftMouseDown, { capture: true }); + document.removeEventListener("mousedown", handleGlobalShiftMouseDown, { + capture: true, + }); }; }, [handleGlobalShiftMouseDown]); - // Set up global mouse event listeners for move and up useEffect(() => { - if (isSelecting) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + if (!isSelecting) return; - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - } + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; }, [isSelecting, handleMouseMove, handleMouseUp]); - // Calculate display rectangle (relative to viewport) - const displayRect = marqueeRect && scrollContainerRef.current ? { - left: marqueeRect.left - scrollContainerRef.current.scrollLeft, - top: marqueeRect.top - scrollContainerRef.current.scrollTop, - width: marqueeRect.width, - height: marqueeRect.height, - } : null; - - // Render marquee via portal to escape stacking context and appear above sidebar - const marqueeElement = isSelecting && displayRect && displayRect.width > 0 && displayRect.height > 0 ? ( -
- {displayRect.width > 120 && displayRect.height > 30 && ( - - Select items in workspace - - )} -
- ) : null; - - return typeof document !== 'undefined' && marqueeElement + const displayRect = + marqueeRect && scrollContainerRef.current + ? { + left: marqueeRect.left - scrollContainerRef.current.scrollLeft, + top: marqueeRect.top - scrollContainerRef.current.scrollTop, + width: marqueeRect.width, + height: marqueeRect.height, + } + : null; + + const marqueeElement = + isSelecting && displayRect && displayRect.width > 0 && displayRect.height > 0 ? ( +
+ {displayRect.width > 120 && displayRect.height > 30 && ( + + Select items in workspace + + )} +
+ ) : null; + + return typeof document !== "undefined" && marqueeElement ? createPortal(marqueeElement, document.body) : null; } - diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 740a4efc7..4d6e8ea63 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, memo, useRef, useEffect } from "react"; +import { useCallback, useState, memo, type CSSProperties } from "react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { @@ -34,10 +34,6 @@ interface WorkspaceCardProps { onMoveItem?: (itemId: string, folderId: string | null) => void; } -/** - * Individual workspace card component. - * Handles rendering a single card with drag handle, options menu, and content. - */ function WorkspaceCard({ item, allItems, @@ -60,34 +56,6 @@ function WorkspaceCard({ const [showRenameDialog, setShowRenameDialog] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); - const mouseDownRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; - - const handlersRef = useRef<{ - handleGlobalMouseMove: ((e: MouseEvent) => void) | null; - handleGlobalMouseUp: (() => void) | null; - }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - - useEffect(() => { - const handlers = handlersRef.current; - return () => { - if ( - listenersActiveRef.current && - handlers.handleGlobalMouseMove && - handlers.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlers.handleGlobalMouseMove, - ); - document.removeEventListener("mouseup", handlers.handleGlobalMouseUp); - listenersActiveRef.current = false; - } - }; - }, []); - const handleNameChange = useCallback( (v: string) => { onUpdateItem(item.id, { name: v }); @@ -160,127 +128,35 @@ function WorkspaceCard({ } }, [item.type, item.data]); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest("button") || + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || target.closest("input") || target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || target.closest('[role="menuitem"]') || - target.closest('[contenteditable="true"]') - ) { - e.stopPropagation(); - return; - } - - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - e.stopPropagation(); - return; - } - - mouseDownRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; - - if (!listenersActiveRef.current) { - const handleGlobalMouseMove = (e: MouseEvent) => { - if (!mouseDownRef.current) return; - - const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - - if (hasMovedRef.current) { - return; - } - - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - mouseDownRef.current = null; - hasMovedRef.current = false; - return; - } - - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }; - - const handleGlobalMouseUp = () => { - mouseDownRef.current = null; - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - handlersRef.current.handleGlobalMouseMove = null; - handlersRef.current.handleGlobalMouseUp = null; - } - }; - - handlersRef.current.handleGlobalMouseMove = handleGlobalMouseMove; - handlersRef.current.handleGlobalMouseUp = handleGlobalMouseUp; - document.addEventListener("mousemove", handleGlobalMouseMove); - document.addEventListener("mouseup", handleGlobalMouseUp); - listenersActiveRef.current = true; - } - }, - [DRAG_THRESHOLD], - ); - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!mouseDownRef.current) return; - - const target = e.target as HTMLElement; - if ( - target.closest("textarea") || - target.closest("input") || - target.closest('[contenteditable="true"]') - ) { - mouseDownRef.current = null; - hasMovedRef.current = false; - } - }, []); - - const handleMouseUp = useCallback(() => {}, []); - - const handleCardClick = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest('[data-slot="dropdown-menu-item"]') || + target.closest('[contenteditable="true"]') || target.closest('[data-slot="dropdown-menu-content"]') || target.closest('[data-slot="dropdown-menu-trigger"]') || target.closest('[data-slot="popover-content"]') || target.closest('[data-slot="popover"]') || target.closest('[data-slot="dialog-content"]') || target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]') - ) { + target.closest('[data-slot="dialog-overlay"]'), + ); + }, []); + + const handleCardClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { e.preventDefault(); e.stopPropagation(); return; } - if (item.type === "flashcard") { - const flashcardElement = target.closest( - '.flashcard-container, .flashcard, [class*="flashcard"]', - ); - if (flashcardElement) { - e.stopPropagation(); - return; - } - } - const selection = window.getSelection(); if (selection && selection.toString().length > 0) { return; @@ -292,10 +168,7 @@ function WorkspaceCard({ return; } - const wasDragging = hasMovedRef.current; - hasMovedRef.current = false; - - if (wasDragging || isEditingTitle) { + if (isEditingTitle) { e.preventDefault(); e.stopPropagation(); return; @@ -303,7 +176,7 @@ function WorkspaceCard({ onOpenModal(item.id); }, - [isEditingTitle, item.id, item.type, onOpenModal, onToggleSelection], + [isEditingTitle, isInteractiveTarget, item.id, onOpenModal, onToggleSelection], ); const handleMove = useCallback( @@ -361,23 +234,17 @@ function WorkspaceCard({ : undefined, transition: "border-color 150ms ease-out, box-shadow 150ms ease-out, background-color 150ms ease-out", - } as React.CSSProperties + } as CSSProperties } - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseUp} onClick={handleCardClick} > {}} onToggleSelection={() => onToggleSelection(item.id)} onOpenRename={() => setShowRenameDialog(true)} onOpenMove={() => setShowMoveDialog(true)} diff --git a/src/components/workspace-canvas/WorkspaceCardActions.tsx b/src/components/workspace-canvas/WorkspaceCardActions.tsx index 2f30c53cd..bb2950c35 100644 --- a/src/components/workspace-canvas/WorkspaceCardActions.tsx +++ b/src/components/workspace-canvas/WorkspaceCardActions.tsx @@ -11,7 +11,6 @@ import { X, Pencil, } from "lucide-react"; -import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; import { cn } from "@/lib/utils"; import type { Item } from "@/lib/workspace-state/types"; import { @@ -142,14 +141,11 @@ function getFloatingControlHandlers({ interface WorkspaceCardControlsProps { itemType: Item["type"]; - showScrollLockButton: boolean; useDarkOverlay: boolean; resolvedTheme?: string; - isScrollLocked: boolean; isSelected: boolean; isEditingTitle: boolean; canMove: boolean; - onToggleScrollLock: () => void; onToggleSelection: () => void; onOpenRename: () => void; onOpenMove: () => void; @@ -160,14 +156,11 @@ interface WorkspaceCardControlsProps { export function WorkspaceCardControls({ itemType, - showScrollLockButton, useDarkOverlay, resolvedTheme, - isScrollLocked, isSelected, isEditingTitle, canMove, - onToggleScrollLock, onToggleSelection, onOpenRename, onOpenMove, @@ -195,11 +188,6 @@ export function WorkspaceCardControls({ ? "rgba(239, 68, 68, 0.6)" : "rgba(239, 68, 68, 0.5)" : defaultHoverBackgroundColor; - const scrollLockHandlers = getFloatingControlHandlers({ - defaultBackgroundColor, - hoverBackgroundColor: defaultHoverBackgroundColor, - onClick: onToggleScrollLock, - }); const selectionHandlers = getFloatingControlHandlers({ defaultBackgroundColor: selectionBackgroundColor, hoverBackgroundColor: selectionHoverBackgroundColor, @@ -217,38 +205,6 @@ export function WorkspaceCardControls({ isEditingTitle ? "" : "opacity-0 group-hover:opacity-100", )} > - {showScrollLockButton && ( - - )} -
From 7cbdf854cbef3df778ce38e13c7cd84ce71d55c4 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 04:07:26 +0000 Subject: [PATCH 04/32] Fix workspace marquee and loader sync Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/MarqueeSelector.tsx | 77 +++++++++++-------- src/components/workspace/WorkspaceLoader.tsx | 18 ++--- 2 files changed, 52 insertions(+), 43 deletions(-) diff --git a/src/components/workspace-canvas/MarqueeSelector.tsx b/src/components/workspace-canvas/MarqueeSelector.tsx index 3d1ae8fe8..36ca9ad9c 100644 --- a/src/components/workspace-canvas/MarqueeSelector.tsx +++ b/src/components/workspace-canvas/MarqueeSelector.tsx @@ -32,6 +32,47 @@ export function MarqueeSelector({ const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = useAutoScroll(scrollContainerRef as React.RefObject); + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest('[id^="item-"]') || + target.closest("button") || + target.closest('[role="button"]') || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="popover-content"]') || + target.closest('[data-slot="popover"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]') || + target.isContentEditable, + ); + }, []); + + const canStartMarqueeFromEvent = useCallback( + (e: MouseEvent) => { + const container = scrollContainerRef.current; + const target = e.target; + + if (!container || !(target instanceof HTMLElement)) { + return false; + } + + if (!container.contains(target)) { + return false; + } + + return !isInteractiveTarget(target); + }, + [isInteractiveTarget, scrollContainerRef], + ); + useEffect(() => { if (isSelecting) { document.body.classList.add("marquee-selecting"); @@ -92,54 +133,28 @@ export function MarqueeSelector({ (e: MouseEvent) => { if (e.button !== 0) return; - const target = e.target as HTMLElement; window.getSelection()?.removeAllRanges(); - if ( - target.closest('[id^="item-"]') || - target.closest("button") || - target.closest('[role="button"]') || - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { + if (!canStartMarqueeFromEvent(e)) { return; } startMarqueeSelection(e); }, - [startMarqueeSelection], + [canStartMarqueeFromEvent, startMarqueeSelection], ); const handleGlobalShiftMouseDown = useCallback( (e: MouseEvent) => { if (e.button !== 0 || !e.shiftKey) return; - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { + if (!canStartMarqueeFromEvent(e)) { return; } - const container = scrollContainerRef.current; - if (!container) return; - - const containerRect = container.getBoundingClientRect(); - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; - - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; - startAutoScroll(); - - e.preventDefault(); - e.stopPropagation(); + startMarqueeSelection(e); }, - [scrollContainerRef, startAutoScroll], + [canStartMarqueeFromEvent, startMarqueeSelection], ); const handleMouseMove = useCallback( diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 3151f778c..8081f8adb 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -7,14 +7,8 @@ import { THREAD_TOP_INSET } from "@/components/chat/thread-layout"; import { PANEL_DEFAULTS } from "@/lib/layout-constants"; import { useUIStore } from "@/lib/stores/ui-store"; -/** Must stay in sync with `WorkspaceGrid` rowHeight + GRID_MARGIN + default 1-col h. */ -const GRID_ROW_HEIGHT_PX = 25; -const GRID_MARGIN_Y_PX = 16; -/** Default single-column card height in grid rows for standard cards. */ -const DEFAULT_CARD_GRID_ROWS = 4; -const SKELETON_CARD_HEIGHT_PX = - DEFAULT_CARD_GRID_ROWS * GRID_ROW_HEIGHT_PX + - (DEFAULT_CARD_GRID_ROWS - 1) * GRID_MARGIN_Y_PX; +/** Must stay in sync with the smallest `WorkspaceGrid` card wrapper min-height (`min-h-[14rem]`). */ +const SKELETON_CARD_HEIGHT_PX = 14 * 16; /** * Full-shell skeleton: header bar + bento card grid + (when chat is open) * a chat-panel slot. Used by `ZeroProvider` while the session/Zero client @@ -57,10 +51,10 @@ export function WorkspaceLoader() { /** * In-shell loader for `view.kind === "loading"` inside `WorkspaceSection`. * Padding/gap mirror `WorkspaceContent` + `WorkspaceGrid`: outer `py-4`, - * horizontal inset 16px (`px-4`, same as RGL `containerPadding` [16, 0]), - * `gap-4` (= 16px) like `GRID_MARGIN` [16, 16]. Tile height matches RGL’s - * pixel height for a default h=4 card (rowHeight×h + marginY×(h−1)). Ghost - * tiles use a flat muted fill (not solid `Skeleton` blocks). + * horizontal inset 16px (`px-4`), and `gap-4`. Tile height matches the + * smallest simplified `WorkspaceGrid` card wrapper so loading cards do not + * collapse relative to the real canvas. Ghost tiles use a flat muted fill + * (not solid `Skeleton` blocks). */ export function WorkspaceCardsLoader() { return ( From ddbddc618c97c9662a74805ca75fa684cb05458c Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 04:10:36 +0000 Subject: [PATCH 05/32] Use rem height for workspace loader skeleton Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/workspace/WorkspaceLoader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/workspace/WorkspaceLoader.tsx b/src/components/workspace/WorkspaceLoader.tsx index 8081f8adb..3354dc856 100644 --- a/src/components/workspace/WorkspaceLoader.tsx +++ b/src/components/workspace/WorkspaceLoader.tsx @@ -8,7 +8,7 @@ import { PANEL_DEFAULTS } from "@/lib/layout-constants"; import { useUIStore } from "@/lib/stores/ui-store"; /** Must stay in sync with the smallest `WorkspaceGrid` card wrapper min-height (`min-h-[14rem]`). */ -const SKELETON_CARD_HEIGHT_PX = 14 * 16; +const SKELETON_CARD_HEIGHT = "14rem"; /** * Full-shell skeleton: header bar + bento card grid + (when chat is open) * a chat-panel slot. Used by `ZeroProvider` while the session/Zero client @@ -71,7 +71,7 @@ export function WorkspaceCardsLoader() { "animate-pulse", )} style={{ - height: SKELETON_CARD_HEIGHT_PX, + height: SKELETON_CARD_HEIGHT, animationDelay: `${i * 80}ms`, }} /> From 61ccd56404a14e2851e0194a5d8afbcd72852b33 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 04:52:24 +0000 Subject: [PATCH 06/32] Add workspace item sort order support Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- drizzle/0003_lumpy_jamie_braddock.sql | 17 + drizzle/meta/0003_snapshot.json | 2119 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../workspace/use-workspace-operations.ts | 75 +- src/hooks/workspace/use-workspace-state.ts | 26 +- src/lib/db/schema.ts | 9 +- src/lib/workspace-state/order.ts | 29 + src/lib/workspace-state/types.ts | 1 + .../workspace/workspace-item-model-types.ts | 1 + src/lib/workspace/workspace-item-model.ts | 8 +- src/lib/workspace/workspace-item-write.ts | 49 +- src/lib/workspace/workspace-state-read.ts | 9 +- .../mutator-extracted-regressions.test.ts | 108 +- src/lib/zero/mutators.ts | 276 ++- src/lib/zero/server-mutators.ts | 5 + src/lib/zero/zero-schema.gen.ts | 1 + 16 files changed, 2702 insertions(+), 38 deletions(-) create mode 100644 drizzle/0003_lumpy_jamie_braddock.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/lib/workspace-state/order.ts diff --git a/drizzle/0003_lumpy_jamie_braddock.sql b/drizzle/0003_lumpy_jamie_braddock.sql new file mode 100644 index 000000000..700219b3e --- /dev/null +++ b/drizzle/0003_lumpy_jamie_braddock.sql @@ -0,0 +1,17 @@ +ALTER TABLE "workspace_items" ADD COLUMN "sort_order" integer;--> statement-breakpoint +WITH "ranked_workspace_items" AS ( + SELECT + "workspace_id", + "item_id", + ROW_NUMBER() OVER ( + PARTITION BY "workspace_id", "folder_id", ("type" = 'folder') + ORDER BY "created_at" ASC, "item_id" ASC + ) - 1 AS "sort_order" + FROM "workspace_items" +) +UPDATE "workspace_items" AS "workspace_items" +SET "sort_order" = "ranked_workspace_items"."sort_order" +FROM "ranked_workspace_items" +WHERE "workspace_items"."workspace_id" = "ranked_workspace_items"."workspace_id" + AND "workspace_items"."item_id" = "ranked_workspace_items"."item_id";--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_folder_type_sort_order" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"folder_id" text_ops,"type" text_ops,"sort_order" int4_ops); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..b157025d6 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2119 @@ +{ + "id": "3f6d5193-71d1-4f7f-98f7-4ab9c405d12f", + "prevId": "77a2e4b0-3244-4eda-8ffd-41ba7999b3e1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_messages_thread_message_key": { + "name": "chat_messages_thread_message_key", + "nullsNotDistinct": false, + "columns": ["thread_id", "message_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "head_message_id": { + "name": "head_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "chat_threads_user_scoped": { + "name": "chat_threads_user_scoped", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "((chat_threads.user_id = (auth.jwt() ->> 'sub'::text))\n AND ((EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": ["invite_token"] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id", "user_id"] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_content": { + "name": "workspace_item_content", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "structured_data": { + "name": "structured_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "asset_data": { + "name": "asset_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embed_data": { + "name": "embed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_data": { + "name": "source_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_content_workspace": { + "name": "idx_workspace_item_content_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_content_workspace_item_fkey": { + "name": "workspace_item_content_workspace_item_fkey", + "tableFrom": "workspace_item_content", + "tableTo": "workspace_items", + "columnsFrom": ["workspace_id", "item_id"], + "columnsTo": ["workspace_id", "item_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_content_pkey": { + "name": "workspace_item_content_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace item content they have access to": { + "name": "Users can read workspace item content they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace item content they have write access to": { + "name": "Users can write workspace item content they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_extracted": { + "name": "workspace_item_extracted", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_preview": { + "name": "content_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_text": { + "name": "ocr_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_text": { + "name": "transcript_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_pages": { + "name": "ocr_pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transcript_segments": { + "name": "transcript_segments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_extracted_workspace": { + "name": "idx_workspace_item_extracted_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_extracted_workspace_item_fkey": { + "name": "workspace_item_extracted_workspace_item_fkey", + "tableFrom": "workspace_item_extracted", + "tableTo": "workspace_items", + "columnsFrom": ["workspace_id", "item_id"], + "columnsTo": ["workspace_id", "item_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_extracted_pkey": { + "name": "workspace_item_extracted_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace item extracted data they have access to": { + "name": "Users can read workspace item extracted data they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace item extracted data they have write access to": { + "name": "Users can write workspace item extracted data they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_items": { + "name": "workspace_items", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_ocr": { + "name": "has_ocr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ocr_status": { + "name": "ocr_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_page_count": { + "name": "ocr_page_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_transcript": { + "name": "has_transcript", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_count": { + "name": "source_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_items_workspace": { + "name": "idx_workspace_items_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_folder": { + "name": "idx_workspace_items_workspace_folder", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_type": { + "name": "idx_workspace_items_workspace_type", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_folder_type_sort_order": { + "name": "idx_workspace_items_workspace_folder_type_sort_order", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_updated": { + "name": "idx_workspace_items_workspace_updated", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_version": { + "name": "idx_workspace_items_workspace_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "source_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_processing": { + "name": "idx_workspace_items_workspace_processing", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_ocr": { + "name": "idx_workspace_items_workspace_ocr", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "has_ocr", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "ocr_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_ocr_page_count": { + "name": "idx_workspace_items_workspace_ocr_page_count", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "ocr_page_count", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_has_transcript": { + "name": "idx_workspace_items_workspace_has_transcript", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "has_transcript", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_source_count": { + "name": "idx_workspace_items_workspace_source_count", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "source_count", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_items_workspace_id_fkey": { + "name": "workspace_items_workspace_id_fkey", + "tableFrom": "workspace_items", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_items_pkey": { + "name": "workspace_items_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace items they have access to": { + "name": "Users can read workspace items they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace items they have write access to": { + "name": "Users can write workspace items they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["authenticated"], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c5478bf09..6ad060dd6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775793600000, "tag": "0002_zero_publication", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777697286614, + "tag": "0003_lumpy_jamie_braddock", + "breakpoints": true } ] } diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 3325e611e..ab8e9ff65 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -19,6 +19,10 @@ import { getNextUniqueDefaultName, } from "@/lib/workspace/unique-name"; import { filterItemIdsForFolderCreation } from "@/lib/workspace-state/search"; +import { + getWorkspaceItemLane, + sortWorkspaceItemsByOrder, +} from "@/lib/workspace-state/order"; import { sanitizeWorkspaceItemChanges, sanitizeWorkspaceItemForPersistence, @@ -42,6 +46,7 @@ type WorkspaceItemMutatorChanges = { data?: JsonObject; color?: string | null; folderId?: string | null; + sortOrder?: number | null; layout?: JsonObject | null; lastModified?: number; }; @@ -60,6 +65,24 @@ function getAllDescendantIds(folderId: string, items: Item[]): string[] { return descendantIds; } +function getNextSortOrderForItem( + items: Item[], + item: Pick, +): number { + const siblings = items.filter( + (candidate) => + getWorkspaceItemLane(candidate) === getWorkspaceItemLane(item) && + (candidate.folderId ?? null) === (item.folderId ?? null), + ); + const maxSortOrder = siblings.reduce( + (max, candidate) => + candidate.sortOrder == null ? max : Math.max(max, candidate.sortOrder), + -1, + ); + + return Math.max(maxSortOrder + 1, siblings.length); +} + function toMutatorChanges(changes: Partial): WorkspaceItemMutatorChanges { const sanitized = sanitizeWorkspaceItemChanges(changes); const mutatorChanges: WorkspaceItemMutatorChanges = {}; @@ -79,6 +102,9 @@ function toMutatorChanges(changes: Partial): WorkspaceItemMutatorChanges { if (sanitized.folderId !== undefined) { mutatorChanges.folderId = sanitized.folderId ?? null; } + if (sanitized.sortOrder !== undefined) { + mutatorChanges.sortOrder = sanitized.sortOrder ?? null; + } if (sanitized.layout !== undefined) { mutatorChanges.layout = (sanitized.layout as JsonObject | undefined) ?? null; @@ -359,11 +385,7 @@ export function useWorkspaceOperations( }, []); const createItem = useCallback( - ( - type: CardType, - name?: string, - initialData?: Partial, - ) => { + (type: CardType, name?: string, initialData?: Partial) => { const validTypes: CardType[] = [ "pdf", "flashcard", @@ -387,7 +409,10 @@ export function useWorkspaceOperations( const mergedData = initialData ? { ...baseData, ...initialData } : baseData; - + const sortOrder = getNextSortOrderForItem(currentItemsRef.current, { + type: validType, + folderId: activeFolderId ?? undefined, + }); const item = sanitizeWorkspaceItemForPersistence({ id, @@ -397,6 +422,7 @@ export function useWorkspaceOperations( data: mergedData as ItemData, color: getRandomCardColor(), folderId: activeFolderId ?? undefined, + sortOrder, }); if (workspaceIdRef.current) { @@ -412,6 +438,7 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, + sortOrder: item.sortOrder ?? null, }, }), ); @@ -470,7 +497,13 @@ export function useWorkspaceOperations( const mergedData = initialData ? { ...baseData, ...initialData } : baseData; - + const sortOrder = getNextSortOrderForItem( + [...currentItemsRef.current, ...itemsSoFar], + { + type: validType, + folderId: activeFolderId ?? undefined, + }, + ); const newItem = sanitizeWorkspaceItemForPersistence({ id, @@ -480,6 +513,7 @@ export function useWorkspaceOperations( data: mergedData as ItemData, color: getRandomCardColor(), folderId: activeFolderId ?? undefined, + sortOrder, }); itemsSoFar.push(newItem); return newItem; @@ -509,6 +543,7 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, + sortOrder: item.sortOrder ?? null, })), }), ); @@ -634,6 +669,7 @@ export function useWorkspaceOperations( data: item.data as JsonObject, color: item.color ?? null, folderId: item.folderId ?? null, + sortOrder: item.sortOrder ?? null, })), previousItemCount, }), @@ -641,13 +677,16 @@ export function useWorkspaceOperations( } return; } - }, []); const createFolder = useCallback( (name: string, color?: CardColor): string => { const folderId = crypto.randomUUID(); const activeFolderId = useUIStore.getState().activeFolderId; + const sortOrder = getNextSortOrderForItem(currentItemsRef.current, { + type: "folder", + folderId: activeFolderId ?? undefined, + }); const folder = sanitizeWorkspaceItemForPersistence({ id: folderId, type: "folder", @@ -656,6 +695,7 @@ export function useWorkspaceOperations( data: defaultDataFor("folder") as ItemData, color: color || getRandomCardColor(), folderId: activeFolderId ?? undefined, + sortOrder, }); if (workspaceIdRef.current) { @@ -671,6 +711,7 @@ export function useWorkspaceOperations( data: folder.data as JsonObject, color: folder.color ?? null, folderId: folder.folderId ?? null, + sortOrder: folder.sortOrder ?? null, }, }), ); @@ -698,6 +739,10 @@ export function useWorkspaceOperations( } const folderId = crypto.randomUUID(); + const sortOrder = getNextSortOrderForItem(getLatestItemsFromState(), { + type: "folder", + folderId: activeFolderId ?? undefined, + }); const folder = sanitizeWorkspaceItemForPersistence({ id: folderId, type: "folder", @@ -706,6 +751,7 @@ export function useWorkspaceOperations( data: defaultDataFor("folder") as ItemData, color: color || getRandomCardColor(), folderId: activeFolderId ?? undefined, + sortOrder, }); if (workspaceIdRef.current) { @@ -720,6 +766,7 @@ export function useWorkspaceOperations( data: folder.data as JsonObject, color: folder.color ?? null, folderId: folder.folderId ?? null, + sortOrder: folder.sortOrder ?? null, }, itemIds: safeItemIds, }), @@ -839,16 +886,18 @@ export function useWorkspaceOperations( return; } - const itemNames = itemIds - .map( - (id) => currentItemsRef.current.find((item) => item.id === id)?.name, - ) + const orderedItems = sortWorkspaceItemsByOrder( + currentItemsRef.current.filter((item) => itemIds.includes(item.id)), + ); + const orderedItemIds = orderedItems.map((item) => item.id); + const itemNames = orderedItems + .map((item) => item.name) .filter((name): name is string => name != null); zeroRef.current.mutate( mutators.item.moveMany({ workspaceId: currentWorkspaceId, - itemIds, + itemIds: orderedItemIds, folderId, itemNames, }), diff --git a/src/hooks/workspace/use-workspace-state.ts b/src/hooks/workspace/use-workspace-state.ts index f3f8be102..3e585f1da 100644 --- a/src/hooks/workspace/use-workspace-state.ts +++ b/src/hooks/workspace/use-workspace-state.ts @@ -15,6 +15,29 @@ type WorkspaceItemQueryRow = WorkspaceItemsRow & { | WorkspaceItemContentRow; }; +function compareWorkspaceItemQueryRows( + a: WorkspaceItemQueryRow, + b: WorkspaceItemQueryRow, +): number { + const sortOrderDiff = + (a.sortOrder ?? Number.MAX_SAFE_INTEGER) - + (b.sortOrder ?? Number.MAX_SAFE_INTEGER); + + if (sortOrderDiff !== 0) { + return sortOrderDiff; + } + + const createdAtDiff = + (a.createdAt ?? Number.MAX_SAFE_INTEGER) - + (b.createdAt ?? Number.MAX_SAFE_INTEGER); + + if (createdAtDiff !== 0) { + return createdAtDiff; + } + + return a.itemId.localeCompare(b.itemId); +} + const LOADING_TIMEOUT_MS = 8_000; export type WorkspaceStateStatus = @@ -66,7 +89,7 @@ export function useWorkspaceState( return []; } - return rows.map((row) => { + return [...rows].sort(compareWorkspaceItemQueryRows).map((row) => { const contentRow = Array.isArray(row.workspaceItemContent) ? (row.workspaceItemContent[0] ?? null) : (row.workspaceItemContent ?? null); @@ -79,6 +102,7 @@ export function useWorkspaceState( subtitle: row.subtitle, color: (row.color as Item["color"]) ?? null, folderId: row.folderId ?? null, + sortOrder: row.sortOrder ?? null, layout: (row.layout as Item["layout"]) ?? null, lastModified: row.lastModified ?? null, ocrStatus: row.ocrStatus ?? null, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index fbac899a0..108afeea1 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -269,6 +269,7 @@ export const workspaceItems = pgTable( subtitle: text().default("").notNull(), color: text(), folderId: text("folder_id"), + sortOrder: integer("sort_order"), layout: jsonb(), lastModified: bigint("last_modified", { mode: "number" }), sourceVersion: integer("source_version").notNull(), @@ -306,6 +307,13 @@ export const workspaceItems = pgTable( table.workspaceId.asc().nullsLast().op("uuid_ops"), table.type.asc().nullsLast().op("text_ops"), ), + index("idx_workspace_items_workspace_folder_type_sort_order").using( + "btree", + table.workspaceId.asc().nullsLast().op("uuid_ops"), + table.folderId.asc().nullsLast().op("text_ops"), + table.type.asc().nullsLast().op("text_ops"), + table.sortOrder.asc().nullsLast().op("int4_ops"), + ), index("idx_workspace_items_workspace_updated").using( "btree", table.workspaceId.asc().nullsLast().op("uuid_ops"), @@ -487,7 +495,6 @@ export const workspaceItemExtracted = pgTable( ], ); - export const workspaceCollaborators = pgTable( "workspace_collaborators", { diff --git a/src/lib/workspace-state/order.ts b/src/lib/workspace-state/order.ts new file mode 100644 index 000000000..3ce7d5895 --- /dev/null +++ b/src/lib/workspace-state/order.ts @@ -0,0 +1,29 @@ +import type { Item } from "./types"; + +export function getWorkspaceItemLane( + item: Pick, +): "folders" | "items" { + return item.type === "folder" ? "folders" : "items"; +} + +export function getWorkspaceItemContainerId( + item: Pick, +): string | null { + return item.folderId ?? null; +} + +export function compareWorkspaceItemsByOrder(a: Item, b: Item): number { + const sortOrderDiff = + (a.sortOrder ?? Number.MAX_SAFE_INTEGER) - + (b.sortOrder ?? Number.MAX_SAFE_INTEGER); + + if (sortOrderDiff !== 0) { + return sortOrderDiff; + } + + return a.id.localeCompare(b.id); +} + +export function sortWorkspaceItemsByOrder(items: Item[]): Item[] { + return [...items].sort(compareWorkspaceItemsByOrder); +} diff --git a/src/lib/workspace-state/types.ts b/src/lib/workspace-state/types.ts index 73efed9c2..f240988a9 100644 --- a/src/lib/workspace-state/types.ts +++ b/src/lib/workspace-state/types.ts @@ -71,6 +71,7 @@ export interface Item { data: ItemData; color?: CardColor; folderId?: string; + sortOrder?: number | null; layout?: ResponsiveLayouts | LayoutPosition; lastModified?: number; } diff --git a/src/lib/workspace/workspace-item-model-types.ts b/src/lib/workspace/workspace-item-model-types.ts index 12d608fb8..cad152631 100644 --- a/src/lib/workspace/workspace-item-model-types.ts +++ b/src/lib/workspace/workspace-item-model-types.ts @@ -27,6 +27,7 @@ export interface WorkspaceItemShellProjection { subtitle: string; color: CardColor | null; folderId: string | null; + sortOrder: number | null; layout: ResponsiveLayouts | LayoutPosition | null; lastModified: number | null; dataSchemaVersion: number; diff --git a/src/lib/workspace/workspace-item-model.ts b/src/lib/workspace/workspace-item-model.ts index e63047750..6147184a0 100644 --- a/src/lib/workspace/workspace-item-model.ts +++ b/src/lib/workspace/workspace-item-model.ts @@ -14,7 +14,10 @@ import type { YouTubeData, } from "@/lib/workspace-state/types"; import { getOcrPagesTextContent } from "@/lib/utils/ocr-pages"; -import { emptyDataForType, itemDataSchemas } from "./workspace-item-model-schemas"; +import { + emptyDataForType, + itemDataSchemas, +} from "./workspace-item-model-schemas"; import { getItemContentPreview, getItemSearchIndex, @@ -381,6 +384,7 @@ export function splitWorkspaceItem(item: Item): WorkspaceItemSplitResult { subtitle: normalized.subtitle ?? "", color: normalized.color ?? null, folderId: normalized.folderId ?? null, + sortOrder: normalized.sortOrder ?? null, layout: normalized.layout ?? null, lastModified: normalized.lastModified ?? null, dataSchemaVersion: WORKSPACE_ITEM_DATA_SCHEMA_VERSION, @@ -434,6 +438,7 @@ export function rehydrateWorkspaceItem(params: { | "subtitle" | "color" | "folderId" + | "sortOrder" | "layout" | "lastModified" | "ocrStatus" @@ -460,6 +465,7 @@ export function rehydrateWorkspaceItem(params: { ), ...(shell.color ? { color: shell.color } : {}), ...(shell.folderId ? { folderId: shell.folderId } : {}), + ...(shell.sortOrder != null ? { sortOrder: shell.sortOrder } : {}), ...(shell.layout ? { layout: shell.layout } : {}), ...(shell.lastModified != null ? { lastModified: shell.lastModified } : {}), }; diff --git a/src/lib/workspace/workspace-item-write.ts b/src/lib/workspace/workspace-item-write.ts index e7eef9ed6..15ead4f0c 100644 --- a/src/lib/workspace/workspace-item-write.ts +++ b/src/lib/workspace/workspace-item-write.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull, ne, sql } from "drizzle-orm"; import { db, workspaceItemContent, @@ -20,6 +20,38 @@ type DbTransaction = Parameters<(typeof db)["transaction"]>[0] extends ( : never; type DbExecutor = typeof db | DbTransaction; +function getWorkspaceItemLaneFilter(type: Item["type"]) { + return type === "folder" + ? eq(workspaceItems.type, "folder") + : ne(workspaceItems.type, "folder"); +} + +export async function getNextWorkspaceItemSortOrder( + tx: DbExecutor, + params: { + workspaceId: string; + folderId?: string | null; + type: Item["type"]; + }, +): Promise { + const [row] = await tx + .select({ + maxSortOrder: sql`max(${workspaceItems.sortOrder})`, + }) + .from(workspaceItems) + .where( + and( + eq(workspaceItems.workspaceId, params.workspaceId), + params.folderId == null + ? isNull(workspaceItems.folderId) + : eq(workspaceItems.folderId, params.folderId), + getWorkspaceItemLaneFilter(params.type), + ), + ); + + return (row?.maxSortOrder ?? -1) + 1; +} + export async function loadWorkspaceItemRecord( tx: DbExecutor, params: { workspaceId: string; itemId: string }, @@ -70,6 +102,7 @@ export async function loadWorkspaceItemRecord( subtitle: shell.subtitle, color: (shell.color as Item["color"]) ?? null, folderId: shell.folderId ?? null, + sortOrder: shell.sortOrder ?? null, layout: (shell.layout as Item["layout"]) ?? null, lastModified: shell.lastModified ?? null, ocrStatus: shell.ocrStatus ?? null, @@ -115,9 +148,20 @@ export async function insertWorkspaceItem( sourceVersion?: number; }, ): Promise { + const item = + params.item.sortOrder == null + ? { + ...params.item, + sortOrder: await getNextWorkspaceItemSortOrder(tx, { + workspaceId: params.workspaceId, + folderId: params.item.folderId ?? null, + type: params.item.type, + }), + } + : params.item; const rows = buildWorkspaceItemTableRows({ workspaceId: params.workspaceId, - item: sanitizeWorkspaceItemForPersistence(params.item), + item: sanitizeWorkspaceItemForPersistence(item), sourceVersion: params.sourceVersion ?? 0, }); @@ -165,6 +209,7 @@ export async function upsertWorkspaceItem( subtitle: rows.item.subtitle, color: rows.item.color, folderId: rows.item.folderId, + sortOrder: rows.item.sortOrder, layout: rows.item.layout, lastModified: rows.item.lastModified, sourceVersion: rows.item.sourceVersion, diff --git a/src/lib/workspace/workspace-state-read.ts b/src/lib/workspace/workspace-state-read.ts index 45c19aa5c..6b471ec77 100644 --- a/src/lib/workspace/workspace-state-read.ts +++ b/src/lib/workspace/workspace-state-read.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { db, workspaceItemContent, @@ -26,7 +26,11 @@ async function readWorkspaceState( .select() .from(workspaceItems) .where(eq(workspaceItems.workspaceId, workspaceId)) - .orderBy(asc(workspaceItems.createdAt), asc(workspaceItems.itemId)); + .orderBy( + sql`${workspaceItems.sortOrder} asc nulls last`, + asc(workspaceItems.createdAt), + asc(workspaceItems.itemId), + ); if (shellRows.length === 0) { return []; @@ -85,6 +89,7 @@ async function readWorkspaceState( subtitle: shell.subtitle, color: (shell.color as Item["color"]) ?? null, folderId: shell.folderId ?? null, + sortOrder: shell.sortOrder ?? null, layout: (shell.layout as Item["layout"]) ?? null, lastModified: shell.lastModified ?? null, ocrStatus: shell.ocrStatus ?? null, diff --git a/src/lib/zero/__tests__/mutator-extracted-regressions.test.ts b/src/lib/zero/__tests__/mutator-extracted-regressions.test.ts index 2cd8bc058..c6691aa9f 100644 --- a/src/lib/zero/__tests__/mutator-extracted-regressions.test.ts +++ b/src/lib/zero/__tests__/mutator-extracted-regressions.test.ts @@ -284,10 +284,12 @@ function createServerTx( }; } -function createZeroTx(state: FakeState) { +function createZeroTx(state: FakeState, runQueue?: unknown[]) { + const queue = [...(runQueue ?? [])]; + return { mutate: createMutateApi(state), - run: vi.fn(async () => null), + run: vi.fn(async () => queue.shift() ?? null), }; } @@ -484,6 +486,108 @@ describe("zero mutator extracted-data regressions", () => { expect(state.shells.get(key)?.hasOcr).toBe(true); }); + it("item.create appends to the end of its container lane", async () => { + const state = createState(); + seedItem(state, { + id: "folder-1", + type: "folder", + name: "Folder 1", + subtitle: "", + data: {}, + sortOrder: 0, + }); + seedItem(state, { + id: "folder-2", + type: "folder", + name: "Folder 2", + subtitle: "", + data: {}, + sortOrder: 1, + }); + seedItem(state, { + id: "doc-1", + type: "document", + name: "Doc 1", + subtitle: "", + data: { markdown: "" }, + sortOrder: 0, + }); + + const zeroTx = createZeroTx(state, [ + Array.from(state.shells.values()).map((row) => clone(row)), + ]); + + await mutators.item.create.fn({ + tx: zeroTx as never, + ctx: { userId: USER_ID }, + args: { + workspaceId: WORKSPACE_ID, + id: "folder-3", + item: { + id: "folder-3", + type: "folder", + name: "Folder 3", + subtitle: "", + data: {}, + }, + }, + }); + + expect(state.shells.get(itemKey(WORKSPACE_ID, "folder-3"))?.sortOrder).toBe( + 2, + ); + }); + + it("item.move appends moved items to the destination lane", async () => { + const state = createState(); + seedItem(state, { + id: "doc-root", + type: "document", + name: "Doc root", + subtitle: "", + data: { markdown: "" }, + sortOrder: 0, + }); + seedItem(state, { + id: "doc-a", + type: "document", + name: "Doc A", + subtitle: "", + folderId: "folder-1", + data: { markdown: "" }, + sortOrder: 0, + }); + seedItem(state, { + id: "doc-b", + type: "document", + name: "Doc B", + subtitle: "", + folderId: "folder-1", + data: { markdown: "" }, + sortOrder: 1, + }); + + const zeroTx = createZeroTx(state, [ + clone(state.shells.get(itemKey(WORKSPACE_ID, "doc-root"))!), + clone(state.contents.get(itemKey(WORKSPACE_ID, "doc-root"))!), + Array.from(state.shells.values()).map((row) => clone(row)), + ]); + + await mutators.item.move.fn({ + tx: zeroTx as never, + ctx: { userId: USER_ID }, + args: { + workspaceId: WORKSPACE_ID, + itemId: "doc-root", + folderId: "folder-1", + }, + }); + + const moved = state.shells.get(itemKey(WORKSPACE_ID, "doc-root")); + expect(moved?.folderId).toBe("folder-1"); + expect(moved?.sortOrder).toBe(2); + }); + it("Client optimistic upsertItem no longer clobbers shell flags", async () => { const update = vi.fn(async (_row: Record) => {}); const upsert = vi.fn(async (_row: Record) => {}); diff --git a/src/lib/zero/mutators.ts b/src/lib/zero/mutators.ts index 93af39c1b..c98ff8ccb 100644 --- a/src/lib/zero/mutators.ts +++ b/src/lib/zero/mutators.ts @@ -14,6 +14,10 @@ import { sanitizeWorkspaceItemChanges, sanitizeWorkspaceItemForPersistence, } from "@/lib/workspace/workspace-item-sanitize"; +import { + getWorkspaceItemLane, + sortWorkspaceItemsByOrder, +} from "@/lib/workspace-state/order"; import { schema, zql } from "./zero-schema.gen"; type ZeroTx = Transaction; @@ -83,6 +87,7 @@ const itemSchema = z.object({ data: jsonObjectSchema, color: z.string().nullable().optional(), folderId: z.string().nullable().optional(), + sortOrder: z.number().int().nullable().optional(), layout: jsonObjectSchema.nullable().optional(), lastModified: z.number().int().optional(), }); @@ -97,6 +102,7 @@ const itemChangesSchema = z.object({ data: jsonObjectSchema.optional(), color: z.string().nullable().optional(), folderId: z.string().nullable().optional(), + sortOrder: z.number().int().nullable().optional(), layout: jsonObjectSchema.nullable().optional(), lastModified: z.number().int().optional(), }); @@ -160,6 +166,15 @@ export const zeroMutatorSchemas = { folderId: z.string().nullable(), itemNames: z.array(z.string()).optional(), }), + reorder: z.object({ + workspaceId: z.string().uuid(), + updates: z.array( + z.object({ + itemId: z.string(), + sortOrder: z.number().int(), + }), + ), + }), }, folder: { createWithItems: z.object({ @@ -183,6 +198,7 @@ function toItem(input: z.infer): Item { ...(input.folderId !== undefined ? { folderId: input.folderId ?? undefined } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), ...(input.layout !== undefined ? { layout: input.layout ?? undefined } : {}), @@ -214,6 +230,9 @@ function mergeItemChanges( ...(sanitizedChanges.folderId !== undefined ? { folderId: sanitizedChanges.folderId ?? undefined } : {}), + ...(sanitizedChanges.sortOrder !== undefined + ? { sortOrder: sanitizedChanges.sortOrder } + : {}), ...(sanitizedChanges.layout !== undefined ? { layout: sanitizedChanges.layout ?? undefined } : {}), @@ -253,6 +272,61 @@ function toMutateContentRow( }; } +async function loadWorkspaceShells( + tx: ZeroTx, + workspaceId: string, +): Promise { + const shells = + (await tx.run(zql.workspace_items.where("workspaceId", workspaceId))) ?? []; + + return shells.map((shell) => + rehydrateWorkspaceItem({ + shell: { + itemId: shell.itemId, + type: shell.type as Item["type"], + name: shell.name, + subtitle: shell.subtitle, + color: (shell.color as Item["color"]) ?? null, + folderId: shell.folderId ?? null, + sortOrder: shell.sortOrder ?? null, + layout: (shell.layout as Item["layout"] | undefined) ?? null, + lastModified: shell.lastModified ?? null, + ocrStatus: shell.ocrStatus ?? null, + processingStatus: shell.processingStatus ?? null, + }, + }), + ); +} + +function getNextSortOrderForItem( + items: Item[], + item: Pick, +) { + const siblings = items.filter( + (candidate) => + getWorkspaceItemLane(candidate) === getWorkspaceItemLane(item) && + (candidate.folderId ?? null) === (item.folderId ?? null), + ); + const maxSortOrder = siblings.reduce( + (max, candidate) => + candidate.sortOrder == null ? max : Math.max(max, candidate.sortOrder), + -1, + ); + + return Math.max(maxSortOrder + 1, siblings.length); +} + +function withAssignedSortOrder(item: Item, items: Item[]): Item { + if (item.sortOrder != null) { + return item; + } + + return { + ...item, + sortOrder: getNextSortOrderForItem(items, item), + }; +} + async function loadItem( tx: ZeroTx, params: { workspaceId: string; itemId: string }, @@ -287,6 +361,7 @@ async function loadItem( subtitle: shell.subtitle, color: (shell.color as Item["color"]) ?? null, folderId: shell.folderId ?? null, + sortOrder: shell.sortOrder ?? null, layout: (shell.layout as Item["layout"] | undefined) ?? null, lastModified: shell.lastModified ?? null, ocrStatus: shell.ocrStatus ?? null, @@ -319,9 +394,16 @@ async function insertItem( userId: string | null; }, ) { + const item = + params.item.sortOrder != null + ? params.item + : withAssignedSortOrder( + params.item, + await loadWorkspaceShells(tx, params.workspaceId), + ); const rows = buildWorkspaceItemTableRows({ workspaceId: params.workspaceId, - item: params.item, + item, sourceVersion: params.sourceVersion ?? 0, }); @@ -387,6 +469,7 @@ async function updateShellOnly( shellChanges: Partial<{ layout: Item["layout"]; folderId: string | undefined; + sortOrder: number | null; lastModified: number; }>; }, @@ -400,6 +483,9 @@ async function updateShellOnly( ...("folderId" in params.shellChanges ? { folderId: params.shellChanges.folderId ?? null } : {}), + ...("sortOrder" in params.shellChanges + ? { sortOrder: params.shellChanges.sortOrder } + : {}), ...(params.shellChanges.lastModified !== undefined ? { lastModified: params.shellChanges.lastModified } : {}), @@ -460,11 +546,15 @@ export const mutators = defineMutators({ zeroMutatorSchemas.item.create, async ({ tx, ctx, args }) => { const now = Date.now(); - const item = toItem({ - ...args.item, - id: args.id, - lastModified: now, - }); + const existingItems = await loadWorkspaceShells(tx, args.workspaceId); + const item = withAssignedSortOrder( + toItem({ + ...args.item, + id: args.id, + lastModified: now, + }), + existingItems, + ); await insertItem(tx, { workspaceId: args.workspaceId, @@ -511,14 +601,21 @@ export const mutators = defineMutators({ zeroMutatorSchemas.item.createMany, async ({ tx, ctx, args }) => { const now = Date.now(); + const allItems = await loadWorkspaceShells(tx, args.workspaceId); for (const rawItem of args.items) { - await insertItem(tx, { - workspaceId: args.workspaceId, - item: toItem({ + const item = withAssignedSortOrder( + toItem({ ...rawItem, lastModified: now, }), + allItems, + ); + + allItems.push(item); + await insertItem(tx, { + workspaceId: args.workspaceId, + item, userId: ctx.userId, sourceVersion: 0, }); @@ -567,14 +664,21 @@ export const mutators = defineMutators({ if (args.addedItems?.length) { const now = Date.now(); + const allItems = await loadWorkspaceShells(tx, args.workspaceId); for (const rawItem of args.addedItems) { - await insertItem(tx, { - workspaceId: args.workspaceId, - item: toItem({ + const item = withAssignedSortOrder( + toItem({ ...rawItem, lastModified: now, }), + allItems, + ); + + allItems.push(item); + await insertItem(tx, { + workspaceId: args.workspaceId, + item, userId: ctx.userId, sourceVersion: 0, }); @@ -603,11 +707,38 @@ export const mutators = defineMutators({ }, ), move: defineMutator(zeroMutatorSchemas.item.move, async ({ tx, args }) => { + const existing = await loadItem(tx, { + workspaceId: args.workspaceId, + itemId: args.itemId, + }); + + if (!existing) { + await updateShellOnly(tx, { + workspaceId: args.workspaceId, + itemId: args.itemId, + shellChanges: { + folderId: args.folderId ?? undefined, + layout: undefined, + lastModified: Date.now(), + }, + }); + return; + } + + const allItems = sortWorkspaceItemsByOrder( + await loadWorkspaceShells(tx, args.workspaceId), + ); + const nextSortOrder = getNextSortOrderForItem(allItems, { + type: existing.item.type, + folderId: args.folderId ?? undefined, + }); + await updateShellOnly(tx, { workspaceId: args.workspaceId, itemId: args.itemId, shellChanges: { folderId: args.folderId ?? undefined, + sortOrder: nextSortOrder, layout: undefined, lastModified: Date.now(), }, @@ -617,8 +748,54 @@ export const mutators = defineMutators({ zeroMutatorSchemas.item.moveMany, async ({ tx, args }) => { const now = Date.now(); + const allItems = sortWorkspaceItemsByOrder( + await loadWorkspaceShells(tx, args.workspaceId), + ); + const movedItems = sortWorkspaceItemsByOrder( + ( + await Promise.all( + args.itemIds.map(async (itemId) => { + const existing = await loadItem(tx, { + workspaceId: args.workspaceId, + itemId, + }); + + return existing?.item ?? null; + }), + ) + ).filter((item): item is Item => item != null), + ); + + for (const item of movedItems) { + const nextSortOrder = getNextSortOrderForItem(allItems, { + type: item.type, + folderId: args.folderId ?? undefined, + }); + + allItems.push({ + ...item, + folderId: args.folderId ?? undefined, + sortOrder: nextSortOrder, + }); + await updateShellOnly(tx, { + workspaceId: args.workspaceId, + itemId: item.id, + shellChanges: { + folderId: args.folderId ?? undefined, + sortOrder: nextSortOrder, + layout: undefined, + lastModified: now, + }, + }); + } + + const movedItemIds = new Set(movedItems.map((item) => item.id)); for (const itemId of args.itemIds) { + if (movedItemIds.has(itemId)) { + continue; + } + await updateShellOnly(tx, { workspaceId: args.workspaceId, itemId, @@ -631,24 +808,91 @@ export const mutators = defineMutators({ } }, ), + reorder: defineMutator( + zeroMutatorSchemas.item.reorder, + async ({ tx, args }) => { + const now = Date.now(); + + for (const update of args.updates) { + await updateShellOnly(tx, { + workspaceId: args.workspaceId, + itemId: update.itemId, + shellChanges: { + sortOrder: update.sortOrder, + lastModified: now, + }, + }); + } + }, + ), }, folder: { createWithItems: defineMutator( zeroMutatorSchemas.folder.createWithItems, async ({ tx, ctx, args }) => { const now = Date.now(); - - await insertItem(tx, { - workspaceId: args.workspaceId, - item: toItem({ + const allItems = await loadWorkspaceShells(tx, args.workspaceId); + const folder = withAssignedSortOrder( + toItem({ ...args.folder, lastModified: now, }), + allItems, + ); + + allItems.push(folder); + await insertItem(tx, { + workspaceId: args.workspaceId, + item: folder, userId: ctx.userId, sourceVersion: 0, }); + const movedItems = sortWorkspaceItemsByOrder( + ( + await Promise.all( + args.itemIds.map(async (itemId) => { + const existing = await loadItem(tx, { + workspaceId: args.workspaceId, + itemId, + }); + + return existing?.item ?? null; + }), + ) + ).filter((item): item is Item => item != null), + ); + + for (const item of movedItems) { + const nextSortOrder = getNextSortOrderForItem(allItems, { + type: item.type, + folderId: args.folder.id, + }); + + allItems.push({ + ...item, + folderId: args.folder.id, + sortOrder: nextSortOrder, + }); + await updateShellOnly(tx, { + workspaceId: args.workspaceId, + itemId: item.id, + shellChanges: { + folderId: args.folder.id, + sortOrder: nextSortOrder, + layout: undefined, + lastModified: now, + }, + }); + } + + const movedItemIds = new Set(movedItems.map((item) => item.id)); + for (const itemId of args.itemIds) { + if (movedItemIds.has(itemId)) { + continue; + } + await updateShellOnly(tx, { workspaceId: args.workspaceId, itemId, diff --git a/src/lib/zero/server-mutators.ts b/src/lib/zero/server-mutators.ts index 1453d60ef..813dd3d3d 100644 --- a/src/lib/zero/server-mutators.ts +++ b/src/lib/zero/server-mutators.ts @@ -135,6 +135,7 @@ export async function syncExtractedRow( subtitle: shell.subtitle, color: (shell.color as never) ?? null, folderId: shell.folderId ?? null, + sortOrder: shell.sortOrder ?? null, layout: (shell.layout as never) ?? null, lastModified: shell.lastModified ?? null, ocrStatus: shell.ocrStatus ?? null, @@ -393,6 +394,10 @@ export const serverMutators = defineMutators(sharedMutators, { zeroMutatorSchemas.item.moveMany, sharedMutators.item.moveMany, ), + reorder: withAuthOnly( + zeroMutatorSchemas.item.reorder, + sharedMutators.item.reorder, + ), }, folder: { createWithItems: withAuth( diff --git a/src/lib/zero/zero-schema.gen.ts b/src/lib/zero/zero-schema.gen.ts index 41877afb2..272ad976c 100644 --- a/src/lib/zero/zero-schema.gen.ts +++ b/src/lib/zero/zero-schema.gen.ts @@ -21,6 +21,7 @@ const workspaceItems = table("workspace_items") subtitle: string(), color: string().optional(), folderId: string().from("folder_id").optional(), + sortOrder: number().from("sort_order").optional(), layout: json().optional(), lastModified: number().from("last_modified").optional(), sourceVersion: number().from("source_version"), From 656f844ad19a0d7d2f33bc1c8595ddbf6df2a71d Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 04:57:27 +0000 Subject: [PATCH 07/32] Handle null item sort orders when appending Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/lib/workspace/workspace-item-write.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/workspace/workspace-item-write.ts b/src/lib/workspace/workspace-item-write.ts index 15ead4f0c..c8d1136d6 100644 --- a/src/lib/workspace/workspace-item-write.ts +++ b/src/lib/workspace/workspace-item-write.ts @@ -37,6 +37,7 @@ export async function getNextWorkspaceItemSortOrder( const [row] = await tx .select({ maxSortOrder: sql`max(${workspaceItems.sortOrder})`, + siblingCount: sql`count(*)`, }) .from(workspaceItems) .where( @@ -49,7 +50,7 @@ export async function getNextWorkspaceItemSortOrder( ), ); - return (row?.maxSortOrder ?? -1) + 1; + return Math.max((row?.maxSortOrder ?? -1) + 1, row?.siblingCount ?? 0); } export async function loadWorkspaceItemRecord( From 382d539ecd1a476c1d2e6ae2cde4ec319b215520 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:06:42 +0000 Subject: [PATCH 08/32] Render workspace folder and item lanes separately Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/WorkspaceContent.tsx | 20 ++++- .../workspace-canvas/WorkspaceGrid.tsx | 79 ++++++++++++------- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index d398fa8a3..a593ac507 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -63,6 +63,16 @@ export default function WorkspaceContent({ return filterItemsByFolder(viewState, activeFolderId ?? null); }, [viewState, activeFolderId]); + const folderItems = useMemo( + () => filteredItems.filter((item) => item.type === "folder"), + [filteredItems], + ); + + const contentItems = useMemo( + () => filteredItems.filter((item) => item.type !== "folder"), + [filteredItems], + ); + const handleOpenFolder = useCallback( (folderId: string) => { setActiveFolderId(folderId); @@ -215,9 +225,10 @@ export default function WorkspaceContent({ [onPDFUpload], ); - const isFiltering = activeFolderId !== null; - - if (viewState.length === 0 || (isFiltering && filteredItems.length === 0)) { + if ( + viewState.length === 0 || + (folderItems.length === 0 && contentItems.length === 0) + ) { return (
0 ? "pb-20" : undefined}> ) => void; onDeleteItem: (itemId: string) => void; @@ -31,8 +32,12 @@ const GRID_ITEM_HEIGHTS: Record = { document: "min-h-[22rem]", }; +const GRID_COLUMNS_CLASS = + "grid grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] gap-4"; + function WorkspaceGridComponent({ - items, + folderItems, + contentItems, allItems, onUpdateItem, onDeleteItem, @@ -82,29 +87,41 @@ function WorkspaceGridComponent({ return counts; }, [allItems]); - const children = useMemo(() => { - return items.map((item) => { - const wrapperClass = `min-w-0 ${GRID_ITEM_HEIGHTS[item.type]}`; + const folderChildren = useMemo(() => { + return folderItems.map((item) => ( +
+ +
+ )); + }, [ + allItems, + folderItemCounts, + folderItems, + handleDeleteItem, + handleOpenFolder, + handleUpdateItem, + onDeleteFolderWithContents, + onMoveItem, + workspaceColor, + workspaceIcon, + workspaceName, + ]); - if (item.type === "folder") { - return ( -
- -
- ); - } + const contentChildren = useMemo(() => { + return contentItems.map((item) => { + const wrapperClass = `min-w-0 ${GRID_ITEM_HEIGHTS[item.type]}`; if (item.type === "flashcard") { return ( @@ -142,13 +159,10 @@ function WorkspaceGridComponent({ }); }, [ allItems, - folderItemCounts, + contentItems, handleDeleteItem, - handleOpenFolder, handleOpenModal, handleUpdateItem, - items, - onDeleteFolderWithContents, onMoveItem, workspaceColor, workspaceIcon, @@ -157,8 +171,13 @@ function WorkspaceGridComponent({ return (
-
- {children} +
+ {folderChildren.length > 0 ? ( +
{folderChildren}
+ ) : null} + {contentChildren.length > 0 ? ( +
{contentChildren}
+ ) : null}
); From 7a644ddaa1062a59597477ac2016eec66df1f2e3 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:14:51 +0000 Subject: [PATCH 09/32] Preserve order ties and assign promoted child sort order Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/lib/workspace-state/order.ts | 2 +- src/lib/zero/mutators.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lib/workspace-state/order.ts b/src/lib/workspace-state/order.ts index 3ce7d5895..7cb7c0a1c 100644 --- a/src/lib/workspace-state/order.ts +++ b/src/lib/workspace-state/order.ts @@ -21,7 +21,7 @@ export function compareWorkspaceItemsByOrder(a: Item, b: Item): number { return sortOrderDiff; } - return a.id.localeCompare(b.id); + return 0; } export function sortWorkspaceItemsByOrder(items: Item[]): Item[] { diff --git a/src/lib/zero/mutators.ts b/src/lib/zero/mutators.ts index c98ff8ccb..3397941a9 100644 --- a/src/lib/zero/mutators.ts +++ b/src/lib/zero/mutators.ts @@ -526,12 +526,29 @@ async function deleteItemById( .where("folderId", params.itemId), ); const now = Date.now(); + const allItems = sortWorkspaceItemsByOrder( + await loadWorkspaceShells(tx, params.workspaceId), + ); + for (const child of children) { + const childItem = toItem(child); + const sortOrder = getNextSortOrderForItem(allItems, { + type: childItem.type, + folderId: undefined, + }); + + allItems.push({ + ...childItem, + folderId: undefined, + sortOrder, + }); + await updateShellOnly(tx, { workspaceId: params.workspaceId, itemId: child.itemId, shellChanges: { folderId: undefined, + sortOrder, layout: undefined, lastModified: now, }, From bcd7ee98fe45d8d0a4d1844b2e861c92b26e06e6 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:20:59 +0000 Subject: [PATCH 10/32] Use compact uniform workspace card sizing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/WorkspaceGrid.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index cd77e6ff4..36bff16c4 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -20,20 +20,10 @@ interface WorkspaceGridProps { onDeleteFolderWithContents?: (folderId: string) => void; } -const GRID_ITEM_HEIGHTS: Record = { - pdf: "min-h-[22rem]", - flashcard: "min-h-[24rem]", - folder: "min-h-[14rem]", - youtube: "min-h-[22rem]", - quiz: "min-h-[28rem]", - image: "min-h-[22rem]", - audio: "min-h-[22rem]", - website: "min-h-[14rem]", - document: "min-h-[22rem]", -}; +const GRID_ITEM_CLASS = "min-w-0 h-[10rem]"; const GRID_COLUMNS_CLASS = - "grid grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] gap-4"; + "grid grid-cols-[repeat(auto-fill,14rem)] justify-start gap-4"; function WorkspaceGridComponent({ folderItems, @@ -89,7 +79,7 @@ function WorkspaceGridComponent({ const folderChildren = useMemo(() => { return folderItems.map((item) => ( -
+
{ return contentItems.map((item) => { - const wrapperClass = `min-w-0 ${GRID_ITEM_HEIGHTS[item.type]}`; - if (item.type === "flashcard") { return ( -
+
+
Date: Sat, 2 May 2026 20:24:22 +0000 Subject: [PATCH 11/32] Make workspace card sizing fluid Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/workspace-canvas/WorkspaceGrid.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index 36bff16c4..8e3286736 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -20,10 +20,10 @@ interface WorkspaceGridProps { onDeleteFolderWithContents?: (folderId: string) => void; } -const GRID_ITEM_CLASS = "min-w-0 h-[10rem]"; +const GRID_ITEM_CLASS = "min-w-0 aspect-[4/3]"; const GRID_COLUMNS_CLASS = - "grid grid-cols-[repeat(auto-fill,14rem)] justify-start gap-4"; + "grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4"; function WorkspaceGridComponent({ folderItems, From c400387a7834032ea36e87a99cb46e5982b7d0ba Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:28:49 +0000 Subject: [PATCH 12/32] Fix promoted child sort order type error Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/lib/zero/mutators.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/zero/mutators.ts b/src/lib/zero/mutators.ts index 3397941a9..1efefcd01 100644 --- a/src/lib/zero/mutators.ts +++ b/src/lib/zero/mutators.ts @@ -531,7 +531,11 @@ async function deleteItemById( ); for (const child of children) { - const childItem = toItem(child); + const childItem = allItems.find((item) => item.id === child.itemId); + if (!childItem) { + continue; + } + const sortOrder = getNextSortOrderForItem(allItems, { type: childItem.type, folderId: undefined, From 8a7b88a0fc041655e2401e553999b5294bd0d58d Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:51:32 +0000 Subject: [PATCH 13/32] Add workspace lane drag sorting Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- package.json | 2 + pnpm-lock.yaml | 79 +++++ .../FlashcardWorkspaceCard.tsx | 21 +- .../workspace-canvas/FolderCard.tsx | 6 +- .../SortableWorkspaceGridItem.tsx | 79 +++++ .../workspace-canvas/WorkspaceCard.tsx | 12 +- .../WorkspaceCardDragHandle.tsx | 40 +++ .../workspace-canvas/WorkspaceContent.tsx | 3 + .../workspace-canvas/WorkspaceGrid.tsx | 278 +++++++++++++++--- .../workspace-canvas/WorkspaceSection.tsx | 1 + .../workspace/use-workspace-operations.ts | 19 ++ 11 files changed, 487 insertions(+), 53 deletions(-) create mode 100644 src/components/workspace-canvas/SortableWorkspaceGridItem.tsx create mode 100644 src/components/workspace-canvas/WorkspaceCardDragHandle.tsx diff --git a/package.json b/package.json index ca31c528e..578975453 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "@ai-sdk/react": "^3.0.170", "@contextcompany/otel": "^1.0.15", "@databuddy/sdk": "^2.4.11", + "@dnd-kit/helpers": "0.4.1-beta-20260430231058", + "@dnd-kit/react": "0.4.1-beta-20260430231058", "@e2b/code-interpreter": "^2.4.0", "@embedpdf/core": "^2.14.0", "@embedpdf/engines": "^2.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee8792027..8c43e493c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@databuddy/sdk': specifier: ^2.4.11 version: 2.4.11(react@19.2.5)(vue@3.5.32(typescript@6.0.3)) + '@dnd-kit/helpers': + specifier: 0.4.1-beta-20260430231058 + version: 0.4.1-beta-20260430231058 + '@dnd-kit/react': + specifier: 0.4.1-beta-20260430231058 + version: 0.4.1-beta-20260430231058(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@e2b/code-interpreter': specifier: ^2.4.0 version: 2.4.0 @@ -897,6 +903,30 @@ packages: vue: optional: true + '@dnd-kit/abstract@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-59hnowyFzR7sfUuX3scjiRIBHbBtj87zMMyxD1w2Aj1LykbsM5igi4gMoPM7N6ZEQbIO2+ov/LvepOtQAeAMNw==} + + '@dnd-kit/collision@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-rL5sVmRhJNcgjXPapD504x5wpDYNXcb79uVdMreyc0eSlFL5COlxxGi/GrBGfoZpsLJBsslfLcFyGvh0EdqLoQ==} + + '@dnd-kit/dom@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-hpvIQEsgHXwvWeJrtMySIPGfzFNlsHNJjChyssVRrseR9LXx8pNt2bsqmSZ7RfaCBhu/fslOPE47edxf+2iBFA==} + + '@dnd-kit/geometry@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-QqRhwKubUFAg4CxzX/Hxf/Q3TcZsZiLRQF6xQZjwq5KWgItq5qaEjL5VRhECqLCyYiM5jWohzmVMVmtS+MFw/A==} + + '@dnd-kit/helpers@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-cjJ4EMxMUOOydMOITWknkvgidUZkUG6pt5T8VeQa+niCbrs265BS7hSvKaNoLpakebAL+m/kKLiFXRKO+A37nA==} + + '@dnd-kit/react@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-VcS5ZIIYDs+WPws5ZW/bGRfT30q+t6qeFSi1lv8kypQFXJxohW3wdMRAbo++xetMXtzDpAR+KGhOcEzcofvnjQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-QtNxyFGsGlvR32BeNShCPowRx/h9tQNQqWgQs8+UKjEp70k4phGMk8o6YfUCruqyF1yMFEyNxAh+ScAOdVXg8g==} + '@dotenvx/dotenvx@1.61.1': resolution: {integrity: sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q==} hasBin: true @@ -3518,6 +3548,9 @@ packages: peerDependencies: webpack: ^5 + '@preact/signals-core@1.14.1': + resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -10992,6 +11025,50 @@ snapshots: react: 19.2.5 vue: 3.5.32(typescript@6.0.3) + '@dnd-kit/abstract@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/geometry': 0.4.1-beta-20260430231058 + '@dnd-kit/state': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/collision@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + '@dnd-kit/geometry': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/dom@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + '@dnd-kit/collision': 0.4.1-beta-20260430231058 + '@dnd-kit/geometry': 0.4.1-beta-20260430231058 + '@dnd-kit/state': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/geometry@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/state': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/helpers@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/react@0.4.1-beta-20260430231058(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + '@dnd-kit/dom': 0.4.1-beta-20260430231058 + '@dnd-kit/state': 0.4.1-beta-20260430231058 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tslib: 2.8.1 + + '@dnd-kit/state@0.4.1-beta-20260430231058': + dependencies: + '@preact/signals-core': 1.14.1 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.61.1': dependencies: commander: 11.1.0 @@ -13319,6 +13396,8 @@ snapshots: transitivePeerDependencies: - debug + '@preact/signals-core@1.14.1': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx index 53ef0d5c8..4506f8859 100644 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx @@ -1,6 +1,14 @@ "use client"; -import { useState, useCallback, useEffect, useRef, useMemo, memo } from "react"; +import { + useState, + useCallback, + useEffect, + useRef, + useMemo, + memo, + type ReactNode, +} from "react"; import { toast } from "sonner"; import { MoreVertical, @@ -73,6 +81,7 @@ interface FlashcardWorkspaceCardProps { onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder + dragHandle?: ReactNode; // NOTE: isSelected removed - card subscribes directly to store for performance // onToggleSelection is still passed as a prop for the shift+click handler } @@ -158,6 +167,7 @@ export function FlashcardWorkspaceCard({ onDeleteItem, onOpenModal, onMoveItem, + dragHandle, }: FlashcardWorkspaceCardProps) { // Subscribe directly to this card's selection state from the store // This prevents full grid re-renders when selection changes @@ -559,7 +569,12 @@ export function FlashcardWorkspaceCard({ -
+
{currentIndex + 1} / {cards.length}
@@ -687,6 +702,8 @@ export function FlashcardWorkspaceCard({ }} /> )} + + {dragHandle}
diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index b4ebed40a..6df444508 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useState, useCallback, useEffect } from "react"; +import { memo, useState, useCallback, useEffect, type ReactNode } from "react"; import { MoreVertical, Trash2, @@ -69,6 +69,7 @@ interface FolderCardProps { onDeleteItem: (itemId: string) => void; onDeleteFolderWithContents?: (folderId: string) => void; onMoveItem?: (itemId: string, folderId: string | null) => void; + dragHandle?: ReactNode; } function FolderCardComponent({ @@ -83,6 +84,7 @@ function FolderCardComponent({ onDeleteItem, onDeleteFolderWithContents, onMoveItem, + dragHandle, }: FolderCardProps) { const [showColorPicker, setShowColorPicker] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -390,6 +392,8 @@ function FolderCardComponent({
+ + {dragHandle}
diff --git a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx new file mode 100644 index 000000000..483130856 --- /dev/null +++ b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx @@ -0,0 +1,79 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useSortable } from "@dnd-kit/react/sortable"; +import type { Item } from "@/lib/workspace-state/types"; +import { cn } from "@/lib/utils"; +import { WorkspaceCardDragHandle } from "./WorkspaceCardDragHandle"; + +export type WorkspaceGridLane = "folders" | "items"; + +export function getWorkspaceSortableGroup( + containerId: string | null | undefined, + lane: WorkspaceGridLane, +): string { + return `${containerId ?? "root"}:${lane}`; +} + +interface SortableWorkspaceGridItemProps { + item: Item; + index: number; + lane: WorkspaceGridLane; + containerId: string | null; + className?: string; + children: (props: { + dragHandle: ReactNode; + isDragging: boolean; + isDropTarget: boolean; + }) => ReactNode; +} + +export function SortableWorkspaceGridItem({ + item, + index, + lane, + containerId, + className, + children, +}: SortableWorkspaceGridItemProps) { + const group = getWorkspaceSortableGroup(containerId, lane); + const sortableType = lane === "folders" ? "folder" : "item"; + const { ref, handleRef, isDragging, isDropTarget } = useSortable({ + id: item.id, + index, + group, + type: sortableType, + accept: sortableType, + data: { + group, + lane, + containerId, + itemId: item.id, + }, + }); + + return ( +
void} + className={cn("size-full", className, isDragging && "z-20")} + style={{ + opacity: isDragging ? 0.82 : undefined, + transform: isDragging ? "scale(0.985)" : undefined, + filter: isDragging + ? "drop-shadow(0 12px 24px rgba(0, 0, 0, 0.18))" + : undefined, + }} + > + {children({ + dragHandle: ( + void} + label={lane === "folders" ? "Reorder folder" : "Reorder card"} + /> + ), + isDragging, + isDropTarget, + })} +
+ ); +} diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 4d6e8ea63..1d4e85938 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,4 +1,10 @@ -import { useCallback, useState, memo, type CSSProperties } from "react"; +import { + useCallback, + useState, + memo, + type CSSProperties, + type ReactNode, +} from "react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { @@ -32,6 +38,7 @@ interface WorkspaceCardProps { onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; onMoveItem?: (itemId: string, folderId: string | null) => void; + dragHandle?: ReactNode; } function WorkspaceCard({ @@ -44,6 +51,7 @@ function WorkspaceCard({ onDeleteItem, onOpenModal, onMoveItem, + dragHandle, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); @@ -264,6 +272,8 @@ function WorkspaceCard({ onTitleFocus={handleTitleFocus} onTitleBlur={handleTitleBlur} /> + + {dragHandle} ) { + event.stopPropagation(); +} + +export const WorkspaceCardDragHandle = forwardRef< + HTMLButtonElement, + WorkspaceCardDragHandleProps +>(function WorkspaceCardDragHandle({ label, className }, ref) { + return ( + + ); +}); diff --git a/src/components/workspace-canvas/WorkspaceContent.tsx b/src/components/workspace-canvas/WorkspaceContent.tsx index a593ac507..089b8eb2e 100644 --- a/src/components/workspace-canvas/WorkspaceContent.tsx +++ b/src/components/workspace-canvas/WorkspaceContent.tsx @@ -31,6 +31,7 @@ interface WorkspaceContentProps { workspaceIcon?: string | null; workspaceColor?: string | null; onMoveItem?: (itemId: string, folderId: string | null) => void; + reorderItems?: (orderedItemIds: string[]) => void; onOpenFolder?: (folderId: string) => void; onDeleteFolderWithContents?: (folderId: string) => void; onPDFUpload?: (files: File[]) => Promise; @@ -47,6 +48,7 @@ export default function WorkspaceContent({ workspaceIcon, workspaceColor, onMoveItem, + reorderItems, onOpenFolder, onDeleteFolderWithContents, onPDFUpload, @@ -315,6 +317,7 @@ export default function WorkspaceContent({ workspaceIcon={workspaceIcon} workspaceColor={workspaceColor} onMoveItem={onMoveItem} + onReorderItems={reorderItems} onOpenFolder={handleOpenFolder} onDeleteFolderWithContents={onDeleteFolderWithContents} /> diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index 8e3286736..4cf4a2696 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -1,9 +1,17 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React from "react"; +import { DragDropProvider, type DragEndEvent } from "@dnd-kit/react"; +import { arrayMove } from "@dnd-kit/helpers"; +import { isSortable } from "@dnd-kit/react/sortable"; import type { Item } from "@/lib/workspace-state/types"; import { WorkspaceCard } from "./WorkspaceCard"; import { FlashcardWorkspaceCard } from "./FlashcardWorkspaceCard"; import { FolderCard } from "./FolderCard"; +import { + getWorkspaceSortableGroup, + type WorkspaceGridLane, + SortableWorkspaceGridItem, +} from "./SortableWorkspaceGridItem"; interface WorkspaceGridProps { folderItems: Item[]; @@ -18,6 +26,7 @@ interface WorkspaceGridProps { onMoveItem?: (itemId: string, folderId: string | null) => void; onOpenFolder?: (folderId: string) => void; onDeleteFolderWithContents?: (folderId: string) => void; + onReorderItems?: (orderedItemIds: string[]) => void; } const GRID_ITEM_CLASS = "min-w-0 aspect-[4/3]"; @@ -38,7 +47,17 @@ function WorkspaceGridComponent({ onMoveItem, onOpenFolder, onDeleteFolderWithContents, + onReorderItems, }: WorkspaceGridProps) { + const [orderedFolderItems, setOrderedFolderItems] = useState(folderItems); + const [orderedContentItems, setOrderedContentItems] = useState(contentItems); + const dragSnapshotRef = useRef<{ + folders: Item[]; + items: Item[]; + } | null>(null); + const isDraggingRef = useRef(false); + const currentContainerId = folderItems[0]?.folderId ?? contentItems[0]?.folderId ?? null; + const handleUpdateItem = useCallback( (itemId: string, updates: Partial) => { onUpdateItem(itemId, updates); @@ -67,6 +86,22 @@ function WorkspaceGridComponent({ [onOpenFolder], ); + useEffect(() => { + if (isDraggingRef.current) { + return; + } + + setOrderedFolderItems(folderItems); + }, [folderItems]); + + useEffect(() => { + if (isDraggingRef.current) { + return; + } + + setOrderedContentItems(contentItems); + }, [contentItems]); + const folderItemCounts = useMemo(() => { const counts = new Map(); allItems.forEach((item) => { @@ -77,44 +112,185 @@ function WorkspaceGridComponent({ return counts; }, [allItems]); + const getLaneFromGroup = useCallback((group: string | number | undefined) => { + if (typeof group !== "string") { + return null; + } + + if (group.endsWith(":folders")) { + return "folders" as const; + } + + if (group.endsWith(":items")) { + return "items" as const; + } + + return null; + }, []); + + const commitLaneOrder = useCallback( + (lane: WorkspaceGridLane, nextItems: Item[]) => { + if (lane === "folders") { + setOrderedFolderItems(nextItems); + } else { + setOrderedContentItems(nextItems); + } + + onReorderItems?.(nextItems.map((item) => item.id)); + }, + [onReorderItems], + ); + + const handleDragStart = useCallback(() => { + isDraggingRef.current = true; + dragSnapshotRef.current = { + folders: orderedFolderItems, + items: orderedContentItems, + }; + }, [orderedContentItems, orderedFolderItems]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const snapshot = dragSnapshotRef.current; + isDraggingRef.current = false; + dragSnapshotRef.current = null; + + if (!snapshot) { + return; + } + + if (event.canceled) { + setOrderedFolderItems(snapshot.folders); + setOrderedContentItems(snapshot.items); + return; + } + + const { source } = event.operation; + + if (!isSortable(source)) { + setOrderedFolderItems(snapshot.folders); + setOrderedContentItems(snapshot.items); + return; + } + + const { initialIndex, index, initialGroup, group } = source; + + if ( + initialGroup == null || + group == null || + initialGroup !== group || + initialIndex === index + ) { + setOrderedFolderItems(snapshot.folders); + setOrderedContentItems(snapshot.items); + return; + } + + const lane = getLaneFromGroup( + typeof group === "string" ? group : String(group), + ); + + if (!lane) { + setOrderedFolderItems(snapshot.folders); + setOrderedContentItems(snapshot.items); + return; + } + + const nextItems = arrayMove( + lane === "folders" ? snapshot.folders : snapshot.items, + initialIndex, + index, + ); + + commitLaneOrder(lane, nextItems); + }, + [commitLaneOrder, getLaneFromGroup], + ); + const folderChildren = useMemo(() => { - return folderItems.map((item) => ( -
- -
+ return orderedFolderItems.map((item, index) => ( + + {({ dragHandle }) => ( + + )} + )); }, [ allItems, + currentContainerId, folderItemCounts, - folderItems, handleDeleteItem, handleOpenFolder, handleUpdateItem, onDeleteFolderWithContents, onMoveItem, + orderedFolderItems, workspaceColor, workspaceIcon, workspaceName, ]); const contentChildren = useMemo(() => { - return contentItems.map((item) => { + return orderedContentItems.map((item, index) => { if (item.type === "flashcard") { return ( -
- + {({ dragHandle }) => ( + + )} + + ); + } + + return ( + + {({ dragHandle }) => ( + -
- ); - } - - return ( -
- -
+ )} + ); }); }, [ allItems, - contentItems, + currentContainerId, handleDeleteItem, handleOpenModal, handleUpdateItem, onMoveItem, + orderedContentItems, workspaceColor, workspaceIcon, workspaceName, ]); return ( -
-
- {folderChildren.length > 0 ? ( -
{folderChildren}
- ) : null} - {contentChildren.length > 0 ? ( -
{contentChildren}
- ) : null} + +
+
+ {folderChildren.length > 0 ? ( +
+ {folderChildren} +
+ ) : null} + {contentChildren.length > 0 ? ( +
+ {contentChildren} +
+ ) : null} +
-
+ ); } diff --git a/src/components/workspace-canvas/WorkspaceSection.tsx b/src/components/workspace-canvas/WorkspaceSection.tsx index dafc909e3..25e0f4309 100644 --- a/src/components/workspace-canvas/WorkspaceSection.tsx +++ b/src/components/workspace-canvas/WorkspaceSection.tsx @@ -388,6 +388,7 @@ export function WorkspaceSection({ workspaceIcon={workspaceIcon} workspaceColor={workspaceColor} onMoveItem={operations.moveItemToFolder} + reorderItems={operations.reorderItems} onDeleteFolderWithContents={ operations.deleteFolderWithContents } diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index ab8e9ff65..1a4b6cc55 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -157,6 +157,7 @@ export interface WorkspaceOperations { deleteFolderWithContents: (folderId: string) => void; moveItemToFolder: (itemId: string, folderId: string | null) => void; moveItemsToFolder: (itemIds: string[], folderId: string | null) => void; + reorderItems: (orderedItemIds: string[]) => void; isPending: boolean; isError: boolean; error: Error | null; @@ -906,6 +907,23 @@ export function useWorkspaceOperations( [], ); + const reorderItems = useCallback((orderedItemIds: string[]) => { + const currentWorkspaceId = workspaceIdRef.current; + if (!currentWorkspaceId || orderedItemIds.length === 0) { + return; + } + + zeroRef.current.mutate( + mutators.item.reorder({ + workspaceId: currentWorkspaceId, + updates: orderedItemIds.map((itemId, index) => ({ + itemId, + sortOrder: index, + })), + }), + ); + }, []); + const flushPendingChanges = useCallback((itemId: string) => { updateItemDebouncerRef.current.get(itemId)?.flush(); updateItemDataDebouncerRef.current.get(itemId)?.flush(); @@ -936,6 +954,7 @@ export function useWorkspaceOperations( deleteFolderWithContents, moveItemToFolder, moveItemsToFolder, + reorderItems, isPending: false, isError: false, error: null, From 69212e665cd9f08902b69a9a6097694d4a8436aa Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 21:03:54 +0000 Subject: [PATCH 14/32] Fix drag handle activation and sort order validation Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/WorkspaceCardDragHandle.tsx | 7 +------ src/lib/zero/mutators.ts | 7 ++++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx b/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx index dc4b93d18..5b4083c44 100644 --- a/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx +++ b/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, type MouseEvent } from "react"; +import { forwardRef } from "react"; import { GripHorizontal } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -9,10 +9,6 @@ interface WorkspaceCardDragHandleProps { className?: string; } -function stopCardOpen(event: MouseEvent) { - event.stopPropagation(); -} - export const WorkspaceCardDragHandle = forwardRef< HTMLButtonElement, WorkspaceCardDragHandleProps @@ -28,7 +24,6 @@ export const WorkspaceCardDragHandle = forwardRef< "absolute bottom-3 left-1/2 z-30 inline-flex h-7 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border/70 bg-background/90 text-muted-foreground shadow-sm backdrop-blur-sm transition-all duration-200 hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100", className, )} - onMouseDown={stopCardOpen} onClick={(event) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/lib/zero/mutators.ts b/src/lib/zero/mutators.ts index 1efefcd01..5c09bb284 100644 --- a/src/lib/zero/mutators.ts +++ b/src/lib/zero/mutators.ts @@ -78,6 +78,7 @@ function isJsonObject(value: unknown): value is JsonObject { } const jsonObjectSchema = z.custom(isJsonObject); +const sortOrderSchema = z.number().int().min(0); const itemSchema = z.object({ id: z.string(), @@ -87,7 +88,7 @@ const itemSchema = z.object({ data: jsonObjectSchema, color: z.string().nullable().optional(), folderId: z.string().nullable().optional(), - sortOrder: z.number().int().nullable().optional(), + sortOrder: sortOrderSchema.nullable().optional(), layout: jsonObjectSchema.nullable().optional(), lastModified: z.number().int().optional(), }); @@ -102,7 +103,7 @@ const itemChangesSchema = z.object({ data: jsonObjectSchema.optional(), color: z.string().nullable().optional(), folderId: z.string().nullable().optional(), - sortOrder: z.number().int().nullable().optional(), + sortOrder: sortOrderSchema.nullable().optional(), layout: jsonObjectSchema.nullable().optional(), lastModified: z.number().int().optional(), }); @@ -171,7 +172,7 @@ export const zeroMutatorSchemas = { updates: z.array( z.object({ itemId: z.string(), - sortOrder: z.number().int(), + sortOrder: sortOrderSchema, }), ), }), From 63641e4b0d5c07a4b3c451ab4136eb2095c2cb3a Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 21:18:42 +0000 Subject: [PATCH 15/32] Soften workspace card drag handle styling Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/components/workspace-canvas/WorkspaceCardDragHandle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx b/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx index 5b4083c44..8e35a9d9a 100644 --- a/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx +++ b/src/components/workspace-canvas/WorkspaceCardDragHandle.tsx @@ -21,7 +21,7 @@ export const WorkspaceCardDragHandle = forwardRef< title={label} data-drag-handle className={cn( - "absolute bottom-3 left-1/2 z-30 inline-flex h-7 w-11 -translate-x-1/2 items-center justify-center rounded-full border border-border/70 bg-background/90 text-muted-foreground shadow-sm backdrop-blur-sm transition-all duration-200 hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100", + "absolute bottom-2 left-1/2 z-30 inline-flex h-6 w-10 -translate-x-1/2 items-center justify-center rounded-full text-muted-foreground/55 transition-all duration-200 hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100", className, )} onClick={(event) => { From 683e15052efc43e76e09329a86df00d08fff2d57 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 21:22:41 +0000 Subject: [PATCH 16/32] Add consistent dnd drag feedback Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 3 +++ .../workspace-canvas/SortableWorkspaceGridItem.tsx | 11 +++++++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 578975453..964e27711 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@ai-sdk/react": "^3.0.170", "@contextcompany/otel": "^1.0.15", "@databuddy/sdk": "^2.4.11", + "@dnd-kit/dom": "0.4.1-beta-20260430231058", "@dnd-kit/helpers": "0.4.1-beta-20260430231058", "@dnd-kit/react": "0.4.1-beta-20260430231058", "@e2b/code-interpreter": "^2.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c43e493c..fd9dfe102 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@databuddy/sdk': specifier: ^2.4.11 version: 2.4.11(react@19.2.5)(vue@3.5.32(typescript@6.0.3)) + '@dnd-kit/dom': + specifier: 0.4.1-beta-20260430231058 + version: 0.4.1-beta-20260430231058 '@dnd-kit/helpers': specifier: 0.4.1-beta-20260430231058 version: 0.4.1-beta-20260430231058 diff --git a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx index 483130856..fb56aa811 100644 --- a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx +++ b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx @@ -1,6 +1,7 @@ "use client"; import type { ReactNode } from "react"; +import { Feedback } from "@dnd-kit/dom"; import { useSortable } from "@dnd-kit/react/sortable"; import type { Item } from "@/lib/workspace-state/types"; import { cn } from "@/lib/utils"; @@ -44,6 +45,7 @@ export function SortableWorkspaceGridItem({ group, type: sortableType, accept: sortableType, + plugins: [Feedback.configure({ feedback: "clone" })], data: { group, lane, @@ -55,9 +57,14 @@ export function SortableWorkspaceGridItem({ return (
void} - className={cn("size-full", className, isDragging && "z-20")} + className={cn( + "size-full rounded-md transition-[box-shadow,opacity,transform] duration-150", + className, + isDragging && "z-20 opacity-70", + isDropTarget && !isDragging && "ring-2 ring-primary/30 ring-offset-2 ring-offset-background", + )} style={{ - opacity: isDragging ? 0.82 : undefined, + opacity: isDragging ? 0.7 : undefined, transform: isDragging ? "scale(0.985)" : undefined, filter: isDragging ? "drop-shadow(0 12px 24px rgba(0, 0, 0, 0.18))" From 1c7ee01409cc0fdd7d80913fbae857073d098e3e Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 21:26:22 +0000 Subject: [PATCH 17/32] Validate reorder payloads and preserve flattened child order Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- src/lib/zero/mutators.ts | 78 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/src/lib/zero/mutators.ts b/src/lib/zero/mutators.ts index 5c09bb284..3f9a0d77c 100644 --- a/src/lib/zero/mutators.ts +++ b/src/lib/zero/mutators.ts @@ -531,12 +531,13 @@ async function deleteItemById( await loadWorkspaceShells(tx, params.workspaceId), ); - for (const child of children) { - const childItem = allItems.find((item) => item.id === child.itemId); - if (!childItem) { - continue; - } + const orderedChildren = sortWorkspaceItemsByOrder( + children + .map((child) => allItems.find((item) => item.id === child.itemId) ?? null) + .filter((item): item is Item => item != null), + ); + for (const childItem of orderedChildren) { const sortOrder = getNextSortOrderForItem(allItems, { type: childItem.type, folderId: undefined, @@ -550,7 +551,7 @@ async function deleteItemById( await updateShellOnly(tx, { workspaceId: params.workspaceId, - itemId: child.itemId, + itemId: childItem.id, shellChanges: { folderId: undefined, sortOrder, @@ -834,6 +835,71 @@ export const mutators = defineMutators({ zeroMutatorSchemas.item.reorder, async ({ tx, args }) => { const now = Date.now(); + const updatesByItemId = new Map( + args.updates.map((update) => [update.itemId, update]), + ); + + if (updatesByItemId.size !== args.updates.length) { + throw new ApplicationError("Reorder payload contains duplicate items."); + } + + const allItems = await loadWorkspaceShells(tx, args.workspaceId); + const updatedItems = args.updates.map((update) => { + const item = allItems.find((candidate) => candidate.id === update.itemId); + if (!item) { + throw new ApplicationError( + `Workspace item ${update.itemId} was not found.`, + ); + } + return item; + }); + const firstItem = updatedItems[0]; + + if (!firstItem) { + return; + } + + const lane = getWorkspaceItemLane(firstItem); + const folderId = firstItem.folderId ?? null; + + if ( + updatedItems.some( + (item) => + getWorkspaceItemLane(item) !== lane || + (item.folderId ?? null) !== folderId, + ) + ) { + throw new ApplicationError( + "Reorder payload must contain items from one folder and lane.", + ); + } + + const siblingItems = allItems.filter( + (item) => + getWorkspaceItemLane(item) === lane && + (item.folderId ?? null) === folderId, + ); + + if (siblingItems.length !== args.updates.length) { + throw new ApplicationError( + "Reorder payload must include every item in the lane.", + ); + } + + const sortOrders = args.updates.map((update) => update.sortOrder); + const uniqueSortOrders = new Set(sortOrders); + const expectedSortOrders = new Set( + Array.from({ length: siblingItems.length }, (_, index) => index), + ); + + if ( + uniqueSortOrders.size !== sortOrders.length || + sortOrders.some((sortOrder) => !expectedSortOrders.has(sortOrder)) + ) { + throw new ApplicationError( + "Reorder payload must use contiguous sort orders starting at zero.", + ); + } for (const update of args.updates) { await updateShellOnly(tx, { From f28fff37d8e2005e22478b11bd141b99efc87da4 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 22:02:23 +0000 Subject: [PATCH 18/32] Add item to folder drag drops Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/FolderCard.tsx | 108 +++++++++--- .../workspace-canvas/WorkspaceGrid.test.ts | 159 ++++++++++++++++++ .../workspace-canvas/WorkspaceGrid.tsx | 63 +++---- .../workspace-canvas/workspace-grid-dnd.ts | 105 ++++++++++++ 4 files changed, 372 insertions(+), 63 deletions(-) create mode 100644 src/components/workspace-canvas/WorkspaceGrid.test.ts create mode 100644 src/components/workspace-canvas/workspace-grid-dnd.ts diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index 6df444508..3ec7eb3c1 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -1,5 +1,6 @@ "use client"; +import { useDroppable } from "@dnd-kit/react"; import { memo, useState, useCallback, useEffect, type ReactNode } from "react"; import { MoreVertical, @@ -70,6 +71,7 @@ interface FolderCardProps { onDeleteFolderWithContents?: (folderId: string) => void; onMoveItem?: (itemId: string, folderId: string | null) => void; dragHandle?: ReactNode; + itemDropTargetId?: string; } function FolderCardComponent({ @@ -85,10 +87,13 @@ function FolderCardComponent({ onDeleteFolderWithContents, onMoveItem, dragHandle, + itemDropTargetId, }: FolderCardProps) { const [showColorPicker, setShowColorPicker] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deleteOption, setDeleteOption] = useState<"keep" | "delete" | null>(null); + const [deleteOption, setDeleteOption] = useState<"keep" | "delete" | null>( + null, + ); const [showMoveDialog, setShowMoveDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -99,6 +104,15 @@ function FolderCardComponent({ const onToggleSelection = useUIStore((state) => state.toggleCardSelection); const folderColor = item.color || "#6366F1"; + const { ref: dropTargetRef, isDropTarget: isItemDropTarget } = useDroppable({ + id: itemDropTargetId ?? `folder-drop:${item.id}`, + accept: "item", + collisionPriority: 1, + data: { + kind: "folder-card-drop-target", + folderId: item.id, + }, + }); useEffect(() => { if (item.name === "New Folder") { @@ -115,18 +129,18 @@ function FolderCardComponent({ const isInteractiveTarget = useCallback((target: HTMLElement) => { return Boolean( target.closest("button") || - target.closest("input") || - target.closest("textarea") || - target.closest("select") || - target.closest("a") || - target.closest("label") || - target.closest('[role="menuitem"]') || - target.closest('[contenteditable="true"]') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[data-slot="dialog-content"]') || - target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]'), + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]'), ); }, []); @@ -154,7 +168,13 @@ function FolderCardComponent({ onOpenFolder(item.id); }, - [isEditingTitle, isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], + [ + isEditingTitle, + isInteractiveTarget, + item.id, + onOpenFolder, + onToggleSelection, + ], ); const handleColorChange = useCallback( @@ -223,7 +243,12 @@ function FolderCardComponent({
void} + className={cn( + "group size-full rounded-md transition-[box-shadow,transform] duration-150", + isItemDropTarget && + "ring-2 ring-primary/60 ring-offset-2 ring-offset-background", + )} style={selectedRingStyle} onClick={handleClick} > @@ -291,7 +316,10 @@ function FolderCardComponent({ )} - + - e.stopPropagation()}> + e.stopPropagation()} + > { e.stopPropagation(); @@ -391,7 +425,21 @@ function FolderCardComponent({
-
+
+ + {isItemDropTarget ? ( +
+ + Move into folder +
+ ) : null} {dragHandle}
@@ -419,7 +467,10 @@ function FolderCardComponent({
- + e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} @@ -429,7 +480,9 @@ function FolderCardComponent({
- Choose what happens to the {itemCount} {itemCount === 1 ? "item" : "items"} in "{item.name}": + Choose what happens to the {itemCount}{" "} + {itemCount === 1 ? "item" : "items"} in "{item.name} + ":
-
diff --git a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx index fb56aa811..090bf9ed3 100644 --- a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx +++ b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx @@ -1,11 +1,9 @@ "use client"; import type { ReactNode } from "react"; -import { Feedback } from "@dnd-kit/dom"; import { useSortable } from "@dnd-kit/react/sortable"; import type { Item } from "@/lib/workspace-state/types"; import { cn } from "@/lib/utils"; -import { WorkspaceCardDragHandle } from "./WorkspaceCardDragHandle"; export type WorkspaceGridLane = "folders" | "items"; @@ -23,9 +21,7 @@ interface SortableWorkspaceGridItemProps { containerId: string | null; className?: string; children: (props: { - dragHandle: ReactNode; isDragging: boolean; - isDropTarget: boolean; }) => ReactNode; } @@ -39,13 +35,12 @@ export function SortableWorkspaceGridItem({ }: SortableWorkspaceGridItemProps) { const group = getWorkspaceSortableGroup(containerId, lane); const sortableType = lane === "folders" ? "folder" : "item"; - const { ref, handleRef, isDragging, isDropTarget } = useSortable({ + const { ref, isDragging } = useSortable({ id: item.id, index, group, type: sortableType, accept: sortableType, - plugins: [Feedback.configure({ feedback: "clone" })], data: { group, lane, @@ -61,7 +56,6 @@ export function SortableWorkspaceGridItem({ "size-full rounded-md transition-[box-shadow,opacity,transform] duration-150", className, isDragging && "z-20 opacity-70", - isDropTarget && !isDragging && "ring-2 ring-primary/30 ring-offset-2 ring-offset-background", )} style={{ opacity: isDragging ? 0.7 : undefined, @@ -72,14 +66,7 @@ export function SortableWorkspaceGridItem({ }} > {children({ - dragHandle: ( - void} - label={lane === "folders" ? "Reorder folder" : "Reorder card"} - /> - ), isDragging, - isDropTarget, })}
); diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 1d4e85938..bba4638c9 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -3,7 +3,6 @@ import { useState, memo, type CSSProperties, - type ReactNode, } from "react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; @@ -38,7 +37,6 @@ interface WorkspaceCardProps { onDeleteItem: (itemId: string) => void; onOpenModal: (itemId: string) => void; onMoveItem?: (itemId: string, folderId: string | null) => void; - dragHandle?: ReactNode; } function WorkspaceCard({ @@ -51,7 +49,6 @@ function WorkspaceCard({ onDeleteItem, onOpenModal, onMoveItem, - dragHandle, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); @@ -272,8 +269,6 @@ function WorkspaceCard({ onTitleFocus={handleTitleFocus} onTitleBlur={handleTitleBlur} /> - - {dragHandle} (function WorkspaceCardDragHandle({ label, className }, ref) { - return ( - - ); -}); diff --git a/src/components/workspace-canvas/WorkspaceDragDropProvider.tsx b/src/components/workspace-canvas/WorkspaceDragDropProvider.tsx new file mode 100644 index 000000000..a56de94ab --- /dev/null +++ b/src/components/workspace-canvas/WorkspaceDragDropProvider.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { ReactNode } from "react"; +import { DragDropProvider } from "@dnd-kit/react"; +import { PointerSensor, PointerActivationConstraints } from "@dnd-kit/dom"; + +interface WorkspaceDragDropProviderProps { + children: ReactNode; +} +/** + * Wraps workspace shell (header + sidebar + canvas) so grid sortables and future + * header droppables share one dnd-kit context. Drag handlers live in WorkspaceGrid + * via useDragDropMonitor. + */ +export function WorkspaceDragDropProvider({ + children, +}: WorkspaceDragDropProviderProps) { + return ( + + defaults.map((sensor) => + sensor === PointerSensor + ? PointerSensor.configure({ + activationConstraints(event) { + if (event.pointerType === "mouse") { + return [ + new PointerActivationConstraints.Distance({ value: 5 }), + ]; + } + return [ + new PointerActivationConstraints.Delay({ + value: 200, + tolerance: 5, + }), + ]; + }, + }) + : sensor, + ) + } + > + {children} + + ); +} diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index d4c5c5a21..cdc376a70 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React from "react"; -import { DragDropProvider, type DragEndEvent } from "@dnd-kit/react"; +import { useDragDropMonitor, type DragEndEvent } from "@dnd-kit/react"; import { isSortable } from "@dnd-kit/react/sortable"; import type { Item } from "@/lib/workspace-state/types"; import { WorkspaceCard } from "./WorkspaceCard"; @@ -182,8 +182,13 @@ function WorkspaceGridComponent({ return; } - setOrderedFolderItems(snapshot.folders); - setOrderedContentItems(resolution.nextItems); + if (resolution.sourceLane === "folders") { + setOrderedFolderItems(resolution.nextItems); + setOrderedContentItems(snapshot.items); + } else { + setOrderedFolderItems(snapshot.folders); + setOrderedContentItems(resolution.nextItems); + } onMoveItem(resolution.itemId, resolution.folderId); return; } @@ -203,7 +208,7 @@ function WorkspaceGridComponent({ containerId={currentContainerId} className={GRID_ITEM_CLASS} > - {({ dragHandle }) => ( + {() => ( )} @@ -249,7 +253,7 @@ function WorkspaceGridComponent({ containerId={currentContainerId} className={GRID_ITEM_CLASS} > - {({ dragHandle }) => ( + {() => ( )} @@ -276,7 +279,7 @@ function WorkspaceGridComponent({ containerId={currentContainerId} className={GRID_ITEM_CLASS} > - {({ dragHandle }) => ( + {() => ( )} @@ -306,35 +308,38 @@ function WorkspaceGridComponent({ workspaceName, ]); + useDragDropMonitor({ + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + }); + return ( - -
-
- {folderChildren.length > 0 ? ( -
- {folderChildren} -
- ) : null} - {contentChildren.length > 0 ? ( -
- {contentChildren} -
- ) : null} -
+
+
+ {folderChildren.length > 0 ? ( +
+ {folderChildren} +
+ ) : null} + {contentChildren.length > 0 ? ( +
+ {contentChildren} +
+ ) : null}
- +
); } diff --git a/src/components/workspace-canvas/workspace-grid-dnd.ts b/src/components/workspace-canvas/workspace-grid-dnd.ts index 13ce117b3..92e35ce48 100644 --- a/src/components/workspace-canvas/workspace-grid-dnd.ts +++ b/src/components/workspace-canvas/workspace-grid-dnd.ts @@ -27,6 +27,7 @@ export type WorkspaceGridDragResolution = kind: "move-to-folder"; itemId: string; folderId: string; + sourceLane: WorkspaceGridLane; nextItems: Item[]; } | { @@ -52,20 +53,28 @@ export function resolveWorkspaceGridDragEnd(params: { : null; if ( - source.type === "item" && sourceItemId && targetData?.kind === "folder-card-drop-target" && typeof targetData.folderId === "string" ) { - if (sourceContainerId === targetData.folderId) { + const targetFolderId = targetData.folderId; + + if (sourceItemId === targetFolderId) { + return { kind: "reset" }; + } + if (sourceContainerId === targetFolderId) { return { kind: "reset" }; } + const sourceLane: WorkspaceGridLane = source.type === "folder" ? "folders" : "items"; + const affectedList = sourceLane === "folders" ? snapshot.folders : snapshot.items; + return { kind: "move-to-folder", itemId: sourceItemId, - folderId: targetData.folderId, - nextItems: snapshot.items.filter((item) => item.id !== sourceItemId), + folderId: targetFolderId, + sourceLane, + nextItems: affectedList.filter((item) => item.id !== sourceItemId), }; } diff --git a/src/hooks/workspace/use-workspace-operations.ts b/src/hooks/workspace/use-workspace-operations.ts index 1a4b6cc55..86e00ddbe 100644 --- a/src/hooks/workspace/use-workspace-operations.ts +++ b/src/hooks/workspace/use-workspace-operations.ts @@ -868,12 +868,19 @@ export function useWorkspaceOperations( const item = currentItemsRef.current.find( (candidate) => candidate.id === itemId, ); + const sortOrder = item + ? getNextSortOrderForItem(currentItemsRef.current, { + type: item.type, + folderId: folderId ?? undefined, + }) + : undefined; zeroRef.current.mutate( mutators.item.move({ workspaceId: currentWorkspaceId, itemId, folderId, itemName: item?.name, + sortOrder, }), ); }, @@ -894,6 +901,23 @@ export function useWorkspaceOperations( const itemNames = orderedItems .map((item) => item.name) .filter((name): name is string => name != null); + const allItems = [...currentItemsRef.current]; + const sortOrders = orderedItems.map((item) => { + const sortOrder = getNextSortOrderForItem(allItems, { + type: item.type, + folderId: folderId ?? undefined, + }); + allItems.push({ + ...item, + folderId: folderId ?? undefined, + sortOrder, + }); + + return { + itemId: item.id, + sortOrder, + }; + }); zeroRef.current.mutate( mutators.item.moveMany({ @@ -901,6 +925,7 @@ export function useWorkspaceOperations( itemIds: orderedItemIds, folderId, itemNames, + sortOrders, }), ); }, diff --git a/src/lib/zero/mutators.ts b/src/lib/zero/mutators.ts index 3f9a0d77c..efc3bb05f 100644 --- a/src/lib/zero/mutators.ts +++ b/src/lib/zero/mutators.ts @@ -160,12 +160,21 @@ export const zeroMutatorSchemas = { itemId: z.string(), folderId: z.string().nullable(), itemName: z.string().optional(), + sortOrder: sortOrderSchema.optional(), }), moveMany: z.object({ workspaceId: z.string().uuid(), itemIds: z.array(z.string()), folderId: z.string().nullable(), itemNames: z.array(z.string()).optional(), + sortOrders: z + .array( + z.object({ + itemId: z.string(), + sortOrder: sortOrderSchema, + }), + ) + .optional(), }), reorder: z.object({ workspaceId: z.string().uuid(), @@ -730,6 +739,20 @@ export const mutators = defineMutators({ }, ), move: defineMutator(zeroMutatorSchemas.item.move, async ({ tx, args }) => { + if (args.sortOrder !== undefined) { + await updateShellOnly(tx, { + workspaceId: args.workspaceId, + itemId: args.itemId, + shellChanges: { + folderId: args.folderId ?? undefined, + sortOrder: args.sortOrder, + layout: undefined, + lastModified: Date.now(), + }, + }); + return; + } + const existing = await loadItem(tx, { workspaceId: args.workspaceId, itemId: args.itemId, @@ -770,6 +793,35 @@ export const mutators = defineMutators({ moveMany: defineMutator( zeroMutatorSchemas.item.moveMany, async ({ tx, args }) => { + if (args.sortOrders?.length) { + const sortOrdersById = new Map( + args.sortOrders.map((entry) => [entry.itemId, entry.sortOrder]), + ); + const now = Date.now(); + + for (const itemId of args.itemIds) { + const sortOrder = sortOrdersById.get(itemId); + + if (sortOrder === undefined) { + throw new ApplicationError( + `Missing sort order for moved item ${itemId}.`, + ); + } + + await updateShellOnly(tx, { + workspaceId: args.workspaceId, + itemId, + shellChanges: { + folderId: args.folderId ?? undefined, + sortOrder, + layout: undefined, + lastModified: now, + }, + }); + } + return; + } + const now = Date.now(); const allItems = sortWorkspaceItemsByOrder( await loadWorkspaceShells(tx, args.workspaceId), From 5206fdd9de2509d659002ddd418eef86a893a871 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 19:46:49 -0400 Subject: [PATCH 20/32] Add breadcrumb drop targets for workspace moves --- .../workspace-canvas/WorkspaceGrid.test.ts | 70 +++ .../workspace-canvas/WorkspaceHeader.tsx | 516 ++++++++++-------- .../workspace-canvas/workspace-grid-dnd.ts | 61 ++- 3 files changed, 419 insertions(+), 228 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceGrid.test.ts b/src/components/workspace-canvas/WorkspaceGrid.test.ts index 938628049..dc7049c5a 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.test.ts +++ b/src/components/workspace-canvas/WorkspaceGrid.test.ts @@ -49,6 +49,7 @@ describe("resolveWorkspaceGridDragEnd", () => { kind: "move-to-folder", itemId: "doc-a", folderId: "folder-1", + sourceLane: "items", nextItems: [itemB], }); }); @@ -86,6 +87,75 @@ describe("resolveWorkspaceGridDragEnd", () => { expect(result).toEqual({ kind: "reset" }); }); + it("moves an item to the workspace root from a breadcrumb root target", () => { + const item = createItem({ + id: "doc-a", + type: "document", + folderId: "folder-1", + sortOrder: 0, + }); + + const result = resolveWorkspaceGridDragEnd({ + snapshot: { + folders: [], + items: [item], + }, + source: { + type: "item", + data: { + itemId: "doc-a", + containerId: "folder-1", + }, + initialIndex: 0, + index: 0, + initialGroup: "folder-1:items", + group: "folder-1:items", + }, + targetData: { + kind: "breadcrumb-root-drop-target", + }, + }); + + expect(result).toEqual({ + kind: "move-to-folder", + itemId: "doc-a", + folderId: null, + sourceLane: "items", + nextItems: [], + }); + }); + + it("no-ops when dropping a root item onto the root breadcrumb", () => { + const item = createItem({ + id: "doc-a", + type: "document", + sortOrder: 0, + }); + + const result = resolveWorkspaceGridDragEnd({ + snapshot: { + folders: [], + items: [item], + }, + source: { + type: "item", + data: { + itemId: "doc-a", + containerId: null, + }, + initialIndex: 0, + index: 0, + initialGroup: "root:items", + group: "root:items", + }, + targetData: { + kind: "breadcrumb-root-drop-target", + }, + }); + + expect(result).toEqual({ kind: "reset" }); + }); + it("keeps same-lane item reorder behavior", () => { const itemA = createItem({ id: "doc-a", type: "document", sortOrder: 0 }); const itemB = createItem({ id: "doc-b", type: "document", sortOrder: 1 }); diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index 2ef3a7c93..e2b85df9a 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -24,6 +24,8 @@ import { ExternalLink, MessageSquareText, } from "lucide-react"; +import { useDragDropMonitor, useDroppable } from "@dnd-kit/react"; +import { isSortable } from "@dnd-kit/react/sortable"; import { cn } from "@/lib/utils"; import { Tooltip, @@ -220,6 +222,92 @@ function GoogleIcon(props: React.SVGProps) { import { useWorkspaceFilePicker } from "@/hooks/workspace/use-workspace-file-picker"; import { startAudioProcessing } from "@/lib/audio/start-audio-processing"; +type ActiveWorkspaceDragSource = { + itemId: string | null; + containerId: string | null; +}; + +function BreadcrumbDropTarget({ + id, + kind, + folderId = null, + activeDragSource, + children, +}: { + id: string; + kind: "breadcrumb-folder-drop-target" | "breadcrumb-root-drop-target"; + folderId?: string | null; + activeDragSource: ActiveWorkspaceDragSource | null; + children: React.ReactNode; +}) { + const pointerOnlyCollision = useMemo( + () => + ({ + dragOperation, + droppable, + }: { + dragOperation: { + source?: { id?: string | number } | null; + position: { current: { x: number; y: number } | null }; + }; + droppable: { + id: string | number; + shape?: { + containsPoint: (p: { x: number; y: number }) => boolean; + center: { x: number; y: number }; + } | null; + }; + }) => { + const pointer = dragOperation.position.current; + if (!pointer || !droppable.shape) return null; + if (!droppable.shape.containsPoint(pointer)) return null; + + const cx = droppable.shape.center.x - pointer.x; + const cy = droppable.shape.center.y - pointer.y; + + return { + id: droppable.id, + value: 1 / Math.sqrt(cx * cx + cy * cy), + type: 2, + priority: 3, + }; + }, + [], + ); + + const { ref, isDropTarget } = useDroppable({ + id, + accept: ["item", "folder"], + collisionPriority: 4, + collisionDetector: pointerOnlyCollision, + data: + kind === "breadcrumb-root-drop-target" + ? { kind } + : { kind, folderId }, + }); + + const isNoOpTarget = activeDragSource + ? folderId === null + ? activeDragSource.containerId === null + : activeDragSource.itemId === folderId || + activeDragSource.containerId === folderId + : false; + const showDropAffordance = + Boolean(activeDragSource) && isDropTarget && !isNoOpTarget; + + return ( +
void} + className="relative inline-flex min-w-0 max-w-full items-center rounded-md" + > + {children} + {showDropAffordance ? ( +
+ ) : null} +
+ ); +} + interface WorkspaceHeaderProps { onOpenSearch?: () => void; // Save indicator props @@ -326,6 +414,8 @@ export function WorkspaceHeader({ const [responsiveBreadcrumbs, setResponsiveBreadcrumbs] = useState( EMPTY_RESPONSIVE_BREADCRUMBS, ); + const [activeDragSource, setActiveDragSource] = + useState(null); // Consistent breadcrumb item styling const breadcrumbItemClass = @@ -568,257 +658,220 @@ export function WorkspaceHeader({ [breadcrumbEntryLookup, responsiveBreadcrumbs.visibleTailKeys], ); - const renderRootBreadcrumbLabel = useCallback( - () => ( - <> - - - {workspaceBreadcrumbLabel} - - - ), - [workspaceBreadcrumbLabel, workspaceColor, workspaceIcon], - ); - - const renderFolderBreadcrumbLabel = useCallback( - (entry: Extract) => ( - <> - - - {entry.label} - - - ), - [], - ); - - const renderItemBreadcrumbLabel = useCallback( - (entry: Extract) => ( - <> - - - {entry.label} - - - ), - [], - ); + const renderBreadcrumbLabel = (entry: BreadcrumbEntry) => { + switch (entry.kind) { + case "root": + return ( + <> + + + {workspaceBreadcrumbLabel} + + + ); + case "folder": + return ( + <> + + + {entry.label} + + + ); + case "item": + return ( + <> + + + {entry.label} + + + ); + default: + return null; + } + }; - const renderRootBreadcrumb = useCallback(() => { - if (activeFolderId || activeOpenWorkspaceItem) { + const renderItemBreadcrumb = ( + entry: Extract, + measurement = false, + ) => { + if (measurement) { return ( - - ); - } - - if (onOpenSettings || onOpenShare) { - return ( - - - - - - {onOpenSettings && ( - - - Settings - - )} - {onOpenShare && ( - - - Share - - )} - - + {renderBreadcrumbLabel(entry)} + +
); } return (
openItemRenameDialog(entry.id, entry.label)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + openItemRenameDialog(entry.id, entry.label); + } + }} className={cn( breadcrumbItemClass, - "text-sidebar-foreground/75", + BREADCRUMB_INTERACTIVE_CLASS, + "group pr-0.5", )} > - {renderRootBreadcrumbLabel()} + {renderBreadcrumbLabel(entry)} + +
); - }, [ - activeFolderId, - breadcrumbItemClass, - onNavigateToRoot, - onOpenSettings, - onOpenShare, - activeOpenWorkspaceItem, - renderRootBreadcrumbLabel, - ]); + }; - const renderFolderBreadcrumb = useCallback( - (entry: Extract) => ( - - ), - [ - breadcrumbItemClass, - handleFolderClick, - renderFolderBreadcrumbLabel, - ], - ); + const renderBreadcrumbEntry = (entry: BreadcrumbEntry) => { + if (entry.kind === "item") { + return renderItemBreadcrumb(entry); + } - const renderItemBreadcrumb = useCallback( - ( - entry: Extract, - measurement = false, - ) => { - if (measurement) { - return ( -
+
- ); - } + {renderBreadcrumbLabel(entry)} + + + ); + } + if (activeFolderId || activeOpenWorkspaceItem) { return ( -
openItemRenameDialog(entry.id, entry.label)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - openItemRenameDialog(entry.id, entry.label); - } - }} - className={cn( - breadcrumbItemClass, - BREADCRUMB_INTERACTIVE_CLASS, - "group pr-0.5", - )} + - {renderItemBreadcrumbLabel(entry)} - -
+ ); - }, - [ - breadcrumbItemClass, - onCloseActiveItem, - openItemRenameDialog, - renderItemBreadcrumbLabel, - ], - ); + } - const renderBreadcrumbEntry = useCallback( - (entry: BreadcrumbEntry) => { - switch (entry.kind) { - case "root": - return renderRootBreadcrumb(); - case "folder": - return renderFolderBreadcrumb(entry); - case "item": - return renderItemBreadcrumb(entry); - default: - return null; - } - }, - [renderFolderBreadcrumb, renderItemBreadcrumb, renderRootBreadcrumb], - ); + if (onOpenSettings || onOpenShare) { + return ( + + + + + + + {onOpenSettings && ( + + + Settings + + )} + {onOpenShare && ( + + + Share + + )} + + + + ); + } - const renderMeasurementBreadcrumbEntry = useCallback( - (entry: BreadcrumbEntry) => { - switch (entry.kind) { - case "root": - return ( -
- {renderRootBreadcrumbLabel()} -
- ); - case "folder": - return ( -
- {renderFolderBreadcrumbLabel(entry)} -
- ); - case "item": - return renderItemBreadcrumb(entry, true); - default: - return null; - } - }, - [ - breadcrumbItemClass, - renderFolderBreadcrumbLabel, - renderItemBreadcrumb, - renderRootBreadcrumbLabel, - ], - ); + return ( +
+ {renderBreadcrumbLabel(entry)} +
+ ); + }; + + const renderMeasurementBreadcrumbEntry = (entry: BreadcrumbEntry) => { + if (entry.kind === "item") { + return renderItemBreadcrumb(entry, true); + } + + return
{renderBreadcrumbLabel(entry)}
; + }; // Auto-focus and select all text when dialog opens useEffect(() => { @@ -828,6 +881,29 @@ export function WorkspaceHeader({ } }, [showRenameDialog]); + useDragDropMonitor({ + onDragStart(event) { + const { source } = event.operation; + + if (!isSortable(source)) { + setActiveDragSource(null); + return; + } + + setActiveDragSource({ + itemId: + typeof source.data?.itemId === "string" ? source.data.itemId : null, + containerId: + typeof source.data?.containerId === "string" + ? source.data.containerId + : null, + }); + }, + onDragEnd() { + setActiveDragSource(null); + }, + }); + const handleYouTubeCreate = useCallback( (url: string, name: string, thumbnail?: string) => { @@ -963,7 +1039,7 @@ export function WorkspaceHeader({ ref={breadcrumbNavRef} className="flex min-w-0 flex-1 items-center overflow-hidden text-xs text-sidebar-foreground/70" > - {renderRootBreadcrumb()} + {renderBreadcrumbEntry(breadcrumbEntries[0])} {hiddenBreadcrumbEntries.length > 0 && ( <> diff --git a/src/components/workspace-canvas/workspace-grid-dnd.ts b/src/components/workspace-canvas/workspace-grid-dnd.ts index 92e35ce48..ec4f33f51 100644 --- a/src/components/workspace-canvas/workspace-grid-dnd.ts +++ b/src/components/workspace-canvas/workspace-grid-dnd.ts @@ -7,6 +7,14 @@ export type FolderCardDropTargetData = { folderId?: string; }; +export type BreadcrumbRootDropTargetData = { + kind?: string; +}; + +export type WorkspaceMoveDropTargetData = + | FolderCardDropTargetData + | BreadcrumbRootDropTargetData; + export type WorkspaceGridDragSource = { type?: unknown; data?: { @@ -26,7 +34,7 @@ export type WorkspaceGridDragResolution = | { kind: "move-to-folder"; itemId: string; - folderId: string; + folderId: string | null; sourceLane: WorkspaceGridLane; nextItems: Item[]; } @@ -42,7 +50,7 @@ export function resolveWorkspaceGridDragEnd(params: { items: Item[]; }; source: WorkspaceGridDragSource; - targetData?: FolderCardDropTargetData; + targetData?: WorkspaceMoveDropTargetData; }): WorkspaceGridDragResolution { const { snapshot, source, targetData } = params; const sourceItemId = @@ -52,13 +60,52 @@ export function resolveWorkspaceGridDragEnd(params: { ? source.data.containerId : null; + let targetFolderId: string | null | undefined; + if ( - sourceItemId && - targetData?.kind === "folder-card-drop-target" && - typeof targetData.folderId === "string" + targetData?.kind === "folder-card-drop-target" || + targetData?.kind === "breadcrumb-folder-drop-target" ) { - const targetFolderId = targetData.folderId; + targetFolderId = + "folderId" in targetData && typeof targetData.folderId === "string" + ? targetData.folderId + : undefined; + } else if (targetData?.kind === "breadcrumb-root-drop-target") { + targetFolderId = null; + } + const { initialIndex, index, initialGroup, group } = source; + if ( + initialGroup != null && + group != null && + initialGroup === group && + initialIndex !== index + ) { + const groupName = typeof group === "string" ? group : String(group); + let lane: WorkspaceGridLane | null = null; + + if (groupName.endsWith(":folders")) { + lane = "folders"; + } else if (groupName.endsWith(":items")) { + lane = "items"; + } + + if (!lane) { + return { kind: "reset" }; + } + + return { + kind: "reorder", + lane, + nextItems: arrayMove( + lane === "folders" ? snapshot.folders : snapshot.items, + initialIndex, + index, + ), + }; + } + + if (sourceItemId && targetFolderId !== undefined) { if (sourceItemId === targetFolderId) { return { kind: "reset" }; } @@ -78,8 +125,6 @@ export function resolveWorkspaceGridDragEnd(params: { }; } - const { initialIndex, index, initialGroup, group } = source; - if ( initialGroup == null || group == null || From a960f2a57ab5770bcf39278682b32df9144576d9 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 19:49:39 -0400 Subject: [PATCH 21/32] Tune workspace dnd feedback --- .../SortableWorkspaceGridItem.tsx | 8 ++++ .../workspace-canvas/WorkspaceHeader.tsx | 45 ++++++++----------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx index 090bf9ed3..1f0f9afc7 100644 --- a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx +++ b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx @@ -1,6 +1,7 @@ "use client"; import type { ReactNode } from "react"; +import { Feedback } from "@dnd-kit/dom"; import { useSortable } from "@dnd-kit/react/sortable"; import type { Item } from "@/lib/workspace-state/types"; import { cn } from "@/lib/utils"; @@ -41,6 +42,13 @@ export function SortableWorkspaceGridItem({ group, type: sortableType, accept: sortableType, + plugins: (defaults) => [ + ...defaults, + Feedback.configure({ + feedback: "clone", + dropAnimation: null, + }), + ], data: { group, lane, diff --git a/src/components/workspace-canvas/WorkspaceHeader.tsx b/src/components/workspace-canvas/WorkspaceHeader.tsx index e2b85df9a..e2f0f7db8 100644 --- a/src/components/workspace-canvas/WorkspaceHeader.tsx +++ b/src/components/workspace-canvas/WorkspaceHeader.tsx @@ -24,7 +24,7 @@ import { ExternalLink, MessageSquareText, } from "lucide-react"; -import { useDragDropMonitor, useDroppable } from "@dnd-kit/react"; +import { useDragOperation, useDroppable } from "@dnd-kit/react"; import { isSortable } from "@dnd-kit/react/sortable"; import { cn } from "@/lib/utils"; import { @@ -414,8 +414,23 @@ export function WorkspaceHeader({ const [responsiveBreadcrumbs, setResponsiveBreadcrumbs] = useState( EMPTY_RESPONSIVE_BREADCRUMBS, ); - const [activeDragSource, setActiveDragSource] = - useState(null); + const { source: dragSource } = useDragOperation(); + const activeDragSource = useMemo(() => { + if (!dragSource || !isSortable(dragSource)) { + return null; + } + + return { + itemId: + typeof dragSource.data?.itemId === "string" + ? dragSource.data.itemId + : null, + containerId: + typeof dragSource.data?.containerId === "string" + ? dragSource.data.containerId + : null, + }; + }, [dragSource]); // Consistent breadcrumb item styling const breadcrumbItemClass = @@ -881,30 +896,6 @@ export function WorkspaceHeader({ } }, [showRenameDialog]); - useDragDropMonitor({ - onDragStart(event) { - const { source } = event.operation; - - if (!isSortable(source)) { - setActiveDragSource(null); - return; - } - - setActiveDragSource({ - itemId: - typeof source.data?.itemId === "string" ? source.data.itemId : null, - containerId: - typeof source.data?.containerId === "string" - ? source.data.containerId - : null, - }); - }, - onDragEnd() { - setActiveDragSource(null); - }, - }); - - const handleYouTubeCreate = useCallback( (url: string, name: string, thumbnail?: string) => { if (addItem) { From 6ea0ee4911791d713e1d1dd3b2fdac855c4131f8 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:22:39 -0400 Subject: [PATCH 22/32] Simplify workspace card interactions --- package.json | 1 - pnpm-lock.yaml | 14 - .../workspace-canvas/CardDialogs.tsx | 266 +++++++ .../workspace-canvas/CardRenderer.tsx | 130 --- .../workspace-canvas/FlashcardContent.tsx | 2 - .../FlashcardWorkspaceCard.tsx | 741 ------------------ src/components/workspace-canvas/FlipCard.tsx | 161 ---- .../workspace-canvas/FolderCard.tsx | 592 +++++--------- .../workspace-canvas/ItemHeader.tsx | 7 +- .../workspace-canvas/ItemPanelContent.tsx | 100 +-- .../workspace-canvas/WorkspaceCard.tsx | 143 ++-- .../workspace-canvas/WorkspaceCardActions.tsx | 21 +- .../workspace-canvas/WorkspaceCardContent.tsx | 177 ----- .../workspace-canvas/WorkspaceCardDialogs.tsx | 134 ---- .../workspace-canvas/WorkspaceGrid.tsx | 28 - .../workspace-canvas/WorkspaceItemView.tsx | 276 +++++++ .../workspace-canvas/useCardActionState.ts | 41 + src/lib/stores/ui-store.ts | 31 - 18 files changed, 855 insertions(+), 2010 deletions(-) create mode 100644 src/components/workspace-canvas/CardDialogs.tsx delete mode 100644 src/components/workspace-canvas/CardRenderer.tsx delete mode 100644 src/components/workspace-canvas/FlashcardWorkspaceCard.tsx delete mode 100644 src/components/workspace-canvas/FlipCard.tsx delete mode 100644 src/components/workspace-canvas/WorkspaceCardContent.tsx delete mode 100644 src/components/workspace-canvas/WorkspaceCardDialogs.tsx create mode 100644 src/components/workspace-canvas/WorkspaceItemView.tsx create mode 100644 src/components/workspace-canvas/useCardActionState.ts diff --git a/package.json b/package.json index 964e27711..dce604433 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,6 @@ "react-icons": "^5.5.0", "react-jsx-parser": "^2.4.1", "react-markdown": "^10.1.0", - "react-quizlet-flashcard": "^4.0.22", "react-resizable-panels": "^4.9.0", "react-shiki": "^0.9.1", "react-zoom-pan-pinch": "^4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd9dfe102..fb050e6db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,9 +392,6 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) - react-quizlet-flashcard: - specifier: ^4.0.22 - version: 4.0.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-resizable-panels: specifier: ^4.9.0 version: 4.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9227,12 +9224,6 @@ packages: '@types/react': '>=18' react: '>=18' - react-quizlet-flashcard@4.0.22: - resolution: {integrity: sha512-YbkAY4BWi8anBNinqjuLIK6cLddlFw6iRgzcoJEXgXBR3Of/cO0uxfPN9Pr3+fO0fUTk8d1hc/tXQAupL+LhcQ==} - peerDependencies: - react: ^19.1.0 - react-dom: ^19.1.0 - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -20003,11 +19994,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-quizlet-flashcard@4.0.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: react: 19.2.5 diff --git a/src/components/workspace-canvas/CardDialogs.tsx b/src/components/workspace-canvas/CardDialogs.tsx new file mode 100644 index 000000000..6dc58476a --- /dev/null +++ b/src/components/workspace-canvas/CardDialogs.tsx @@ -0,0 +1,266 @@ +"use client"; + +import type { ColorResult } from "react-color"; +import { SwatchesPicker } from "react-color"; +import type { Item } from "@/lib/workspace-state/types"; +import { SWATCHES_COLOR_GROUPS } from "@/lib/workspace-state/colors"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import MoveToDialog from "@/components/modals/MoveToDialog"; +import RenameDialog from "@/components/modals/RenameDialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; + +interface SharedCardDialogsProps { + item: Item; + allItems: Item[]; + workspaceName: string; + workspaceIcon?: string | null; + workspaceColor?: string | null; + isColorPickerOpen: boolean; + onColorPickerOpenChange: (open: boolean) => void; + showMoveDialog: boolean; + onMoveDialogChange: (open: boolean) => void; + showRenameDialog: boolean; + onRenameDialogChange: (open: boolean) => void; + onColorChange: (color: ColorResult) => void; + onRename: (newName: string) => void; + onMove?: (folderId: string | null) => void; + colorDialogTitle?: string; +} + +export function SharedCardDialogs({ + item, + allItems, + workspaceName, + workspaceIcon, + workspaceColor, + isColorPickerOpen, + onColorPickerOpenChange, + showMoveDialog, + onMoveDialogChange, + showRenameDialog, + onRenameDialogChange, + onColorChange, + onRename, + onMove, + colorDialogTitle = "Choose a Color", +}: SharedCardDialogsProps) { + return ( + <> + + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > + + {colorDialogTitle} + +
event.stopPropagation()} + > + +
+
+
+ + + + {onMove ? ( + + ) : null} + + ); +} + +interface SimpleDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + onConfirm: () => void; +} + +export function SimpleDeleteDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Delete", + onConfirm, +}: SimpleDeleteDialogProps) { + return ( + + + + {title} + {description} + + + Cancel + + {confirmLabel} + + + + + ); +} + +interface FolderDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + itemName: string; + itemCount: number; + canDeleteContents: boolean; + deleteOption: "keep" | "delete" | null; + onDeleteOptionChange: (value: "keep" | "delete") => void; + onConfirm: () => void; + onCancel: () => void; +} + +export function FolderDeleteDialog({ + open, + onOpenChange, + itemName, + itemCount, + canDeleteContents, + deleteOption, + onDeleteOptionChange, + onConfirm, + onCancel, +}: FolderDeleteDialogProps) { + return ( + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + Delete Folder + +
+
+ Choose what happens to the {itemCount}{" "} + {itemCount === 1 ? "item" : "items"} in "{itemName} + ": +
+
+ + +
+
+
+
+ + { + e.stopPropagation(); + onCancel(); + }} + onMouseDown={(e) => e.stopPropagation()} + > + Cancel + + { + e.stopPropagation(); + onConfirm(); + }} + onMouseDown={(e) => e.stopPropagation()} + disabled={deleteOption === null} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50" + > + Delete + + +
+
+ ); +} diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx deleted file mode 100644 index 70ebbb8b9..000000000 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import type { - Item, - ItemData, - PdfData, - FlashcardData, - YouTubeData, - ImageData, - DocumentData, -} from "@/lib/workspace-state/types"; -import { DocumentEditor } from "@/components/editor/DocumentEditor"; -import FlashcardContent from "./FlashcardContent"; -import YouTubeCardContent from "./YouTubeCardContent"; -import ImageCardContent from "./ImageCardContent"; -import { AudioCardContent } from "./AudioCardContent"; - -import { QuizContent } from "./QuizContent"; - -export function CardRenderer(props: { - item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; - layoutKey?: string | number; - quizClassName?: string; // Optional padding/className for quiz when shown in modal -}) { - const { item, onUpdateData, quizClassName } = props; - - if (item.type === "pdf") { - const pdfData = item.data as PdfData; - return ( -
-

- PDF: {pdfData.filename || "Document"} -

-

- Use the PDF viewer to read this document -

-
- ); - } - - if (item.type === "flashcard") { - return ; - } - - if (item.type === "quiz") { - return ( - - ); - } - - if (item.type === "youtube") { - return ; - } - - if (item.type === "image") { - return ; - } - - if (item.type === "audio") { - return ; - } - - if (item.type === "website") { - // Website cards are rendered via WebsitePanelContent in the panel; - // this fallback is for generic/modal context - const websiteData = - item.data as import("@/lib/workspace-state/types").WebsiteData; - let hostname = ""; - try { - hostname = new URL(websiteData.url).hostname.replace(/^www\./, ""); - } catch {} - return ( -
- {websiteData.favicon ? ( - - ) : ( -
- - {hostname.charAt(0).toUpperCase()} - -
- )} - {hostname && ( - {hostname} - )} -
- ); - } - - if (item.type === "document") { - const documentData = item.data as DocumentData; - const md = documentData.markdown ?? ""; - return ( -
- { - onUpdateData(() => ({ - markdown, - })); - }} - /> -
- ); - } - - return ( -
-

- Unknown card type: {item.type} -

-
- ); -} - -export default CardRenderer; diff --git a/src/components/workspace-canvas/FlashcardContent.tsx b/src/components/workspace-canvas/FlashcardContent.tsx index b1c4cb10a..6d7a48b87 100644 --- a/src/components/workspace-canvas/FlashcardContent.tsx +++ b/src/components/workspace-canvas/FlashcardContent.tsx @@ -3,14 +3,12 @@ import { useMemo } from "react"; import type { Item, - ItemData, FlashcardData, } from "@/lib/workspace-state/types"; import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; interface FlashcardContentProps { item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; } function FlashcardSidePreview({ diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx deleted file mode 100644 index 20c476e8f..000000000 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ /dev/null @@ -1,741 +0,0 @@ -"use client"; - -import { - useState, - useCallback, - useEffect, - useRef, - useMemo, - memo, -} from "react"; -import { toast } from "sonner"; -import { - MoreVertical, - Trash2, - CheckCircle2, - Pencil, - Palette, - ChevronLeft, - ChevronRight, - X, - FolderInput, -} from "lucide-react"; -import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; -import { useTheme } from "next-themes"; -import type { - Item, - FlashcardData, - FlashcardItem, -} from "@/lib/workspace-state/types"; -import { FlipCard } from "./FlipCard"; -import { - SWATCHES_COLOR_GROUPS, - getCardColorCSS, - getCardAccentColor, - type CardColor, -} from "@/lib/workspace-state/colors"; -import { SwatchesPicker, ColorResult } from "react-color"; -import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; -import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import MoveToDialog from "@/components/modals/MoveToDialog"; -import RenameDialog from "@/components/modals/RenameDialog"; -import { cn } from "@/lib/utils"; -interface FlashcardWorkspaceCardProps { - item: Item; - allItems?: Item[]; // All items for the move dialog tree - workspaceName?: string; - workspaceIcon?: string | null; - workspaceColor?: string | null; - onUpdateItem: (itemId: string, updates: Partial) => void; - onDeleteItem: (itemId: string) => void; - onOpenModal: (itemId: string) => void; - onMoveItem?: (itemId: string, folderId: string | null) => void; - // NOTE: isSelected removed - card subscribes directly to store for performance - // onToggleSelection is still passed as a prop for the shift+click handler -} - -const EMPTY_FLASHCARD_PLACEHOLDER: FlashcardItem = { - id: "__empty__", - front: "", - back: "", -}; - -/** Center all markdown blocks on the card; keep code blocks full-width and left-aligned. */ -const FLASHCARD_STREAMDOWN_CLASS = cn( - // Fluid type: size comes from the nearest [container-type:size] ancestor (card face) - "font-medium max-w-none text-center leading-[1.45] text-[length:clamp(0.82rem,0.42rem+3cqmin,2.85rem)]", - // globals.css fixes .streamdown-content at 0.875rem — inherit this wrapper’s fluid size instead - "[&_.streamdown-content]:!text-inherit [&_.streamdown-content]:![font-size:1em]", - "[&_.streamdown-content_p]:!text-inherit [&_.streamdown-content_li]:!text-inherit [&_.streamdown-content_td]:!text-inherit [&_.streamdown-content_th]:!text-inherit", - "[&_.streamdown-content_h1]:!text-[1.32em] [&_.streamdown-content_h2]:!text-[1.2em] [&_.streamdown-content_h3]:!text-[1.1em] [&_.streamdown-content_h4]:!text-[1.04em]", - "[&_.streamdown-content_pre]:!text-[0.9em]", - "[&_.streamdown-content]:flex [&_.streamdown-content]:flex-col [&_.streamdown-content]:items-center [&_.streamdown-content]:text-center", - // Lists: drop outside gutter so the block sits in the centered column - "[&_.streamdown-content_ul]:!ml-0 [&_.streamdown-content_ul]:list-inside", - "[&_.streamdown-content_ol]:!ml-0 [&_.streamdown-content_ol]:list-inside", - // Wide / monospace blocks: stretch and align like normal editors - "[&_.streamdown-content_[data-streamdown=code-block]]:w-full [&_.streamdown-content_[data-streamdown=code-block]]:max-w-full [&_.streamdown-content_[data-streamdown=code-block]]:self-stretch [&_.streamdown-content_[data-streamdown=code-block]]:!text-left", - "[&_.streamdown-content_pre]:w-full [&_.streamdown-content_pre]:max-w-full [&_.streamdown-content_pre]:self-stretch [&_.streamdown-content_pre]:!text-left", - // Tables: use full width when present; cells follow markdown alignment when set - "[&_.streamdown-content_table]:w-full [&_.streamdown-content_table]:max-w-full [&_.streamdown-content_table]:self-stretch", -); - -/** Read-only markdown per side. */ -const FlashcardSideMarkdownView = memo( - function FlashcardSideMarkdownView({ - markdown, - isScrollLocked, - className = "", - }: { - markdown: string; - isScrollLocked: boolean; - className?: string; - }) { - return ( -
-
-
- {!markdown.trim() ? ( -
- Ask the AI or click the pencil icon to add flashcards -
- ) : ( - - {markdown} - - )} -
-
-
- ); - }, - (prev, next) => - prev.markdown === next.markdown && - prev.isScrollLocked === next.isScrollLocked && - prev.className === next.className, -); - -export function FlashcardWorkspaceCard({ - item, - allItems, - workspaceName, - workspaceIcon, - workspaceColor, - onUpdateItem, - onDeleteItem, - onOpenModal, - onMoveItem, -}: FlashcardWorkspaceCardProps) { - // Subscribe directly to this card's selection state from the store - // This prevents full grid re-renders when selection changes - const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); - const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - const { resolvedTheme } = useTheme(); - - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [isFlipped, setIsFlipped] = useState(false); - const [isFlipping, setIsFlipping] = useState(false); - const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); - const toggleItemScrollLocked = useUIStore( - (state) => state.toggleItemScrollLocked, - ); - const flashcardData = item.data as FlashcardData; - - // Navigation State - const [currentIndex, setCurrentIndex] = useState(0); - - const cards = useMemo( - () => flashcardData.cards ?? [], - [flashcardData.cards], - ); - - // Ensure index is valid - useEffect(() => { - if (currentIndex >= cards.length) { - setCurrentIndex(0); - } - }, [cards.length, currentIndex]); - - // Persist index change (optional debounce?) - const handleIndexChange = useCallback((newIndex: number) => { - // Don't persist on every click to avoid network spam, or do? - // For now just local state is smooth, maybe updating item is fine. - // Let's keep it local for now, prop update on unmount? - setCurrentIndex(newIndex); - }, []); - - const currentCard = useMemo((): FlashcardItem => { - if (cards.length === 0) { - return EMPTY_FLASHCARD_PLACEHOLDER; - } - const safeIndex = Math.min( - Math.max(0, currentIndex), - cards.length - 1, - ); - return cards[safeIndex] ?? EMPTY_FLASHCARD_PLACEHOLDER; - }, [cards, currentIndex]); - - const FLIP_ANIMATION_DURATION = 600; - const lastFlipTimeRef = useRef(0); - - const handleDelete = useCallback(() => { - setShowDeleteDialog(true); - }, []); - - const confirmDelete = useCallback(() => { - onDeleteItem(item.id); - setShowDeleteDialog(false); - }, [item.id, onDeleteItem]); - - const handleColorChange = useCallback( - (color: ColorResult) => { - onUpdateItem(item.id, { color: color.hex as CardColor }); - setIsColorPickerOpen(false); - }, - [item.id, onUpdateItem], - ); - - const handleRename = useCallback( - (newName: string) => { - onUpdateItem(item.id, { name: newName }); - toast.success("Flashcard renamed"); - }, - [item.id, onUpdateItem], - ); - - const startFlipAnimation = useCallback(() => { - setIsFlipping(true); - setTimeout(() => setIsFlipping(false), FLIP_ANIMATION_DURATION); - }, []); - - const handleFlip = useCallback(() => { - const now = Date.now(); - if (now - lastFlipTimeRef.current < 200) return; - lastFlipTimeRef.current = now; - setIsFlipped((prev) => !prev); - startFlipAnimation(); - }, [startFlipAnimation]); - - const isInteractiveTarget = useCallback((target: HTMLElement) => { - return Boolean( - target.closest("button") || - target.closest("input") || - target.closest("textarea") || - target.closest("select") || - target.closest("a") || - target.closest("label") || - target.closest(".flashcard-control-button") || - target.closest('[role="menuitem"]') || - target.closest('[contenteditable="true"]') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[data-slot="dialog-content"]') || - target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]') - ); - }, []); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if (isInteractiveTarget(target)) { - e.stopPropagation(); - return; - } - - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) return; - - if (e.shiftKey) { - e.stopPropagation(); - onToggleSelection(item.id); - return; - } - - if (!isScrollLocked) return; - - handleFlip(); - }, - [handleFlip, isInteractiveTarget, isScrollLocked, onToggleSelection, item.id], - ); - - // Navigation Handlers - const goNext = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - const wasFlipped = isFlipped; // Check if we're currently on the back side - setIsFlipped(false); // Reset flip - // Only hide tabs if we were on the back side (will cause flip animation) - if (wasFlipped) { - startFlipAnimation(); - } - handleIndexChange((currentIndex + 1) % cards.length); - }, - [ - isFlipped, - startFlipAnimation, - handleIndexChange, - currentIndex, - cards.length, - ], - ); - - const goPrev = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - const wasFlipped = isFlipped; // Check if we're currently on the back side - setIsFlipped(false); // Reset flip - // Only hide tabs if we were on the back side (will cause flip animation) - if (wasFlipped) { - startFlipAnimation(); - } - handleIndexChange((currentIndex - 1 + cards.length) % cards.length); - }, - [ - isFlipped, - startFlipAnimation, - handleIndexChange, - currentIndex, - cards.length, - ], - ); - - // Calculate border styling to match WorkspaceCard - const borderColor = isSelected - ? "rgba(255, 255, 255, 0.8)" - : item.color - ? getCardAccentColor(item.color, resolvedTheme === "dark" ? 0.5 : 0.3) - : "transparent"; - const borderWidth = isSelected ? "3px" : "1px"; - const selectedBoxShadow = - isSelected && resolvedTheme !== "dark" - ? "0 0 3px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.5)" - : undefined; - const neutralControlBg = - resolvedTheme === "dark" ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"; - const neutralControlHoverBg = "rgba(0, 0, 0, 0.5)"; - const selectedControlBg = isSelected - ? "rgba(239, 68, 68, 0.3)" - : neutralControlBg; - const selectedControlHoverBg = isSelected - ? "rgba(239, 68, 68, 0.5)" - : neutralControlHoverBg; - const getControlStyle = (backgroundColor: string) => ({ - backgroundColor, - backdropFilter: "blur(8px)", - }); - const createControlHoverHandlers = (baseColor: string, hoverColor: string) => ({ - onMouseEnter: (e: React.MouseEvent) => { - e.currentTarget.style.backgroundColor = hoverColor; - }, - onMouseLeave: (e: React.MouseEvent) => { - e.currentTarget.style.backgroundColor = baseColor; - }, - }); - - return ( - - -
- {/* Floating Controls */} -
- - - - - - - - - - - e.stopPropagation()} - > - setShowRenameDialog(true)}> - - Rename - - {onMoveItem && ( - <> - setShowMoveDialog(true)}> - - Move to - - - - )} - onOpenModal(item.id)}> - - Edit - - setIsColorPickerOpen(true)}> - - Change Color - - - - - Delete - - - -
- - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - - Choose a Color - -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - -
-
-
- - {cards.length > 1 && ( - <> - - - -
- {currentIndex + 1} / {cards.length} -
- - )} - -
-
- {item.name === "Update me" && - (!flashcardData.cards || flashcardData.cards.length === 0) ? ( -
-
-
- Generating flashcards... -
-
- ) : ( - - } - back={ - - } - color={item.color} - isFlipped={isFlipped} - borderColor={borderColor} - borderWidth={borderWidth} - selectedBoxShadow={selectedBoxShadow} - /> - )} -
-
-
-
- - - e.stopPropagation()}> - - Delete Flashcard - - Are you sure you want to delete " - {item.name || "this flashcard"}"? You can restore from version - history if needed. - - - - Cancel - - Delete - - - - - - {/* Rename Dialog */} - - - {/* Move to Dialog */} - {onMoveItem && allItems && workspaceName && ( - { - onMoveItem(item.id, folderId); - toast.success("Item moved"); - }} - /> - )} -
- - - {/* Right-Click Context Menu */} - - setShowRenameDialog(true)}> - - Rename - - {onMoveItem && ( - <> - setShowMoveDialog(true)}> - - Move to - - - - )} - onOpenModal(item.id)}> - - Edit - - setIsColorPickerOpen(true)}> - - Change Color - - - - - Delete - - - - ); -} - -export default FlashcardWorkspaceCard; diff --git a/src/components/workspace-canvas/FlipCard.tsx b/src/components/workspace-canvas/FlipCard.tsx deleted file mode 100644 index 389c5c5a1..000000000 --- a/src/components/workspace-canvas/FlipCard.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import { memo, type ReactNode, useMemo, useState, useEffect, useRef } from "react"; -import { getCardColorCSS, getCardAccentColor, type CardColor } from "@/lib/workspace-state/colors"; - -interface FlipCardProps { - front: ReactNode; - back: ReactNode; - className?: string; - color?: string; - isFlipped: boolean; - borderColor?: string; - borderWidth?: string; - selectedBoxShadow?: string; -} - -// Memoize the entire FlipCard to prevent unnecessary re-renders -export const FlipCard = memo(function FlipCard({ front, back, className = "", color, isFlipped, borderColor, borderWidth, selectedBoxShadow }: FlipCardProps) { - // Track animation state to control content visibility - const [isAnimating, setIsAnimating] = useState(false); - const [showFront, setShowFront] = useState(!isFlipped); - const animationTimeoutRef = useRef(null); - const prevFlippedRef = useRef(isFlipped); - - // Memoize colors to prevent recalculation - use same colors as WorkspaceCard - const cardBgColor = useMemo(() => - color ? getCardColorCSS(color as CardColor, 0.25) : 'var(--card)', - [color]); - - // Handle flip state changes - useEffect(() => { - if (prevFlippedRef.current !== isFlipped) { - prevFlippedRef.current = isFlipped; - setIsAnimating(true); - - // Clear any existing timeout - if (animationTimeoutRef.current) { - clearTimeout(animationTimeoutRef.current); - } - - // At the midpoint of the animation (300ms), switch which content is visible - animationTimeoutRef.current = setTimeout(() => { - setShowFront(!isFlipped); - }, 280); - - // Animation complete - setTimeout(() => { - setIsAnimating(false); - }, 600); - } - - return () => { - if (animationTimeoutRef.current) { - clearTimeout(animationTimeoutRef.current); - } - }; - }, [isFlipped]); - - // Static box shadow, merge with selected shadow if present - const boxShadow = selectedBoxShadow - ? `0 8px 30px rgba(0, 0, 0, 0.2), ${selectedBoxShadow}` - : "0 8px 30px rgba(0, 0, 0, 0.2)"; - - return ( -
-
- {/* Front Side */} -
- {/* Content wrapper with visibility control */} -
- {front} -
-
- - {/* Back Side */} -
- {/* Content wrapper with visibility control */} -
- {back} -
-
-
-
- ); -}, (prevProps, nextProps) => { - // Only re-render if these specific props change - return ( - prevProps.isFlipped === nextProps.isFlipped && - prevProps.color === nextProps.color && - prevProps.className === nextProps.className && - prevProps.front === nextProps.front && - prevProps.back === nextProps.back && - prevProps.borderColor === nextProps.borderColor && - prevProps.borderWidth === nextProps.borderWidth && - prevProps.selectedBoxShadow === nextProps.selectedBoxShadow - ); -}); - -export default FlipCard; diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index 69995c24b..9d09e70e6 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -2,61 +2,29 @@ import { useDroppable } from "@dnd-kit/react"; import { memo, useState, useCallback, useEffect, useMemo } from "react"; -import { - MoreVertical, - Trash2, - Palette, - CheckCircle2, - FolderInput, - X, - Pencil, -} from "lucide-react"; -import ItemHeader from "@/components/workspace-canvas/ItemHeader"; -import { SwatchesPicker, ColorResult } from "react-color"; +import { FolderInput } from "lucide-react"; +import { type ColorResult } from "react-color"; import { cn } from "@/lib/utils"; import type { Item } from "@/lib/workspace-state/types"; import { getCardColorCSS, getCardAccentColor, - SWATCHES_COLOR_GROUPS, type CardColor, } from "@/lib/workspace-state/colors"; import { useTheme } from "next-themes"; - import { useUIStore } from "@/lib/stores/ui-store"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { ContextMenu, ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import MoveToDialog from "@/components/modals/MoveToDialog"; -import RenameDialog from "@/components/modals/RenameDialog"; import { toast } from "sonner"; +import { + WorkspaceCardContextMenuItems, + WorkspaceCardControls, +} from "./WorkspaceCardActions"; +import { FolderDeleteDialog, SharedCardDialogs } from "./CardDialogs"; +import { useCardActionState } from "./useCardActionState"; interface FolderCardProps { item: Item; @@ -87,25 +55,48 @@ function FolderCardComponent({ onMoveItem, itemDropTargetId, }: FolderCardProps) { - const [showColorPicker, setShowColorPicker] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const { + isColorPickerOpen, + setIsColorPickerOpen, + showDeleteDialog, + setShowDeleteDialog, + showMoveDialog, + setShowMoveDialog, + showRenameDialog, + setShowRenameDialog, + openColorPicker, + openDeleteDialog, + openMoveDialog, + openRenameDialog, + } = useCardActionState(); const [deleteOption, setDeleteOption] = useState<"keep" | "delete" | null>( null, ); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isEditingTitle, setIsEditingTitle] = useState(false); - const [shouldAutoFocus, setShouldAutoFocus] = useState(false); const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); const onToggleSelection = useUIStore((state) => state.toggleCardSelection); + const { resolvedTheme } = useTheme(); const folderColor = item.color || "#6366F1"; const pointerOnlyCollision = useMemo( () => - ({ dragOperation, droppable }: { dragOperation: { source?: { id?: string | number } | null; position: { current: { x: number; y: number } | null } }; droppable: { id: string | number; shape?: { containsPoint: (p: { x: number; y: number }) => boolean; center: { x: number; y: number } } | null } }) => { + ({ + dragOperation, + droppable, + }: { + dragOperation: { + source?: { id?: string | number } | null; + position: { current: { x: number; y: number } | null }; + }; + droppable: { + id: string | number; + shape?: { + containsPoint: (point: { x: number; y: number }) => boolean; + center: { x: number; y: number }; + } | null; + }; + }) => { if (dragOperation.source?.id === item.id) return null; const pointer = dragOperation.position.current; if (!pointer || !droppable.shape) return null; @@ -134,32 +125,46 @@ function FolderCardComponent({ }); useEffect(() => { - if (item.name === "New Folder") { - setShouldAutoFocus(true); - const element = document.getElementById(`item-${item.id}`); - if (element) { - setTimeout(() => { - element.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 100); - } + if (item.name !== "New Folder") { + return; + } + + setShowRenameDialog(true); + + const element = document.getElementById(`item-${item.id}`); + if (!element) { + return; + } + + const timeoutId = setTimeout(() => { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + + return () => clearTimeout(timeoutId); + }, [item.id, item.name, setShowRenameDialog]); + + useEffect(() => { + if (!showDeleteDialog) { + setDeleteOption(null); } - }, [item.id, item.name]); + }, [showDeleteDialog]); const isInteractiveTarget = useCallback((target: HTMLElement) => { return Boolean( target.closest("button") || - target.closest("input") || - target.closest("textarea") || - target.closest("select") || - target.closest("a") || - target.closest("label") || - target.closest('[role="menuitem"]') || - target.closest('[contenteditable="true"]') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[data-slot="dialog-content"]') || - target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]'), + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="popover-content"]') || + target.closest('[data-slot="popover"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]'), ); }, []); @@ -181,39 +186,46 @@ function FolderCardComponent({ return; } - if (isEditingTitle) { + onOpenFolder(item.id); + }, + [isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Enter" && e.key !== " ") { + return; + } + + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { + return; + } + + e.preventDefault(); + + if (e.shiftKey) { + onToggleSelection(item.id); return; } onOpenFolder(item.id); }, - [ - isEditingTitle, - isInteractiveTarget, - item.id, - onOpenFolder, - onToggleSelection, - ], + [isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], ); const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); - setShowColorPicker(false); + setIsColorPickerOpen(false); }, - [item.id, onUpdateItem], + [item.id, onUpdateItem, setIsColorPickerOpen], ); - const handleNameChange = useCallback( - (v: string) => { - onUpdateItem(item.id, { name: v }); - }, - [item.id, onUpdateItem], - ); - - const handleNameCommit = useCallback( - (v: string) => { - onUpdateItem(item.id, { name: v }); + const handleRename = useCallback( + (newName: string) => { + onUpdateItem(item.id, { name: newName }); + toast.success("Folder renamed"); }, [item.id, onUpdateItem], ); @@ -224,25 +236,15 @@ function FolderCardComponent({ } else { onDeleteItem(item.id); } - setShowDeleteConfirm(false); + setShowDeleteDialog(false); setDeleteOption(null); - }, [item.id, onDeleteItem, onDeleteFolderWithContents, deleteOption]); - - const handleRename = useCallback( - (newName: string) => { - onUpdateItem(item.id, { name: newName }); - toast.success("Folder renamed"); - }, - [item.id, onUpdateItem], - ); - - useEffect(() => { - if (!showDeleteConfirm) { - setDeleteOption(null); - } - }, [showDeleteConfirm]); - - const { resolvedTheme } = useTheme(); + }, [ + deleteOption, + item.id, + onDeleteFolderWithContents, + onDeleteItem, + setShowDeleteDialog, + ]); const bodyBgColor = getCardColorCSS(folderColor, 0.25); const tabBgColor = getCardColorCSS(folderColor, 0.35); @@ -270,13 +272,16 @@ function FolderCardComponent({ )} style={selectedRingStyle} onClick={handleClick} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} >
- - - - - - - e.stopPropagation()} - > - { - e.stopPropagation(); - setShowRenameDialog(true); - }} - > - - Rename - - {onMoveItem && ( - <> - { - e.stopPropagation(); - setShowMoveDialog(true); - }} - > - - Move to - - - - )} - { - e.stopPropagation(); - setShowColorPicker(true); - }} - > - - Change Color - - - { - e.stopPropagation(); - setShowDeleteConfirm(true); - }} - className="text-destructive focus:text-destructive" - > - - Delete Folder - - - - -
-
- { - handleNameCommit(value); - if (shouldAutoFocus) { - setShouldAutoFocus(false); - } - }} - onSubtitleChange={() => {}} - onTitleFocus={() => setIsEditingTitle(true)} - onTitleBlur={() => setIsEditingTitle(false)} - readOnly={false} - noMargin={true} - autoFocus={shouldAutoFocus} - /> -

+ onToggleSelection(item.id)} + onOpenRename={openRenameDialog} + onOpenMove={openMoveDialog} + onCopyMarkdown={() => {}} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} + /> + +

+
+
+ {item.name || "Folder"} +
+

{itemCount} {itemCount === 1 ? "item" : "items"}

@@ -446,10 +332,8 @@ function FolderCardComponent({
@@ -461,168 +345,54 @@ function FolderCardComponent({ ) : null}
- - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - - Choose Folder Color - -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - -
-
-
- - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - - Delete Folder - -
-
- Choose what happens to the {itemCount}{" "} - {itemCount === 1 ? "item" : "items"} in "{item.name} - ": -
-
- - -
-
-
-
- - { - e.stopPropagation(); - setDeleteOption(null); - }} - onMouseDown={(e) => e.stopPropagation()} - > - Cancel - - { - e.stopPropagation(); - handleDelete(); - }} - onMouseDown={(e) => e.stopPropagation()} - disabled={deleteOption === null} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 disabled:cursor-not-allowed" - > - Delete - - -
-
- - { + onMoveItem(item.id, folderId); + toast.success("Folder moved"); + } + : undefined + } + colorDialogTitle="Choose Folder Color" + /> + setDeleteOption(null)} /> - - {onMoveItem && ( - { - onMoveItem(item.id, folderId); - toast.success("Folder moved"); - }} - /> - )}
- setShowRenameDialog(true)}> - - Rename - - {onMoveItem && ( - <> - setShowMoveDialog(true)}> - - Move to - - - - )} - setShowColorPicker(true)}> - - Change Color - - - setShowDeleteConfirm(true)} - className="text-destructive focus:text-destructive" - > - - Delete Folder - + {}} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} + /> ); diff --git a/src/components/workspace-canvas/ItemHeader.tsx b/src/components/workspace-canvas/ItemHeader.tsx index bc4a17cf7..1127c91c3 100644 --- a/src/components/workspace-canvas/ItemHeader.tsx +++ b/src/components/workspace-canvas/ItemHeader.tsx @@ -8,8 +8,8 @@ export function ItemHeader(props: { name: string; subtitle: string; description: string; - onNameChange: (value: string) => void; - onSubtitleChange: (value: string) => void; + onNameChange?: (value: string) => void; + onSubtitleChange?: (value: string) => void; onNameCommit?: (value: string) => void; onDescriptionCommit?: (value: string) => void; readOnly?: boolean; @@ -115,7 +115,7 @@ export function ItemHeader(props: { if (value !== name) { if (onNameCommit) { onNameCommit(value); - } else { + } else if (onNameChange) { onNameChange(value); } } @@ -235,4 +235,3 @@ export function ItemHeader(props: { export default ItemHeader; - diff --git a/src/components/workspace-canvas/ItemPanelContent.tsx b/src/components/workspace-canvas/ItemPanelContent.tsx index 88ebed0ad..fdedb584f 100644 --- a/src/components/workspace-canvas/ItemPanelContent.tsx +++ b/src/components/workspace-canvas/ItemPanelContent.tsx @@ -1,21 +1,17 @@ "use client"; -import { useState } from "react"; import { X } from "lucide-react"; import { LuMinimize2 } from "react-icons/lu"; import ChatFloatingButton from "@/components/chat/ChatFloatingButton"; import ItemHeader from "@/components/workspace-canvas/ItemHeader"; -import CardRenderer from "@/components/workspace-canvas/CardRenderer"; -import LazyAppPdfViewer from "@/components/pdf/LazyAppPdfViewer"; -import LazyImageViewer from "@/components/image-viewer/LazyImageViewer"; -import { ImagePanelHeader } from "@/components/image-viewer/ImagePanelHeader"; -import { YouTubePanelContent } from "@/components/workspace-canvas/YouTubePanelContent"; -import { WebsitePanelContent } from "@/components/workspace-canvas/WebsitePanelContent"; -import { PdfPanelHeader } from "@/components/pdf/PdfPanelHeader"; import { getCardColorCSS, getWhiteTintedColor } from "@/lib/workspace-state/colors"; -import type { Item, ItemData, PdfData, ImageData } from "@/lib/workspace-state/types"; +import type { Item, ItemData, PdfData } from "@/lib/workspace-state/types"; import { useUIStore } from "@/lib/stores/ui-store"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + usesManagedWorkspacePanelLayout, + WorkspacePanelItemPreview, +} from "@/components/workspace-canvas/WorkspaceItemView"; interface ItemPanelContentProps { item: Item; @@ -40,15 +36,10 @@ export function ItemPanelContent({ const isDesktop = true; const isPdf = item.type === 'pdf'; - const isYouTube = item.type === 'youtube'; - const isWebsite = item.type === 'website'; - const isImage = item.type === 'image'; const pdfPreviewUrl = isPdf ? (item.data as PdfData).fileUrl : undefined; /** PDF cards still processing upload have no viewer yet — need a local title bar. */ const showPdfAwaitingFileHeader = isPdf && !pdfPreviewUrl; - const [showThumbnails, setShowThumbnails] = useState(false); - const renderPdfAwaitingFileHeader = () => (
@@ -122,83 +113,20 @@ export function ItemPanelContent({ {showPdfAwaitingFileHeader && renderPdfAwaitingFileHeader()}
- {isPdf && pdfPreviewUrl ? ( -
- = 1 && - !citationHighlightQuery?.query?.trim() - ? citationHighlightQuery.pageNumber - : undefined - } - isMaximized={true} - renderHeader={(documentId, annotationControls) => ( -
- setShowThumbnails(!showThumbnails)} - renderInPortal={true} - /> -
- )} - /> -
- ) : isYouTube ? ( - - ) : isWebsite ? ( - - ) : isImage ? ( -
- ( - - )} - /> -
- ) : ( - - )} +
); diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index bba4638c9..b9b1e84ba 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -1,9 +1,4 @@ -import { - useCallback, - useState, - memo, - type CSSProperties, -} from "react"; +import { useCallback, memo, type CSSProperties } from "react"; import { useTheme } from "next-themes"; import { toast } from "sonner"; import { @@ -23,9 +18,13 @@ import { WorkspaceCardContextMenuItems, WorkspaceCardControls, } from "./WorkspaceCardActions"; -import { WorkspaceCardContent } from "./WorkspaceCardContent"; -import { WorkspaceCardDialogs } from "./WorkspaceCardDialogs"; +import { SharedCardDialogs, SimpleDeleteDialog } from "./CardDialogs"; import { WorkspaceCardTypeBadge } from "./WorkspaceCardTypeBadge"; +import { + isFramelessWorkspaceCardItem, + WorkspaceCanvasItemPreview, +} from "./WorkspaceItemView"; +import { useCardActionState } from "./useCardActionState"; interface WorkspaceCardProps { item: Item; @@ -54,59 +53,34 @@ function WorkspaceCard({ const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - - const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [isEditingTitle, setIsEditingTitle] = useState(false); - - const handleNameChange = useCallback( - (v: string) => { - onUpdateItem(item.id, { name: v }); - }, - [item.id, onUpdateItem], - ); - - const handleNameCommit = useCallback( - (v: string) => { - onUpdateItem(item.id, { name: v }); - }, - [item.id, onUpdateItem], - ); - - const handleSubtitleChange = useCallback( - (v: string) => { - onUpdateItem(item.id, { subtitle: v }); - }, - [item.id, onUpdateItem], - ); - - const handleTitleFocus = useCallback(() => { - setIsEditingTitle(true); - }, []); - - const handleTitleBlur = useCallback(() => { - setIsEditingTitle(false); - }, []); + const { + isColorPickerOpen, + setIsColorPickerOpen, + showDeleteDialog, + setShowDeleteDialog, + showMoveDialog, + setShowMoveDialog, + showRenameDialog, + setShowRenameDialog, + openColorPicker, + openDeleteDialog, + openMoveDialog, + openRenameDialog, + } = useCardActionState(); const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); setIsColorPickerOpen(false); }, - [item.id, onUpdateItem], + [item.id, onUpdateItem, setIsColorPickerOpen], ); - const handleDelete = useCallback(() => { - setShowDeleteDialog(true); - }, []); - const handleDeleteConfirm = useCallback(() => { onDeleteItem(item.id); setShowDeleteDialog(false); toast.success("Card deleted successfully"); - }, [item.id, onDeleteItem]); + }, [item.id, onDeleteItem, setShowDeleteDialog]); const handleRename = useCallback( (newName: string) => { @@ -142,7 +116,6 @@ function WorkspaceCard({ target.closest("a") || target.closest("label") || target.closest('[role="menuitem"]') || - target.closest('[contenteditable="true"]') || target.closest('[data-slot="dropdown-menu-content"]') || target.closest('[data-slot="dropdown-menu-trigger"]') || target.closest('[data-slot="popover-content"]') || @@ -173,15 +146,32 @@ function WorkspaceCard({ return; } - if (isEditingTitle) { - e.preventDefault(); - e.stopPropagation(); + onOpenModal(item.id); + }, + [isInteractiveTarget, item.id, onOpenModal, onToggleSelection], + ); + + const handleCardKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Enter" && e.key !== " ") { + return; + } + + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { + return; + } + + e.preventDefault(); + + if (e.shiftKey) { + onToggleSelection(item.id); return; } onOpenModal(item.id); }, - [isEditingTitle, isInteractiveTarget, item.id, onOpenModal, onToggleSelection], + [isInteractiveTarget, item.id, onOpenModal, onToggleSelection], ); const handleMove = useCallback( @@ -193,8 +183,7 @@ function WorkspaceCard({ [item.id, onMoveItem], ); - const shouldUseFramelessLayout = - item.type === "youtube" || item.type === "image"; + const shouldUseFramelessLayout = isFramelessWorkspaceCardItem(item); return ( @@ -204,6 +193,8 @@ function WorkspaceCard({ id={`item-${item.id}`} data-youtube-card data-item-type={item.type} + role="button" + tabIndex={0} className={`relative rounded-md scroll-mt-4 size-full flex flex-col overflow-hidden transition-all duration-200 cursor-pointer ${ shouldUseFramelessLayout ? "p-0" @@ -242,36 +233,28 @@ function WorkspaceCard({ } as CSSProperties } onClick={handleCardClick} + onKeyDown={handleCardKeyDown} > onToggleSelection(item.id)} - onOpenRename={() => setShowRenameDialog(true)} - onOpenMove={() => setShowMoveDialog(true)} + onOpenRename={openRenameDialog} + onOpenMove={openMoveDialog} onCopyMarkdown={handleCopyMarkdown} - onOpenColorPicker={() => setIsColorPickerOpen(true)} - onDelete={handleDelete} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} /> - + - +
@@ -297,11 +284,11 @@ function WorkspaceCard({ setShowRenameDialog(true)} - onOpenMove={() => setShowMoveDialog(true)} + onOpenRename={openRenameDialog} + onOpenMove={openMoveDialog} onCopyMarkdown={handleCopyMarkdown} - onOpenColorPicker={() => setIsColorPickerOpen(true)} - onDelete={handleDelete} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} /> diff --git a/src/components/workspace-canvas/WorkspaceCardActions.tsx b/src/components/workspace-canvas/WorkspaceCardActions.tsx index bb2950c35..a718bf599 100644 --- a/src/components/workspace-canvas/WorkspaceCardActions.tsx +++ b/src/components/workspace-canvas/WorkspaceCardActions.tsx @@ -144,8 +144,9 @@ interface WorkspaceCardControlsProps { useDarkOverlay: boolean; resolvedTheme?: string; isSelected: boolean; - isEditingTitle: boolean; canMove: boolean; + selectionLabel?: string; + settingsLabel?: string; onToggleSelection: () => void; onOpenRename: () => void; onOpenMove: () => void; @@ -159,8 +160,9 @@ export function WorkspaceCardControls({ useDarkOverlay, resolvedTheme, isSelected, - isEditingTitle, canMove, + selectionLabel = "card", + settingsLabel = "Card settings", onToggleSelection, onOpenRename, onOpenMove, @@ -199,16 +201,11 @@ export function WorkspaceCardControls({ }); return ( -
+
- ); - })()} - - {item.type === "youtube" && - (() => { - const youtubeData = item.data as YouTubeData; - const hasValidUrl = - extractYouTubeVideoId(youtubeData.url) !== null || - extractYouTubePlaylistId(youtubeData.url) !== null; - - if (!hasValidUrl) { - return ( -
-
-
- - Invalid YouTube URL - -
-

- Please check the URL and try again -

-
-
- ); - } - - return ; - })()} - - {item.type === "image" && } - - ); -} diff --git a/src/components/workspace-canvas/WorkspaceCardDialogs.tsx b/src/components/workspace-canvas/WorkspaceCardDialogs.tsx deleted file mode 100644 index c27f0dbb3..000000000 --- a/src/components/workspace-canvas/WorkspaceCardDialogs.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import type { ColorResult } from "react-color"; -import { SwatchesPicker } from "react-color"; -import type { Item } from "@/lib/workspace-state/types"; -import { SWATCHES_COLOR_GROUPS } from "@/lib/workspace-state/colors"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import MoveToDialog from "@/components/modals/MoveToDialog"; -import RenameDialog from "@/components/modals/RenameDialog"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; - -interface WorkspaceCardDialogsProps { - item: Item; - allItems: Item[]; - workspaceName: string; - workspaceIcon?: string | null; - workspaceColor?: string | null; - isColorPickerOpen: boolean; - onColorPickerOpenChange: (open: boolean) => void; - showDeleteDialog: boolean; - onDeleteDialogChange: (open: boolean) => void; - showMoveDialog: boolean; - onMoveDialogChange: (open: boolean) => void; - showRenameDialog: boolean; - onRenameDialogChange: (open: boolean) => void; - onColorChange: (color: ColorResult) => void; - onDeleteConfirm: () => void; - onRename: (newName: string) => void; - onMove?: (folderId: string | null) => void; -} - -export function WorkspaceCardDialogs({ - item, - allItems, - workspaceName, - workspaceIcon, - workspaceColor, - isColorPickerOpen, - onColorPickerOpenChange, - showDeleteDialog, - onDeleteDialogChange, - showMoveDialog, - onMoveDialogChange, - showRenameDialog, - onRenameDialogChange, - onColorChange, - onDeleteConfirm, - onRename, - onMove, -}: WorkspaceCardDialogsProps) { - return ( - <> - - event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - > - - Choose a Color - -
event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - > - -
-
-
- - - - - Delete Card - - Are you sure you want to delete " - {item.name || "this card"}"? You can restore from version - history if needed. - - - - Cancel - - Delete - - - - - - - - {onMove && ( - - )} - - ); -} diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index cdc376a70..17482c4c1 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -4,7 +4,6 @@ import { useDragDropMonitor, type DragEndEvent } from "@dnd-kit/react"; import { isSortable } from "@dnd-kit/react/sortable"; import type { Item } from "@/lib/workspace-state/types"; import { WorkspaceCard } from "./WorkspaceCard"; -import { FlashcardWorkspaceCard } from "./FlashcardWorkspaceCard"; import { FolderCard } from "./FolderCard"; import { getWorkspaceSortableGroup, @@ -243,33 +242,6 @@ function WorkspaceGridComponent({ const contentChildren = useMemo(() => { return orderedContentItems.map((item, index) => { - if (item.type === "flashcard") { - return ( - - {() => ( - - )} - - ); - } - return ( + {shouldShowHeader ? ( +
+
+ +
+
+ ) : null} + + {item.type === "flashcard" ? ( +
+
+

Flashcards

+
+
+ ) : null} + + {item.type === "youtube" ? ( + + ) : null} + + {item.type === "image" ? : null} + + ); +} + +function CanvasYouTubePreview({ item }: { item: Item }) { + const youtubeData = item.data as YouTubeData; + const hasValidUrl = + extractYouTubeVideoId(youtubeData.url) !== null || + extractYouTubePlaylistId(youtubeData.url) !== null; + + if (!hasValidUrl) { + return ( +
+
+
+ + Invalid YouTube URL + +
+

+ Please check the URL and try again +

+
+
+ ); + } + + return ; +} + +interface WorkspacePanelItemPreviewProps { + item: Item; + onClose: () => void; + onMaximize: () => void; + onUpdateData: (updater: (prev: ItemData) => ItemData) => void; + citationHighlightQuery: { + itemId: string; + query: string; + pageNumber?: number; + } | null; +} + +export function WorkspacePanelItemPreview({ + item, + onClose, + onMaximize, + onUpdateData, + citationHighlightQuery, +}: WorkspacePanelItemPreviewProps) { + const [showThumbnails, setShowThumbnails] = useState(false); + + if (item.type === "pdf") { + const pdfData = item.data as PdfData; + + if (!pdfData.fileUrl) { + return ( +
+

+ PDF: {pdfData.filename || "Document"} +

+

+ Use the PDF viewer to read this document +

+
+ ); + } + + return ( +
+ = 1 && + !citationHighlightQuery?.query?.trim() + ? citationHighlightQuery.pageNumber + : undefined + } + isMaximized={true} + renderHeader={(documentId) => ( +
+ + setShowThumbnails((current) => !current) + } + renderInPortal={true} + /> +
+ )} + /> +
+ ); + } + + if (item.type === "youtube") { + return ; + } + + if (item.type === "website") { + return ; + } + + if (item.type === "image") { + const imageData = item.data as ImageData; + + return ( +
+ ( + + )} + /> +
+ ); + } + + if (item.type === "flashcard") { + return ; + } + + if (item.type === "quiz") { + return ( + + ); + } + + if (item.type === "audio") { + return ; + } + + if (item.type === "document") { + const documentData = item.data as DocumentData; + const markdown = documentData.markdown ?? ""; + + return ( +
+ { + onUpdateData(() => ({ + markdown: nextMarkdown, + })); + }} + /> +
+ ); + } + + return ( +
+

+ Unknown card type: {item.type} +

+
+ ); +} diff --git a/src/components/workspace-canvas/useCardActionState.ts b/src/components/workspace-canvas/useCardActionState.ts new file mode 100644 index 000000000..45ee57368 --- /dev/null +++ b/src/components/workspace-canvas/useCardActionState.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; + +export function useCardActionState() { + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showMoveDialog, setShowMoveDialog] = useState(false); + const [showRenameDialog, setShowRenameDialog] = useState(false); + const openColorPicker = useCallback(() => setIsColorPickerOpen(true), []); + const openDeleteDialog = useCallback(() => setShowDeleteDialog(true), []); + const openMoveDialog = useCallback(() => setShowMoveDialog(true), []); + const openRenameDialog = useCallback(() => setShowRenameDialog(true), []); + + return useMemo( + () => ({ + isColorPickerOpen, + setIsColorPickerOpen, + showDeleteDialog, + setShowDeleteDialog, + showMoveDialog, + setShowMoveDialog, + showRenameDialog, + setShowRenameDialog, + openColorPicker, + openDeleteDialog, + openMoveDialog, + openRenameDialog, + }), + [ + isColorPickerOpen, + openColorPicker, + openDeleteDialog, + openMoveDialog, + openRenameDialog, + showDeleteDialog, + showMoveDialog, + showRenameDialog, + ], + ); +} diff --git a/src/lib/stores/ui-store.ts b/src/lib/stores/ui-store.ts index 7010474a3..e27099c64 100644 --- a/src/lib/stores/ui-store.ts +++ b/src/lib/stores/ui-store.ts @@ -76,9 +76,6 @@ interface UIState { // Card selection state (user actions only — opening a panel does not change selection) selectedCardIds: Set; - // Scroll lock state per item (itemId -> isScrollLocked) - itemScrollLocked: Map; - // Reply selection state replySelections: ReplySelection[]; @@ -136,10 +133,6 @@ interface UIState { clearCardSelection: () => void; selectMultipleCards: (ids: string[]) => void; - // Actions - Scroll lock state - setItemScrollLocked: (itemId: string, isLocked: boolean) => void; - toggleItemScrollLocked: (itemId: string) => void; - // Actions - Reply selection addReplySelection: (selection: ReplySelection) => void; removeReplySelection: (index: number) => void; @@ -178,9 +171,6 @@ const initialState = { // Card selection selectedCardIds: new Set(), - // Scroll lock state - itemScrollLocked: new Map(), - // Reply selection replySelections: [], citationHighlightQuery: null, @@ -345,21 +335,6 @@ export const useUIStore = create()( selectMultipleCards: (ids) => set({ selectedCardIds: new Set(ids) }), - // Scroll lock actions - setItemScrollLocked: (itemId, isLocked) => - set((state) => { - const newMap = new Map(state.itemScrollLocked); - newMap.set(itemId, isLocked); - return { itemScrollLocked: newMap }; - }), - toggleItemScrollLocked: (itemId) => - set((state) => { - const newMap = new Map(state.itemScrollLocked); - const current = newMap.get(itemId) ?? true; - newMap.set(itemId, !current); - return { itemScrollLocked: newMap }; - }), - // Reply selection actions addReplySelection: (selection) => set((state) => { @@ -440,9 +415,3 @@ export const selectSelectedCardIdsArray = (state: UIState): string[] => { }; export const selectReplySelections = (state: UIState) => state.replySelections; - -export const selectItemScrollLocked = - (itemId: string) => - (state: UIState): boolean => { - return state.itemScrollLocked.get(itemId) ?? true; - }; From d5496a4f0824aa95d11299fe098fcb1af11a7d1c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 20:24:42 -0400 Subject: [PATCH 23/32] Make flashcards use standard card badge --- .../workspace-canvas/WorkspaceCardTypeBadge.tsx | 7 +++++++ src/components/workspace-canvas/WorkspaceItemView.tsx | 9 +-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx index 2976370d7..bbfd77dbb 100644 --- a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx +++ b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx @@ -1,6 +1,7 @@ "use client"; import { Loader2, File, FileText, Brain, Mic, Globe } from "lucide-react"; +import { PiCardsThreeBold } from "react-icons/pi"; import type { Item, PdfData, WebsiteData } from "@/lib/workspace-state/types"; import { getCardColorWithBlackMix, @@ -19,6 +20,7 @@ export function WorkspaceCardTypeBadge({ }: WorkspaceCardTypeBadgeProps) { const showTypeBadge = item.type === "pdf" || + item.type === "flashcard" || item.type === "quiz" || item.type === "audio" || item.type === "website" || @@ -55,6 +57,11 @@ export function WorkspaceCardTypeBadge({ PDF ) + ) : item.type === "flashcard" ? ( + <> + + Flashcards + ) : item.type === "quiz" ? ( <> diff --git a/src/components/workspace-canvas/WorkspaceItemView.tsx b/src/components/workspace-canvas/WorkspaceItemView.tsx index f10c4cad8..3b3bfb82d 100644 --- a/src/components/workspace-canvas/WorkspaceItemView.tsx +++ b/src/components/workspace-canvas/WorkspaceItemView.tsx @@ -46,6 +46,7 @@ export function usesManagedWorkspacePanelLayout(item: Item): boolean { export function WorkspaceCanvasItemPreview({ item }: { item: Item }) { const isCompactTextCard = item.type === "pdf" || + item.type === "flashcard" || item.type === "quiz" || item.type === "audio" || item.type === "document"; @@ -73,14 +74,6 @@ export function WorkspaceCanvasItemPreview({ item }: { item: Item }) {
) : null} - {item.type === "flashcard" ? ( -
-
-

Flashcards

-
-
- ) : null} - {item.type === "youtube" ? ( ) : null} From 9316f792ffa60a86f6818050fc6d27f97fd24b98 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 2 May 2026 22:17:47 -0400 Subject: [PATCH 24/32] refactor(workspace-canvas): card titles below preview and menu cleanup - Lay out FolderCard and WorkspaceCard as flex columns with truncated titles (and folder item counts) beneath the preview area. - Use folder-tab border thickness and glow for selection instead of an outer box-shadow ring on FolderCard. - Remove Copy Markdown from workspace card dropdown and context menus. - Drop ItemHeader from canvas item previews now that titles sit on cards. - Add standalone Folder.jsx and Folder.css animated folder illustration (not yet wired into cards). Co-authored-by: Cursor --- src/components/Folder.css | 122 ++++++++++++++++++ src/components/Folder.jsx | 107 +++++++++++++++ .../workspace-canvas/FolderCard.tsx | 51 ++++---- .../workspace-canvas/WorkspaceCard.tsx | 32 ++--- .../workspace-canvas/WorkspaceCardActions.tsx | 16 --- .../workspace-canvas/WorkspaceItemView.tsx | 33 +---- 6 files changed, 264 insertions(+), 97 deletions(-) create mode 100644 src/components/Folder.css create mode 100644 src/components/Folder.jsx diff --git a/src/components/Folder.css b/src/components/Folder.css new file mode 100644 index 000000000..717e4f0d1 --- /dev/null +++ b/src/components/Folder.css @@ -0,0 +1,122 @@ +:root { + --folder-color: #70a1ff; + --folder-back-color: #4785ff; + --paper-1: #e6e6e6; + --paper-2: #f2f2f2; + --paper-3: #ffffff; +} + +.folder { + transition: all 0.2s ease-in; + cursor: pointer; +} + +.folder:not(.folder--click):hover { + transform: translateY(-8px); +} + +.folder:not(.folder--click):hover .paper { + transform: translate(-50%, 0%); +} + +.folder:not(.folder--click):hover .folder__front { + transform: skew(15deg) scaleY(0.6); +} + +.folder:not(.folder--click):hover .right { + transform: skew(-15deg) scaleY(0.6); +} + +.folder.open { + transform: translateY(-8px); +} + +.folder.open .paper:nth-child(1) { + transform: translate(-120%, -70%) rotateZ(-15deg); +} + +.folder.open .paper:nth-child(1):hover { + transform: translate(-120%, -70%) rotateZ(-15deg) scale(1.1); +} + +.folder.open .paper:nth-child(2) { + transform: translate(10%, -70%) rotateZ(15deg); + height: 80%; +} + +.folder.open .paper:nth-child(2):hover { + transform: translate(10%, -70%) rotateZ(15deg) scale(1.1); +} + +.folder.open .paper:nth-child(3) { + transform: translate(-50%, -100%) rotateZ(5deg); + height: 80%; +} + +.folder.open .paper:nth-child(3):hover { + transform: translate(-50%, -100%) rotateZ(5deg) scale(1.1); +} + +.folder.open .folder__front { + transform: skew(15deg) scaleY(0.6); +} + +.folder.open .right { + transform: skew(-15deg) scaleY(0.6); +} + +.folder__back { + position: relative; + width: 100px; + height: 80px; + background: var(--folder-back-color); + border-radius: 0px 10px 10px 10px; +} + +.folder__back::after { + position: absolute; + z-index: 0; + bottom: 98%; + left: 0; + content: ''; + width: 30px; + height: 10px; + background: var(--folder-back-color); + border-radius: 5px 5px 0 0; +} + +.paper { + position: absolute; + z-index: 2; + bottom: 10%; + left: 50%; + transform: translate(-50%, 10%); + width: 70%; + height: 80%; + background: var(--paper-1); + border-radius: 10px; + transition: all 0.3s ease-in-out; +} + +.paper:nth-child(2) { + background: var(--paper-2); + width: 80%; + height: 70%; +} + +.paper:nth-child(3) { + background: var(--paper-3); + width: 90%; + height: 60%; +} + +.folder__front { + position: absolute; + z-index: 3; + width: 100%; + height: 100%; + background: var(--folder-color); + border-radius: 5px 10px 10px 10px; + transform-origin: bottom; + transition: all 0.3s ease-in-out; +} diff --git a/src/components/Folder.jsx b/src/components/Folder.jsx new file mode 100644 index 000000000..121b394f7 --- /dev/null +++ b/src/components/Folder.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import './Folder.css'; + +const darkenColor = (hex, percent) => { + let color = hex.startsWith('#') ? hex.slice(1) : hex; + if (color.length === 3) { + color = color + .split('') + .map(c => c + c) + .join(''); + } + const num = parseInt(color, 16); + let r = (num >> 16) & 0xff; + let g = (num >> 8) & 0xff; + let b = num & 0xff; + r = Math.max(0, Math.min(255, Math.floor(r * (1 - percent)))); + g = Math.max(0, Math.min(255, Math.floor(g * (1 - percent)))); + b = Math.max(0, Math.min(255, Math.floor(b * (1 - percent)))); + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); +}; + +const Folder = ({ color = '#5227FF', size = 1, items = [], className = '' }) => { + const maxItems = 3; + const papers = items.slice(0, maxItems); + while (papers.length < maxItems) { + papers.push(null); + } + + const [open, setOpen] = useState(false); + const [paperOffsets, setPaperOffsets] = useState(Array.from({ length: maxItems }, () => ({ x: 0, y: 0 }))); + + const folderBackColor = darkenColor(color, 0.08); + const paper1 = darkenColor('#ffffff', 0.1); + const paper2 = darkenColor('#ffffff', 0.05); + const paper3 = '#ffffff'; + + const handleClick = () => { + setOpen(prev => !prev); + if (open) { + setPaperOffsets(Array.from({ length: maxItems }, () => ({ x: 0, y: 0 }))); + } + }; + + const handlePaperMouseMove = (e, index) => { + if (!open) return; + const rect = e.currentTarget.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const offsetX = (e.clientX - centerX) * 0.15; + const offsetY = (e.clientY - centerY) * 0.15; + setPaperOffsets(prev => { + const newOffsets = [...prev]; + newOffsets[index] = { x: offsetX, y: offsetY }; + return newOffsets; + }); + }; + + const handlePaperMouseLeave = (e, index) => { + setPaperOffsets(prev => { + const newOffsets = [...prev]; + newOffsets[index] = { x: 0, y: 0 }; + return newOffsets; + }); + }; + + const folderStyle = { + '--folder-color': color, + '--folder-back-color': folderBackColor, + '--paper-1': paper1, + '--paper-2': paper2, + '--paper-3': paper3 + }; + + const folderClassName = `folder ${open ? 'open' : ''}`.trim(); + const scaleStyle = { transform: `scale(${size})` }; + + return ( +
+
+
+ {papers.map((item, i) => ( +
handlePaperMouseMove(e, i)} + onMouseLeave={e => handlePaperMouseLeave(e, i)} + style={ + open + ? { + '--magnet-x': `${paperOffsets[i]?.x || 0}px`, + '--magnet-y': `${paperOffsets[i]?.y || 0}px` + } + : {} + } + > + {item} +
+ ))} +
+
+
+
+
+ ); +}; + +export default Folder; diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index 9d09e70e6..ec00c9da6 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -251,13 +251,13 @@ function FolderCardComponent({ const borderColor = isSelected ? "rgba(255, 255, 255, 0.8)" : getCardAccentColor(folderColor, 0.5); - const selectedRingStyle = isSelected - ? { - boxShadow: - resolvedTheme === "dark" - ? "0 0 0 3px rgba(255, 255, 255, 0.8)" - : "0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.4)", - } + const folderTitle = item.name || "Folder"; + const itemCountLabel = `${itemCount} ${itemCount === 1 ? "item" : "items"}`; + const selectedOutlineWidth = isSelected ? "3px" : "1px"; + const selectedFolderGlow = isSelected + ? resolvedTheme === "dark" + ? "0 0 10px rgba(255, 255, 255, 0.2)" + : "0 0 2px rgba(0, 0, 0, 0.7), 0 0 6px rgba(0, 0, 0, 0.35)" : undefined; return ( @@ -266,11 +266,10 @@ function FolderCardComponent({
void} className={cn( - "group size-full rounded-md transition-[box-shadow,transform] duration-150", + "group flex size-full min-h-0 flex-col rounded-md transition-[box-shadow,transform] duration-150", isItemDropTarget && "ring-2 ring-primary/60 ring-offset-2 ring-offset-background", )} - style={selectedRingStyle} onClick={handleClick} onKeyDown={handleKeyDown} role="button" @@ -278,17 +277,18 @@ function FolderCardComponent({ >
@@ -298,7 +298,8 @@ function FolderCardComponent({ style={{ backgroundColor: bodyBgColor, borderColor, - borderWidth: "1px", + borderWidth: selectedOutlineWidth, + boxShadow: selectedFolderGlow, transition: "border-color 150ms ease-out", }} /> @@ -314,22 +315,10 @@ function FolderCardComponent({ onToggleSelection={() => onToggleSelection(item.id)} onOpenRename={openRenameDialog} onOpenMove={openMoveDialog} - onCopyMarkdown={() => {}} onOpenColorPicker={openColorPicker} onDelete={openDeleteDialog} /> -
-
-
- {item.name || "Folder"} -
-

- {itemCount} {itemCount === 1 ? "item" : "items"} -

-
-
-
+
+
+ {folderTitle} +
+
+ {itemCountLabel} +
+
+ {}} onOpenColorPicker={openColorPicker} onDelete={openDeleteDialog} /> diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index b9b1e84ba..6c52a3ee0 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -6,7 +6,7 @@ import { getCardAccentColor, type CardColor, } from "@/lib/workspace-state/colors"; -import type { Item, DocumentData } from "@/lib/workspace-state/types"; +import type { Item } from "@/lib/workspace-state/types"; import type { ColorResult } from "react-color"; import { useUIStore } from "@/lib/stores/ui-store"; import { @@ -90,23 +90,6 @@ function WorkspaceCard({ [item.id, onUpdateItem], ); - const handleCopyMarkdown = useCallback(() => { - if (item.type !== "document") return; - const md = (item.data as DocumentData).markdown ?? ""; - if (md) { - navigator.clipboard - .writeText(md) - .then(() => { - toast.success("Copied to clipboard"); - }) - .catch(() => { - toast.error("Failed to copy"); - }); - } else { - toast.error("No content to copy"); - } - }, [item.type, item.data]); - const isInteractiveTarget = useCallback((target: HTMLElement) => { return Boolean( target.closest("button") || @@ -184,18 +167,19 @@ function WorkspaceCard({ ); const shouldUseFramelessLayout = isFramelessWorkspaceCardItem(item); + const cardTitle = item.name || "Untitled"; return ( -
+
onToggleSelection(item.id)} onOpenRename={openRenameDialog} onOpenMove={openMoveDialog} - onCopyMarkdown={handleCopyMarkdown} onOpenColorPicker={openColorPicker} onDelete={openDeleteDialog} /> @@ -254,6 +237,12 @@ function WorkspaceCard({
+
+
+ {cardTitle} +
+
+ diff --git a/src/components/workspace-canvas/WorkspaceCardActions.tsx b/src/components/workspace-canvas/WorkspaceCardActions.tsx index a718bf599..443f84f03 100644 --- a/src/components/workspace-canvas/WorkspaceCardActions.tsx +++ b/src/components/workspace-canvas/WorkspaceCardActions.tsx @@ -7,7 +7,6 @@ import { Palette, CheckCircle2, FolderInput, - Copy, X, Pencil, } from "lucide-react"; @@ -38,7 +37,6 @@ interface WorkspaceCardMenuItemsProps { canMove: boolean; onOpenRename: () => void; onOpenMove: () => void; - onCopyMarkdown: () => void; onOpenColorPicker: () => void; onDelete: () => void; MenuItem: MenuItemComponent; @@ -59,7 +57,6 @@ function WorkspaceCardMenuItems({ canMove, onOpenRename, onOpenMove, - onCopyMarkdown, onOpenColorPicker, onDelete, MenuItem, @@ -80,15 +77,6 @@ function WorkspaceCardMenuItems({ )} - {itemType === "document" && ( - <> - - - Copy Markdown - - - - )} Change Color @@ -150,7 +138,6 @@ interface WorkspaceCardControlsProps { onToggleSelection: () => void; onOpenRename: () => void; onOpenMove: () => void; - onCopyMarkdown: () => void; onOpenColorPicker: () => void; onDelete: () => void; } @@ -166,7 +153,6 @@ export function WorkspaceCardControls({ onToggleSelection, onOpenRename, onOpenMove, - onCopyMarkdown, onOpenColorPicker, onDelete, }: WorkspaceCardControlsProps) { @@ -236,7 +222,6 @@ export function WorkspaceCardControls({ canMove={canMove} onOpenRename={onOpenRename} onOpenMove={onOpenMove} - onCopyMarkdown={onCopyMarkdown} onOpenColorPicker={onOpenColorPicker} onDelete={onDelete} MenuItem={DropdownMenuItem} @@ -253,7 +238,6 @@ interface WorkspaceCardContextMenuItemsProps { canMove: boolean; onOpenRename: () => void; onOpenMove: () => void; - onCopyMarkdown: () => void; onOpenColorPicker: () => void; onDelete: () => void; } diff --git a/src/components/workspace-canvas/WorkspaceItemView.tsx b/src/components/workspace-canvas/WorkspaceItemView.tsx index 3b3bfb82d..2100f3f4b 100644 --- a/src/components/workspace-canvas/WorkspaceItemView.tsx +++ b/src/components/workspace-canvas/WorkspaceItemView.tsx @@ -9,7 +9,6 @@ import type { ImageData, DocumentData, } from "@/lib/workspace-state/types"; -import ItemHeader from "@/components/workspace-canvas/ItemHeader"; import { DocumentEditor } from "@/components/editor/DocumentEditor"; import LazyAppPdfViewer from "@/components/pdf/LazyAppPdfViewer"; import { PdfPanelHeader } from "@/components/pdf/PdfPanelHeader"; @@ -44,39 +43,9 @@ export function usesManagedWorkspacePanelLayout(item: Item): boolean { } export function WorkspaceCanvasItemPreview({ item }: { item: Item }) { - const isCompactTextCard = - item.type === "pdf" || - item.type === "flashcard" || - item.type === "quiz" || - item.type === "audio" || - item.type === "document"; - const shouldShowHeader = item.type !== "youtube" && item.type !== "image"; - return ( <> - {shouldShowHeader ? ( -
-
- -
-
- ) : null} - - {item.type === "youtube" ? ( - - ) : null} + {item.type === "youtube" ? : null} {item.type === "image" ? : null} From cc49511b40a917f862a8128cdffa7f69cdc01d52 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 3 May 2026 00:27:33 -0400 Subject: [PATCH 25/32] Refine workspace card previews and layout --- src/app/globals.css | 9 ++ .../workspace-canvas/AudioCardContent.tsx | 94 +++++++-------- .../workspace-canvas/DocumentCardContent.tsx | 35 ++++++ .../workspace-canvas/FolderCard.tsx | 77 ++++--------- .../workspace-canvas/ImageCardContent.tsx | 79 ++++++------- .../workspace-canvas/PdfCardContent.tsx | 46 ++++++++ .../workspace-canvas/WorkspaceCard.tsx | 67 ++++++----- .../workspace-canvas/WorkspaceCardActions.tsx | 108 +++++------------- .../WorkspaceCardTypeBadge.tsx | 58 +--------- .../workspace-canvas/WorkspaceGrid.tsx | 2 +- .../workspace-canvas/WorkspaceItemView.tsx | 21 ++-- .../workspace-canvas/YouTubeCardContent.tsx | 86 ++++++-------- .../markdown/extract-document-preview.test.ts | 25 ++++ src/lib/markdown/extract-document-preview.ts | 21 ++++ 14 files changed, 356 insertions(+), 372 deletions(-) create mode 100644 src/components/workspace-canvas/DocumentCardContent.tsx create mode 100644 src/components/workspace-canvas/PdfCardContent.tsx create mode 100644 src/lib/markdown/extract-document-preview.test.ts create mode 100644 src/lib/markdown/extract-document-preview.ts diff --git a/src/app/globals.css b/src/app/globals.css index 147e6c888..912b33c2b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -695,6 +695,15 @@ body.marquee-selecting iframe { } } +/* @dnd-kit clone feedback — in-grid placeholder only (`data-dnd-placeholder="clone"`). + Floated preview is the same draggable node tagged `data-dnd-dragging` — not matched here. + Observer copies inline opacity from SortableWorkspaceGridItem; !important shadows that + only on the slot clone. Avoid filter here: it can create stacking-context quirks beside + the fixed drag layer on some GPUs. */ +.workspace-grid-container [data-dnd-placeholder="clone"]:not([data-dnd-dragging]) { + opacity: 0.42 !important; +} + /* Hide workspace cards when detail modal is open */ body:has(.card-detail-modal) .workspace-grid-container { opacity: 0; diff --git a/src/components/workspace-canvas/AudioCardContent.tsx b/src/components/workspace-canvas/AudioCardContent.tsx index 7a8c51ce1..9cee26fca 100644 --- a/src/components/workspace-canvas/AudioCardContent.tsx +++ b/src/components/workspace-canvas/AudioCardContent.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { + AudioLines, Mic, AlertCircle, Loader2, @@ -43,6 +44,25 @@ function parseTimestamp(ts: string): number { return 0; } +function formatAudioDuration(seconds: number | undefined): string { + if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) { + return "Audio"; + } + + const totalSeconds = Math.round(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds + .toString() + .padStart(2, "0")}`; + } + + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; +} + const SPEAKER_COLORS = [ "bg-blue-500/15 text-blue-700 dark:text-blue-400", "bg-purple-500/15 text-purple-700 dark:text-purple-400", @@ -75,6 +95,33 @@ export function AudioCardContent({ const audioData = item.data as AudioData; const [isRetrying, setIsRetrying] = useState(false); + if (isCompact) { + const compactLabel = + audioData.processingStatus === "uploading" + ? "Uploading..." + : audioData.processingStatus === "processing" + ? "Processing..." + : audioData.processingStatus === "failed" + ? "Preview unavailable" + : formatAudioDuration(audioData.duration); + + return ( +
+
+ {audioData.processingStatus === "uploading" || + audioData.processingStatus === "processing" ? ( + + ) : audioData.processingStatus === "failed" ? ( + + ) : ( + + )} +
+

{compactLabel}

+
+ ); + } + const handleRetry = useCallback(() => { if (!audioData.fileUrl || !workspaceId || isRetrying) return; setIsRetrying(true); @@ -345,53 +392,6 @@ function AudioCardComplete({ // ── Compact view ──────────────────────────────────────────────────────── - if (isCompact) { - return ( -
- {audioData.processingStatus === "complete" && ( -
-

- Timeline -

- {isLoadingSegments && segments.length === 0 ? ( - - ) : transcriptError ? ( -
- Failed to load transcript. -
- ) : segments.length > 0 ? ( -
- {segments.slice(0, 3).map((segment, idx) => ( - - ))} - {segments.length > 3 && ( -
- ... {segments.length - 3} more timestamps -
- )} -
- ) : null} -
- )} -
- ); - } - // ── Non-compact view ──────────────────────────────────────────────────── return ( diff --git a/src/components/workspace-canvas/DocumentCardContent.tsx b/src/components/workspace-canvas/DocumentCardContent.tsx new file mode 100644 index 000000000..3c392c708 --- /dev/null +++ b/src/components/workspace-canvas/DocumentCardContent.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useMemo } from "react"; +import type { DocumentData, Item } from "@/lib/workspace-state/types"; +import { extractDocumentPreview } from "@/lib/markdown/extract-document-preview"; + +interface DocumentCardContentProps { + item: Item; +} + +export function DocumentCardContent({ item }: DocumentCardContentProps) { + const documentData = item.data as DocumentData; + const previewText = useMemo( + () => extractDocumentPreview(documentData.markdown ?? ""), + [documentData.markdown], + ); + + const hasPreview = previewText.length > 0; + + return ( +
+ {hasPreview ? ( +

+ {previewText} +

+ ) : ( +

+ Empty document +

+ )} +
+ ); +} + +export default DocumentCardContent; diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index ec00c9da6..3f8a168aa 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -2,7 +2,6 @@ import { useDroppable } from "@dnd-kit/react"; import { memo, useState, useCallback, useEffect, useMemo } from "react"; -import { FolderInput } from "lucide-react"; import { type ColorResult } from "react-color"; import { cn } from "@/lib/utils"; import type { Item } from "@/lib/workspace-state/types"; @@ -124,25 +123,6 @@ function FolderCardComponent({ }, }); - useEffect(() => { - if (item.name !== "New Folder") { - return; - } - - setShowRenameDialog(true); - - const element = document.getElementById(`item-${item.id}`); - if (!element) { - return; - } - - const timeoutId = setTimeout(() => { - element.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 100); - - return () => clearTimeout(timeoutId); - }, [item.id, item.name, setShowRenameDialog]); - useEffect(() => { if (!showDeleteDialog) { setDeleteOption(null); @@ -252,7 +232,6 @@ function FolderCardComponent({ ? "rgba(255, 255, 255, 0.8)" : getCardAccentColor(folderColor, 0.5); const folderTitle = item.name || "Folder"; - const itemCountLabel = `${itemCount} ${itemCount === 1 ? "item" : "items"}`; const selectedOutlineWidth = isSelected ? "3px" : "1px"; const selectedFolderGlow = isSelected ? resolvedTheme === "dark" @@ -265,11 +244,7 @@ function FolderCardComponent({
void} - className={cn( - "group flex size-full min-h-0 flex-col rounded-md transition-[box-shadow,transform] duration-150", - isItemDropTarget && - "ring-2 ring-primary/60 ring-offset-2 ring-offset-background", - )} + className="group flex size-full min-h-0 flex-col rounded-md transition-[box-shadow,transform] duration-150" onClick={handleClick} onKeyDown={handleKeyDown} role="button" @@ -304,43 +279,40 @@ function FolderCardComponent({ }} /> - onToggleSelection(item.id)} - onOpenRename={openRenameDialog} - onOpenMove={openMoveDialog} - onOpenColorPicker={openColorPicker} - onDelete={openDeleteDialog} - /> -
{isItemDropTarget ? ( -
- - Move into folder -
+
) : null}
-
-
+
+
{ + e.preventDefault(); + e.stopPropagation(); + openRenameDialog(); + }} + > {folderTitle}
-
- {itemCountLabel} -
+ onToggleSelection(item.id)} + onOpenRename={openRenameDialog} + onOpenMove={openMoveDialog} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} + />
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - {!loaded && !hasError && ( -
- -
- )} - {hasError ? ( -
- -

- Image couldn't be displayed. HEIC/HEIF may not work in all browsers. -

-
- ) : ( - {alt} setLoaded(true)} - onError={() => setHasError(true)} - /> - )} +
+ +

Preview unavailable

+
+ ); + } - {/* Optional: Caption overlay on hover */} - {imageData.caption && isHovering && !hasError && ( -
- {imageData.caption} -
- )} + return ( +
+ {!src ? ( +
+
- ); + ) : ( + {alt} setHasError(true)} + /> + )} +
+ ); } export default ImageCardContent; diff --git a/src/components/workspace-canvas/PdfCardContent.tsx b/src/components/workspace-canvas/PdfCardContent.tsx new file mode 100644 index 000000000..8de0db3c7 --- /dev/null +++ b/src/components/workspace-canvas/PdfCardContent.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Image from "next/image"; +import { FileText, Loader2 } from "lucide-react"; +import type { Item, PdfData } from "@/lib/workspace-state/types"; + +interface PdfCardContentProps { + item: Item; +} + +export function PdfCardContent({ item }: PdfCardContentProps) { + const pdfData = item.data as PdfData; + const thumbnailUrl = pdfData.thumbnailUrl; + const isPending = pdfData.thumbnailStatus === "pending"; + + if (thumbnailUrl) { + return ( +
+ {item.name +
+ ); + } + + return ( +
+
+ {isPending ? ( + + ) : ( + + )} +
+

+ {isPending ? "Generating preview..." : "Preview unavailable"} +

+
+ ); +} + +export default PdfCardContent; diff --git a/src/components/workspace-canvas/WorkspaceCard.tsx b/src/components/workspace-canvas/WorkspaceCard.tsx index 6c52a3ee0..867be331d 100644 --- a/src/components/workspace-canvas/WorkspaceCard.tsx +++ b/src/components/workspace-canvas/WorkspaceCard.tsx @@ -19,7 +19,6 @@ import { WorkspaceCardControls, } from "./WorkspaceCardActions"; import { SharedCardDialogs, SimpleDeleteDialog } from "./CardDialogs"; -import { WorkspaceCardTypeBadge } from "./WorkspaceCardTypeBadge"; import { isFramelessWorkspaceCardItem, WorkspaceCanvasItemPreview, @@ -50,6 +49,14 @@ function WorkspaceCard({ onMoveItem, }: WorkspaceCardProps) { const { resolvedTheme } = useTheme(); + const youtubeBackgroundColor = + resolvedTheme === "dark" + ? "rgba(220, 38, 38, 0.22)" + : "rgba(239, 68, 68, 0.14)"; + const youtubeBorderColor = + resolvedTheme === "dark" + ? "rgba(248, 113, 113, 0.38)" + : "rgba(220, 38, 38, 0.26)"; const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); const onToggleSelection = useUIStore((state) => state.toggleCardSelection); @@ -187,8 +194,8 @@ function WorkspaceCard({ style={ { backgroundColor: - item.type === "youtube" || item.type === "image" - ? "transparent" + item.type === "youtube" + ? youtubeBackgroundColor : item.color ? getCardColorCSS( item.color, @@ -197,7 +204,9 @@ function WorkspaceCard({ : "var(--card)", borderColor: isSelected ? "rgba(255, 255, 255, 0.8)" - : item.color + : item.type === "youtube" + ? youtubeBorderColor + : item.color ? getCardAccentColor( item.color, resolvedTheme === "dark" ? 0.5 : 0.3, @@ -218,30 +227,35 @@ function WorkspaceCard({ } onClick={handleCardClick} onKeyDown={handleCardKeyDown} - > - onToggleSelection(item.id)} - onOpenRename={openRenameDialog} - onOpenMove={openMoveDialog} - onOpenColorPicker={openColorPicker} - onDelete={openDeleteDialog} - /> - - - - - + > +
+
{ + e.preventDefault(); + e.stopPropagation(); + openRenameDialog(); + }} + > +
+ {cardTitle} +
+
+ onToggleSelection(item.id)} + onOpenRename={openRenameDialog} + onOpenMove={openMoveDialog} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} + /> +
-
-
- {cardTitle} +
+
-
+ >; interface WorkspaceCardMenuItemsProps { - itemType: Item["type"]; canMove: boolean; onOpenRename: () => void; onOpenMove: () => void; @@ -53,7 +43,6 @@ function preventCardMouseDown(event: MouseEvent) { } function WorkspaceCardMenuItems({ - itemType, canMove, onOpenRename, onOpenMove, @@ -93,33 +82,9 @@ function WorkspaceCardMenuItems({ ); } -const floatingControlButtonClassName = - "inline-flex h-8 items-center justify-center rounded-xl text-white/90 hover:text-white hover:shadow-lg transition-all duration-200 cursor-pointer"; - -function getFloatingControlStyle(backgroundColor: string): CSSProperties { - return { - backgroundColor, - backdropFilter: "blur(8px)", - }; -} - -function getFloatingControlHandlers({ - defaultBackgroundColor, - hoverBackgroundColor, - onClick, -}: { - defaultBackgroundColor: string; - hoverBackgroundColor: string; - onClick?: () => void; -}) { +function getControlHandlers(onClick?: () => void) { return { onMouseDown: preventCardMouseDown, - onMouseEnter: (event: MouseEvent) => { - event.currentTarget.style.backgroundColor = hoverBackgroundColor; - }, - onMouseLeave: (event: MouseEvent) => { - event.currentTarget.style.backgroundColor = defaultBackgroundColor; - }, onClick: (event: MouseEvent) => { event.stopPropagation(); onClick?.(); @@ -128,9 +93,6 @@ function getFloatingControlHandlers({ } interface WorkspaceCardControlsProps { - itemType: Item["type"]; - useDarkOverlay: boolean; - resolvedTheme?: string; isSelected: boolean; canMove: boolean; selectionLabel?: string; @@ -143,9 +105,6 @@ interface WorkspaceCardControlsProps { } export function WorkspaceCardControls({ - itemType, - useDarkOverlay, - resolvedTheme, isSelected, canMove, selectionLabel = "card", @@ -156,45 +115,30 @@ export function WorkspaceCardControls({ onOpenColorPicker, onDelete, }: WorkspaceCardControlsProps) { - const defaultBackgroundColor = useDarkOverlay - ? "rgba(0, 0, 0, 0.6)" - : resolvedTheme === "dark" - ? "rgba(255, 255, 255, 0.1)" - : "rgba(0, 0, 0, 0.2)"; - const defaultHoverBackgroundColor = useDarkOverlay - ? "rgba(0, 0, 0, 0.8)" - : resolvedTheme === "dark" - ? "rgba(0, 0, 0, 0.5)" - : "rgba(0, 0, 0, 0.3)"; - const selectionBackgroundColor = isSelected - ? useDarkOverlay - ? "rgba(239, 68, 68, 0.4)" - : "rgba(239, 68, 68, 0.3)" - : defaultBackgroundColor; - const selectionHoverBackgroundColor = isSelected - ? useDarkOverlay - ? "rgba(239, 68, 68, 0.6)" - : "rgba(239, 68, 68, 0.5)" - : defaultHoverBackgroundColor; - const selectionHandlers = getFloatingControlHandlers({ - defaultBackgroundColor: selectionBackgroundColor, - hoverBackgroundColor: selectionHoverBackgroundColor, - onClick: onToggleSelection, - }); - const settingsHandlers = getFloatingControlHandlers({ - defaultBackgroundColor, - hoverBackgroundColor: defaultHoverBackgroundColor, - }); + const buttonClassName = cn( + "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground outline-hidden transition-colors", + "hover:bg-black/10 dark:hover:bg-white/14 hover:text-foreground", + "focus-visible:ring-2 focus-visible:ring-sidebar-ring/50 focus-visible:ring-offset-1 focus-visible:ring-offset-background", + "hover:text-foreground", + ); + const selectionClassName = isSelected + ? "bg-red-500/18 hover:bg-red-500/24" + : ""; return ( -
+
@@ -205,9 +149,8 @@ export function WorkspaceCardControls({ type="button" aria-label={settingsLabel} title={settingsLabel} - className={cn(floatingControlButtonClassName, "w-8 hover:scale-110")} - style={getFloatingControlStyle(defaultBackgroundColor)} - {...settingsHandlers} + className={buttonClassName} + {...getControlHandlers()} > @@ -216,9 +159,11 @@ export function WorkspaceCardControls({ align="end" className="w-48" onClick={stopCardPropagation} + onCloseAutoFocus={(event) => { + event.preventDefault(); + }} > void; onOpenMove: () => void; diff --git a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx index bbfd77dbb..a02aeb6b1 100644 --- a/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx +++ b/src/components/workspace-canvas/WorkspaceCardTypeBadge.tsx @@ -1,8 +1,8 @@ "use client"; -import { Loader2, File, FileText, Brain, Mic, Globe } from "lucide-react"; +import { Loader2, File, Brain, Mic } from "lucide-react"; import { PiCardsThreeBold } from "react-icons/pi"; -import type { Item, PdfData, WebsiteData } from "@/lib/workspace-state/types"; +import type { Item, PdfData } from "@/lib/workspace-state/types"; import { getCardColorWithBlackMix, getIconColorFromCardColorWithOpacity, @@ -22,9 +22,7 @@ export function WorkspaceCardTypeBadge({ item.type === "pdf" || item.type === "flashcard" || item.type === "quiz" || - item.type === "audio" || - item.type === "website" || - item.type === "document"; + item.type === "audio"; if (!showTypeBadge) { return null; @@ -67,56 +65,6 @@ export function WorkspaceCardTypeBadge({ Quiz - ) : item.type === "website" ? ( - (() => { - const websiteData = item.data as WebsiteData; - const favicon = websiteData.favicon; - const fallbackId = `fallback-${item.id}`; - const faviconId = `favicon-${item.id}`; - - return ( - <> - {favicon && ( - /* eslint-disable-next-line @next/next/no-img-element */ - { - if (event.currentTarget.naturalHeight === 16) { - event.currentTarget.style.display = "none"; - const fallback = document.getElementById(fallbackId); - if (fallback) { - fallback.style.display = "flex"; - } - } - }} - onError={(event) => { - event.currentTarget.style.display = "none"; - const fallback = document.getElementById(fallbackId); - if (fallback) { - fallback.style.display = "flex"; - } - }} - /> - )} -
- -
- Website - - ); - })() - ) : item.type === "document" ? ( - <> - - DOC - ) : ( <> diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index 17482c4c1..872fe04f0 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -34,7 +34,7 @@ interface WorkspaceGridProps { const GRID_ITEM_CLASS = "min-w-0 aspect-[4/3]"; const GRID_COLUMNS_CLASS = - "grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4"; + "grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-6"; function WorkspaceGridComponent({ folderItems, diff --git a/src/components/workspace-canvas/WorkspaceItemView.tsx b/src/components/workspace-canvas/WorkspaceItemView.tsx index 2100f3f4b..844035832 100644 --- a/src/components/workspace-canvas/WorkspaceItemView.tsx +++ b/src/components/workspace-canvas/WorkspaceItemView.tsx @@ -19,19 +19,20 @@ import { QuizContent } from "./QuizContent"; import { AudioCardContent } from "./AudioCardContent"; import { YouTubeCardContent } from "./YouTubeCardContent"; import { ImageCardContent } from "./ImageCardContent"; +import { DocumentCardContent } from "./DocumentCardContent"; +import { PdfCardContent } from "./PdfCardContent"; import { YouTubePanelContent } from "./YouTubePanelContent"; -import { WebsitePanelContent } from "./WebsitePanelContent"; import { extractYouTubePlaylistId, extractYouTubeVideoId, } from "@/lib/utils/youtube-url"; export function isFramelessWorkspaceCardItem(item: Item): boolean { - return item.type === "youtube" || item.type === "image"; + return false; } export function usesManagedWorkspacePanelLayout(item: Item): boolean { - if (item.type === "youtube" || item.type === "website" || item.type === "image") { + if (item.type === "youtube" || item.type === "image") { return true; } @@ -42,12 +43,20 @@ export function usesManagedWorkspacePanelLayout(item: Item): boolean { return false; } -export function WorkspaceCanvasItemPreview({ item }: { item: Item }) { +export function WorkspaceCanvasItemPreview({ + item, +}: { + item: Item; +}) { return ( <> {item.type === "youtube" ? : null} {item.type === "image" ? : null} + + {item.type === "document" ? : null} + + {item.type === "pdf" ? : null} ); } @@ -156,10 +165,6 @@ export function WorkspacePanelItemPreview({ return ; } - if (item.type === "website") { - return ; - } - if (item.type === "image") { const imageData = item.data as ImageData; diff --git a/src/components/workspace-canvas/YouTubeCardContent.tsx b/src/components/workspace-canvas/YouTubeCardContent.tsx index 9b3ee1faa..04a3e228c 100644 --- a/src/components/workspace-canvas/YouTubeCardContent.tsx +++ b/src/components/workspace-canvas/YouTubeCardContent.tsx @@ -1,82 +1,64 @@ "use client"; +import Image from "next/image"; +import { AlertCircle, Play } from "lucide-react"; import type { Item, YouTubeData } from "@/lib/workspace-state/types"; import { + extractYouTubePlaylistId, extractYouTubeVideoId, getYouTubeThumbnailUrl, - extractYouTubePlaylistId, } from "@/lib/utils/youtube-url"; -import { Play } from "lucide-react"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; interface YouTubeCardContentProps { item: Item; } -/** Thumbnail preview on the canvas; playback happens in the YouTube panel. */ export function YouTubeCardContent({ item }: YouTubeCardContentProps) { const youtubeData = item.data as YouTubeData; const videoId = extractYouTubeVideoId(youtubeData.url); const playlistId = extractYouTubePlaylistId(youtubeData.url); - const thumbnailUrl = youtubeData.thumbnail || getYouTubeThumbnailUrl(youtubeData.url); + const thumbnailUrl = + youtubeData.thumbnail || getYouTubeThumbnailUrl(youtubeData.url); const hasValidUrl = videoId !== null || playlistId !== null; if (!hasValidUrl) { return ( -
-
-
- Invalid YouTube URL -
-

- Please check the URL and try again -

-
+
+ +

+ Invalid YouTube URL +

); } - const title = item.name || "YouTube Video"; + if (!thumbnailUrl) { + return ( +
+
+ +
+

YouTube

+
+ ); + } return ( - - -
-
- {thumbnailUrl ? ( - <> - {item.name -
-
-
- -
-
- - ) : ( -
-
- -
-

YouTube Playlist

-

Click to play

-
- )} -
+
+ {item.name +
+
+
+
- - -

{title}

-
- +
+
); } diff --git a/src/lib/markdown/extract-document-preview.test.ts b/src/lib/markdown/extract-document-preview.test.ts new file mode 100644 index 000000000..5840cd953 --- /dev/null +++ b/src/lib/markdown/extract-document-preview.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { extractDocumentPreview } from "./extract-document-preview"; + +describe("extractDocumentPreview", () => { + it("preserves inline code content", () => { + expect(extractDocumentPreview("Use `pnpm dev` to start.")).toContain( + "pnpm dev", + ); + }); + + it("preserves fenced code block content while removing fences", () => { + const preview = extractDocumentPreview([ + "Before", + "```ts", + "const answer = 42;", + "console.log(answer);", + "```", + "After", + ].join("\n")); + + expect(preview).toContain("const answer = 42;"); + expect(preview).toContain("console.log(answer);"); + expect(preview).not.toContain("```"); + }); +}); diff --git a/src/lib/markdown/extract-document-preview.ts b/src/lib/markdown/extract-document-preview.ts new file mode 100644 index 000000000..e76b4cffb --- /dev/null +++ b/src/lib/markdown/extract-document-preview.ts @@ -0,0 +1,21 @@ +export function extractDocumentPreview(markdown: string): string { + if (!markdown.trim()) { + return ""; + } + + return markdown + .replace(/```(?:[\w-]+\n)?([\s\S]*?)```/g, "$1") + .replace(/`([^`\n]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^>\s?/gm, "") + .replace(/^[-*+]\s+/gm, "• ") + .replace(/^\d+\.\s+/gm, "") + .replace(/[|]/g, " ") + .replace(/\*\*|__|\*|_|~~/g, "") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\s+([.,!?;:])/g, "$1") + .trim(); +} From 984282172512d7df7ff69aa89c2e4151ff942769 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 3 May 2026 00:28:12 -0400 Subject: [PATCH 26/32] Add PDF thumbnail generation on upload --- package.json | 1 + pnpm-lock.yaml | 129 +++++++++++++++++++ src/lib/pdf/generate-pdf-thumbnail.ts | 95 ++++++++++++++ src/lib/pdf/pdf-item.ts | 3 +- src/lib/uploads/upload-selection.ts | 43 +++++++ src/lib/uploads/uploaded-asset.ts | 46 ++++++- src/lib/workspace-state/item-data-schemas.ts | 9 +- src/lib/workspace/workspace-item-model.ts | 43 ++++--- 8 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 src/lib/pdf/generate-pdf-thumbnail.ts diff --git a/package.json b/package.json index dce604433..365a8f3f8 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "next-themes": "^0.4.6", "papaparse": "^5.5.3", "parse-diff": "^0.11.1", + "pdfjs-dist": "4.10.38", "postcss": "^8.5.8", "postgres": "^3.4.9", "posthog-js": "^1.369.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb050e6db..7a0275513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,6 +347,9 @@ importers: parse-diff: specifier: ^0.11.1 version: 0.11.1 + pdfjs-dist: + specifier: 4.10.38 + version: 4.10.38 postcss: specifier: ^8.5.8 version: 8.5.10 @@ -2123,6 +2126,76 @@ packages: '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -8849,6 +8922,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -11967,6 +12044,54 @@ snapshots: dependencies: langium: 4.2.2 + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -19480,6 +19605,10 @@ snapshots: pathe@2.0.3: {} + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + peberminta@0.9.0: {} pend@1.2.0: {} diff --git a/src/lib/pdf/generate-pdf-thumbnail.ts b/src/lib/pdf/generate-pdf-thumbnail.ts new file mode 100644 index 000000000..914a2bdb7 --- /dev/null +++ b/src/lib/pdf/generate-pdf-thumbnail.ts @@ -0,0 +1,95 @@ +const PDF_THUMBNAIL_WIDTH = 720; + +interface GeneratedPdfThumbnail { + file: File; + width: number; + height: number; +} + +function getThumbnailFilename(filename: string): string { + const baseName = filename.replace(/\.[^/.]+$/, ""); + return `${baseName}-thumb.jpg`; +} + +async function canvasToJpegFile( + canvas: HTMLCanvasElement, + filename: string, +): Promise { + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (value) => { + if (value) { + resolve(value); + return; + } + reject(new Error("Failed to encode PDF thumbnail")); + }, + "image/jpeg", + 0.86, + ); + }); + + return new File([blob], filename, { type: "image/jpeg" }); +} + +export async function generatePdfThumbnail(params: { + filename: string; + file?: File; + url?: string; +}): Promise { + if (!params.file && !params.url) { + throw new Error("A PDF file or URL is required to generate a thumbnail"); + } + + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + if (!pdfjs.GlobalWorkerOptions.workerSrc) { + pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/legacy/build/pdf.worker.min.mjs", + import.meta.url, + ).toString(); + } + + const loadingTask = pdfjs.getDocument({ + ...(params.file + ? { data: new Uint8Array(await params.file.arrayBuffer()) } + : { url: params.url }), + isEvalSupported: false, + useSystemFonts: true, + } as any); + + const pdf = await loadingTask.promise; + + try { + const page = await pdf.getPage(1); + const baseViewport = page.getViewport({ scale: 1 }); + const scale = PDF_THUMBNAIL_WIDTH / Math.max(1, baseViewport.width); + const viewport = page.getViewport({ scale }); + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.floor(viewport.width)); + canvas.height = Math.max(1, Math.floor(viewport.height)); + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to get canvas context for PDF thumbnail"); + } + + await page.render({ + canvasContext: context, + viewport, + }).promise; + + const file = await canvasToJpegFile( + canvas, + getThumbnailFilename(params.filename), + ); + + return { + file, + width: canvas.width, + height: canvas.height, + }; + } finally { + await loadingTask.destroy(); + } +} diff --git a/src/lib/pdf/pdf-item.ts b/src/lib/pdf/pdf-item.ts index 9c83ca091..0b9f27cef 100644 --- a/src/lib/pdf/pdf-item.ts +++ b/src/lib/pdf/pdf-item.ts @@ -17,6 +17,7 @@ export function buildPdfDataFromUpload(meta: UploadedPdfMeta): PdfData { fileUrl: meta.fileUrl, filename: meta.filename, fileSize: meta.fileSize, + thumbnailStatus: "pending", ocrStatus: "processing", ocrPages: [], }; @@ -31,5 +32,5 @@ export function getPdfSourceFilename(pdfData: PdfData | undefined | null): strin } export function getPdfPreviewUrl(pdfData: PdfData | undefined | null): string | undefined { - return pdfData?.fileUrl; + return pdfData?.thumbnailUrl || pdfData?.fileUrl; } diff --git a/src/lib/uploads/upload-selection.ts b/src/lib/uploads/upload-selection.ts index 8173997de..b8c62268f 100644 --- a/src/lib/uploads/upload-selection.ts +++ b/src/lib/uploads/upload-selection.ts @@ -1,5 +1,7 @@ import { uploadFileDirect } from "@/lib/uploads/client-upload"; import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation"; +import { generatePdfThumbnail } from "@/lib/pdf/generate-pdf-thumbnail"; +import { isPdfUploadMeta } from "@/lib/pdf/pdf-item"; import { createUploadedAsset, type UploadedAsset, @@ -60,6 +62,47 @@ export async function prepareWorkspaceUploadSelection( export async function uploadSelectedFile(file: File): Promise { const uploadResult = await uploadFileDirect(file); + + const isPdf = + isPdfUploadMeta(uploadResult.displayName || file.name, uploadResult.contentType); + + if (isPdf) { + try { + const generatedThumbnail = await generatePdfThumbnail({ + filename: uploadResult.displayName || file.name, + file: file.type === "application/pdf" ? file : undefined, + url: file.type === "application/pdf" ? undefined : uploadResult.url, + }); + const thumbnailUpload = await uploadFileDirect(generatedThumbnail.file); + + return createUploadedAsset({ + fileUrl: uploadResult.url, + filename: uploadResult.filename || file.name, + displayName: uploadResult.displayName || file.name, + fileSize: file.size, + contentType: + uploadResult.contentType || file.type || "application/octet-stream", + originalFile: file, + pdfThumbnailUrl: thumbnailUpload.url, + pdfThumbnailWidth: generatedThumbnail.width, + pdfThumbnailHeight: generatedThumbnail.height, + pdfThumbnailStatus: "ready", + }); + } catch (error) { + console.error(`PDF thumbnail generation failed for "${file.name}":`, error); + return createUploadedAsset({ + fileUrl: uploadResult.url, + filename: uploadResult.filename || file.name, + displayName: uploadResult.displayName || file.name, + fileSize: file.size, + contentType: + uploadResult.contentType || file.type || "application/octet-stream", + originalFile: file, + pdfThumbnailStatus: "failed", + }); + } + } + return createUploadedAsset({ fileUrl: uploadResult.url, filename: uploadResult.filename || file.name, diff --git a/src/lib/uploads/uploaded-asset.ts b/src/lib/uploads/uploaded-asset.ts index a7fe7b740..a744576ce 100644 --- a/src/lib/uploads/uploaded-asset.ts +++ b/src/lib/uploads/uploaded-asset.ts @@ -17,6 +17,10 @@ export interface UploadedAsset { contentType: string; name: string; originalFile?: File; + pdfThumbnailUrl?: string; + pdfThumbnailWidth?: number; + pdfThumbnailHeight?: number; + pdfThumbnailStatus?: PdfData["thumbnailStatus"]; } export type WorkspaceItemDefinition = @@ -72,6 +76,10 @@ export function createUploadedAsset(params: { fileSize?: number; contentType: string; originalFile?: File; + pdfThumbnailUrl?: string; + pdfThumbnailWidth?: number; + pdfThumbnailHeight?: number; + pdfThumbnailStatus?: PdfData["thumbnailStatus"]; }): UploadedAsset { const kind = inferAssetKind(params); const name = getBaseName(params.displayName); @@ -86,6 +94,18 @@ export function createUploadedAsset(params: { fileSize: params.fileSize, contentType: resolvedType, name, + ...(params.pdfThumbnailUrl + ? { pdfThumbnailUrl: params.pdfThumbnailUrl } + : {}), + ...(params.pdfThumbnailWidth != null + ? { pdfThumbnailWidth: params.pdfThumbnailWidth } + : {}), + ...(params.pdfThumbnailHeight != null + ? { pdfThumbnailHeight: params.pdfThumbnailHeight } + : {}), + ...(params.pdfThumbnailStatus != null + ? { pdfThumbnailStatus: params.pdfThumbnailStatus } + : {}), ...(params.originalFile ? { originalFile: params.originalFile } : {}), }; } @@ -123,13 +143,25 @@ export function buildWorkspaceItemDefinitionFromAsset( return { type: "pdf", name: asset.name, - initialData: buildPdfDataFromUpload({ - fileUrl: asset.fileUrl, - filename: asset.filename, - contentType: asset.contentType, - fileSize: asset.fileSize, - displayName: asset.displayName, - }), + initialData: { + ...buildPdfDataFromUpload({ + fileUrl: asset.fileUrl, + filename: asset.filename, + contentType: asset.contentType, + fileSize: asset.fileSize, + displayName: asset.displayName, + }), + ...(asset.pdfThumbnailUrl ? { thumbnailUrl: asset.pdfThumbnailUrl } : {}), + ...(asset.pdfThumbnailWidth != null + ? { thumbnailWidth: asset.pdfThumbnailWidth } + : {}), + ...(asset.pdfThumbnailHeight != null + ? { thumbnailHeight: asset.pdfThumbnailHeight } + : {}), + ...(asset.pdfThumbnailStatus != null + ? { thumbnailStatus: asset.pdfThumbnailStatus } + : {}), + }, }; } diff --git a/src/lib/workspace-state/item-data-schemas.ts b/src/lib/workspace-state/item-data-schemas.ts index 315e67076..24f2d2e09 100644 --- a/src/lib/workspace-state/item-data-schemas.ts +++ b/src/lib/workspace-state/item-data-schemas.ts @@ -107,6 +107,10 @@ export const pdfDataSchema = z.object({ fileUrl: z.string().default(""), filename: z.string().default(""), fileSize: z.number().optional(), + thumbnailUrl: z.string().optional(), + thumbnailWidth: z.number().optional(), + thumbnailHeight: z.number().optional(), + thumbnailStatus: z.enum(["pending", "ready", "failed"]).optional(), ocrStatus: z.enum(["complete", "failed", "processing"]).optional(), ocrError: z.string().optional(), ocrPages: z.array(ocrPageSchema).optional(), @@ -149,11 +153,6 @@ export const youtubeDataSchema = z.object({ thumbnail: z.string().optional(), }); -export const websiteDataSchema = z.object({ - url: z.string().default(""), - favicon: z.string().optional(), -}); - export const documentDataSchema = z.object({ markdown: z.string().optional(), sources: z.array(sourceSchema).optional(), diff --git a/src/lib/workspace/workspace-item-model.ts b/src/lib/workspace/workspace-item-model.ts index 6147184a0..fc7c2d2ae 100644 --- a/src/lib/workspace/workspace-item-model.ts +++ b/src/lib/workspace/workspace-item-model.ts @@ -10,7 +10,6 @@ import type { PdfData, QuizData, QuizQuestion, - WebsiteData, YouTubeData, } from "@/lib/workspace-state/types"; import { getOcrPagesTextContent } from "@/lib/utils/ocr-pages"; @@ -104,6 +103,18 @@ export function rehydrateWorkspaceItemData( ...(typeof assetData.fileSize === "number" ? { fileSize: assetData.fileSize } : {}), + ...(typeof assetData.thumbnailUrl === "string" + ? { thumbnailUrl: assetData.thumbnailUrl } + : {}), + ...(typeof assetData.thumbnailWidth === "number" + ? { thumbnailWidth: assetData.thumbnailWidth } + : {}), + ...(typeof assetData.thumbnailHeight === "number" + ? { thumbnailHeight: assetData.thumbnailHeight } + : {}), + ...(typeof assetData.thumbnailStatus === "string" + ? { thumbnailStatus: assetData.thumbnailStatus as PdfData["thumbnailStatus"] } + : {}), ...(typeof shell.ocrStatus === "string" ? { ocrStatus: shell.ocrStatus as PdfData["ocrStatus"] } : {}), @@ -216,18 +227,6 @@ export function rehydrateWorkspaceItemData( } satisfies YouTubeData; break; } - case "website": { - const embedData = isRecord(contentRecord.embedData) - ? contentRecord.embedData - : {}; - merged = { - url: typeof embedData.url === "string" ? embedData.url : "", - ...(typeof embedData.favicon === "string" - ? { favicon: embedData.favicon } - : {}), - } satisfies WebsiteData; - break; - } case "folder": { merged = {}; break; @@ -282,6 +281,16 @@ export function splitWorkspaceItem(item: Item): WorkspaceItemSplitResult { fileUrl: data.fileUrl, filename: data.filename, ...(data.fileSize != null ? { fileSize: data.fileSize } : {}), + ...(data.thumbnailUrl != null ? { thumbnailUrl: data.thumbnailUrl } : {}), + ...(data.thumbnailWidth != null + ? { thumbnailWidth: data.thumbnailWidth } + : {}), + ...(data.thumbnailHeight != null + ? { thumbnailHeight: data.thumbnailHeight } + : {}), + ...(data.thumbnailStatus != null + ? { thumbnailStatus: data.thumbnailStatus } + : {}), ...(data.ocrError != null ? { ocrError: data.ocrError } : {}), }; ocrStatus = data.ocrStatus ?? null; @@ -345,14 +354,6 @@ export function splitWorkspaceItem(item: Item): WorkspaceItemSplitResult { }; break; } - case "website": { - const data = normalized.data as WebsiteData; - content.embedData = { - url: data.url, - ...(data.favicon != null ? { favicon: data.favicon } : {}), - }; - break; - } case "folder": break; } From 41d144c3ef3af28da0a046cd3dcec7bc2363242a Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sun, 3 May 2026 00:28:38 -0400 Subject: [PATCH 27/32] Remove website workspace item type --- src/components/modals/CreateWebsiteDialog.tsx | 190 ------------------ .../workspace-canvas/WebsitePanelContent.tsx | 36 ---- .../workspace-canvas/WorkspaceHeader.tsx | 45 +---- .../workspace-canvas/WorkspaceSection.tsx | 23 --- .../workspace-canvas/workspace-menu-items.tsx | 11 +- .../workspace/WorkspaceItemTypeIcon.tsx | 4 +- .../workspace/use-workspace-operations.ts | 2 - src/lib/ai/tools/read-workspace.ts | 4 +- src/lib/ai/tools/search-workspace.ts | 4 +- src/lib/ai/tools/workspace-search-utils.ts | 11 - src/lib/utils/format-workspace-context.ts | 37 ---- src/lib/workspace-state/item-helpers.ts | 4 +- src/lib/workspace-state/state.ts | 3 - src/lib/workspace-state/types.ts | 4 - .../__tests__/workspace-item-model.test.ts | 10 - .../workspace/workspace-item-model-schemas.ts | 5 - .../workspace/workspace-item-model-shared.ts | 6 - src/lib/zero/mutators.ts | 1 - 18 files changed, 8 insertions(+), 392 deletions(-) delete mode 100644 src/components/modals/CreateWebsiteDialog.tsx delete mode 100644 src/components/workspace-canvas/WebsitePanelContent.tsx diff --git a/src/components/modals/CreateWebsiteDialog.tsx b/src/components/modals/CreateWebsiteDialog.tsx deleted file mode 100644 index f8eb93f5b..000000000 --- a/src/components/modals/CreateWebsiteDialog.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { useState, useCallback, useEffect, useRef } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { toast } from "sonner"; - -interface CreateWebsiteDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onCreate: (url: string, name: string, favicon?: string) => void; -} - -/** - * Validates if a string is a valid HTTP/HTTPS URL - */ -function isValidUrl(str: string): boolean { - try { - const url = new URL(str.trim()); - return url.protocol === "http:" || url.protocol === "https:"; - } catch { - return false; - } -} - -/** - * Get the favicon URL for a given website URL using Google's favicon service - * Note: We use sz=64 to detect default globe icons (they stay 16x16 while real favicons scale) - */ -function getFaviconUrl(websiteUrl: string): string | undefined { - try { - const url = new URL(websiteUrl.trim()); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`; - } catch { - return undefined; - } -} - -/** - * Extract a display name from a URL (domain without www) - */ -function getDisplayName(websiteUrl: string): string { - try { - const url = new URL(websiteUrl.trim()); - return url.hostname.replace(/^www\./, ""); - } catch { - return "Website"; - } -} - -export function CreateWebsiteDialog({ - open, - onOpenChange, - onCreate, -}: CreateWebsiteDialogProps) { - const [url, setUrl] = useState(""); - const [name, setName] = useState(""); - const [isValid, setIsValid] = useState(false); - const [isLoadingTitle, setIsLoadingTitle] = useState(false); - const debounceTimerRef = useRef(null); - - // Validate URL as user types - useEffect(() => { - setIsValid(isValidUrl(url)); - }, [url]); - - // Auto-fill name from domain when a valid URL is entered - useEffect(() => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - if (isValid && url.trim() && !name.trim()) { - debounceTimerRef.current = setTimeout(() => { - setName(getDisplayName(url)); - }, 300); - } - - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, [url, isValid]); - - const handleSubmit = useCallback(() => { - if (!isValid || !url.trim()) { - toast.error("Please enter a valid website URL"); - return; - } - - const cardName = name.trim() || getDisplayName(url); - const favicon = getFaviconUrl(url); - onCreate(url.trim(), cardName, favicon); - - // Reset form - setUrl(""); - setName(""); - onOpenChange(false); - }, [url, name, isValid, onCreate, onOpenChange]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && isValid) { - e.preventDefault(); - handleSubmit(); - } else if (e.key === 'Escape') { - onOpenChange(false); - } - }, [isValid, handleSubmit, onOpenChange]); - - // Reset form when dialog opens - useEffect(() => { - if (open) { - setUrl(""); - setName(""); - setIsValid(false); - setIsLoadingTitle(false); - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - } - }, [open]); - - return ( - - - - Embed Website - - Enter a website URL to embed it in your workspace. - - - -
-
- - setUrl(e.target.value)} - autoFocus - /> - {!isValid && url && ( -

- Please enter a valid URL (http:// or https://) -

- )} -
- -
- - setName(e.target.value)} - /> -

- Leave empty to use the domain name -

-
-
- - - - - -
-
- ); -} diff --git a/src/components/workspace-canvas/WebsitePanelContent.tsx b/src/components/workspace-canvas/WebsitePanelContent.tsx deleted file mode 100644 index a9ed6c9e4..000000000 --- a/src/components/workspace-canvas/WebsitePanelContent.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import type { Item, WebsiteData } from "@/lib/workspace-state/types"; - -interface WebsitePanelContentProps { - item: Item; -} - -export function WebsitePanelContent({ - item, -}: WebsitePanelContentProps) { - const websiteData = item.data as WebsiteData; - - if (!websiteData.url) { - return ( -
-
- No URL provided -
-
- ); - } - - return ( -
-