diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index a9bd10a2..c2c4fbad 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -614,3 +614,32 @@ 0 0 0 3px white, 0 4px 16px rgba(0, 0, 0, 0.2); } + +/* ── Touch / mobile ───────────────────────────────────────────────────────── + The canvas owns all touch gestures (pan, pinch-zoom, card drag), so disable + the browser's native panning/zoom on it; text fields inside cards opt back in + so typing/caret placement still works. The drag/resize/connection handles have + no hover on touch, so surface them and enlarge them into comfortable targets. */ +@media (max-width: 767px) { + .container { + touch-action: none; + } + + .card_description_input, + .card_header_title_input, + .audio_timeline { + touch-action: auto; + } + + .card_resize_handle { + opacity: 1; + width: 28px; + height: 28px; + } + + .connection_handle { + opacity: 1; + width: 28px; + height: 28px; + } +} diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 360ad865..7a3c2648 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -12,12 +12,13 @@ import { } from "@components/utils/ContextMenu"; import styles from "./BoardCanvas.module.css"; import { v7 as uuidv7 } from "uuid"; -import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react"; +import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square, Image as ImageIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; import { importImageFile, importAudioFile, syncAssetToCloud } from "@src/lib/assets/asset-store"; import { CloudQuotaError } from "@src/lib/assets/cloud-asset-sync"; import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; +import { useIsPhone } from "@src/lib/utils/hooks"; import { useAudioRecorder } from "./use-audio-recorder"; const GRID_SIZE = 20; @@ -83,6 +84,35 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const selectionStart = useRef<{ x: number; y: number } | null>(null); const isSelecting = useRef(false); + // ── Touch (mobile) state ────────────────────────────────────────────────── + const isPhone = useIsPhone(); + const imageInputRef = useRef(null); + const imageImportCoords = useRef({ x: 0, y: 0 }); + const gesture = useRef<{ + mode: "none" | "pan" | "pinch"; + startX: number; + startY: number; + startOffset: { x: number; y: number }; + startDist: number; + startScale: number; + pinchCanvasX: number; + pinchCanvasY: number; + moved: boolean; + }>({ + mode: "none", + startX: 0, + startY: 0, + startOffset: { x: 0, y: 0 }, + startDist: 0, + startScale: 1, + pinchCanvasX: 0, + pinchCanvasY: 0, + moved: false, + }); + const longPressTimer = useRef | null>(null); + const lastTap = useRef({ time: 0, x: 0, y: 0 }); + const lastTouchPoint = useRef({ x: 0, y: 0 }); + // Center camera to fit all cards const centerCameraOnCards = useCallback((cardsToFit: BoardCardData[]) => { const container = containerRef.current; @@ -710,28 +740,66 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) } }, [recorder, projectId, saveCards, syncCreatedAssets]); - // Right-clicking empty canvas opens a menu (create card / record audio). - // Cards and arrows have their own menus, so bail when the click landed on one. - const handleCanvasContextMenu = useCallback( - (e: React.MouseEvent) => { - if (isReadOnly) return; - const target = e.target as HTMLElement; - if ( - target.closest(`.${styles.card}`) || - target.closest(`.${styles.arrow_group}`) || - target.closest("[data-context-menu]") || - target.closest(`.${styles.zoom_controls}`) - ) - return; + // Import an image file as a card at the given canvas-space coords. Shared by + // OS file drops and the "Import image" menu action (mobile has no drag-drop). + const addImageCard = useCallback( + async (file: File, x: number, y: number) => { + if (isReadOnly || !projectId) return; + try { + const { hash, width, height } = await importImageFile(projectId, file); + const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(width, height, 1)); + const newCard: BoardCardData = { + id: uuidv7(), + type: "image", + assetId: hash, + title: "", + description: "", + color: "transparent", + x, + y, + width: Math.max(60, Math.round(width * fit)), + height: Math.max(60, Math.round(height * fit)), + }; + const newCards = [...cardsRef.current, newCard]; + setCards(newCards); + saveCards(newCards); + syncCreatedAssets([newCard], projectId); + } catch (err) { + console.error("[BoardCanvas] Failed to import image:", err); + } + }, + [isReadOnly, projectId, saveCards, syncCreatedAssets], + ); + const openImagePicker = useCallback((x: number, y: number) => { + imageImportCoords.current = { x, y }; + imageInputRef.current?.click(); + }, []); + + const handleImageInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) { + const { x, y } = imageImportCoords.current; + void addImageCard(file, x, y); + } + }, + [addImageCard], + ); + + // Build the empty-canvas menu (create card / import image / record audio) at + // the given *screen* coords, resolving the canvas-space drop point via refs. + const showCanvasMenu = useCallback( + (screenX: number, screenY: number) => { + if (isReadOnly) return; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); - e.preventDefault(); - const canvasX = (e.clientX - rect.left - offset.x) / scale; - const canvasY = (e.clientY - rect.top - offset.y) / scale; + const canvasX = (screenX - rect.left - offsetRef.current.x) / scaleRef.current; + const canvasY = (screenY - rect.top - offsetRef.current.y) / scaleRef.current; updateContextMenu({ - position: { x: e.clientX, y: e.clientY }, + position: { x: screenX, y: screenY }, content: ( <> handleCreateCard(canvasX, canvasY)} /> + openImagePicker(canvasX, canvasY)} + /> { + if (isReadOnly) return; + const target = e.target as HTMLElement; + if ( + target.closest(`.${styles.card}`) || + target.closest(`.${styles.arrow_group}`) || + target.closest("[data-context-menu]") || + target.closest(`.${styles.zoom_controls}`) + ) + return; + e.preventDefault(); + showCanvasMenu(e.clientX, e.clientY); + }, + [isReadOnly, showCanvasMenu], ); // Update card (with multi-drag support) @@ -1049,17 +1141,180 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) setConnectingLine(null); }, []); - // Setup connection mouse events + // Setup connection mouse + touch events useEffect(() => { - if (connectingFrom) { - window.addEventListener("mousemove", handleConnectionMouseMove); - window.addEventListener("mouseup", handleConnectionMouseUp); - return () => { - window.removeEventListener("mousemove", handleConnectionMouseMove); - window.removeEventListener("mouseup", handleConnectionMouseUp); + if (!connectingFrom) return; + window.addEventListener("mousemove", handleConnectionMouseMove); + window.addEventListener("mouseup", handleConnectionMouseUp); + + // Touch: track the finger to draw the pending line, and on release resolve + // the card under the finger via elementFromPoint to complete the link. + const onTouchMove = (e: TouchEvent) => { + const t = e.touches[0]; + const container = containerRef.current; + if (!t || !container) return; + const rect = container.getBoundingClientRect(); + lastTouchPoint.current = { x: t.clientX, y: t.clientY }; + setConnectingLine({ + x: (t.clientX - rect.left - offsetRef.current.x) / scaleRef.current, + y: (t.clientY - rect.top - offsetRef.current.y) / scaleRef.current, + }); + }; + const onTouchEnd = () => { + const p = lastTouchPoint.current; + const el = document.elementFromPoint(p.x, p.y) as HTMLElement | null; + const targetId = el?.closest("[data-card-id]")?.getAttribute("data-card-id"); + if (targetId) handleCompleteConnection(targetId); + else handleConnectionMouseUp(); + }; + window.addEventListener("touchmove", onTouchMove, { passive: true }); + window.addEventListener("touchend", onTouchEnd); + + return () => { + window.removeEventListener("mousemove", handleConnectionMouseMove); + window.removeEventListener("mouseup", handleConnectionMouseUp); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); + }; + }, [connectingFrom, handleConnectionMouseMove, handleConnectionMouseUp, handleCompleteConnection]); + + // ── Container touch gestures (mobile) ───────────────────────────────────── + // One finger pans the canvas; two fingers pinch-zoom (centred on the pinch); + // a double-tap on empty canvas creates a card; a long-press opens the canvas + // menu. Cards/handles stop propagation, so their touches never reach here. + useEffect( + () => () => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + }, + [], + ); + + const toCanvasPoint = (clientX: number, clientY: number) => { + const container = containerRef.current; + if (!container) return { x: 0, y: 0 }; + const rect = container.getBoundingClientRect(); + return { + x: (clientX - rect.left - offsetRef.current.x) / scaleRef.current, + y: (clientY - rect.top - offsetRef.current.y) / scaleRef.current, + }; + }; + + const cancelLongPress = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const isChromeTarget = (target: HTMLElement) => + !!( + target.closest(`.${styles.card}`) || + target.closest(`.${styles.zoom_controls}`) || + target.closest(`.${styles.recording_indicator}`) || + target.closest("[data-context-menu]") + ); + + const handleContainerTouchStart = (e: React.TouchEvent) => { + if (connectingFrom) return; + if (isChromeTarget(e.target as HTMLElement)) return; + + if (e.touches.length === 1) { + const t = e.touches[0]; + gesture.current = { + ...gesture.current, + mode: "pan", + startX: t.clientX, + startY: t.clientY, + startOffset: { ...offsetRef.current }, + moved: false, + }; + cancelLongPress(); + const lpX = t.clientX; + const lpY = t.clientY; + longPressTimer.current = setTimeout(() => { + gesture.current.mode = "none"; + showCanvasMenu(lpX, lpY); + }, 500); + } else if (e.touches.length === 2) { + cancelLongPress(); + const a = e.touches[0]; + const b = e.touches[1]; + const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); + const mid = toCanvasPoint((a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2); + gesture.current = { + ...gesture.current, + mode: "pinch", + startDist: dist || 1, + startScale: scaleRef.current, + pinchCanvasX: mid.x, + pinchCanvasY: mid.y, + moved: true, + }; + } + }; + + const handleContainerTouchMove = (e: React.TouchEvent) => { + const g = gesture.current; + if (g.mode === "pan" && e.touches.length === 1) { + const t = e.touches[0]; + const dx = t.clientX - g.startX; + const dy = t.clientY - g.startY; + if (!g.moved && Math.hypot(dx, dy) > 8) { + g.moved = true; + cancelLongPress(); + } + if (g.moved) setOffset({ x: g.startOffset.x + dx, y: g.startOffset.y + dy }); + } else if (g.mode === "pinch" && e.touches.length >= 2) { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const a = e.touches[0]; + const b = e.touches[1]; + const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); + const midX = (a.clientX + b.clientX) / 2; + const midY = (a.clientY + b.clientY) / 2; + const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, g.startScale * (dist / g.startDist))); + setScale(newScale); + setOffset({ + x: midX - rect.left - g.pinchCanvasX * newScale, + y: midY - rect.top - g.pinchCanvasY * newScale, + }); + } + }; + + const handleContainerTouchEnd = (e: React.TouchEvent) => { + cancelLongPress(); + const g = gesture.current; + if (g.mode === "pan" && !g.moved && e.changedTouches.length > 0) { + const t = e.changedTouches[0]; + const now = Date.now(); + const isDoubleTap = + now - lastTap.current.time < 300 && + Math.hypot(t.clientX - lastTap.current.x, t.clientY - lastTap.current.y) < 30; + if (isDoubleTap) { + const c = toCanvasPoint(t.clientX, t.clientY); + setSelectedCardIds(new Set()); + handleCreateCard(c.x, c.y); + lastTap.current = { time: 0, x: 0, y: 0 }; + } else { + lastTap.current = { time: now, x: t.clientX, y: t.clientY }; + } + } + if (e.touches.length === 0) { + g.mode = "none"; + } else if (e.touches.length === 1) { + // A finger lifted from a pinch — resume panning with the one that remains. + const t = e.touches[0]; + gesture.current = { + ...g, + mode: "pan", + startX: t.clientX, + startY: t.clientY, + startOffset: { ...offsetRef.current }, + moved: true, }; } - }, [connectingFrom, handleConnectionMouseMove, handleConnectionMouseUp]); + }; // Generate grid pattern const gridPattern = useMemo(() => { @@ -1076,12 +1331,23 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) return (
+ {/* Hidden picker for the mobile "Import image" menu action. */} +
)} -
- - {Math.round(scale * 100)}% - -
+ {/* Zoom controls hidden on phone — pinch-to-zoom replaces them. */} + {!isPhone && ( +
+ + {Math.round(scale * 100)}% + +
+ )}
); diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index c2b3d963..fc7e7d31 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { Play, Pause } from "lucide-react"; import { BoardCardData } from "@src/lib/project/project-state"; import { useAssetUrl } from "@src/lib/assets/use-asset-url"; +import { useIsPhone } from "@src/lib/utils/hooks"; /** Join truthy class names (false/undefined are skipped). */ const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" "); @@ -306,7 +307,12 @@ const BoardCard = ({ isSelected, }: BoardCardProps) => { const kind = kindOf(card); + const isPhone = useIsPhone(); const cardRef = useRef(null); + // Latest card data for touch handlers, which capture their closure at + // gesture start but must merge against the current card on each update. + const cardDataRef = useRef(card); + cardDataRef.current = card; const [isDragging, setIsDragging] = useState(false); const [isEditing, setIsEditing] = useState(false); const [isResizing, setIsResizing] = useState(false); @@ -360,32 +366,36 @@ const BoardCard = ({ [isEditing, isEditingTitle, scale], ); - const handleMouseMove = useCallback( - (e: MouseEvent) => { - if (!isDragging && !isResizing) return; - - if (isDragging) { - const parent = cardRef.current?.parentElement; - if (!parent) return; - - const parentRect = parent.getBoundingClientRect(); - const newX = (e.clientX - parentRect.left) / scale - dragOffset.current.x; - const newY = (e.clientY - parentRect.top) / scale - dragOffset.current.y; - - onUpdate({ ...card, x: snapToGrid(newX), y: snapToGrid(newY) }); - } - - if (isResizing) { - const dx = (e.clientX - resizeStart.current.x) / scale; - const dy = (e.clientY - resizeStart.current.y) / scale; + // Shared drag/resize math, driven by either mouse or touch coordinates. + const applyDrag = useCallback( + (clientX: number, clientY: number) => { + const parent = cardRef.current?.parentElement; + if (!parent) return; + const parentRect = parent.getBoundingClientRect(); + const newX = (clientX - parentRect.left) / scale - dragOffset.current.x; + const newY = (clientY - parentRect.top) / scale - dragOffset.current.y; + onUpdate({ ...cardDataRef.current, x: snapToGrid(newX), y: snapToGrid(newY) }); + }, + [scale, onUpdate, snapToGrid], + ); - const newWidth = Math.max(150, resizeStart.current.width + dx); - const newHeight = Math.max(minHeightFor(kind), resizeStart.current.height + dy); + const applyResize = useCallback( + (clientX: number, clientY: number) => { + const dx = (clientX - resizeStart.current.x) / scale; + const dy = (clientY - resizeStart.current.y) / scale; + const newWidth = Math.max(150, resizeStart.current.width + dx); + const newHeight = Math.max(minHeightFor(kind), resizeStart.current.height + dy); + onUpdate({ ...cardDataRef.current, width: snapToGrid(newWidth), height: snapToGrid(newHeight) }); + }, + [scale, kind, onUpdate, snapToGrid], + ); - onUpdate({ ...card, width: snapToGrid(newWidth), height: snapToGrid(newHeight) }); - } + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (isDragging) applyDrag(e.clientX, e.clientY); + if (isResizing) applyResize(e.clientX, e.clientY); }, - [isDragging, isResizing, kind, card, onUpdate, scale, snapToGrid], + [isDragging, isResizing, applyDrag, applyResize], ); const handleMouseUp = useCallback(() => { @@ -419,6 +429,141 @@ const BoardCard = ({ } }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); + // ── Touch (mobile) ─────────────────────────────────────────────────────── + // One finger drags the card. A stationary long-press opens the card menu; a + // tap (no movement) completes a pending connection, or on a second tap enters + // edit mode. The resize / connection handles get their own touch starters. + const cardLongPress = useRef | null>(null); + const lastCardTap = useRef(0); + const touchDrag = useRef<{ startX: number; startY: number; moved: boolean; suppressed: boolean } | null>( + null, + ); + + useEffect( + () => () => { + if (cardLongPress.current) clearTimeout(cardLongPress.current); + }, + [], + ); + + const handleCardTouchStart = useCallback( + (e: React.TouchEvent) => { + if (isEditing || isEditingTitle) return; + const target = e.target as HTMLElement; + if ( + target.closest(`.${styles.card_resize_handle}`) || + target.closest(`.${styles.connection_handle}`) || + target.closest("input,textarea,button,audio") + ) + return; + const t = e.touches[0]; + if (!t) return; + e.stopPropagation(); + + const rect = cardRef.current?.getBoundingClientRect(); + if (!rect) return; + dragOffset.current = { x: (t.clientX - rect.left) / scale, y: (t.clientY - rect.top) / scale }; + touchDrag.current = { startX: t.clientX, startY: t.clientY, moved: false, suppressed: false }; + + if (cardLongPress.current) clearTimeout(cardLongPress.current); + const lpX = t.clientX; + const lpY = t.clientY; + cardLongPress.current = setTimeout(() => { + if (touchDrag.current) touchDrag.current.suppressed = true; + setIsDragging(false); + onContextMenu( + { + clientX: lpX, + clientY: lpY, + preventDefault() {}, + stopPropagation() {}, + } as unknown as React.MouseEvent, + card, + ); + }, 500); + + const onMove = (ev: TouchEvent) => { + const tt = ev.touches[0]; + const state = touchDrag.current; + if (!tt || !state) return; + const dx = tt.clientX - state.startX; + const dy = tt.clientY - state.startY; + if (!state.moved && Math.hypot(dx, dy) > 8) { + state.moved = true; + if (cardLongPress.current) { + clearTimeout(cardLongPress.current); + cardLongPress.current = null; + } + setIsDragging(true); + } + if (state.moved && !state.suppressed) applyDrag(tt.clientX, tt.clientY); + }; + const onEnd = () => { + if (cardLongPress.current) { + clearTimeout(cardLongPress.current); + cardLongPress.current = null; + } + window.removeEventListener("touchmove", onMove); + window.removeEventListener("touchend", onEnd); + window.removeEventListener("touchcancel", onEnd); + setIsDragging(false); + const state = touchDrag.current; + touchDrag.current = null; + if (state && !state.moved && !state.suppressed) { + if (isConnecting) { + onCompleteConnection(card.id); + return; + } + const now = Date.now(); + if (now - lastCardTap.current < 300) { + lastCardTap.current = 0; + if (kind === "text") setIsEditing(true); + else if (kind === "audio") setIsEditingTitle(true); + } else { + lastCardTap.current = now; + } + } + }; + window.addEventListener("touchmove", onMove, { passive: true }); + window.addEventListener("touchend", onEnd); + window.addEventListener("touchcancel", onEnd); + }, + [isEditing, isEditingTitle, scale, card, kind, isConnecting, applyDrag, onContextMenu, onCompleteConnection], + ); + + const handleResizeTouchStart = useCallback( + (e: React.TouchEvent) => { + const t = e.touches[0]; + if (!t) return; + e.stopPropagation(); + resizeStart.current = { x: t.clientX, y: t.clientY, width: card.width, height: card.height }; + setIsResizing(true); + const onMove = (ev: TouchEvent) => { + const tt = ev.touches[0]; + if (tt) applyResize(tt.clientX, tt.clientY); + }; + const onEnd = () => { + setIsResizing(false); + window.removeEventListener("touchmove", onMove); + window.removeEventListener("touchend", onEnd); + window.removeEventListener("touchcancel", onEnd); + }; + window.addEventListener("touchmove", onMove, { passive: true }); + window.addEventListener("touchend", onEnd); + window.addEventListener("touchcancel", onEnd); + }, + [card.width, card.height, applyResize], + ); + + const handleConnectionTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!e.touches[0]) return; + e.stopPropagation(); + onStartConnection(card.id, "center", card.x + card.width / 2, card.y + card.height / 2); + }, + [card.id, card.x, card.y, card.width, card.height, onStartConnection], + ); + const handleStartEditDescription = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setIsEditing(true); @@ -535,6 +680,7 @@ const BoardCard = ({ return (
{renderBody()} -
+
{/* Connection handle */} -
+
); }; diff --git a/components/dashboard/DashboardModal.module.css b/components/dashboard/DashboardModal.module.css index 0f426ff0..d2b4a371 100644 --- a/components/dashboard/DashboardModal.module.css +++ b/components/dashboard/DashboardModal.module.css @@ -153,3 +153,33 @@ .close_btn:hover { color: var(--primary-text); } + +/* Phone: the sidebar nav lives in the burger menu, so the modal drops it and + * becomes a full-screen content pane. Tabs are switched from the burger, which + * re-opens the dashboard on the chosen tab. */ +@media (max-width: 767px) { + .overlay { + backdrop-filter: none; + } + + .modal { + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + border-radius: 0; + } + + .sidebar { + display: none; + } + + .content { + flex: 1; + padding: calc(16px + var(--safe-top)) 16px calc(16px + var(--safe-bottom)); + } + + .scrollArea { + padding-right: 8px; + } +} diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index 8f2f0fc4..a7ea5b2c 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -1,17 +1,16 @@ "use client"; -import { useContext, useEffect, useMemo, useRef, useState, Suspense } from "react"; +import { useContext, useEffect, useRef, useState, Suspense } from "react"; import { DashboardContext } from "@src/context/DashboardContext"; -import { ProjectContext } from "@src/context/ProjectContext"; -import { useCookieUser } from "@src/lib/utils/hooks"; -import SidebarMenu, { MenuSection } from "./DashboardSidebar"; +import SidebarMenu from "./DashboardSidebar"; +import { useDashboardMenu } from "./useDashboardMenu"; import ProjectSettings from "./project/ProjectSettings"; import CollaboratorsSettings from "./project/CollaboratorsSettings"; import styles from "./DashboardModal.module.css"; import ExportProject from "./project/ExportProject"; -import { CreditCard, FileDown, Folder, Globe, HardDrive, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; +import { X } from "lucide-react"; import { useTranslations } from "next-intl"; import KeybindsSettings from "./preferences/KeybindsSettings"; import AppearanceSettings from "./preferences/AppearanceSettings"; @@ -26,55 +25,20 @@ import AboutSettings from "./AboutSettings"; const DashboardModal = () => { const { isOpen, closeDashboard, activeTab, setActiveTab } = useContext(DashboardContext); - const { project, isYjsReady } = useContext(ProjectContext); - const { user, isLoading: isUserLoading } = useCookieUser(); const t = useTranslations("modal"); - const PROJECT_MENU = useMemo(() => ({ - group: t("groups.project"), - items: [ - { id: "General", label: t("tabs.General"), icon: }, - { id: "Layout", label: t("tabs.Layout"), icon: }, - { id: "Production", label: t("tabs.Production"), icon: }, - { id: "Export", label: t("tabs.Export"), icon: }, - { id: "Storage", label: t("tabs.Storage"), icon: }, - { id: "Collaborators", label: t("tabs.Collaborators"), icon: }, - ], - }), [t]); + const { + structure: menuStructure, + projectMenu: PROJECT_MENU, + preferencesMenu: PREFERENCES_MENU, + accountMenu: ACCOUNT_MENU, + isInProject, + isSignedIn, + isUserLoading, + } = useDashboardMenu(); - const PREFERENCES_MENU = useMemo(() => ({ - group: t("groups.preferences"), - items: [ - { id: "Keybinds", label: t("tabs.Keybinds"), icon: }, - { id: "Appearance", label: t("tabs.Appearance"), icon: }, - { id: "Language", label: t("tabs.Language"), icon: }, - ], - }), [t]); - - const ACCOUNT_MENU = useMemo(() => ({ - group: t("groups.account"), - items: [ - { id: "Profile", label: t("tabs.Profile"), icon: }, - { id: "Subscription", label: t("tabs.Subscription"), icon: }, - ], - }), [t]); - - // We're in a project if either: - // - We have API membership data (cloud project), OR - // - Yjs is ready (local project on desktop without auth) - const isInProject = project !== null || isYjsReady; - const isSignedIn = !!user; const [dangerOpen, setDangerOpen] = useState(false); - // Build menu structure based on whether we're in a project context and signed in - const menuStructure = useMemo(() => { - const sections: MenuSection[] = []; - if (isInProject) sections.push(PROJECT_MENU); - sections.push(PREFERENCES_MENU); - if (isSignedIn) sections.push(ACCOUNT_MENU); - return sections; - }, [isInProject, isSignedIn, PROJECT_MENU, PREFERENCES_MENU, ACCOUNT_MENU]); - // Auto-switch active tab when the surrounding context changes: // - leave a project tab when there's no longer a project to talk about // - leave an account tab when the user signs out diff --git a/components/dashboard/project/ProductionSettings.module.css b/components/dashboard/project/ProductionSettings.module.css index ea66a6c8..327391eb 100644 --- a/components/dashboard/project/ProductionSettings.module.css +++ b/components/dashboard/project/ProductionSettings.module.css @@ -1,10 +1,20 @@ .styleOptions { display: grid; - grid-template-columns: 1fr 1fr; + /* minmax(0, 1fr) lets the tracks shrink below the cards' content width so + * the grid never forces horizontal overflow on a narrow screen. */ + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; } +/* Phone: the two style cards carry a label + example each, too wide to sit + * side by side — stack them. */ +@media (max-width: 767px) { + .styleOptions { + grid-template-columns: 1fr; + } +} + .styleName { font-size: 0.85rem; color: var(--secondary-text); @@ -33,7 +43,7 @@ .letterToggles { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 10px; } diff --git a/components/dashboard/useDashboardMenu.ts b/components/dashboard/useDashboardMenu.ts new file mode 100644 index 00000000..0f843b5f --- /dev/null +++ b/components/dashboard/useDashboardMenu.ts @@ -0,0 +1,86 @@ +"use client"; + +import { createElement, useContext, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { + CreditCard, + FileDown, + Folder, + Globe, + HardDrive, + Keyboard, + Lock, + Palette, + PanelsTopLeft, + User, + Users, +} from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { useCookieUser } from "@src/lib/utils/hooks"; +import type { MenuSection } from "./DashboardSidebar"; + +/** + * The dashboard's navigation structure, shared between the desktop sidebar + * ([DashboardModal]) and the phone burger menu ([ProjectNavbar]) so the two + * never drift. Sections are gated the same way the modal renders their content: + * project tabs only in a project, account tabs only when signed in. + */ +export const useDashboardMenu = () => { + const t = useTranslations("modal"); + const { project, isYjsReady } = useContext(ProjectContext); + const { user, isLoading: isUserLoading } = useCookieUser(); + + const projectMenu = useMemo( + () => ({ + group: t("groups.project"), + items: [ + { id: "General", label: t("tabs.General"), icon: createElement(Folder, { size: 18 }) }, + { id: "Layout", label: t("tabs.Layout"), icon: createElement(PanelsTopLeft, { size: 18 }) }, + { id: "Production", label: t("tabs.Production"), icon: createElement(Lock, { size: 18 }) }, + { id: "Export", label: t("tabs.Export"), icon: createElement(FileDown, { size: 18 }) }, + { id: "Storage", label: t("tabs.Storage"), icon: createElement(HardDrive, { size: 18 }) }, + { id: "Collaborators", label: t("tabs.Collaborators"), icon: createElement(Users, { size: 18 }) }, + ], + }), + [t], + ); + + const preferencesMenu = useMemo( + () => ({ + group: t("groups.preferences"), + items: [ + { id: "Keybinds", label: t("tabs.Keybinds"), icon: createElement(Keyboard, { size: 18 }) }, + { id: "Appearance", label: t("tabs.Appearance"), icon: createElement(Palette, { size: 18 }) }, + { id: "Language", label: t("tabs.Language"), icon: createElement(Globe, { size: 18 }) }, + ], + }), + [t], + ); + + const accountMenu = useMemo( + () => ({ + group: t("groups.account"), + items: [ + { id: "Profile", label: t("tabs.Profile"), icon: createElement(User, { size: 18 }) }, + { id: "Subscription", label: t("tabs.Subscription"), icon: createElement(CreditCard, { size: 18 }) }, + ], + }), + [t], + ); + + // We're in a project if either API membership exists (cloud) or Yjs is ready + // (local project on desktop without auth). + const isInProject = project !== null || isYjsReady; + const isSignedIn = !!user; + + const structure = useMemo(() => { + const sections: MenuSection[] = []; + if (isInProject) sections.push(projectMenu); + sections.push(preferencesMenu); + if (isSignedIn) sections.push(accountMenu); + return sections; + }, [isInProject, isSignedIn, projectMenu, preferencesMenu, accountMenu]); + + return { structure, projectMenu, preferencesMenu, accountMenu, isInProject, isSignedIn, isUserLoading }; +}; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 2415fa4b..ee541878 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -6,7 +6,7 @@ import { EditorContent } from "@tiptap/react"; import { applyElement, insertElement, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor"; import { ScreenplayElement } from "@src/lib/utils/enums"; -import { Eye } from "lucide-react"; +import { Eye, GripVertical } from "lucide-react"; import { useTranslations } from "next-intl"; import { DUAL_DIALOGUE_COLUMN } from "@src/lib/screenplay/nodes/dual-dialogue-column-node"; import { DEFAULT_ELEMENT_MARGINS, DEFAULT_ELEMENT_STYLES } from "@src/lib/project/project-state"; @@ -94,8 +94,25 @@ const DocumentEditorPanel = ({ const { user } = useUser(); const isPhone = useIsPhone(); + // Phones always render continuous (no discrete page rectangles): the compact + // "reading margins" applied on phone (--display-margin-scale in the CSS) reflow + // text to a different width than the canonical page, so the fixed-height page + // spacers would misalign. Page COUNT and numbering stay canonical — they are + // measured off-screen at full page width — so this is purely a visual mode. + const effectiveEndless = isEndlessScroll || isPhone; + const [isEditorReady, setIsEditorReady] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + // Phone-only draggable scroll handle: a fixed-size grab handle (styled like + // the sidebar edge toggles) that rides the right edge tracking scroll + // position, so it's easy to grab and drag the page up/down. Shown while + // actively scrolling (or being dragged) and faded out shortly after, so it + // never sits on top of the writing while at rest. + const [showScrollThumb, setShowScrollThumb] = useState(false); + const [thumbTop, setThumbTop] = useState(0); + const [canScrollThumb, setCanScrollThumb] = useState(false); + const scrollIdleTimer = useRef | null>(null); + const isDraggingThumb = useRef(false); // Callback ref stored in state so the zoom effect re-runs when the scroll // container actually mounts (it may render after a Loading fallback). const [containerEl, setContainerEl] = useState(null); @@ -159,8 +176,8 @@ const DocumentEditorPanel = ({ useEffect(() => { const el = editor?.view?.dom; if (!el) return; - el.classList.toggle("endless-scroll", isEndlessScroll); - }, [editor, isEndlessScroll]); + el.classList.toggle("endless-scroll", effectiveEndless); + }, [editor, effectiveEndless]); // Ready state useEffect(() => { @@ -170,34 +187,14 @@ const DocumentEditorPanel = ({ } }, [editor, isYjsReady]); - // ---- Fit-to-width auto-zoom (phone only) ---- - // Screenplay pages have a fixed physical width; on a narrow phone screen we - // scale the page down so its whole width fits, avoiding horizontal scrolling. - // The ratio is published as --editor-zoom and consumed via `zoom` in the phone - // media query; recomputed on container resize (rotation, split-screen, etc.). - useEffect(() => { - const container = containerEl; - if (!container) return; - - const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS]; - if (!isPhone || !pageSize) { - container.style.removeProperty("--editor-zoom"); - return; - } - - const apply = () => { - const avail = container.clientWidth; - if (!avail) return; - // Never upscale past 100%; leave a small horizontal breathing margin. - const ratio = Math.min(1, (avail - 8) / pageSize.pageWidth); - container.style.setProperty("--editor-zoom", `${ratio}`); - }; - - apply(); - const ro = new ResizeObserver(apply); - ro.observe(container); - return () => ro.disconnect(); - }, [containerEl, isPhone, pageFormat]); + // ---- Phone reading layout ---- + // Phones no longer scale the whole page down to fit (which shrank the text). + // Instead the CSS applies a compact --display-margin-scale to reflow text at + // full size into the viewport width (see the max-width:767px block in + // EditorPanel.module.css). Because that reflow uses a narrower measure than + // the canonical page, the discrete page rectangles can't line up, so phones + // render continuous (effectiveEndless above). Pagination itself is measured + // off-screen at full page width, so page count / numbering are unchanged. // ---- Orphaned comment cleanup ---- // Comments anchor to a node's data-id. When that node is deleted the comment @@ -709,12 +706,91 @@ const DocumentEditorPanel = ({ commentOps.setActiveNodeId(null); }, [commentOps]); + // Fixed handle height and inset of the track from the panel's top/bottom. + // (Keep HANDLE_HEIGHT in sync with .scroll_handle's height in the CSS.) + const HANDLE_HEIGHT = 44; + const TRACK_PAD = 6; + + // Recompute the handle's position from the container's scroll metrics: it's a + // fixed-size grab handle whose offset mirrors how far down we're scrolled. + const updateThumb = useCallback(() => { + const el = containerEl; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + const scrollable = scrollHeight - clientHeight; + if (scrollable <= 0) { + setCanScrollThumb(false); + return; + } + setCanScrollThumb(true); + const travel = Math.max(0, clientHeight - 2 * TRACK_PAD - HANDLE_HEIGHT); + setThumbTop((scrollTop / scrollable) * travel); + }, [containerEl]); + + // Reveal the thumb and (re)arm the timer that hides it once scrolling has + // been idle for a beat. While dragging, keep it pinned open (no auto-hide). + const revealScrollThumb = useCallback(() => { + setShowScrollThumb(true); + if (scrollIdleTimer.current) clearTimeout(scrollIdleTimer.current); + if (!isDraggingThumb.current) { + scrollIdleTimer.current = setTimeout(() => setShowScrollThumb(false), 1200); + } + }, []); + const onScroll = (e: React.UIEvent) => { if (suggestions.length > 0) updateSuggestions?.([]); const scrollTop = e.currentTarget.scrollTop; setIsScrolled(scrollTop > 0); + if (isPhone) { + updateThumb(); + revealScrollThumb(); + } }; + // Drag the thumb to scroll: map vertical pointer movement onto scrollTop via + // the same track/scrollable ratio used to size the thumb. + const onThumbPointerDown = useCallback( + (e: React.PointerEvent) => { + const el = containerEl; + if (!el) return; + e.preventDefault(); + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + isDraggingThumb.current = true; + revealScrollThumb(); + + const startY = e.clientY; + const startScrollTop = el.scrollTop; + const scrollable = el.scrollHeight - el.clientHeight; + const maxThumbTravel = el.clientHeight - 2 * TRACK_PAD - HANDLE_HEIGHT; + + const onMove = (ev: PointerEvent) => { + if (maxThumbTravel <= 0) return; + const delta = ev.clientY - startY; + const ratio = (delta / maxThumbTravel) * scrollable; + el.scrollTop = Math.max(0, Math.min(scrollable, startScrollTop + ratio)); + }; + const onUp = () => { + isDraggingThumb.current = false; + revealScrollThumb(); // re-arm the auto-hide now that the drag is done + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }, + [containerEl, revealScrollThumb], + ); + + // Clean up the idle timer on unmount. + useEffect(() => { + return () => { + if (scrollIdleTimer.current) clearTimeout(scrollIdleTimer.current); + }; + }, []); + const focusType = focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title"); @@ -748,7 +824,7 @@ const DocumentEditorPanel = ({ } >
)} -
+
@@ -782,6 +858,22 @@ const DocumentEditorPanel = ({ /> )}
+ {isPhone && canScrollThumb && ( +
+
+ +
+
+ )}
); }; diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index d08c2622..d6bd4596 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -43,6 +43,12 @@ box-sizing: border-box; } +/* Passthrough wrapper around the editor content; carries the context-menu + * handler and has no visual styling of its own. */ +.page_shift { + display: block; +} + .editor_shadow { position: sticky; top: 0; @@ -111,14 +117,83 @@ pointer-events: none; } +/* Draggable scroll handle (phone). A fixed-size grab handle riding the right + * edge — styled like the sidebar edge toggles — that tracks scroll position and + * can be grabbed to move the page. Revealed while scrolling (or dragging) and + * faded out at rest so it never disrupts writing. Its offset is set inline by + * DocumentEditorPanel; keep .scroll_handle's height in sync with HANDLE_HEIGHT. */ +.scroll_track { + position: absolute; + top: 6px; + bottom: 6px; + /* Right offset matches .right_sidebar_toggle (ProjectWorkspace) so the handle + * lines up vertically with the sidebar edge toggle. */ + right: 8px; + width: 28px; + z-index: 52; + pointer-events: none; + + opacity: 0; + visibility: hidden; + transition: + opacity 0.25s ease, + visibility 0.25s; +} + +.scroll_track_visible { + opacity: 1; + visibility: visible; +} + +.scroll_handle { + position: absolute; + top: 0; + right: 0; + width: 28px; + height: 44px; + + display: flex; + align-items: center; + justify-content: center; + + border-radius: 16px; + background-color: var(--secondary); + color: var(--secondary-text); + box-shadow: var(--panel-shadow); + + pointer-events: auto; + cursor: grab; + touch-action: none; + -webkit-tap-highlight-color: transparent; + /* Only background animates; the vertical offset is driven live via inline + * transform during scroll/drag, so it must not be transitioned. */ + transition: background-color 0.15s ease; +} + +.scroll_handle:active { + cursor: grabbing; + background-color: var(--secondary-hover); +} + /* Endless Scroll Mode Overrides */ .endless_scroll :global(.pagination-page-break) { display: none !important; } -/* Phone: full-bleed editor with fit-to-width auto-zoom. --editor-zoom is set by - * DocumentEditorPanel from the measured container width vs. the page width, so - * the whole page fits without horizontal scrolling. */ +/* Phone: reflow the screenplay into the viewport at full size instead of + * shrinking the whole page. The editor fills the screen width and the wide + * screenplay margins are compressed via --display-margin-scale (consumed by + * every element padding in scriptio.css), so text wraps to a narrow measure at + * real font size — much more readable than the old fit-to-page zoom. + * + * This is purely visual: the offscreen pagination measurement div + * (#pagination-test-div, appended to ) is not matched by the + * `.editor_wrapper` selector below, so it keeps --display-margin-scale: 1 and + * measures at the canonical page width. Page count, page numbers, scene + * numbering and PDF export are therefore unchanged from desktop. Phones also + * render continuous (endless-scroll) — see effectiveEndless in + * DocumentEditorPanel — because the reflowed text can't align to the fixed + * page rectangles. */ @media (max-width: 767px) { .container { -webkit-overflow-scrolling: touch; @@ -129,8 +204,71 @@ padding-bottom: 40%; } + /* width:100% !important overrides the pagination extension's injected + * `.pagination { width: var(--page-width) !important }` (this two-class + * selector has higher specificity, so !important vs !important resolves in + * our favour). --display-margin-scale drives the compact reading margins. */ .editor_wrapper :global(.ProseMirror) { - zoom: var(--editor-zoom, 1); + width: 100% !important; + --display-margin-scale: 0.3; + } + + /* Render each page boundary as a footer + gap + header band that carries the + * configured headers/footers (page numbers, title, etc.) at full height, just + * like desktop but without the page-fill whitespace. Un-hide the widget (the + * base `.endless_scroll .pagination-page-break { display:none }` rule collapses + * it in endless mode) and reset content-visibility so an off-screen band + * doesn't reserve a full canonical page height. */ + .editor_wrapper.endless_scroll :global(.pagination-page-break) { + display: block !important; + height: auto !important; + content-visibility: visible !important; + contain-intrinsic-size: auto !important; + } + + /* Drop only the freespace: the spacer/overlay are `freespace + marginBottom + + * gap + marginTop`. Collapsing them to `marginBottom + gap + marginTop` keeps + * the footer of the ending page, the inter-page gap, and the header of the + * next page at full height — so headers/footers show — while removing the + * blank page fill. */ + .editor_wrapper.endless_scroll :global(.pagination-page-break > .pagination-spacer), + .editor_wrapper.endless_scroll :global(.pagination-page-break > .pagination-overlay) { + height: calc(var(--page-margin-bottom) + var(--page-gap) + var(--page-margin-top)) !important; + /* Clip anything taller than the collapsed band — e.g. the orphan-locked + * "empty page" area, whose full page-height block would otherwise spill. */ + overflow: hidden !important; + } + + /* The last-page widget's spacer is `freespace + marginBottom`; collapse the + * freespace so the script ends with just its bottom margin (symmetric with the + * first page's top margin, no text stuck to the page's bottom edge). */ + .editor_wrapper.endless_scroll :global(.pagination-last-page) { + content-visibility: visible !important; + contain-intrinsic-size: auto !important; + } + + .editor_wrapper.endless_scroll :global(.pagination-last-page > .pagination-spacer), + .editor_wrapper.endless_scroll :global(.pagination-last-page > .pagination-overlay) { + height: var(--page-margin-bottom) !important; + } + + /* Header/footer text is inset by the page margins, which are set in the + * injected pagination stylesheet at their canonical (full-page) size. Scale + * that inset by --display-margin-scale so the header/footer fits the viewport + * exactly the way the screenplay content margins do. Applies to first-page, + * last-page and inter-page widgets alike. */ + .editor_wrapper.endless_scroll :global(.pagination-header-area), + .editor_wrapper.endless_scroll :global(.pagination-footer-area) { + padding-left: calc(var(--page-margin-left) * var(--display-margin-scale)) !important; + padding-right: calc(var(--page-margin-right) * var(--display-margin-scale)) !important; + } + + /* (MORE) / (CONT'D) overlays are positioned against the canonical text column, + * so they'd sit at the wrong offset once the text reflows — hide them (the + * straddling dialogue reads continuously across the band instead). */ + .editor_wrapper.endless_scroll :global(.page-more-overlay), + .editor_wrapper.endless_scroll :global(.page-contd-overlay) { + display: none !important; } .editor_shadow { diff --git a/components/editor/sidebar/DocumentTreeSidebarView.tsx b/components/editor/sidebar/DocumentTreeSidebarView.tsx index d0ed1be4..d5d39229 100644 --- a/components/editor/sidebar/DocumentTreeSidebarView.tsx +++ b/components/editor/sidebar/DocumentTreeSidebarView.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; import { useViewContext } from "@src/context/ViewContext"; +import { useIsPhone } from "@src/lib/utils/hooks"; import { DocumentNode } from "@src/lib/project/project-state"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; import { join } from "@src/lib/utils/misc"; @@ -23,7 +24,9 @@ const DocumentTreeSidebarView = () => { const t = useTranslations("editorSidebar"); const { documents, repository } = useContext(ProjectContext); const { updateContextMenu } = useContext(UserContext); - const { setSideDocument, closeDocument, primaryDocId, secondaryDocId } = useViewContext(); + const { setSideDocument, closeDocument, primaryDocId, secondaryDocId, setLeftSidebarOpen } = + useViewContext(); + const isPhone = useIsPhone(); // Documents currently open in a panel — highlighted in the tree. const openDocIds = useMemo( @@ -69,10 +72,17 @@ const DocumentTreeSidebarView = () => { const openDocument = useCallback( (node: DocumentNode) => { - if (node.type === "board") setSideDocument("secondary", node.id, "board"); - else if (node.type === "editor") setSideDocument("secondary", node.id, "document"); + // On phone only the primary side is ever rendered, so open documents + // there (the secondary side would be invisible, leaving no way to edit + // a freshly-created board/document); larger screens use the split side. + const side = isPhone ? "primary" : "secondary"; + if (node.type === "board") setSideDocument(side, node.id, "board"); + else if (node.type === "editor") setSideDocument(side, node.id, "document"); + else return; + // Close the tree drawer so the opened document is actually visible. + if (isPhone) setLeftSidebarOpen(false); }, - [setSideDocument], + [setSideDocument, isPhone, setLeftSidebarOpen], ); const createInside = useCallback( diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index 47d7685a..c9a25a41 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -45,7 +45,7 @@ .mobile_center { flex: 1; display: flex; - justify-content: center; + justify-content: flex-start; align-items: center; min-width: 0; height: calc(100% - 12px); diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index da9e6ead..dddde341 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -4,7 +4,6 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { ConnectionStatus } from "@src/lib/utils/enums"; import { useCookieUser, useIsPhone, useIsPro, useProjectIdFromUrl } from "@src/lib/utils/hooks"; -import { redirectHome } from "@src/lib/utils/redirects"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; @@ -13,6 +12,7 @@ import ProjectNavbarMobileMenu from "./ProjectNavbarMobileMenu"; import debounce from "debounce"; import { editProject } from "@src/lib/utils/requests"; import { join } from "@src/lib/utils/misc"; +import { redirectHome } from "@src/lib/utils/redirects"; import { uploadToCloudPopup } from "@src/lib/screenplay/popup"; import type { StorageUsage } from "@src/lib/assets/cloud-asset-sync"; import { DashboardContext } from "@src/context/DashboardContext"; @@ -23,16 +23,17 @@ import { CircleCheckBig, CloudUpload, History, + Info, + LogIn, Lock, Menu, Monitor, - PanelLeft, - PanelRight, Settings, WifiOff, WifiSync, } from "lucide-react"; import AnalyticsModal from "@components/analytics/AnalyticsModal"; +import { useDashboardMenu } from "@components/dashboard/useDashboardMenu"; import SavesPanel from "./SavesPanel"; import ProductionPanel from "./ProductionPanel"; import ReadAloudPanel from "./ReadAloudPanel"; @@ -194,11 +195,27 @@ const ProjectNavbar = () => { const isPhone = useIsPhone(); const { setLeftSidebarOpen, setRightSidebarOpen } = useViewContext(); + // Return to the projects list by clearing the ?projectId query. Use the same + // redirect() primitive that opens a project (ProjectItem → redirectScreenplay): + // in the Tauri static export a query-only router.replace("/projects") does not + // reliably re-render the layout back to the list (useSearchParams doesn't pick + // up the change), whereas redirect() — which successfully swaps projectId in the + // other direction — does. redirect() navigates cleanly from a client handler + // here (it's what ProjectItem relies on), so it works on web and native alike. + const backToProjects = () => redirectHome(); + const { isPro } = useIsPro(); const { user } = useCookieUser(); const projectId = useProjectIdFromUrl(); const t = useTranslations("navbar"); + const tModal = useTranslations("modal"); + const tSidebar = useTranslations("sidebar"); + + // The dashboard's nav lives in this burger on phone (the modal drops its + // sidebar there), so the same grouped tabs are rendered below and open the + // dashboard directly on the chosen tab. + const { structure: dashboardMenu, isSignedIn } = useDashboardMenu(); useEffect(() => { if (!projectId) { @@ -271,11 +288,6 @@ const ProjectNavbar = () => { return (