From 70e782370b3d82cff9ba05360df52f074d488f48 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 7 Jul 2026 11:47:31 +0200 Subject: [PATCH 1/2] fixed mobile experience --- components/board/BoardCanvas.module.css | 29 ++ components/board/BoardCanvas.tsx | 345 ++++++++++++++++-- components/board/BoardCard.tsx | 211 +++++++++-- .../dashboard/DashboardModal.module.css | 30 ++ components/dashboard/DashboardModal.tsx | 62 +--- .../project/ProductionSettings.module.css | 14 +- components/dashboard/useDashboardMenu.ts | 86 +++++ components/editor/DocumentEditorPanel.tsx | 152 +++++++- components/editor/EditorPanel.module.css | 74 +++- .../sidebar/DocumentTreeSidebarView.tsx | 18 +- components/navbar/ProjectNavbar.module.css | 2 +- components/navbar/ProjectNavbar.tsx | 80 +++- .../navbar/ProjectNavbarMobileMenu.module.css | 13 +- components/navbar/ScreenplaySearch.module.css | 7 +- components/popup/Popup.module.css | 31 +- components/project/EditorFooter.module.css | 11 + .../project/ProjectWorkspace.module.css | 13 +- components/project/ProjectWorkspace.tsx | 2 +- .../project/SplitPanelContainer.module.css | 9 + components/project/SplitPanelContainer.tsx | 2 +- .../projects/ProjectPageContainer.module.css | 37 +- components/utils/ColorPicker.module.css | 11 + messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/ja.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + messages/zh.json | 1 + next.config.ts | 15 + package.json | 1 + src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 6 + .../apple/Scriptio.xcodeproj/project.pbxproj | 10 +- .../xcschemes/Scriptio_iOS.xcscheme | 26 +- src-tauri/gen/apple/Scriptio_iOS/Info.plist | 4 +- src-tauri/src/lib.rs | 31 ++ src-tauri/tauri.conf.json | 2 +- styles/globals.css | 7 - styles/scriptio.css | 9 - 41 files changed, 1152 insertions(+), 209 deletions(-) create mode 100644 components/dashboard/useDashboardMenu.ts 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..cdd50bf0 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"; @@ -96,6 +96,16 @@ const DocumentEditorPanel = ({ 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); @@ -171,8 +181,12 @@ 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. + // Screenplay pages have a fixed physical width, most of which is fixed margin + // whitespace. On a narrow phone that wastes the screen and shrinks the text, + // so instead of fitting the whole page we zoom to the *text column* (the + // widest element's margins = action) plus a little breathing margin. The page + // then overflows the viewport, but it is centred and `.container` clips the + // overflow (overflow-x: clip), so only a sliver of margin shows on each side. // 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(() => { @@ -182,15 +196,48 @@ const DocumentEditorPanel = ({ const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS]; if (!isPhone || !pageSize) { container.style.removeProperty("--editor-zoom"); + container.style.removeProperty("--editor-shift"); return; } + const KEEP_MARGIN_PX = 0.15 * 96; // ~0.15in of page margin kept visible each side + const apply = () => { const avail = container.clientWidth; if (!avail) return; + + // The action element carries the widest text column; its margins are + // set in inches (defaulting via CSS), so read + convert to px. + const pm = container.querySelector(".ProseMirror"); + const readInchVar = (name: string, fallbackPx: number) => { + if (!pm) return fallbackPx; + const inches = parseFloat(getComputedStyle(pm).getPropertyValue(name)); + return Number.isFinite(inches) ? inches * 96 : fallbackPx; + }; + const actionLeft = readInchVar("--action-l-margin", 144); + const actionRight = readInchVar("--action-r-margin", 96); + + const contentFit = pageSize.pageWidth - actionLeft - actionRight + 2 * KEEP_MARGIN_PX; + // Guard against unusual margin configs: only zoom to the content column + // when it's a sane fraction of the page; otherwise fall back to the + // whole page (which then fits and centres normally). + const zoomToContent = contentFit >= pageSize.pageWidth * 0.4 && contentFit < pageSize.pageWidth; + const fitWidth = zoomToContent ? contentFit : pageSize.pageWidth; + // Never upscale past 100%; leave a small horizontal breathing margin. - const ratio = Math.min(1, (avail - 8) / pageSize.pageWidth); + const ratio = Math.min(1, (avail - 8) / fitWidth); container.style.setProperty("--editor-zoom", `${ratio}`); + + // Zoomed to the content, the page is wider than the viewport. Auto + // margins DON'T centre an overflowing block — they collapse to 0 and + // left-align it, leaving the full left margin on screen. So translate + // the (un-zoomed) page layer left to slide the text column behind a + // small left margin; both margins then clip evenly. The shift is in + // *screen* px (already multiplied by the zoom ratio) so it's applied + // on the un-zoomed wrapper and doesn't depend on how the webview + // scales lengths inside a `zoom`ed subtree. + const shift = zoomToContent ? (KEEP_MARGIN_PX - actionLeft) * ratio : 0; + container.style.setProperty("--editor-shift", `${shift}px`); }; apply(); @@ -709,12 +756,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"); @@ -762,7 +888,7 @@ const DocumentEditorPanel = ({
)} -
+
@@ -782,6 +908,22 @@ const DocumentEditorPanel = ({ /> )}
+ {isPhone && canScrollThumb && ( +
+
+ +
+
+ )}
); }; diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index d08c2622..0720ee6a 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -111,14 +111,74 @@ 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: full-bleed editor zoomed to the text column. --editor-zoom and + * --editor-shift are set by DocumentEditorPanel: the page is scaled so the + * content (not the wide screenplay margins) fills the width, then shifted left + * so it centres on its content. The page overflows the viewport; .container's + * overflow-x: clip trims the margins to a small sliver each side. */ @media (max-width: 767px) { .container { -webkit-overflow-scrolling: touch; @@ -133,6 +193,14 @@ zoom: var(--editor-zoom, 1); } + /* Wraps the (zoomed) page but is itself un-zoomed, so this translate is a + * plain screen-pixel shift — reliable, unlike a negative margin on the + * zoomed page, which iOS WebKit leaves left-aligned. Slides the text column + * behind a small left margin; the overflow clips against .container. */ + .page_shift { + transform: translateX(var(--editor-shift, 0px)); + } + .editor_shadow { max-width: none; } 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..14d56992 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -1,10 +1,10 @@ "use client"; import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; 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"; @@ -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"; @@ -192,13 +193,27 @@ const ProjectNavbar = () => { const isLocalEdit = useRef(false); const isPhone = useIsPhone(); + const router = useRouter(); const { setLeftSidebarOpen, setRightSidebarOpen } = useViewContext(); + // Clear the ?projectId query to return to the projects list. Client-side + // (soft) navigation — a server redirect() throws NEXT_REDIRECT in an event + // handler and, in the iOS static export, a hard nav to /projects has no file + // to load (only projects.html; the dev-only rewrites don't apply). + const backToProjects = () => router.replace("/projects"); + 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 +286,6 @@ const ProjectNavbar = () => { return (