diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index af8ddc56..a9bd10a2 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -420,6 +420,25 @@ font-variant-numeric: tabular-nums; } +.asset_error { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 12; + display: flex; + align-items: center; + gap: 8px; + max-width: 80%; + height: 36px; + padding: 0 14px; + border-radius: 16px; + background-color: var(--error); + color: #fff; + font-size: 13px; + user-select: none; +} + .recording_stop { display: flex; align-items: center; diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index e9598ab1..360ad865 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -15,7 +15,8 @@ import { v7 as uuidv7 } from "uuid"; import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react"; import { useTranslations } from "next-intl"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; -import { importImageFile, importAudioFile } from "@src/lib/assets/asset-store"; +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 { useAudioRecorder } from "./use-audio-recorder"; @@ -56,6 +57,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const [isPanning, setIsPanning] = useState(false); const [isDraggingFile, setIsDraggingFile] = useState(false); const [isSnapping, setIsSnapping] = useState(true); + /** Transient banner shown when an asset can't be saved (e.g. cloud quota). */ + const [assetError, setAssetError] = useState(null); + const assetErrorTimer = useRef | null>(null); const recorder = useAudioRecorder(); const [prevIsVisible, setPrevIsVisible] = useState(isVisible); if (prevIsVisible !== isVisible) { @@ -527,6 +531,50 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false); }, []); + // Show a transient error banner (auto-dismissed). Used when an asset can't be + // persisted, e.g. the owner is out of cloud storage. + const showAssetError = useCallback((message: string) => { + setAssetError(message); + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + assetErrorTimer.current = setTimeout(() => setAssetError(null), 4000); + }, []); + + useEffect(() => () => { + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + }, []); + + // Remove cards by id (used to roll back a card whose asset can't be saved). + const removeCards = useCallback( + (ids: Set) => { + const next = cardsRef.current.filter((c) => !ids.has(c.id)); + cardsRef.current = next; // keep the ref current so concurrent removals don't race + setCards(next); + saveCards(next); + }, + [saveCards], + ); + + // Upload the new cards' assets to the cloud in the background, so the cards + // appear instantly (the bytes are already cached locally and render offline). + // If an upload is rejected for quota, roll back that card and explain why. + const syncCreatedAssets = useCallback( + (createdCards: BoardCardData[], pid: string) => { + for (const card of createdCards) { + if (!card.assetId) continue; + const cardId = card.id; + void syncAssetToCloud(pid, card.assetId).catch((err) => { + if (err instanceof CloudQuotaError) { + removeCards(new Set([cardId])); + showAssetError(t("storageLimitReached")); + } else { + console.error("[BoardCanvas] cloud asset upload failed:", err); + } + }); + } + }, + [removeCards, showAssetError, t], + ); + // Drop image files → store each in IndexedDB (deduped) and drop an image // card referencing its hash at the cursor. const handleDrop = useCallback( @@ -589,8 +637,11 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const newCards = [...cardsRef.current, ...created]; setCards(newCards); saveCards(newCards); + + // Upload to the cloud in the background (cards already show locally). + syncCreatedAssets(created, projectId); }, - [isReadOnly, projectId, offset, scale, saveCards], + [isReadOnly, projectId, offset, scale, saveCards, syncCreatedAssets], ); // Create a text card at the given canvas-space coords (from the canvas menu). @@ -651,10 +702,13 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const newCards = [...cardsRef.current, newCard]; setCards(newCards); saveCards(newCards); + + // Upload to the cloud in the background (card already shows locally). + syncCreatedAssets([newCard], projectId); } catch (err) { console.error("[BoardCanvas] Failed to store recording:", err); } - }, [recorder, projectId, saveCards]); + }, [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. @@ -1206,6 +1260,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) )} + {/* Transient asset error (e.g. cloud storage limit reached) */} + {assetError &&
{assetError}
} + {/* Recording indicator */} {recorder.isRecording && (
diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index 7fd6c06f..8f2f0fc4 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -11,7 +11,7 @@ import CollaboratorsSettings from "./project/CollaboratorsSettings"; import styles from "./DashboardModal.module.css"; import ExportProject from "./project/ExportProject"; -import { CreditCard, FileDown, Folder, Globe, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; +import { CreditCard, FileDown, Folder, Globe, HardDrive, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; import { useTranslations } from "next-intl"; import KeybindsSettings from "./preferences/KeybindsSettings"; import AppearanceSettings from "./preferences/AppearanceSettings"; @@ -20,6 +20,7 @@ import ProfileSettings from "./account/ProfileSettings"; import SubscriptionSettings from "./account/SubscriptionSettings"; import LayoutSettings from "./project/LayoutSettings"; import ProductionSettings from "./project/ProductionSettings"; +import StorageSettings from "./project/StorageSettings"; import DashboardAuth from "./account/DashboardAuth"; import AboutSettings from "./AboutSettings"; @@ -36,6 +37,7 @@ const DashboardModal = () => { { 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]); @@ -138,6 +140,7 @@ const DashboardModal = () => { {isInProject && activeTab === "Layout" && } {isInProject && activeTab === "Production" && } {isInProject && activeTab === "Export" && } + {isInProject && activeTab === "Storage" && } {isInProject && activeTab === "Collaborators" && } {/* Preferences tabs */} {activeTab === "Keybinds" && } diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index 73f2b50e..163fba17 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -18,6 +18,7 @@ export type Category = | "Layout" | "Production" | "Export" + | "Storage" | "Collaborators" | "Profile" | "Subscription" diff --git a/components/dashboard/preferences/LanguageSettings.tsx b/components/dashboard/preferences/LanguageSettings.tsx index a0292f36..4fb1b2dd 100644 --- a/components/dashboard/preferences/LanguageSettings.tsx +++ b/components/dashboard/preferences/LanguageSettings.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Check, ChevronDown, Download, Loader2, Plus, X } from "lucide-react"; +import { Check, ChevronDown, Download, Loader2, Plus, SpellCheck, X } from "lucide-react"; import form from "./../../utils/Form.module.css"; import sharedStyles from "../project/ProjectSettings.module.css"; import styles from "./SpellcheckSettings.module.css"; @@ -10,7 +10,11 @@ import { useLocale } from "@src/context/LocaleContext"; import { useSettings } from "@src/lib/utils/hooks"; import { useSpellcheck } from "@src/context/SpellcheckContext"; import { ProjectContext } from "@src/context/ProjectContext"; -import { DICTIONARY_CATALOG, formatDictionarySize } from "@src/lib/spellcheck/spellcheck-dictionaries"; +import { + BUILTIN_DICTIONARY_CODE, + DICTIONARY_CATALOG, + formatDictionarySize, +} from "@src/lib/spellcheck/spellcheck-dictionaries"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; import { useTranslations } from "next-intl"; @@ -72,12 +76,8 @@ const LanguageSettings = () => { ); const spellcheckOptions: DropdownOption[] = useMemo(() => { - const noneOption: DropdownOption = { - value: "none", - label: t("spellcheckNone"), - }; - - const dictOptions: DropdownOption[] = DICTIONARY_CATALOG.map((dict) => { + return DICTIONARY_CATALOG.map((dict) => { + const isBuiltin = dict.code === BUILTIN_DICTIONARY_CODE; const installed = installedDictionaries.find((d) => d.code === dict.code); const isDownloading = downloadProgress?.code === dict.code; @@ -87,7 +87,12 @@ const LanguageSettings = () => {
{dict.name} - {isDownloading ? ( + {isBuiltin ? ( + <> + {t("spellcheckBuiltin")} + + + ) : isDownloading ? ( ) : installed ? ( <> @@ -100,20 +105,19 @@ const LanguageSettings = () => {
), - triggerLabel: dict.name, + triggerLabel: ( + + + {dict.name} + + ), }; }); - - return [noneOption, ...dictOptions]; }, [installedDictionaries, downloadProgress, t]); const handleSpellcheckChange = useCallback( (value: string) => { - if (value === "none") { - setSpellcheckLang(null); - return; - } - const isInstalled = installedDictionaries.some((d) => d.code === value); + const isInstalled = value === BUILTIN_DICTIONARY_CODE || installedDictionaries.some((d) => d.code === value); if (isInstalled) { setSpellcheckLang(value); } else { @@ -140,7 +144,7 @@ const LanguageSettings = () => {

{t("spellcheckHelpText")}

{ type="file" ref={fileInputRef} onChange={handleFileImport} - accept=".fountain,.txt,.fdx,.scriptio" + accept={getSupportedImportExtensions()} style={{ display: "none" }} /> diff --git a/components/dashboard/project/StorageSettings.module.css b/components/dashboard/project/StorageSettings.module.css new file mode 100644 index 00000000..399665e7 --- /dev/null +++ b/components/dashboard/project/StorageSettings.module.css @@ -0,0 +1,175 @@ +.storageSummary { + display: flex; + flex-direction: column; + gap: 10px; +} + +.storageHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.storageUsed { + font-size: 0.85rem; + color: var(--secondary-text); + font-variant-numeric: tabular-nums; +} + +.bar { + display: flex; + width: 100%; + height: 10px; + border-radius: 6px; + overflow: hidden; + background-color: var(--tertiary); +} + +.barSeg { + height: 100%; + transition: width 0.3s ease; +} + +.segThis { + background-color: #3b82f6; /* vivid blue */ +} + +.segOther { + background-color: #f59e0b; /* vivid amber */ +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + font-size: 0.8rem; + color: var(--secondary-text); +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.legendValue { + color: var(--primary-text); + font-variant-numeric: tabular-nums; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} + +.dotThis { + background-color: #3b82f6; /* vivid blue */ +} + +.dotOther { + background-color: #f59e0b; /* vivid amber */ +} + +.dotFree { + background-color: var(--tertiary); + border: 1px solid var(--secondary-text); +} + +.empty { + color: var(--secondary-text); + font-size: 0.9rem; + padding: 16px 0; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + border-radius: 10px; + background-color: var(--secondary); +} + +.thumb { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--tertiary); + color: var(--secondary-text); +} + +.thumbImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.metaTitle { + font-size: 0.9rem; + color: var(--primary-text); + font-variant-numeric: tabular-nums; +} + +.metaSub { + font-size: 0.8rem; + color: var(--secondary-text); + font-variant-numeric: tabular-nums; +} + +.deleteBtn, +.confirmBtn, +.cancelBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background-color: transparent; + color: var(--secondary-text); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.deleteBtn:hover { + background-color: var(--tertiary); + color: var(--error); +} + +.confirm { + display: flex; + gap: 4px; +} + +.confirmBtn { + background-color: var(--error); + color: #fff; +} + +.cancelBtn:hover { + background-color: var(--tertiary); + color: var(--primary-text); +} diff --git a/components/dashboard/project/StorageSettings.tsx b/components/dashboard/project/StorageSettings.tsx new file mode 100644 index 00000000..472a8791 --- /dev/null +++ b/components/dashboard/project/StorageSettings.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useCallback, useContext, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Check, Image as ImageIcon, Music, Trash2, X } from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { useProjectMembership } from "@src/lib/utils/hooks"; +import { listInUseAssets, type ProjectAssetInfo } from "@src/lib/assets/asset-store"; +import { useAssetUrl } from "@src/lib/assets/use-asset-url"; +import { USER_STORAGE_QUOTA_BYTES } from "@src/lib/utils/storage-limits"; +import type { BoardArrowData, BoardCardData } from "@src/lib/project/project-doc"; + +import sharedStyles from "./ProjectSettings.module.css"; +import form from "./../../utils/Form.module.css"; +import styles from "./StorageSettings.module.css"; + +/** Human-readable byte size, e.g. 1.4 GB. */ +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +} + +/** Owner-shared storage split into this project, the owner's other projects, and free. */ +interface StorageBreakdown { + thisUsed: number; + otherUsed: number; + quota: number; +} + +const emptyBreakdown = (): StorageBreakdown => ({ + thisUsed: 0, + otherUsed: 0, + quota: USER_STORAGE_QUOTA_BYTES, +}); + +/** Last fetched breakdown (tagged with its project id), so re-opening the section + * shows the previous figures immediately and the bar never shifts layout. */ +let cachedBreakdown: { projectId: string; breakdown: StorageBreakdown } | null = null; + +const AssetRow = ({ + projectId, + asset, + readOnly, + onDelete, +}: { + projectId: string; + asset: ProjectAssetInfo; + readOnly: boolean; + onDelete: (hash: string) => void; +}) => { + const t = useTranslations("storage"); + const isImage = asset.mime.startsWith("image/"); + const url = useAssetUrl(projectId, isImage ? asset.hash : null); + const [confirming, setConfirming] = useState(false); + + const dims = isImage && asset.width && asset.height ? `${asset.width}×${asset.height}` : null; + + return ( +
+
+ {isImage ? ( + url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + ) + ) : ( + + )} +
+
+ {isImage ? t("image") : t("audio")} + + {dims ? `${dims} · ${formatBytes(asset.size)}` : formatBytes(asset.size)} + +
+ + {!readOnly && + (confirming ? ( +
+ + +
+ ) : ( + + ))} +
+ ); +}; + +const StorageBar = ({ breakdown, isLocalOnly }: { breakdown: StorageBreakdown; isLocalOnly: boolean }) => { + const t = useTranslations("storage"); + const { thisUsed, otherUsed, quota } = breakdown; + const used = thisUsed + otherUsed; + const free = Math.max(0, quota - used); + + const thisPct = quota > 0 ? Math.min(100, (thisUsed / quota) * 100) : 0; + const otherPct = quota > 0 ? Math.min(100 - thisPct, (otherUsed / quota) * 100) : 0; + + return ( +
+
+ {t("sharedStorage")} + + {formatBytes(used)} / {formatBytes(quota)} + +
+ +
+
+
+
+ +
+ + + {t("thisProject")} + {formatBytes(thisUsed)} + + + + {t("otherProjects")} + {formatBytes(otherUsed)} + + + + {t("free")} + {formatBytes(free)} + +
+ + {isLocalOnly &&

{t("localNote")}

} +
+ ); +}; + +const StorageSettings = () => { + const t = useTranslations("storage"); + const { projectId, repository, isReadOnly } = useContext(ProjectContext); + const { isLocalOnly } = useProjectMembership(); + const [assets, setAssets] = useState(null); + // Seed from the cache so the bar shows last-known figures immediately (and at a + // stable size); fetched values refresh it in place without shifting layout. + const [breakdown, setBreakdown] = useState(() => + cachedBreakdown?.projectId === projectId ? cachedBreakdown.breakdown : emptyBreakdown(), + ); + + const load = useCallback(async () => { + if (!projectId || !repository) { + setAssets([]); + setBreakdown(emptyBreakdown()); + return; + } + + const list = await listInUseAssets(projectId, repository.getState()); + list.sort((a, b) => b.size - a.size); + setAssets(list); + + const { fetchProjectStorage, fetchMyStorage } = await import("@src/lib/assets/cloud-asset-sync"); + let bd: StorageBreakdown; + if (isLocalOnly) { + // Not synced: this project's usage is local; "other" is the owner's + // current cloud usage (this project would add on top once synced). + const localTotal = list.reduce((sum, a) => sum + a.size, 0); + const my = await fetchMyStorage(); + bd = { thisUsed: localTotal, otherUsed: my?.used ?? 0, quota: my?.quota ?? USER_STORAGE_QUOTA_BYTES }; + } else { + const s = await fetchProjectStorage(projectId); + bd = s + ? { thisUsed: s.projectUsed, otherUsed: Math.max(0, s.ownerTotalUsed - s.projectUsed), quota: s.quota } + : emptyBreakdown(); + } + cachedBreakdown = { projectId, breakdown: bd }; + setBreakdown(bd); + }, [projectId, repository, isLocalOnly]); + + useEffect(() => { + void load(); + }, [load]); + + const deleteAsset = useCallback( + async (hash: string) => { + if (!projectId || !repository) return; + const ydoc = repository.getState(); + + // Remove every board card (and dangling arrow) referencing the asset. + ydoc.transact(() => { + ydoc.documents().forEach((node) => { + if (node.type !== "board") return; + const map = ydoc.boardData(node.id); + const rawCards = map.get("cards"); + if (!rawCards) return; + let cards: BoardCardData[]; + try { + cards = JSON.parse(rawCards); + } catch { + return; + } + const removed = new Set(cards.filter((c) => c.assetId === hash).map((c) => c.id)); + if (removed.size === 0) return; + map.set("cards", JSON.stringify(cards.filter((c) => !removed.has(c.id)))); + + const rawArrows = map.get("arrows"); + if (!rawArrows) return; + try { + const arrows = JSON.parse(rawArrows) as BoardArrowData[]; + const next = arrows.filter( + (a) => !removed.has(a.fromCardId) && !removed.has(a.toCardId), + ); + if (next.length !== arrows.length) map.set("arrows", JSON.stringify(next)); + } catch { + // leave arrows untouched if unparseable + } + }); + }); + + // Reclaim the now-orphaned bytes (local immediately; cloud follows the + // snapshot-aware GC rules) and refresh. + const { gcProjectAssets } = await import("@src/lib/assets/asset-gc"); + await gcProjectAssets(projectId, ydoc).catch(() => {}); + await load(); + }, + [projectId, repository, load], + ); + + return ( +
+
+ +

{t("description")}

+
+ + {assets === null ? null : assets.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ {assets.map((asset) => ( + + ))} +
+ )} +
+ ); +}; + +export default StorageSettings; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 29235393..000c78a5 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -83,6 +83,10 @@ const DocumentEditorPanel = ({ const { settings } = useSettings(); const { isEndlessScroll } = useViewContext(); const { user } = useUser(); + // Localised label for the manual page-break hint rendered in the page gap. + // Injected as a CSS variable so the plain-DOM pagination widget can show it, + // mirroring how the (MORE)/(CONT'D) labels are localised. + const pageBreakHint = useTranslations("contextMenu")("pageBreakHint"); const [isEditorReady, setIsEditorReady] = useState(false); const [isScrolled, setIsScrolled] = useState(false); @@ -238,6 +242,7 @@ const DocumentEditorPanel = ({ editorElement.style.setProperty("--contd-label", `"${contdLabel}"`); editorElement.style.setProperty("--more-label", `"${moreLabel}"`); + editorElement.style.setProperty("--page-break-label", `"${pageBreakHint}"`); const elementKeys = [ "action", @@ -308,6 +313,7 @@ const DocumentEditorPanel = ({ sceneNumberOnRight, contdLabel, moreLabel, + pageBreakHint, elementMargins, elementStyles, sceneLocking, @@ -591,6 +597,20 @@ const DocumentEditorPanel = ({ } } + // Manual page break: the top-level block under the caret, plus whether + // it already forces a page break. Paginated screenplay editors only, and + // never the document's first block (there is nothing to break before it). + let pageBreak: { pos: number; active: boolean } | undefined; + if (config.features.paginationMode === "screenplay") { + const $pos = editor.state.doc.resolve(from); + if ($pos.depth >= 1) { + const nodeStart = $pos.before(1); + if (nodeStart > 0) { + pageBreak = { pos: nodeStart, active: !!$pos.node(1).attrs.pageBreak }; + } + } + } + // Comments anchor to the node under the caret, not a text range. const commentNodeId = getNodeIdAtPos(editor.state, from); const onAddComment = commentNodeId @@ -600,10 +620,21 @@ const DocumentEditorPanel = ({ updateContextMenu({ type: ContextMenuType.EditorContextMenu, position: { x: e.clientX, y: e.clientY }, - typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene }, + // Pass the editor that was right-clicked: positions above are + // resolved against it, and ProjectContext.editor is always the + // MAIN screenplay editor — so secondary editors (tree document, + // draft, title page) must act on this instance, not that one. + typeSpecificProps: { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak }, }); }, - [editor, updateContextMenu, addCommentToNode, config.features.shelving, config.documentId], + [ + editor, + updateContextMenu, + addCommentToNode, + config.features.shelving, + config.features.paginationMode, + config.documentId, + ], ); // Clear the open discussion when clicking elsewhere in the editor. diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index 9d5221f8..ed40e210 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -21,6 +21,9 @@ width: 100%; max-width: 1000px; margin: 0 auto; + /* Breathing room so the last page doesn't butt against the viewport edge + * when scrolled to the bottom. Matches the inter-page gap (20px). */ + padding-bottom: 30%; contain: layout; } @@ -56,13 +59,7 @@ 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 diff --git a/components/editor/TitlePagePanel.tsx b/components/editor/TitlePagePanel.tsx index 3fb878bc..15308b61 100644 --- a/components/editor/TitlePagePanel.tsx +++ b/components/editor/TitlePagePanel.tsx @@ -40,30 +40,37 @@ const TitlePagePanel = ({ isVisible }: { isVisible?: boolean }) => { const state = repository.getState(); const meta = state.metadata(); - if (!meta.get("titlepageInitialized")) { - titleEditor.commands.setContent(DEFAULT_TITLEPAGE_CONTENT); - - const { state: editorState, view } = titleEditor; - const tr = editorState.tr; - let modified = false; - - tr.doc.descendants((node, pos) => { - if (node.type.name === TitlePageElement.Title) { - const markType = editorState.schema.marks.underline; - if (markType) { - tr.addMark(pos, pos + node.nodeSize, markType.create({ class: "underline" })); - modified = true; - } - return false; - } - }); + // Seed the default template only into a genuinely empty title page. Guard + // on the Yjs fragment being empty — not on the metadata flag alone — so a + // fresh editor mount, a not-yet-synced flag, or an imported title page + // never gets overwritten or has the template appended more than once. + if (meta.get("titlepageInitialized") || state.titlepageFragment().length > 0) { + if (!meta.get("titlepageInitialized")) meta.set("titlepageInitialized", true); + return; + } + + titleEditor.commands.setContent(DEFAULT_TITLEPAGE_CONTENT); - if (modified && view) { - view.dispatch(tr); + const { state: editorState, view } = titleEditor; + const tr = editorState.tr; + let modified = false; + + tr.doc.descendants((node, pos) => { + if (node.type.name === TitlePageElement.Title) { + const markType = editorState.schema.marks.underline; + if (markType) { + tr.addMark(pos, pos + node.nodeSize, markType.create({ class: "underline" })); + modified = true; + } + return false; } + }); - meta.set("titlepageInitialized", true); + if (modified && view) { + view.dispatch(tr); } + + meta.set("titlepageInitialized", true); }, [titleEditor, isYjsReady, repository]); // Sync project metadata into the module-level ref and editor storage for node view rendering diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 429883b4..6341d7d3 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -1,5 +1,6 @@ "use client"; +import { Editor } from "@tiptap/react"; import { ReactNode, useContext, useEffect, useState } from "react"; import { UserContext } from "@src/context/UserContext"; import { useSpellcheck } from "@src/context/SpellcheckContext"; @@ -34,6 +35,7 @@ import { MessageSquarePlus, Pencil, Scissors, + SeparatorHorizontal, Trash2, UserRound, } from "lucide-react"; @@ -485,6 +487,9 @@ const ShelveNodeMenu = ({ props }: SubMenuProps<{ pos: number; nodeClass: string /* ============================== */ export type EditorContextMenuProps = { + /** The editor that was right-clicked. All actions act on this instance — not + * ProjectContext.editor, which is always the main screenplay editor. */ + editor: Editor; from: number; to: number; /** Present when the caret sits on a node a comment can anchor to. */ @@ -494,15 +499,20 @@ export type EditorContextMenuProps = { nodeClass?: string; /** Present when the caret sits on a scene heading that can be sent to the Outline. */ outlineScene?: { refDocId: string; refId: string; title: string }; + /** Present on paginated screenplay editors: the top-level block under the + * caret (`pos`) and whether it already forces a manual page break. */ + pageBreak?: { pos: number; active: boolean }; }; const EditorContextMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); - const { editor, repository, isReadOnly, persistentScenes } = - useContext(ProjectContext); + const { repository, isReadOnly, persistentScenes } = useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); - const { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene } = props; + // Act on the editor that was right-clicked (passed through props), NOT + // ProjectContext.editor — that one is always the main screenplay editor, so + // using it would misfire actions in the tree-document / draft / title editors. + const { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak } = props; const hasSelection = from !== to; const handleSendToOutline = () => { @@ -620,6 +630,12 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { updateContextMenu(undefined); }; + const handleTogglePageBreak = () => { + if (!editor || !pageBreak || isReadOnly) return; + editor.commands.toggleManualPageBreak(pageBreak.pos); + updateContextMenu(undefined); + }; + const canDualDialogue = (() => { if (nodeClass !== ScreenplayElement.Character || !editor || nodePos === undefined) return false; const doc = editor.state.doc; @@ -717,6 +733,18 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} + {/* Manual page break — force a new page that begins at this block */} + {pageBreak && !isReadOnly && ( + <> + + + + )} + {/* Send a scene heading to the Outline */} {outlineScene && !isReadOnly && ( <> diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index 2c7110e6..9dca27ba 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -146,6 +146,61 @@ height: 20px; } +/* Storage usage panel (hover of the cloud status indicator) */ +.status_wrapper { + position: relative; + display: flex; + align-items: center; +} + +.storage_panel { + position: absolute; + top: 40px; + left: 0; + min-width: 220px; + background-color: var(--secondary); + color: var(--primary-text); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + padding: 10px 12px; + border-radius: 8px; + font-size: 12px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 6px; +} + +.storage_status { + font-weight: 600; +} + +.storage_separator { + height: 1px; + background-color: var(--tertiary); + margin: 1px 0; +} + +.storage_row { + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--secondary-text); +} + +.storage_bar { + height: 6px; + border-radius: 3px; + background-color: var(--tertiary); + overflow: hidden; +} + +.storage_bar_fill { + height: 100%; + background-color: var(--primary); + border-radius: 3px; + transition: width 0.3s ease; +} + .failed { color: var(--error); } diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index 30e519e3..c6bece4b 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -12,6 +12,7 @@ import debounce from "debounce"; import { editProject } from "@src/lib/utils/requests"; import { join } from "@src/lib/utils/misc"; import { uploadToCloudPopup } from "@src/lib/screenplay/popup"; +import type { StorageUsage } from "@src/lib/assets/cloud-asset-sync"; import { DashboardContext } from "@src/context/DashboardContext"; import { BarChart2, @@ -34,8 +35,73 @@ import navBtn from "@components/utils/NavbarIconButton.module.css"; import ScreenplayFormatDropdown from "./ScreenplayFormatDropdown"; import ScreenplaySearch from "./ScreenplaySearch"; +/** Human-readable byte size, e.g. 1.4 GB. */ +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +}; + +/** Last fetched usage, so re-hovering shows the previous numbers immediately (the + * panel unmounts on mouse-leave) instead of placeholders. Tagged with its project + * id so switching projects doesn't flash the wrong project's figures. */ +let cachedStorage: { projectId: string; usage: StorageUsage } | null = null; + +const StorageUsageBody = ({ projectId }: { projectId: string }) => { + const t = useTranslations("navbar"); + const [usage, setUsage] = useState(() => + cachedStorage?.projectId === projectId ? cachedStorage.usage : null, + ); + + useEffect(() => { + let cancelled = false; + (async () => { + const { fetchProjectStorage } = await import("@src/lib/assets/cloud-asset-sync"); + const data = await fetchProjectStorage(projectId); + // Keep the last known value on a failed refresh rather than wiping it. + if (!cancelled && data) { + cachedStorage = { projectId, usage: data }; + setUsage(data); + } + })(); + return () => { + cancelled = true; + }; + }, [projectId]); + + // Render the full layout immediately (stable size); only the amounts fill in + // once fetched, so the panel doesn't grow after appearing. + const pct = usage && usage.quota > 0 ? Math.min(100, Math.round((usage.ownerTotalUsed / usage.quota) * 100)) : 0; + + return ( + <> +
+ {t("storageProject")} + {usage ? formatBytes(usage.projectUsed) : "—"} +
+
+ {t("storageTotal")} + + {usage ? `${formatBytes(usage.ownerTotalUsed)} / ${formatBytes(usage.quota)}` : "—"} + +
+
+
+
+ + ); +}; + const StatusIndicator = () => { const { connectionStatus } = useContext(ProjectContext); + const projectId = useProjectIdFromUrl(); + const [hovered, setHovered] = useState(false); const t = useTranslations("navbar"); const STATUS: Record = { connected: t("synced"), @@ -43,19 +109,32 @@ const StatusIndicator = () => { connecting: t("reconnecting"), }; return ( - <> -
- {connectionStatus === "connected" && ( - - )} - {connectionStatus === "disconnected" && ( - - )} - {connectionStatus === "connecting" && ( - - )} -
- +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {connectionStatus === "connected" && ( + + )} + {connectionStatus === "disconnected" && ( + + )} + {connectionStatus === "connecting" && ( + + )} + {hovered && ( +
+
{STATUS[connectionStatus]}
+ {projectId && ( + <> +
+ + + )} +
+ )} +
); }; diff --git a/components/popup/Popup.module.css b/components/popup/Popup.module.css index 96ac22b3..3fffd44c 100644 --- a/components/popup/Popup.module.css +++ b/components/popup/Popup.module.css @@ -141,3 +141,23 @@ width: 100% !important; margin-top: 10px; } + +.loading { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/components/popup/PopupImportFile.tsx b/components/popup/PopupImportFile.tsx index 2eb1c11c..821c33e7 100644 --- a/components/popup/PopupImportFile.tsx +++ b/components/popup/PopupImportFile.tsx @@ -2,10 +2,10 @@ import popup from "./Popup.module.css"; -import { X } from "lucide-react"; +import { Loader2, X } from "lucide-react"; import { useDraggable } from "@src/lib/utils/hooks"; import { PopupData, PopupImportFileData, closePopup } from "@src/lib/screenplay/popup"; -import { useContext } from "react"; +import { useContext, useState } from "react"; import { UserContext } from "@src/context/UserContext"; import { useTranslations } from "next-intl"; @@ -13,10 +13,30 @@ const PopupImportFile = ({ data: { confirmImport } }: PopupData { - confirmImport(); - closePopup(userCtx); + const onConfirmImport = async () => { + if (isImporting) return; + setIsImporting(true); + // Parsing the file and writing it into the editor/Yjs document is heavy + // and runs on the main thread. Yield for a couple of frames first so the + // button's loading state actually paints before that work blocks the UI, + // rather than the popup vanishing and the app appearing to freeze. + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + try { + await confirmImport(); + } catch (error) { + console.error("Import failed:", error); + } finally { + setIsImporting(false); + closePopup(userCtx); + } + }; + + // Block dismissal while an import is in flight so the document isn't left + // half-written by a popup that closed mid-import. + const dismiss = () => { + if (!isImporting) closePopup(userCtx); }; return ( @@ -28,7 +48,7 @@ const PopupImportFile = ({ data: { confirmImport } }: PopupData

{t("title")}

- closePopup(userCtx)} /> +

@@ -38,10 +58,17 @@ const PopupImportFile = ({ data: { confirmImport } }: PopupData

- -
diff --git a/components/project/EditorFooter.tsx b/components/project/EditorFooter.tsx index be03ceec..2f551918 100644 --- a/components/project/EditorFooter.tsx +++ b/components/project/EditorFooter.tsx @@ -3,11 +3,12 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import type { Editor } from "@tiptap/react"; -import { FileText, Maximize, MessageSquare, MessageSquareOff, Minimize, Scroll } from "lucide-react"; +import { FileText, Maximize, Minimize, Scroll, SpellCheck } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; import { useViewContext } from "@src/context/ViewContext"; +import { useSpellcheck } from "@src/context/SpellcheckContext"; import { paginationKey } from "@src/lib/screenplay/extensions/pagination-extension"; import { join } from "@src/lib/utils/misc"; import WritingTimer from "./WritingTimer"; @@ -50,17 +51,22 @@ const EditorFooter = () => { const t = useTranslations("navbar"); const { editor } = useContext(ProjectContext); const { isZenMode, updateIsZenMode } = useContext(UserContext); - const { - isEndlessScroll, - setIsEndlessScroll, - showComments, - setShowComments, - setLeftSidebarOpen, - setRightSidebarOpen, - } = useViewContext(); + const { isEndlessScroll, setIsEndlessScroll, setLeftSidebarOpen, setRightSidebarOpen } = useViewContext(); + const { spellcheckLang, setSpellcheckLang } = useSpellcheck(); const pageCount = useScreenplayPageCount(editor); + // Remember the last active language so the toggle can restore it after being turned + // off; default to English when nothing has been selected yet. + const lastSpellcheckLang = useRef("en"); + useEffect(() => { + if (spellcheckLang) lastSpellcheckLang.current = spellcheckLang; + }, [spellcheckLang]); + + const toggleSpellcheck = useCallback(() => { + setSpellcheckLang(spellcheckLang ? null : lastSpellcheckLang.current); + }, [spellcheckLang, setSpellcheckLang]); + // Sidebar open-state captured on entering focus mode so it can be restored on exit. const sidebarsBeforeFocus = useRef<{ left: boolean; right: boolean } | null>(null); @@ -124,12 +130,12 @@ const EditorFooter = () => {