diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 91bfcd93..b0dee478 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -2,7 +2,7 @@ import { useContext, useRef, useState, useCallback, useEffect, useMemo } from "react"; import { ProjectContext } from "@src/context/ProjectContext"; -import { getBoardMap, BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; +import { BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; import BoardCard from "./BoardCard"; import styles from "./BoardCanvas.module.css"; import { v7 as uuidv7 } from "uuid"; @@ -36,9 +36,9 @@ interface ArrowContextMenuState { } const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { - const { repository, isYjsReady } = useContext(ProjectContext); + const { repository, isYjsReady, isReadOnly } = useContext(ProjectContext); const t = useTranslations("board"); - const ydoc = repository?.getState(); + const projectState = repository?.getState(); const containerRef = useRef(null); const canvasRef = useRef(null); @@ -123,9 +123,9 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { // Sync cards with Yjs useEffect(() => { - if (!ydoc || !isYjsReady) return; + if (!projectState || !isYjsReady) return; - const boardMap = getBoardMap(ydoc); + const boardMap = projectState.board(); const syncCards = () => { const cardsData = boardMap.get("cards"); @@ -176,26 +176,26 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { return () => { boardMap.unobserve(syncCards); }; - }, [ydoc, isYjsReady, centerCameraOnCards]); + }, [projectState, isYjsReady, centerCameraOnCards]); // Save cards to Yjs const saveCards = useCallback( (newCards: BoardCardData[]) => { - if (!ydoc || !isYjsReady) return; - const boardMap = getBoardMap(ydoc); + if (!projectState || !isYjsReady || isReadOnly) return; + const boardMap = projectState.board(); boardMap.set("cards", JSON.stringify(newCards)); }, - [ydoc, isYjsReady], + [projectState, isYjsReady, isReadOnly], ); // Save arrows to Yjs const saveArrows = useCallback( (newArrows: BoardArrowData[]) => { - if (!ydoc || !isYjsReady) return; - const boardMap = getBoardMap(ydoc); + if (!projectState || !isYjsReady || isReadOnly) return; + const boardMap = projectState.board(); boardMap.set("arrows", JSON.stringify(newArrows)); }, - [ydoc, isYjsReady], + [projectState, isYjsReady, isReadOnly], ); // Handle keyboard events for snapping diff --git a/components/dashboard/project/CollaboratorsSettings.tsx b/components/dashboard/project/CollaboratorsSettings.tsx index 286bf601..05f4b053 100644 --- a/components/dashboard/project/CollaboratorsSettings.tsx +++ b/components/dashboard/project/CollaboratorsSettings.tsx @@ -15,9 +15,7 @@ import styles from "./CollaboratorsSettings.module.css"; import { deleteInvite, inviteCollaborator, kickCollaborator, updateMemberRole } from "@src/lib/utils/requests"; import * as Roles from "@src/lib/utils/roles"; -import { ApiResponse } from "@src/lib/utils/api-utils"; import { DashboardContext } from "@src/context/DashboardContext"; -import { redirect } from "next/navigation"; import Link from "next/link"; const MAX_COLLABORATORS = 5; @@ -169,18 +167,17 @@ const MemberSlot = ({ data, membership, mutateCollaborators, user }: MemberSlotP const handleKick = async () => { const res = await kickCollaborator(membership.project.id, data.user.id); - - if (res.ok) { - if (res.status !== 204) { - // If user left the project by himself, redirect him to home - const json = (await res.json()) as ApiResponse<{ redirectUrl: string }>; - if (json.data && json.data.redirectUrl) { - closeDashboard(); - redirect(json.data.redirectUrl); - } - } else { - mutateCollaborators(); - } + if (!res.ok) return; + + if (isSelf) { + // Self-leave: the server has already deleted the membership and + // blacklisted the user on the WS, so a 4003 close is on its way. + // The cloud-sync hook surfaces ProjectUnavailableDialog from there, + // letting the leaver decide whether to keep a local copy or discard. + // We just close the dashboard so the dialog isn't covered. + closeDashboard(); + } else { + mutateCollaborators(); } }; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index d4d51123..cf5dada4 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -6,6 +6,8 @@ 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 { 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"; import { join } from "@src/lib/utils/misc"; @@ -59,6 +61,7 @@ const DocumentEditorPanel = ({ const projectCtx = useContext(ProjectContext); const { isYjsReady, + isReadOnly, selectedElement, setSelectedElement, setSelectedStyles, @@ -124,6 +127,17 @@ const DocumentEditorPanel = ({ }; }, [editor, onEditorCreated]); + // Read-only enforcement for VIEWER role. + // + // The server already drops doc writes from viewers (see protocol.ts), but + // disabling tiptap locally avoids a confusing "I typed but nothing + // happened" experience: keystrokes are blocked at the editor level and + // collaboration carets/awareness still render normally. + useEffect(() => { + if (!editor || editor.isDestroyed) return; + editor.setEditable(!isReadOnly); + }, [editor, isReadOnly]); + // Ready state useEffect(() => { if (editor && isYjsReady) { @@ -514,6 +528,15 @@ const DocumentEditorPanel = ({ const focusType = focusedTypeOverride ?? (config.type === "screenplay" ? "screenplay" : "title"); + const pageSize = SCREENPLAY_FORMATS[pageFormat as keyof typeof SCREENPLAY_FORMATS]; + const wrapperStyle = pageSize + ? ({ + "--page-width": `${pageSize.pageWidth}px`, + "--page-height": `${pageSize.pageHeight}px`, + } as React.CSSProperties) + : undefined; + + const t = useTranslations("navbar"); const isLocalAccess = isTauri() || isLocalOnly; if (!isLocalAccess && (!membership || isLoading)) return ; @@ -524,13 +547,30 @@ const DocumentEditorPanel = ({ onScroll={onScroll} onMouseDown={handleContainerMouseDown} onFocus={() => setFocusedEditorType(focusType)} + onPasteCapture={ + isReadOnly + ? (e) => { + e.preventDefault(); + e.stopPropagation(); + } + : undefined + } >
+ {isReadOnly && ( +
+
+ + {t("viewOnly")} +
+
+ )}
diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index eeade120..9d5221f8 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -24,6 +24,22 @@ contain: layout; } +/* Default page-shaped sizing for the ProseMirror element so it is correctly + * dimensioned from its very first paint. The pagination extension's onCreate + * fires asynchronously (Tiptap emits 'create' via setTimeout(0)), which would + * otherwise leave a gap where .ProseMirror exists without the .pagination + * class — stretching to the full wrapper width before snapping back to the + * page width. The wrapper sets --page-width / --page-height inline based on + * pageFormat, and the !important rule from the pagination extension's + * stylesheet still wins once the class is added (with the same value, so no + * visible change). */ +.editor_wrapper :global(.ProseMirror) { + width: var(--page-width); + min-height: var(--page-height); + margin: 0 auto; + box-sizing: border-box; +} + .editor_shadow { position: sticky; top: 0; @@ -40,7 +56,48 @@ transition: opacity 0.7s ease; background: linear-gradient(to bottom, var(--editor-shadow) 0%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 20%, + black 80%, + transparent 100% + ); +} + +/* Zero-height sticky container so the banner overlays the editor without + * shifting any content — overflow: visible lets the banner render beyond it. */ +.viewOnlyBannerWrapper { + position: sticky; + top: 0; + z-index: 51; + height: 0; + overflow: visible; + pointer-events: none; + width: 100%; +} + +/* Read-only banner shown to VIEWER role at the top of the editor scroll area. */ +.viewOnlyBanner { + pointer-events: auto; + width: fit-content; + margin: 0 auto; + + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding: 4px 14px; + border-radius: 0 0 12px 12px; + + background-color: var(--main-bg); + border-top: none; + color: var(--secondary-text); + font-size: 0.75rem; + font-weight: 500; + user-select: none; + cursor: default; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .show_shadow { diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 86b8a744..36a21ac2 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -107,7 +107,7 @@ export type SceneContextProps = { const SceneItemMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); const userCtx = useContext(UserContext); - const { editor } = useContext(ProjectContext); + const { editor, isReadOnly } = useContext(ProjectContext); const scene: Scene = props.scene; return ( @@ -121,12 +121,16 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { text={t("selectInEditor")} action={() => selectTextInEditor(editor!, scene.position, scene.nextPosition)} /> -
- editScenePopup(scene, userCtx)} /> - cutText(editor!, scene.position, scene.nextPosition)} - /> + {!isReadOnly && ( + <> +
+ editScenePopup(scene, userCtx)} /> + cutText(editor!, scene.position, scene.nextPosition)} + /> + + )} ); }; @@ -148,18 +152,22 @@ const CharacterItemMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); const userCtx = useContext(UserContext); const projectCtx = useContext(ProjectContext); - const { toggleCharacterHighlight } = projectCtx; + const { toggleCharacterHighlight, isReadOnly } = projectCtx; const character: CharacterData = props.character; return ( <> - editCharacterPopup(character, userCtx)} /> - deleteCharacter(character.name, projectCtx)} /> - pasteText(projectCtx.editor!, character.name)} - /> -
+ {!isReadOnly && ( + <> + editCharacterPopup(character, userCtx)} /> + deleteCharacter(character.name, projectCtx)} /> + pasteText(projectCtx.editor!, character.name)} + /> +
+ + )} ) => { const t = useTranslations("contextMenu"); const projectCtx = useContext(ProjectContext); + const { isReadOnly } = projectCtx; const location: LocationData = props.location; + if (isReadOnly) return null; + return ( <> deleteLocation(location.name, projectCtx)} /> @@ -212,7 +223,7 @@ export type EditorSelectionContextProps = { const EditorSelectionMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); const projectCtx = useContext(ProjectContext); - const { editor } = projectCtx; + const { editor, isReadOnly } = projectCtx; const { updateContextMenu } = useContext(UserContext); const { from, to, onAddComment } = props; const hasSelection = from !== to; @@ -225,7 +236,7 @@ const EditorSelectionMenu = ({ props }: SubMenuProps { - if (!editor) return; + if (!editor || isReadOnly) return; const text = editor.state.doc.textBetween(from, to, "\n"); await navigator.clipboard.writeText(text); editor.commands.deleteRange({ from, to }); @@ -233,7 +244,7 @@ const EditorSelectionMenu = ({ props }: SubMenuProps { - if (!editor) return; + if (!editor || isReadOnly) return; editor.commands.insertContent(await readClipboardText()); updateContextMenu(undefined); }; @@ -246,12 +257,16 @@ const EditorSelectionMenu = ({ props }: SubMenuProps {hasSelection && } - {hasSelection && } - + {hasSelection && !isReadOnly && } + {!isReadOnly && ( + + )} {hasSelection && ( <>
- + {!isReadOnly && ( + + )} )} @@ -345,10 +360,12 @@ const SpellcheckMenu = ({ props }: SubMenuProps) => { const DualDialogueMenu = ({ props }: SubMenuProps<{ pos: number }>) => { const t = useTranslations("contextMenu"); - const { editor } = useContext(ProjectContext); + const { editor, isReadOnly } = useContext(ProjectContext); const { updateContextMenu } = useContext(UserContext); const { pos } = props; + if (isReadOnly) return null; + return ( ) => { const ShelveNodeMenu = ({ props }: SubMenuProps<{ pos: number; nodeClass: string }>) => { const t = useTranslations("contextMenu"); - const { editor, repository } = useContext(ProjectContext); + const { editor, repository, isReadOnly } = useContext(ProjectContext); const { updateContextMenu } = useContext(UserContext); const { pos, nodeClass } = props; const handleShelve = () => { - if (!editor || !repository) return; + if (!editor || !repository || isReadOnly) return; const candidate = extractShelveCandidate(editor, pos); if (candidate) { repository.shelveNode(candidate.nodeId, candidate.title, candidate.type, candidate.content); @@ -406,6 +423,8 @@ const ShelveNodeMenu = ({ props }: SubMenuProps<{ pos: number; nodeClass: string ? t("shelveDialogue") : t("shelveAction"); + if (isReadOnly) return null; + return ( <> @@ -437,7 +456,7 @@ export type EditorContextMenuProps = { const EditorContextMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); - const { editor, repository } = useContext(ProjectContext); + const { editor, repository, isReadOnly } = useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); const { from, to, onAddComment, spellError, nodePos, nodeClass } = props; @@ -466,20 +485,20 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { }; const handleCut = async () => { - if (!editor) return; + if (!editor || isReadOnly) return; await navigator.clipboard.writeText(editor.state.doc.textBetween(from, to, "\n")); editor.commands.deleteRange({ from, to }); updateContextMenu(undefined); }; const handlePaste = async () => { - if (!editor) return; + if (!editor || isReadOnly) return; editor.commands.insertContent(await readClipboardText()); updateContextMenu(undefined); }; const handleSpellReplace = (suggestion: string) => { - if (!editor || !spellError) return; + if (!editor || !spellError || isReadOnly) return; const tr = editor.state.tr.replaceWith(spellError.from, spellError.to, editor.state.schema.text(suggestion)); editor.view.dispatch(tr); updateContextMenu(undefined); @@ -503,7 +522,7 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { }; const handleShelve = () => { - if (!editor || !repository || nodePos === undefined) return; + if (!editor || !repository || nodePos === undefined || isReadOnly) return; const candidate = extractShelveCandidate(editor, nodePos); if (candidate) { repository.shelveNode(candidate.nodeId, candidate.title, candidate.type, candidate.content); @@ -554,27 +573,33 @@ const EditorContextMenu = ({ props }: SubMenuProps) => {

{s}

))} - + {!isReadOnly && ( + + )}
)} - {/* Clipboard — always visible */} - + {/* Clipboard — copy is always visible; cut/paste hidden for viewers */} + {!isReadOnly && ( + + )} - + {!isReadOnly && } {/* Selection actions — only when there's a selection and no spellcheck error */} {hasSelection && !spellError && ( <>
- + {!isReadOnly && ( + + )} )} {/* Node actions — shelve and optional dual dialogue */} - {isShelvable && ( + {isShelvable && !isReadOnly && ( <>
{ setSelectedTitlePageElement, focusedEditorType, draftEditor, + isReadOnly, } = useContext(ProjectContext); const [isOpen, setIsOpen] = useState(false); @@ -89,6 +90,7 @@ const ScreenplayFormatDropdown = () => { const handleElementSelect = useCallback( (element: ScreenplayElement | TitlePageElement) => { + if (isReadOnly) return; if (isTitleContext) { setSelectedTitlePageElement(element as TitlePageElement); if (titlePageEditor) applyTitlePageElement(titlePageEditor, element as TitlePageElement); @@ -103,6 +105,7 @@ const ScreenplayFormatDropdown = () => { const toggleStyle = useCallback( (style: Style) => { + if (isReadOnly) return; setSelectedStyles((prev) => (prev ^ style) as Style); if (isTitleContext && titlePageEditor) { applyTitlePageMarkToggle(titlePageEditor, style); @@ -137,6 +140,7 @@ const ScreenplayFormatDropdown = () => { const setAlignment = useCallback( (align: string) => { + if (isReadOnly) return; setSelectedAlign(align); if (isTitleContext) { if (!titlePageEditor) return; @@ -208,7 +212,7 @@ const ScreenplayFormatDropdown = () => { {/* Element dropdown */}
-