diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 2151a831..db6f0461 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -45,12 +45,12 @@ const AVAILABLE_ICONS = [ /** Layout positions for autogen items (matches desired workspace arrangement) */ const AUTOGEN_LAYOUTS = { - youtube: { x: 0, y: 0, w: 2, h: 7 }, - flashcard: { x: 2, y: 0, w: 2, h: 5 }, - note: { x: 2, y: 5, w: 1, h: 4 }, - quiz: { x: 0, y: 7, w: 2, h: 13 }, - pdf: { w: 1, h: 4 }, - image: { w: 2, h: 8 }, + youtube: { x: 0, y: 0, w: 1, h: 1 }, + flashcard: { x: 2, y: 0, w: 2, h: 1 }, + note: { x: 2, y: 1, w: 1, h: 1 }, + quiz: { x: 0, y: 2, w: 2, h: 3 }, + pdf: { w: 1, h: 1 }, + image: { w: 2, h: 2 }, } as const; type FileUrlItem = { url: string; mediaType: string; filename?: string; fileSize?: number }; diff --git a/src/app/globals.css b/src/app/globals.css index 77428936..1b359938 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -966,6 +966,13 @@ body.marquee-selecting iframe { touch-action: none !important; } +/* Hide resize handles when isResizable is false (e.g. YouTube cards) + react-grid-layout adds .react-resizable-hide to Resizable when isResizable: false */ +.react-resizable-hide .react-resizable-handle { + display: none !important; + pointer-events: none !important; +} + /* Improve resize handles for mobile */ @media (max-width: 768px) { .react-grid-item>.react-resizable-handle { diff --git a/src/components/home/ParallaxBentoBackground.tsx b/src/components/home/ParallaxBentoBackground.tsx deleted file mode 100644 index aa048ad1..00000000 --- a/src/components/home/ParallaxBentoBackground.tsx +++ /dev/null @@ -1,435 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef, useMemo } from "react"; -import { cn } from "@/lib/utils"; -import { getCardColorCSS, getCardAccentColor, type CardColor } from "@/lib/workspace-state/colors"; -import { Play, FileText, Layers, FolderOpen, MoreVertical, ChevronLeft, ChevronRight, CheckSquare } from "lucide-react"; - -type CardType = "note" | "flashcard" | "quiz" | "youtube" | "pdf" | "folder"; -type DepthLayer = "far" | "mid" | "near"; - -interface BentoCard { - id: string; - type: CardType; - color: CardColor; - x: number; - y: number; - w: number; - h: number; - layer: DepthLayer; -} - -interface LayerConfig { - scale: number; - opacity: number; - blur: number; - parallaxIntensity: number; - breatheDuration: number; -} - -// MUCH BRIGHTER layer opacities for vibrant, eye-catching look -const LAYER_CONFIG: Record = { - far: { - scale: 0.75, - opacity: 0.25, // Was 0.10 - now 2.5x brighter! - blur: 2, - parallaxIntensity: 8, - breatheDuration: 10, - }, - mid: { - scale: 0.88, - opacity: 0.40, // Was 0.15 - now 2.6x brighter! - blur: 0.5, - parallaxIntensity: 16, - breatheDuration: 7, - }, - near: { - scale: 1.0, - opacity: 0.55, // Was 0.22 - now 2.5x brighter! - blur: 0, - parallaxIntensity: 28, - breatheDuration: 5, - }, -}; - -// Realistic card sizes based on actual workspace dimensions (grid-layout-helpers.ts) -// Note: w=1 h=4, Flashcard: w=2 h=5, Quiz: w=2 h=13, YouTube: w=2-4 h=10, PDF: w=1 h=4, Folder: w=1 h=4 -// CENTER AREA AVOIDED: cols 1-2, rows 5-25 (where hero sits) -const BENTO_CARDS: BentoCard[] = [ - // === TOP ROW (rows 0-5) - full width allowed === - { id: "1", type: "folder", x: 0, y: 0, w: 1, h: 4, layer: "far", color: "#93C5FD" }, // Light blue - { id: "2", type: "flashcard", x: 1, y: 0, w: 2, h: 5, layer: "mid", color: "#86EFAC" }, // Light green - { id: "3", type: "note", x: 3, y: 0, w: 1, h: 4, layer: "near", color: "#FCD34D" }, // Bright amber - - // === LEFT SIDE (col 0 only, avoiding center) === - { id: "4", type: "pdf", x: 0, y: 4, w: 1, h: 4, layer: "mid", color: "#C4B5FD" }, // Light violet - { id: "5", type: "note", x: 0, y: 8, w: 1, h: 4, layer: "near", color: "#FCA5A5" }, // Light red - { id: "6", type: "folder", x: 0, y: 12, w: 1, h: 4, layer: "far", color: "#F9A8D4" }, // Light pink - { id: "7", type: "flashcard", x: 0, y: 16, w: 1, h: 5, layer: "mid", color: "#67E8F9" }, // Bright cyan (narrow variant) - { id: "8", type: "pdf", x: 0, y: 21, w: 1, h: 4, layer: "near", color: "#F0ABFC" }, // Bright fuchsia - - // === RIGHT SIDE (col 3 only, avoiding center) === - { id: "9", type: "note", x: 3, y: 4, w: 1, h: 4, layer: "mid", color: "#BEF264" }, // Bright lime - { id: "10", type: "folder", x: 3, y: 8, w: 1, h: 4, layer: "far", color: "#FDA4AF" }, // Light rose - { id: "11", type: "pdf", x: 3, y: 12, w: 1, h: 4, layer: "near", color: "#7DD3FC" }, // Sky blue - { id: "12", type: "note", x: 3, y: 16, w: 1, h: 4, layer: "mid", color: "#D8B4FE" }, // Light purple - { id: "13", type: "flashcard", x: 3, y: 20, w: 1, h: 5, layer: "far", color: "#FDB972" }, // Light orange (narrow variant) - - // === BOTTOM SECTION (rows 26+, below hero) - full width allowed === - { id: "14", type: "quiz", x: 0, y: 26, w: 2, h: 13, layer: "mid", color: "#FBBF24" }, // Amber - tall quiz! - { id: "15", type: "youtube", x: 2, y: 26, w: 2, h: 10, layer: "near", color: "#FCA5A5" }, // Light red - video - - { id: "16", type: "note", x: 0, y: 39, w: 2, h: 9, layer: "far", color: "#6EE7B7" }, // Light emerald - expanded note - { id: "17", type: "flashcard", x: 2, y: 36, w: 2, h: 5, layer: "mid", color: "#A5B4FC" }, // Light indigo - { id: "18", type: "folder", x: 2, y: 41, w: 1, h: 4, layer: "near", color: "#FDE047" }, // Bright yellow - { id: "19", type: "pdf", x: 3, y: 41, w: 1, h: 4, layer: "far", color: "#5EEAD4" }, // Bright teal -]; - -interface StaticPreviewCardProps { - type: CardType; - color: CardColor; - breatheDuration: number; - breatheDelay: number; -} - -// Sample content for realistic placeholders -const NOTE_TITLES = ["Research Notes", "Study Guide", "Meeting Notes", "Project Ideas"]; -const FLASHCARD_QUESTIONS = ["What is photosynthesis?", "Define mitochondria", "Explain osmosis"]; -const QUIZ_QUESTIONS = ["What converts sunlight to energy?", "Which organelle produces ATP?"]; -const QUIZ_OPTIONS = [ - ["Respiration", "Photosynthesis", "Digestion", "Circulation"], - ["Nucleus", "Mitochondria", "Ribosome", "Vacuole"], -]; -const FOLDER_NAMES = ["Study Materials", "Chapter Notes", "Exam Prep", "Resources"]; - -function StaticPreviewCard({ type, color, breatheDuration, breatheDelay }: StaticPreviewCardProps) { - // EXACT styling from WorkspaceCard.tsx (lines 567-571) - const bgColor = getCardColorCSS(color, 0.25); // Same as WorkspaceCard - const borderColor = getCardAccentColor(color, 0.5); // Same as WorkspaceCard - - const baseStyle: React.CSSProperties = { - animation: `breathe ${breatheDuration}s ease-in-out infinite`, - animationDelay: `${breatheDelay}s`, - }; - - // Get random but consistent content based on color (deterministic) - const colorIndex = parseInt(color.slice(1, 3), 16) % 4; - - switch (type) { - case "note": - return ( -
- {/* Title bar with icon - matches WorkspaceCard */} -
- - - {NOTE_TITLES[colorIndex]} - -
- {/* Realistic lorem ipsum content */} -
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

-

Sed do eiusmod tempor incididunt ut labore et dolore.

-

Ut enim ad minim veniam, quis nostrud exercitation...

-
-
- ); - - case "flashcard": - return ( -
- {/* Stacked cards behind - same as FlashcardWorkspaceCard */} -
-
- - {/* Main card */} -
-
- - Vocabulary -
- - {/* Centered question content */} -
-

- {FLASHCARD_QUESTIONS[colorIndex % FLASHCARD_QUESTIONS.length]} -

-
- - {/* Bottom controls */} -
- - 1 / 12 - -
-
-
- ); - - case "quiz": - return ( -
- {/* Header */} -
- - Biology Quiz -
- - {/* Question */} -

- {QUIZ_QUESTIONS[colorIndex % QUIZ_QUESTIONS.length]} -

- - {/* Answer options A-D with realistic text */} -
- {['A', 'B', 'C', 'D'].map((letter, i) => ( -
- - {letter} - - - {QUIZ_OPTIONS[colorIndex % QUIZ_OPTIONS.length][i]} - -
- ))} -
-
- ); - - case "youtube": - return ( -
- {/* Gradient overlay */} -
- - {/* Play button - matches YouTubeCardContent exactly */} -
-
- -
-
- - {/* Progress bar */} -
-
-
-
- ); - - case "pdf": - return ( -
- {/* Header like LightweightPdfPreview */} -
-
- Document.pdf -
- - {/* PDF preview area */} -
-
-
-
-
-
-
-
-
-
-
- ); - - case "folder": - return ( -
- {/* Folder tab - matches FolderCard exactly (0.35 opacity) */} -
- - {/* Folder body - matches FolderCard exactly (0.25 opacity) */} -
-
- - - {FOLDER_NAMES[colorIndex]} - -
-
- 8 items -
-
-
- ); - } -} - -interface BentoLayerProps { - depth: DepthLayer; - mousePosition: { x: number; y: number }; - isMobile: boolean; -} - -function BentoLayer({ depth, mousePosition, isMobile }: BentoLayerProps) { - const config = LAYER_CONFIG[depth]; - const cards = BENTO_CARDS.filter((c) => c.layer === depth); - - const offsetX = isMobile ? 0 : (mousePosition.x - 0.5) * config.parallaxIntensity; - const offsetY = isMobile ? 0 : (mousePosition.y - 0.5) * config.parallaxIntensity; - - return ( -
0 ? `blur(${config.blur}px)` : undefined, - opacity: config.opacity, - willChange: "transform", - }} - > -
- {cards.map((card, index) => ( -
- -
- ))} -
-
- ); -} - -interface ParallaxBentoBackgroundProps { - className?: string; -} - -export function ParallaxBentoBackground({ className }: ParallaxBentoBackgroundProps) { - const [mousePosition, setMousePosition] = useState({ x: 0.5, y: 0.5 }); - const [isMobile, setIsMobile] = useState(false); - const rafRef = useRef(undefined); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); - - useEffect(() => { - if (isMobile) return; - - const handleMouseMove = (e: MouseEvent) => { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } - rafRef.current = requestAnimationFrame(() => { - setMousePosition({ - x: e.clientX / window.innerWidth, - y: e.clientY / window.innerHeight, - }); - }); - }; - - window.addEventListener("mousemove", handleMouseMove); - return () => { - window.removeEventListener("mousemove", handleMouseMove); - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } - }; - }, [isMobile]); - - const layers = useMemo(() => ["far", "mid", "near"], []); - - return ( -
- {/* CSS for breathing animation */} - - - {/* Extended container for parallax movement */} -
- {layers.map((depth) => ( - isMobile && depth === "far" ? null : ( - - ) - ))} -
-
- ); -} diff --git a/src/components/layout/HomeLayout.tsx b/src/components/layout/HomeLayout.tsx index 69959228..a69a9e1f 100644 --- a/src/components/layout/HomeLayout.tsx +++ b/src/components/layout/HomeLayout.tsx @@ -7,7 +7,6 @@ interface HomeLayoutProps { /** * Simplified layout for home page. * No sidebar - uses a top bar navigation instead. - * Background is handled by ParallaxBentoBackground in HomeContent. */ export function HomeLayout({ children }: HomeLayoutProps) { return ( diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx index a822d5e1..1a7cf950 100644 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx @@ -119,10 +119,10 @@ const FlashcardSideContent = memo(function FlashcardSideContent({ // Match ItemHeader note card title styling: text-base (1rem) font-medium (500) fontSize: '1rem', fontWeight: 500, - paddingTop: '1.5rem', - paddingBottom: '1.5rem', - paddingLeft: '1.5rem', - paddingRight: '1.5rem', + paddingTop: '0.75rem', + paddingBottom: '0.75rem', + paddingLeft: '0.75rem', + paddingRight: '0.75rem', // Text rendering optimization - NO transforms here to avoid 3D context conflicts WebkitFontSmoothing: 'antialiased', MozOsxFontSmoothing: 'grayscale' as any, @@ -599,7 +599,7 @@ export function FlashcardWorkspaceCard({ textFallback={currentCard.front} isEditing={isEditingInModal} isScrollLocked={isScrollLocked} - className="p-4" + className="p-3" /> } back={ @@ -608,7 +608,7 @@ export function FlashcardWorkspaceCard({ textFallback={currentCard.back} isEditing={isEditingInModal} isScrollLocked={isScrollLocked} - className="p-4" + className="p-3" /> } color={item.color} diff --git a/src/components/workspace-canvas/WorkspaceGrid.tsx b/src/components/workspace-canvas/WorkspaceGrid.tsx index d527ee18..3b3c0d4e 100644 --- a/src/components/workspace-canvas/WorkspaceGrid.tsx +++ b/src/components/workspace-canvas/WorkspaceGrid.tsx @@ -1,12 +1,19 @@ 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 { wrapCompactor } from "react-grid-layout/extras"; import { useMemo, useCallback, useRef, useEffect, useState } from "react"; import React from "react"; import type { Item, CardType } from "@/lib/workspace-state/types"; import { GRID_FRAMES } from "@/lib/workspace-state/aspect-ratios"; import type { CardColor } from "@/lib/workspace-state/colors"; -import { itemsToLayout, generateMissingLayouts, updateItemsWithLayout, hasLayoutChanged } from "@/lib/workspace-state/grid-layout-helpers"; +import { + COMPACT_CARD_HEIGHT_UNITS, + GRID_GAP_PX, + GRID_ROW_HEIGHT_PX, + itemsToLayout, + generateMissingLayouts, + updateItemsWithLayout, + hasLayoutChanged, +} from "@/lib/workspace-state/grid-layout-helpers"; import { isDescendantOf } from "@/lib/workspace-state/search"; import { WorkspaceCard } from "./WorkspaceCard"; import { FlashcardWorkspaceCard } from "./FlashcardWorkspaceCard"; @@ -70,9 +77,6 @@ export function WorkspaceGrid({ onPDFUpload, setOpenModalItemId, }: WorkspaceGridProps) { - const useWrapCompactor = useFeatureFlagEnabled("wrap-compactor"); - const compactor = useWrapCompactor ? wrapCompactor : fastVerticalCompactor; - const layoutChangeTimeoutRef = useRef(null); const hasUserInteractedRef = useRef(false); const draggedItemIdRef = useRef(null); @@ -110,7 +114,7 @@ export function WorkspaceGrid({ }, [allItems]); // Generate layouts for items that don't have them - // lg: 4 columns; xxs: 1 column with h=10 default for consistent single-column stacking + // lg: 4 columns; xxs: 1 column with compact-unit defaults for consistent stacking const itemsWithLayout = useMemo(() => { const withLg = generateMissingLayouts(items, 4); return generateMissingLayouts(withLg, 1, 'xxs'); @@ -501,7 +505,7 @@ export function WorkspaceGrid({ }, [onDragStop, isFiltered, isTemporaryFilter, onGridDragStateChange, onUpdateAllItems, onMoveItem, onMoveItems, selectedCardIds]); // Handle resize to enforce constraints - // Note cards can transition between compact (w=1, h=4) and expanded (w>=2, h>=9) modes + // Cards snap to compact=1 base units to keep the grid stable. // based on EITHER width or height changes, allowing vertical-only resizing to trigger mode switches const handleResize = useCallback((layout: Layout, oldItem: LayoutItem | null, newItem: LayoutItem | null, placeholder: LayoutItem | null, e: Event, element: HTMLElement | null) => { @@ -541,26 +545,21 @@ export function WorkspaceGrid({ newItem.w = closestFrame.w; } } else if (itemData.type === 'youtube') { - // At w=1: force h=4 (matches note compact). At w=2: force h=7 - if (newItem.w === 1) { - newItem.h = 4; - } else { - newItem.h = 7; - } + // Fixed at 1 width, 1 height + newItem.h = COMPACT_CARD_HEIGHT_UNITS; } else if (itemData.type === 'folder' || itemData.type === 'flashcard') { // Folders and flashcards don't need minimum height enforcement - skip } else if (currentBreakpointRef.current !== 'xxs' && (itemData.type === 'note' || itemData.type === 'pdf' || itemData.type === 'quiz' || itemData.type === 'audio')) { - // Note, PDF, Quiz, and Audio (recording) cards: handle transitions between compact and expanded modes - // Note/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 + // Note, PDF, Quiz, and Audio cards: + // Compact mode: w=1, h=1 + // Expanded mode: w>=2 with type-specific minimum heights in base units. const wasCompact = oldItem.w === 1; const widthChanged = oldItem.w !== newItem.w; - const minExpandedHeight = itemData.type === 'pdf' ? 6 : itemData.type === 'quiz' ? 13 : 9; + const minExpandedHeight = itemData.type === 'quiz' ? 3 : 2; // Check for mode transitions triggered by height-only resize if (!widthChanged) { - if (wasCompact && newItem.h > 4) { + if (wasCompact && newItem.h > COMPACT_CARD_HEIGHT_UNITS) { // Growing a compact card taller → expand to wide mode newItem.w = 2; } else if (!wasCompact && newItem.h < minExpandedHeight) { @@ -573,7 +572,7 @@ export function WorkspaceGrid({ if (newItem.w >= 2) { newItem.h = Math.max(newItem.h, minExpandedHeight); } else { - newItem.h = 4; + newItem.h = COMPACT_CARD_HEIGHT_UNITS; } } @@ -671,8 +670,8 @@ export function WorkspaceGrid({ // OPTIMIZED: Memoize array props to prevent ResponsiveGridLayout/DraggableCore re-renders // These arrays are recreated on every render, causing unnecessary re-renders - const margin = useMemo(() => [16, 16] as [number, number], []); - const containerPadding = useMemo(() => [16, 0] as [number, number], []); + const margin = useMemo(() => [GRID_GAP_PX, GRID_GAP_PX] as [number, number], []); + const containerPadding = useMemo(() => [GRID_GAP_PX, 0] as [number, number], []); const resizeHandles = useMemo(() => ['s', 'w', 'e', 'n', 'se', 'sw', 'ne', 'nw'] as Array<'s' | 'w' | 'e' | 'n' | 'se' | 'sw' | 'ne' | 'nw'>, []); // OPTIMIZED: Create stable Set reference check - only recreate if Set contents changed @@ -777,7 +776,7 @@ export function WorkspaceGrid({ const cols = useMemo(() => ({ lg: 4, xxs: 1 }), []); // Create layouts object for ResponsiveGridLayout with both breakpoints - // Always provide xxs layout (h=10 default) for consistent single-column stacking + // Always provide xxs layout for consistent single-column stacking const xxsLayout = useMemo(() => itemsToLayout(itemsWithLayout, 'xxs'), [itemsWithLayout]); const layouts = useMemo(() => ({ lg: combinedLayout, @@ -803,7 +802,7 @@ export function WorkspaceGrid({ layouts={layouts} breakpoints={breakpoints} cols={cols} - rowHeight={25} + rowHeight={GRID_ROW_HEIGHT_PX} margin={margin} containerPadding={containerPadding} @@ -821,7 +820,7 @@ export function WorkspaceGrid({ onResizeStart={handleResizeStart} onResizeStop={handleResizeStop} onBreakpointChange={handleBreakpointChange} - compactor={compactor} + compactor={wrapCompactor} > {children} diff --git a/src/components/workspace-canvas/YouTubeCardContent.tsx b/src/components/workspace-canvas/YouTubeCardContent.tsx index 44b936d2..7f909ef4 100644 --- a/src/components/workspace-canvas/YouTubeCardContent.tsx +++ b/src/components/workspace-canvas/YouTubeCardContent.tsx @@ -111,7 +111,7 @@ export function YouTubeCardContent({ item, isPlaying, onTogglePlay }: YouTubeCar ) : ( // Thumbnail view with play button (or playlist fallback)
{thumbnailUrl ? ( @@ -119,22 +119,22 @@ export function YouTubeCardContent({ item, isPlaying, onTogglePlay }: YouTubeCar {item.name {/* Dark overlay */}
{/* Play button */}
-
- +
+
) : ( // Fallback for playlists without thumbnails
-
- +
+

YouTube Playlist

Click to play

diff --git a/src/lib/workspace-state/aspect-ratios.ts b/src/lib/workspace-state/aspect-ratios.ts index ef4a2a7c..6473bb5e 100644 --- a/src/lib/workspace-state/aspect-ratios.ts +++ b/src/lib/workspace-state/aspect-ratios.ts @@ -19,7 +19,7 @@ export const ASPECT_RATIOS = { }; // Optimal grid dimensions for each aspect ratio -// Based on: Row Height = 25px, Col Width ~160px, Gap = 16px +// Based on: Row Height = 148px, Col Width ~160px, Gap = 16px // Grid Widths: // 1 col = 160px // 2 col = 336px @@ -27,46 +27,24 @@ export const ASPECT_RATIOS = { // 4 col = 688px export const GRID_FRAMES: GridFrame[] = [ // --- 1 COLUMN (160px) --- - { w: 1, h: 4, ratio: 1.08, label: "1:1" }, // 160x148 - { w: 1, h: 3, ratio: 1.49, label: "4:3 / 3:2" }, // 160x107 - Compromise for Standard/Photo - { w: 1, h: 2, ratio: 2.42, label: "16:9" }, // 160x66 - Very wide, but mathematically closest to video? Actually 1.77 of 160 is 90px (h=2.5). - // Let's check h=3 vs h=2 for video. h=3 is 160/107 = 1.49. h=2 is 160/66 = 2.42. - // Video (1.77) is right in between. We'll offer h=3 as a "tall video" option and maybe h=2 for wide. - // Actually, let's stick to standard approx. + { w: 1, h: 1, ratio: 1.08, label: "1:1" }, // 160x148 + { w: 1, h: 2, ratio: 0.51, label: "Tall" }, // 160x312 // --- 2 COLUMN (336px) --- - // Square - { w: 2, h: 8, ratio: 1.08, label: "1:1" }, - // 4:3 - { w: 2, h: 7, ratio: 1.24, label: "4:3" }, - // 3:2 - { w: 2, h: 6, ratio: 1.46, label: "3:2" }, - // 16:9 - { w: 2, h: 5, ratio: 1.77, label: "16:9" }, + { w: 2, h: 2, ratio: 1.08, label: "1:1" }, // 336x312 + { w: 2, h: 1, ratio: 2.27, label: "Wide" }, // 336x148 + { w: 2, h: 3, ratio: 0.71, label: "Portrait" }, // 336x476 // --- 3 COLUMN (512px) --- - // Square - { w: 3, h: 13, ratio: 0.99, label: "1:1" }, // 512x517 - // 4:3 - { w: 3, h: 10, ratio: 1.30, label: "4:3" }, // 512x394 - // 3:2 - { w: 3, h: 9, ratio: 1.45, label: "3:2" }, // 512x353 - // 16:9 - { w: 3, h: 8, ratio: 1.64, label: "16:9" }, // 512x312 - // 1.91:1 - { w: 3, h: 7, ratio: 1.88, label: "1.91:1" }, // 512x271 + { w: 3, h: 3, ratio: 1.08, label: "1:1" }, // 512x476 + { w: 3, h: 2, ratio: 1.64, label: "16:9-ish" }, // 512x312 + { w: 3, h: 4, ratio: 0.8, label: "Portrait" }, // 512x640 // --- 4 COLUMN (688px) --- - // Square - { w: 4, h: 17, ratio: 0.99, label: "1:1" }, // 688x681 - // 4:3 - { w: 4, h: 13, ratio: 1.30, label: "4:3" }, // 688x517 - // 3:2 - { w: 4, h: 11, ratio: 1.52, label: "3:2" }, // 688x435 - // 16:9 - { w: 4, h: 10, ratio: 1.75, label: "16:9" }, // 688x394 - // 1.91:1 - { w: 4, h: 9, ratio: 1.95, label: "1.91:1" } // 688x353 + { w: 4, h: 4, ratio: 1.08, label: "1:1" }, // 688x640 + { w: 4, h: 3, ratio: 1.44, label: "3:2-ish" }, // 688x476 + { w: 4, h: 2, ratio: 2.21, label: "Wide" }, // 688x312 + { w: 4, h: 5, ratio: 0.86, label: "Portrait" }, // 688x804 ]; /** diff --git a/src/lib/workspace-state/grid-layout-helpers.ts b/src/lib/workspace-state/grid-layout-helpers.ts index 161d0ad0..49fe7038 100644 --- a/src/lib/workspace-state/grid-layout-helpers.ts +++ b/src/lib/workspace-state/grid-layout-helpers.ts @@ -1,18 +1,27 @@ import type { Layout, LayoutItem } from "react-grid-layout"; import type { Item, CardType, LayoutPosition, ResponsiveLayouts } from "./types"; +/** + * Canonical grid sizing model. + * A compact card (note/pdf/folder) is exactly 1 height unit. + */ +export const COMPACT_CARD_HEIGHT_UNITS = 1; +export const GRID_ROW_HEIGHT_PX = 148; +export const GRID_GAP_PX = 16; +export const LEGACY_TO_BASE_HEIGHT_RATIO = 4; + /** * Default dimensions for each card type in grid units */ export const DEFAULT_CARD_DIMENSIONS: Record = { - note: { w: 1, h: 4 }, - 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: 4, h: 10 }, - audio: { w: 2, h: 10 }, + note: { w: 1, h: COMPACT_CARD_HEIGHT_UNITS }, + pdf: { w: 1, h: COMPACT_CARD_HEIGHT_UNITS }, + flashcard: { w: 2, h: COMPACT_CARD_HEIGHT_UNITS }, + folder: { w: 1, h: COMPACT_CARD_HEIGHT_UNITS }, + youtube: { w: 1, h: 1 }, + quiz: { w: 2, h: 3 }, + image: { w: 4, h: 3 }, + audio: { w: 2, h: 2 }, }; /** @@ -26,6 +35,38 @@ function isLegacyLayout(layout: ResponsiveLayouts | LayoutPosition | undefined): return 'x' in layout && typeof layout.x === 'number'; } +function shouldMigrateLegacyScale(itemType: CardType, layout: LayoutPosition): boolean { + // Detect old 25px-row model values and migrate to the compact=1 model once. + // Thresholds are type-aware so migrated layouts won't be remigrated. + switch (itemType) { + case "note": + case "pdf": + case "folder": + return layout.h >= 4; + case "flashcard": + return layout.h >= 5; + case "youtube": + return layout.h >= 4; + case "quiz": + return layout.h >= 8; + case "audio": + return layout.h >= 8; + case "image": + return layout.h >= 7; + default: + return layout.h >= 8; + } +} + +function migrateLegacyLayoutScale(itemType: CardType, layout: LayoutPosition): LayoutPosition { + if (!shouldMigrateLegacyScale(itemType, layout)) return layout; + return { + ...layout, + y: Math.max(0, Math.round(layout.y / LEGACY_TO_BASE_HEIGHT_RATIO)), + h: Math.max(COMPACT_CARD_HEIGHT_UNITS, Math.round(layout.h / LEGACY_TO_BASE_HEIGHT_RATIO)), + }; +} + /** * Get the layout for a specific breakpoint from an item. * Handles backwards compatibility with old flat layout format. @@ -33,13 +74,29 @@ function isLegacyLayout(layout: ResponsiveLayouts | LayoutPosition | undefined): export function getLayoutForBreakpoint(item: Item, breakpoint: 'lg' | 'xxs'): LayoutPosition | undefined { if (!item.layout) return undefined; + const normalize = (layout: LayoutPosition): LayoutPosition => { + let sanitized: LayoutPosition = { + x: Math.max(0, Math.round(layout.x)), + y: Math.max(0, Math.round(layout.y)), + w: Math.max(1, Math.round(layout.w)), + h: Math.max(COMPACT_CARD_HEIGHT_UNITS, Math.round(layout.h)), + }; + sanitized = migrateLegacyLayoutScale(item.type, sanitized); + // YouTube: clamp to 1x1 (legacy cards may have w=2) + if (item.type === 'youtube') { + sanitized = { ...sanitized, w: 1, h: COMPACT_CARD_HEIGHT_UNITS }; + } + return sanitized; + }; + if (isLegacyLayout(item.layout)) { // Old format - treat as 'lg' layout - return breakpoint === 'lg' ? item.layout : undefined; + return breakpoint === 'lg' ? normalize(item.layout) : undefined; } // New format - get the specific breakpoint - return item.layout[breakpoint]; + const layout = item.layout[breakpoint]; + return layout ? normalize(layout) : undefined; } export const DEFAULT_COLS = 4; @@ -51,7 +108,7 @@ export function itemsToLayout(items: Item[], breakpoint: 'lg' | 'xxs' = 'lg'): L return items.map((item) => { const layout = getLayoutForBreakpoint(item, breakpoint); - // YouTube: resizable smaller but not larger than 2x7; at w=1 force h=4 (matches note compact) + // YouTube: fixed at 1x1; no resize handles if (item.type === 'youtube') { return { i: item.id, @@ -60,9 +117,10 @@ export function itemsToLayout(items: Item[], breakpoint: 'lg' | 'xxs' = 'lg'): L 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, + minH: COMPACT_CARD_HEIGHT_UNITS, + maxW: 1, + maxH: COMPACT_CARD_HEIGHT_UNITS, + isResizable: false, }; } @@ -75,9 +133,9 @@ export function itemsToLayout(items: Item[], breakpoint: 'lg' | 'xxs' = 'lg'): L 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) + minH: COMPACT_CARD_HEIGHT_UNITS, maxW: 4, - maxH: 20, // Increased to support tall/square images + maxH: 6, }; } @@ -90,9 +148,9 @@ export function itemsToLayout(items: Item[], breakpoint: 'lg' | 'xxs' = 'lg'): L w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, minW: 1, - minH: 4, + minH: COMPACT_CARD_HEIGHT_UNITS, maxW: 4, - maxH: 25, + maxH: 7, anchor: true, // Anchor items act as obstacles but can be moved }; } @@ -105,9 +163,9 @@ export function itemsToLayout(items: Item[], breakpoint: 'lg' | 'xxs' = 'lg'): L w: layout?.w ?? DEFAULT_CARD_DIMENSIONS[item.type].w, h: layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type].h, minW: 1, - minH: 4, + minH: COMPACT_CARD_HEIGHT_UNITS, maxW: 4, - maxH: 25, + maxH: 7, }; }); } @@ -141,7 +199,7 @@ export function findNextAvailablePosition( 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; + const ih = layout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type]?.h ?? COMPACT_CARD_HEIGHT_UNITS; occupiedRects.push({ x: ix, y: iy, w: iw, h: ih }); maxY = Math.max(maxY, iy + ih); }); @@ -178,7 +236,7 @@ export function findNextAvailablePosition( } /** Default height for all items in xxs (single-column) mode */ -export const XXS_DEFAULT_HEIGHT = 12; +export const XXS_DEFAULT_HEIGHT = 3; /** * Generate missing layouts for items that don't have them. @@ -268,8 +326,7 @@ export function recompactLayout(items: Item[], cols: number): Item[] { 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 h = existingLayout?.h ?? DEFAULT_CARD_DIMENSIONS[item.type]?.h ?? COMPACT_CARD_HEIGHT_UNITS; const dimensions = existingLayout ? { w: Math.min(existingLayout.w, cols), h } : { w: DEFAULT_CARD_DIMENSIONS[item.type].w, h: DEFAULT_CARD_DIMENSIONS[item.type].h }; diff --git a/src/lib/workspace/templates.ts b/src/lib/workspace/templates.ts index 8fee70ba..8f4b345e 100644 --- a/src/lib/workspace/templates.ts +++ b/src/lib/workspace/templates.ts @@ -44,7 +44,7 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [ field1: "", }, color: sampleColors[0], - layout: { x: 2, y: 5, w: 1, h: 4 }, + layout: { x: 2, y: 1, w: 1, h: 1 }, }, { id: "sample-quiz-1", @@ -55,7 +55,7 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [ questions: [] }, color: sampleColors[1], - layout: { x: 0, y: 0, w: 2, h: 13 }, + layout: { x: 0, y: 0, w: 2, h: 3 }, }, { id: "sample-flashcard-1", @@ -66,7 +66,7 @@ export const WORKSPACE_TEMPLATES: TemplateDefinition[] = [ cards: [] }, color: sampleColors[2], - layout: { x: 2, y: 0, w: 2, h: 5 }, + layout: { x: 2, y: 0, w: 2, h: 1 }, } ], globalTitle: "",