diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index 157042b..e943ad0 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -285,18 +285,24 @@ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); } -/* Zoom controls - matching navbar button style */ +/* Zoom controls - styled like the panel switcher buttons (secondary pill, no + border, secondary-hover on hover) and sitting in the same top toolbar row, + just to their right. The anchor is at top:8/left:8; the primary side has two + 20px buttons + 4px gap ending ~52px, so left:56px keeps a matching 4px gap. + z-index sits above the board's top shadow gradient (z-index:10). */ .zoom_controls { position: absolute; - bottom: 24px; - left: 24px; + top: 8px; + left: 56px; + z-index: 11; display: flex; align-items: center; - gap: 4px; - padding: 6px 12px; - border-radius: 64px; - border: 2px solid var(--tertiary); + gap: 2px; + height: 36px; + padding: 0 4px; + border-radius: 16px; background-color: var(--secondary); + color: var(--secondary-text); user-select: none; } @@ -304,36 +310,36 @@ display: flex; align-items: center; justify-content: center; - width: 28px; + width: 26px; height: 28px; border: none; - border-radius: 50%; + border-radius: 14px; background: transparent; - color: var(--primary-text); + color: var(--secondary-text); cursor: pointer; transition: background-color 0.15s ease; } .zoom_btn:hover { - background-color: var(--tertiary); + background-color: var(--secondary-hover); } .zoom_level { - min-width: 50px; + min-width: 38px; text-align: center; - font-size: 13px; - color: var(--primary-text); + font-size: 12px; font-weight: 500; + color: var(--secondary-text); } /* Hints in corner with low opacity */ .hints { position: absolute; bottom: 24px; - right: 24px; + left: 24px; display: flex; flex-direction: column; - align-items: flex-end; + align-items: flex-start; gap: 4px; font-size: 12px; color: var(--secondary-text); diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 9927b30..ad1ed28 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -989,11 +989,11 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
{Math.round(scale * 100)}%
diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 60a053a..0ebdd65 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -139,6 +139,16 @@ const DocumentEditorPanel = ({ editor.setEditable(!isReadOnly); }, [editor, isReadOnly]); + // Marker class on the editor DOM so global CSS (scriptio.css) can drop the + // first-of-page top-margin reset in endless-scroll mode. There the page-break + // widgets are hidden, so the reset would otherwise make each page's first + // node stick to the previous page's content. + useEffect(() => { + const el = editor?.view?.dom; + if (!el) return; + el.classList.toggle("endless-scroll", isEndlessScroll); + }, [editor, isEndlessScroll]); + // Ready state useEffect(() => { if (editor && isYjsReady) { diff --git a/components/editor/sidebar/DocumentTreeItem.tsx b/components/editor/sidebar/DocumentTreeItem.tsx index 4960f13..0c5ceae 100644 --- a/components/editor/sidebar/DocumentTreeItem.tsx +++ b/components/editor/sidebar/DocumentTreeItem.tsx @@ -161,7 +161,11 @@ const DocumentTreeItem = ({ ) : ( )} - + {isRenaming ? ( { (parentId: string | null, type: "folder" | "editor" | "board") => { if (!repository) return; if (parentId) setExpanded((prev) => new Set(prev).add(parentId)); - if (type === "folder") repository.createFolder(t("untitledFolder"), parentId); - else if (type === "board") repository.createBoardDocument(t("boardTitle"), parentId); - else repository.createEditorDocument(t("untitledDocument"), parentId); + let id: string; + if (type === "folder") id = repository.createFolder(t("untitledFolder"), parentId); + else if (type === "board") id = repository.createBoardDocument(t("boardTitle"), parentId); + else id = repository.createEditorDocument(t("untitledDocument"), parentId); + // Drop the new node straight into inline rename so the writer can name it instantly. + if (id) setRenamingId(id); }, [repository, t], ); diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index d10e97f..30118f7 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -10,7 +10,7 @@ display: flex; flex-direction: column; gap: 20px; - padding: 0 15px 30px 30px; + padding: 0 15px 20px 30px; width: var(--navigation-sidebar-width); min-height: 0; overflow: hidden; diff --git a/components/navbar/ProductionPanel.module.css b/components/navbar/ProductionPanel.module.css index 0de9f09..db74197 100644 --- a/components/navbar/ProductionPanel.module.css +++ b/components/navbar/ProductionPanel.module.css @@ -9,7 +9,9 @@ display: flex; flex-direction: column; border-radius: 16px; - overflow: hidden; + /* Not `hidden`: the revision dropdown menu in the last section needs to + extend past the panel's bottom edge without being clipped. */ + overflow: visible; } .header { @@ -162,19 +164,27 @@ cursor: not-allowed; } -/* Revision swatches */ -.swatches { - display: flex; - flex-wrap: wrap; - gap: 6px; +/* Revision color dropdown */ +.revision_select { margin-top: 10px; + padding: 8px 12px; + font-size: 0.85rem; + background: var(--secondary); + border: 1px solid var(--separator); + border-radius: 8px; + color: var(--primary-text); } -.swatch { - width: 18px; - height: 18px; +.revision_option { + display: flex; + align-items: center; + gap: 10px; +} + +.revision_dot { + width: 14px; + height: 14px; border-radius: 50%; border: 1px solid var(--separator); - opacity: 0.5; - cursor: not-allowed; + flex-shrink: 0; } diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index b2297d4..d00b815 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { X, BookOpen, Clapperboard, PencilLine, Settings } from "lucide-react"; @@ -12,6 +12,7 @@ import { computeSceneItems } from "@src/lib/screenplay/scenes"; import { unlockDraftPopup, unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; import { getPageAnchors, getPageAnchorInfo } from "@src/lib/screenplay/extensions/pagination-extension"; import Switch from "@components/utils/Switch"; +import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; import styles from "./ProductionPanel.module.css"; @@ -20,16 +21,18 @@ interface ProductionPanelProps { onClose: () => void; } -const REVISION_COLORS = [ - "#ffffff", // white - "#bbdfff", // blue - "#ffb6c1", // pink - "#ffea7a", // yellow - "#a5d6a7", // green - "#d4a017", // goldenrod - "#e0c58b", // buff - "#fa8072", // salmon - "#9b1c2a", // cherry +// Standard production revision color order. Names stay in English on purpose — +// they're surfaced verbatim in the printed page headers. +const REVISION_COLORS: { name: string; value: string }[] = [ + { name: "White", value: "#ffffff" }, + { name: "Blue", value: "#bbdfff" }, + { name: "Pink", value: "#ffb6c1" }, + { name: "Yellow", value: "#ffea7a" }, + { name: "Green", value: "#a5d6a7" }, + { name: "Goldenrod", value: "#d4a017" }, + { name: "Buff", value: "#e0c58b" }, + { name: "Salmon", value: "#fa8072" }, + { name: "Cherry", value: "#9b1c2a" }, ]; const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { @@ -52,6 +55,20 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const panelRef = useRef(null); + // Revisions are inert in v1: this only tracks the previewed color locally + // until revision tracking is wired to the repository. + const [revisionColor, setRevisionColor] = useState(REVISION_COLORS[0].name); + + const revisionOptions: DropdownOption[] = REVISION_COLORS.map((c) => ({ + value: c.name, + label: ( + + + {c.name} + + ), + })); + const handleOpenSettings = () => { onClose(); openDashboard("Production"); @@ -424,16 +441,12 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { {}} ariaLabel={t("revisions")} /> -
- {REVISION_COLORS.map((color, idx) => ( - - ))} -
+ ); diff --git a/components/navbar/ViewOptionsDropdown.tsx b/components/navbar/ViewOptionsDropdown.tsx index 4684915..78e77d8 100644 --- a/components/navbar/ViewOptionsDropdown.tsx +++ b/components/navbar/ViewOptionsDropdown.tsx @@ -1,16 +1,10 @@ "use client"; -import { useContext, useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; -import { UserContext } from "@src/context/UserContext"; import { PanelType, useViewContext } from "@src/context/ViewContext"; import { ChevronDown, - Scroll, - MessageSquare, - MessageSquareOff, - Maximize, - Minimize, PanelRight, PanelRightClose, ArrowLeftRight, @@ -22,14 +16,7 @@ import styles from "./ViewOptionsDropdown.module.css"; const ViewOptionsDropdown = () => { const t = useTranslations("navbar"); - const { isZenMode, updateIsZenMode } = useContext(UserContext); const { - isEndlessScroll, - setIsEndlessScroll, - showComments, - setShowComments, - setLeftSidebarOpen, - setRightSidebarOpen, isSplit, primaryPanel, setSecondaryPanel, @@ -50,7 +37,6 @@ const ViewOptionsDropdown = () => { }, [isSplit, primaryPanel, setSecondaryPanel]); const [isOpen, setIsOpen] = useState(false); - const sidebarsBeforeFocus = useRef<{ left: boolean; right: boolean } | null>(null); const dropdownRef = useRef(null); // Close dropdown when clicking outside @@ -67,41 +53,6 @@ const ViewOptionsDropdown = () => { return () => window.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); - const enterFocusMode = useCallback(() => { - setLeftSidebarOpen((prev) => { - setRightSidebarOpen((prevRight) => { - sidebarsBeforeFocus.current = { left: prev, right: prevRight }; - return false; - }); - return false; - }); - updateIsZenMode(true); - document.documentElement.requestFullscreen?.(); - }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]); - - const exitFocusMode = useCallback(() => { - updateIsZenMode(false); - if (document.fullscreenElement) { - document.exitFullscreen(); - } - if (sidebarsBeforeFocus.current) { - setLeftSidebarOpen(sidebarsBeforeFocus.current.left); - setRightSidebarOpen(sidebarsBeforeFocus.current.right); - sidebarsBeforeFocus.current = null; - } - }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]); - - // Sync zen mode state when user exits fullscreen via Escape - useEffect(() => { - const onFullscreenChange = () => { - if (!document.fullscreenElement && isZenMode) { - exitFocusMode(); - } - }; - document.addEventListener("fullscreenchange", onFullscreenChange); - return () => document.removeEventListener("fullscreenchange", onFullscreenChange); - }, [isZenMode, exitFocusMode]); - return (
- - - + + +
+ ); +}; + +export default EditorFooter; diff --git a/components/project/ProjectWorkspace.tsx b/components/project/ProjectWorkspace.tsx index de55bfe..05aa309 100644 --- a/components/project/ProjectWorkspace.tsx +++ b/components/project/ProjectWorkspace.tsx @@ -8,6 +8,7 @@ import ContextMenu from "@components/editor/sidebar/ContextMenu"; import SuggestionMenu, { SuggestionData } from "@components/editor/SuggestionMenu"; import { Popup } from "@components/popup/Popup"; import SplitPanelContainer from "./SplitPanelContainer"; +import EditorFooter from "./EditorFooter"; import styles from "./ProjectWorkspace.module.css"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -44,6 +45,9 @@ const ProjectWorkspace = () => {
setRightSidebarOpen((prev) => !prev)}> {rightSidebarOpen ? : }
+ + {/* Floating page-count + view-mode bubbles */} + {/* Right sidebar */} diff --git a/components/project/SplitPanelContainer.module.css b/components/project/SplitPanelContainer.module.css index 7911d9c..ef54dcf 100644 --- a/components/project/SplitPanelContainer.module.css +++ b/components/project/SplitPanelContainer.module.css @@ -33,6 +33,19 @@ border-radius: 4px; background-color: var(--editor-style-bg-hover); opacity: 0.5; + transition: + left 0.12s ease, + right 0.12s ease; +} + +/* Edge variants preview a split: the overlay shrinks to the half the dropped + document would occupy, hinting that the panel will divide rather than swap. */ +.panel_drop_overlay_left { + right: 50%; +} + +.panel_drop_overlay_right { + left: 50%; } /* Mounted-but-hidden panels: kept alive in the DOM so editors diff --git a/components/project/SplitPanelContainer.tsx b/components/project/SplitPanelContainer.tsx index 6ae9698..550b88d 100644 --- a/components/project/SplitPanelContainer.tsx +++ b/components/project/SplitPanelContainer.tsx @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; -import { UserContext } from "@src/context/UserContext"; -import { PanelType, SplitSide, useViewContext } from "@src/context/ViewContext"; +import { DocumentPanelKind, PanelType, SplitSide, useViewContext } from "@src/context/ViewContext"; +import { join } from "@src/lib/utils/misc"; import { DOC_DND_MIME } from "@components/editor/sidebar/DocumentTreeItem"; import EditorPanel from "@components/editor/EditorPanel"; import TitlePagePanel from "@components/editor/TitlePagePanel"; @@ -21,14 +21,9 @@ import { Clapperboard, FileText, ListTree, - Maximize, Menu, - MessageSquare, - MessageSquareOff, - Minimize, PanelRight, PanelRightClose, - Scroll, } from "lucide-react"; import styles from "./SplitPanelContainer.module.css"; import dropdown from "@components/navbar/ViewOptionsDropdown.module.css"; @@ -88,20 +83,14 @@ const SWITCHABLE_PANELS: { type: PanelType; icon: typeof Clapperboard; labelKey: const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; side: "primary" | "secondary" }) => { const t = useTranslations("navbar"); - const { isZenMode, updateIsZenMode } = useContext(UserContext); const { setSidePanel, isSplit, primaryPanel, setSecondaryPanel, swapPanels, - isEndlessScroll, - setIsEndlessScroll, - showComments, - setShowComments, leftSidebarOpen, setLeftSidebarOpen, - setRightSidebarOpen, } = useViewContext(); const handleSplitToggle = useCallback(() => { @@ -116,7 +105,6 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si }, [isSplit, primaryPanel, setSecondaryPanel]); const [isOpen, setIsOpen] = useState(false); const ref = useRef(null); - const sidebarsBeforeFocus = useRef<{ left: boolean; right: boolean } | null>(null); useEffect(() => { if (!isOpen) return; @@ -135,40 +123,6 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si [currentPanel, side, setSidePanel], ); - const enterFocusMode = useCallback(() => { - setLeftSidebarOpen((prev) => { - setRightSidebarOpen((prevRight) => { - sidebarsBeforeFocus.current = { left: prev, right: prevRight }; - return false; - }); - return false; - }); - updateIsZenMode(true); - document.documentElement.requestFullscreen?.(); - }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]); - - const exitFocusMode = useCallback(() => { - updateIsZenMode(false); - if (document.fullscreenElement) { - document.exitFullscreen(); - } - if (sidebarsBeforeFocus.current) { - setLeftSidebarOpen(sidebarsBeforeFocus.current.left); - setRightSidebarOpen(sidebarsBeforeFocus.current.right); - sidebarsBeforeFocus.current = null; - } - }, [updateIsZenMode, setLeftSidebarOpen, setRightSidebarOpen]); - - useEffect(() => { - const onFullscreenChange = () => { - if (!document.fullscreenElement && isZenMode) { - exitFocusMode(); - } - }; - document.addEventListener("fullscreenchange", onFullscreenChange); - return () => document.removeEventListener("fullscreenchange", onFullscreenChange); - }, [isZenMode, exitFocusMode]); - return (
{side === "primary" && ( @@ -213,34 +167,28 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si {t(labelKey as Parameters[0])} ))} -
- - -
)}
); }; +// Drop zone within a panel: "center" replaces the panel's content, while the +// "left"/"right" edges split it and open the document on that side. +type DropZone = "left" | "center" | "right"; + +// Fraction of the panel width on each side that counts as a split edge. +const SPLIT_EDGE_RATIO = 0.3; + +const computeDropZone = (e: React.DragEvent, allowSplit: boolean): DropZone => { + if (!allowSplit) return "center"; + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + if (x < rect.width * SPLIT_EDGE_RATIO) return "left"; + if (x > rect.width * (1 - SPLIT_EDGE_RATIO)) return "right"; + return "center"; +}; + const SplitPanelContainer = ({ suggestions, updateSuggestions, @@ -258,10 +206,13 @@ const SplitPanelContainer = ({ focusedSide, setFocusedSide, setSideDocument, + splitWithDocument, } = useViewContext(); - // Side currently highlighted while a document is dragged from the sidebar. - const [docDragOverSide, setDocDragOverSide] = useState(null); + // Where a document dragged from the sidebar would land: which side it is over + // and which zone of that side ("center" replaces the panel; "left"/"right" + // splits, opening the document on that edge). + const [docDragOver, setDocDragOver] = useState<{ side: SplitSide; zone: DropZone } | null>(null); // Capture-phase so the panel claims a document drop before the editor's own // drop handling sees it; non-document drags fall through untouched. @@ -270,9 +221,11 @@ const SplitPanelContainer = ({ if (!e.dataTransfer.types.includes(DOC_DND_MIME)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; - setDocDragOverSide((prev) => (prev === side ? prev : side)); + // Edge zones only split when there is room for a second panel. + const zone = computeDropZone(e, !isSplit); + setDocDragOver((prev) => (prev?.side === side && prev.zone === zone ? prev : { side, zone })); }, - [], + [isSplit], ); const handleDocDrop = useCallback( @@ -281,16 +234,22 @@ const SplitPanelContainer = ({ if (!raw) return; e.preventDefault(); e.stopPropagation(); - setDocDragOverSide(null); + const zone = computeDropZone(e, !isSplit); + setDocDragOver(null); let data: { id: string; type: "editor" | "board" }; try { data = JSON.parse(raw); } catch { return; } - setSideDocument(side, data.id, data.type === "board" ? "board" : "document"); + const kind: DocumentPanelKind = data.type === "board" ? "board" : "document"; + if (zone === "center") { + setSideDocument(side, data.id, kind); + } else { + splitWithDocument(data.id, kind, zone === "left" ? "primary" : "secondary"); + } }, - [setSideDocument], + [isSplit, setSideDocument, splitWithDocument], ); const gridStyle = useMemo(() => { @@ -318,7 +277,7 @@ const SplitPanelContainer = ({ }) => { const { keyId, panelKind, side, isPrimary, isVisible, content } = opts; const isFocused = isSplit && isVisible && focusedSide === side; - const isDocDropTarget = isVisible && docDragOverSide === side; + const dropZone = isVisible && docDragOver?.side === side ? docDragOver.zone : null; const panelClass = !isVisible ? styles.panel_hidden : `${styles.panel}${isFocused ? ` ${styles.panel_focused}` : ""}`; @@ -334,12 +293,20 @@ const SplitPanelContainer = ({ onDragLeave={ isVisible ? (e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setDocDragOverSide(null); + if (!e.currentTarget.contains(e.relatedTarget as Node)) setDocDragOver(null); } : undefined } > - {isDocDropTarget &&
} + {dropZone && ( +
+ )} {isVisible && } {content}
diff --git a/components/project/WritingTimer.module.css b/components/project/WritingTimer.module.css new file mode 100644 index 0000000..46b4f7b --- /dev/null +++ b/components/project/WritingTimer.module.css @@ -0,0 +1,223 @@ +.anchor { + position: relative; + display: flex; + align-items: center; + gap: 6px; +} + +.time { + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--secondary-text); + min-width: 38px; + text-align: center; +} + +.time_done { + color: var(--primary-text); + font-weight: 600; +} + +/* Popover opens above the footer pill. */ +.panel { + position: absolute; + bottom: calc(100% + 10px); + right: 0; + z-index: 20; + width: 220px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + background-color: var(--secondary); + border: 1px solid var(--separator); + border-radius: 14px; + box-shadow: var(--panel-shadow); + color: var(--secondary-text); + cursor: default; +} + +.title { + font-size: 12px; + font-weight: 600; + color: var(--primary-text); +} + +.tabs { + display: flex; + gap: 4px; + padding: 3px; + background-color: var(--main-bg); + border-radius: 8px; +} + +.tab { + flex: 1; + padding: 5px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--secondary-text); + font-size: 11px; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.tab:hover:not(:disabled) { + color: var(--primary-text); +} + +.tab_active { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +.tab:disabled { + cursor: default; + opacity: 0.5; +} + +/* Big time flanked by up/down minute steppers (countdown setup only). */ +.stepper_group { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.stepper { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 20px; + border: none; + border-radius: 8px; + background-color: var(--main-bg); + color: var(--secondary-text); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.stepper:hover { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +/* The display and its edit input occupy an identical fixed box so swapping + between them (on click) causes no layout shift: same width, height, border + (the display's is transparent), font, and box-sizing. A fixed height avoids + the input's intrinsic font-based height differing from the div's. */ +.display, +.display_input { + box-sizing: border-box; + width: 130px; + height: 44px; + padding: 0; + font-family: inherit; + font-size: 30px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 1px; + text-align: center; + color: var(--primary-text); + border: 1px solid transparent; + border-radius: 8px; +} + +/* Center the div text to match the input's intrinsic vertical centering. */ +.display { + display: flex; + align-items: center; + justify-content: center; +} + +.display_done { + color: var(--tertiary-hover); +} + +/* Click-to-edit affordance on the countdown display. */ +.display_editable { + cursor: text; + transition: background-color 0.15s ease; +} + +.display_editable:hover { + background-color: var(--main-bg); +} + +.display_input { + background-color: var(--main-bg); + border-color: var(--tertiary-hover); + outline: none; +} + +.presets { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip { + flex: 1 1 calc(25% - 6px); + padding: 6px 0; + border: 1px solid var(--separator); + border-radius: 8px; + background: transparent; + color: var(--secondary-text); + font-size: 11px; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease, + border-color 0.15s ease; +} + +.chip:hover { + color: var(--primary-text); + border-color: var(--tertiary-hover); +} + +.chip_active { + background-color: var(--secondary-hover); + border-color: var(--tertiary-hover); + color: var(--primary-text); +} + +.controls { + display: flex; + gap: 8px; +} + +.btn { + flex: 1; + padding: 8px 0; + border: none; + border-radius: 9px; + background-color: var(--main-bg); + color: var(--primary-text); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: + opacity 0.15s ease, + background-color 0.15s ease; +} + +.btn:hover { + background-color: var(--secondary-hover); +} + +.btn_primary { + background-color: var(--tertiary-hover); + color: var(--primary-text); +} + +.btn_primary:hover { + opacity: 0.9; + background-color: var(--tertiary-hover); +} diff --git a/components/project/WritingTimer.tsx b/components/project/WritingTimer.tsx new file mode 100644 index 0000000..2fa0dfd --- /dev/null +++ b/components/project/WritingTimer.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ChevronDown, ChevronUp, Timer } from "lucide-react"; +import { join } from "@src/lib/utils/misc"; + +import styles from "./WritingTimer.module.css"; + +type TimerMode = "countdown" | "stopwatch"; + +const PRESETS = [10, 15, 25, 45]; +// Minutes added/removed per stepper click, and the allowed duration range (sec). +const STEP = 5; +const MIN_SECONDS = 30; +const MAX_SECONDS = 180 * 60; + +const clampSeconds = (sec: number) => Math.min(MAX_SECONDS, Math.max(MIN_SECONDS, Math.round(sec))); + +/** Parse a manually typed duration: "M", "M:SS", or "H:MM:SS". Returns total + * seconds, or null if unparseable. A bare number is read as minutes. */ +const parseClock = (raw: string): number | null => { + const s = raw.trim(); + if (!s) return null; + const parts = s.split(":"); + if (parts.length > 3) return null; + const nums = parts.map((p) => Number(p)); + if (nums.some((n) => !Number.isFinite(n) || n < 0)) return null; + if (nums.length === 1) return nums[0] * 60; + if (nums.length === 2) return nums[0] * 60 + nums[1]; + return nums[0] * 3600 + nums[1] * 60 + nums[2]; +}; + +/** Gentle two-note chime when a countdown completes. Synthesised so no audio + * asset is needed; failures (autoplay policy, no WebAudio) are ignored. */ +const playChime = () => { + try { + const Ctx = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctx) return; + const ctx = new Ctx(); + const now = ctx.currentTime; + [880, 1175].forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.value = freq; + const t0 = now + i * 0.18; + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(0.2, t0 + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.35); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t0); + osc.stop(t0 + 0.4); + }); + setTimeout(() => ctx.close().catch(() => {}), 1000); + } catch { + // Audio is a nice-to-have; ignore failures. + } +}; + +const formatClock = (ms: number, roundUp: boolean): string => { + const totalSec = Math.max(0, roundUp ? Math.ceil(ms / 1000) : Math.floor(ms / 1000)); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; +}; + +interface WritingTimerProps { + /** Shared footer action-button classes, so the trigger matches the others. */ + triggerClassName: string; + triggerActiveClassName: string; +} + +/** + * Writing-session timer: a footer icon that opens a popover to run either a + * countdown (set a duration, get a chime at zero) or a count-up stopwatch. + * Timer state is local and ephemeral — it lives as long as the footer is + * mounted but is not persisted across reloads. + */ +const WritingTimer = ({ triggerClassName, triggerActiveClassName }: WritingTimerProps) => { + const t = useTranslations("timer"); + + const [open, setOpen] = useState(false); + const [mode, setMode] = useState("countdown"); + const [durationSec, setDurationSec] = useState(25 * 60); + const [running, setRunning] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); + const [completed, setCompleted] = useState(false); + // Manual editing of the countdown duration via the display field. + const [editing, setEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + + const anchorRef = useRef(null); + // Timestamp such that elapsed = Date.now() - startTsRef while running. + const startTsRef = useRef(0); + + const targetMs = durationSec * 1000; + const isIdle = !running && elapsedMs === 0 && !completed; + const isActive = running || elapsedMs > 0; + // Duration is only adjustable before a countdown starts. + const canSetDuration = mode === "countdown" && isIdle; + const displayMs = mode === "countdown" ? Math.max(0, targetMs - elapsedMs) : elapsedMs; + const clock = formatClock(displayMs, mode === "countdown"); + + // Tick while running. + useEffect(() => { + if (!running) return; + const id = setInterval(() => setElapsedMs(Date.now() - startTsRef.current), 250); + return () => clearInterval(id); + }, [running]); + + // Countdown completion. + useEffect(() => { + if (mode === "countdown" && running && elapsedMs >= targetMs) { + setRunning(false); + setElapsedMs(targetMs); + setCompleted(true); + playChime(); + } + }, [mode, running, elapsedMs, targetMs]); + + // Close the popover on outside click. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (anchorRef.current && !anchorRef.current.contains(e.target as Node)) { + setOpen(false); + setEditing(false); + } + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [open]); + + const start = () => { + setEditing(false); + setCompleted(false); + startTsRef.current = Date.now() - elapsedMs; + setRunning(true); + }; + const pause = () => setRunning(false); + const reset = () => { + setEditing(false); + setRunning(false); + setElapsedMs(0); + setCompleted(false); + }; + const adjustDuration = (deltaMin: number) => + setDurationSec((s) => clampSeconds(s + deltaMin * 60)); + + const selectMode = (next: TimerMode) => { + if (next === mode) return; + reset(); + setMode(next); + }; + + const toggleOpen = () => { + setOpen((o) => !o); + setEditing(false); + }; + + const startEdit = () => { + setEditValue(formatClock(durationSec * 1000, false)); + setEditing(true); + }; + const commitEdit = () => { + const parsed = parseClock(editValue); + if (parsed !== null) setDurationSec(clampSeconds(parsed)); + setEditing(false); + }; + + return ( +
+ + + {isActive && {clock}} + + {open && ( +
+ {t("title")} + +
+ + +
+ +
+ {canSetDuration && ( + + )} + {editing ? ( + setEditValue(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(); + else if (e.key === "Escape") setEditing(false); + }} + /> + ) : ( +
+ {clock} +
+ )} + {canSetDuration && ( + + )} +
+ + {canSetDuration && ( +
+ {PRESETS.map((m) => ( + + ))} +
+ )} + +
+ {running ? ( + + ) : isActive && !completed ? ( + + ) : !completed ? ( + + ) : null} + {isActive && ( + + )} +
+
+ )} +
+ ); +}; + +export default WritingTimer; diff --git a/messages/de.json b/messages/de.json index 20a69cd..5cd3936 100644 --- a/messages/de.json +++ b/messages/de.json @@ -26,7 +26,18 @@ "swapPanels": "Panels tauschen", "draftEditor": "Entwurfseditor", "viewOnly": "Nur ansehen", - "viewOnlyHint": "Sie haben Lesezugriff. Die Bearbeitung ist deaktiviert." + "viewOnlyHint": "Sie haben Lesezugriff. Die Bearbeitung ist deaktiviert.", + "pageCount": "{count, plural, one {# Seite} other {# Seiten}}" + }, + "timer": { + "title": "Schreibsitzung", + "countdown": "Countdown", + "stopwatch": "Stoppuhr", + "start": "Starten", + "pause": "Pause", + "resume": "Fortsetzen", + "reset": "Zurücksetzen", + "minutesShort": "Min" }, "common": { "save": "Änderungen speichern", diff --git a/messages/en.json b/messages/en.json index 6dcdfed..c72afd3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -25,7 +25,18 @@ "swapPanels": "Swap Panels", "draftEditor": "Draft Editor", "viewOnly": "View only", - "viewOnlyHint": "You have viewer access. Editing is disabled." + "viewOnlyHint": "You have viewer access. Editing is disabled.", + "pageCount": "{count, plural, one {# page} other {# pages}}" + }, + "timer": { + "title": "Writing session", + "countdown": "Countdown", + "stopwatch": "Stopwatch", + "start": "Start", + "pause": "Pause", + "resume": "Resume", + "reset": "Reset", + "minutesShort": "min" }, "common": { "save": "Save", diff --git a/messages/es.json b/messages/es.json index 449d2c7..d212320 100644 --- a/messages/es.json +++ b/messages/es.json @@ -25,7 +25,18 @@ "swapPanels": "Intercambiar paneles", "draftEditor": "Editor de borrador", "viewOnly": "Solo lectura", - "viewOnlyHint": "Tienes acceso de solo visualización. La edición está desactivada." + "viewOnlyHint": "Tienes acceso de solo visualización. La edición está desactivada.", + "pageCount": "{count, plural, one {# página} other {# páginas}}" + }, + "timer": { + "title": "Sesión de escritura", + "countdown": "Cuenta atrás", + "stopwatch": "Cronómetro", + "start": "Iniciar", + "pause": "Pausar", + "resume": "Reanudar", + "reset": "Reiniciar", + "minutesShort": "min" }, "common": { "save": "Guardar cambios", diff --git a/messages/fr.json b/messages/fr.json index 7e11c07..df70cb1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -26,7 +26,18 @@ "swapPanels": "Inverser les panneaux", "draftEditor": "Éditeur de brouillon", "viewOnly": "Vue seule", - "viewOnlyHint": "Vous avez un accès visiteur. La modification est désactivée." + "viewOnlyHint": "Vous avez un accès visiteur. La modification est désactivée.", + "pageCount": "{count, plural, one {# page} other {# pages}}" + }, + "timer": { + "title": "Session d'écriture", + "countdown": "Compte à rebours", + "stopwatch": "Chronomètre", + "start": "Démarrer", + "pause": "Pause", + "resume": "Reprendre", + "reset": "Réinitialiser", + "minutesShort": "min" }, "common": { "save": "Enregistrer", diff --git a/messages/ja.json b/messages/ja.json index 039fc42..02b93ce 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -25,7 +25,18 @@ "swapPanels": "パネルを入れ替え", "draftEditor": "下書きエディター", "viewOnly": "閲覧のみ", - "viewOnlyHint": "閲覧者アクセスです。編集は無効になっています。" + "viewOnlyHint": "閲覧者アクセスです。編集は無効になっています。", + "pageCount": "{count}ページ" + }, + "timer": { + "title": "執筆セッション", + "countdown": "カウントダウン", + "stopwatch": "ストップウォッチ", + "start": "開始", + "pause": "一時停止", + "resume": "再開", + "reset": "リセット", + "minutesShort": "分" }, "common": { "save": "変更を保存", diff --git a/messages/ko.json b/messages/ko.json index 9b4336b..8d464cf 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -25,7 +25,18 @@ "swapPanels": "패널 교체", "draftEditor": "초안 편집기", "viewOnly": "보기 전용", - "viewOnlyHint": "뷰어 권한이 있습니다. 편집이 비활성화되었습니다." + "viewOnlyHint": "뷰어 권한이 있습니다. 편집이 비활성화되었습니다.", + "pageCount": "{count}페이지" + }, + "timer": { + "title": "글쓰기 세션", + "countdown": "카운트다운", + "stopwatch": "스톱워치", + "start": "시작", + "pause": "일시정지", + "resume": "계속", + "reset": "초기화", + "minutesShort": "분" }, "common": { "save": "변경사항 저장", diff --git a/messages/pl.json b/messages/pl.json index f5bf623..d91a6f9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -25,7 +25,18 @@ "swapPanels": "Zamień panele", "draftEditor": "Edytor szkiców", "viewOnly": "Tylko podgląd", - "viewOnlyHint": "Masz dostęp gościa. Edytowanie jest wyłączone." + "viewOnlyHint": "Masz dostęp gościa. Edytowanie jest wyłączone.", + "pageCount": "{count, plural, one {# strona} few {# strony} other {# stron}}" + }, + "timer": { + "title": "Sesja pisania", + "countdown": "Odliczanie", + "stopwatch": "Stoper", + "start": "Start", + "pause": "Pauza", + "resume": "Wznów", + "reset": "Resetuj", + "minutesShort": "min" }, "common": { "save": "Zapisz zmiany", diff --git a/messages/zh.json b/messages/zh.json index d4153cc..3bd3101 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -25,7 +25,18 @@ "swapPanels": "交换面板", "draftEditor": "草稿编辑器", "viewOnly": "仅查看", - "viewOnlyHint": "您拥有访客权限,编辑功能已禁用。" + "viewOnlyHint": "您拥有访客权限,编辑功能已禁用。", + "pageCount": "{count} 页" + }, + "timer": { + "title": "写作计时", + "countdown": "倒计时", + "stopwatch": "秒表", + "start": "开始", + "pause": "暂停", + "resume": "继续", + "reset": "重置", + "minutesShort": "分钟" }, "common": { "save": "保存", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 6dd2490..5229e0c 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -17,6 +17,7 @@ import { PersistentPageMap } from "@src/lib/screenplay/page-locking"; import { ProjectMembershipPayload } from "@src/server/repository/project-repository"; import { ProjectRole } from "@src/generated/client/browser"; import { useUser } from "@src/lib/utils/hooks"; +import { getCloudToken } from "@src/lib/utils/requests"; import { CollaboratorInfo, ConnectionStatus, @@ -446,14 +447,11 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setProject((prev) => (prev ? { ...prev, role: newRole as ProjectRole } : prev)); if (project?.project.id) { try { - const res = await fetch(`/api/projects/${project.project.id}/cloud-token`); - if (res.ok) { - const { token } = (await res.json()) as { token: string }; - if (token) { - // Update token silently so future reconnects use the new role - // We don't force reconnect because the DO already updated our active session - await provider.updateToken(token, false); - } + const { token } = await getCloudToken(project.project.id); + if (token) { + // Update token silently so future reconnects use the new role. + // We don't force reconnect because the DO already updated our active session. + await provider.updateToken(token, false); } } catch (e) { console.warn("Failed to fetch new token on role change", e); diff --git a/src/context/ViewContext.tsx b/src/context/ViewContext.tsx index 520f3e8..ef08bbe 100644 --- a/src/context/ViewContext.tsx +++ b/src/context/ViewContext.tsx @@ -32,6 +32,12 @@ interface ViewContextType { setSidePanel: (side: SplitSide, panel: PanelType) => void; /** Open a specific document (board/editor) on a given side and focus it. */ setSideDocument: (side: SplitSide, docId: string, kind: DocumentPanelKind) => void; + /** + * Split the single panel and open a document on one side, keeping the + * currently-shown panel on the other. `side` is where the new document + * goes. Assumes the view is not already split. + */ + splitWithDocument: (docId: string, kind: DocumentPanelKind, side: SplitSide) => void; /** Clear a document from any side currently showing it (e.g. after delete). */ closeDocument: (docId: string) => void; swapPanels: () => void; @@ -194,6 +200,25 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { setFocusedSideState(side); }, []); + const splitWithDocument = useCallback( + (docId: string, kind: DocumentPanelKind, side: SplitSide) => { + if (side === "primary") { + // New document takes the left; the existing panel slides right. + setSecondaryPanelState(primaryPanel); + setSecondaryDocId(primaryDocId); + setPrimaryPanelState(kind); + setPrimaryDocId(docId); + setFocusedSideState("primary"); + } else { + // Existing panel stays on the left; new document opens on the right. + setSecondaryPanelState(kind); + setSecondaryDocId(docId); + setFocusedSideState("secondary"); + } + }, + [primaryPanel, primaryDocId], + ); + const closeDocument = useCallback((docId: string) => { setPrimaryDocId((prev) => (prev === docId ? null : prev)); setSecondaryDocId((prev) => (prev === docId ? null : prev)); @@ -231,6 +256,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { setFocusedPanel, setSidePanel, setSideDocument, + splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, @@ -238,7 +264,7 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { setLeftSidebarOpen, setRightSidebarOpen, }), - [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments], + [primaryPanel, secondaryPanel, primaryDocId, secondaryDocId, splitRatio, isSplit, visiblePanels, mountedPanels, focusedSide, focusedPanel, isEndlessScroll, showComments, leftSidebarOpen, rightSidebarOpen, setPrimaryPanel, setSecondaryPanel, setFocusedSide, setFocusedPanel, setSidePanel, setSideDocument, splitWithDocument, closeDocument, swapPanels, setIsEndlessScroll, setShowComments], ); return {children}; diff --git a/styles/scriptio.css b/styles/scriptio.css index 2c7ae64..9e2d0fe 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -73,8 +73,15 @@ (0) collapses with the next paragraph's margin-top (16) and leaves a wasted 16px gap at the top of every page. Pagination's break decisions use measured outer heights in isolation, so removing the margin only - affects rendering — same paragraphs per page, no determinism hit. */ - > .pagination-page-break + p, + affects rendering — same paragraphs per page, no determinism hit. + + In endless-scroll mode the page-break widgets are hidden (display:none), + so there is no gap to collapse and zeroing the margin would make each + page's first node stick to the previous page's content. The + `:not(.endless-scroll)` guard skips it there so the node keeps its + natural top margin. The first-page widget is still shown in endless + scroll, so its following paragraph keeps the reset. */ + &:not(.endless-scroll) > .pagination-page-break + p, > .pagination-first-page + p { margin-top: 0 !important; }