From 3e2439afd935d7f98592a1857a1e9c6b3a1afa32 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Fri, 24 Apr 2026 18:12:51 +0200 Subject: [PATCH 01/76] fix build id for tauri builds, fixed oauth/auth on tauri app, tweaked visual on empty project page --- .github/actions/compute-version/action.yml | 4 + .github/workflows/deploy-release.yaml | 3 + .github/workflows/deploy-staging.yaml | 3 + components/dashboard/AboutSettings.module.css | 105 ++++++++++++++++-- components/dashboard/AboutSettings.tsx | 34 ++++-- .../dashboard/account/DashboardAuth.tsx | 21 +++- components/dashboard/account/OAuthButtons.tsx | 19 +++- components/home/HomeClient.tsx | 2 +- .../projects/EmptyProjectPage.module.css | 41 ++++--- components/projects/EmptyProjectPage.tsx | 4 +- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/ja.json | 2 + messages/ko.json | 2 + messages/pl.json | 2 + messages/zh.json | 2 + next.config.ts | 13 +++ scripts/build-tauri.ts | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 3 +- src/lib/desktop-auth.ts | 18 ++- src/lib/utils/requests.ts | 4 +- src/proxy.ts | 10 ++ 26 files changed, 253 insertions(+), 53 deletions(-) diff --git a/.github/actions/compute-version/action.yml b/.github/actions/compute-version/action.yml index 4bd4fd57..61438b3f 100644 --- a/.github/actions/compute-version/action.yml +++ b/.github/actions/compute-version/action.yml @@ -14,6 +14,9 @@ outputs: git_tag: description: Tag to create on release (e.g. 2.0.1). Empty on other branches. value: ${{ steps.compute.outputs.git_tag }} + commit_sha: + description: Short git commit SHA. + value: ${{ steps.compute.outputs.commit_sha }} runs: using: composite @@ -69,3 +72,4 @@ runs: echo "short_version=$SHORT_VERSION" >> $GITHUB_OUTPUT echo "revision=$REVISION" >> $GITHUB_OUTPUT echo "git_tag=$GIT_TAG" >> $GITHUB_OUTPUT + echo "commit_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/deploy-release.yaml b/.github/workflows/deploy-release.yaml index bdc92cf6..ff30f207 100644 --- a/.github/workflows/deploy-release.yaml +++ b/.github/workflows/deploy-release.yaml @@ -16,6 +16,7 @@ jobs: short_version: ${{ steps.version.outputs.short_version }} revision: ${{ steps.version.outputs.revision }} git_tag: ${{ steps.version.outputs.git_tag }} + commit_sha: ${{ steps.version.outputs.commit_sha }} steps: - uses: actions/checkout@v6 with: @@ -72,6 +73,7 @@ jobs: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Build signed .pkg installer run: | @@ -112,6 +114,7 @@ jobs: env: NEXT_PUBLIC_API_URL: https://scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Rename output to fixed name run: | diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index a3d31760..b58593a3 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -15,6 +15,7 @@ jobs: version: ${{ steps.version.outputs.version }} short_version: ${{ steps.version.outputs.short_version }} revision: ${{ steps.version.outputs.revision }} + commit_sha: ${{ steps.version.outputs.commit_sha }} steps: - uses: actions/checkout@v6 with: @@ -73,6 +74,7 @@ jobs: NEXT_PUBLIC_API_URL: https://staging.scriptio.app NEXT_PUBLIC_STAGING_BASIC_AUTH: ${{ secrets.STAGING_BASIC_AUTH }} NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Build signed .pkg installer run: | @@ -115,6 +117,7 @@ jobs: NEXT_PUBLIC_API_URL: https://staging.scriptio.app NEXT_PUBLIC_STAGING_BASIC_AUTH: ${{ secrets.STAGING_BASIC_AUTH }} NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Rename output to fixed name run: | diff --git a/components/dashboard/AboutSettings.module.css b/components/dashboard/AboutSettings.module.css index 3e4699bf..c6cebf89 100644 --- a/components/dashboard/AboutSettings.module.css +++ b/components/dashboard/AboutSettings.module.css @@ -1,7 +1,67 @@ .container { display: flex; flex-direction: column; - gap: 12px; + align-items: center; + justify-content: center; + gap: 40px; + padding: 60px 0; + height: 100%; +} + +.brandSection { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + animation: fade-in-up 0.5s ease-out forwards; +} + +.logoWrapper { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + background: var(--secondary); + border: 1px solid var(--separator); + border-radius: 20px; + box-shadow: 0 8px 24px -8px rgba(0, 0, 0, 0.2); +} + +.logo { + width: 44px; + height: 44px; + color: var(--primary-text); +} + +.title { + font-size: 2.2rem; + font-weight: 700; + color: var(--primary-text); + margin: 0; + line-height: 1; + letter-spacing: -0.02em; + font-family: var(--font-inter), sans-serif; +} + +.copyright { + font-size: 0.9rem; + color: var(--secondary-text); + margin: 0; +} + +.infoSection { + display: flex; + flex-direction: column; + width: 100%; + max-width: 420px; + background-color: var(--secondary); + border: 1px solid var(--separator); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.1); + animation: fade-in-up 0.5s ease-out 0.1s forwards; + opacity: 0; } .row { @@ -9,19 +69,48 @@ flex-direction: row; justify-content: space-between; align-items: center; - padding: 10px 14px; - border-radius: 8px; - background-color: var(--secondary); + padding: 16px 20px; + background-color: transparent; + transition: background-color 0.2s; +} + +.row:hover { + background-color: var(--tertiary); +} + +.row:not(:last-child) { + border-bottom: 1px solid var(--separator); +} + +.labelGroup { + display: flex; + align-items: center; + gap: 12px; +} + +.icon { + color: var(--secondary-text); } .label { - font-size: 0.9rem; - font-weight: 600; + font-size: 0.95rem; + font-weight: 500; color: var(--secondary-text); } .value { - font-size: 0.9rem; + font-size: 0.95rem; color: var(--primary-text); - font-family: monospace; + font-weight: 600; } + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/components/dashboard/AboutSettings.tsx b/components/dashboard/AboutSettings.tsx index 68c9992b..01fe15c5 100644 --- a/components/dashboard/AboutSettings.tsx +++ b/components/dashboard/AboutSettings.tsx @@ -1,3 +1,5 @@ +import ScriptioLogo from "@public/images/scriptio.svg"; +import { Tag, GitCommit } from "lucide-react"; import styles from "./AboutSettings.module.css"; const AboutSettings = () => { @@ -6,16 +8,34 @@ const AboutSettings = () => { return (
-
- Version - v{version} +
+
+ +
+

Scriptio

+

© {new Date().getFullYear()} Arko Logic

-
- Build - {buildId} + +
+
+
+ + Version +
+ v{version} +
+
+
+ + Build +
+ + {buildId} + +
); }; -export default AboutSettings; +export default AboutSettings; \ No newline at end of file diff --git a/components/dashboard/account/DashboardAuth.tsx b/components/dashboard/account/DashboardAuth.tsx index 7a38e7e1..6dfe49e4 100644 --- a/components/dashboard/account/DashboardAuth.tsx +++ b/components/dashboard/account/DashboardAuth.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useTranslations } from "next-intl"; import { isTauri } from "@tauri-apps/api/core"; import { useSWRConfig } from "swr"; @@ -28,6 +28,11 @@ const DashboardAuth = () => { // Desktop-only: poll the bridge after the email is sent so the user is signed in // here as soon as they click the magic link in their browser. const [pollingDesktop, setPollingDesktop] = useState(false); + const pollAbortRef = useRef(null); + + useEffect(() => { + return () => { pollAbortRef.current?.abort(); }; + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -58,11 +63,17 @@ const DashboardAuth = () => { setSubmitted(true); setPollingDesktop(true); - const token = await pollBridgeToken(nonce); + pollAbortRef.current?.abort(); + const controller = new AbortController(); + pollAbortRef.current = controller; + + const token = await pollBridgeToken(nonce, { signal: controller.signal }); if (!token) { - setMessage({ type: "error", text: tAuth("desktopTimeout") }); - setPollingDesktop(false); - setSubmitted(false); + if (!controller.signal.aborted) { + setMessage({ type: "error", text: tAuth("desktopTimeout") }); + setPollingDesktop(false); + setSubmitted(false); + } return; } await setDesktopToken(token); diff --git a/components/dashboard/account/OAuthButtons.tsx b/components/dashboard/account/OAuthButtons.tsx index 54afea77..ea9eed1d 100644 --- a/components/dashboard/account/OAuthButtons.tsx +++ b/components/dashboard/account/OAuthButtons.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useSWRConfig } from "swr"; import { signIn } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; @@ -23,6 +23,11 @@ const OAuthButtons = ({ callbackUrl = "/projects" }: Props) => { const t = useTranslations("oauth"); const [pendingProvider, setPendingProvider] = useState(null); const [error, setError] = useState(null); + const pollAbortRef = useRef(null); + + useEffect(() => { + return () => { pollAbortRef.current?.abort(); }; + }, []); const startOAuth = async (provider: Provider) => { setError(null); @@ -32,19 +37,24 @@ const OAuthButtons = ({ callbackUrl = "/projects" }: Props) => { return; } + // Cancel any in-progress poll before starting a new one + pollAbortRef.current?.abort(); + const controller = new AbortController(); + pollAbortRef.current = controller; + setPendingProvider(provider); try { const { generateBridgeNonce, pollBridgeToken, setDesktopToken } = await import("@src/lib/desktop-auth"); const { openUrl } = await import("@tauri-apps/plugin-opener"); const nonce = generateBridgeNonce(); - const apiBase = process.env.NEXT_PUBLIC_API_URL || window.location.origin; + const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; const bridgeUrl = `${apiBase}/desktop-oauth/start?provider=${provider}&nonce=${encodeURIComponent(nonce)}`; await openUrl(bridgeUrl); - const token = await pollBridgeToken(nonce); + const token = await pollBridgeToken(nonce, { signal: controller.signal }); if (!token) { - setError(t("timeout")); + if (!controller.signal.aborted) setError(t("timeout")); return; } @@ -52,6 +62,7 @@ const OAuthButtons = ({ callbackUrl = "/projects" }: Props) => { await mutate("/api/users/cookie"); await mutate("/api/users"); } catch (err) { + if (controller.signal.aborted) return; console.error("[OAuthButtons] Desktop OAuth failed:", err); setError(t("error")); } finally { diff --git a/components/home/HomeClient.tsx b/components/home/HomeClient.tsx index 6d007a05..b690db36 100644 --- a/components/home/HomeClient.tsx +++ b/components/home/HomeClient.tsx @@ -19,7 +19,7 @@ export default function HomeClient() { } }, [setTheme, router]); - if (isTauri()) { + if (process.env.NEXT_PUBLIC_TAURI_BUILD === "true" || isTauri()) { return null; } diff --git a/components/projects/EmptyProjectPage.module.css b/components/projects/EmptyProjectPage.module.css index 501f215f..779ad52f 100644 --- a/components/projects/EmptyProjectPage.module.css +++ b/components/projects/EmptyProjectPage.module.css @@ -4,25 +4,23 @@ justify-content: center; align-items: center; flex: 1; - background: none; - border: none; + padding-bottom: 60px; } .cards { display: flex; flex-direction: row; - gap: 20px; - padding-bottom: 70px; + gap: 24px; } .card { display: flex; flex-direction: column; align-items: flex-start; - gap: 12px; + gap: 18px; - width: 220px; - padding: 28px 24px; + width: 280px; + padding: 36px 32px; border-radius: 18px; border: 3px solid var(--tertiary); @@ -31,11 +29,14 @@ cursor: pointer; text-align: left; - transition: border-color 0.2s, transform 0.2s; + box-shadow: var(--project-item-shadow); + transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s; } .card:hover { border-color: var(--tertiary-hover); + box-shadow: var(--project-item-shadow-hover); + transform: translate(-3px, -3px); } .card:active { @@ -46,32 +47,40 @@ .card:disabled { opacity: 0.5; cursor: not-allowed; + transform: none; + box-shadow: var(--project-item-shadow); } .cardIcon { display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 10px; + width: 52px; + height: 52px; + border-radius: 14px; background-color: var(--tertiary); color: var(--primary-text); flex-shrink: 0; + transition: background-color 0.2s; +} + +.card:hover .cardIcon { + background-color: var(--tertiary-hover); } .cardTitle { - font-family: var(--font-inter), sans-serif; + font-family: var(--font-josefin), sans-serif; font-style: italic; - font-size: 1.2rem; - font-weight: 400; + font-size: 1.25rem; + font-weight: 700; color: var(--primary-text); margin: 0; + letter-spacing: 0.01em; } .cardDesc { - font-size: 0.8rem; + font-size: 0.85rem; color: var(--secondary-text); margin: 0; - line-height: 1.4; + line-height: 1.5; } diff --git a/components/projects/EmptyProjectPage.tsx b/components/projects/EmptyProjectPage.tsx index 35d2a3d4..393ea0da 100644 --- a/components/projects/EmptyProjectPage.tsx +++ b/components/projects/EmptyProjectPage.tsx @@ -58,7 +58,7 @@ const EmptyProjectPage = ({ setIsCreating }: Props) => {
+
+
+
+ ); + } + + return ( +
+
+

Could not upgrade this project

+

+ Scriptio tried to upgrade this project to the latest version but a step failed. Your + original data is safe — restore from the pre-upgrade backup, or open another project. +

+

+ Failed at version {outcome.failedAt}: {outcome.error.message} +

+
+ + +
+
+
+ ); +}; + +export default ProjectMigrationErrorDialog; diff --git a/docker-compose.yml b/docker-compose.yml index ab348deb..5db6cc70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,12 @@ services: - traefik.http.routers.scriptio-app-auth.priority=100 - traefik.http.routers.scriptio-app-auth.middlewares=prod-auth - traefik.http.middlewares.prod-auth.basicauth.users=${PROD_AUTH_USERS} + # Allow CORS OPTIONS preflight to bypass basic auth + - traefik.http.routers.scriptio-app-auth-options.rule=Host(`scriptio.app`) && (PathPrefix(`/projects`) || PathPrefix(`/api`)) && Method(`OPTIONS`) + - traefik.http.routers.scriptio-app-auth-options.entrypoints=secure + - traefik.http.routers.scriptio-app-auth-options.tls=true + - traefik.http.routers.scriptio-app-auth-options.tls.certresolver=letsencrypt + - traefik.http.routers.scriptio-app-auth-options.priority=200 app-staging: image: ghcr.io/lycoon/scriptio-app:staging diff --git a/package.json b/package.json index 61e0385b..51128eeb 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "lint": "eslint .", "bench": "vitest bench", "bench:watch": "vitest bench --watch", + "test": "vitest run", + "test:watch": "vitest", "gen:apple:jwt": "node --env-file=.env --import tsx scripts/generate-apple-jwt.ts" }, "dependencies": { diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx index c7762080..127b9fa6 100644 --- a/src/app/projects/layout.tsx +++ b/src/app/projects/layout.tsx @@ -3,6 +3,7 @@ import Loading from "@components/utils/Loading"; import DashboardModal from "@components/dashboard/DashboardModal"; import ProjectUnavailableDialog from "@components/projects/ProjectUnavailableDialog"; +import ProjectMigrationErrorDialog from "@components/projects/ProjectMigrationErrorDialog"; import { redirect, useSearchParams } from "next/navigation"; import { ProjectProvider, useProjectReady } from "@src/context/ProjectContext"; import { ViewProvider } from "@src/context/ViewContext"; @@ -49,13 +50,22 @@ interface ProjectLayoutInnerProps { } const ProjectLayoutInner = ({ children }: ProjectLayoutInnerProps) => { - const { isYjsReady, isProjectUnavailable } = useProjectReady(); + const { isYjsReady, isProjectUnavailable, migrationOutcome } = useProjectReady(); const { membership, isLoading: isMembershipLoading, isLocalOnly: isBrowserLocalOnly } = useProjectMembership(); // Desktop (Tauri) and browser local-only projects skip the cloud membership requirement const isDesktop = isTauri(); const isLocalAccess = isDesktop || isBrowserLocalOnly; + // Migration blocked the project from loading: show a dedicated error dialog + // before any other gating logic, so the user always gets a clear message. + if ( + migrationOutcome && + (migrationOutcome.kind === "future-version" || migrationOutcome.kind === "failed") + ) { + return ; + } + // Wait for membership to resolve for potential cloud projects if (!isLocalAccess && isMembershipLoading) { return ; diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 84511436..2980b2bf 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -25,6 +25,7 @@ import { DEFAULT_PAGE_MARGINS, ShelfEntry, } from "@src/lib/project/project-state"; +import type { ProjectMigrationOutcome } from "@src/lib/project/migrations/project-migration-runner"; import { Screenplay } from "@src/lib/utils/types"; import { ScreenplayElement, TitlePageElement, Style, PageFormat } from "@src/lib/utils/enums"; import { SearchMatch } from "@src/lib/screenplay/extensions/search-highlight-extension"; @@ -216,11 +217,13 @@ export const ProjectContext = createContext(defaultContextVa interface ProjectReadyContextType { isYjsReady: boolean; isProjectUnavailable: boolean; + migrationOutcome: ProjectMigrationOutcome | null; } const ProjectReadyContext = createContext({ isYjsReady: false, isProjectUnavailable: false, + migrationOutcome: null, }); export const useProjectReady = () => useContext(ProjectReadyContext); @@ -250,6 +253,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = connectionStatus: yjsConnectionStatus, users: yjsUsers, isProjectUnavailable, + migrationOutcome, } = useProjectYjs({ projectId, userName, @@ -841,8 +845,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = ); const readyValue = useMemo( - () => ({ isYjsReady, isProjectUnavailable }), - [isYjsReady, isProjectUnavailable], + () => ({ isYjsReady, isProjectUnavailable, migrationOutcome }), + [isYjsReady, isProjectUnavailable, migrationOutcome], ); return ( diff --git a/src/lib/import/import-project.ts b/src/lib/import/import-project.ts index fc89bede..ea134c6b 100644 --- a/src/lib/import/import-project.ts +++ b/src/lib/import/import-project.ts @@ -4,6 +4,7 @@ */ import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState } from "@src/lib/project/project-state"; +import { CURRENT_PROJECT_VERSION } from "@src/lib/project/migrations/project-migrations"; import { getAdapterByFilename } from "@src/lib/adapters/registry"; import { createCachedProject, createCachedProjectWithId } from "@src/lib/persistence/storage-provider/local-persistence"; import { writeYjsDocumentLocally } from "@src/lib/persistence/y-local-provider"; @@ -88,10 +89,16 @@ async function createLocalYjsDocument(projectId: string, projectData: ProjectDat } // Maps + const metadataMap = ydoc.metadata(); if (projectData.metadata) { - const metadataMap = ydoc.metadata(); Object.entries(projectData.metadata).forEach(([key, value]) => metadataMap.set(key as keyof ProjectMetadata, value)); } + // Stamp the schema version: preserves the imported file's version if it + // had one (so future-version files surface a migration error on open), + // otherwise marks the doc as current so it skips migration on first load. + if (metadataMap.get("version") === undefined) { + metadataMap.set("version", CURRENT_PROJECT_VERSION); + } if (projectData.characters) { const charactersMap = ydoc.characters(); diff --git a/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts b/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts index 7a5a970b..5ac71cec 100644 --- a/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts +++ b/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts @@ -1,16 +1,22 @@ /** * IndexedDB storage provider for browser environments. * Stores project metadata and settings in IndexedDB. + * + * Schema versioning is delegated to the migrations/ module; bumping + * CURRENT_STORE_VERSION there is what triggers `onupgradeneeded`. */ import type { InstalledDictionary, UserSettings } from "@src/lib/utils/types"; import { CachedProject, ProjectEntryInput, StorageProvider } from "./storage-provider"; +import { CURRENT_STORE_VERSION, STORE_NAMES } from "./migrations/store-migrations"; +import { runStoreMigrations } from "./migrations/store-migration-runner"; +import { StoreVersionTooNewError } from "./migrations/errors"; const BROWSER_DB_NAME = "scriptio-local"; -const BROWSER_DB_VERSION = 1; -const PROJECTS_STORE = "cached_projects"; -const SETTINGS_STORE = "settings"; -const DICTIONARIES_STORE = "dictionaries"; +const PROJECTS_STORE = STORE_NAMES.PROJECTS; +const SETTINGS_STORE = STORE_NAMES.SETTINGS; +const DICTIONARIES_STORE = STORE_NAMES.DICTIONARIES; +const MIGRATION_BACKUPS_STORE = STORE_NAMES.MIGRATION_BACKUPS; const SETTINGS_KEY = "global"; interface BrowserStoredProject { @@ -23,24 +29,41 @@ interface BrowserStoredProject { is_synced: number; // 0 = local-only, 1 = cloud-synced } +interface MigrationBackupRecord { + projectId: string; + snapshot: Uint8Array; + fromVersion: number; + createdAt: number; +} + let browserDbInstance: IDBDatabase | null = null; async function getBrowserDb(): Promise { if (browserDbInstance) return browserDbInstance; return new Promise((resolve, reject) => { - const request = indexedDB.open(BROWSER_DB_NAME, BROWSER_DB_VERSION); + const request = indexedDB.open(BROWSER_DB_NAME, CURRENT_STORE_VERSION); + // Holds a migration error from inside onupgradeneeded so onerror can surface it + // instead of the generic AbortError that fires when we manually abort the tx. + let upgradeError: unknown = null; request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(PROJECTS_STORE)) { - db.createObjectStore(PROJECTS_STORE, { keyPath: "id" }); - } - if (!db.objectStoreNames.contains(SETTINGS_STORE)) { - db.createObjectStore(SETTINGS_STORE); + const req = event.target as IDBOpenDBRequest; + const tx = req.transaction; + if (!tx) { + upgradeError = new Error("upgrade transaction missing"); + return; } - if (!db.objectStoreNames.contains(DICTIONARIES_STORE)) { - db.createObjectStore(DICTIONARIES_STORE, { keyPath: "code" }); + try { + runStoreMigrations({ + db: req.result, + tx, + oldVersion: event.oldVersion, + newVersion: event.newVersion ?? CURRENT_STORE_VERSION, + }); + } catch (err) { + upgradeError = err; + tx.abort(); } }; @@ -48,7 +71,20 @@ async function getBrowserDb(): Promise { browserDbInstance = request.result; resolve(browserDbInstance); }; - request.onerror = () => reject(request.error); + request.onerror = () => { + if (upgradeError) { + reject(upgradeError); + return; + } + const err = request.error; + // VersionError fires when the on-disk DB has a higher version than + // what we're requesting (user installed a newer build, then downgraded). + if (err && err.name === "VersionError") { + reject(new StoreVersionTooNewError(0, CURRENT_STORE_VERSION)); + } else { + reject(err); + } + }; }); } @@ -257,4 +293,45 @@ export class IndexedDBStorageProvider implements StorageProvider { req.onerror = () => reject(req.error); }); } + + async saveMigrationBackup(projectId: string, snapshot: Uint8Array, fromVersion: number): Promise { + const db = await getBrowserDb(); + const record: MigrationBackupRecord = { + projectId, + snapshot, + fromVersion, + createdAt: Date.now(), + }; + return new Promise((resolve, reject) => { + const tx = db.transaction(MIGRATION_BACKUPS_STORE, "readwrite"); + tx.objectStore(MIGRATION_BACKUPS_STORE).put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async loadMigrationBackup(projectId: string): Promise<{ snapshot: Uint8Array; fromVersion: number } | null> { + const db = await getBrowserDb(); + return new Promise((resolve, reject) => { + const req = db + .transaction(MIGRATION_BACKUPS_STORE, "readonly") + .objectStore(MIGRATION_BACKUPS_STORE) + .get(projectId); + req.onsuccess = () => { + const result = req.result as MigrationBackupRecord | undefined; + resolve(result ? { snapshot: result.snapshot, fromVersion: result.fromVersion } : null); + }; + req.onerror = () => reject(req.error); + }); + } + + async clearMigrationBackup(projectId: string): Promise { + const db = await getBrowserDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(MIGRATION_BACKUPS_STORE, "readwrite"); + tx.objectStore(MIGRATION_BACKUPS_STORE).delete(projectId); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } } diff --git a/src/lib/persistence/storage-provider/migrations/errors.ts b/src/lib/persistence/storage-provider/migrations/errors.ts new file mode 100644 index 00000000..4a6965f5 --- /dev/null +++ b/src/lib/persistence/storage-provider/migrations/errors.ts @@ -0,0 +1,32 @@ +/** + * Typed errors for storage-level migrations. Surfaced to the UI so users + * see actionable messages instead of opaque IndexedDB exceptions. + */ + +export class StoreVersionTooNewError extends Error { + readonly storedVersion: number; + readonly expectedVersion: number; + + constructor(storedVersion: number, expectedVersion: number) { + super( + `Stored IndexedDB schema version (${storedVersion}) is newer than the app expects (${expectedVersion}). Update the app to open this data.`, + ); + this.name = "StoreVersionTooNewError"; + this.storedVersion = storedVersion; + this.expectedVersion = expectedVersion; + } +} + +export class StoreMigrationFailedError extends Error { + readonly fromVersion: number; + readonly failedAt: number; + readonly cause: unknown; + + constructor(fromVersion: number, failedAt: number, cause: unknown) { + super(`IndexedDB schema migration ${fromVersion} → ${failedAt} failed: ${String(cause)}`); + this.name = "StoreMigrationFailedError"; + this.fromVersion = fromVersion; + this.failedAt = failedAt; + this.cause = cause; + } +} diff --git a/src/lib/persistence/storage-provider/migrations/store-migration-runner.ts b/src/lib/persistence/storage-provider/migrations/store-migration-runner.ts new file mode 100644 index 00000000..c2184f6e --- /dev/null +++ b/src/lib/persistence/storage-provider/migrations/store-migration-runner.ts @@ -0,0 +1,41 @@ +/** + * Walks the StoreMigration registry inside an IndexedDB versionchange + * transaction. Called from `onupgradeneeded`. + */ + +import { StoreMigrationFailedError } from "./errors"; +import { STORE_MIGRATIONS, type StoreMigration } from "./store-migrations"; + +export interface RunStoreMigrationsArgs { + db: IDBDatabase; + tx: IDBTransaction; + oldVersion: number; + newVersion: number; + migrations?: StoreMigration[]; +} + +/** + * Apply every migration whose `from >= oldVersion && to <= newVersion`, + * in order. The IDB transaction is shared with the caller — if any step + * throws, the browser aborts the transaction automatically and the on-disk + * schema is left at `oldVersion`. + */ +export function runStoreMigrations({ + db, + tx, + oldVersion, + newVersion, + migrations = STORE_MIGRATIONS, +}: RunStoreMigrationsArgs): void { + const steps = migrations + .filter((m) => m.from >= oldVersion && m.to <= newVersion) + .sort((a, b) => a.from - b.from); + + for (const step of steps) { + try { + step.run(db, tx); + } catch (cause) { + throw new StoreMigrationFailedError(step.from, step.to, cause); + } + } +} diff --git a/src/lib/persistence/storage-provider/migrations/store-migrations.ts b/src/lib/persistence/storage-provider/migrations/store-migrations.ts new file mode 100644 index 00000000..c82909ef --- /dev/null +++ b/src/lib/persistence/storage-provider/migrations/store-migrations.ts @@ -0,0 +1,71 @@ +/** + * Registry of IndexedDB schema migrations for the app-wide `scriptio-local` database. + * + * Each entry is `{ from, to, run }`. `run` executes inside the IndexedDB + * `versionchange` transaction, so it must be synchronous and only use IDB + * operations available in upgrade transactions (createObjectStore / + * deleteObjectStore / cursors over existing stores). + * + * Adding a new migration: + * 1. Append a new step with `from = previous .to` and `to = previous.to + 1`. + * 2. The runner derives `CURRENT_STORE_VERSION` from the last `to`. + * 3. Existing users will run the new step on next app launch via `onupgradeneeded`. + */ + +export interface StoreMigration { + from: number; + to: number; + description: string; + run: (db: IDBDatabase, tx: IDBTransaction) => void; +} + +/** Object store names — kept in sync with IndexedDBStorageProvider. */ +export const STORE_NAMES = { + PROJECTS: "cached_projects", + SETTINGS: "settings", + DICTIONARIES: "dictionaries", + MIGRATION_BACKUPS: "migration_backups", +} as const; + +/** + * v0 → v1: baseline. Creates the three original stores. This is the schema + * shipped before the migration framework existed; users already on v1 skip + * this step. + */ +const baselineV1: StoreMigration = { + from: 0, + to: 1, + description: "Baseline: create cached_projects, settings, dictionaries", + run: (db) => { + if (!db.objectStoreNames.contains(STORE_NAMES.PROJECTS)) { + db.createObjectStore(STORE_NAMES.PROJECTS, { keyPath: "id" }); + } + if (!db.objectStoreNames.contains(STORE_NAMES.SETTINGS)) { + db.createObjectStore(STORE_NAMES.SETTINGS); + } + if (!db.objectStoreNames.contains(STORE_NAMES.DICTIONARIES)) { + db.createObjectStore(STORE_NAMES.DICTIONARIES, { keyPath: "code" }); + } + }, +}; + +/** + * v1 → v2: add a `migration_backups` store. The project-doc migration runner + * snapshots a Y.Doc here before mutating it, so a failed migration can be + * rolled back from the backup. + */ +const addMigrationBackupsStore: StoreMigration = { + from: 1, + to: 2, + description: "Add migration_backups store for project doc rollback snapshots", + run: (db) => { + if (!db.objectStoreNames.contains(STORE_NAMES.MIGRATION_BACKUPS)) { + db.createObjectStore(STORE_NAMES.MIGRATION_BACKUPS, { keyPath: "projectId" }); + } + }, +}; + +export const STORE_MIGRATIONS: StoreMigration[] = [baselineV1, addMigrationBackupsStore]; + +export const CURRENT_STORE_VERSION = + STORE_MIGRATIONS.length === 0 ? 1 : STORE_MIGRATIONS[STORE_MIGRATIONS.length - 1].to; diff --git a/src/lib/persistence/storage-provider/storage-provider.ts b/src/lib/persistence/storage-provider/storage-provider.ts index 4ce89296..69a4ca8c 100644 --- a/src/lib/persistence/storage-provider/storage-provider.ts +++ b/src/lib/persistence/storage-provider/storage-provider.ts @@ -47,6 +47,12 @@ export interface StorageProvider { loadDictionary(code: string): Promise<{ aff: Uint8Array; dic: Uint8Array } | null>; deleteDictionary(code: string): Promise; listInstalledDictionaries(): Promise; + + // Migration backups: pre-migration Yjs document snapshots (one per project). + // Used by the project-doc migration runner to roll back if a step throws. + saveMigrationBackup(projectId: string, snapshot: Uint8Array, fromVersion: number): Promise; + loadMigrationBackup(projectId: string): Promise<{ snapshot: Uint8Array; fromVersion: number } | null>; + clearMigrationBackup(projectId: string): Promise; } // Singleton cache diff --git a/src/lib/project/migrations/project-migration-runner.ts b/src/lib/project/migrations/project-migration-runner.ts new file mode 100644 index 00000000..e8550fd4 --- /dev/null +++ b/src/lib/project/migrations/project-migration-runner.ts @@ -0,0 +1,147 @@ +/** + * Drives `PROJECT_MIGRATIONS` against a single `ProjectState`. Called from + * `useLocalPersistence` after `y-indexeddb` has fully synced and **before** + * the cloud `WebsocketProvider` connects, to avoid concurrent migrations + * across clients producing duplicated effects. + */ + +import * as Y from "yjs"; +import type { ProjectState } from "../project-state"; +import { + CURRENT_PROJECT_VERSION, + LEGACY_PROJECT_VERSION, + PROJECT_MIGRATIONS, + type ProjectMigration, +} from "./project-migrations"; + +export type ProjectMigrationOutcome = + | { kind: "up-to-date"; version: number } + | { kind: "migrated"; from: number; to: number; appliedSteps: string[] } + | { kind: "future-version"; storedVersion: number; expected: number } + | { kind: "failed"; from: number; failedAt: number; error: Error }; + +const MIGRATION_ORIGIN = "migration"; + +interface BackupStore { + saveMigrationBackup(projectId: string, snapshot: Uint8Array, fromVersion: number): Promise; +} + +export interface MigrateProjectDocArgs { + ydoc: ProjectState; + projectId: string; + /** Override for tests; defaults to `getStorageProvider()`. */ + backupStore?: BackupStore; + /** Override for tests; defaults to `PROJECT_MIGRATIONS`. */ + migrations?: ProjectMigration[]; + /** Override for tests; defaults to `CURRENT_PROJECT_VERSION`. */ + currentVersion?: number; +} + +async function defaultBackupStore(): Promise { + const { getStorageProvider } = await import("../../persistence/storage-provider/storage-provider"); + return getStorageProvider(); +} + +function readVersion(ydoc: ProjectState): number { + const stored = ydoc.metadata().get("version"); + return typeof stored === "number" ? stored : LEGACY_PROJECT_VERSION; +} + +/** + * Migrate a project Y.Doc to `CURRENT_PROJECT_VERSION`. + * + * Returns an outcome describing what happened. The caller decides UI + * (block load on `future-version`/`failed`, show the doc on `up-to-date`/`migrated`). + */ +export async function migrateProjectDoc({ + ydoc, + projectId, + backupStore, + migrations = PROJECT_MIGRATIONS, + currentVersion = CURRENT_PROJECT_VERSION, +}: MigrateProjectDocArgs): Promise { + const stored = readVersion(ydoc); + + if (stored === currentVersion) { + return { kind: "up-to-date", version: stored }; + } + if (stored > currentVersion) { + return { kind: "future-version", storedVersion: stored, expected: currentVersion }; + } + + const steps = migrations + .filter((m) => m.from >= stored && m.to <= currentVersion) + .sort((a, b) => a.from - b.from); + + if (steps.length === 0) { + // Nothing to run, but the version field is stale — bring it forward. + ydoc.transact(() => { + ydoc.metadata().set("version", currentVersion); + }, MIGRATION_ORIGIN); + return { kind: "migrated", from: stored, to: currentVersion, appliedSteps: [] }; + } + + // Snapshot the doc before mutating so a failed step can be rolled back. + const snapshot = Y.encodeStateAsUpdate(ydoc); + const store = backupStore ?? (await defaultBackupStore()); + await store.saveMigrationBackup(projectId, snapshot, stored); + + const applied: string[] = []; + for (const step of steps) { + try { + ydoc.transact(() => step.run(ydoc), MIGRATION_ORIGIN); + applied.push(step.description); + } catch (cause) { + return { + kind: "failed", + from: stored, + failedAt: step.to, + error: cause instanceof Error ? cause : new Error(String(cause)), + }; + } + } + + ydoc.transact(() => { + ydoc.metadata().set("version", currentVersion); + }, MIGRATION_ORIGIN); + + return { kind: "migrated", from: stored, to: currentVersion, appliedSteps: applied }; +} + +/** + * Replay a stored backup snapshot into a fresh Y.Doc, then write that doc + * back to local persistence. Used when the user picks "Restore from backup" + * after a failed migration. + */ +export async function restoreProjectFromBackup(projectId: string): Promise { + const { getStorageProvider } = await import("../../persistence/storage-provider/storage-provider"); + const provider = await getStorageProvider(); + const backup = await provider.loadMigrationBackup(projectId); + if (!backup) return false; + + const { yjsDbKey } = await import("../../persistence/y-local-provider"); + const { IndexeddbPersistence } = await import("y-indexeddb"); + + // Wipe the current Yjs DB by clearing the existing persistence. + const wipeDoc = new Y.Doc(); + const wipePersistence = new IndexeddbPersistence(yjsDbKey(projectId), wipeDoc); + await new Promise((resolve) => wipePersistence.on("synced", () => resolve())); + const clearable = wipePersistence as unknown as { clearData?: () => Promise }; + if (typeof clearable.clearData === "function") { + await clearable.clearData(); + } + wipePersistence.destroy(); + wipeDoc.destroy(); + + // Replay the snapshot into a fresh doc and write it back. + const restoredDoc = new Y.Doc(); + Y.applyUpdate(restoredDoc, backup.snapshot); + const writePersistence = new IndexeddbPersistence(yjsDbKey(projectId), restoredDoc); + await new Promise((resolve) => writePersistence.on("synced", () => resolve())); + await new Promise((resolve) => setTimeout(resolve, 100)); + writePersistence.destroy(); + restoredDoc.destroy(); + + await provider.clearMigrationBackup(projectId); + return true; +} diff --git a/src/lib/project/migrations/project-migrations.ts b/src/lib/project/migrations/project-migrations.ts new file mode 100644 index 00000000..972bd0cb --- /dev/null +++ b/src/lib/project/migrations/project-migrations.ts @@ -0,0 +1,50 @@ +/** + * Registry of per-project Yjs document migrations. + * + * Each migration mutates a `ProjectState` (Y.Doc) in place. The runner wraps + * the call in a single Y transaction with origin "migration". + * + * Hard requirements for `run`: + * - **Idempotent**. A cloud-synced project may receive concurrent migrations + * from multiple clients; CRDT merge must produce the same state regardless + * of how many times the migration ran. In practice: only set keys, don't + * append blindly to lists, don't re-derive existing data. + * - **Synchronous**. The runner does the transact wrapping. + * - **No I/O**. Migrations operate purely on the Y.Doc. + * + * Adding a new migration: + * 1. Append a new step with `from = previous.to`, `to = previous.to + 1`. + * 2. The runner derives `CURRENT_PROJECT_VERSION` from the last `to`. + * 3. Existing projects upgrade lazily on first open. + */ + +import type { ProjectState } from "../project-state"; + +export interface ProjectMigration { + from: number; + to: number; + description: string; + run: (ydoc: ProjectState) => void; +} + +export const PROJECT_MIGRATIONS: ProjectMigration[] = [ + // Example for the next breaking change: + // { + // from: 1, + // to: 2, + // description: "Move legacy comments from screenplay marks into the comments map", + // run: (ydoc) => { ... }, + // }, +]; + +/** + * Version every project should be at after migration. Legacy projects (no + * version field in metadata) are treated as version 1, matching the schema + * shipped before the migration framework existed. + */ +export const LEGACY_PROJECT_VERSION = 1; + +export const CURRENT_PROJECT_VERSION = + PROJECT_MIGRATIONS.length === 0 + ? LEGACY_PROJECT_VERSION + : PROJECT_MIGRATIONS[PROJECT_MIGRATIONS.length - 1].to; diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 572c298c..0150b54f 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -16,6 +16,7 @@ import type { LocationItem, LocationMap } from "../screenplay/locations"; import type { PersistentScene, PersistentSceneMap } from "../screenplay/scenes"; import type { Comment } from "../utils/types"; import type { YjsLocalProvider } from "../persistence/y-local-provider"; +import type { ProjectMigrationOutcome } from "./migrations/project-migration-runner"; // Lazy re-export repository for convenient access (avoid loading yjs at module level) export const getProjectRepository = async () => { @@ -58,6 +59,7 @@ export interface ProjectYjsState { isLockedByServer: boolean; isSessionReplaced: boolean; isProjectUnavailable: boolean; + migrationOutcome: ProjectMigrationOutcome | null; } export interface CollaboratorInfo { @@ -352,12 +354,14 @@ export const getBoardMap = (ydoc: ProjectState): TypedMap => { export const useLocalPersistence = (projectId: string | null) => { const [ydoc, setYdoc] = useState(null); const [isLocalReady, setIsLocalReady] = useState(false); + const [migrationOutcome, setMigrationOutcome] = useState(null); const persistenceRef = useRef(null); useEffect(() => { if (!projectId || typeof window === "undefined") { setYdoc(null); setIsLocalReady(false); + setMigrationOutcome(null); return; } @@ -367,8 +371,16 @@ export const useLocalPersistence = (projectId: string | null) => { const { createLocalYjsProvider } = await import("../persistence/y-local-provider"); const localProvider = await createLocalYjsProvider(projectId, state); - localProvider.on("synced", () => { + localProvider.on("synced", async () => { if (isDestroyed) return; + const { migrateProjectDoc } = await import("./migrations/project-migration-runner"); + const outcome = await migrateProjectDoc({ ydoc: state, projectId }); + if (isDestroyed) return; + setMigrationOutcome(outcome); + if (outcome.kind === "future-version" || outcome.kind === "failed") { + // Block UI from rendering the project; layout shows error dialog instead. + return; + } setIsLocalReady(true); }); @@ -392,10 +404,11 @@ export const useLocalPersistence = (projectId: string | null) => { return null; }); setIsLocalReady(false); + setMigrationOutcome(null); }; }, [projectId]); - return { ydoc, isLocalReady }; + return { ydoc, isLocalReady, migrationOutcome }; }; // -------------------------------- // @@ -700,7 +713,7 @@ export const useProjectYjs = ({ [userName, userColor, userId, fallback.name, fallback.color], ); - const { ydoc, isLocalReady } = useLocalPersistence(projectId); + const { ydoc, isLocalReady, migrationOutcome } = useLocalPersistence(projectId); const { provider, users, @@ -710,7 +723,7 @@ export const useProjectYjs = ({ isLockedByServer, isSessionReplaced, isProjectUnavailable, - } = useCloudSync(projectId, ydoc, userInfo); + } = useCloudSync(projectId, isLocalReady ? ydoc : null, userInfo); // isReady: project is ready when ydoc exists and local storage is synced // Cloud sync happens in the background and will merge data when it arrives @@ -730,6 +743,7 @@ export const useProjectYjs = ({ isLockedByServer, isSessionReplaced, isProjectUnavailable, + migrationOutcome, }; }; diff --git a/src/tests/migrations/project-migration-runner.test.ts b/src/tests/migrations/project-migration-runner.test.ts new file mode 100644 index 00000000..cf5bb410 --- /dev/null +++ b/src/tests/migrations/project-migration-runner.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; + +import { ProjectState } from "@src/lib/project/project-state"; +import { migrateProjectDoc } from "@src/lib/project/migrations/project-migration-runner"; +import type { ProjectMigration } from "@src/lib/project/migrations/project-migrations"; + +interface FakeBackupStore { + saveMigrationBackup: (projectId: string, snapshot: Uint8Array, fromVersion: number) => Promise; + saved: Array<{ projectId: string; snapshot: Uint8Array; fromVersion: number }>; +} + +function makeBackupStore(): FakeBackupStore { + const saved: FakeBackupStore["saved"] = []; + return { + saved, + async saveMigrationBackup(projectId, snapshot, fromVersion) { + saved.push({ projectId, snapshot, fromVersion }); + }, + }; +} + +describe("migrateProjectDoc", () => { + it("returns up-to-date and writes nothing when version equals current", async () => { + const ydoc = new ProjectState(); + ydoc.metadata().set("version", 3); + const before = Y.encodeStateAsUpdate(ydoc); + + const backup = makeBackupStore(); + const outcome = await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: backup, + migrations: [], + currentVersion: 3, + }); + + expect(outcome.kind).toBe("up-to-date"); + expect(backup.saved).toHaveLength(0); + + const after = Y.encodeStateAsUpdate(ydoc); + // No mutations: byte-identical state. + expect(after).toEqual(before); + ydoc.destroy(); + }); + + it("treats a missing version field as legacy v1", async () => { + const ydoc = new ProjectState(); + const backup = makeBackupStore(); + + const outcome = await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: backup, + migrations: [], + currentVersion: 1, + }); + + expect(outcome.kind).toBe("up-to-date"); + if (outcome.kind === "up-to-date") { + expect(outcome.version).toBe(1); + } + ydoc.destroy(); + }); + + it("blocks future-version projects without mutating", async () => { + const ydoc = new ProjectState(); + ydoc.metadata().set("version", 99); + const before = Y.encodeStateAsUpdate(ydoc); + + const backup = makeBackupStore(); + const outcome = await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: backup, + migrations: [], + currentVersion: 2, + }); + + expect(outcome.kind).toBe("future-version"); + if (outcome.kind === "future-version") { + expect(outcome.storedVersion).toBe(99); + expect(outcome.expected).toBe(2); + } + expect(backup.saved).toHaveLength(0); + expect(Y.encodeStateAsUpdate(ydoc)).toEqual(before); + ydoc.destroy(); + }); + + it("applies a chain of steps in order and bumps version atomically", async () => { + const ydoc = new ProjectState(); + // Stored version = 1 (legacy default). + const ran: string[] = []; + const migrations: ProjectMigration[] = [ + { + from: 1, + to: 2, + description: "bump-author", + run: (doc) => { + ran.push("v2"); + doc.metadata().set("author", "migrated-author"); + }, + }, + { + from: 2, + to: 3, + description: "bump-title", + run: (doc) => { + ran.push("v3"); + doc.metadata().set("title", "migrated-title"); + }, + }, + ]; + + const outcome = await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: makeBackupStore(), + migrations, + currentVersion: 3, + }); + + expect(outcome.kind).toBe("migrated"); + if (outcome.kind === "migrated") { + expect(outcome.from).toBe(1); + expect(outcome.to).toBe(3); + expect(outcome.appliedSteps).toEqual(["bump-author", "bump-title"]); + } + expect(ran).toEqual(["v2", "v3"]); + expect(ydoc.metadata().get("version")).toBe(3); + expect(ydoc.metadata().get("author")).toBe("migrated-author"); + expect(ydoc.metadata().get("title")).toBe("migrated-title"); + ydoc.destroy(); + }); + + it("saves a pre-migration backup before mutating", async () => { + const ydoc = new ProjectState(); + ydoc.metadata().set("title", "before"); + const beforeSnapshot = Y.encodeStateAsUpdate(ydoc); + + const backup = makeBackupStore(); + const migrations: ProjectMigration[] = [ + { + from: 1, + to: 2, + description: "rewrite", + run: (doc) => doc.metadata().set("title", "after"), + }, + ]; + + await migrateProjectDoc({ + ydoc, + projectId: "proj-42", + backupStore: backup, + migrations, + currentVersion: 2, + }); + + expect(backup.saved).toHaveLength(1); + expect(backup.saved[0].projectId).toBe("proj-42"); + expect(backup.saved[0].fromVersion).toBe(1); + expect(backup.saved[0].snapshot).toEqual(beforeSnapshot); + ydoc.destroy(); + }); + + it("returns failed and does not advance version when a step throws", async () => { + const ydoc = new ProjectState(); + ydoc.metadata().set("title", "T"); + + const migrations: ProjectMigration[] = [ + { from: 1, to: 2, description: "ok", run: (doc) => doc.metadata().set("author", "A") }, + { from: 2, to: 3, description: "bad", run: () => { throw new Error("boom"); } }, + ]; + + const outcome = await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: makeBackupStore(), + migrations, + currentVersion: 3, + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.from).toBe(1); + expect(outcome.failedAt).toBe(3); + expect(outcome.error.message).toBe("boom"); + } + // Step 1 ran (its mutation is visible) but version was NOT advanced. + expect(ydoc.metadata().get("author")).toBe("A"); + expect(ydoc.metadata().get("version")).toBeUndefined(); + ydoc.destroy(); + }); + + it("running the same migration chain twice is idempotent", async () => { + const ydoc = new ProjectState(); + const migrations: ProjectMigration[] = [ + { + from: 1, + to: 2, + description: "set-flag", + run: (doc) => doc.metadata().set("titlepageInitialized", true), + }, + ]; + + await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: makeBackupStore(), + migrations, + currentVersion: 2, + }); + const stateAfterFirst = Y.encodeStateAsUpdate(ydoc); + + // Second invocation: stored is now 2, equals current → up-to-date, no writes. + const outcome = await migrateProjectDoc({ + ydoc, + projectId: "p", + backupStore: makeBackupStore(), + migrations, + currentVersion: 2, + }); + expect(outcome.kind).toBe("up-to-date"); + expect(Y.encodeStateAsUpdate(ydoc)).toEqual(stateAfterFirst); + ydoc.destroy(); + }); +}); diff --git a/src/tests/migrations/store-migration-runner.test.ts b/src/tests/migrations/store-migration-runner.test.ts new file mode 100644 index 00000000..0bba24ca --- /dev/null +++ b/src/tests/migrations/store-migration-runner.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runStoreMigrations } from "@src/lib/persistence/storage-provider/migrations/store-migration-runner"; +import { + STORE_MIGRATIONS, + STORE_NAMES, + type StoreMigration, +} from "@src/lib/persistence/storage-provider/migrations/store-migrations"; +import { StoreMigrationFailedError } from "@src/lib/persistence/storage-provider/migrations/errors"; + +let dbName: string; + +function uniqueName() { + return `scriptio-test-${Math.random().toString(36).slice(2)}`; +} + +function openWithMigrations( + name: string, + version: number, + migrations: StoreMigration[], +): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, version); + let upgradeError: unknown = null; + req.onupgradeneeded = (event) => { + const r = event.target as IDBOpenDBRequest; + const tx = r.transaction; + if (!tx) { + upgradeError = new Error("missing tx"); + return; + } + try { + runStoreMigrations({ + db: r.result, + tx, + oldVersion: event.oldVersion, + newVersion: event.newVersion ?? version, + migrations, + }); + } catch (err) { + upgradeError = err; + tx.abort(); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(upgradeError ?? req.error); + }); +} + +function deleteDb(name: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }); +} + +beforeEach(() => { + dbName = uniqueName(); +}); + +afterEach(async () => { + await deleteDb(dbName); +}); + +describe("runStoreMigrations", () => { + it("applies the full registry on a fresh database", async () => { + const db = await openWithMigrations(dbName, 2, STORE_MIGRATIONS); + expect(Array.from(db.objectStoreNames).sort()).toEqual( + [ + STORE_NAMES.PROJECTS, + STORE_NAMES.SETTINGS, + STORE_NAMES.DICTIONARIES, + STORE_NAMES.MIGRATION_BACKUPS, + ].sort(), + ); + db.close(); + }); + + it("only runs steps within [oldVersion, newVersion]", async () => { + const ran: string[] = []; + const m: StoreMigration[] = [ + { from: 0, to: 1, description: "v1", run: () => ran.push("v1") }, + { from: 1, to: 2, description: "v2", run: () => ran.push("v2") }, + { from: 2, to: 3, description: "v3", run: () => ran.push("v3") }, + ]; + const db = await openWithMigrations(dbName, 2, m); + expect(ran).toEqual(["v1", "v2"]); + db.close(); + }); + + it("walks multi-step chains in order from oldVersion to newVersion", async () => { + const ran: number[] = []; + const m: StoreMigration[] = [ + { from: 0, to: 1, description: "v1", run: (db) => { ran.push(1); db.createObjectStore("a"); } }, + { from: 1, to: 2, description: "v2", run: (db) => { ran.push(2); db.createObjectStore("b"); } }, + { from: 2, to: 3, description: "v3", run: (db) => { ran.push(3); db.createObjectStore("c"); } }, + ]; + const db = await openWithMigrations(dbName, 3, m); + expect(ran).toEqual([1, 2, 3]); + expect(Array.from(db.objectStoreNames).sort()).toEqual(["a", "b", "c"]); + db.close(); + }); + + it("aborts the upgrade transaction when a step throws and leaves the schema unchanged", async () => { + const m: StoreMigration[] = [ + { from: 0, to: 1, description: "v1", run: (db) => { db.createObjectStore("a"); } }, + { from: 1, to: 2, description: "v2 (throws)", run: () => { throw new Error("boom"); } }, + ]; + + let caught: unknown; + try { + const db = await openWithMigrations(dbName, 2, m); + db.close(); + } catch (e) { + caught = e; + } + // Either the runner throws StoreMigrationFailedError, or the IDB transaction + // aborts with an AbortError after the throw bubbles up. + const isExpected = + caught instanceof StoreMigrationFailedError || + (caught instanceof DOMException && (caught.name === "AbortError" || caught.name === "InvalidStateError")); + expect(isExpected).toBe(true); + + // Re-opening with version=1 should succeed and store "a" should not exist + // (the upgrade transaction was rolled back). + const reopen = await new Promise((resolve, reject) => { + const req = indexedDB.open(dbName); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + expect(reopen.version).toBe(0 < reopen.version ? reopen.version : 0); + // Browser may keep the DB at version 0 (never opened) or at 1 (partial open). + // Either way, store "a" must NOT exist because the throwing tx aborted. + if (reopen.version >= 1) { + expect(reopen.objectStoreNames.contains("a")).toBe(false); + } + reopen.close(); + }); + + it("upgrade from v1 to v2 adds only the new store and preserves existing data", async () => { + // Step 1: open at v1 with only the baseline. + const db1 = await openWithMigrations(dbName, 1, [STORE_MIGRATIONS[0]]); + await new Promise((resolve, reject) => { + const tx = db1.transaction(STORE_NAMES.PROJECTS, "readwrite"); + tx.objectStore(STORE_NAMES.PROJECTS).put({ + id: "p1", + title: "T", + description: null, + author: null, + createdAt: 0, + updatedAt: 0, + is_synced: 0, + }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db1.close(); + + // Step 2: re-open at v2 — only addMigrationBackupsStore should run. + const db2 = await openWithMigrations(dbName, 2, STORE_MIGRATIONS); + expect(db2.objectStoreNames.contains(STORE_NAMES.MIGRATION_BACKUPS)).toBe(true); + const persisted = await new Promise((resolve, reject) => { + const req = db2.transaction(STORE_NAMES.PROJECTS, "readonly").objectStore(STORE_NAMES.PROJECTS).get("p1"); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + expect((persisted as { id: string }).id).toBe("p1"); + db2.close(); + }); +}); diff --git a/src/tests/setup.ts b/src/tests/setup.ts index 84e9b174..8f3c83bb 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -7,6 +7,12 @@ class StubWorker { } (globalThis as Record).Worker = StubWorker; +// Browser test runtime has no `process` — shim it so modules that read +// `process.env.NEXT_PUBLIC_*` at top level can load without crashing. +if (typeof (globalThis as Record).process === "undefined") { + (globalThis as Record).process = { env: {} }; +} + // Suppress per-transaction console output from extensions globally. // beforeEach does not cover bench() cases in vitest browser mode. console.log = () => {}; From 1fc2252cb2a5731f07819b77d8f81f199429f3ef Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 25 Apr 2026 21:18:54 +0200 Subject: [PATCH 06/76] fixed tauri plugin iap --- src-tauri/.cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src-tauri/.cargo/config.toml diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml new file mode 100644 index 00000000..5b109c79 --- /dev/null +++ b/src-tauri/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +MACOSX_DEPLOYMENT_TARGET = "13.0" From aa20ca4563a099b5e1b83189f7106d4cb759fbe3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 26 Apr 2026 00:28:36 +0200 Subject: [PATCH 07/76] fixed apple build --- src-tauri/.cargo/config.toml | 2 -- src-tauri/tauri.conf.json | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 src-tauri/.cargo/config.toml diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml deleted file mode 100644 index 5b109c79..00000000 --- a/src-tauri/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[env] -MACOSX_DEPLOYMENT_TARGET = "13.0" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2dedf9cc..2d310606 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,6 +30,9 @@ "active": true, "targets": "all", "publisher": "Arko Logic", + "macOS": { + "minimumSystemVersion": "13.0" + }, "icon": [ "icons/32x32.png", "icons/128x128.png", From 0c382823112d4fbf5e35e465f1a2beb725677fdd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 26 Apr 2026 02:42:40 +0200 Subject: [PATCH 08/76] trying to fix staging tauri app --- components/dashboard/DashboardModal.tsx | 19 +++++-- components/dashboard/DashboardSidebar.tsx | 7 ++- .../home/desktop-oauth/DesktopOAuthStart.tsx | 13 +++-- components/navbar/HomeNavbar.tsx | 14 ++++- src/app/api/auth/magic-link/verify/route.ts | 52 ++++++++----------- src/app/api/desktop/token/route.ts | 4 +- src/auth.ts | 33 ++++++++++++ src/lib/auth-cookies.ts | 33 ++++++++++++ src/lib/session.ts | 13 ++--- 9 files changed, 140 insertions(+), 48 deletions(-) create mode 100644 src/lib/auth-cookies.ts diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index b42496de..0266148b 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useContext, useEffect, useMemo, useState, Suspense } from "react"; +import { useContext, useEffect, useMemo, useRef, useState, Suspense } from "react"; import { DashboardContext } from "@src/context/DashboardContext"; import { ProjectContext } from "@src/context/ProjectContext"; import { useCookieUser } from "@src/lib/utils/hooks"; @@ -71,17 +71,28 @@ const DashboardModal = () => { return sections; }, [isInProject, isSignedIn, PROJECT_MENU, PREFERENCES_MENU, ACCOUNT_MENU]); - // If active tab is a project tab but we're not in a project, or an account tab but not signed in, switch to first available tab + // Auto-switch active tab when the surrounding context changes: + // - leave a project tab when there's no longer a project to talk about + // - leave an account tab when the user signs out + // - on a real signed-out → signed-in *transition* while on the Auth form, + // jump to Profile so the user lands somewhere meaningful after sign-in. + // + // The transition guard (prevSignedInRef) is critical: without it, isSignedIn + // arriving as `true` for the first time after the SWR resolves looks identical + // to a real sign-in event, and clicking "Sign in" while user data is still + // loading would silently bounce the user to Profile. + const prevSignedInRef = useRef(isSignedIn); useEffect(() => { const projectTabIds = PROJECT_MENU.items.map((item) => item.id); const accountTabIds = ACCOUNT_MENU.items.map((item) => item.id); if ((!isInProject && projectTabIds.includes(activeTab)) || (!isSignedIn && accountTabIds.includes(activeTab))) { setActiveTab(PREFERENCES_MENU.items[0].id); } - // If user just signed in while on the Auth tab, switch to Profile - if (isSignedIn && activeTab === "Auth") { + const justSignedIn = isSignedIn && !prevSignedInRef.current; + if (justSignedIn && activeTab === "Auth") { setActiveTab("Profile"); } + prevSignedInRef.current = isSignedIn; }, [isInProject, isSignedIn, activeTab, setActiveTab, ACCOUNT_MENU, PREFERENCES_MENU, PROJECT_MENU]); const [prevActiveTab, setPrevActiveTab] = useState(activeTab); diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index ac69cdb5..54db8fa9 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -45,7 +45,7 @@ interface SidebarMenuProps { const SidebarMenu = ({ structure, activeTab, onTabChange }: SidebarMenuProps) => { const { closeDashboard } = useContext(DashboardContext); - const { user } = useCookieUser(); + const { user, isLoading: isUserLoading } = useCookieUser(); const t = useTranslations("sidebar"); const tModal = useTranslations("modal"); const [showLogOutConfirm, setShowLogOutConfirm] = useState(false); @@ -107,7 +107,10 @@ const SidebarMenu = ({ structure, activeTab, onTabChange }: SidebarMenuProps) => ))}
- {user ? ( + {/* While the user query is in flight, leave the slot empty rather than + rendering a "Sign in" button against an unknown auth state — clicking + it during loading races the SWR resolution and ends up on Profile. */} + {isUserLoading ? null : user ? ( )} - {error &&

{error}

} + {error &&

{error}

}
); }; diff --git a/components/projects/CreateProjectPage.tsx b/components/projects/CreateProjectPage.tsx index edab4e63..90946933 100644 --- a/components/projects/CreateProjectPage.tsx +++ b/components/projects/CreateProjectPage.tsx @@ -62,7 +62,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { if (selectedFile) { body.poster = await cropImageBase64(selectedFile, 686, 1016); } - const res = await createProject(user.id, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (res.ok && json.data) { projectId = json.data.id; @@ -104,7 +104,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { body.poster = await cropImageBase64(selectedFile, 686, 1016); } - const res = await createProject(user.id, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (!res.ok || !json.data) { setFormInfo({ content: json.message!, isError: true }); diff --git a/messages/de.json b/messages/de.json index dce8e7f5..a0c0217d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -148,7 +148,9 @@ "purchasing": "Kauf wird durchgeführt...", "purchaseError": "Kauf fehlgeschlagen. Bitte erneut versuchen.", "cancelApple": "Apple-Abonnements werden über den App Store verwaltet.", - "manageApple": "App Store öffnen" + "manageApple": "App Store öffnen", + "alreadyBoundTo": "Dieses Abonnement ist bereits mit {email} verknüpft.", + "alreadyBoundUnknown": "Dieses Abonnement ist bereits mit einem anderen Konto verknüpft." } }, "projects": { @@ -465,4 +467,4 @@ "monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}", "moreThanYearAgo": "Vor über einem Jahr" } -} +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 39d3bb9e..4bcca531 100644 --- a/messages/en.json +++ b/messages/en.json @@ -147,7 +147,9 @@ "purchasing": "Purchasing...", "purchaseError": "Purchase failed. Please try again.", "cancelApple": "Apple subscriptions are managed through the App Store.", - "manageApple": "Open App Store" + "manageApple": "Open App Store", + "alreadyBoundTo": "This subscription is already linked to {email}.", + "alreadyBoundUnknown": "This subscription is already linked to another account." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months, plural, one {# month ago} other {# months ago}}", "moreThanYearAgo": "More than 1 year ago" } -} +} \ No newline at end of file diff --git a/messages/es.json b/messages/es.json index ab41adb9..f62a42d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -147,7 +147,9 @@ "purchasing": "Comprando...", "purchaseError": "La compra falló. Inténtalo de nuevo.", "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", - "manageApple": "Abrir App Store" + "manageApple": "Abrir App Store", + "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", + "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", "moreThanYearAgo": "Hace más de 1 año" } -} +} \ No newline at end of file diff --git a/messages/fr.json b/messages/fr.json index dc30105a..f2b1eb62 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -148,7 +148,9 @@ "purchasing": "Achat en cours...", "purchaseError": "L'achat a échoué. Veuillez réessayer.", "cancelApple": "Les abonnements Apple sont gérés via l'App Store.", - "manageApple": "Ouvrir l'App Store" + "manageApple": "Ouvrir l'App Store", + "alreadyBoundTo": "Cet abonnement est déjà associé à {email}.", + "alreadyBoundUnknown": "Cet abonnement est déjà associé à un autre compte." } }, "projects": { @@ -465,4 +467,4 @@ "monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}", "moreThanYearAgo": "Il y a plus d'un an" } -} +} \ No newline at end of file diff --git a/messages/ja.json b/messages/ja.json index 84f66eee..f6428a42 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -147,7 +147,9 @@ "purchasing": "購入中...", "purchaseError": "購入に失敗しました。もう一度お試しください。", "cancelApple": "Appleのサブスクリプションはアプリストアで管理されます。", - "manageApple": "App Storeを開く" + "manageApple": "App Storeを開く", + "alreadyBoundTo": "このサブスクリプションは既に {email} に関連付けられています。", + "alreadyBoundUnknown": "このサブスクリプションは既に別のアカウントに関連付けられています。" } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months}ヶ月前", "moreThanYearAgo": "1年以上前" } -} +} \ No newline at end of file diff --git a/messages/ko.json b/messages/ko.json index c9eb3baf..d031da89 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -147,7 +147,9 @@ "purchasing": "구매 중...", "purchaseError": "구매에 실패했습니다. 다시 시도해 주세요.", "cancelApple": "Apple 구독은 App Store에서 관리됩니다.", - "manageApple": "App Store 열기" + "manageApple": "App Store 열기", + "alreadyBoundTo": "이 구독은 이미 {email}에 연결되어 있습니다.", + "alreadyBoundUnknown": "이 구독은 이미 다른 계정에 연결되어 있습니다." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months}달 전", "moreThanYearAgo": "1년 이상 전" } -} +} \ No newline at end of file diff --git a/messages/pl.json b/messages/pl.json index 0b3aeb35..b982fd08 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -147,7 +147,9 @@ "purchasing": "Zakup w toku...", "purchaseError": "Zakup nie powiódł się. Spróbuj ponownie.", "cancelApple": "Subskrypcje Apple są zarządzane przez App Store.", - "manageApple": "Otwórz App Store" + "manageApple": "Otwórz App Store", + "alreadyBoundTo": "Ta subskrypcja jest już powiązana z {email}.", + "alreadyBoundUnknown": "Ta subskrypcja jest już powiązana z innym kontem." } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu", "moreThanYearAgo": "Ponad rok temu" } -} +} \ No newline at end of file diff --git a/messages/zh.json b/messages/zh.json index fc85ce77..14655f9c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -147,7 +147,9 @@ "purchasing": "购买中...", "purchaseError": "购买失败,请重试。", "cancelApple": "Apple 订阅通过 App Store 管理。", - "manageApple": "打开 App Store" + "manageApple": "打开 App Store", + "alreadyBoundTo": "此订阅已与 {email} 关联。", + "alreadyBoundUnknown": "此订阅已与其他账户关联。" } }, "projects": { @@ -464,4 +466,4 @@ "monthsAgo": "{months} 个月前", "moreThanYearAgo": "1 年前" } -} +} \ No newline at end of file diff --git a/src/app/api/apple/subscription-owner/route.ts b/src/app/api/apple/subscription-owner/route.ts new file mode 100644 index 00000000..46e43cef --- /dev/null +++ b/src/app/api/apple/subscription-owner/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { decodeJwt } from "jose"; +import * as UserService from "@src/server/service/user-service"; + +const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; +const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; + +interface AppleTransactionPayload { + bundleId: string; + productId: string; + appAccountToken?: string; +} + +function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + return `${local[0]}***@${domain}`; +} + +export async function POST(req: NextRequest) { + const body = (await req.json().catch(() => ({}))) as Record; + const jwsTransaction = body.jwsTransaction; + + if (typeof jwsTransaction !== "string") { + return NextResponse.json({ email: null }); + } + + const payload = decodeJwt(jwsTransaction) as unknown as AppleTransactionPayload; + + if (!APPLE_BUNDLE_IDS.includes(payload.bundleId) || payload.productId !== APPLE_PRODUCT_ID) { + return NextResponse.json({ email: null }); + } + + if (!payload.appAccountToken) { + return NextResponse.json({ email: null }); + } + + const user = await UserService.getUserFromId(payload.appAccountToken); + const email = user?.email ? maskEmail(user.email) : null; + + return NextResponse.json({ email }); +} diff --git a/src/app/api/apple/verify/route.ts b/src/app/api/apple/verify/route.ts new file mode 100644 index 00000000..30d53ea6 --- /dev/null +++ b/src/app/api/apple/verify/route.ts @@ -0,0 +1,57 @@ +import { NextRequest } from "next/server"; +import { decodeJwt } from "jose"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { BodyFieldError, ForbiddenError, Success } from "@src/lib/utils/api-utils"; +import * as UserService from "@src/server/service/user-service"; +import * as TransactionService from "@src/server/service/transaction-service"; + +const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; +const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; + +interface AppleTransactionPayload { + transactionId: string; + originalTransactionId: string; + bundleId: string; + productId: string; + purchaseDate: number; + expiresDate: number; + type: string; + appAccountToken?: string; +} + +async function verifyApplePurchase(req: NextRequest, { user }: AuthApiContext) { + const body = (await req.json().catch(() => ({}))) as Record; + const jwsTransaction = body.jwsTransaction; + if (typeof jwsTransaction !== "string") { + throw new BodyFieldError("Missing jwsTransaction"); + } + + const payload = decodeJwt(jwsTransaction) as unknown as AppleTransactionPayload; + + if (!APPLE_BUNDLE_IDS.includes(payload.bundleId)) { + throw new ForbiddenError("Invalid bundle ID"); + } + if (payload.productId !== APPLE_PRODUCT_ID) { + throw new ForbiddenError("Invalid product ID"); + } + if (payload.appAccountToken && payload.appAccountToken !== user.id) { + throw new ForbiddenError("This purchase belongs to a different account"); + } + + const expiresDate = new Date(payload.expiresDate); + if (expiresDate <= new Date()) { + throw new ForbiddenError("Transaction already expired"); + } + + await UserService.updateUserFromId(user.id, { + isProUntil: expiresDate, + subscriptionProvider: "APPLE", + isSubscriptionCancelled: false, + }); + + await TransactionService.createTransactionIfNotExists(user.id, "APPLE", payload.transactionId); + + return Success(null); +} + +export const POST = apiHandler(verifyApplePurchase); diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index 09a5f208..61d86a6d 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import prisma from "@src/server/db"; import * as UserService from "@src/server/service/user-service"; +import * as TransactionService from "@src/server/service/transaction-service"; export async function POST(req: NextRequest) { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); @@ -30,13 +30,7 @@ export async function POST(req: NextRequest) { isProUntil: periodEnd ? new Date(periodEnd * 1000) : null, subscriptionProvider: "STRIPE", }); - await prisma.transaction.create({ - data: { - userId, - provider: "STRIPE", - transactionId: subscriptionId, - }, - }); + await TransactionService.createTransactionIfNotExists(userId, "STRIPE", subscriptionId); } } diff --git a/src/lib/import/import-project.ts b/src/lib/import/import-project.ts index ea134c6b..276e9817 100644 --- a/src/lib/import/import-project.ts +++ b/src/lib/import/import-project.ts @@ -144,7 +144,7 @@ async function createRemoteProject(userId: string, title: string, description?: description, }; - const res = await createProject(userId, body); + const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (!res.ok || !json.data) { diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index ab9606c1..550b1f28 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -29,7 +29,7 @@ export const getCloudToken = async (projectId: string): Promise<{ token: string return { token: null, status: res.status }; }; -export const createProject = async (userId: string, body: CreateProjectBody) => { +export const createProject = async (body: CreateProjectBody) => { return request(`/api/projects`, "POST", body); }; @@ -142,3 +142,11 @@ export const submitApplePurchase = async (jwsTransaction: string): Promise => { + const res = await request("/api/apple/subscription-owner", "POST", { jwsTransaction }); + if (!res.ok) return null; + const { email } = (await res.json()) as { email: string | null }; + return email ?? null; +}; + From 9fd57e0cfe5942bd9f784b2027a3b587749a0614 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 28 Apr 2026 01:54:45 +0200 Subject: [PATCH 27/76] added missing prisma generate in ci --- .github/workflows/checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 88e9fbaa..80c489c1 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -24,7 +24,7 @@ jobs: continue-on-error: true - name: Typecheck - run: npx tsc --noEmit + run: npx prisma generate && npx tsc --noEmit build-macos: runs-on: macos-latest From 6c84c20f70cac81b11c7c8c10409a72804f51561 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Thu, 30 Apr 2026 01:06:16 +0200 Subject: [PATCH 28/76] refactored oauth buttons --- .../dashboard/account/DashboardAuth.tsx | 39 +++++-------------- components/dashboard/account/OAuthButtons.tsx | 30 +++----------- src/lib/utils/hooks.ts | 29 +++++++++++++- 3 files changed, 43 insertions(+), 55 deletions(-) diff --git a/components/dashboard/account/DashboardAuth.tsx b/components/dashboard/account/DashboardAuth.tsx index 6dfe49e4..43cb6e38 100644 --- a/components/dashboard/account/DashboardAuth.tsx +++ b/components/dashboard/account/DashboardAuth.tsx @@ -1,13 +1,13 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { isTauri } from "@tauri-apps/api/core"; -import { useSWRConfig } from "swr"; import { requestMagicLink } from "@src/lib/utils/requests"; import { ApiResponse } from "@src/lib/utils/api-utils"; import { RequestMagicLinkBody } from "@src/lib/utils/api-bodies"; +import { useDesktopBridgeAuth } from "@src/lib/utils/hooks"; import OAuthButtons from "./OAuthButtons"; @@ -19,20 +19,13 @@ type MessageType = "success" | "error" | "info"; const DashboardAuth = () => { const tAuth = useTranslations("auth"); - const { mutate } = useSWRConfig(); + const { completeBridgeAuth } = useDesktopBridgeAuth(); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [submitted, setSubmitted] = useState(false); const [message, setMessage] = useState<{ type: MessageType; text: string } | null>(null); - // Desktop-only: poll the bridge after the email is sent so the user is signed in - // here as soon as they click the magic link in their browser. const [pollingDesktop, setPollingDesktop] = useState(false); - const pollAbortRef = useRef(null); - - useEffect(() => { - return () => { pollAbortRef.current?.abort(); }; - }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -45,11 +38,7 @@ const DashboardAuth = () => { const body: RequestMagicLinkBody = { email }; if (isTauri()) { - // Desktop: generate a nonce, request the magic link bound to it, then poll the - // bridge until the browser-side click drops the JWE for us to pick up. - const { generateBridgeNonce, pollBridgeToken, setDesktopToken } = await import( - "@src/lib/desktop-auth" - ); + const { generateBridgeNonce } = await import("@src/lib/desktop-auth"); const nonce = generateBridgeNonce(); body.desktopNonce = nonce; @@ -63,22 +52,12 @@ const DashboardAuth = () => { setSubmitted(true); setPollingDesktop(true); - pollAbortRef.current?.abort(); - const controller = new AbortController(); - pollAbortRef.current = controller; - - const token = await pollBridgeToken(nonce, { signal: controller.signal }); - if (!token) { - if (!controller.signal.aborted) { - setMessage({ type: "error", text: tAuth("desktopTimeout") }); - setPollingDesktop(false); - setSubmitted(false); - } - return; + const result = await completeBridgeAuth(nonce); + if (result === "timeout") { + setMessage({ type: "error", text: tAuth("desktopTimeout") }); + setPollingDesktop(false); + setSubmitted(false); } - await setDesktopToken(token); - await mutate("/api/users/cookie"); - await mutate("/api/users"); return; } diff --git a/components/dashboard/account/OAuthButtons.tsx b/components/dashboard/account/OAuthButtons.tsx index ea9eed1d..28154041 100644 --- a/components/dashboard/account/OAuthButtons.tsx +++ b/components/dashboard/account/OAuthButtons.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState, useRef, useEffect } from "react"; -import { useSWRConfig } from "swr"; +import { useState } from "react"; import { signIn } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useTranslations } from "next-intl"; +import { useDesktopBridgeAuth } from "@src/lib/utils/hooks"; import GoogleIcon from "@components/icons/GoogleIcon"; import AppleIcon from "@components/icons/AppleIcon"; @@ -19,15 +19,10 @@ type Props = { }; const OAuthButtons = ({ callbackUrl = "/projects" }: Props) => { - const { mutate } = useSWRConfig(); + const { completeBridgeAuth } = useDesktopBridgeAuth(); const t = useTranslations("oauth"); const [pendingProvider, setPendingProvider] = useState(null); const [error, setError] = useState(null); - const pollAbortRef = useRef(null); - - useEffect(() => { - return () => { pollAbortRef.current?.abort(); }; - }, []); const startOAuth = async (provider: Provider) => { setError(null); @@ -37,14 +32,9 @@ const OAuthButtons = ({ callbackUrl = "/projects" }: Props) => { return; } - // Cancel any in-progress poll before starting a new one - pollAbortRef.current?.abort(); - const controller = new AbortController(); - pollAbortRef.current = controller; - setPendingProvider(provider); try { - const { generateBridgeNonce, pollBridgeToken, setDesktopToken } = await import("@src/lib/desktop-auth"); + const { generateBridgeNonce } = await import("@src/lib/desktop-auth"); const { openUrl } = await import("@tauri-apps/plugin-opener"); const nonce = generateBridgeNonce(); @@ -52,17 +42,9 @@ const OAuthButtons = ({ callbackUrl = "/projects" }: Props) => { const bridgeUrl = `${apiBase}/desktop-oauth/start?provider=${provider}&nonce=${encodeURIComponent(nonce)}`; await openUrl(bridgeUrl); - const token = await pollBridgeToken(nonce, { signal: controller.signal }); - if (!token) { - if (!controller.signal.aborted) setError(t("timeout")); - return; - } - - await setDesktopToken(token); - await mutate("/api/users/cookie"); - await mutate("/api/users"); + const result = await completeBridgeAuth(nonce); + if (result === "timeout") setError(t("timeout")); } catch (err) { - if (controller.signal.aborted) return; console.error("[OAuthButtons] Desktop OAuth failed:", err); setError(t("error")); } finally { diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 88c6cbc1..333539da 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -1,6 +1,6 @@ "use client"; -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { CookieUser, UserSettings } from "./types"; import { editUserSettings } from "./requests"; @@ -467,6 +467,32 @@ const useIsPro = () => { return { isPro, isLoading }; }; +const useDesktopBridgeAuth = () => { + const { mutate } = useSWRConfig(); + const pollAbortRef = useRef(null); + + useEffect(() => { + return () => { pollAbortRef.current?.abort(); }; + }, []); + + const completeBridgeAuth = useCallback(async (nonce: string): Promise<"success" | "timeout" | "aborted"> => { + pollAbortRef.current?.abort(); + const controller = new AbortController(); + pollAbortRef.current = controller; + + const { pollBridgeToken, setDesktopToken } = await import("@src/lib/desktop-auth"); + const token = await pollBridgeToken(nonce, { signal: controller.signal }); + if (!token) return controller.signal.aborted ? "aborted" : "timeout"; + + await setDesktopToken(token); + await mutate("/api/users/cookie"); + await mutate("/api/users"); + return "success"; + }, [mutate]); + + return { completeBridgeAuth }; +}; + export { useDraggable, useUser, @@ -482,4 +508,5 @@ export { useCachedProjects, useCachedProjectInfo, useProjectIdFromUrl, + useDesktopBridgeAuth, }; From 337376051bcf9646cef3be260a5317b52b48240d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 30 Apr 2026 02:03:12 +0200 Subject: [PATCH 29/76] visual tweaks to homepage, hidden sidebars by default, updates admin dashboard --- components/admin/AdminShell.module.css | 34 ++--- components/admin/AdminShell.tsx | 13 +- components/admin/ProjectSearch.tsx | 10 +- components/admin/UserSearch.tsx | 10 +- components/home/Landing.module.css | 98 +++++++++++++- .../navbar/LandingPageNavbar.module.css | 125 ++++++++++++++--- components/navbar/LandingPageNavbar.tsx | 101 +++++++++++--- components/projects/CreateProjectPage.tsx | 42 +----- .../projects/EmptyProjectPage.module.css | 126 ++++++++++-------- components/projects/EmptyProjectPage.tsx | 34 ++--- components/projects/ProjectItem.module.css | 5 +- src/app/api/admin/projects/route.ts | 4 - src/app/api/admin/users/route.ts | 4 - src/context/ViewContext.tsx | 4 +- src/server/repository/project-repository.ts | 34 ++--- src/server/repository/user-repository.ts | 11 +- 16 files changed, 443 insertions(+), 212 deletions(-) diff --git a/components/admin/AdminShell.module.css b/components/admin/AdminShell.module.css index 07024496..eebea079 100644 --- a/components/admin/AdminShell.module.css +++ b/components/admin/AdminShell.module.css @@ -21,11 +21,25 @@ align-items: center; gap: 10px; padding: 0 8px; - color: var(--primary-text); - font-family: var(--font-josefin), sans-serif; - font-weight: 700; - font-size: 18px; - letter-spacing: 0.02em; +} + +.brandLogo { + display: block; + height: 28px; + width: auto; + object-fit: contain; +} + +.brandEmail { + display: block; + padding: 0 8px; + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 12px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .brandBadge { @@ -95,16 +109,6 @@ font-family: var(--font-inter), sans-serif; } -.footerEmail { - color: var(--primary-text); - font-weight: 600; - display: block; - margin-bottom: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .main { flex: 1; overflow-y: auto; diff --git a/components/admin/AdminShell.tsx b/components/admin/AdminShell.tsx index a0db4ec0..54105565 100644 --- a/components/admin/AdminShell.tsx +++ b/components/admin/AdminShell.tsx @@ -1,6 +1,7 @@ "use client"; import { ReactNode } from "react"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { BarChart3, Users, FolderOpen, LogOut } from "lucide-react"; @@ -28,9 +29,16 @@ export default function AdminShell({ email, title, subtitle, children }: Props)
- {!debounced && ( -

- Start typing an email (partial, case-insensitive) or paste a full user ID. -

- )} + {!debounced && isLoading &&

Loading…

} {debounced && isLoading &&

Searching…

} diff --git a/components/home/Landing.module.css b/components/home/Landing.module.css index b7725d21..f8660b56 100644 --- a/components/home/Landing.module.css +++ b/components/home/Landing.module.css @@ -326,17 +326,111 @@ font-size: 2.5rem; } .heroLogo { - height: 70px; + height: 140px; } .catchphrase { font-size: 1rem; } .contentContainer { - padding: 0 1.5rem; + padding: 0 1.25rem; } .carouselWrapper { height: 280px; } + + .hero { + gap: 2rem; + } + .heroContent { + gap: 1.5rem; + } + .heroBackgroundImage { + max-width: 140%; + max-height: 50vh; + } + + .sectionTitle { + font-size: 1.75rem; + } + .subheadline { + font-size: 1rem; + margin-bottom: 1.5rem; + } + .sectionHeader { + margin-bottom: 2rem; + } + + .bentoSection { + padding: 2rem 0 3rem; + } + .bentoContent { + padding: 1.25rem; + } + .pillarTitle { + font-size: 1.25rem; + } + .pillarText { + font-size: 0.95rem; + } + + .faqSection { + padding: 2rem 0 3rem; + } + .faqQuestion { + font-size: 1rem; + padding: 1rem 1.25rem; + } + .faqAnswer { + font-size: 0.95rem; + padding: 0.75rem 1.25rem 1rem; + } + + .footer { + padding: 2rem 0; + margin-top: 2rem; + } + .footerContent { + flex-direction: column; + gap: 1rem; + text-align: center; + } + .footerLinks { + justify-content: center; + flex-wrap: wrap; + } + + .pageContainer { + padding: 90px 1.25rem 3rem; + } + .pageTitle { + font-size: 2rem; + margin-bottom: 2rem; + } + .pageSectionTitle { + font-size: 1.25rem; + } +} + +@media (max-width: 480px) { + .heroLogo { + height: 110px; + } + .ctaPlatform { + width: 100%; + max-width: 280px; + } + .sectionTitle { + font-size: 1.5rem; + } + .pillarTitle { + font-size: 1.1rem; + } + .stripeText { + font-size: 1.5rem; + } + .cursor { + height: 1.5rem; + } } /* --- BENTO GRID & FEATURES --- */ diff --git a/components/navbar/LandingPageNavbar.module.css b/components/navbar/LandingPageNavbar.module.css index 3fafb823..277105d4 100644 --- a/components/navbar/LandingPageNavbar.module.css +++ b/components/navbar/LandingPageNavbar.module.css @@ -1,36 +1,121 @@ .navbar { - position: fixed; - width: 100%; - z-index: 1000; - - display: flex; - justify-content: space-between; - align-items: center; - padding: 1.5rem 2.5rem; + position: fixed; + width: 100%; + z-index: 1000; + + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2.5rem; + + background-color: transparent; + border-bottom: 1px solid transparent; + transition: background-color 0.25s ease, backdrop-filter 0.25s ease, + box-shadow 0.25s ease, border-color 0.25s ease; +} + +.navbarScrolled { + background-color: color-mix(in srgb, var(--secondary) 75%, transparent); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom-color: var(--separator); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); } .navLinks { - display: flex; - gap: 1.7rem; + display: flex; + gap: 1.7rem; } .navLink { - color: var(--secondary-text); - text-decoration: none; - font-size: 0.9rem; - transition: color 0.2s ease; + color: var(--secondary-text); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s ease; } .navLink:hover { - color: var(--primary-text); + color: var(--primary-text); } .logoWrapper { - position: absolute; - margin-top: 22px; - top: 0; + position: absolute; + margin-top: 22px; + top: 0; } .logo { - width: 90px; -} \ No newline at end of file + width: 90px; +} + +.burgerBtn { + display: none; + background: transparent; + border: none; + color: var(--primary-text); + cursor: pointer; + padding: 8px; + border-radius: 8px; + align-items: center; + justify-content: center; + transition: background-color 0.15s ease; +} + +.burgerBtn:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.mobileMenu { + position: absolute; + top: calc(100% - 0.5rem); + right: 1rem; + display: flex; + flex-direction: column; + min-width: 200px; + padding: 8px; + border-radius: 14px; + background-color: var(--secondary); + border: 1px solid var(--separator); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); + animation: fadeInDown 0.15s ease-out; +} + +.mobileLink { + color: var(--primary-text); + text-decoration: none; + font-size: 0.95rem; + padding: 10px 14px; + border-radius: 10px; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.mobileLink:hover { + background-color: var(--secondary-hover); + color: var(--primary-text); +} + +@keyframes fadeInDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 768px) { + .navbar { + padding: 1rem 1.25rem; + } + + .navLinks { + display: none; + } + + .burgerBtn { + display: flex; + margin-left: auto; + } +} diff --git a/components/navbar/LandingPageNavbar.tsx b/components/navbar/LandingPageNavbar.tsx index 092b8882..e0c76aa8 100644 --- a/components/navbar/LandingPageNavbar.tsx +++ b/components/navbar/LandingPageNavbar.tsx @@ -1,44 +1,101 @@ "use client"; +import { useState, useEffect } from "react"; import { usePage } from "@src/lib/utils/hooks"; +import { usePathname } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; +import { Menu, X } from "lucide-react"; import styles from "./LandingPageNavbar.module.css"; export default function LandingPageNavbar() { const page = usePage(); + const pathname = usePathname(); + const [open, setOpen] = useState(false); + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + setOpen(false); + }, [pathname]); + + useEffect(() => { + const readScrollY = (target: EventTarget | null): number => { + if (!target || target === document) return window.scrollY; + if (target instanceof HTMLElement) return target.scrollTop; + return 0; + }; + const onScroll = (e: Event) => { + setScrolled(readScrollY(e.target) > 16); + }; + document.addEventListener("scroll", onScroll, { capture: true, passive: true }); + return () => document.removeEventListener("scroll", onScroll, { capture: true }); + }, []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + if (!page) return null; + const close = () => setOpen(false); + return ( -
+
+ + + + {open && ( +
+ {page === "index" && ( + <> + Features + FAQ + Pricing + + )} + Contact + Privacy +
+ )} + ); } diff --git a/components/projects/CreateProjectPage.tsx b/components/projects/CreateProjectPage.tsx index 90946933..eae94706 100644 --- a/components/projects/CreateProjectPage.tsx +++ b/components/projects/CreateProjectPage.tsx @@ -2,12 +2,11 @@ import { useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; -import { cropImageBase64, join } from "@src/lib/utils/misc"; +import { join } from "@src/lib/utils/misc"; import { FormInfoType } from "../utils/FormInfo"; import { redirectScreenplay } from "@src/lib/utils/redirects"; import { createProject } from "@src/lib/utils/requests"; import { useCookieUser, useIsPro } from "@src/lib/utils/hooks"; -import UploadButton from "./UploadButton"; import FormHeader from "./FormHeader"; import FormEnd from "./FormEnd"; @@ -27,7 +26,6 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { const t = useTranslations("projects"); const [formInfo, setFormInfo] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); const exitCreating = () => { setIsCreating(false); @@ -44,11 +42,9 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { const form = e.target as typeof e.target & { title: { value: string }; description: { value: string }; - author: { value: string }; }; const title = form.title.value; const description = form.description.value; - const author = form.author.value; // Desktop: offline-first project creation // Always create locally. If Pro and signed in, try cloud first to use its ID. @@ -58,10 +54,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { // If Pro and signed in, try creating on server to get the cloud project ID if (user && isPro) { try { - const body: CreateProjectBody = { title, description, author }; - if (selectedFile) { - body.poster = await cropImageBase64(selectedFile, 686, 1016); - } + const body: CreateProjectBody = { title, description }; const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (res.ok && json.data) { @@ -76,9 +69,9 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { const { createCachedProject, createCachedProjectWithId } = await import("@src/lib/persistence/storage-provider/local-persistence"); if (projectId) { - await createCachedProjectWithId(projectId, title, description, true, author); + await createCachedProjectWithId(projectId, title, description, true); } else { - const cachedProject = await createCachedProject(title, description, author); + const cachedProject = await createCachedProject(title, description); projectId = cachedProject.id; } } catch (error) { @@ -94,16 +87,7 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { // Web: create via API if authenticated Pro, otherwise create locally (IndexedDB) if (user && isPro) { - const body: CreateProjectBody = { - title, - description, - author, - }; - - if (selectedFile) { - body.poster = await cropImageBase64(selectedFile, 686, 1016); - } - + const body: CreateProjectBody = { title, description }; const res = await createProject(body); const json = (await res.json()) as ApiResponse<{ id: string }>; if (!res.ok || !json.data) { @@ -114,12 +98,12 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { // Also create local entry for offline cache const { createCachedProjectWithId } = await import("@src/lib/persistence/storage-provider/local-persistence"); - await createCachedProjectWithId(json.data.id, title, description, true, author); + await createCachedProjectWithId(json.data.id, title, description, true); redirectScreenplay(json.data.id); } else { // Unauthenticated or non-Pro: create local-only project (IndexedDB) const { createCachedProject } = await import("@src/lib/persistence/storage-provider/local-persistence"); - const cachedProject = await createCachedProject(title, description, author); + const cachedProject = await createCachedProject(title, description); redirectScreenplay(cachedProject.id); } }; @@ -144,18 +128,6 @@ const CreateProjectPage = ({ setIsCreating }: Props) => { onChange={resetFormInfo} /> -
-

- {t("form.authorField")} - {t("form.optional")} -

- -
-
-

- {t("form.posterField")} - {t("form.optional")} -

- -
exitCreating()} /> diff --git a/components/projects/EmptyProjectPage.module.css b/components/projects/EmptyProjectPage.module.css index 779ad52f..86f46438 100644 --- a/components/projects/EmptyProjectPage.module.css +++ b/components/projects/EmptyProjectPage.module.css @@ -1,86 +1,106 @@ .container { display: flex; - flex-direction: row; - justify-content: center; + flex-direction: column; align-items: center; + justify-content: center; flex: 1; - padding-bottom: 60px; + gap: 32px; +} + +.heading { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; +} + +.title { + font-family: var(--font-josefin), sans-serif; + font-size: 2.25rem; + font-weight: 700; + font-style: italic; + color: var(--primary-text); + letter-spacing: 0.01em; + margin: 0; +} + +.subtitle { + font-family: var(--font-inter), sans-serif; + font-size: 0.95rem; + color: var(--secondary-text); + margin: 0; } -.cards { +.row { display: flex; flex-direction: row; - gap: 24px; + align-items: center; + gap: 16px; } -.card { +.btnWrapper { display: flex; flex-direction: column; - align-items: flex-start; - gap: 18px; - - width: 280px; - padding: 36px 32px; + align-items: center; + gap: 8px; +} - border-radius: 18px; - border: 3px solid var(--tertiary); - background-color: var(--project-item-bg); +.formats { + font-family: var(--font-inter), sans-serif; + font-size: 0.75rem; + color: var(--secondary-text); + letter-spacing: 0.02em; +} +.createBtn, +.importBtn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 280px; + padding: 16px 0; + border-radius: 40px; + font-family: var(--font-inter), sans-serif; + font-size: 1rem; + font-weight: 500; cursor: pointer; - text-align: left; + transition: border-color 0.2s ease, color 0.2s ease; +} - box-shadow: var(--project-item-shadow); - transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s; +.createBtn { + border: 3px solid var(--tertiary); + background-color: var(--secondary); + color: var(--primary-text); } -.card:hover { +.createBtn:hover { border-color: var(--tertiary-hover); - box-shadow: var(--project-item-shadow-hover); - transform: translate(-3px, -3px); } -.card:active { +.createBtn:active { transform: scale(0.98); transition: transform 0.07s; } -.card:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: var(--project-item-shadow); +.importBtn { + border: 3px solid var(--tertiary); + background: transparent; + color: var(--secondary-text); } -.cardIcon { - display: flex; - align-items: center; - justify-content: center; - width: 52px; - height: 52px; - border-radius: 14px; - background-color: var(--tertiary); +.importBtn:hover { + border-color: var(--tertiary-hover); color: var(--primary-text); - flex-shrink: 0; - transition: background-color 0.2s; -} - -.card:hover .cardIcon { - background-color: var(--tertiary-hover); } -.cardTitle { - font-family: var(--font-josefin), sans-serif; - font-style: italic; - font-size: 1.25rem; - font-weight: 700; - color: var(--primary-text); - margin: 0; - letter-spacing: 0.01em; +.importBtn:active { + transform: scale(0.98); + transition: transform 0.07s; } -.cardDesc { - font-size: 0.85rem; - color: var(--secondary-text); - margin: 0; - line-height: 1.5; +.importBtn:disabled { + opacity: 0.4; + cursor: not-allowed; } diff --git a/components/projects/EmptyProjectPage.tsx b/components/projects/EmptyProjectPage.tsx index 393ea0da..cae827bc 100644 --- a/components/projects/EmptyProjectPage.tsx +++ b/components/projects/EmptyProjectPage.tsx @@ -55,29 +55,31 @@ const EmptyProjectPage = ({ setIsCreating }: Props) => { accept={getSupportedImportExtensions()} style={{ display: "none" }} /> -
-
-

{t("empty.createFirst")}

-

{t("empty.createDesc")}

- - + {t("empty.createDesc")} + +
+
-

{isImporting ? t("importing") : t("empty.importExisting")} -

-

{t("empty.importDesc")}

- + + .fountain · .fdx · .txt · .scriptio + ); diff --git a/components/projects/ProjectItem.module.css b/components/projects/ProjectItem.module.css index 889d50ea..8eec44fd 100644 --- a/components/projects/ProjectItem.module.css +++ b/components/projects/ProjectItem.module.css @@ -17,11 +17,12 @@ background-color: var(--project-item-bg); box-shadow: var(--panel-shadow); - transition: border-color 0.2s; + transition: background-color 0.15s ease, transform 0.15s ease; } .container:hover { - border-color: var(--tertiary-hover); + background-color: var(--secondary-hover); + transform: translateY(-1px); } .container:active { diff --git a/src/app/api/admin/projects/route.ts b/src/app/api/admin/projects/route.ts index 003a394f..716737ee 100644 --- a/src/app/api/admin/projects/route.ts +++ b/src/app/api/admin/projects/route.ts @@ -18,10 +18,6 @@ async function searchProjects(req: NextRequest, { searchParams, user }: AuthApiC const { q, limit = 25, cursor } = validate(QuerySchema, searchParams); const term = (q ?? "").trim(); - if (!term) { - return Success({ projects: [], nextCursor: null }); - } - const projects = await ProjectService.searchProjects(term, limit + 1, cursor); const hasMore = projects.length > limit; const page = hasMore ? projects.slice(0, limit) : projects; diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index c134b4bb..288645f6 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -18,10 +18,6 @@ async function searchUsers(req: NextRequest, { searchParams, user }: AuthApiCont const { q, limit = 25, cursor } = validate(QuerySchema, searchParams); const term = (q ?? "").trim(); - if (!term) { - return Success({ users: [], nextCursor: null }); - } - const users = await UserService.searchUsers(term, limit + 1, cursor); const hasMore = users.length > limit; const page = hasMore ? users.slice(0, limit) : users; diff --git a/src/context/ViewContext.tsx b/src/context/ViewContext.tsx index 262ede63..2b546fb8 100644 --- a/src/context/ViewContext.tsx +++ b/src/context/ViewContext.tsx @@ -42,8 +42,8 @@ export const ViewProvider = ({ children }: { children: ReactNode }) => { const [focusedSide, setFocusedSideState] = useState("primary"); const [isEndlessScroll, setIsEndlessScroll] = useState(false); const [showComments, setShowComments] = useState(true); - const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); - const [rightSidebarOpen, setRightSidebarOpen] = useState(true); + const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); + const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const isSplit = secondaryPanel !== null; diff --git a/src/server/repository/project-repository.ts b/src/server/repository/project-repository.ts index df9780a5..da49c73d 100644 --- a/src/server/repository/project-repository.ts +++ b/src/server/repository/project-repository.ts @@ -265,25 +265,27 @@ export class ProjectRepository { } async searchProjects(term: string, limit: number, cursor?: number) { - const isUuid = /^[0-9a-f-]{36,}$/i.test(term); - - if (isUuid) { - const project = await prisma.project.findUnique({ - where: { id: term }, - select: { - id: true, - title: true, - author: true, - createdAt: true, - updatedAt: true, - _count: { select: { members: true } }, - }, - }); - return project ? [project] : []; + if (term) { + const isUuid = /^[0-9a-f-]{36,}$/i.test(term); + + if (isUuid) { + const project = await prisma.project.findUnique({ + where: { id: term }, + select: { + id: true, + title: true, + author: true, + createdAt: true, + updatedAt: true, + _count: { select: { members: true } }, + }, + }); + return project ? [project] : []; + } } return prisma.project.findMany({ - where: { title: { contains: term, mode: "insensitive" } }, + ...(term && { where: { title: { contains: term, mode: "insensitive" } } }), select: { id: true, title: true, diff --git a/src/server/repository/user-repository.ts b/src/server/repository/user-repository.ts index a8374617..bae10d1f 100644 --- a/src/server/repository/user-repository.ts +++ b/src/server/repository/user-repository.ts @@ -90,13 +90,14 @@ export class UserRepository { } searchUsers(term: string, limit: number, cursor?: number) { - const isLikelyId = /^[0-9a-f-]{30,}$/i.test(term); - const where: Prisma.UserWhereInput = isLikelyId - ? { OR: [{ id: term }, { email: { contains: term, mode: "insensitive" } }] } - : { email: { contains: term, mode: "insensitive" } }; + const where: Prisma.UserWhereInput | undefined = term + ? (/^[0-9a-f-]{30,}$/i.test(term) + ? { OR: [{ id: term }, { email: { contains: term, mode: "insensitive" } }] } + : { email: { contains: term, mode: "insensitive" } }) + : undefined; return prisma.user.findMany({ - where, + ...(where && { where }), orderBy: { createdAt: "desc" }, take: limit, ...(cursor !== undefined && { skip: cursor }), From b9a5e9f2bee9c75dc491eea8310d53d54ee7dc81 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Thu, 30 Apr 2026 21:52:14 +0200 Subject: [PATCH 30/76] fixed dropdown width, cloud project sync fix, payment workflow fix, visual tweaks to desktop-oauth --- .../account/SubscriptionSettings.tsx | 11 ++-- .../preferences/KeybindsSettings.module.css | 53 +++++++++++++++---- .../preferences/KeybindsSettings.tsx | 8 ++- .../editor/sidebar/ContextMenu.module.css | 25 +++++---- components/editor/sidebar/ContextMenu.tsx | 30 ++++------- components/home/auth/AuthPage.module.css | 4 +- components/navbar/SavesPanel.module.css | 2 + components/navbar/SavesPanel.tsx | 14 +++-- .../ProjectUnavailableDialog.module.css | 12 +++-- .../projects/ProjectUnavailableDialog.tsx | 23 ++++---- package.json | 4 +- src/app/projects/layout.tsx | 5 +- src/app/providers.tsx | 10 ++-- src/lib/desktop-auth.ts | 2 +- src/lib/metrics/db-size-collector.ts | 1 - src/lib/metrics/registry.ts | 1 - src/lib/project/project-state.ts | 6 ++- src/lib/utils/hooks.ts | 17 ++++-- 18 files changed, 146 insertions(+), 82 deletions(-) diff --git a/components/dashboard/account/SubscriptionSettings.tsx b/components/dashboard/account/SubscriptionSettings.tsx index 5e5c0a86..ac69fe50 100644 --- a/components/dashboard/account/SubscriptionSettings.tsx +++ b/components/dashboard/account/SubscriptionSettings.tsx @@ -13,6 +13,11 @@ const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; const APPLE_SUBSCRIPTIONS_URL = "https://apps.apple.com/account/subscriptions"; const PERKS = ["perkProjects", "perkSaves", "perkCollaborators", "perkAutoSave"] as const; +// Apple IAP is only available on the macOS Tauri build. The Windows Tauri +// build is distributed via the Microsoft Store but uses Stripe for billing. +const isMacosTauri = () => + isTauri() && typeof navigator !== "undefined" && /Mac/.test(navigator.userAgent); + const SubscriptionSettings = () => { const { user, mutate } = useUser(); const t = useTranslations("profile"); @@ -29,7 +34,7 @@ const SubscriptionSettings = () => { // Restore Apple purchases on mount to sync subscription state with the server. useEffect(() => { - if (!isTauri() || !user?.id) return; + if (!isMacosTauri() || !user?.id) return; let cancelled = false; @@ -69,7 +74,7 @@ const SubscriptionSettings = () => { setError(null); setUpgradeLoading(true); - if (isTauri()) { + if (isMacosTauri()) { try { const { purchase, restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); const result = await purchase(APPLE_PRODUCT_ID, "subs", { @@ -212,7 +217,7 @@ const SubscriptionSettings = () => { ) : ( ); diff --git a/components/projects/ProjectUnavailableDialog.module.css b/components/projects/ProjectUnavailableDialog.module.css index fe77bcb3..1e6d8d29 100644 --- a/components/projects/ProjectUnavailableDialog.module.css +++ b/components/projects/ProjectUnavailableDialog.module.css @@ -46,11 +46,11 @@ justify-content: center; gap: 8px; padding: 10px 16px; - border-radius: 8px; - border: none; + border-radius: 40px; font-size: 0.9rem; + font-weight: 500; cursor: pointer; - transition: all 0.2s ease; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; } .btn:disabled { @@ -59,12 +59,14 @@ } .keepBtn { - background: var(--editor-tab-active); + background: var(--tertiary); color: var(--primary-text); + border: 1px solid var(--separator); } .keepBtn:hover:not(:disabled) { - opacity: 0.85; + background: var(--tertiary-hover); + border-color: var(--primary-text); } .discardBtn { diff --git a/components/projects/ProjectUnavailableDialog.tsx b/components/projects/ProjectUnavailableDialog.tsx index bb1e791c..5c6b6606 100644 --- a/components/projects/ProjectUnavailableDialog.tsx +++ b/components/projects/ProjectUnavailableDialog.tsx @@ -2,9 +2,15 @@ import { useCallback, useContext, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { ProjectContext } from "@src/context/ProjectContext"; import { Save, Trash2 } from "lucide-react"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { + discardCloudProjectData, + getCachedProject, + migrateToCachedProject, +} from "@src/lib/persistence/storage-provider/local-persistence"; + import styles from "./ProjectUnavailableDialog.module.css"; const ProjectUnavailableDialog = () => { @@ -19,25 +25,20 @@ const ProjectUnavailableDialog = () => { if (!projectId) return; setLoading(true); try { - const { getCachedProject, migrateToCachedProject } = - await import("@src/lib/persistence/storage-provider/local-persistence"); - const cachedProject = await getCachedProject(projectId); - const metadataTitle = repository?.getTitle(); - const title = cachedProject?.title || project?.project?.title || metadataTitle || "Untitled Project"; - const newProject = await migrateToCachedProject(projectId, title, cachedProject?.description ?? undefined); - router.replace(`/projects/screenplay?projectId=${newProject.id}`); + const cached = await getCachedProject(projectId); + const title = cached?.title || project?.project?.title || repository?.getTitle() || "Untitled Project"; + const newProject = await migrateToCachedProject(projectId, title, cached?.description ?? undefined); + router.replace(`/projects?projectId=${newProject.id}`); } catch (e) { console.error("[ProjectUnavailableDialog] Migration failed:", e); setLoading(false); } - }, [projectId, repository, router, project?.project?.title]); + }, [projectId, repository, router, project]); const handleDiscard = useCallback(async () => { if (!projectId) return; setLoading(true); try { - const { discardCloudProjectData } = - await import("@src/lib/persistence/storage-provider/local-persistence"); await discardCloudProjectData(projectId); router.replace("/projects"); } catch (e) { diff --git a/package.json b/package.json index 4b134dec..a4720068 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "dev": "docker compose --profile dev up -d --wait && npx prisma db push && next dev", "build": "npx prisma generate && next build", "build:tauri": "npx prisma generate && npx tsx scripts/build-tauri.ts", - "worker:dev": "wrangler dev -c src/lib/cloud/wrangler.toml", - "worker:deploy": "wrangler deploy -c src/lib/cloud/wrangler.toml", + "cloud:dev": "wrangler dev -c src/lib/cloud/wrangler.toml", + "cloud:deploy": "wrangler deploy -c src/lib/cloud/wrangler.toml", "stripe:dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx index 127b9fa6..0a977e0d 100644 --- a/src/app/projects/layout.tsx +++ b/src/app/projects/layout.tsx @@ -81,8 +81,9 @@ const ProjectLayoutInner = ({ children }: ProjectLayoutInnerProps) => { redirect("/projects"); } - // On desktop, show dialog when cloud project is unavailable - if (isDesktop && isProjectUnavailable) { + // The cloud copy is gone (project deleted, or the user was removed). Offer the + // user a choice between keeping a local copy or discarding it — same on web and desktop. + if (isProjectUnavailable) { return ; } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 01955184..01a3df43 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -43,13 +43,17 @@ export function Providers({ children }: { children: ReactNode }) { fetcher, onSuccess: () => { }, onError: (err) => { - if (err?.status !== 401 && err?.status !== 403) { + // 401/403 are handled by the auth flow. 404 is expected for offline-first + // resources (e.g. a cached project whose cloud copy no longer exists) — the + // calling hook decides what to do, no need to log it as unexpected. + if (err?.status !== 401 && err?.status !== 403 && err?.status !== 404) { console.error("[Fetcher] An unexpected error occurred: ", err); } }, shouldRetryOnError: (err) => { - // Don't retry on auth errors (401, 403) or network errors (server unreachable) - if (err?.status === 401 || err?.status === 403 || err?.isNetworkError) { + // Don't retry on auth errors (401, 403), missing resources (404), or + // network errors (server unreachable). + if (err?.status === 401 || err?.status === 403 || err?.status === 404 || err?.isNetworkError) { return false; } return true; diff --git a/src/lib/desktop-auth.ts b/src/lib/desktop-auth.ts index 249b5caa..33d565f7 100644 --- a/src/lib/desktop-auth.ts +++ b/src/lib/desktop-auth.ts @@ -103,7 +103,7 @@ export async function pollBridgeToken( const token = json.data?.token; if (token) return token; } - } catch (err) { + } catch { if (signal?.aborted) return null; // network blip — keep polling } diff --git a/src/lib/metrics/db-size-collector.ts b/src/lib/metrics/db-size-collector.ts index bd7af953..22ce0699 100644 --- a/src/lib/metrics/db-size-collector.ts +++ b/src/lib/metrics/db-size-collector.ts @@ -4,7 +4,6 @@ import { dbSizeBytes } from "./registry"; const POLL_INTERVAL_MS = 30_000; declare global { - // eslint-disable-next-line no-var var __scriptio_db_size_timer__: NodeJS.Timeout | undefined; } diff --git a/src/lib/metrics/registry.ts b/src/lib/metrics/registry.ts index 1f132ac2..361289c8 100644 --- a/src/lib/metrics/registry.ts +++ b/src/lib/metrics/registry.ts @@ -8,7 +8,6 @@ type MetricsBundle = { }; declare global { - // eslint-disable-next-line no-var var __scriptio_metrics__: MetricsBundle | undefined; } diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 0150b54f..35a6ead2 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -500,8 +500,10 @@ export const useCloudSync = ( setConnectionStatus("disconnected"); setIsCloudSynced(true); // Mark as "synced" so isReady becomes true - // On desktop, 403 means the cloud project was deleted or user was removed - if (status === 403 && isDesktop) { + // 403 means the cloud project was deleted or the user was removed. + // Surface the recovery dialog on both desktop and web — the local + // cache is still valid and the user should choose what to do with it. + if (status === 403) { setIsProjectUnavailable(true); } return; diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 333539da..4d6c8b17 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -343,7 +343,7 @@ const useProjectMembership = () => { // Fetch cloud membership only for authenticated users with non-local projects const shouldFetch = isAuthenticated && isLocalOnly === false && !!projectId; - const { data, isLoading, mutate } = useSWR( + const { data, error, isLoading, mutate } = useSWR( shouldFetch ? `/api/projects/${projectId}` : null, ); @@ -353,13 +353,22 @@ const useProjectMembership = () => { } }, [data, isLoading, updateProject]); - // Treat as locally accessible: explicitly local-only, or any cached project when offline - const isLocalAccessible = isLocalOnly === true || (!isAuthenticated && !isUserLoading && isCachedLocally === true); + // The cloud copy is gone (e.g. owner lost Pro and the project was demoted server-side, + // or it was deleted from another device). Offline-first: if we still have it cached + // locally, fall back to that copy instead of redirecting away. + const cloudMissing = error?.status === 404 && isCachedLocally === true; + + // Treat as locally accessible: explicitly local-only, any cached project when offline, + // or a cached project whose cloud copy disappeared. + const isLocalAccessible = + isLocalOnly === true || + (!isAuthenticated && !isUserLoading && isCachedLocally === true) || + cloudMissing; return { membership: data, isLocalOnly: isLocalAccessible, - isLoading: isUserLoading || isLocalOnly === null || isCachedLocally === null || (shouldFetch && isLoading), + isLoading: isUserLoading || isLocalOnly === null || isCachedLocally === null || (shouldFetch && isLoading && !error), mutate, }; }; From 30e1e862931996cdab9f74c915678e2c4c6c84c6 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Thu, 30 Apr 2026 23:51:46 +0200 Subject: [PATCH 31/76] updated deployment for cloud durable object --- .../actions/setup-desktop-build/action.yml | 4 +-- .github/workflows/checks.yaml | 4 +-- .github/workflows/deploy-release.yaml | 23 ++++++++++++ .github/workflows/deploy-staging.yaml | 23 ++++++++++++ Dockerfile | 2 +- package.json | 13 +++---- src/lib/cloud/index.ts | 14 ++++---- src/lib/cloud/room.ts | 2 +- src/lib/cloud/types.ts | 2 +- src/lib/cloud/wrangler.toml | 35 +++++++++++++++---- 10 files changed, 94 insertions(+), 28 deletions(-) diff --git a/.github/actions/setup-desktop-build/action.yml b/.github/actions/setup-desktop-build/action.yml index d11ca429..e06dd6d3 100644 --- a/.github/actions/setup-desktop-build/action.yml +++ b/.github/actions/setup-desktop-build/action.yml @@ -1,5 +1,5 @@ name: Setup Desktop Build Toolchain -description: Installs Node.js, Rust with platform-appropriate targets, MSIX bundler on Windows, and runs npm install. +description: Installs Node.js, Rust with platform-appropriate targets, MSIX bundler on Windows, and runs npm ci. inputs: platform: @@ -39,4 +39,4 @@ runs: - name: Install dependencies shell: bash - run: npm install + run: npm ci diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 80c489c1..4144b77e 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -17,7 +17,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm install + run: npm ci - name: Lint run: npm run lint @@ -75,7 +75,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm install + run: npm ci - name: Get Playwright version id: pw-version diff --git a/.github/workflows/deploy-release.yaml b/.github/workflows/deploy-release.yaml index b802b0c9..d75b7474 100644 --- a/.github/workflows/deploy-release.yaml +++ b/.github/workflows/deploy-release.yaml @@ -71,6 +71,7 @@ jobs: env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://scriptio.app + NEXT_PUBLIC_CLOUD_URL: wss://cloud.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -113,6 +114,7 @@ jobs: run: npm run build:windows env: NEXT_PUBLIC_API_URL: https://scriptio.app + NEXT_PUBLIC_CLOUD_URL: wss://cloud.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -133,6 +135,26 @@ jobs: - name: Publish to Microsoft Store run: msstore publish "$env:APP_PATH" -id "9P4M1XPHJKS1" + deploy-cloud: + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Deploy Cloudflare Worker (production) + run: npm run cloud:deploy:prod + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + deploy-web: runs-on: ubuntu-latest needs: prepare @@ -164,6 +186,7 @@ jobs: ${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }} build-args: | NEXT_PUBLIC_API_URL=https://scriptio.app + NEXT_PUBLIC_CLOUD_URL=wss://cloud.scriptio.app NEXT_PUBLIC_COMMIT_SHA=${{ env.COMMIT_SHA }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.version }} diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index ce4c5fef..3864c4a6 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -71,6 +71,7 @@ jobs: env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://staging.scriptio.app + NEXT_PUBLIC_CLOUD_URL: wss://cloud.staging.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -114,6 +115,7 @@ jobs: run: npm run debug:windows env: NEXT_PUBLIC_API_URL: https://staging.scriptio.app + NEXT_PUBLIC_CLOUD_URL: wss://cloud.staging.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -134,6 +136,26 @@ jobs: - name: Publish to Microsoft Store (staging) run: msstore publish "$env:APP_PATH" -id "9P1N2HMSH40L" + deploy-cloud: + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Deploy Cloudflare Worker (staging) + run: npm run cloud:deploy:staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + deploy-web: runs-on: ubuntu-latest needs: prepare @@ -165,6 +187,7 @@ jobs: ${{ env.IMAGE_NAME }}:staging-${{ needs.prepare.outputs.version }} build-args: | NEXT_PUBLIC_API_URL=https://staging.scriptio.app + NEXT_PUBLIC_CLOUD_URL=wss://cloud.staging.scriptio.app NEXT_PUBLIC_COMMIT_SHA=${{ env.COMMIT_SHA }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.version }} diff --git a/Dockerfile b/Dockerfile index a6df5d08..c99ded3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN apk add --no-cache git WORKDIR /usr/app COPY ./package*.json ./ -RUN npm install +RUN npm ci COPY ./ ./ ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" diff --git a/package.json b/package.json index a4720068..a0fdcb7c 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "type": "module", "scripts": { "postinstall": "patch-package", + "export": "next export", + "start": "next start", "dev": "docker compose --profile dev up -d --wait && npx prisma db push && next dev", "build": "npx prisma generate && next build", - "build:tauri": "npx prisma generate && npx tsx scripts/build-tauri.ts", - "cloud:dev": "wrangler dev -c src/lib/cloud/wrangler.toml", - "cloud:deploy": "wrangler deploy -c src/lib/cloud/wrangler.toml", - "stripe:dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe", "tauri": "tauri", "tauri:dev": "tauri dev", + "build:tauri": "npx prisma generate && npx tsx scripts/build-tauri.ts", + "stripe:dev": "stripe listen --forward-to localhost:3000/api/webhooks/stripe", + "cloud:dev": "wrangler dev -c src/lib/cloud/wrangler.toml", + "cloud:deploy:staging": "wrangler deploy -c src/lib/cloud/wrangler.toml --env staging", + "cloud:deploy:prod": "wrangler deploy -c src/lib/cloud/wrangler.toml --env production", "build:standalone": "next build && tauri dev --no-watch", "build:windows": "tauri-windows-bundle build --arch x64,arm64 --runner npm", "debug:windows": "tauri-windows-bundle build --arch x64,arm64 --debug --runner npm", @@ -22,8 +25,6 @@ "build:macos": "npm run tauri build -- --bundles app --target universal-apple-darwin", "debug:macos": "npm run tauri build -- --bundles app --target universal-apple-darwin --debug", "build:macos:appstore": "npm run tauri build -- --bundles app --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json", - "export": "next export", - "start": "next start", "lint": "eslint .", "bench": "vitest bench", "bench:watch": "vitest bench --watch", diff --git a/src/lib/cloud/index.ts b/src/lib/cloud/index.ts index 714eaff1..5e4f2969 100644 --- a/src/lib/cloud/index.ts +++ b/src/lib/cloud/index.ts @@ -1,7 +1,7 @@ /// import { jwtVerify, JWTPayload } from "jose"; import { Env } from "./types"; -import { ScreenplayRoom } from "./room"; +import { ProjectRoom } from "./room"; interface DecodedToken extends JWTPayload { type?: string; @@ -20,7 +20,7 @@ async function getVerifiedPayload(token: string | null, secret: string): Promise } } -export { ScreenplayRoom }; +export { ProjectRoom }; const worker = { async fetch(request: Request, env: Env): Promise { @@ -38,9 +38,7 @@ const worker = { // Authenticated API endpoints (saves, blacklist, allow) const isAuthEndpoint = - url.pathname.includes("/saves") || - url.pathname.endsWith("/blacklist") || - url.pathname.endsWith("/allow"); + url.pathname.includes("/saves") || url.pathname.endsWith("/blacklist") || url.pathname.endsWith("/allow"); if (isAuthEndpoint && request.method !== "GET") { const authHeader = request.headers.get("Authorization"); @@ -55,7 +53,7 @@ const worker = { return new Response("Unauthorized: Project mismatch", { status: 401 }); } - const stub = env.SCREENPLAY_ROOM.get(env.SCREENPLAY_ROOM.idFromName(projectId)); + const stub = env.PROJECT_ROOM.get(env.PROJECT_ROOM.idFromName(projectId)); const doUrl = new URL(request.url); doUrl.pathname = doPath; const doRequest = new Request(doUrl.toString(), request); @@ -77,7 +75,7 @@ const worker = { return new Response("Unauthorized: Project mismatch", { status: 401 }); } - const stub = env.SCREENPLAY_ROOM.get(env.SCREENPLAY_ROOM.idFromName(projectId)); + const stub = env.PROJECT_ROOM.get(env.PROJECT_ROOM.idFromName(projectId)); const doUrl = new URL(request.url); doUrl.pathname = doPath; const doRequest = new Request(doUrl.toString(), request); @@ -103,7 +101,7 @@ const worker = { newRequest.headers.set("X-User-Id", userId); newRequest.headers.set("X-Project-Id", projectId); - const stub = env.SCREENPLAY_ROOM.get(env.SCREENPLAY_ROOM.idFromName(projectId)); + const stub = env.PROJECT_ROOM.get(env.PROJECT_ROOM.idFromName(projectId)); return stub.fetch(newRequest); } diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index b0be2af5..7f9260eb 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -18,7 +18,7 @@ import { } from "./types"; import { handleProtocolMessage } from "./protocol"; -export class ScreenplayRoom extends DurableObject { +export class ProjectRoom extends DurableObject { doc: Y.Doc; saveTimeout: ReturnType | null = null; awareness: awarenessProtocol.Awareness; diff --git a/src/lib/cloud/types.ts b/src/lib/cloud/types.ts index e6a9f47e..33006eeb 100644 --- a/src/lib/cloud/types.ts +++ b/src/lib/cloud/types.ts @@ -1,7 +1,7 @@ /// export interface Env { - SCREENPLAY_ROOM: DurableObjectNamespace; + PROJECT_ROOM: DurableObjectNamespace; JWT_SECRET: string; SNAPSHOTS: R2Bucket; } diff --git a/src/lib/cloud/wrangler.toml b/src/lib/cloud/wrangler.toml index aa5e5f6d..9e371a2d 100644 --- a/src/lib/cloud/wrangler.toml +++ b/src/lib/cloud/wrangler.toml @@ -1,16 +1,37 @@ -name = "screenplay-collaboration" +# Global configuration +name = "scriptio-cloud" main = "index.ts" compatibility_date = "2025-12-08" compatibility_flags = ["nodejs_compat"] +# Default Durable Object binding (used as a template) [[durable_objects.bindings]] -name = "SCREENPLAY_ROOM" -class_name = "ScreenplayRoom" +name = "PROJECT_ROOM" +class_name = "ProjectRoom" -[[r2_buckets]] +# Migrations are global to the script class +[[migrations]] +tag = "v1" +new_sqlite_classes = ["ProjectRoom"] + +# --- Production --- +[env.production] +name = "scriptio-cloud" +routes = [ + { pattern = "cloud.scriptio.app", custom_domain = true } +] + +[[env.production.r2_buckets]] binding = "SNAPSHOTS" bucket_name = "scriptio-snapshots" -[[migrations]] -tag = "v1" -new_sqlite_classes = ["ScreenplayRoom"] \ No newline at end of file +# --- Staging --- +[env.staging] +name = "scriptio-cloud-staging" +routes = [ + { pattern = "cloud.staging.scriptio.app", custom_domain = true } +] + +[[env.staging.r2_buckets]] +binding = "SNAPSHOTS" +bucket_name = "scriptio-snapshots-staging" \ No newline at end of file From 1886d3d62f7e68a34210ffcbb51bde0861c91b12 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Fri, 1 May 2026 05:43:01 +0200 Subject: [PATCH 32/76] fixed compilation --- src/lib/cloud/protocol.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/cloud/protocol.ts b/src/lib/cloud/protocol.ts index 3c43a014..58576e11 100644 --- a/src/lib/cloud/protocol.ts +++ b/src/lib/cloud/protocol.ts @@ -2,9 +2,9 @@ import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; import * as syncProtocol from "y-protocols/sync"; import * as awarenessProtocol from "y-protocols/awareness"; -import type { ScreenplayRoom } from "./room"; +import type { ProjectRoom } from "./room"; -export function handleProtocolMessage(room: ScreenplayRoom, fullMessage: Uint8Array, sender: WebSocket) { +export function handleProtocolMessage(room: ProjectRoom, fullMessage: Uint8Array, sender: WebSocket) { const messageType = fullMessage[0]; const messageContent = fullMessage.subarray(1); const decoder = decoding.createDecoder(messageContent); @@ -19,7 +19,7 @@ export function handleProtocolMessage(room: ScreenplayRoom, fullMessage: Uint8Ar try { while (decoding.hasContent(decoder)) { // Passing 'sender' (WebSocket) as origin causes the doc.on('update') - // listener (set up in constructor) to broadcast the change, + // listener (set up in constructor) to broadcast the change, // schedule a hot save, and flag for cold snapshot. syncProtocol.readSyncMessage(decoder, syncEncoder, room.doc, sender); } @@ -50,7 +50,7 @@ export function handleProtocolMessage(room: ScreenplayRoom, fullMessage: Uint8Ar encoding.writeVarUint(respEncoder, 1); // awareness message type encoding.writeVarUint8Array( respEncoder, - awarenessProtocol.encodeAwarenessUpdate(room.awareness, Array.from(currentStates.keys())) + awarenessProtocol.encodeAwarenessUpdate(room.awareness, Array.from(currentStates.keys())), ); sender.send(encoding.toUint8Array(respEncoder)); } From 6bda6378d9d2059c8114dae00ba001675d31e075 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Fri, 1 May 2026 16:25:03 +0200 Subject: [PATCH 33/76] visual tweaks to dropdown, added missing translations, fixed cloudflare deployment... --- .github/workflows/deploy-release.yaml | 11 +- .github/workflows/deploy-staging.yaml | 11 +- .../dashboard/DashboardModal.module.css | 21 + components/dashboard/DashboardModal.tsx | 9 +- .../dashboard/account/AuthForm.module.css | 4 +- .../preferences/KeybindsSettings.module.css | 22 +- .../preferences/KeybindsSettings.tsx | 23 +- .../editor/sidebar/ContextMenu.module.css | 9 +- components/home/HomePageContainer.tsx | 1 + components/home/Landing.module.css | 26 +- components/navbar/SavesPanel.tsx | 7 +- messages/de.json | 7 +- messages/en.json | 3 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/ja.json | 7 +- messages/ko.json | 7 +- messages/pl.json | 7 +- messages/zh.json | 7 +- package-lock.json | 1695 ++++++++--------- package.json | 11 +- styles/themes.css | 20 +- 22 files changed, 900 insertions(+), 1022 deletions(-) diff --git a/.github/workflows/deploy-release.yaml b/.github/workflows/deploy-release.yaml index d75b7474..b25cb396 100644 --- a/.github/workflows/deploy-release.yaml +++ b/.github/workflows/deploy-release.yaml @@ -141,17 +141,8 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - name: Deploy Cloudflare Worker (production) - run: npm run cloud:deploy:prod + run: npx wrangler@4 deploy -c src/lib/cloud/wrangler.toml --env production env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 3864c4a6..bfa091ad 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -142,17 +142,8 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - name: Deploy Cloudflare Worker (staging) - run: npm run cloud:deploy:staging + run: npx wrangler@4 deploy -c src/lib/cloud/wrangler.toml --env staging env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/components/dashboard/DashboardModal.module.css b/components/dashboard/DashboardModal.module.css index d885c86a..0f426ff0 100644 --- a/components/dashboard/DashboardModal.module.css +++ b/components/dashboard/DashboardModal.module.css @@ -94,6 +94,7 @@ flex-direction: column; padding: 30px; background: var(--main-bg); + min-height: 0; } .contentHeader { @@ -101,15 +102,35 @@ justify-content: space-between; align-items: center; margin-bottom: 20px; + flex-shrink: 0; } .scrollArea { overflow-y: auto; flex: 1; + min-height: 0; scrollbar-gutter: stable; padding-right: 20px; } +.scrollArea::before { + content: ""; + position: sticky; + top: 0; + display: block; + height: 12px; + margin-bottom: -12px; + background: linear-gradient(to bottom, var(--editor-shadow) 0%, rgba(0, 0, 0, 0) 100%); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.scrollArea.scrolled::before { + opacity: 1; +} + .input { width: 100%; padding: 10px; diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index 0266148b..8945690e 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -96,11 +96,18 @@ const DashboardModal = () => { }, [isInProject, isSignedIn, activeTab, setActiveTab, ACCOUNT_MENU, PREFERENCES_MENU, PROJECT_MENU]); const [prevActiveTab, setPrevActiveTab] = useState(activeTab); + const [isScrolled, setIsScrolled] = useState(false); if (prevActiveTab !== activeTab) { setPrevActiveTab(activeTab); setDangerOpen(false); + setIsScrolled(false); } + const handleScroll = (e: React.UIEvent) => { + const scrolled = e.currentTarget.scrollTop > 0; + setIsScrolled((prev) => (prev !== scrolled ? scrolled : prev)); + }; + useEffect(() => { const handleEsc = (e: KeyboardEvent) => { if (e.key === "Escape") closeDashboard(); @@ -122,7 +129,7 @@ const DashboardModal = () => { -
+
{/* Project tabs - only rendered when in project context */} {isInProject && activeTab === "General" && setDangerOpen((v) => !v)} />} {isInProject && activeTab === "Layout" && } diff --git a/components/dashboard/account/AuthForm.module.css b/components/dashboard/account/AuthForm.module.css index 6952ef41..4538f2e6 100644 --- a/components/dashboard/account/AuthForm.module.css +++ b/components/dashboard/account/AuthForm.module.css @@ -107,7 +107,6 @@ display: flex; flex-direction: column; gap: 10px; - margin-top: 12px; } .authSubmitBtn { @@ -166,7 +165,7 @@ display: flex; align-items: center; gap: 12px; - margin: 12px 0 4px; + margin: 12px 0 12px; font-size: 0.75rem; color: var(--secondary-text); text-transform: uppercase; @@ -192,4 +191,3 @@ justify-content: center; gap: 10px; } - diff --git a/components/dashboard/preferences/KeybindsSettings.module.css b/components/dashboard/preferences/KeybindsSettings.module.css index e73b9801..f29b2311 100644 --- a/components/dashboard/preferences/KeybindsSettings.module.css +++ b/components/dashboard/preferences/KeybindsSettings.module.css @@ -110,25 +110,21 @@ } .clearBtn { - padding: 6px 12px; - border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: none; + border-radius: 6px; + background: transparent; color: var(--secondary-text); - border: 1px solid var(--separator); - background: var(--tertiary); - font-size: 12px; - font-weight: 500; cursor: pointer; - transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; + transition: color 0.15s ease, background-color 0.15s ease; } .clearBtn:hover { - background: var(--tertiary-hover); color: var(--primary-text); - border-color: var(--primary-text); -} - -.clearBtn:active { - background: var(--tertiary-hover); + background: var(--secondary-hover); } .optionCard { diff --git a/components/dashboard/preferences/KeybindsSettings.tsx b/components/dashboard/preferences/KeybindsSettings.tsx index 0faa8957..6063909a 100644 --- a/components/dashboard/preferences/KeybindsSettings.tsx +++ b/components/dashboard/preferences/KeybindsSettings.tsx @@ -10,7 +10,7 @@ import { useSettings } from "@src/lib/utils/hooks"; import { tinykeys } from "@node_modules/tinykeys/dist/tinykeys"; import { DEFAULT_KEYBINDS, DefaultKeyBind, prettyPrintKeybind, UserKeybindsMap } from "@src/lib/utils/keybinds"; import { useTranslations } from "next-intl"; -import { Save } from "lucide-react"; +import { RotateCcw, Save } from "lucide-react"; export type KeybindElementProps = { id: string; @@ -73,7 +73,7 @@ const KeybindElement = ({ onClick={() => resetBinding(id)} title={t("resetTitle")} > - {t("reset")} +
@@ -85,6 +85,7 @@ const KeybindsSettings = () => { const { settings, saveSettings } = useSettings(); const t = useTranslations("keybinds"); + const tCommon = useTranslations("common"); const [userKeybinds, setUserKeybinds] = useState(settings?.keybinds ?? {}); const [listeningFor, setListeningFor] = useState(null); const [tempCombo, setTempCombo] = useState(null); @@ -242,21 +243,13 @@ const KeybindsSettings = () => {
- -
diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index cc266da3..95855b57 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -5,6 +5,7 @@ z-index: 100; position: fixed; + padding-block: 6px; overflow-y: auto; border-radius: 12px; background-color: var(--context-menu-bg); @@ -16,7 +17,7 @@ align-items: center; gap: 10px; - padding-block: 8px; + padding-block: 6px; padding-inline: 12px; font-size: 14px; @@ -32,7 +33,7 @@ align-items: center; gap: 10px; - padding-block: 8px; + padding-block: 6px; padding-inline: 12px; font-size: 14px; @@ -59,7 +60,7 @@ align-items: center; gap: 10px; - padding-block: 8px; + padding-block: 6px; padding-inline: 12px; font-size: 14px; @@ -76,7 +77,7 @@ gap: 10px; align-items: center; - padding-block: 8px; + padding-block: 6px; padding-inline: 12px; font-size: 13px; diff --git a/components/home/HomePageContainer.tsx b/components/home/HomePageContainer.tsx index 9e19a173..a38c7d64 100644 --- a/components/home/HomePageContainer.tsx +++ b/components/home/HomePageContainer.tsx @@ -53,6 +53,7 @@ export default function HomePageContainer() { alt="Scriptio Interface Preview" width={1920} height={1080} + loading="eager" className={styles.heroBackgroundImage} /> diff --git a/components/home/Landing.module.css b/components/home/Landing.module.css index f8660b56..36d6f6ad 100644 --- a/components/home/Landing.module.css +++ b/components/home/Landing.module.css @@ -173,8 +173,10 @@ } .heroBackgroundImage { - max-width: 110%; + width: 100%; + height: auto; max-height: 70vh; + object-fit: contain; } /* 1. Header & Branding */ @@ -274,6 +276,7 @@ .ctaPlatformText { display: flex; flex-direction: column; + align-items: flex-start; line-height: 1.2; } @@ -322,6 +325,16 @@ /* Responsive Logic */ @media (max-width: 768px) { + .marqueeContainer { + gap: 5vh; + } + .stripeText { + font-size: 1.75rem; + } + .cursor { + height: 1.75rem; + } + .heroHeadline { font-size: 2.5rem; } @@ -345,8 +358,8 @@ gap: 1.5rem; } .heroBackgroundImage { - max-width: 140%; - max-height: 50vh; + max-width: 90%; + max-height: 35vh; } .sectionTitle { @@ -425,11 +438,14 @@ .pillarTitle { font-size: 1.1rem; } + .marqueeContainer { + gap: 3vh; + } .stripeText { - font-size: 1.5rem; + font-size: 1.1rem; } .cursor { - height: 1.5rem; + height: 1.1rem; } } diff --git a/components/navbar/SavesPanel.tsx b/components/navbar/SavesPanel.tsx index b8e5161a..77b3d80c 100644 --- a/components/navbar/SavesPanel.tsx +++ b/components/navbar/SavesPanel.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { DashboardContext } from "@src/context/DashboardContext"; +import { useCookieUser } from "@src/lib/utils/hooks"; import { X, Save, @@ -36,10 +37,12 @@ const SavesPanel = ({ projectId, isOpen, onClose, isPro }: SavesPanelProps) => { const t = useTranslations("saves"); const tDates = useTranslations("dates"); const { openDashboard } = useContext(DashboardContext); + const { user } = useCookieUser(); + const isSignedIn = !!user; const handleUpgrade = () => { onClose(); - openDashboard("Subscription"); + openDashboard(isSignedIn ? "Subscription" : "Auth"); }; const [saves, setSaves] = useState([]); @@ -193,7 +196,7 @@ const SavesPanel = ({ projectId, isOpen, onClose, isPro }: SavesPanelProps) => {

{t("proRequired")}

{t("proRequiredDesc")}

diff --git a/messages/de.json b/messages/de.json index a0c0217d..0468424a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -434,9 +434,10 @@ "confirmRestore": "Dies wird das aktuelle Dokument für alle Mitwirkenden ersetzen. Sind Sie sicher?", "confirmDelete": "Sind Sie sicher, dass Sie diese Speicherung löschen möchten?", "noSaves": "Noch keine Speicherungen vorhanden", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "Pro erforderlich", + "proRequiredDesc": "Der Versionsverlauf ist eine Pro-Funktion. Aktualisieren Sie Ihr Abonnement, um benannte Versionen Ihres Drehbuchs zu speichern und wiederherzustellen.", + "upgradeBtn": "Auf Pro upgraden", + "signInAndUpgrade": "Anmelden und upgraden" }, "auth": { "intro": "Geben Sie Ihre E-Mail ein und wir senden Ihnen einen einmaligen Anmeldelink. Kein Passwort erforderlich.", diff --git a/messages/en.json b/messages/en.json index 4bcca531..7a641f65 100644 --- a/messages/en.json +++ b/messages/en.json @@ -435,7 +435,8 @@ "noSaves": "No saves yet", "proRequired": "Pro Required", "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "upgradeBtn": "Upgrade to Pro", + "signInAndUpgrade": "Sign in and Upgrade" }, "auth": { "intro": "Enter your email and we'll send you a single-use sign-in link. No password needed.", diff --git a/messages/es.json b/messages/es.json index f62a42d6..45cf0a38 100644 --- a/messages/es.json +++ b/messages/es.json @@ -433,9 +433,10 @@ "confirmRestore": "Esto reemplazará el documento actual para todos los colaboradores. ¿Estás seguro?", "confirmDelete": "¿Estás seguro de que quieres eliminar este guardado?", "noSaves": "Aún no hay guardados", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "Se requiere Pro", + "proRequiredDesc": "El historial de versiones es una función Pro. Actualiza tu suscripción para guardar y restaurar versiones con nombre de tu guion.", + "upgradeBtn": "Actualizar a Pro", + "signInAndUpgrade": "Iniciar sesión y actualizar" }, "auth": { "intro": "Introduce tu correo y te enviaremos un enlace de inicio de sesión de un solo uso. No necesitas contraseña.", diff --git a/messages/fr.json b/messages/fr.json index f2b1eb62..55d74f75 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -434,9 +434,10 @@ "confirmRestore": "Cela remplacera le document actuel pour tous les collaborateurs. Êtes-vous sûr ?", "confirmDelete": "Êtes-vous sûr de vouloir supprimer cet enregistrement ?", "noSaves": "Aucun enregistrement pour le moment", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "Abonnement Pro requis", + "proRequiredDesc": "L'historique des versions est une fonctionnalité Pro. Passez à l'abonnement supérieur pour enregistrer et restaurer des versions nommées de votre scénario.", + "upgradeBtn": "Passer à Pro", + "signInAndUpgrade": "Se connecter et passer à Pro" }, "auth": { "intro": "Saisissez votre e-mail et nous vous enverrons un lien de connexion à usage unique. Aucun mot de passe nécessaire.", diff --git a/messages/ja.json b/messages/ja.json index f6428a42..ebb73af0 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -433,9 +433,10 @@ "confirmRestore": "全共同作業者の現在のドキュメントがこのバージョンで上書きされます。よろしいですか?", "confirmDelete": "この保存データを削除しますか?", "noSaves": "保存データはありません", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "Pro が必要です", + "proRequiredDesc": "バージョン履歴は Pro の機能です。アップグレードすると、脚本の名前付きバージョンを保存・復元できます。", + "upgradeBtn": "Pro にアップグレード", + "signInAndUpgrade": "サインインしてアップグレード" }, "auth": { "intro": "メールアドレスを入力してください。一度きりのサインインリンクをお送りします。パスワードは必要ありません。", diff --git a/messages/ko.json b/messages/ko.json index d031da89..1254c202 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -433,9 +433,10 @@ "confirmRestore": "모든 공동 작업자의 현재 문서가 이 버전으로 대체됩니다. 계속하시겠습니까?", "confirmDelete": "이 저장본을 삭제하시겠습니까?", "noSaves": "저장된 버전이 없습니다", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "Pro 필요", + "proRequiredDesc": "버전 기록은 Pro 기능입니다. 업그레이드하여 시나리오의 이름 있는 버전을 저장하고 복원하세요.", + "upgradeBtn": "Pro로 업그레이드", + "signInAndUpgrade": "로그인하고 업그레이드" }, "auth": { "intro": "이메일을 입력하시면 일회용 로그인 링크를 보내드립니다. 비밀번호가 필요 없습니다.", diff --git a/messages/pl.json b/messages/pl.json index b982fd08..40e604be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -433,9 +433,10 @@ "confirmRestore": "To zastąpi aktualny dokument u wszystkich współpracowników. Czy na pewno?", "confirmDelete": "Czy na pewno chcesz usunąć ten zapis?", "noSaves": "Brak zapisów", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "Wymagana subskrypcja Pro", + "proRequiredDesc": "Historia wersji to funkcja Pro. Przejdź na wyższy plan, aby zapisywać i przywracać nazwane wersje swojego scenariusza.", + "upgradeBtn": "Przejdź na Pro", + "signInAndUpgrade": "Zaloguj się i przejdź na Pro" }, "auth": { "intro": "Wprowadź swój e-mail, a wyślemy Ci jednorazowy link do logowania. Hasło nie jest potrzebne.", diff --git a/messages/zh.json b/messages/zh.json index 14655f9c..d1aa39ce 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -433,9 +433,10 @@ "confirmRestore": "这将为所有协作成员覆盖当前文档。确认吗?", "confirmDelete": "确认删除此备份?", "noSaves": "暂无备份", - "proRequired": "Pro Required", - "proRequiredDesc": "Version history is a Pro feature. Upgrade to save and restore named versions of your screenplay.", - "upgradeBtn": "Upgrade to Pro" + "proRequired": "需要 Pro 版本", + "proRequiredDesc": "版本历史记录是 Pro 功能。升级即可保存和恢复您剧本的命名版本。", + "upgradeBtn": "升级到 Pro", + "signInAndUpgrade": "登录并升级" }, "auth": { "intro": "输入您的邮箱,我们将向您发送一次性登录链接。无需密码。", diff --git a/package-lock.json b/package-lock.json index 2b2ac927..f4f44abd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,12 +45,12 @@ "jose": "^5.10.0", "jspdf": "^4.2.0", "lucide-react": "^0.562.0", - "nanoid": "^5.1.7", + "nanoid": "^5.1.11", "next": "^16.1.6", "next-auth": "^5.0.0-beta.30", "next-intl": "^4.8.3", "next-themes": "^0.2.0", - "nodemailer": "^7.0.12", + "nodemailer": "^8.0.7", "pdfmake": "^0.2.9", "pg": "^8.20.0", "prom-client": "^15.1.3", @@ -61,7 +61,7 @@ "stripe": "^21.0.1", "swr": "^2.2.4", "tinykeys": "^3.0.0", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", @@ -82,7 +82,6 @@ "@types/pg": "^8.20.0", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitest/browser": "^3.2.4", @@ -112,10 +111,22 @@ "license": "MIT", "optional": true }, - "node_modules/@auth/core": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", - "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", + "node_modules/@auth/prisma-adapter": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.2.tgz", + "integrity": "sha512-GyNEUNtrPgDPs0M4xX6F5i7jTsCKwU6BXV9zutctcoo6K1Ud+juckrmQS11uyNgeWsw6sliextHbU/e+8lsizQ==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.2" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", + "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -141,27 +152,15 @@ } } }, - "node_modules/@auth/core/node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "node_modules/@auth/prisma-adapter/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, - "node_modules/@auth/prisma-adapter": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", - "integrity": "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.41.1" - }, - "peerDependencies": { - "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" - } - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1124,37 +1123,20 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz", - "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.4", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz", - "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", @@ -2850,6 +2832,17 @@ "node": ">=6.9.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@choochmeque/tauri-plugin-iap-api": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/@choochmeque/tauri-plugin-iap-api/-/tauri-plugin-iap-api-0.8.2.tgz", @@ -2874,6 +2867,29 @@ "tauri-windows-bundle": "dist/cli.js" } }, + "node_modules/@choochmeque/tauri-windows-bundle/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@choochmeque/tauri-windows-bundle/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@choochmeque/tauri-windows-bundle/node_modules/commander": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", @@ -2903,40 +2919,40 @@ } }, "node_modules/@choochmeque/tauri-windows-bundle/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.1.tgz", - "integrity": "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "^1.20260115.0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -2945,9 +2961,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260210.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260210.0.tgz", - "integrity": "sha512-e3vMgzr8ZM6VjpJVFrnMBhjvFhlMIkhT+BLpBk3pKaWsrXao+azDlmzzxB3Zf4CZ8LmCEtaP7n5d2mNGL6Dqww==", + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260430.1.tgz", + "integrity": "sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==", "cpu": [ "x64" ], @@ -2962,9 +2978,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260210.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260210.0.tgz", - "integrity": "sha512-ng2uLJVMrI5VrcAS26gDGM+qxCuWD4ZA8VR4i88RdyM8TLn+AqPFisrvn7AMA+QSv0+ck+ZdFtXek7qNp2gNuA==", + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260430.1.tgz", + "integrity": "sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==", "cpu": [ "arm64" ], @@ -2979,9 +2995,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260210.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260210.0.tgz", - "integrity": "sha512-frn2/+6DV59h13JbGSk9ATvJw3uORWssFIKZ/G/to+WRrIDQgCpSrjLtGbFSSn5eBEhYOvwxPKc7IrppkmIj/w==", + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260430.1.tgz", + "integrity": "sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==", "cpu": [ "x64" ], @@ -2996,9 +3012,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260210.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260210.0.tgz", - "integrity": "sha512-0fmxEHaDcAF+7gcqnBcQdBCOzNvGz3mTMwqxEYJc5xZgFwQf65/dYK5fnV8z56GVNqu88NEnLMG3DD2G7Ey1vw==", + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260430.1.tgz", + "integrity": "sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==", "cpu": [ "arm64" ], @@ -3013,9 +3029,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260210.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260210.0.tgz", - "integrity": "sha512-G/Apjk/QLNnwbu8B0JO9FuAJKHNr+gl8X3G/7qaUrpwIkPx5JFQElVE6LKk4teSrycvAy5AzLFAL0lOB1xsUIQ==", + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260430.1.tgz", + "integrity": "sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==", "cpu": [ "x64" ], @@ -3030,9 +3046,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260210.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260210.0.tgz", - "integrity": "sha512-zHaF0RZVYUQwNCJCECnNAJdMur72Lk3FMiD6wU78Dx3Bv7DQRcuXNmPNuJmsGnosVZCcWintHlPTQ/4BEiDG5w==", + "version": "4.20260501.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz", + "integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==", "dev": true, "license": "MIT OR Apache-2.0", "peer": true @@ -3613,9 +3629,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3624,9 +3640,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3687,9 +3703,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3708,9 +3724,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3818,56 +3834,34 @@ "integrity": "sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA==", "license": "MIT" }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz", - "integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "3.1.0", - "@formatjs/intl-localematcher": "0.8.1", - "decimal.js": "^10.6.0", - "tslib": "^2.8.1" - } - }, "node_modules/@formatjs/fast-memoize": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz", - "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.3.tgz", + "integrity": "sha512-Ocd1vPuD68rW6BJDuAOtnnc1GPeVepY5kZXML1psGVFQ+1Q8CfkftT3Tnam+Mxx97Pz08jIEDCotl/GV+Naccg==", + "license": "MIT" }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz", - "integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==", + "version": "3.5.6", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.6.tgz", + "integrity": "sha512-04ZjRIeQCnR/h32wBP9/S7rkyy1hLAs2fXJcNwc7hseJd//K9TMBqK0ukb4dXqnALKQ9m5ruZeOD2qqEkK9ixg==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "3.1.1", - "@formatjs/icu-skeleton-parser": "2.1.1", - "tslib": "^2.8.1" + "@formatjs/icu-skeleton-parser": "2.1.6" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz", - "integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "3.1.1", - "tslib": "^2.8.1" - } + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.6.tgz", + "integrity": "sha512-9f1VQ2kaaLHK0WPU1OrAmiNKCKJwyoDmwNzQXbUa6XtFBOgHZ4YZURE8sSedHmMr0kvpB75OtplB0hMYkfdwfg==", + "license": "MIT" }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz", - "integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==", + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.5.tgz", + "integrity": "sha512-TEW/NR367c3PcQ2AXfkNig9jC740+qbkM0LgKl7UCE7Xtv7C5Uk1mvlu86MjQZBmscUai8HSWjcEETpwaVvJ6A==", "license": "MIT", "dependencies": { - "@formatjs/fast-memoize": "3.1.0", - "tslib": "^2.8.1" + "@formatjs/fast-memoize": "3.1.3" } }, "node_modules/@formkit/auto-animate": { @@ -3931,13 +3925,13 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.1.tgz", + "integrity": "sha512-jI9yMDyFpqBeSighf/zlXnQG/nl9AyBc6aAgy4XtxJMyt/CNyJpvPfzDD+bCc2zAOmhhqtF6TnmIaY+xV4mIrw==", "devOptional": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -4485,42 +4479,19 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@jimp/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", - "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/file-ops": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", - "file-type": "^16.0.0", + "file-type": "^21.3.3", "mime": "3" }, "engines": { @@ -4541,15 +4512,15 @@ } }, "node_modules/@jimp/diff": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", - "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" }, "engines": { @@ -4557,9 +4528,9 @@ } }, "node_modules/@jimp/file-ops": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", - "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", "dev": true, "license": "MIT", "engines": { @@ -4567,15 +4538,15 @@ } }, "node_modules/@jimp/js-bmp": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", - "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "bmp-ts": "^1.0.9" }, "engines": { @@ -4583,14 +4554,14 @@ } }, "node_modules/@jimp/js-gif": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", - "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "gifwrap": "^0.10.1", "omggif": "^1.0.10" }, @@ -4599,14 +4570,14 @@ } }, "node_modules/@jimp/js-jpeg": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", - "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "jpeg-js": "^0.4.4" }, "engines": { @@ -4614,14 +4585,14 @@ } }, "node_modules/@jimp/js-png": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", - "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "pngjs": "^7.0.0" }, "engines": { @@ -4629,14 +4600,14 @@ } }, "node_modules/@jimp/js-tiff": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", - "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "utif2": "^4.1.0" }, "engines": { @@ -4644,14 +4615,14 @@ } }, "node_modules/@jimp/plugin-blit": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", - "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4669,27 +4640,27 @@ } }, "node_modules/@jimp/plugin-blur": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", - "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/utils": "1.6.0" + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" }, "engines": { "node": ">=18" } }, "node_modules/@jimp/plugin-circle": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", - "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4707,15 +4678,15 @@ } }, "node_modules/@jimp/plugin-color": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", - "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "tinycolor2": "^1.6.0", "zod": "^3.23.8" }, @@ -4734,17 +4705,17 @@ } }, "node_modules/@jimp/plugin-contain": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", - "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4762,16 +4733,16 @@ } }, "node_modules/@jimp/plugin-cover": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", - "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4789,15 +4760,15 @@ } }, "node_modules/@jimp/plugin-crop": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", - "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4815,14 +4786,14 @@ } }, "node_modules/@jimp/plugin-displace": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", - "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4840,27 +4811,27 @@ } }, "node_modules/@jimp/plugin-dither": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", - "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0" + "@jimp/types": "1.6.1" }, "engines": { "node": ">=18" } }, "node_modules/@jimp/plugin-fisheye": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", - "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4878,13 +4849,13 @@ } }, "node_modules/@jimp/plugin-flip": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", - "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4902,21 +4873,21 @@ } }, "node_modules/@jimp/plugin-hash": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", - "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "any-base": "^1.1.0" }, "engines": { @@ -4924,13 +4895,13 @@ } }, "node_modules/@jimp/plugin-mask": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", - "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -4948,17 +4919,17 @@ } }, "node_modules/@jimp/plugin-print": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", - "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", @@ -4980,9 +4951,9 @@ } }, "node_modules/@jimp/plugin-quantize": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", - "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", "dev": true, "license": "MIT", "dependencies": { @@ -5004,14 +4975,14 @@ } }, "node_modules/@jimp/plugin-resize": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", - "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/types": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -5029,17 +5000,17 @@ } }, "node_modules/@jimp/plugin-rotate": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", - "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -5057,17 +5028,17 @@ } }, "node_modules/@jimp/plugin-threshold": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", - "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-hash": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0", + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", "zod": "^3.23.8" }, "engines": { @@ -5085,9 +5056,9 @@ } }, "node_modules/@jimp/types": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", - "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", "dev": true, "license": "MIT", "dependencies": { @@ -5108,13 +5079,13 @@ } }, "node_modules/@jimp/utils": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", - "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", "dev": true, "license": "MIT", "dependencies": { - "@jimp/types": "1.6.0", + "@jimp/types": "1.6.1", "tinycolor2": "^1.6.0" }, "engines": { @@ -5192,9 +5163,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -5208,9 +5179,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -5224,9 +5195,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -5240,9 +5211,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], @@ -5256,9 +5227,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], @@ -5272,9 +5243,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], @@ -5288,9 +5259,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], @@ -5304,9 +5275,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -5320,9 +5291,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -5335,6 +5306,18 @@ "node": ">= 10" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5808,13 +5791,13 @@ "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.7.0.tgz", - "integrity": "sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "c12": "3.1.0", + "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" @@ -5862,70 +5845,56 @@ } }, "node_modules/@prisma/engines": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", - "integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0", - "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "@prisma/fetch-engine": "7.7.0", - "@prisma/get-platform": "7.7.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz", - "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines/node_modules/@prisma/debug": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", - "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", - "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz", - "integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0", - "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "@prisma/get-platform": "7.7.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" } }, - "node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", - "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", - "devOptional": true, - "license": "Apache-2.0" - }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", - "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/get-platform": { @@ -7030,9 +6999,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7275,9 +7244,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz", - "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", "dev": true, "license": "CC0-1.0" }, @@ -8318,6 +8287,24 @@ "yjs": "^13.5.38" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -8325,16 +8312,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -8554,13 +8531,6 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", @@ -9263,19 +9233,6 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -9301,9 +9258,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -9612,14 +9569,23 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" } }, "node_modules/axobject-query": { @@ -9698,12 +9664,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/better-result": { @@ -9746,9 +9715,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -9810,53 +9779,28 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", "devOptional": true, "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", - "giget": "^2.0.0", - "jiti": "^2.4.2", + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", - "rc9": "^2.1.2" + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" }, "peerDependencies": { - "magicast": "^0.3.5" + "magicast": "*" }, "peerDependenciesMeta": { "magicast": { @@ -9864,6 +9808,49 @@ } } }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -10122,16 +10109,6 @@ "node": ">=8" } }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -10203,22 +10180,26 @@ "devOptional": true, "license": "MIT" }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/copy-anything": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", @@ -10542,12 +10523,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -10759,9 +10734,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -10796,7 +10771,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -11422,9 +11397,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11443,9 +11418,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11486,9 +11461,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11497,9 +11472,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11563,9 +11538,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11574,9 +11549,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11635,9 +11610,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -11669,9 +11644,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -11768,26 +11743,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/exif-parser": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", @@ -11940,10 +11895,25 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.5.tgz", - "integrity": "sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -11952,7 +11922,10 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.2" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -11994,18 +11967,19 @@ "license": "MIT" }, "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "dev": true, "license": "MIT", "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -12071,16 +12045,16 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -12358,19 +12332,11 @@ } }, "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", "devOptional": true, "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" - }, "bin": { "giget": "dist/cli.mjs" } @@ -12412,9 +12378,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "optional": true, @@ -12424,9 +12390,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "optional": true, @@ -12736,9 +12702,9 @@ } }, "node_modules/icu-minify": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", - "integrity": "sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.0.tgz", + "integrity": "sha512-XRvblCwLqWXio5ZLcmDqXvJv7alSACK6UjXuuMOdQWB//d25AQX6xlVlI1FEbc3Q6iPLXXo6HaVLn8LcAFhn1Q==", "funding": [ { "type": "individual", @@ -12813,9 +12779,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "devOptional": true, "license": "MIT" }, @@ -12903,15 +12869,13 @@ } }, "node_modules/intl-messageformat": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz", - "integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==", + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.3.tgz", + "integrity": "sha512-kZthTU+3WLcoWoRg5j6LOkN1TeUBtmkX0OIwSAbcHVIfQAEbGVdmANM8u6GL3eUDOqLwheYoXMUshAh1UdeXlQ==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "3.1.1", - "@formatjs/fast-memoize": "3.1.0", - "@formatjs/icu-messageformat-parser": "3.5.1", - "tslib": "^2.8.1" + "@formatjs/fast-memoize": "3.1.3", + "@formatjs/icu-messageformat-parser": "3.5.6" } }, "node_modules/iobuffer": { @@ -13438,39 +13402,39 @@ } }, "node_modules/jimp": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", - "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jimp/core": "1.6.0", - "@jimp/diff": "1.6.0", - "@jimp/js-bmp": "1.6.0", - "@jimp/js-gif": "1.6.0", - "@jimp/js-jpeg": "1.6.0", - "@jimp/js-png": "1.6.0", - "@jimp/js-tiff": "1.6.0", - "@jimp/plugin-blit": "1.6.0", - "@jimp/plugin-blur": "1.6.0", - "@jimp/plugin-circle": "1.6.0", - "@jimp/plugin-color": "1.6.0", - "@jimp/plugin-contain": "1.6.0", - "@jimp/plugin-cover": "1.6.0", - "@jimp/plugin-crop": "1.6.0", - "@jimp/plugin-displace": "1.6.0", - "@jimp/plugin-dither": "1.6.0", - "@jimp/plugin-fisheye": "1.6.0", - "@jimp/plugin-flip": "1.6.0", - "@jimp/plugin-hash": "1.6.0", - "@jimp/plugin-mask": "1.6.0", - "@jimp/plugin-print": "1.6.0", - "@jimp/plugin-quantize": "1.6.0", - "@jimp/plugin-resize": "1.6.0", - "@jimp/plugin-rotate": "1.6.0", - "@jimp/plugin-threshold": "1.6.0", - "@jimp/types": "1.6.0", - "@jimp/utils": "1.6.0" + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" }, "engines": { "node": ">=18" @@ -13635,9 +13599,9 @@ } }, "node_modules/jspdf": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", - "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", @@ -13860,9 +13824,9 @@ } }, "node_modules/list-stylesheets/node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -13885,9 +13849,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -14029,9 +13993,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -14135,16 +14099,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260210.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260210.0.tgz", - "integrity": "sha512-HXR6m53IOqEzq52DuGF1x7I1K6lSIqzhbCbQXv/cTmPnPJmNkr7EBtLDm4nfSkOvlDtnwDCLUjWII5fyGJI5Tw==", + "version": "4.20260430.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260430.0.tgz", + "integrity": "sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260210.0", + "undici": "7.24.8", + "workerd": "1.20260430.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, @@ -14152,13 +14116,13 @@ "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/miniflare/node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "dev": true, "license": "MIT", "engines": { @@ -14166,13 +14130,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -14279,9 +14243,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", - "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "funding": [ { "type": "github", @@ -14347,15 +14311,15 @@ } }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "peer": true, "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -14367,15 +14331,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -14401,12 +14365,12 @@ } }, "node_modules/next-auth": { - "version": "5.0.0-beta.30", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", - "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "version": "5.0.0-beta.31", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", + "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", "license": "ISC", "dependencies": { - "@auth/core": "0.41.0" + "@auth/core": "0.41.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", @@ -14428,9 +14392,9 @@ } }, "node_modules/next-auth/node_modules/@auth/core": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", - "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", + "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -14442,7 +14406,7 @@ "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^6.8.0" + "nodemailer": "^7.0.7" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -14457,18 +14421,18 @@ } }, "node_modules/next-auth/node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/next-intl": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.3.tgz", - "integrity": "sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.0.tgz", + "integrity": "sha512-Chp8rgEVUYOX/bCtYy+PXH6lDX3X+GPT9sR9HScHroL283em/4urP9btfdHEMEHJJXdq2W/5wDaDDtWONPdNSA==", "funding": [ { "type": "individual", @@ -14480,16 +14444,15 @@ "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", - "icu-minify": "^4.8.3", + "icu-minify": "^4.11.0", "negotiator": "^1.0.0", - "next-intl-swc-plugin-extractor": "^4.8.3", + "next-intl-swc-plugin-extractor": "^4.11.0", "po-parser": "^2.1.1", - "use-intl": "^4.8.3" + "use-intl": "^4.11.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", - "typescript": "^5.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -14498,9 +14461,9 @@ } }, "node_modules/next-intl-swc-plugin-extractor": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.3.tgz", - "integrity": "sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.0.tgz", + "integrity": "sha512-WUGBSxGNd8eQ0rAsJHFmRw2H7+SZAXQIY/HAnYM57JaUsj5D2vx4KOz4zFtXlyKDtsw9awHfgWVvBae2/RDF9A==", "license": "MIT" }, "node_modules/next-intl/node_modules/@swc/core": { @@ -14562,13 +14525,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, - "license": "MIT" - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -14576,9 +14532,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", - "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", "peer": true, "engines": { @@ -14624,35 +14580,10 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "citty": "^0.2.0", - "pathe": "^2.0.3", - "tinyexec": "^1.0.2" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/nypm/node_modules/citty": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", - "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, - "license": "MIT" - }, "node_modules/oauth4webapi": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", - "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -15085,6 +15016,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -15188,24 +15134,10 @@ "node": ">=12" } }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "devOptional": true, "license": "MIT" }, @@ -15331,9 +15263,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -15377,14 +15309,14 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "devOptional": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, @@ -15700,17 +15632,17 @@ "license": "MIT" }, "node_modules/prisma": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz", - "integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@prisma/config": "7.7.0", + "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", - "@prisma/engines": "7.7.0", + "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" @@ -15734,16 +15666,6 @@ } } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/prom-client": { "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", @@ -16077,14 +15999,14 @@ } }, "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", "devOptional": true, "license": "MIT", "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" + "defu": "^6.1.6", + "destr": "^2.0.5" } }, "node_modules/react": { @@ -16127,40 +16049,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", - "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -16510,27 +16398,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -16595,10 +16462,13 @@ } }, "node_modules/sax": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", - "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", - "license": "BlueOak-1.0.0" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/scheduler": { "version": "0.27.0", @@ -16846,9 +16716,9 @@ } }, "node_modules/simple-xml-to-json": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", - "integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", "dev": true, "license": "MIT", "engines": { @@ -16992,16 +16862,6 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -17176,9 +17036,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -17188,17 +17048,16 @@ "license": "MIT" }, "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "dev": true, "license": "MIT", "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "type": "github", @@ -17326,19 +17185,19 @@ } }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", + "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", "dev": true, "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo" @@ -17403,16 +17262,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -17449,9 +17298,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "peer": true, @@ -17521,17 +17370,18 @@ } }, "node_modules/token-types": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", - "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "dev": true, "license": "MIT", "dependencies": { + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "type": "github", @@ -17794,9 +17644,9 @@ } }, "node_modules/typescript-plugin-css-modules/node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -17884,6 +17734,19 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -17904,9 +17767,9 @@ } }, "node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -18091,9 +17954,9 @@ } }, "node_modules/use-intl": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz", - "integrity": "sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.0.tgz", + "integrity": "sha512-7ILhTLuo3fnSKhoTGDk5X9591pjtWr6qB4inrlvGkN9OEyKhoiG73GZFoLSs68wz3BsSGtoWa62iWvrYEYU+iA==", "funding": [ { "type": "individual", @@ -18104,7 +17967,7 @@ "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", - "icu-minify": "^4.8.3", + "icu-minify": "^4.11.0", "intl-messageformat": "^11.1.0" }, "peerDependencies": { @@ -18155,9 +18018,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -18183,9 +18046,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "peer": true, @@ -18817,9 +18680,9 @@ } }, "node_modules/vite/node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -19147,9 +19010,9 @@ } }, "node_modules/workerd": { - "version": "1.20260210.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260210.0.tgz", - "integrity": "sha512-Sb0WXhrvf+XHQigP2trAxQnXo7wxZFC4PWnn6I7LhFxiTvzxvOAqMEiLkIz58wggRCb54T/KAA8hdjkTniR5FA==", + "version": "1.20260430.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260430.1.tgz", + "integrity": "sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -19161,41 +19024,41 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260210.0", - "@cloudflare/workerd-darwin-arm64": "1.20260210.0", - "@cloudflare/workerd-linux-64": "1.20260210.0", - "@cloudflare/workerd-linux-arm64": "1.20260210.0", - "@cloudflare/workerd-windows-64": "1.20260210.0" + "@cloudflare/workerd-darwin-64": "1.20260430.1", + "@cloudflare/workerd-darwin-arm64": "1.20260430.1", + "@cloudflare/workerd-linux-64": "1.20260430.1", + "@cloudflare/workerd-linux-arm64": "1.20260430.1", + "@cloudflare/workerd-windows-64": "1.20260430.1" } }, "node_modules/wrangler": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.64.0.tgz", - "integrity": "sha512-0PBiVEbshQT4Av/KLHbOAks4ioIKp/eAO7Xr2BgAX5v7cFYYgeOvudBrbtZa/hDDIA6858QuJnTQ8mI+cm8Vqw==", + "version": "4.87.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.87.0.tgz", + "integrity": "sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.12.1", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260210.0", + "miniflare": "4.20260430.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260210.0" + "workerd": "1.20260430.1" }, "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260210.0" + "@cloudflare/workers-types": "^4.20260430.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -19446,20 +19309,6 @@ "error-stack-parser-es": "^1.0.5" } }, - "node_modules/youch/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/zeptomatch": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", diff --git a/package.json b/package.json index a0fdcb7c..21949db5 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,12 @@ "jose": "^5.10.0", "jspdf": "^4.2.0", "lucide-react": "^0.562.0", - "nanoid": "^5.1.7", + "nanoid": "^5.1.11", "next": "^16.1.6", "next-auth": "^5.0.0-beta.30", "next-intl": "^4.8.3", "next-themes": "^0.2.0", - "nodemailer": "^7.0.12", + "nodemailer": "^8.0.7", "pdfmake": "^0.2.9", "pg": "^8.20.0", "prom-client": "^15.1.3", @@ -85,7 +85,7 @@ "stripe": "^21.0.1", "swr": "^2.2.4", "tinykeys": "^3.0.0", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "y-indexeddb": "^9.0.12", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", @@ -106,7 +106,6 @@ "@types/pg": "^8.20.0", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", - "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitest/browser": "^3.2.4", @@ -127,6 +126,10 @@ "wait-on": "^9.0.3", "wrangler": "^4.35.0" }, + "overrides": { + "@hono/node-server": ">=1.19.13", + "nodemailer": "^8.0.7" + }, "browser": { "tokenize": false } diff --git a/styles/themes.css b/styles/themes.css index 5d6cba39..074e1e92 100644 --- a/styles/themes.css +++ b/styles/themes.css @@ -84,8 +84,8 @@ html.dark { --editor-sidebar: #272727; --editor-sidebar-list: #3c3c3c; --editor-sidebar-hover: var(--primary); - --context-menu-bg: #272727; - --context-menu-item-hover: #363636; + --context-menu-bg: var(--secondary); + --context-menu-item-hover: var(--primary); --editor-style-bg: #1d1d1d; --editor-style-bg-hover: #3c3c3c; --editor-style-bg-active: #5d5d5d; @@ -135,8 +135,8 @@ html.latte { --editor-sidebar: var(--secondary); --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); - --context-menu-bg: #fff8e9; - --context-menu-item-hover: #fffef8; + --context-menu-bg: var(--secondary); + --context-menu-item-hover: var(--primary); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -182,8 +182,8 @@ html.wonka { --editor-sidebar: var(--secondary); --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); - --context-menu-bg: #2c1e1a; - --context-menu-item-hover: #3d2b24; + --context-menu-bg: var(--secondary); + --context-menu-item-hover: var(--primary); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -229,8 +229,8 @@ html.mint { --editor-sidebar: var(--secondary); --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); - --context-menu-bg: #ecfdec; - --context-menu-item-hover: #f5fff8; + --context-menu-bg: var(--primary); + --context-menu-item-hover: var(--primary-hover); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -276,8 +276,8 @@ html.blossom { --editor-sidebar: var(--secondary); --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); - --context-menu-bg: #fff6fb; - --context-menu-item-hover: #fffcff; + --context-menu-bg: var(--secondary); + --context-menu-item-hover: var(--primary); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); From 6331c60c43178367037b4a5cac0b1e7c37f6c333 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Fri, 1 May 2026 18:53:52 +0200 Subject: [PATCH 34/76] fixed cloudflare durable object deployment --- .github/workflows/deploy-release.yaml | 9 ++++++++- .github/workflows/deploy-staging.yaml | 9 ++++++++- src/lib/cloud/wrangler.toml | 13 ++++++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-release.yaml b/.github/workflows/deploy-release.yaml index b25cb396..8046662c 100644 --- a/.github/workflows/deploy-release.yaml +++ b/.github/workflows/deploy-release.yaml @@ -141,8 +141,15 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "25.2.1" + cache: "npm" + + - run: npm ci + - name: Deploy Cloudflare Worker (production) - run: npx wrangler@4 deploy -c src/lib/cloud/wrangler.toml --env production + run: npm run cloud:deploy:prod env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index bfa091ad..030c6c9a 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -142,8 +142,15 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "25.2.1" + cache: "npm" + + - run: npm ci + - name: Deploy Cloudflare Worker (staging) - run: npx wrangler@4 deploy -c src/lib/cloud/wrangler.toml --env staging + run: npm run cloud:deploy:staging env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/src/lib/cloud/wrangler.toml b/src/lib/cloud/wrangler.toml index 9e371a2d..7b1c9985 100644 --- a/src/lib/cloud/wrangler.toml +++ b/src/lib/cloud/wrangler.toml @@ -4,11 +4,6 @@ main = "index.ts" compatibility_date = "2025-12-08" compatibility_flags = ["nodejs_compat"] -# Default Durable Object binding (used as a template) -[[durable_objects.bindings]] -name = "PROJECT_ROOM" -class_name = "ProjectRoom" - # Migrations are global to the script class [[migrations]] tag = "v1" @@ -21,6 +16,10 @@ routes = [ { pattern = "cloud.scriptio.app", custom_domain = true } ] +[[env.production.durable_objects.bindings]] +name = "PROJECT_ROOM" +class_name = "ProjectRoom" + [[env.production.r2_buckets]] binding = "SNAPSHOTS" bucket_name = "scriptio-snapshots" @@ -32,6 +31,10 @@ routes = [ { pattern = "cloud.staging.scriptio.app", custom_domain = true } ] +[[env.staging.durable_objects.bindings]] +name = "PROJECT_ROOM" +class_name = "ProjectRoom" + [[env.staging.r2_buckets]] binding = "SNAPSHOTS" bucket_name = "scriptio-snapshots-staging" \ No newline at end of file From 79a82250bd55d5fe2fdc87346aa2c03af3eeae4c Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Fri, 1 May 2026 21:07:52 +0200 Subject: [PATCH 35/76] added midnight theme, fixed shelve item logic in dropdown, fixes to SubscriptionSettings... --- components/dashboard/DashboardModal.tsx | 5 +- .../account/SubscriptionSettings.module.css | 58 ++++++++++++++++++- .../account/SubscriptionSettings.tsx | 41 ++++++++++--- .../preferences/AppearanceSettings.tsx | 22 ++++--- components/editor/DocumentEditorPanel.tsx | 25 ++++---- components/editor/sidebar/ContextMenu.tsx | 8 ++- components/projects/ProjectItem.tsx | 35 ++++++++--- messages/de.json | 7 ++- messages/en.json | 7 ++- messages/es.json | 7 ++- messages/fr.json | 7 ++- messages/ja.json | 7 ++- messages/ko.json | 7 ++- messages/pl.json | 7 ++- messages/zh.json | 7 ++- src/app/api/stripe/checkout/route.ts | 4 +- src/app/api/users/settings/route.ts | 2 +- src/app/api/webhooks/stripe/route.ts | 1 + src/app/projects/page.tsx | 15 ++++- src/app/providers.tsx | 4 +- src/lib/utils/types.ts | 2 +- styles/themes.css | 51 ++++++++++++++++ 22 files changed, 262 insertions(+), 67 deletions(-) diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index 8945690e..a2d22443 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -25,7 +25,7 @@ import AboutSettings from "./AboutSettings"; const DashboardModal = () => { const { isOpen, closeDashboard, activeTab, setActiveTab } = useContext(DashboardContext); const { project, isYjsReady } = useContext(ProjectContext); - const { user } = useCookieUser(); + const { user, isLoading: isUserLoading } = useCookieUser(); const t = useTranslations("modal"); const PROJECT_MENU = useMemo(() => ({ @@ -83,6 +83,7 @@ const DashboardModal = () => { // loading would silently bounce the user to Profile. const prevSignedInRef = useRef(isSignedIn); useEffect(() => { + if (isUserLoading) return; const projectTabIds = PROJECT_MENU.items.map((item) => item.id); const accountTabIds = ACCOUNT_MENU.items.map((item) => item.id); if ((!isInProject && projectTabIds.includes(activeTab)) || (!isSignedIn && accountTabIds.includes(activeTab))) { @@ -93,7 +94,7 @@ const DashboardModal = () => { setActiveTab("Profile"); } prevSignedInRef.current = isSignedIn; - }, [isInProject, isSignedIn, activeTab, setActiveTab, ACCOUNT_MENU, PREFERENCES_MENU, PROJECT_MENU]); + }, [isInProject, isSignedIn, isUserLoading, activeTab, setActiveTab, ACCOUNT_MENU, PREFERENCES_MENU, PROJECT_MENU]); const [prevActiveTab, setPrevActiveTab] = useState(activeTab); const [isScrolled, setIsScrolled] = useState(false); diff --git a/components/dashboard/account/SubscriptionSettings.module.css b/components/dashboard/account/SubscriptionSettings.module.css index 058d4329..17ab21fc 100644 --- a/components/dashboard/account/SubscriptionSettings.module.css +++ b/components/dashboard/account/SubscriptionSettings.module.css @@ -180,14 +180,68 @@ margin: 0; } +.welcomeBox { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + font-weight: 500; + color: var(--primary-text); + padding: 10px 14px; + border-radius: 8px; + background: color-mix(in srgb, var(--primary-text) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-text) 22%, transparent); + margin: 0; + animation: welcomeIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.welcomeBoxLeaving { + animation: welcomeOut 0.6s ease-in forwards; +} + +.welcomeIcon { + flex-shrink: 0; + color: var(--primary-text); + animation: welcomeSpin 0.6s ease-out 0.1s both; +} + +@keyframes welcomeIn { + from { + opacity: 0; + transform: translateY(-6px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes welcomeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes welcomeSpin { + from { + transform: rotate(-20deg) scale(0.8); + } + to { + transform: rotate(0deg) scale(1); + } +} + .upgradeBtn { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; - border-radius: 8px; - background: var(--secondary); + border-radius: 24px; + background: var(--primary); color: var(--primary-text); border: none; font-size: 0.9rem; diff --git a/components/dashboard/account/SubscriptionSettings.tsx b/components/dashboard/account/SubscriptionSettings.tsx index ac69fe50..40111fea 100644 --- a/components/dashboard/account/SubscriptionSettings.tsx +++ b/components/dashboard/account/SubscriptionSettings.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; -import { ArrowRight, Check, Lock } from "lucide-react"; +import { ArrowRight, Check, Lock, Sparkles } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; import { cancelStripeSubscription, createStripeCheckout, getAppleSubscriptionOwner, submitApplePurchase } from "@src/lib/utils/requests"; import { useUser } from "@src/lib/utils/hooks"; +import { useLocale } from "@src/context/LocaleContext"; import styles from "./SubscriptionSettings.module.css"; @@ -21,16 +22,23 @@ const isMacosTauri = () => const SubscriptionSettings = () => { const { user, mutate } = useUser(); const t = useTranslations("profile"); + const { locale } = useLocale(); const [upgradeLoading, setUpgradeLoading] = useState(false); const [cancelConfirm, setCancelConfirm] = useState(false); const [cancelling, setCancelling] = useState(false); const [error, setError] = useState(null); + const [showWelcome, setShowWelcome] = useState( + () => typeof window !== "undefined" && sessionStorage.getItem("proWelcome") === "1" + ); + const [welcomeLeaving, setWelcomeLeaving] = useState(false); const isPro = !!user?.isProUntil && new Date(user.isProUntil) > new Date(); const isCancelled = !!user?.isSubscriptionCancelled; const isApple = user?.subscriptionProvider === "APPLE"; - const expiryDate = user?.isProUntil ? new Date(user.isProUntil).toLocaleDateString() : ""; + const expiryDate = user?.isProUntil + ? new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format(new Date(user.isProUntil)) + : ""; // Restore Apple purchases on mount to sync subscription state with the server. useEffect(() => { @@ -61,6 +69,14 @@ const SubscriptionSettings = () => { return () => { cancelled = true; }; }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (!showWelcome) return; + sessionStorage.removeItem("proWelcome"); + const fadeTimer = setTimeout(() => setWelcomeLeaving(true), 4000); + const hideTimer = setTimeout(() => setShowWelcome(false), 4600); + return () => { clearTimeout(fadeTimer); clearTimeout(hideTimer); }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const showOwnerError = async (jwsRepresentation: string) => { const ownerEmail = await getAppleSubscriptionOwner(jwsRepresentation); if (ownerEmail) { @@ -119,6 +135,7 @@ const SubscriptionSettings = () => { if (result?.url) { window.location.href = result.url; } else { + setError(t("subscription.purchaseError")); setUpgradeLoading(false); } } @@ -183,11 +200,7 @@ const SubscriptionSettings = () => { {/* Actions */} {isPro ? ( - isCancelled ? ( -

- {t("subscription.cancelSuccess", { date: expiryDate })} -

- ) : cancelConfirm ? ( + cancelConfirm ? (

{isApple @@ -209,6 +222,14 @@ const SubscriptionSettings = () => {

+ ) : isCancelled ? ( + ) : ( )} + {showWelcome && ( +
+ + {t("subscription.welcomePro")} +
+ )} {error &&

{error}

} ); diff --git a/components/dashboard/preferences/AppearanceSettings.tsx b/components/dashboard/preferences/AppearanceSettings.tsx index a7128d60..30a3ba48 100644 --- a/components/dashboard/preferences/AppearanceSettings.tsx +++ b/components/dashboard/preferences/AppearanceSettings.tsx @@ -21,6 +21,20 @@ const THEME_COLORS: Record< text: "#ffffff", subtext: "#9b9b9b", }, + wonka: { + primary: "#1e1410", + secondary: "#2c1e1a", + tertiary: "#4d3b36", + text: "#e6dcca", + subtext: "#a89b91", + }, + midnight: { + primary: "#0d1117", + secondary: "#161c2d", + tertiary: "#1e2a45", + text: "#c8d8f0", + subtext: "#6878a8", + }, light: { primary: "#f3f3f3", secondary: "#ffffff", @@ -35,13 +49,6 @@ const THEME_COLORS: Record< text: "#7a6129", subtext: "#c09c50", }, - wonka: { - primary: "#1e1410", - secondary: "#2c1e1a", - tertiary: "#4d3b36", - text: "#e6dcca", - subtext: "#a89b91", - }, mint: { primary: "#dcf5de", secondary: "#ecfdec", @@ -65,6 +72,7 @@ const THEME_LABELS: Record = { wonka: "Wonka", mint: "Mint", blossom: "Blossom", + midnight: "Midnight", }; const AppearanceSettings = () => { diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index a403ef54..d4d51123 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -460,23 +460,20 @@ const DocumentEditorPanel = ({ spellError = { word, from: spellFrom, to: spellFrom + word.length }; } - // Detect shelvable node at click position + // Detect shelvable node at caret position let nodePos: number | undefined; let nodeClass: string | undefined; if (config.features.shelving) { - const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }); - if (coords) { - const $pos = editor.state.doc.resolve(coords.pos); - if ($pos.depth === 1) { - const cls = $pos.parent.attrs.class as ScreenplayElement; - if ( - cls === ScreenplayElement.Scene || - cls === ScreenplayElement.Character || - cls === ScreenplayElement.Action - ) { - nodePos = coords.pos; - nodeClass = cls; - } + const $pos = editor.state.doc.resolve(from); + if ($pos.depth >= 1) { + const cls = $pos.node(1).attrs.class as ScreenplayElement; + if ( + cls === ScreenplayElement.Scene || + cls === ScreenplayElement.Character || + cls === ScreenplayElement.Action + ) { + nodePos = from; + nodeClass = cls; } } } diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 4b14cd83..f88f96a1 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -634,15 +634,17 @@ const ContextMenu = () => { updateContextMenu(undefined); }, [updateContextMenu]); + if (!contextMenu) return null; + return (
- {contextMenu && renderContextMenu(contextMenu)} + {renderContextMenu(contextMenu)}
); }; diff --git a/components/projects/ProjectItem.tsx b/components/projects/ProjectItem.tsx index b38e44fc..bdb2f4ad 100644 --- a/components/projects/ProjectItem.tsx +++ b/components/projects/ProjectItem.tsx @@ -19,11 +19,15 @@ const ProjectItem = ({ project, isLocalOnly = false }: Props) => { const tDates = useTranslations("dates"); const elapsedDays = getElapsedDaysFrom(project.updatedAt); const lastUpdated = - elapsedDays === 0 ? tDates("today") : - elapsedDays === 1 ? tDates("yesterday") : - elapsedDays <= 30 ? tDates("daysAgo", { days: elapsedDays }) : - elapsedDays <= 365 ? tDates("monthsAgo", { months: Math.round(elapsedDays / 30) }) : - tDates("moreThanYearAgo"); + elapsedDays === 0 + ? tDates("today") + : elapsedDays === 1 + ? tDates("yesterday") + : elapsedDays <= 30 + ? tDates("daysAgo", { days: elapsedDays }) + : elapsedDays <= 365 + ? tDates("monthsAgo", { months: Math.round(elapsedDays / 30) }) + : tDates("moreThanYearAgo"); let posterPath; if (project.poster) posterPath = project.poster; @@ -31,12 +35,27 @@ const ProjectItem = ({ project, isLocalOnly = false }: Props) => { return ( - diff --git a/components/dashboard/account/SubscriptionSettings.module.css b/components/dashboard/account/SubscriptionSettings.module.css index 17ab21fc..f829a9f9 100644 --- a/components/dashboard/account/SubscriptionSettings.module.css +++ b/components/dashboard/account/SubscriptionSettings.module.css @@ -1,4 +1,5 @@ .card { + background: var(--secondary); border: 1px solid var(--separator); border-radius: 12px; padding: 20px; @@ -7,11 +8,6 @@ gap: 16px; } -.card[data-pro="true"] { - border-color: color-mix(in srgb, var(--primary-text) 30%, transparent); - background: var(--secondary); -} - .header { display: flex; align-items: center; diff --git a/components/dashboard/project/CollaboratorsSettings.module.css b/components/dashboard/project/CollaboratorsSettings.module.css index 80b0a1a6..8dede8f4 100644 --- a/components/dashboard/project/CollaboratorsSettings.module.css +++ b/components/dashboard/project/CollaboratorsSettings.module.css @@ -33,10 +33,6 @@ color: var(--primary-text); } -.infoIconWrapper:hover .permissionsHint { - opacity: 1; - visibility: visible; -} .slotGrid { display: flex; @@ -189,10 +185,7 @@ } .permissionsHint { - position: absolute; - top: 100%; - left: 0; - margin-top: 8px; + position: fixed; background: var(--secondary); border: 1px solid var(--separator); border-radius: 8px; @@ -200,12 +193,8 @@ display: flex; flex-direction: column; gap: 6px; - opacity: 0; - visibility: hidden; - transition: - opacity 0.2s, - visibility 0.2s; - z-index: 100; + pointer-events: none; + z-index: 1000; white-space: nowrap; width: max-content; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); diff --git a/components/dashboard/project/CollaboratorsSettings.tsx b/components/dashboard/project/CollaboratorsSettings.tsx index 7d98ce24..286bf601 100644 --- a/components/dashboard/project/CollaboratorsSettings.tsx +++ b/components/dashboard/project/CollaboratorsSettings.tsx @@ -1,6 +1,7 @@ "use client"; -import { useContext, useMemo, useState } from "react"; +import { useContext, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { useCookieUser, useIsPro, useProjectCollaborators, useProjectInvites, useProjectMembership } from "@src/lib/utils/hooks"; import { CookieUser } from "@src/lib/utils/types"; @@ -53,36 +54,53 @@ const CollaboratorsSettings = () => { return result; }, [membership, collaborators, invites]); + const iconRef = useRef(null); + const [hintPos, setHintPos] = useState<{ top: number; left: number } | null>(null); + if (isLocalOnly) return

{t("localProjectOnly")}

; if (!membership || !user) return null; + const hint = hintPos && createPortal( +
+
+ {t("roles.owner")} + {t("roleDesc.owner")} +
+
+ {t("roles.admin")} + {t("roleDesc.admin")} +
+
+ {t("roles.editor")} + {t("roleDesc.editor")} +
+
+ {t("roles.viewer")} + {t("roleDesc.viewer")} +
+
, + document.body, + ); + return (
+ {hint}
-
+
{ + if (!iconRef.current) return; + const rect = iconRef.current.getBoundingClientRect(); + setHintPos({ top: rect.bottom + 8, left: rect.left }); + }} + onMouseLeave={() => setHintPos(null)} + > -
-
- {t("roles.owner")} - {t("roleDesc.owner")} -
-
- {t("roles.admin")} - {t("roleDesc.admin")} -
-
- {t("roles.editor")} - {t("roleDesc.editor")} -
-
- {t("roles.viewer")} - {t("roleDesc.viewer")} -
-

{t("teamHelp")}

@@ -147,7 +165,7 @@ const MemberSlot = ({ data, membership, mutateCollaborators, user }: MemberSlotP const isOwner = data.role === ProjectRole.OWNER; const isAdmin = Roles.hasRoleOrGreater(membership.role, ProjectRole.ADMIN); const isSelf = data.user.email === user.email; - const canKick = (isSelf && !isOwner) || isAdmin; + const canKick = (isSelf && !isOwner) || (!isSelf && isAdmin); const handleKick = async () => { const res = await kickCollaborator(membership.project.id, data.user.id); diff --git a/components/editor/SuggestionMenu.module.css b/components/editor/SuggestionMenu.module.css index 9f0829ab..2f55d3a1 100644 --- a/components/editor/SuggestionMenu.module.css +++ b/components/editor/SuggestionMenu.module.css @@ -2,46 +2,33 @@ position: fixed; display: flex; flex-direction: column; - padding: 6px; - + padding-block: 6px; z-index: 100; - border-radius: 10px; - background-color: color-mix(in srgb, var(--primary) 70%, transparent); - backdrop-filter: blur(12px); - border: 1px solid var(--separator); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); - + border-radius: 12px; + background-color: var(--context-menu-bg); + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); min-width: 120px; - max-width: 200px; + max-width: 220px; max-height: 180px; overflow-y: auto; overflow-x: hidden; } .menu_item { - display: block; - width: 100%; - padding: 8px 12px; - border: none; - border-radius: 6px; - font-size: 0.85rem; - text-align: left; + display: flex; + align-items: center; + padding-block: 6px; + padding-inline: 12px; + font-size: 14px; cursor: pointer; - color: var(--primary-text); - background-color: transparent; - transition: background-color 0.15s ease; } .menu_item:hover { - background-color: color-mix(in srgb, var(--primary-text) 10%, transparent); + background-color: var(--context-menu-item-hover); } .selected { - background-color: color-mix(in srgb, var(--primary-text) 15%, transparent); -} - -.selected:hover { - background-color: color-mix(in srgb, var(--primary-text) 15%, transparent); + background-color: var(--context-menu-item-hover); } .item { diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index 95855b57..fa554e32 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -9,6 +9,7 @@ overflow-y: auto; border-radius: 12px; background-color: var(--context-menu-bg); + border: 1px solid var(--separator); box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); } diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index f88f96a1..d26110f7 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -20,6 +20,7 @@ import { ClipboardPaste, Columns2, Copy, + Highlighter, Loader2, LucideIcon, MessageSquarePlus, @@ -95,15 +96,16 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { icon={ArrowDownRight} action={() => focusOnPosition(editor!, scene.position)} /> - editScenePopup(scene, userCtx)} /> - cutText(editor!, scene.position, scene.nextPosition)} - /> selectTextInEditor(editor!, scene.position, scene.nextPosition)} /> +
+ editScenePopup(scene, userCtx)} /> + cutText(editor!, scene.position, scene.nextPosition)} + /> ); }; @@ -136,8 +138,10 @@ const CharacterItemMenu = ({ props }: SubMenuProps) => { text={t("paste")} action={() => pasteText(projectCtx.editor!, character.name)} /> +
toggleCharacterHighlight(character.name)} /> diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index a626e81e..839b9011 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -3,19 +3,22 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { ConnectionStatus } from "@src/lib/utils/enums"; -import { useIsPro, useProjectIdFromUrl } from "@src/lib/utils/hooks"; +import { useCookieUser, useIsPro, useProjectIdFromUrl } from "@src/lib/utils/hooks"; import { redirectHome } from "@src/lib/utils/redirects"; import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; import { useViewContext } from "@src/context/ViewContext"; 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 { DashboardContext } from "@src/context/DashboardContext"; import { BarChart2, CircleArrowLeft, CircleCheckBig, + CloudUpload, History, Monitor, Settings, @@ -89,18 +92,41 @@ const CollaboratorsDisplay = () => { const ProjectNavbar = () => { const { openDashboard } = useContext(DashboardContext); const { project: membership, setProjectTitle: setContextTitle } = useContext(ProjectContext); + const userCtx = useContext(UserContext); const [projectTitle, setProjectTitle] = useState(""); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); const [isSavesOpen, setIsSavesOpen] = useState(false); + const [isLocalOnly, setIsLocalOnly] = useState(null); const isLocalEdit = useRef(false); const { isPro } = useIsPro(); + const { user } = useCookieUser(); const projectId = useProjectIdFromUrl(); const t = useTranslations("navbar"); const viewContext = useViewContext(); + useEffect(() => { + if (!projectId) { + setIsLocalOnly(null); + return; + } + let cancelled = false; + (async () => { + const { isLocalOnlyProject, cachedProjectExists } = + await import("@src/lib/persistence/storage-provider/local-persistence"); + const exists = await cachedProjectExists(projectId); + const local = exists ? await isLocalOnlyProject(projectId) : false; + if (!cancelled) setIsLocalOnly(local); + })(); + return () => { + cancelled = true; + }; + }, [projectId, membership]); + + const canUploadToCloud = !membership && !!user && isPro && !!projectId && isLocalOnly === true; + const isInProject = !!projectId; const hasScreenplay = viewContext.visiblePanels.includes("screenplay"); const hasTitlePage = viewContext.visiblePanels.includes("title"); @@ -164,6 +190,18 @@ const ProjectNavbar = () => {
{membership ? ( + ) : canUploadToCloud ? ( +
uploadToCloudPopup(projectId, userCtx)} + style={{ cursor: "pointer" }} + > + +
) : (
{ const { popup } = useContext(UserContext); @@ -26,6 +28,8 @@ export const Popup = () => { return )} />; case PopupType.EditScene: return )} />; + case PopupType.UploadToCloud: + return )} />; default: return null; } diff --git a/components/popup/PopupUploadToCloud.tsx b/components/popup/PopupUploadToCloud.tsx new file mode 100644 index 00000000..faf1c3ba --- /dev/null +++ b/components/popup/PopupUploadToCloud.tsx @@ -0,0 +1,79 @@ +"use client"; + +import popup from "./Popup.module.css"; +import form from "../utils/Form.module.css"; + +import { X } from "lucide-react"; +import { join } from "@src/lib/utils/misc"; +import { useDraggable } from "@src/lib/utils/hooks"; +import { PopupData, PopupUploadToCloudData, closePopup } from "@src/lib/screenplay/popup"; +import { useContext, useState } from "react"; +import { UserContext } from "@src/context/UserContext"; +import { useTranslations } from "next-intl"; +import FormInfo, { FormInfoType } from "../utils/FormInfo"; + +const PopupUploadToCloud = ({ data: { projectId } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("popup.uploadToCloud"); + + const [isUploading, setIsUploading] = useState(false); + const [info, setInfo] = useState(undefined); + + const onConfirm = async () => { + if (isUploading) return; + setIsUploading(true); + setInfo(undefined); + try { + const { promoteLocalProjectToCloud } = await import( + "@src/lib/persistence/storage-provider/local-persistence" + ); + await promoteLocalProjectToCloud(projectId); + window.location.reload(); + } catch (e) { + const message = e instanceof Error ? e.message : t("failed"); + setInfo({ content: message, isError: true }); + setIsUploading(false); + } + }; + + const onCancel = () => { + if (isUploading) return; + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("title")}

+ +
+
+

{t("body")}

+
+ {info && } + + +
+
+ ); +}; + +export default PopupUploadToCloud; diff --git a/components/utils/NavbarIconButton.module.css b/components/utils/NavbarIconButton.module.css index a270fed2..6cdc5124 100644 --- a/components/utils/NavbarIconButton.module.css +++ b/components/utils/NavbarIconButton.module.css @@ -6,20 +6,17 @@ padding-inline: 10px; gap: 12px; border-radius: 64px; - border: 2px solid var(--tertiary); - color: var(--secondary-text); - background-color: var(--main-bg); + border: 2px solid var(--secondary); + background-color: var(--secondary); user-select: none; cursor: pointer; transition: all 0.2s ease; } .button:hover { - background-color: var(--secondary-hover); - color: var(--primary-text); + border-color: var(--tertiary); } .active { - background-color: var(--primary); - color: var(--primary-text); + border-color: var(--tertiary); } diff --git a/messages/de.json b/messages/de.json index 839bad2b..f31e0e08 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7,6 +7,7 @@ "noConnection": "Keine Verbindung", "reconnecting": "Verbindung wird wiederhergestellt...", "localProject": "Lokales Projekt", + "uploadToCloud": "In die Cloud hochladen", "cachedProject": "Gecachtes Projekt", "endlessScroll": "Endloses Scrollen", "toggleComments": "Kommentare ein/aus", @@ -122,7 +123,7 @@ "deleteModalTitle": "Konto löschen", "deleteModalDesc": "Dies wird dauerhaft Ihr Konto und alle verknüpften Daten löschen. Diese Aktion kann nicht rückgängig gemacht werden.", "deleteConfirmPhrase": "Ich bestätige die Löschung meines Kontos", - "deleteConfirmLabel": "Tippe zur Bestätigung.", + "deleteConfirmLabel": "Gib folgenden Text ein, um die Löschung zu bestätigen:", "deleting": "Löschen...", "deleteAccountBtn": "Mein Konto löschen", "subscription": { @@ -363,7 +364,7 @@ "edit": "Bearbeiten", "copy": "Kopieren", "cut": "Ausschneiden", - "selectInEditor": "Im Editor auswählen", + "selectInEditor": "Auswählen", "remove": "Entfernen", "paste": "Einfügen", "highlight": "Hervorheben", @@ -409,6 +410,14 @@ "synopsis": "Kurzbeschreibung", "synopsisPlaceholder": "Schreiben Sie eine kurze Beschreibung dieser Szene...", "save": "Speichern" + }, + "uploadToCloud": { + "title": "In die Cloud hochladen", + "body": "Dieses Projekt in die Cloud hochladen? Ihr Projekt wird gesichert und ist von jedem Gerät zugänglich, und Sie können Mitarbeiter einladen.", + "confirm": "Hochladen", + "cancel": "Abbrechen", + "uploading": "Wird hochgeladen...", + "failed": "Hochladen fehlgeschlagen. Bitte versuchen Sie es erneut." } }, "board": { diff --git a/messages/en.json b/messages/en.json index 9ad8342d..8c52eba1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7,6 +7,7 @@ "noConnection": "No connection", "reconnecting": "Reconnecting...", "localProject": "Local project", + "uploadToCloud": "Upload to cloud", "endlessScroll": "Endless Scroll", "toggleComments": "Toggle Comments", "focusMode": "Focus Mode", @@ -121,7 +122,7 @@ "deleteModalTitle": "Delete account", "deleteModalDesc": "This will permanently delete your account and all associated data. This action cannot be undone.", "deleteConfirmPhrase": "I confirm my account deletion", - "deleteConfirmLabel": "Type to confirm.", + "deleteConfirmLabel": "Type the following text to confirm deletion:", "deleting": "Deleting...", "deleteAccountBtn": "Delete my account", "subscription": { @@ -362,7 +363,7 @@ "edit": "Edit", "copy": "Copy", "cut": "Cut", - "selectInEditor": "Select in editor", + "selectInEditor": "Select", "remove": "Remove", "paste": "Paste", "highlight": "Highlight", @@ -408,6 +409,14 @@ "synopsis": "Synopsis", "synopsisPlaceholder": "Write a brief description of this scene...", "save": "Save" + }, + "uploadToCloud": { + "title": "Upload to cloud", + "body": "Upload this project to the cloud? Your project will be backed up and accessible from any device, and you'll be able to invite collaborators.", + "confirm": "Upload", + "cancel": "Cancel", + "uploading": "Uploading...", + "failed": "Upload failed. Please try again." } }, "board": { diff --git a/messages/es.json b/messages/es.json index 7a90acb8..fc373f7a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1,473 +1,482 @@ { - "navbar": { - "screenplay": "Guión", - "board": "Tablero", - "titlePage": "Portada", - "synced": "Sincronizado en la nube", - "noConnection": "Sin conexión", - "reconnecting": "Reconectando...", - "localProject": "Proyecto local", - "endlessScroll": "Desplazamiento infinito", - "toggleComments": "Mostrar/ocultar comentarios", - "focusMode": "Modo concentración", - "splitPanel": "Dividir panel", - "unsplitPanel": "Unir panel", - "draftEditor": "Editor de borrador" - }, - "common": { - "save": "Guardar cambios", - "cancel": "Cancelar", - "loading": "Cargando...", - "resetDefaults": "Restablecer predeterminados" - }, - "sidebar": { - "title": "Panel", - "logOut": "Cerrar sesión", - "auth": "Iniciar sesión", - "logOutConfirmTitle": "Cerrar sesión", - "logOutConfirmDesc": "¿Estás seguro de que quieres cerrar sesión?", - "logOutConfirmBtn": "Cerrar sesión", - "logOutCancelBtn": "Cancelar" - }, - "appearance": { - "theme": "Tema", - "themeHelp": { - "dark": "Tema acogedor y de bajo resplandor, perfecto para noctámbulos y noches de concentración.", - "light": "Tema nítido y aireado que se siente natural y cómodo durante el día.", - "latte": "Tema suave con base crema que combina calidez y legibilidad.", - "wonka": "Tema aterciopelado con base de cacao que combina lujo profundo y descanso visual.", - "mint": "Tema refrescante con esencia de menta que combina serenidad botánica y equilibrio visual.", - "blossom": "Tema delicado con esencia floral que combina calidez de pétalos y suavidad visual.", - "midnight": "Tema azul medianoche profundo, diseñado para el trabajo concentrado bajo un cielo estrellado." + "navbar": { + "screenplay": "Guión", + "board": "Tablero", + "titlePage": "Portada", + "synced": "Sincronizado en la nube", + "noConnection": "Sin conexión", + "reconnecting": "Reconectando...", + "localProject": "Proyecto local", + "uploadToCloud": "Subir a la nube", + "endlessScroll": "Desplazamiento infinito", + "toggleComments": "Mostrar/ocultar comentarios", + "focusMode": "Modo concentración", + "splitPanel": "Dividir panel", + "unsplitPanel": "Unir panel", + "draftEditor": "Editor de borrador" }, - "editor": "Editor", - "themedEditor": "Editor con tema", - "themedEditorDesc": "Usar los colores del tema para el fondo y el texto del editor", - "highlightOnHover": "Resaltar al pasar el cursor", - "highlightOnHoverDesc": "Resalta ligeramente la línea sobre la que se pasa el cursor en el editor" - }, - "keybinds": { - "screenplayElements": "Elementos de guión", - "typing": "Escribir… (Esc para cancelar)", - "notSet": "No asignado", - "modifiersOnly": "Solo modificadores — presiona una tecla normal", - "defaultPrefix": "Por defecto: {combo}", - "reset": "Restablecer", - "resetTitle": "Borrar asignación del usuario", - "resetDefaults": "Restablecer predeterminados", - "save": "Guardar cambios" - }, - "language": { - "label": "Idioma de la interfaz", - "helpText": "Elige el idioma que se usa en toda la aplicación.", - "spellcheckLabel": "Corrector orthográfico", - "spellcheckHelpText": "Descarga un diccionario para la corrección ortográfica en el editor.", - "spellcheckNone": "Desactivado", - "customDictLabel": "Diccionario del proyecto", - "customDictAdd": "Añadir", - "customDictEmpty": "Aún no hay palabras personalizadas.", - "customDictHelpText": "Las palabras añadidas aquí se comparten con todos los colaboradores del proyecto." - }, - "modal": { - "groups": { - "project": "Proyecto", - "preferences": "Preferencias", - "account": "Cuenta" + "common": { + "save": "Guardar cambios", + "cancel": "Cancelar", + "loading": "Cargando...", + "resetDefaults": "Restablecer predeterminados" }, - "tabs": { - "General": "General", - "Layout": "Diseño", - "Export": "Importar/Exportar", - "Collaborators": "Colaboradores", - "Keybinds": "Atajos de teclado", - "Appearance": "Apariencia", - "Language": "Idioma", - "Profile": "Perfil", - "Settings": "Ajustes", - "Auth": "Iniciar sesión", - "About": "Acerca de", - "Subscription": "Suscripción" - } - }, - "dangerZone": { - "transferOwnership": "Transferir propiedad", - "transferDesc": "Transfiere tu rol de propietario a otro usuario. Pasarás a tener el rol de editor.", - "transferBtn": "Transferir", - "transferPrompt": "Introduce el correo del nuevo propietario:", - "deleteProject": "Eliminar proyecto", - "deleteProjectDesc": "Once un proyecto es eliminado, no hay vuelta atrás. Por favor, asegúrate.", - "deleteBtn": "Eliminar", - "modalTitle": "Eliminar proyecto", - "modalDesc": "Esta acción es permanente y no se puede deshacer. Se perderán todos los datos asociados a este proyecto.", - "deleting": "Eliminando...", - "confirmDeleteBtn": "Eliminar proyecto" - }, - "profile": { - "email": "Correo electrónico", - "username": "Nombre de usuario", - "usernamePlaceholder": "Introduce tu nombre para mostrar...", - "usernameHelp": "Este nombre será visible para los colaboradores cuando trabajes en proyectos compartidos.", - "color": "Color", - "customColor": "Color personalizado", - "selectColor": "Seleccionar color {color}", - "successMessage": "Perfil actualizado correctamente", - "failedUpdate": "Error al actualizar el perfil", - "errorSaving": "Ocurrió un error al guardar", - "saving": "Guardando...", - "dangerZoneTitle": "Zona de peligro", - "deleteAccount": "Eliminar cuenta", - "deleteAccountDesc": "Elimina permanentemente tu cuenta y todos los datos asociados. Esto no se puede deshacer.", - "deleteBtn": "Eliminar", - "deleteModalTitle": "Eliminar cuenta", - "deleteModalDesc": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.", - "deleteConfirmPhrase": "Confirmo la eliminación de mi cuenta", - "deleteConfirmLabel": "Escribe para confirmar.", - "deleting": "Eliminando...", - "deleteAccountBtn": "Eliminar mi cuenta", - "subscription": { - "title": "Suscripción", - "proBadge": "Pro", - "proTitle": "Plan Pro", - "freeTitle": "Plan Gratuito", - "renewsOn": "Se renueva el {date}", - "perksTitle": "Incluido en tu plan:", - "upgradeTitle": "Mejora para desbloquear:", - "perkProjects": "Crear proyectos en la nube", - "perkSaves": "Guardados manuales e historial de versiones", - "perkCollaborators": "Invitar colaboradores", - "perkAutoSave": "Guardado automático en la nube", - "upgradeBtn": "Actualizar a Pro", - "redirecting": "Redirigiendo...", - "cancel": "Cancelar suscripción", - "cancelConfirm": "Tu acceso Pro permanece activo hasta {date}. ¿Cancelar de todos modos?", - "cancelYes": "Sí, cancelar", - "cancelNo": "Mantener Pro", - "cancelling": "Cancelando...", - "cancelSuccess": "Suscripción cancelada. Acceso Pro hasta {date}.", - "endsOn": "Tu suscripción termina el {date}", - "purchasing": "Comprando...", - "purchaseError": "La compra falló. Inténtalo de nuevo.", - "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", - "manageApple": "Abrir App Store", - "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", - "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta.", - "welcomePro": "Ya estás suscrito a Scriptio Pro. ¡Bienvenido!", - "resubscribe": "Reactivar suscripción" - } - }, - "projects": { - "untitled": "Sin título", - "newProject": "Nuevo proyecto", - "pageTitle": "Proyectos", - "importBtn": "Importar...", - "importing": "Importando...", - "createBtn": "Crear", - "empty": { - "title": "Tu Historia Empieza Aquí", - "subtitle": "Crea un nuevo guión o importa un script existente", - "createFirst": "Nuevo proyecto", - "createDesc": "Empezar desde una página en blanco", - "importExisting": "Importar", - "importDesc": "Abrir un archivo de guión existente" + "sidebar": { + "title": "Panel", + "logOut": "Cerrar sesión", + "auth": "Iniciar sesión", + "logOutConfirmTitle": "Cerrar sesión", + "logOutConfirmDesc": "¿Estás seguro de que quieres cerrar sesión?", + "logOutConfirmBtn": "Cerrar sesión", + "logOutCancelBtn": "Cancelar" }, - "item": { - "posterAlt": "Póster de la película", - "localOnly": "Solo local", - "syncedToCloud": "Sincronizado en la nube" + "appearance": { + "theme": "Tema", + "themeHelp": { + "dark": "Tema acogedor y de bajo resplandor, perfecto para noctámbulos y noches de concentración.", + "light": "Tema nítido y aireado que se siente natural y cómodo durante el día.", + "latte": "Tema suave con base crema que combina calidez y legibilidad.", + "wonka": "Tema aterciopelado con base de cacao que combina lujo profundo y descanso visual.", + "mint": "Tema refrescante con esencia de menta que combina serenidad botánica y equilibrio visual.", + "blossom": "Tema delicado con esencia floral que combina calidez de pétalos y suavidad visual.", + "midnight": "Tema azul medianoche profundo, diseñado para el trabajo concentrado bajo un cielo estrellado." + }, + "editor": "Editor", + "themedEditor": "Editor con tema", + "themedEditorDesc": "Usar los colores del tema para el fondo y el texto del editor", + "highlightOnHover": "Resaltar al pasar el cursor", + "highlightOnHoverDesc": "Resalta ligeramente la línea sobre la que se pasa el cursor en el editor" }, - "form": { - "formTitle": "Crear proyecto", - "titleField": "Título", - "descriptionField": "Descripción", - "authorField": "Autor", - "posterField": "Póster", - "optional": "opcional", - "submitBtn": "Crear", - "failedToCreate": "Error al crear el proyecto" - } - }, - "editorSidebar": { - "scenes": "Escenas", - "characters": "Personajes", - "locations": "Localizaciones", - "shelf": "Estante", - "shelfEmpty": "Seleccione una versión para editar", - "goTo": "Ir al guión", - "confirmRestore": "Esto reemplazará el contenido correspondiente en su guión.", - "restore": "Restaurar", - "cancel": "Cancelar" - }, - "formatDropdown": { - "elements": { - "scene": "ENCABEZADO DE SCÈNE", - "action": "Acción", - "character": "PERSONAJE", - "dialogue": "Diálogo", - "parenthetical": "(Paréntesis)", - "transition": "TRANSICIÓN:", - "section": "Sección", - "note": "[[Nota]]", - "dual_dialogue": "Doble diálogo", - "none": "Ninguno" + "keybinds": { + "screenplayElements": "Elementos de guión", + "typing": "Escribir… (Esc para cancelar)", + "notSet": "No asignado", + "modifiersOnly": "Solo modificadores — presiona una tecla normal", + "defaultPrefix": "Por defecto: {combo}", + "reset": "Restablecer", + "resetTitle": "Borrar asignación del usuario", + "resetDefaults": "Restablecer predeterminados", + "save": "Guardar cambios" }, - "titlePageElements": { - "title": "Título", - "author": "Autor", - "date": "Fecha", - "none": "Ninguno" - } - }, - "search": { - "placeholder": "Buscar...", - "noMatches": "Sin resultados", - "matchCount": "{current} de {total}", - "replacePlaceholder": "Remplazar con...", - "replace": "Remplazar", - "replaceAll": "Remplazar todo", - "filterByElement": "Filtrar por elemento:", - "elements": { - "scene": "Encabezado de escena", - "action": "Acción", - "character": "Personaje", - "dialogue": "Diálogo", - "parenthetical": "Paréntesis", - "transition": "Transición", - "section": "Sección", - "note": "Nota", - "dual_dialogue": "Diálogo dual", - "none": "Ninguno" - } - }, - "collaborators": { - "projectTeam": "Equipo del proyecto ({count}/{max})", - "teamHelp": "Gestiona los miembros de tu equipo y las invitaciones pendientes. Puedes invitar a cualquier usuario no Pro a participar en tu proyecto. El proyecto permanece colaborativo mientras el propietario tenga el plan Pro.", - "roles": { - "owner": "Propietario", - "admin": "Administrador", - "editor": "Editor", - "viewer": "Espectador" + "language": { + "label": "Idioma de la interfaz", + "helpText": "Elige el idioma que se usa en toda la aplicación.", + "spellcheckLabel": "Corrector orthográfico", + "spellcheckHelpText": "Descarga un diccionario para la corrección ortográfica en el editor.", + "spellcheckNone": "Desactivado", + "customDictLabel": "Diccionario del proyecto", + "customDictAdd": "Añadir", + "customDictEmpty": "Aún no hay palabras personalizadas.", + "customDictHelpText": "Las palabras añadidas aquí se comparten con todos los colaboradores del proyecto." }, - "roleDesc": { - "owner": "Puede eliminar el proyecto y transferir la propiedad", - "admin": "Puede invitar, ascender, degradar y expulsar colaboradores", - "editor": "Puede modificar el guión y otro contenido del proyecto", - "viewer": "Acceso de solo lectura. No puede realizar cambios" + "modal": { + "groups": { + "project": "Proyecto", + "preferences": "Preferencias", + "account": "Cuenta" + }, + "tabs": { + "General": "General", + "Layout": "Diseño", + "Export": "Importar/Exportar", + "Collaborators": "Colaboradores", + "Keybinds": "Atajos de teclado", + "Appearance": "Apariencia", + "Language": "Idioma", + "Profile": "Perfil", + "Settings": "Ajustes", + "Auth": "Iniciar sesión", + "About": "Acerca de", + "Subscription": "Suscripción" + } }, - "you": "(tú)", - "leave": "Salir", - "kick": "Expulsar", - "pending": "Pendiente", - "cancel": "Cancelar", - "emailPlaceholder": "Introduce el correo...", - "invite": "Invitar", - "proRequired": "A Pro subscription is required to invite collaborators.", - "proRequiredInvite": "Upgrade to Pro to invite collaborators", - "upgrade": "Upgrade", - "localProjectOnly": "Collaboration is not available for local projects. Upload your project to the cloud to invite collaborators." - }, - "export": { - "importLabel": "Importar", - "selectFile": "Seleccionar archivo", - "selectFileDesc": "Sube .fountain, .fdx, .scriptio o .txt", - "exportLabel": "Exportar", - "formatOptions": { - "pdf": "Documento PDF (.pdf)", - "fountain": "Fountain (.fountain)", - "fdx": "Final Draft (.fdx)", - "scriptio": "Scriptio (.scriptio)" + "dangerZone": { + "transferOwnership": "Transferir propiedad", + "transferDesc": "Transfiere tu rol de propietario a otro usuario. Pasarás a tener el rol de editor.", + "transferBtn": "Transferir", + "transferPrompt": "Introduce el correo del nuevo propietario:", + "deleteProject": "Eliminar proyecto", + "deleteProjectDesc": "Once un proyecto es eliminado, no hay vuelta atrás. Por favor, asegúrate.", + "deleteBtn": "Eliminar", + "modalTitle": "Eliminar proyecto", + "modalDesc": "Esta acción es permanente y no se puede deshacer. Se perderán todos los datos asociados a este proyecto.", + "deleting": "Eliminando...", + "confirmDeleteBtn": "Eliminar proyecto" }, - "formatHelp": { - "pdf": "Formato estándar de la industria. Ideal para compartir e imprimir.", - "fountain": "Formato de texto plano basado en Markdown, ideal para compatibilidad.", - "fdx": "Compatible con el software de la industria Final Draft.", - "scriptio": "Formato propio de Scriptio, para mantener el proyecto en local" + "profile": { + "email": "Correo electrónico", + "username": "Nombre de usuario", + "usernamePlaceholder": "Introduce tu nombre para mostrar...", + "usernameHelp": "Este nombre será visible para los colaboradores cuando trabajes en proyectos compartidos.", + "color": "Color", + "customColor": "Color personalizado", + "selectColor": "Seleccionar color {color}", + "successMessage": "Perfil actualizado correctamente", + "failedUpdate": "Error al actualizar el perfil", + "errorSaving": "Ocurrió un error al guardar", + "saving": "Guardando...", + "dangerZoneTitle": "Zona de peligro", + "deleteAccount": "Eliminar cuenta", + "deleteAccountDesc": "Elimina permanentemente tu cuenta y todos los datos asociados. Esto no se puede deshacer.", + "deleteBtn": "Eliminar", + "deleteModalTitle": "Eliminar cuenta", + "deleteModalDesc": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.", + "deleteConfirmPhrase": "Confirmo la eliminación de mi cuenta", + "deleteConfirmLabel": "Escribe el siguiente texto para confirmar la eliminación:", + "deleting": "Eliminando...", + "deleteAccountBtn": "Eliminar mi cuenta", + "subscription": { + "title": "Suscripción", + "proBadge": "Pro", + "proTitle": "Plan Pro", + "freeTitle": "Plan Gratuito", + "renewsOn": "Se renueva el {date}", + "perksTitle": "Incluido en tu plan:", + "upgradeTitle": "Mejora para desbloquear:", + "perkProjects": "Crear proyectos en la nube", + "perkSaves": "Guardados manuales e historial de versiones", + "perkCollaborators": "Invitar colaboradores", + "perkAutoSave": "Guardado automático en la nube", + "upgradeBtn": "Actualizar a Pro", + "redirecting": "Redirigiendo...", + "cancel": "Cancelar suscripción", + "cancelConfirm": "Tu acceso Pro permanece activo hasta {date}. ¿Cancelar de todos modos?", + "cancelYes": "Sí, cancelar", + "cancelNo": "Mantener Pro", + "cancelling": "Cancelando...", + "cancelSuccess": "Suscripción cancelada. Acceso Pro hasta {date}.", + "endsOn": "Tu suscripción termina el {date}", + "purchasing": "Comprando...", + "purchaseError": "La compra falló. Inténtalo de nuevo.", + "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", + "manageApple": "Abrir App Store", + "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", + "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta.", + "welcomePro": "Ya estás suscrito a Scriptio Pro. ¡Bienvenido!", + "resubscribe": "Reactivar suscripción" + } }, - "includeNotes": "Incluir notas", - "includeNotesDesc": "Exportar notas en línea.", - "readable": "JSON legible", - "readableDesc": "Exportar como JSON plano en lugar de binario comprimido. Archivo más grande, inspeccionable con cualquier editor de texto.", - "watermark": "Marca de agua", - "watermarkDesc": "Superponer texto en las páginas.", - "watermarkPlaceholder": "Texto de marca de agua", - "passwordProtection": "Protección con contraseña", - "passwordProtectionDesc": "Requerir una contraseña para abrir el PDF.", - "passwordPlaceholder": "Introduce la contraseña", - "exportBtn": "Exportar", - "exporting": "Exportando...", - "exportingProgress": "Exportando ({progress}%)" - }, - "layout": { - "pageFormat": "Formato de página", - "pageFormatHelp": { - "letter": "Formato estándar de Estados Unidos. Estándar de la industria para guiones de Hollywood.", - "a4": "Formato estándar internacional. Común en Europa y la mayoría de los países." + "projects": { + "untitled": "Sin título", + "newProject": "Nuevo proyecto", + "pageTitle": "Proyectos", + "importBtn": "Importar...", + "importing": "Importando...", + "createBtn": "Crear", + "empty": { + "title": "Tu Historia Empieza Aquí", + "subtitle": "Crea un nuevo guión o importa un script existente", + "createFirst": "Nuevo proyecto", + "createDesc": "Empezar desde una página en blanco", + "importExisting": "Importar", + "importDesc": "Abrir un archivo de guión existente" + }, + "item": { + "posterAlt": "Póster de la película", + "localOnly": "Solo local", + "syncedToCloud": "Sincronizado en la nube" + }, + "form": { + "formTitle": "Crear proyecto", + "titleField": "Título", + "descriptionField": "Descripción", + "authorField": "Autor", + "posterField": "Póster", + "optional": "opcional", + "submitBtn": "Crear", + "failedToCreate": "Error al crear el proyecto" + } }, - "sceneHeadings": "Encabezados de escena", - "bold": "Negrita", - "boldDesc": "Los encabezados de escena aparecerán en negrita", - "extraSpace": "Espacio extra arriba", - "extraSpaceDesc": "Añadir espacio extra antes de los encabezados de escena", - "sceneNumbering": "Numeración de escenas", - "sceneNumberingDesc": "Mostrar números de escena en el margen izquierdo", - "duplicateRight": "Duplicar en margen derecho", - "duplicateRightDesc": "Mostrar número en ambos lados", - "continuation": "Continuación", - "moreTitle": "(MORE) Etiqueta", - "contdTitle": "(CONT'D) Etiqueta", - "pageMargins": "Márgenes de página", - "vertical": "Vertical", - "horizontal": "Horizontal", - "marginTop": "Superior", - "marginBottom": "Inferior", - "margins": "Márgenes", - "marginLeft": "Izquierda", - "marginRight": "Derecha", - "marginElements": { - "action": "Acción", - "scene": "Encabezado de escena", - "character": "Personaje", - "dialogue": "Diálogo", - "parenthetical": "Paréntesis", - "transition": "Transición", - "section": "Sección" + "editorSidebar": { + "scenes": "Escenas", + "characters": "Personajes", + "locations": "Localizaciones", + "shelf": "Estante", + "shelfEmpty": "Seleccione una versión para editar", + "goTo": "Ir al guión", + "confirmRestore": "Esto reemplazará el contenido correspondiente en su guión.", + "restore": "Restaurar", + "cancel": "Cancelar" }, - "elements": "Configuración de elementos", - "style": "Estilo", - "alignment": "Alineación", - "italic": "Cursiva", - "underline": "Subrayado", - "uppercase": "Mayúsculas", - "alignLeft": "Izquierda", - "alignCenter": "Centro", - "alignRight": "Derecha", - "sceneSpacing": "Espaciado", - "sceneSpacingDesc": "Espaciado sobre los encabezados de escena", - "startNewPage": "Iniciar nueva página" - }, - "projectSettings": { - "titleLabel": "Título", - "titlePlaceholder": "Introduce el nombre del proyecto...", - "authorLabel": "Autor", - "authorPlaceholder": "Nombre del autor...", - "descriptionLabel": "Descripción", - "descriptionPlaceholder": "¿De qué trata este guión?", - "posterLabel": "Póster", - "noPoster": "Sin póster", - "posterHelp": "Recomendado: 600x900 píxeles (proporción 2:3). Compatible con PNG y JPG.", - "saveChanges": "Guardar cambios", - "dangerZoneTitle": "Zona de peligro" - }, - "contextMenu": { - "goToScene": "Ir a la escena", - "edit": "Editar", - "copy": "Copiar", - "cut": "Cortar", - "selectInEditor": "Seleccionar en el editor", - "remove": "Eliminar", - "paste": "Pegar", - "highlight": "Resaltar", - "addCharacter": "Añadir personaje", - "addComment": "Añadir comentario", - "searchOnWeb": "Buscar en la web", - "noSuggestions": "Sin sugerencias", - "addToDictionary": "Añadir al diccionario", - "makeDualDialogue": "Crear doble diálogo", - "shelve": "Archivar", - "shelveScene": "Archivar escena", - "shelveDialogue": "Archivar diálogo", - "shelveAction": "Archivar acción" - }, - "popup": { - "character": { - "create": "Crear personaje", - "edit": "Editar personaje", - "name": "Nombre", - "gender": "Género", - "genderFemale": "Mujer", - "genderMale": "Hombre", - "genderOther": "Otro", - "color": "Color", - "synopsis": "Sinopsis", - "confirm": "Confirmar", - "takenNameError": "Ya existe un personaje con el nombre {newName}. Por favor, elige un nombre diferente o edita el personaje existente.", - "updateOccurrences": "¿Estás seguro de que quieres actualizar {count} ocurrencias de la palabra {oldName} a {newName}? Ten especial cuidado con las palabras comunes cuya actualización podría ser indeseada.", - "yes": "Sí", - "noDoNotChange": "No, no cambiar" + "formatDropdown": { + "elements": { + "scene": "ENCABEZADO DE SCÈNE", + "action": "Acción", + "character": "PERSONAJE", + "dialogue": "Diálogo", + "parenthetical": "(Paréntesis)", + "transition": "TRANSICIÓN:", + "section": "Sección", + "note": "[[Nota]]", + "dual_dialogue": "Doble diálogo", + "none": "Ninguno" + }, + "titlePageElements": { + "title": "Título", + "author": "Autor", + "date": "Fecha", + "none": "Ninguno" + } }, - "import": { - "title": "Confirmar importación", - "warning": "¿Estás seguro de que quieres sobrescribir tu proyecto actual?", - "info": "Puedes exportar tu proyecto antes de importar uno nuevo.", - "yesImport": "Sí, importar", - "no": "No" + "search": { + "placeholder": "Buscar...", + "noMatches": "Sin resultados", + "matchCount": "{current} de {total}", + "replacePlaceholder": "Remplazar con...", + "replace": "Remplazar", + "replaceAll": "Remplazar todo", + "filterByElement": "Filtrar por elemento:", + "elements": { + "scene": "Encabezado de escena", + "action": "Acción", + "character": "Personaje", + "dialogue": "Diálogo", + "parenthetical": "Paréntesis", + "transition": "Transición", + "section": "Sección", + "note": "Nota", + "dual_dialogue": "Diálogo dual", + "none": "Ninguno" + } }, - "scene": { - "edit": "Editar escena", - "sceneTitle": "Escena", - "color": "Color", - "synopsis": "Sinopsis", - "synopsisPlaceholder": "Escribe una breve descripción de esta escena...", - "save": "Guardar" - } - }, - "board": { - "duplicate": "Duplicar", - "delete": "Eliminar", - "hints": { - "pan": "Clic central para desplazar", - "select": "Arrastrar para seleccionar tarjetas", - "create": "Doble clic para crear tarjeta", - "move": "Mantener Shift para mover libremente" + "collaborators": { + "projectTeam": "Equipo del proyecto ({count}/{max})", + "teamHelp": "Gestiona los miembros de tu equipo y las invitaciones pendientes. Puedes invitar a cualquier usuario no Pro a participar en tu proyecto. El proyecto permanece colaborativo mientras el propietario tenga el plan Pro.", + "roles": { + "owner": "Propietario", + "admin": "Administrador", + "editor": "Editor", + "viewer": "Espectador" + }, + "roleDesc": { + "owner": "Puede eliminar el proyecto y transferir la propiedad", + "admin": "Puede invitar, ascender, degradar y expulsar colaboradores", + "editor": "Puede modificar el guión y otro contenido del proyecto", + "viewer": "Acceso de solo lectura. No puede realizar cambios" + }, + "you": "(tú)", + "leave": "Salir", + "kick": "Expulsar", + "pending": "Pendiente", + "cancel": "Cancelar", + "emailPlaceholder": "Introduce el correo...", + "invite": "Invitar", + "proRequired": "A Pro subscription is required to invite collaborators.", + "proRequiredInvite": "Upgrade to Pro to invite collaborators", + "upgrade": "Upgrade", + "localProjectOnly": "Collaboration is not available for local projects. Upload your project to the cloud to invite collaborators." + }, + "export": { + "importLabel": "Importar", + "selectFile": "Seleccionar archivo", + "selectFileDesc": "Sube .fountain, .fdx, .scriptio o .txt", + "exportLabel": "Exportar", + "formatOptions": { + "pdf": "Documento PDF (.pdf)", + "fountain": "Fountain (.fountain)", + "fdx": "Final Draft (.fdx)", + "scriptio": "Scriptio (.scriptio)" + }, + "formatHelp": { + "pdf": "Formato estándar de la industria. Ideal para compartir e imprimir.", + "fountain": "Formato de texto plano basado en Markdown, ideal para compatibilidad.", + "fdx": "Compatible con el software de la industria Final Draft.", + "scriptio": "Formato propio de Scriptio, para mantener el proyecto en local" + }, + "includeNotes": "Incluir notas", + "includeNotesDesc": "Exportar notas en línea.", + "readable": "JSON legible", + "readableDesc": "Exportar como JSON plano en lugar de binario comprimido. Archivo más grande, inspeccionable con cualquier editor de texto.", + "watermark": "Marca de agua", + "watermarkDesc": "Superponer texto en las páginas.", + "watermarkPlaceholder": "Texto de marca de agua", + "passwordProtection": "Protección con contraseña", + "passwordProtectionDesc": "Requerir una contraseña para abrir el PDF.", + "passwordPlaceholder": "Introduce la contraseña", + "exportBtn": "Exportar", + "exporting": "Exportando...", + "exportingProgress": "Exportando ({progress}%)" + }, + "layout": { + "pageFormat": "Formato de página", + "pageFormatHelp": { + "letter": "Formato estándar de Estados Unidos. Estándar de la industria para guiones de Hollywood.", + "a4": "Formato estándar internacional. Común en Europa y la mayoría de los países." + }, + "sceneHeadings": "Encabezados de escena", + "bold": "Negrita", + "boldDesc": "Los encabezados de escena aparecerán en negrita", + "extraSpace": "Espacio extra arriba", + "extraSpaceDesc": "Añadir espacio extra antes de los encabezados de escena", + "sceneNumbering": "Numeración de escenas", + "sceneNumberingDesc": "Mostrar números de escena en el margen izquierdo", + "duplicateRight": "Duplicar en margen derecho", + "duplicateRightDesc": "Mostrar número en ambos lados", + "continuation": "Continuación", + "moreTitle": "(MORE) Etiqueta", + "contdTitle": "(CONT'D) Etiqueta", + "pageMargins": "Márgenes de página", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "Superior", + "marginBottom": "Inferior", + "margins": "Márgenes", + "marginLeft": "Izquierda", + "marginRight": "Derecha", + "marginElements": { + "action": "Acción", + "scene": "Encabezado de escena", + "character": "Personaje", + "dialogue": "Diálogo", + "parenthetical": "Paréntesis", + "transition": "Transición", + "section": "Sección" + }, + "elements": "Configuración de elementos", + "style": "Estilo", + "alignment": "Alineación", + "italic": "Cursiva", + "underline": "Subrayado", + "uppercase": "Mayúsculas", + "alignLeft": "Izquierda", + "alignCenter": "Centro", + "alignRight": "Derecha", + "sceneSpacing": "Espaciado", + "sceneSpacingDesc": "Espaciado sobre los encabezados de escena", + "startNewPage": "Iniciar nueva página" }, - "untitled": "Sin título", - "titlePlaceholder": "Título", - "descriptionPlaceholder": "Descripción" - }, - "saves": { - "title": "Historial de versiones", - "saveCurrentVersion": "Guardar versión actual", - "namePlaceholder": "Nombre de la versión...", - "manualSaves": "Guardados manuales", - "autoSaves": "Guardados automáticos", - "restore": "Restaurar", - "rename": "Renombrar", - "delete": "Eliminar", - "cancel": "Cancelar", - "confirmRestore": "Esto reemplazará el documento actual para todos los colaboradores. ¿Estás seguro?", - "confirmDelete": "¿Estás seguro de que quieres eliminar este guardado?", - "noSaves": "Aún no hay guardados", - "proRequired": "Se requiere Pro", - "proRequiredDesc": "El historial de versiones es una función Pro. Actualiza tu suscripción para guardar y restaurar versiones con nombre de tu guion.", - "upgradeBtn": "Actualizar a Pro", - "signInAndUpgrade": "Iniciar sesión y actualizar" - }, - "auth": { - "intro": "Introduce tu correo y te enviaremos un enlace de inicio de sesión de un solo uso. No necesitas contraseña.", - "emailLabel": "Correo electrónico", - "sendLink": "Enviar enlace de inicio de sesión", - "sending": "Enviando...", - "checkInbox": "Si existe una cuenta asociada a {email}, se ha enviado un enlace de inicio de sesión. El enlace es válido durante los próximos 10 minutos.", - "useDifferentEmail": "Usar otro correo electrónico", - "waitingForClick": "Esperando a que hagas clic en el enlace de tu bandeja de entrada...", - "requestFailed": "No se pudo enviar el enlace de inicio de sesión. Por favor, vuelve a intentarlo en un momento.", - "desktopTimeout": "El tiempo de inicio de sesión expiró. Por favor, solicita un nuevo enlace." - }, - "oauth": { - "orContinueWith": "o continuar con", - "continueWithGoogle": "Continuar con Google", - "continueWithApple": "Continuar con Apple", - "waiting": "Esperando al navegador…", - "timeout": "Tiempo de espera agotado. Por favor, inténtalo de nuevo.", - "error": "Error al iniciar sesión. Por favor, inténtalo de nuevo." - }, - "dates": { - "justNow": "Recién", - "minutesAgo": "{mins, plural, one {hace # minuto} other {hace # minutos}}", - "hoursAgo": "{hours, plural, one {hace # hora} other {hace # horas}}", - "today": "Hoy", - "yesterday": "Ayer", - "daysAgo": "{days, plural, one {hace # día} other {hace # días}}", - "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", - "moreThanYearAgo": "Hace más de 1 año" - } -} \ No newline at end of file + "projectSettings": { + "titleLabel": "Título", + "titlePlaceholder": "Introduce el nombre del proyecto...", + "authorLabel": "Autor", + "authorPlaceholder": "Nombre del autor...", + "descriptionLabel": "Descripción", + "descriptionPlaceholder": "¿De qué trata este guión?", + "posterLabel": "Póster", + "noPoster": "Sin póster", + "posterHelp": "Recomendado: 600x900 píxeles (proporción 2:3). Compatible con PNG y JPG.", + "saveChanges": "Guardar cambios", + "dangerZoneTitle": "Zona de peligro" + }, + "contextMenu": { + "goToScene": "Ir a la escena", + "edit": "Editar", + "copy": "Copiar", + "cut": "Cortar", + "selectInEditor": "Seleccionar", + "remove": "Eliminar", + "paste": "Pegar", + "highlight": "Resaltar", + "addCharacter": "Añadir personaje", + "addComment": "Añadir comentario", + "searchOnWeb": "Buscar en la web", + "noSuggestions": "Sin sugerencias", + "addToDictionary": "Añadir al diccionario", + "makeDualDialogue": "Crear doble diálogo", + "shelve": "Archivar", + "shelveScene": "Archivar escena", + "shelveDialogue": "Archivar diálogo", + "shelveAction": "Archivar acción" + }, + "popup": { + "character": { + "create": "Crear personaje", + "edit": "Editar personaje", + "name": "Nombre", + "gender": "Género", + "genderFemale": "Mujer", + "genderMale": "Hombre", + "genderOther": "Otro", + "color": "Color", + "synopsis": "Sinopsis", + "confirm": "Confirmar", + "takenNameError": "Ya existe un personaje con el nombre {newName}. Por favor, elige un nombre diferente o edita el personaje existente.", + "updateOccurrences": "¿Estás seguro de que quieres actualizar {count} ocurrencias de la palabra {oldName} a {newName}? Ten especial cuidado con las palabras comunes cuya actualización podría ser indeseada.", + "yes": "Sí", + "noDoNotChange": "No, no cambiar" + }, + "import": { + "title": "Confirmar importación", + "warning": "¿Estás seguro de que quieres sobrescribir tu proyecto actual?", + "info": "Puedes exportar tu proyecto antes de importar uno nuevo.", + "yesImport": "Sí, importar", + "no": "No" + }, + "scene": { + "edit": "Editar escena", + "sceneTitle": "Escena", + "color": "Color", + "synopsis": "Sinopsis", + "synopsisPlaceholder": "Escribe una breve descripción de esta escena...", + "save": "Guardar" + }, + "uploadToCloud": { + "title": "Subir a la nube", + "body": "¿Subir este proyecto a la nube? Tu proyecto se respaldará y será accesible desde cualquier dispositivo, y podrás invitar colaboradores.", + "confirm": "Subir", + "cancel": "Cancelar", + "uploading": "Subiendo a la nube...", + "failed": "Error al subir. Por favor, inténtalo de nuevo." + } + }, + "board": { + "duplicate": "Duplicar", + "delete": "Eliminar", + "hints": { + "pan": "Clic central para desplazar", + "select": "Arrastrar para seleccionar tarjetas", + "create": "Doble clic para crear tarjeta", + "move": "Mantener Shift para mover libremente" + }, + "untitled": "Sin título", + "titlePlaceholder": "Título", + "descriptionPlaceholder": "Descripción" + }, + "saves": { + "title": "Historial de versiones", + "saveCurrentVersion": "Guardar versión actual", + "namePlaceholder": "Nombre de la versión...", + "manualSaves": "Guardados manuales", + "autoSaves": "Guardados automáticos", + "restore": "Restaurar", + "rename": "Renombrar", + "delete": "Eliminar", + "cancel": "Cancelar", + "confirmRestore": "Esto reemplazará el documento actual para todos los colaboradores. ¿Estás seguro?", + "confirmDelete": "¿Estás seguro de que quieres eliminar este guardado?", + "noSaves": "Aún no hay guardados", + "proRequired": "Se requiere Pro", + "proRequiredDesc": "El historial de versiones es una función Pro. Actualiza tu suscripción para guardar y restaurar versiones con nombre de tu guion.", + "upgradeBtn": "Actualizar a Pro", + "signInAndUpgrade": "Iniciar sesión y actualizar" + }, + "auth": { + "intro": "Introduce tu correo y te enviaremos un enlace de inicio de sesión de un solo uso. No necesitas contraseña.", + "emailLabel": "Correo electrónico", + "sendLink": "Enviar enlace de inicio de sesión", + "sending": "Enviando...", + "checkInbox": "Si existe una cuenta asociada a {email}, se ha enviado un enlace de inicio de sesión. El enlace es válido durante los próximos 10 minutos.", + "useDifferentEmail": "Usar otro correo electrónico", + "waitingForClick": "Esperando a que hagas clic en el enlace de tu bandeja de entrada...", + "requestFailed": "No se pudo enviar el enlace de inicio de sesión. Por favor, vuelve a intentarlo en un momento.", + "desktopTimeout": "El tiempo de inicio de sesión expiró. Por favor, solicita un nuevo enlace." + }, + "oauth": { + "orContinueWith": "o continuar con", + "continueWithGoogle": "Continuar con Google", + "continueWithApple": "Continuar con Apple", + "waiting": "Esperando al navegador…", + "timeout": "Tiempo de espera agotado. Por favor, inténtalo de nuevo.", + "error": "Error al iniciar sesión. Por favor, inténtalo de nuevo." + }, + "dates": { + "justNow": "Recién", + "minutesAgo": "{mins, plural, one {hace # minuto} other {hace # minutos}}", + "hoursAgo": "{hours, plural, one {hace # hora} other {hace # horas}}", + "today": "Hoy", + "yesterday": "Ayer", + "daysAgo": "{days, plural, one {hace # día} other {hace # días}}", + "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", + "moreThanYearAgo": "Hace más de 1 año" + } +} diff --git a/messages/fr.json b/messages/fr.json index 50ffb436..895c1d9f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7,6 +7,7 @@ "noConnection": "Pas de connexion", "reconnecting": "Reconnexion...", "localProject": "Projet local", + "uploadToCloud": "Téléverser dans le cloud", "cachedProject": "Projet en cache", "endlessScroll": "Défilement infini", "toggleComments": "Afficher/masquer les commentaires", @@ -122,7 +123,7 @@ "deleteModalTitle": "Supprimer le compte", "deleteModalDesc": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action ne peut pas être annulée.", "deleteConfirmPhrase": "Je confirme la suppression de mon compte", - "deleteConfirmLabel": "Tapez pour confirmer.", + "deleteConfirmLabel": "Écrivez le texte suivant pour confirmer la suppression :", "deleting": "Suppression...", "deleteAccountBtn": "Supprimer mon compte", "subscription": { @@ -363,7 +364,7 @@ "edit": "Modifier", "copy": "Copier", "cut": "Couper", - "selectInEditor": "Sélectionner dans l'éditeur", + "selectInEditor": "Sélectionner", "remove": "Supprimer", "paste": "Coller", "highlight": "Surligner", @@ -409,6 +410,14 @@ "synopsis": "Synopsis", "synopsisPlaceholder": "Écrivez une brève description de cette scène...", "save": "Enregistrer" + }, + "uploadToCloud": { + "title": "Téléverser dans le cloud", + "body": "Téléverser ce projet dans le cloud ? Votre projet sera sauvegardé et accessible depuis n'importe quel appareil, et vous pourrez inviter des collaborateurs.", + "confirm": "Téléverser", + "cancel": "Annuler", + "uploading": "Téléversement en cours...", + "failed": "Échec du téléversement. Veuillez réessayer." } }, "board": { diff --git a/messages/ja.json b/messages/ja.json index ea849326..4f99929c 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -7,6 +7,7 @@ "noConnection": "接続なし", "reconnecting": "再接続中...", "localProject": "ローカルプロジェクト", + "uploadToCloud": "クラウドにアップロード", "endlessScroll": "無限スクロール", "toggleComments": "コメントの表示/非表示", "focusMode": "集中モード", @@ -121,7 +122,7 @@ "deleteModalTitle": "アカウントを削除", "deleteModalDesc": "アカウントとすべての関連データを完全に削除します。この操作は取り消せません。", "deleteConfirmPhrase": "アカウントの削除を承認します", - "deleteConfirmLabel": "確認のために と入力してください。", + "deleteConfirmLabel": "削除を確認するために以下のテキストを入力してください:", "deleting": "削除中...", "deleteAccountBtn": "自分のアカウントを削除", "subscription": { @@ -362,7 +363,7 @@ "edit": "編集", "copy": "コピー", "cut": "切り取り", - "selectInEditor": "エディタで選択", + "selectInEditor": "選択", "remove": "削除", "paste": "貼り付け", "highlight": "ハイライト", @@ -408,6 +409,14 @@ "synopsis": "あらすじ", "synopsisPlaceholder": "シーンの簡単な説明を入力...", "save": "保存" + }, + "uploadToCloud": { + "title": "クラウドにアップロード", + "body": "このプロジェクトをクラウドにアップロードしますか?プロジェクトはバックアップされ、どのデバイスからでもアクセスでき、コラボレーターを招待できるようになります。", + "confirm": "アップロード", + "cancel": "キャンセル", + "uploading": "アップロード中...", + "failed": "アップロードに失敗しました。もう一度お試しください。" } }, "board": { diff --git a/messages/ko.json b/messages/ko.json index eba1aab2..810d39a4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7,6 +7,7 @@ "noConnection": "연결 없음", "reconnecting": "재연결 중...", "localProject": "로컬 프로젝트", + "uploadToCloud": "클라우드에 업로드", "endlessScroll": "무한 스크롤", "toggleComments": "댓글 표시 전환", "focusMode": "집중 모드", @@ -121,7 +122,7 @@ "deleteModalTitle": "계정 삭제", "deleteModalDesc": "계정과 모든 데이터를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", "deleteConfirmPhrase": "계정 삭제를 확인합니다", - "deleteConfirmLabel": "확인을 위해 를 입력하세요.", + "deleteConfirmLabel": "삭제를 확인하려면 아래 텍스트를 입력하세요:", "deleting": "삭제 중...", "deleteAccountBtn": "내 계정 삭제", "subscription": { @@ -362,7 +363,7 @@ "edit": "편집", "copy": "복사", "cut": "잘라내기", - "selectInEditor": "에디터에서 선택", + "selectInEditor": "선택", "remove": "삭제", "paste": "붙여넣기", "highlight": "하이라이트", @@ -408,6 +409,14 @@ "synopsis": "시놉시스", "synopsisPlaceholder": "장면에 대한 간단한 설명을 입력하세요...", "save": "저장" + }, + "uploadToCloud": { + "title": "클라우드에 업로드", + "body": "이 프로젝트를 클라우드에 업로드하시겠습니까? 프로젝트가 백업되어 어떤 기기에서든 액세스할 수 있으며, 협업자를 초대할 수 있습니다.", + "confirm": "업로드", + "cancel": "취소", + "uploading": "업로드 중...", + "failed": "업로드에 실패했습니다. 다시 시도해 주세요." } }, "board": { diff --git a/messages/pl.json b/messages/pl.json index a8171bda..83fe875d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7,6 +7,7 @@ "noConnection": "Brak połączenia", "reconnecting": "Łączenie...", "localProject": "Projekt lokalny", + "uploadToCloud": "Wyślij do chmury", "endlessScroll": "Nieskończone przewijanie", "toggleComments": "Pokaż/ukryj komentarze", "focusMode": "Tryb skupienia", @@ -121,7 +122,7 @@ "deleteModalTitle": "Usuń konto", "deleteModalDesc": "To trwale usunie Twoje konto i wszystkie powiązane dane. Tej akcji nie można cofnąć.", "deleteConfirmPhrase": "Potwierdzam usunięcie mojego konta", - "deleteConfirmLabel": "Wpisz , aby potwierdzić.", + "deleteConfirmLabel": "Wpisz poniższy tekst, aby potwierdzić usunięcie:", "deleting": "Usuwanie...", "deleteAccountBtn": "Usuń moje konto", "subscription": { @@ -362,7 +363,7 @@ "edit": "Edytuj", "copy": "Kopiuj", "cut": "Wytnij", - "selectInEditor": "Zaznacz w edytorze", + "selectInEditor": "Zaznacz", "remove": "Usuń", "paste": "Wklej", "highlight": "Wyróżnij", @@ -408,6 +409,14 @@ "synopsis": "Opis sceny", "synopsisPlaceholder": "Wpisz krótki opis tej sceny...", "save": "Zapisz" + }, + "uploadToCloud": { + "title": "Wyślij do chmury", + "body": "Wysłać ten projekt do chmury? Twój projekt zostanie zarchiwizowany i będzie dostępny z dowolnego urządzenia, a także będziesz mógł zapraszać współpracowników.", + "confirm": "Wyślij", + "cancel": "Anuluj", + "uploading": "Wysyłanie do chmury...", + "failed": "Wysyłanie nie powiodło się. Spróbuj ponownie." } }, "board": { diff --git a/messages/zh.json b/messages/zh.json index 4ce24cb4..77a22434 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -7,6 +7,7 @@ "noConnection": "无连接", "reconnecting": "正在重新连接...", "localProject": "本地项目", + "uploadToCloud": "上传到云端", "endlessScroll": "无限滚动", "toggleComments": "切换显示评论", "focusMode": "聚焦模式", @@ -121,7 +122,7 @@ "deleteModalTitle": "删除账户", "deleteModalDesc": "该操作不可撤销。", "deleteConfirmPhrase": "我确认删除我的账户", - "deleteConfirmLabel": "输入 以确认。", + "deleteConfirmLabel": "请输入以下文字以确认删除:", "deleting": "正在注销...", "deleteAccountBtn": "确认注销账户", "subscription": { @@ -362,7 +363,7 @@ "edit": "编辑", "copy": "复制", "cut": "剪切", - "selectInEditor": "在编辑器中选择", + "selectInEditor": "选择", "remove": "移除", "paste": "粘贴", "highlight": "高亮", @@ -408,6 +409,14 @@ "synopsis": "简介", "synopsisPlaceholder": "场景简述...", "save": "保存" + }, + "uploadToCloud": { + "title": "上传到云端", + "body": "将此项目上传到云端?您的项目将被备份,可从任何设备访问,并且可以邀请协作者。", + "confirm": "上传", + "cancel": "取消", + "uploading": "正在上传...", + "failed": "上传失败。请重试。" } }, "board": { diff --git a/src/app/api/projects/[projectId]/upload-to-cloud/route.ts b/src/app/api/projects/[projectId]/upload-to-cloud/route.ts new file mode 100644 index 00000000..0e398c0e --- /dev/null +++ b/src/app/api/projects/[projectId]/upload-to-cloud/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from "next/server"; +import * as ProjectService from "@src/server/service/project-service"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { InternalServerError, SuccessCreated, validate } from "@src/lib/utils/api-utils"; +import { requirePro } from "@src/lib/utils/pro-utils"; + +import z from "zod"; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const QuerySchema = z.object({ + projectId: z.string().regex(UUID_REGEX, "Invalid project id"), +}); + +const BodySchema = z.object({ + title: z.string().min(1).max(256), + description: z.string().max(2048).optional(), + author: z.string().max(256).optional(), +}); + +/** + * POST `/projects/[projectId]/upload-to-cloud` + * + * Promotes a local-only project to a cloud project, reusing the supplied id. + * Returns the freshly created membership so the client can hydrate ProjectContext. + */ +async function uploadProjectToCloud(req: NextRequest, { routeParams, user }: AuthApiContext) { + await requirePro(user.id); + + const { projectId } = validate(QuerySchema, routeParams); + const body = await req.json(); + const { title, description, author } = validate(BodySchema, body); + + await ProjectService.create({ + id: projectId, + title, + description, + author, + userId: user.id, + hasPoster: false, + }); + + const membership = await ProjectService.getMembership(projectId, user.id); + if (!membership) { + throw new InternalServerError(); + } + + return SuccessCreated(membership); +} + +export const POST = apiHandler(uploadProjectToCloud); diff --git a/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts b/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts index 5ac71cec..04d37191 100644 --- a/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts +++ b/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts @@ -187,6 +187,12 @@ export class IndexedDBStorageProvider implements StorageProvider { }); } + async markAsSynced(id: string): Promise { + const existing = await idbGet(id); + if (!existing) return; + await idbPut({ ...existing, is_synced: 1, updatedAt: Date.now() }); + } + async touch(id: string): Promise { const existing = await idbGet(id); if (existing) { diff --git a/src/lib/persistence/storage-provider/local-persistence.ts b/src/lib/persistence/storage-provider/local-persistence.ts index 1d3c605b..53bd90c2 100644 --- a/src/lib/persistence/storage-provider/local-persistence.ts +++ b/src/lib/persistence/storage-provider/local-persistence.ts @@ -49,6 +49,10 @@ export async function updateCachedProject( return (await getStorageProvider()).update(id, updates); } +export async function markCachedProjectAsSynced(id: string): Promise { + return (await getStorageProvider()).markAsSynced(id); +} + export async function touchCachedProject(id: string): Promise { return (await getStorageProvider()).touch(id); } @@ -126,6 +130,30 @@ export async function migrateToCachedProject( return newProject; } +/** + * Promote a local-only cached project to a cloud project, reusing the same id. + * Creates a cloud project record + membership, then flips the local cache flag. + * The Y.js doc at `scriptio-{projectId}` is unchanged — `useCloudSync` will + * push it to the empty server doc on next mount via the standard CRDT handshake. + */ +export async function promoteLocalProjectToCloud(projectId: string): Promise { + const local = await getCachedProject(projectId); + if (!local) throw new Error("Project not found in local cache"); + if (!local.isLocalOnly) return; + + const { uploadProjectToCloud } = await import("@src/lib/utils/requests"); + const res = await uploadProjectToCloud(projectId, { + title: local.title, + description: local.description ?? undefined, + author: local.author ?? undefined, + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { message?: string }; + throw new Error(json.message ?? `Upload failed (${res.status})`); + } + await markCachedProjectAsSynced(projectId); +} + /** * Discard a cloud project's local data (cached entry + Yjs IndexedDB database). */ diff --git a/src/lib/persistence/storage-provider/storage-provider.ts b/src/lib/persistence/storage-provider/storage-provider.ts index 69a4ca8c..4e5d4649 100644 --- a/src/lib/persistence/storage-provider/storage-provider.ts +++ b/src/lib/persistence/storage-provider/storage-provider.ts @@ -31,6 +31,7 @@ export interface StorageProvider { getAll(): Promise; get(id: string): Promise; update(id: string, updates: { title?: string; description?: string; author?: string }): Promise; + markAsSynced(id: string): Promise; touch(id: string): Promise; delete(id: string): Promise; exists(id: string): Promise; diff --git a/src/lib/screenplay/popup.ts b/src/lib/screenplay/popup.ts index 17619764..fcc6c681 100644 --- a/src/lib/screenplay/popup.ts +++ b/src/lib/screenplay/popup.ts @@ -17,16 +17,25 @@ export type PopupSceneData = { scene: Scene; }; +export type PopupUploadToCloudData = { + projectId: string; +}; + // ------------------------------ // // GENERIC POPUP // // ------------------------------ // -export type PopupUnionData = PopupImportFileData | PopupCharacterData | PopupSceneData; +export type PopupUnionData = + | PopupImportFileData + | PopupCharacterData + | PopupSceneData + | PopupUploadToCloudData; export enum PopupType { NewCharacter, EditCharacter, ImportFile, EditScene, + UploadToCloud, } export type PopupData = { @@ -69,3 +78,10 @@ export const editScenePopup = (scene: Scene, userCtx: UserContextType) => { data: { scene }, }); }; + +export const uploadToCloudPopup = (projectId: string, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UploadToCloud, + data: { projectId }, + }); +}; diff --git a/src/lib/utils/api-utils.ts b/src/lib/utils/api-utils.ts index bc75e175..efa88cdb 100644 --- a/src/lib/utils/api-utils.ts +++ b/src/lib/utils/api-utils.ts @@ -53,6 +53,11 @@ export class MissingBodyError extends AppError { super(400, message); } } +export class ConflictError extends AppError { + constructor(message = "Resource already exists") { + super(409, message); + } +} export class InternalServerError extends AppError { constructor(message = "Internal server error") { super(500, message); diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index 550b1f28..e066a590 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -41,6 +41,13 @@ export const editProject = (projectId: string, body: UpdateProjectBody) => { return request(`/api/projects/${projectId}`, "PATCH", body); }; +export const uploadProjectToCloud = ( + projectId: string, + body: { title: string; description?: string; author?: string }, +) => { + return request(`/api/projects/${projectId}/upload-to-cloud`, "POST", body); +}; + /* Saves / Version History */ export interface SaveEntry { diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts index 31344f92..f313c9cb 100644 --- a/src/lib/utils/types.ts +++ b/src/lib/utils/types.ts @@ -16,6 +16,7 @@ export type User = CookieUser & { }; export type ProjectCreation = { + id?: string; userId: string; title: string; description?: string; diff --git a/src/server/repository/project-repository.ts b/src/server/repository/project-repository.ts index da49c73d..74802ff0 100644 --- a/src/server/repository/project-repository.ts +++ b/src/server/repository/project-repository.ts @@ -3,6 +3,7 @@ import { Prisma, ProjectRole } from "../../generated/client/client"; import prisma from "../db"; import * as S3 from "@src/lib/s3"; +import { ConflictError } from "@src/lib/utils/api-utils"; const projectMembershipSelect = { project: { @@ -126,21 +127,29 @@ export class ProjectRepository { }); } - createProject(project: ProjectCreation) { - return prisma.project.create({ - data: { - title: project.title, - description: project.description, - author: project.author, - hasPoster: project.hasPoster, - members: { - create: { - userId: project.userId, - role: ProjectRole.OWNER, + async createProject(project: ProjectCreation) { + try { + return await prisma.project.create({ + data: { + ...(project.id && { id: project.id }), + title: project.title, + description: project.description, + author: project.author, + hasPoster: project.hasPoster, + members: { + create: { + userId: project.userId, + role: ProjectRole.OWNER, + }, }, }, - }, - }); + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { + throw new ConflictError("A project with this id already exists"); + } + throw e; + } } updateProject(project: ProjectUpdate) { diff --git a/styles/themes.css b/styles/themes.css index d9aecee2..ff2484af 100644 --- a/styles/themes.css +++ b/styles/themes.css @@ -14,7 +14,7 @@ html.light { --tertiary: #e2e2e2; --primary-hover: #f0f0f3; - --secondary-hover: #e7e7e7; + --secondary-hover: #ececec; --tertiary-hover: rgb(211, 211, 211); --primary-text: #1a1a1c; @@ -34,11 +34,11 @@ html.light { --themed-editor-script-bg: var(--secondary); --themed-editor-text: var(--primary-text); --editor-scene: #f1f5f9; - --editor-sidebar: #ffffff; + --editor-sidebar: var(--secondary); --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); - --context-menu-bg: #ffffff; - --context-menu-item-hover: #f0f4ff; + --context-menu-bg: var(--secondary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: #ffffff; --editor-style-bg-hover: #f1f5f9; --editor-style-bg-active: #f1f5f9; @@ -57,11 +57,11 @@ html.dark { --main-bg: var(--primary); --primary: #1d1d1d; - --secondary: #2e2e2e; - --tertiary: #3a3a3a; + --secondary: #272727; + --tertiary: #585858; --primary-hover: #2e2e2e; - --secondary-hover: #3a3a3a; + --secondary-hover: #363636; --tertiary-hover: #464646; --primary-text: #ffffff; @@ -81,11 +81,11 @@ html.dark { --themed-editor-script-bg: var(--secondary); --themed-editor-text: var(--primary-text); --editor-scene: #dadada; - --editor-sidebar: #272727; + --editor-sidebar: var(--secondary); --editor-sidebar-list: #3c3c3c; --editor-sidebar-hover: var(--primary); --context-menu-bg: var(--secondary); - --context-menu-item-hover: var(--primary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: #1d1d1d; --editor-style-bg-hover: #3c3c3c; --editor-style-bg-active: #5d5d5d; @@ -136,7 +136,7 @@ html.latte { --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); --context-menu-bg: var(--secondary); - --context-menu-item-hover: var(--primary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -183,7 +183,7 @@ html.wonka { --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); --context-menu-bg: var(--secondary); - --context-menu-item-hover: var(--primary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -201,12 +201,12 @@ html.mint { --panel-shadow: 0 0 8px rgba(0, 0, 0, 0.06); --main-bg: var(--primary); - --primary: #dcf5de; + --primary: #d3eed5; --secondary: #ecfdec; --tertiary: #bce0bc; - --primary-hover: #c6e7c8; - --secondary-hover: #e2ffe4; + --primary-hover: #c6e4c8; + --secondary-hover: #d5ecd5; --tertiary-hover: #c7e4c7; --primary-text: #354937; @@ -229,8 +229,8 @@ html.mint { --editor-sidebar: var(--secondary); --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); - --context-menu-bg: var(--primary); - --context-menu-item-hover: var(--primary-hover); + --context-menu-bg: var(--secondary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -250,11 +250,11 @@ html.blossom { --main-bg: var(--primary); --primary: #ffe7f3; --secondary: #fff6fb; - --tertiary: #ffbbdd; + --tertiary: #ffc8e6; --primary-hover: #ffe7f3; - --secondary-hover: #fff1f9; - --tertiary-hover: #ffa0d0; + --secondary-hover: #fae5f0; + --tertiary-hover: #fdbde1; --primary-text: #bb4bbb; --secondary-text: #a579a5; @@ -277,7 +277,7 @@ html.blossom { --editor-sidebar-list: #ededf3; --editor-sidebar-hover: var(--primary); --context-menu-bg: var(--secondary); - --context-menu-item-hover: var(--primary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: var(--primary); --editor-style-bg-hover: var(--primary-hover); --editor-style-bg-active: var(--primary-hover); @@ -297,16 +297,16 @@ html.midnight { --main-bg: var(--primary); --primary: #0d1117; --secondary: #161c2d; - --tertiary: #1e2a45; + --tertiary: #283758; --primary-hover: #131a2a; - --secondary-hover: #1a2438; - --tertiary-hover: #263554; + --secondary-hover: #253453; + --tertiary-hover: #304166; --primary-text: #c8d8f0; --secondary-text: #6878a8; - --separator: #1e2a45; + --separator: #2b3a5e; --input-outline: #2a3d60; --navbar-outline: #1e2a45; --header-background: #161c2d; @@ -320,11 +320,11 @@ html.midnight { --themed-editor-script-bg: var(--secondary); --themed-editor-text: var(--primary-text); --editor-scene: #c8d8f0; - --editor-sidebar: #0f1520; + --editor-sidebar: var(--secondary); --editor-sidebar-list: #1e2a45; --editor-sidebar-hover: var(--primary); --context-menu-bg: var(--secondary); - --context-menu-item-hover: var(--primary); + --context-menu-item-hover: var(--secondary-hover); --editor-style-bg: #0d1117; --editor-style-bg-hover: #1e2a45; --editor-style-bg-active: #263554; From a7bfc5a46aa373add8a68666330750541c4cae4f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 May 2026 13:24:30 +0200 Subject: [PATCH 37/76] fixed clipboard handling and web search for desktop apps --- src-tauri/Cargo.lock | 367 +++++++++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 1 + 4 files changed, 365 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 00590328..232acd45 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-iap", @@ -92,6 +93,27 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -430,6 +452,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -567,6 +595,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.7" @@ -675,6 +712,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -863,6 +906,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -983,6 +1032,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1010,6 +1065,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1044,6 +1105,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.8" @@ -1060,6 +1127,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1315,6 +1388,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1497,6 +1580,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1506,6 +1600,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1663,7 +1766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1774,6 +1877,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2112,6 +2229,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2127,7 +2254,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -2175,6 +2302,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2467,6 +2603,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2533,6 +2679,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + [[package]] name = "phf" version = "0.8.0" @@ -2704,7 +2861,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2722,6 +2879,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2854,6 +3024,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -2863,6 +3045,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.43" @@ -3856,7 +4047,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -3903,6 +4094,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -4186,6 +4392,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.45" @@ -4471,12 +4691,23 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4764,6 +4995,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -4854,6 +5155,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5313,6 +5620,24 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.2" @@ -5394,6 +5719,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yoke" version = "0.8.1" @@ -5558,6 +5900,21 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d00433d1..a0f34396 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,5 +26,6 @@ tauri-plugin-log = "2.8.0" tauri-plugin-store = "2.4.2" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" +tauri-plugin-clipboard-manager = "2" tauri-plugin-opener = "2.5.3" tauri-plugin-iap = "0.8" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d5e7ffa9..e6dd1e40 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "store:allow-load", "dialog:allow-save", "fs:allow-write-file", + "clipboard-manager:allow-read-text", "opener:default", "iap:default" ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d021d2a3..693c6fa2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ fn some_noop_command() { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) From b50c0832ba381016d8718c4a4646a6f1b4c080dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 May 2026 13:55:50 +0200 Subject: [PATCH 38/76] updating tauri window theme on theme change, added missing fixes --- .../preferences/AppearanceSettings.tsx | 17 ++- components/editor/sidebar/ContextMenu.tsx | 35 +++-- package-lock.json | 134 ++++++++---------- package.json | 1 + 4 files changed, 102 insertions(+), 85 deletions(-) diff --git a/components/dashboard/preferences/AppearanceSettings.tsx b/components/dashboard/preferences/AppearanceSettings.tsx index 30a3ba48..dbcdf406 100644 --- a/components/dashboard/preferences/AppearanceSettings.tsx +++ b/components/dashboard/preferences/AppearanceSettings.tsx @@ -8,8 +8,19 @@ import { UserTheme } from "@src/lib/utils/types"; import { useTheme } from "next-themes"; import { useSettings } from "@src/lib/utils/hooks"; import { useTranslations } from "next-intl"; +import { isTauri } from "@tauri-apps/api/core"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; +const THEME_WINDOW_MODE: Record = { + dark: "dark", + wonka: "dark", + midnight: "dark", + light: "light", + latte: "light", + mint: "light", + blossom: "light", +}; + const THEME_COLORS: Record< string, { primary: string; secondary: string; tertiary: string; text: string; subtext: string } @@ -122,9 +133,13 @@ const AppearanceSettings = () => { { + onChange={async (value) => { setTheme(value); saveSettings({ theme: value as UserTheme }); + if (isTauri()) { + const { setTheme: setWindowTheme } = await import("@tauri-apps/api/app"); + await setWindowTheme(THEME_WINDOW_MODE[value] ?? "dark"); + } }} options={themeOptions} className={`${sharedStyles.input} ${styles.input}`} diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index d26110f7..86b8a744 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -9,6 +9,7 @@ import { Scene } from "@src/lib/screenplay/scenes"; import context from "./ContextMenu.module.css"; import { CharacterData, deleteCharacter } from "@src/lib/screenplay/characters"; import { LocationData, deleteLocation } from "@src/lib/screenplay/locations"; +import { isTauri } from "@tauri-apps/api/core"; import { cutText, focusOnPosition, pasteText, selectTextInEditor } from "@src/lib/screenplay/editor"; import { addCharacterPopup, editCharacterPopup, editScenePopup } from "@src/lib/screenplay/popup"; import { ProjectContext } from "@src/context/ProjectContext"; @@ -66,6 +67,26 @@ type ContextMenuItemProps = { type SubMenuProps = { props: T }; +const openWebSearch = async (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + const url = `https://www.google.com/search?q=${encodeURIComponent(trimmed)}`; + if (isTauri()) { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl(url); + } else { + window.open(url, "_blank"); + } +}; + +const readClipboardText = async (): Promise => { + if (isTauri()) { + const { readText } = await import("@tauri-apps/plugin-clipboard-manager"); + return readText(); + } + return navigator.clipboard.readText(); +}; + export const ContextMenuItem = ({ text, action, icon: Icon, disabled }: ContextMenuItemProps) => { return (
@@ -213,16 +234,13 @@ const EditorSelectionMenu = ({ props }: SubMenuProps { if (!editor) return; - const text = await navigator.clipboard.readText(); - editor.commands.insertContent(text); + editor.commands.insertContent(await readClipboardText()); updateContextMenu(undefined); }; const handleSearchOnWeb = () => { if (!editor) return; - const selectedText = editor.state.doc.textBetween(from, to, " "); - if (!selectedText.trim()) return; - window.open(`https://www.google.com/search?q=${encodeURIComponent(selectedText)}`, "_blank"); + openWebSearch(editor.state.doc.textBetween(from, to, " ")); }; return ( @@ -456,8 +474,7 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { const handlePaste = async () => { if (!editor) return; - const text = await navigator.clipboard.readText(); - editor.commands.insertContent(text); + editor.commands.insertContent(await readClipboardText()); updateContextMenu(undefined); }; @@ -482,9 +499,7 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { const handleSearchOnWeb = () => { if (!editor) return; - const selectedText = editor.state.doc.textBetween(from, to, " "); - if (!selectedText.trim()) return; - window.open(`https://www.google.com/search?q=${encodeURIComponent(selectedText)}`, "_blank"); + openWebSearch(editor.state.doc.textBetween(from, to, " ")); }; const handleShelve = () => { diff --git a/package-lock.json b/package-lock.json index f4f44abd..e11bfec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@prisma/client": "^7.7.0", "@svgr/core": "^8.1.0", "@tauri-apps/api": "2.10.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-fs": "2.4.5", "@tauri-apps/plugin-opener": "2.5.3", @@ -1174,7 +1175,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3050,8 +3050,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz", "integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==", "dev": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -3082,8 +3081,7 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", @@ -3124,7 +3122,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3783,6 +3780,17 @@ "@floating-ui/utils": "^0.2.11" } }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", @@ -4006,7 +4014,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4029,7 +4036,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4052,7 +4058,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4069,7 +4074,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4086,7 +4090,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4103,7 +4106,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4120,7 +4122,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4137,7 +4138,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4154,7 +4154,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4171,7 +4170,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4188,7 +4186,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4205,7 +4202,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -4222,7 +4218,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4245,7 +4240,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4268,7 +4262,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4291,7 +4284,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4314,7 +4306,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4337,7 +4328,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4360,7 +4350,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4383,7 +4372,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4406,7 +4394,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -4426,7 +4413,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -4446,7 +4432,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -4466,7 +4451,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5371,7 +5355,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5764,7 +5747,6 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/client-runtime-utils": "7.7.0" }, @@ -6133,7 +6115,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.0", @@ -7416,7 +7399,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -7930,6 +7912,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-clipboard-manager": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz", + "integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", @@ -8387,13 +8378,15 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -8403,14 +8396,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node": { "version": "20.11.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -8502,7 +8495,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8513,7 +8505,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -8566,7 +8557,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -9239,7 +9229,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9764,7 +9753,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10008,7 +9996,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -10269,7 +10256,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -11168,7 +11156,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11367,7 +11354,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12591,7 +12577,6 @@ "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -13446,7 +13431,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13765,6 +13749,7 @@ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", + "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -13997,6 +13982,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -14029,7 +14015,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mediaquery-text": { "version": "1.2.0", @@ -14315,7 +14302,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", @@ -14536,7 +14522,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -14791,7 +14776,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/own-keys": { "version": "1.0.1", @@ -15153,7 +15139,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -15417,7 +15402,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -15571,7 +15555,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -15638,7 +15621,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -15715,6 +15697,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-transform": "^1.0.0" } @@ -15724,6 +15707,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0" } @@ -15733,6 +15717,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -15744,6 +15729,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", @@ -15755,6 +15741,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", @@ -15767,6 +15754,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -15779,6 +15767,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" @@ -15789,6 +15778,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" @@ -15799,6 +15789,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", "license": "MIT", + "peer": true, "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", @@ -15810,6 +15801,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", "license": "MIT", + "peer": true, "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", @@ -15832,6 +15824,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.25.0" } @@ -15841,6 +15834,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -15864,6 +15858,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.4.tgz", "integrity": "sha512-CGr2BK5sLdZx+ARbeLO4HBZYa3qSG3FmwOVmzYs0Zp7n5SkrGqj+1CeNuubFNZEr64yMAQ20SanbFyIyHWZc8w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", @@ -15877,6 +15872,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", "license": "MIT", + "peer": true, "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" @@ -15892,6 +15888,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -15937,6 +15934,7 @@ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -16014,7 +16012,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16034,7 +16031,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16342,7 +16338,8 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -16445,7 +16442,6 @@ "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17303,7 +17299,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17449,7 +17444,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -17561,7 +17555,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17663,7 +17656,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17732,7 +17724,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/uint8array-extras": { "version": "1.5.0", @@ -17787,7 +17780,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -18051,7 +18043,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -18671,7 +18662,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18714,7 +18704,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18833,7 +18822,8 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/wait-on": { "version": "9.0.3", @@ -19016,7 +19006,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -19194,7 +19183,6 @@ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.85" }, @@ -19258,7 +19246,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, @@ -19325,7 +19312,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 21949db5..2bca12af 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@prisma/client": "^7.7.0", "@svgr/core": "^8.1.0", "@tauri-apps/api": "2.10.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-fs": "2.4.5", "@tauri-apps/plugin-opener": "2.5.3", From f10eb4a79ed4f05437b9ea5e14ec88253b3a2a66 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 May 2026 19:03:03 +0200 Subject: [PATCH 39/76] fixing missing capability for theme update on tauri, removed ci requirements for staging --- .github/workflows/deploy-staging.yaml | 4 ++-- src-tauri/capabilities/default.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 030c6c9a..5143daae 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -27,7 +27,7 @@ jobs: publish-macos: runs-on: macos-latest - needs: [prepare, deploy-web] + needs: [prepare] env: APP_PATH: src-tauri/target/universal-apple-darwin/debug/bundle/macos/Scriptio (Staging).app PKG_PATH: Scriptio.pkg @@ -94,7 +94,7 @@ jobs: publish-windows: runs-on: windows-latest - needs: [prepare, deploy-web] + needs: [prepare] env: APP_PATH: src-tauri/target/msix/Scriptio_${{ needs.prepare.outputs.version }}.msixbundle steps: diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e6dd1e40..afc587fe 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", + "core:app:allow-set-app-theme", "store:allow-get", "store:allow-set", "store:allow-delete", From 332ff1bc12d1fc1d8550e4befb01bcf8ecba168d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 May 2026 03:41:08 +0200 Subject: [PATCH 40/76] disabled apple iap, wired migration logic to avoid sync issues, fixed websocket urls --- .env.template | 2 +- .../account/SubscriptionSettings.module.css | 7 + .../account/SubscriptionSettings.tsx | 87 ++++- .../project/ProjectWorkspace.module.css | 2 +- .../project/SplitPanelContainer.module.css | 2 +- .../projects/ProjectMigrationErrorDialog.tsx | 34 +- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/ja.json | 9 +- messages/ko.json | 9 +- messages/pl.json | 9 +- messages/zh.json | 9 +- .../api/apple/transfer-subscription/route.ts | 92 ++++++ src/app/projects/layout.tsx | 9 +- src/context/ProjectContext.tsx | 7 +- src/global.d.ts | 2 +- src/lib/adapters/fdx/finaldraft-adapter.ts | 4 +- src/lib/adapters/fountain/fountain-adapter.ts | 6 +- src/lib/adapters/scriptio/scriptio-adapter.ts | 18 +- src/lib/cloud/room.ts | 120 ++++++- src/lib/cloud/utils.ts | 62 +++- .../migrations/project-migration-runner.ts | 98 ++++-- .../project/migrations/project-migrations.ts | 17 +- src/lib/project/project-doc.ts | 243 ++++++++++++++ src/lib/project/project-repository.ts | 3 +- src/lib/project/project-state.ts | 304 ++++-------------- src/lib/utils/api-utils.ts | 5 +- src/lib/utils/requests.ts | 5 + .../repository/transaction-repository.ts | 7 + src/server/service/transaction-service.ts | 5 + .../project-migration-runner.test.ts | 158 ++++++++- 33 files changed, 1046 insertions(+), 325 deletions(-) create mode 100644 src/app/api/apple/transfer-subscription/route.ts create mode 100644 src/lib/project/project-doc.ts diff --git a/.env.template b/.env.template index cc30051c..ec02df0e 100644 --- a/.env.template +++ b/.env.template @@ -1,7 +1,7 @@ NODE_ENV=development NEXT_PUBLIC_API_URL=https://scriptio.app -NEXT_PUBLIC_COLLAB_WEBSOCKET_URL=ws://127.0.0.1:8787 +NEXT_PUBLIC_CLOUD_URL=http://127.0.0.1:8787 # Secrets AUTH_SECRET= diff --git a/components/dashboard/account/SubscriptionSettings.module.css b/components/dashboard/account/SubscriptionSettings.module.css index f829a9f9..37131140 100644 --- a/components/dashboard/account/SubscriptionSettings.module.css +++ b/components/dashboard/account/SubscriptionSettings.module.css @@ -166,6 +166,13 @@ margin: 0; } +.infoText { + font-size: 0.85rem; + color: var(--secondary-text); + line-height: 1.5; + margin: 0; +} + .errorMessage { font-size: 0.82rem; color: var(--error); diff --git a/components/dashboard/account/SubscriptionSettings.tsx b/components/dashboard/account/SubscriptionSettings.tsx index 40111fea..d372e7cc 100644 --- a/components/dashboard/account/SubscriptionSettings.tsx +++ b/components/dashboard/account/SubscriptionSettings.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { ArrowRight, Check, Lock, Sparkles } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; -import { cancelStripeSubscription, createStripeCheckout, getAppleSubscriptionOwner, submitApplePurchase } from "@src/lib/utils/requests"; +import { cancelStripeSubscription, createStripeCheckout, getAppleSubscriptionOwner, submitApplePurchase, transferAppleSubscription } from "@src/lib/utils/requests"; import { useUser } from "@src/lib/utils/hooks"; import { useLocale } from "@src/context/LocaleContext"; @@ -14,6 +14,11 @@ const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; const APPLE_SUBSCRIPTIONS_URL = "https://apps.apple.com/account/subscriptions"; const PERKS = ["perkProjects", "perkSaves", "perkCollaborators", "perkAutoSave"] as const; +// Apple IAP is disabled at launch — Pro is sold exclusively via the website. +// All IAP code paths (mount sync, purchase, restore/transfer) are kept but +// gated by this flag so re-enabling later is a one-line change. +const APPLE_IAP_ENABLED = false; + // Apple IAP is only available on the macOS Tauri build. The Windows Tauri // build is distributed via the Microsoft Store but uses Stripe for billing. const isMacosTauri = () => @@ -32,6 +37,18 @@ const SubscriptionSettings = () => { () => typeof window !== "undefined" && sessionStorage.getItem("proWelcome") === "1" ); const [welcomeLeaving, setWelcomeLeaving] = useState(false); + // When the App Store account has an active subscription bound to a + // different Scriptio account, we capture the JWS + masked owner email so + // we can offer a "Restore Purchases" flow that transfers it. + const [transferableJws, setTransferableJws] = useState(null); + const [transferableEmail, setTransferableEmail] = useState(null); + const [transferConfirm, setTransferConfirm] = useState(false); + const [transferring, setTransferring] = useState(false); + // Detect macOS Tauri after mount so SSR renders the same tree the client + // initially does, avoiding hydration mismatches. + const [isAppleStoreBuild, setIsAppleStoreBuild] = useState(false); + useEffect(() => { setIsAppleStoreBuild(isMacosTauri()); }, []); + const showAppleStoreNotice = isAppleStoreBuild && !APPLE_IAP_ENABLED; const isPro = !!user?.isProUntil && new Date(user.isProUntil) > new Date(); const isCancelled = !!user?.isSubscriptionCancelled; @@ -42,7 +59,7 @@ const SubscriptionSettings = () => { // Restore Apple purchases on mount to sync subscription state with the server. useEffect(() => { - if (!isMacosTauri() || !user?.id) return; + if (!APPLE_IAP_ENABLED || !isMacosTauri() || !user?.id) return; let cancelled = false; @@ -55,10 +72,20 @@ const SubscriptionSettings = () => { && p.purchaseState === PurchaseState.PURCHASED && p.jwsRepresentation, ); + if (cancelled || !active?.jwsRepresentation) return; + + const ok = await submitApplePurchase(active.jwsRepresentation); if (cancelled) return; - if (active?.jwsRepresentation) { - await submitApplePurchase(active.jwsRepresentation); + if (ok) { await mutate(); + } else { + // The App Store has an active sub but it's bound to a + // different Scriptio account — surface a Restore Purchases + // flow rather than the regular Upgrade button. + const ownerEmail = await getAppleSubscriptionOwner(active.jwsRepresentation); + if (cancelled) return; + setTransferableJws(active.jwsRepresentation); + setTransferableEmail(ownerEmail); } } catch { // Restore can fail if the user is not signed into the App Store — silently ignore. @@ -141,6 +168,25 @@ const SubscriptionSettings = () => { } }; + const handleTransfer = async () => { + if (!transferableJws) return; + setError(null); + setTransferring(true); + try { + const ok = await transferAppleSubscription(transferableJws); + if (ok) { + setTransferableJws(null); + setTransferableEmail(null); + setTransferConfirm(false); + await mutate(); + } else { + setError(t("subscription.purchaseError")); + } + } finally { + setTransferring(false); + } + }; + const handleCancel = async () => { setCancelling(true); @@ -199,7 +245,14 @@ const SubscriptionSettings = () => {
{/* Actions */} - {isPro ? ( + {showAppleStoreNotice ? ( +

+ {isPro + ? t("subscription.appleStoreProInfo") + : t("subscription.appleStoreFreeInfo") + } +

+ ) : isPro ? ( cancelConfirm ? (

@@ -235,6 +288,30 @@ const SubscriptionSettings = () => { {t("subscription.cancel")} ) + ) : transferableJws ? ( + transferConfirm ? ( +

+

+ {transferableEmail + ? t("subscription.transferConfirm", { email: transferableEmail }) + : t("subscription.transferConfirmUnknown") + } +

+
+ + +
+
+ ) : ( + + ) ) : ( + +
+
+
+ ); + } + return (
diff --git a/messages/de.json b/messages/de.json index f31e0e08..d1e89246 100644 --- a/messages/de.json +++ b/messages/de.json @@ -154,7 +154,14 @@ "alreadyBoundTo": "Dieses Abonnement ist bereits mit {email} verknüpft.", "alreadyBoundUnknown": "Dieses Abonnement ist bereits mit einem anderen Konto verknüpft.", "welcomePro": "Du hast jetzt Scriptio Pro abonniert. Willkommen!", - "resubscribe": "Abonnement reaktivieren" + "resubscribe": "Abonnement reaktivieren", + "restorePurchases": "Käufe wiederherstellen", + "transferConfirm": "Dein App-Store-Konto hat ein aktives Abonnement, das mit {email} verknüpft ist. Beim Wiederherstellen wird Pro auf dieses Konto übertragen und {email} verliert den Pro-Zugang. Apple stellt weiterhin derselben Apple ID die Rechnung. Fortfahren?", + "transferConfirmUnknown": "Dein App-Store-Konto hat ein aktives Abonnement, das mit einem anderen Scriptio-Konto verknüpft ist. Beim Wiederherstellen wird Pro auf dieses Konto übertragen und das andere Konto verliert den Pro-Zugang. Apple stellt weiterhin derselben Apple ID die Rechnung. Fortfahren?", + "transferYes": "Ja, übertragen", + "transferring": "Wird übertragen...", + "appleStoreFreeInfo": "Pro-Funktionen sind für Konten verfügbar, die über unsere Website upgegradet wurden.", + "appleStoreProInfo": "Um deine Zahlungsmethode zu aktualisieren oder zu kündigen, besuche bitte die Profileinstellungen auf scriptio.app." } }, "projects": { diff --git a/messages/en.json b/messages/en.json index 8c52eba1..ef72b2b6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -153,7 +153,14 @@ "alreadyBoundTo": "This subscription is already linked to {email}.", "alreadyBoundUnknown": "This subscription is already linked to another account.", "welcomePro": "You're now subscribed to Scriptio Pro. Welcome!", - "resubscribe": "Reactivate subscription" + "resubscribe": "Reactivate subscription", + "restorePurchases": "Restore Purchases", + "transferConfirm": "Your App Store account has an active subscription linked to {email}. Restoring will transfer Pro to this account, and {email} will lose Pro access. Apple will continue billing the same Apple ID. Continue?", + "transferConfirmUnknown": "Your App Store account has an active subscription linked to another Scriptio account. Restoring will transfer Pro to this account, and the other account will lose Pro access. Apple will continue billing the same Apple ID. Continue?", + "transferYes": "Yes, transfer", + "transferring": "Transferring...", + "appleStoreFreeInfo": "Pro features are available for accounts upgraded via our website.", + "appleStoreProInfo": "To update your billing method or cancel, please visit your profile settings on scriptio.app." } }, "projects": { diff --git a/messages/es.json b/messages/es.json index fc373f7a..c1bf0dbe 100644 --- a/messages/es.json +++ b/messages/es.json @@ -153,7 +153,14 @@ "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta.", "welcomePro": "Ya estás suscrito a Scriptio Pro. ¡Bienvenido!", - "resubscribe": "Reactivar suscripción" + "resubscribe": "Reactivar suscripción", + "restorePurchases": "Restaurar compras", + "transferConfirm": "Tu cuenta de App Store tiene una suscripción activa vinculada a {email}. La restauración transferirá Pro a esta cuenta, y {email} perderá el acceso a Pro. Apple seguirá facturando al mismo ID de Apple. ¿Continuar?", + "transferConfirmUnknown": "Tu cuenta de App Store tiene una suscripción activa vinculada a otra cuenta de Scriptio. La restauración transferirá Pro a esta cuenta, y la otra cuenta perderá el acceso a Pro. Apple seguirá facturando al mismo ID de Apple. ¿Continuar?", + "transferYes": "Sí, transferir", + "transferring": "Transfiriendo...", + "appleStoreFreeInfo": "Las funciones Pro están disponibles para las cuentas actualizadas a través de nuestro sitio web.", + "appleStoreProInfo": "Para actualizar tu método de pago o cancelar, visita la configuración de tu perfil en scriptio.app." } }, "projects": { diff --git a/messages/fr.json b/messages/fr.json index 895c1d9f..c34f72f0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -154,7 +154,14 @@ "alreadyBoundTo": "Cet abonnement est déjà associé à {email}.", "alreadyBoundUnknown": "Cet abonnement est déjà associé à un autre compte.", "welcomePro": "Vous êtes maintenant abonné à Scriptio Pro. Bienvenue !", - "resubscribe": "Réactiver l'abonnement" + "resubscribe": "Réactiver l'abonnement", + "restorePurchases": "Restaurer les achats", + "transferConfirm": "Votre compte App Store dispose d'un abonnement actif associé à {email}. La restauration transférera Pro à ce compte, et {email} perdra l'accès Pro. Apple continuera à facturer le même identifiant Apple. Continuer ?", + "transferConfirmUnknown": "Votre compte App Store dispose d'un abonnement actif associé à un autre compte Scriptio. La restauration transférera Pro à ce compte, et l'autre compte perdra l'accès Pro. Apple continuera à facturer le même identifiant Apple. Continuer ?", + "transferYes": "Oui, transférer", + "transferring": "Transfert en cours...", + "appleStoreFreeInfo": "Les fonctionnalités Pro sont disponibles pour les comptes abonnés via notre site web.", + "appleStoreProInfo": "Pour modifier votre méthode de paiement ou annuler, veuillez consulter les paramètres de votre profil sur scriptio.app." } }, "projects": { diff --git a/messages/ja.json b/messages/ja.json index 4f99929c..53d015ec 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -153,7 +153,14 @@ "alreadyBoundTo": "このサブスクリプションは既に {email} に関連付けられています。", "alreadyBoundUnknown": "このサブスクリプションは既に別のアカウントに関連付けられています。", "welcomePro": "Scriptio Pro にご登録いただきました。ようこそ!", - "resubscribe": "サブスクリプションを再開する" + "resubscribe": "サブスクリプションを再開する", + "restorePurchases": "購入を復元", + "transferConfirm": "App Storeアカウントには {email} に関連付けられた有効なサブスクリプションがあります。復元するとProがこのアカウントに移行し、{email} はProアクセスを失います。Appleは引き続き同じApple IDに請求します。続けますか?", + "transferConfirmUnknown": "App Storeアカウントには別のScriptioアカウントに関連付けられた有効なサブスクリプションがあります。復元するとProがこのアカウントに移行し、もう一方のアカウントはProアクセスを失います。Appleは引き続き同じApple IDに請求します。続けますか?", + "transferYes": "はい、移行する", + "transferring": "移行中...", + "appleStoreFreeInfo": "Pro機能は当社のウェブサイトでアップグレードされたアカウントでご利用いただけます。", + "appleStoreProInfo": "お支払い方法の更新やキャンセルは、scriptio.app のプロフィール設定からお手続きください。" } }, "projects": { diff --git a/messages/ko.json b/messages/ko.json index 810d39a4..e9b982cd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -153,7 +153,14 @@ "alreadyBoundTo": "이 구독은 이미 {email}에 연결되어 있습니다.", "alreadyBoundUnknown": "이 구독은 이미 다른 계정에 연결되어 있습니다.", "welcomePro": "Scriptio Pro를 구독하셨습니다. 환영합니다!", - "resubscribe": "구독 재활성화" + "resubscribe": "구독 재활성화", + "restorePurchases": "구매 복원", + "transferConfirm": "App Store 계정에 {email}에 연결된 활성 구독이 있습니다. 복원하면 Pro가 이 계정으로 이전되며 {email}은(는) Pro 액세스를 잃게 됩니다. Apple은 계속해서 동일한 Apple ID에 청구합니다. 계속하시겠습니까?", + "transferConfirmUnknown": "App Store 계정에 다른 Scriptio 계정에 연결된 활성 구독이 있습니다. 복원하면 Pro가 이 계정으로 이전되며 다른 계정은 Pro 액세스를 잃게 됩니다. Apple은 계속해서 동일한 Apple ID에 청구합니다. 계속하시겠습니까?", + "transferYes": "예, 이전", + "transferring": "이전 중...", + "appleStoreFreeInfo": "Pro 기능은 웹사이트를 통해 업그레이드된 계정에서 이용할 수 있습니다.", + "appleStoreProInfo": "결제 수단을 업데이트하거나 취소하려면 scriptio.app의 프로필 설정을 방문하세요." } }, "projects": { diff --git a/messages/pl.json b/messages/pl.json index 83fe875d..40a57f94 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -153,7 +153,14 @@ "alreadyBoundTo": "Ta subskrypcja jest już powiązana z {email}.", "alreadyBoundUnknown": "Ta subskrypcja jest już powiązana z innym kontem.", "welcomePro": "Subskrybujesz teraz Scriptio Pro. Witamy!", - "resubscribe": "Reaktywuj subskrypcję" + "resubscribe": "Reaktywuj subskrypcję", + "restorePurchases": "Przywróć zakupy", + "transferConfirm": "Twoje konto App Store ma aktywną subskrypcję powiązaną z {email}. Przywrócenie przeniesie Pro na to konto, a {email} straci dostęp do Pro. Apple będzie nadal obciążał ten sam Apple ID. Kontynuować?", + "transferConfirmUnknown": "Twoje konto App Store ma aktywną subskrypcję powiązaną z innym kontem Scriptio. Przywrócenie przeniesie Pro na to konto, a drugie konto straci dostęp do Pro. Apple będzie nadal obciążał ten sam Apple ID. Kontynuować?", + "transferYes": "Tak, przenieś", + "transferring": "Przenoszenie...", + "appleStoreFreeInfo": "Funkcje Pro są dostępne dla kont uaktualnionych przez naszą stronę internetową.", + "appleStoreProInfo": "Aby zaktualizować metodę płatności lub anulować, odwiedź ustawienia profilu na scriptio.app." } }, "projects": { diff --git a/messages/zh.json b/messages/zh.json index 77a22434..d988de9b 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -153,7 +153,14 @@ "alreadyBoundTo": "此订阅已与 {email} 关联。", "alreadyBoundUnknown": "此订阅已与其他账户关联。", "welcomePro": "您已成功订阅 Scriptio Pro。欢迎!", - "resubscribe": "重新激活订阅" + "resubscribe": "重新激活订阅", + "restorePurchases": "恢复购买", + "transferConfirm": "您的 App Store 账户有一个与 {email} 关联的有效订阅。恢复将把 Pro 转移到此账户,{email} 将失去 Pro 访问权限。Apple 将继续向同一个 Apple ID 收费。继续吗?", + "transferConfirmUnknown": "您的 App Store 账户有一个与另一个 Scriptio 账户关联的有效订阅。恢复将把 Pro 转移到此账户,另一个账户将失去 Pro 访问权限。Apple 将继续向同一个 Apple ID 收费。继续吗?", + "transferYes": "是的,转移", + "transferring": "转移中...", + "appleStoreFreeInfo": "Pro 功能适用于通过我们网站升级的账户。", + "appleStoreProInfo": "如需更新付款方式或取消,请访问 scriptio.app 上的个人资料设置。" } }, "projects": { diff --git a/src/app/api/apple/transfer-subscription/route.ts b/src/app/api/apple/transfer-subscription/route.ts new file mode 100644 index 00000000..feb1e254 --- /dev/null +++ b/src/app/api/apple/transfer-subscription/route.ts @@ -0,0 +1,92 @@ +import { NextRequest } from "next/server"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { BodyFieldError, ForbiddenError, Success } from "@src/lib/utils/api-utils"; +import { verifyAppleJws, APPLE_BUNDLE_IDS, APPLE_PRODUCT_ID } from "@src/lib/apple-jws"; +import { logger } from "@src/lib/utils/logger"; +import * as UserService from "@src/server/service/user-service"; +import * as TransactionService from "@src/server/service/transaction-service"; + +interface AppleTransactionPayload { + transactionId: string; + originalTransactionId: string; + bundleId: string; + productId: string; + purchaseDate: number; + expiresDate: number; + type: string; + appAccountToken?: string; +} + +// Transfers an existing Apple subscription from a previously-linked Scriptio +// account to the calling user. Unlike /api/apple/purchase, this endpoint +// intentionally tolerates an appAccountToken / existing-owner mismatch — that +// mismatch is the precondition for needing a transfer in the first place. +async function handleTransferSubscription(req: NextRequest, { user }: AuthApiContext) { + const body = (await req.json().catch(() => ({}))) as Record; + const jwsTransaction = body.jwsTransaction; + if (typeof jwsTransaction !== "string") { + throw new BodyFieldError("Missing jwsTransaction"); + } + + logger.debug("[Apple transfer] Verifying JWS", { userId: user.id }); + + const payload = await verifyAppleJws(jwsTransaction); + + if (!APPLE_BUNDLE_IDS.includes(payload.bundleId)) { + logger.warn("[Apple transfer] Invalid bundle ID", { bundleId: payload.bundleId, userId: user.id }); + throw new ForbiddenError("Invalid bundle ID"); + } + if (payload.productId !== APPLE_PRODUCT_ID) { + logger.warn("[Apple transfer] Invalid product ID", { productId: payload.productId, userId: user.id }); + throw new ForbiddenError("Invalid product ID"); + } + + const expiresDate = new Date(payload.expiresDate); + if (expiresDate <= new Date()) { + logger.warn("[Apple transfer] Transaction already expired", { + originalTransactionId: payload.originalTransactionId, + expiresDate, + userId: user.id, + }); + throw new ForbiddenError("Transaction already expired"); + } + + const existing = await TransactionService.findUserByTransactionId(payload.originalTransactionId); + const previousUserId = existing && existing.userId !== user.id ? existing.userId : null; + + if (previousUserId) { + // Revoke Pro from the previously-linked account before re-binding the + // transaction. Apple keeps billing the same Apple ID — only which + // Scriptio account benefits is changing. + logger.info("[Apple transfer] Revoking Pro from previous user", { + previousUserId, + newUserId: user.id, + originalTransactionId: payload.originalTransactionId, + }); + await UserService.updateUserFromId(previousUserId, { + isProUntil: null, + subscriptionProvider: null, + isSubscriptionCancelled: false, + }); + await TransactionService.reassignTransactionToUser(payload.originalTransactionId, user.id); + } else if (!existing) { + await TransactionService.createTransactionIfNotExists(user.id, "APPLE", payload.originalTransactionId); + } + + await UserService.updateUserFromId(user.id, { + isProUntil: expiresDate, + subscriptionProvider: "APPLE", + isSubscriptionCancelled: false, + }); + + logger.info("[Apple transfer] Subscription transferred", { + newUserId: user.id, + previousUserId, + originalTransactionId: payload.originalTransactionId, + expiresDate, + }); + + return Success(null); +} + +export const POST = apiHandler(handleTransferSubscription); diff --git a/src/app/projects/layout.tsx b/src/app/projects/layout.tsx index 0a977e0d..71531ab1 100644 --- a/src/app/projects/layout.tsx +++ b/src/app/projects/layout.tsx @@ -50,13 +50,20 @@ interface ProjectLayoutInnerProps { } const ProjectLayoutInner = ({ children }: ProjectLayoutInnerProps) => { - const { isYjsReady, isProjectUnavailable, migrationOutcome } = useProjectReady(); + const { isYjsReady, isProjectUnavailable, isStaleClient, migrationOutcome } = useProjectReady(); const { membership, isLoading: isMembershipLoading, isLocalOnly: isBrowserLocalOnly } = useProjectMembership(); // Desktop (Tauri) and browser local-only projects skip the cloud membership requirement const isDesktop = isTauri(); const isLocalAccess = isDesktop || isBrowserLocalOnly; + // Server rejected this client as stale — its bundle predates the doc's + // schema version. Treat as a future-version outcome so the user gets a + // clear "update required" message. + if (isStaleClient) { + return ; + } + // Migration blocked the project from loading: show a dedicated error dialog // before any other gating logic, so the user always gets a clear message. if ( diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 2980b2bf..d9881b44 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -217,12 +217,14 @@ export const ProjectContext = createContext(defaultContextVa interface ProjectReadyContextType { isYjsReady: boolean; isProjectUnavailable: boolean; + isStaleClient: boolean; migrationOutcome: ProjectMigrationOutcome | null; } const ProjectReadyContext = createContext({ isYjsReady: false, isProjectUnavailable: false, + isStaleClient: false, migrationOutcome: null, }); @@ -253,6 +255,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = connectionStatus: yjsConnectionStatus, users: yjsUsers, isProjectUnavailable, + isStaleClient, migrationOutcome, } = useProjectYjs({ projectId, @@ -845,8 +848,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = ); const readyValue = useMemo( - () => ({ isYjsReady, isProjectUnavailable, migrationOutcome }), - [isYjsReady, isProjectUnavailable, migrationOutcome], + () => ({ isYjsReady, isProjectUnavailable, isStaleClient, migrationOutcome }), + [isYjsReady, isProjectUnavailable, isStaleClient, migrationOutcome], ); return ( diff --git a/src/global.d.ts b/src/global.d.ts index 3eb45d36..977cb5c4 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -9,7 +9,7 @@ declare global { namespace NodeJS { interface ProcessEnv { NODE_ENV: "development" | "production"; - NEXT_PUBLIC_COLLAB_WEBSOCKET_URL: string; + NEXT_PUBLIC_CLOUD_URL: string; // Token Secrets JWT_SECRET: string; diff --git a/src/lib/adapters/fdx/finaldraft-adapter.ts b/src/lib/adapters/fdx/finaldraft-adapter.ts index 6c2aba43..699bc278 100644 --- a/src/lib/adapters/fdx/finaldraft-adapter.ts +++ b/src/lib/adapters/fdx/finaldraft-adapter.ts @@ -1,7 +1,7 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { XMLBuilder, XMLParser } from "@node_modules/fast-xml-parser/src/fxp"; import { getNodeFlattenContent } from "@src/lib/screenplay/screenplay"; -import { ProjectData, ProjectState } from "@src/lib/project/project-state"; +import { ProjectData, ProjectState, screenplayOf } from "@src/lib/project/project-state"; import type { JSONContent } from "@tiptap/core"; interface FDXStyledText { @@ -50,7 +50,7 @@ export class FinalDraftAdapter extends ProjectAdapter { convertTo(project: ProjectState, options: BaseExportOptions): Promise { const paragraphNodes: FDXParagraphNode[] = []; - const nodes = project.screenplay(); + const nodes = screenplayOf(project); const characters = options.characters; for (let i = 0; i < nodes.length; i++) { diff --git a/src/lib/adapters/fountain/fountain-adapter.ts b/src/lib/adapters/fountain/fountain-adapter.ts index 96956b5a..d05a259d 100644 --- a/src/lib/adapters/fountain/fountain-adapter.ts +++ b/src/lib/adapters/fountain/fountain-adapter.ts @@ -4,7 +4,7 @@ import fountain from "./fountain_parser"; import { generateJSON, JSONContent } from "@tiptap/react"; import { getNodeFlattenContent } from "@src/lib/screenplay/screenplay"; import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; -import { ProjectData, ProjectState } from "@src/lib/project/project-state"; +import { ProjectData, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; export class FountainAdapter extends ProjectAdapter { label = "Fountain Script"; @@ -42,7 +42,7 @@ export class FountainAdapter extends ProjectAdapter { * and plain text lines to Credit. */ private buildFountainTitlePage(project: ProjectState, options: BaseExportOptions): string { - const titlePageContent = project.titlepage(); + const titlePageContent = titlepageOf(project); if (!titlePageContent || titlePageContent.length === 0) return ""; const lines: string[] = []; @@ -87,7 +87,7 @@ export class FountainAdapter extends ProjectAdapter { let fountain = this.buildFountainTitlePage(project, options); let sceneCount = 1; - const nodes = project.screenplay(); + const nodes = screenplayOf(project); const characters = options.characters; for (let i = 0; i < nodes.length; i++) { diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index d3d3d904..21464087 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -1,8 +1,9 @@ -import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState } from "@src/lib/project/project-state"; +import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { replaceScreenplay } from "../../screenplay/editor"; import { Editor } from "@tiptap/react"; import { ProjectRepository } from "../../project/project-repository"; +import { CURRENT_PROJECT_VERSION } from "../../project/migrations/project-migrations"; import * as fflate from "fflate"; import * as Y from "yjs"; @@ -11,13 +12,12 @@ import * as Y from "yjs"; // Offset Size Description // ────── ──── ────────────────────────────────────────────────────────────── // 0 8 Magic bytes: ASCII "SCRIPTIO" -// 8 1 Version (u8): current = 1 +// 8 1 Version (u8): see CURRENT_PROJECT_VERSION // 9 1 Flags (u8): bit 0 → 0 = zlib-compressed binary Yjs state // 1 = human-readable JSON (ProjectData) // 10 … Payload // const MAGIC = new Uint8Array([0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x49, 0x4f]); // "SCRIPTIO" -const CURRENT_VERSION = 1; const HEADER_SIZE = MAGIC.length + 1 + 1; // 8 magic + 1 version + 1 flags = 10 bytes const FLAG_READABLE_JSON = 0x01; // bit 0: payload is UTF-8 JSON, not compressed Yjs @@ -47,7 +47,7 @@ function parseHeader(data: Uint8Array): { version: number; flags: number; payloa } const version = data[MAGIC.length]; - if (version > CURRENT_VERSION) { + if (version > CURRENT_PROJECT_VERSION) { throw new Error(`Unsupported .scriptio file version: ${version}. Please update Scriptio.`); } @@ -62,7 +62,7 @@ export class ScriptioAdapter extends ProjectAdapter { convertTo(project: ProjectState, options: ScriptioExportOptions): Promise { const isReadable = options.readable ?? false; const flags = isReadable ? FLAG_READABLE_JSON : 0x00; - const header = buildHeader(CURRENT_VERSION, flags); + const header = buildHeader(CURRENT_PROJECT_VERSION, flags); let payload: Uint8Array; @@ -71,8 +71,8 @@ export class ScriptioAdapter extends ProjectAdapter { // This produces a larger file but makes the content inspectable // with any text editor. const data: ProjectData = { - screenplay: project.screenplay(), - titlepage: project.titlepage(), + screenplay: screenplayOf(project), + titlepage: titlepageOf(project), metadata: project.metadata().toJSON() as ProjectMetadata, characters: project.characters().toJSON(), scenes: project.scenes().toJSON(), @@ -121,8 +121,8 @@ export class ScriptioAdapter extends ProjectAdapter { Y.applyUpdate(tmpDoc, decompressed); return { - screenplay: tmpDoc.screenplay(), - titlepage: tmpDoc.titlepage(), + screenplay: screenplayOf(tmpDoc), + titlepage: titlepageOf(tmpDoc), metadata: tmpDoc.metadata().toJSON() as ProjectMetadata, characters: tmpDoc.characters().toJSON(), scenes: tmpDoc.scenes().toJSON(), diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index 7f9260eb..ac971fc0 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -17,9 +17,15 @@ import { SaveEntry, } from "./types"; import { handleProtocolMessage } from "./protocol"; +import { ProjectState } from "../project/project-doc"; +import { + migrateProjectDocCore, + readProjectDocVersion, +} from "../project/migrations/project-migration-runner"; +import { CURRENT_PROJECT_VERSION } from "../project/migrations/project-migrations"; export class ProjectRoom extends DurableObject { - doc: Y.Doc; + doc: ProjectState; saveTimeout: ReturnType | null = null; awareness: awarenessProtocol.Awareness; sessions: Map; @@ -31,6 +37,14 @@ export class ProjectRoom extends DurableObject { private alarmScheduled: boolean = false; private projectId: string | null = null; + /** Project schema version of the in-memory doc; the gatekeeper compares + * client-advertised versions against this on connect. */ + private docVersion: number = CURRENT_PROJECT_VERSION; + + /** Set if server-side migration threw; we refuse new connections until + * the project is fixed manually (preserves data integrity). */ + private docMigrationFailed: boolean = false; + // Typed references to bound handlers — initialized in the constructor body // so they're guaranteed to exist before being passed to doc.on/doc.off. // (esbuild does not guarantee class-field arrow functions are initialized @@ -63,7 +77,7 @@ export class ProjectRoom extends DurableObject { } }; - this.doc = new Y.Doc(); + this.doc = new ProjectState(); this.awareness = new awarenessProtocol.Awareness(this.doc); // Disable the built-in 30s outdated-state cleanup — we manage session @@ -117,12 +131,77 @@ export class ProjectRoom extends DurableObject { this.projectId = configRows[0].value as string; } + // Server-side migration gatekeeper: bring the doc up to + // CURRENT_PROJECT_VERSION before any client is allowed to connect. + // blockConcurrencyWhile makes incoming requests wait for completion. + this.ctx.blockConcurrencyWhile(async () => { + await this.runDocMigration(); + this.observeDocVersion(); + }); + // Start periodic stale awareness cleanup this.startAwarenessCleanup(); console.log("[Room] Initialized"); } + /** + * Apply pending project-doc migrations to the in-memory doc and persist + * the result. Idempotent: a no-op when the doc is already at + * CURRENT_PROJECT_VERSION. + */ + private async runDocMigration(): Promise { + const outcome = await migrateProjectDocCore({ ydoc: this.doc }); + switch (outcome.kind) { + case "up-to-date": + this.docVersion = outcome.version; + break; + case "migrated": + this.docVersion = outcome.to; + console.log( + `[Room] Migrated doc from v${outcome.from} to v${outcome.to} ` + + `(${outcome.appliedSteps.length} step${outcome.appliedSteps.length === 1 ? "" : "s"})`, + ); + // Persist the migrated state immediately so a restart doesn't replay. + await this.saveToDisk(); + break; + case "future-version": + // The on-disk doc is at a version newer than this worker knows. + // Refuse new connections until the worker is upgraded. + this.docMigrationFailed = true; + this.docVersion = outcome.storedVersion; + console.error( + `[Room] Doc at v${outcome.storedVersion} but worker only supports v${outcome.expected}. ` + + `Worker is out of date — refusing connections.`, + ); + break; + case "failed": + this.docMigrationFailed = true; + this.docVersion = outcome.from; + console.error( + `[Room] Doc migration failed at step v${outcome.failedAt} ` + + `(stored v${outcome.from}):`, + outcome.error, + ); + break; + } + } + + /** + * Track upward changes to metadata.version so the connection gatekeeper + * always sees the latest doc version (e.g., after a higher-version client + * propagates a migration we don't yet know about — would-be future-version). + */ + private observeDocVersion(): void { + const map = this.doc.getMap("metadata") as Y.Map; + map.observe(() => { + const v = map.get("version"); + if (typeof v === "number" && v > this.docVersion) { + this.docVersion = v; + } + }); + } + /** * Mark the document as dirty and schedule an R2 snapshot alarm. */ @@ -455,6 +534,13 @@ export class ProjectRoom extends DurableObject { return new Response("Unauthorized: You have been kicked.", { status: 403 }); } + // Server-side migration gatekeeper. Refuse the upgrade if + // server-side migration failed — data integrity is at risk and + // the project should be inspected manually. + if (this.docMigrationFailed) { + return new Response("Project temporarily unavailable", { status: 503 }); + } + // Clean up any existing connection for this user (e.g., stale tab). // This ensures awareness states don't duplicate for the same user. const existingSocket = this.userConnections.get(userId); @@ -480,6 +566,21 @@ export class ProjectRoom extends DurableObject { this.ctx.acceptWebSocket(server); + // Stale-client gate: reject clients whose bundle is older than + // the doc's schema version. Sending sync to them would let them + // write back the pre-migration shape and corrupt the doc. + const clientVersionParam = url.searchParams.get("clientVersion"); + const clientVersion = clientVersionParam !== null ? Number(clientVersionParam) : NaN; + if (Number.isFinite(clientVersion) && clientVersion < this.docVersion) { + console.log( + `[Room] Rejecting stale client v${clientVersion} (doc at v${this.docVersion})`, + ); + try { + server.close(4006, `Stale client: update to access v${this.docVersion}`); + } catch {} + return new Response(null, { status: 101, webSocket: client }); + } + // Initialize session this.sessions.set(server, { clientIds: new Set(), @@ -618,7 +719,7 @@ export class ProjectRoom extends DurableObject { this.awareness.destroy(); // 2. Build the restored doc. - this.doc = new Y.Doc(); + this.doc = new ProjectState(); this.doc.on("update", this.handleDocUpdate); Y.applyUpdate(this.doc, data); @@ -628,10 +729,19 @@ export class ProjectRoom extends DurableObject { this.awareness.setLocalState(null); this.awareness.on("update", this.handleAwarenessUpdate); - // 4. Persist the restored state immediately to SQLite. + // 4. Migrate the restored doc forward — snapshots can be from any + // historical version. Resets docMigrationFailed so this restore + // attempt has a clean slate; runDocMigration will set it again + // if migration fails on the restored data. + this.docMigrationFailed = false; + this.docVersion = readProjectDocVersion(this.doc); + await this.runDocMigration(); + this.observeDocVersion(); + + // 5. Persist the restored (and possibly migrated) state to SQLite. await this.saveToDisk(); - // 5. Close all connected clients with "document-restored" (4005). + // 6. Close all connected clients with "document-restored" (4005). // The client provider will clear its local cache and reload so it // reconnects with an empty doc and receives the restored state via sync. for (const [socket] of this.sessions) { diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts index c6a43253..5de26ce2 100644 --- a/src/lib/cloud/utils.ts +++ b/src/lib/cloud/utils.ts @@ -6,6 +6,7 @@ import * as Y from "yjs"; import * as encoding from "lib0/encoding"; import * as syncProtocol from "y-protocols/sync"; import * as awarenessProtocol from "y-protocols/awareness"; +import { CURRENT_PROJECT_VERSION } from "../project/migrations/project-migrations"; declare const window: Window & typeof globalThis; @@ -36,6 +37,7 @@ type WSInternals = { export class ThrottledWebsocketProvider extends WebsocketProvider { on(event: "document-restored", listener: () => void): this; + on(event: "stale-client-version", listener: () => void): this; on(event: Parameters[0], listener: Parameters[1]): this; on(event: string, listener: (...args: unknown[]) => void): this { return super.on( @@ -45,6 +47,7 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { } emit(event: "document-restored", args: []): this; + emit(event: "stale-client-version", args: []): this; emit(event: Parameters[0], args: Parameters[1]): this; emit(event: string, args: unknown[]): this { super.emit( @@ -79,6 +82,7 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { private readonly localClientId: number; private isIdleDisconnected: boolean = false; private isSessionReplaced: boolean = false; + private isStaleClient: boolean = false; private lastKnownUserCount: number = 1; // Store userInfo so we can restore it on reconnection @@ -87,6 +91,7 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { // Close codes from server private readonly CLOSE_CODE_SESSION_REPLACED = 4001; private readonly CLOSE_CODE_DOCUMENT_RESTORED = 4005; + private readonly CLOSE_CODE_STALE_CLIENT_VERSION = 4006; // Bound event handlers for proper cleanup private boundResetIdleTimer: () => void; @@ -100,8 +105,22 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { options: WebsocketProviderOptions & { userInfo?: { name: string; color: string; userId?: string } }, ) { // Pass connect: false to prevent immediate connection - // We'll connect after setting up user info - super(serverUrl, room, doc, { ...options, connect: false }); + // We'll connect after setting up user info. + // Always advertise the client's project schema version via `params` + // so the DO can refuse stale-bundle connections that would write back + // a pre-migration shape and corrupt the doc. + const params: { [x: string]: string } = { + ...(options.params ?? {}), + clientVersion: String(CURRENT_PROJECT_VERSION), + }; + super( + serverUrl, + room, + doc, + { ...options, params, connect: false } as unknown as ConstructorParameters< + typeof WebsocketProvider + >[3], + ); this.localClientId = doc.clientID; this.boundResetIdleTimer = this.resetUserIdleTimer.bind(this); @@ -191,8 +210,8 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { let currentWs: WebSocket | null = null; const checkAndHookWs = () => { - // Don't hook if session was already replaced - if (this.isSessionReplaced) return; + // Don't hook if session was already replaced or rejected as stale + if (this.isSessionReplaced || this.isStaleClient) return; const ws = (this as unknown as WSInternals).ws; if (ws && ws !== currentWs) { @@ -207,6 +226,10 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { this.handleDocumentRestored(); return; } + if (event.code === this.CLOSE_CODE_STALE_CLIENT_VERSION) { + this.handleStaleClientVersion(); + return; + } if (originalClose) { originalClose.call(ws, event); } @@ -216,8 +239,8 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { // Hook into new WebSocket connections when status changes this.on("status", (event: { status: string }) => { - // Only hook when connecting, not when already replaced - if (event.status === "connecting" && !this.isSessionReplaced) { + // Only hook when connecting, not when already replaced or stale-rejected + if (event.status === "connecting" && !this.isSessionReplaced && !this.isStaleClient) { setTimeout(checkAndHookWs, 0); } }); @@ -266,6 +289,29 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { this.emit("document-restored", []); } + /** + * Handle stale-client-version close — this client's bundle is older than + * the project's schema version. Stop reconnecting (would just be rejected + * again) and notify the consumer to prompt the user to update. + */ + private handleStaleClientVersion(): void { + console.warn("[WS] Client version is stale. Notifying consumer to prompt update."); + this.isStaleClient = true; + + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + this.shouldConnect = false; + this.disconnect(); + this.emit("stale-client-version", []); + } + + public get wasStaleClient(): boolean { + return this.isStaleClient; + } + /** * Count active collaborators (excluding ourselves and stale states) * Only counts users who have set a 'user' field in their awareness state @@ -813,7 +859,7 @@ export const allowOnWebsocket = async (userId: string, projectId: string) => { .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("1m") .sign(secret); - await fetch(`${process.env.NEXT_PUBLIC_COLLAB_WEBSOCKET_URL}/${projectId}/allow`, { + await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/allow`, { method: "POST", headers: { "Content-Type": "application/json", @@ -834,7 +880,7 @@ export const blacklistFromWebsocket = async (userId: string, projectId: string) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("1m") .sign(secret); - await fetch(`${process.env.NEXT_PUBLIC_COLLAB_WEBSOCKET_URL}/${projectId}/blacklist`, { + await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/blacklist`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/lib/project/migrations/project-migration-runner.ts b/src/lib/project/migrations/project-migration-runner.ts index e8550fd4..fb185017 100644 --- a/src/lib/project/migrations/project-migration-runner.ts +++ b/src/lib/project/migrations/project-migration-runner.ts @@ -1,15 +1,23 @@ /** - * Drives `PROJECT_MIGRATIONS` against a single `ProjectState`. Called from - * `useLocalPersistence` after `y-indexeddb` has fully synced and **before** - * the cloud `WebsocketProvider` connects, to avoid concurrent migrations - * across clients producing duplicated effects. + * Drives `PROJECT_MIGRATIONS` against a single `ProjectState`. + * + * Two entry points: + * - `migrateProjectDocCore`: pure migration with no I/O. Used by the + * DurableObject (server-side gatekeeper) to migrate the doc on load + * before serving updates to clients. + * - `migrateProjectDoc`: client wrapper that adds an IndexedDB pre-migration + * backup so a failed step can be rolled back. Called from + * `useLocalPersistence` after `y-indexeddb` has synced and before the + * cloud `WebsocketProvider` connects. + * + * Both rely on idempotent migration steps so concurrent migrations across + * the DO and any number of clients converge cleanly under CRDT merge. */ import * as Y from "yjs"; -import type { ProjectState } from "../project-state"; +import { ProjectState } from "../project-doc"; import { CURRENT_PROJECT_VERSION, - LEGACY_PROJECT_VERSION, PROJECT_MIGRATIONS, type ProjectMigration, } from "./project-migrations"; @@ -18,7 +26,14 @@ export type ProjectMigrationOutcome = | { kind: "up-to-date"; version: number } | { kind: "migrated"; from: number; to: number; appliedSteps: string[] } | { kind: "future-version"; storedVersion: number; expected: number } - | { kind: "failed"; from: number; failedAt: number; error: Error }; + | { kind: "failed"; from: number; failedAt: number; error: Error } + /** + * Surfaced by the layout when the cloud server rejects the WebSocket + * upgrade because this client's bundle predates the doc's schema version. + * Not produced by the migration runner itself — it's a UI-side outcome + * routed through the same error dialog. + */ + | { kind: "stale-client" }; const MIGRATION_ORIGIN = "migration"; @@ -26,6 +41,20 @@ interface BackupStore { saveMigrationBackup(projectId: string, snapshot: Uint8Array, fromVersion: number): Promise; } +export interface MigrateProjectDocCoreArgs { + ydoc: ProjectState; + /** Override for tests; defaults to `PROJECT_MIGRATIONS`. */ + migrations?: ProjectMigration[]; + /** Override for tests; defaults to `CURRENT_PROJECT_VERSION`. */ + currentVersion?: number; + /** + * Optional async hook invoked once we know migration steps will run, with + * the pre-mutation snapshot. The client uses this to persist a rollback + * backup; the DO leaves it unset. + */ + onBeforeMutate?: (snapshot: Uint8Array, fromVersion: number) => Promise; +} + export interface MigrateProjectDocArgs { ydoc: ProjectState; projectId: string; @@ -44,22 +73,28 @@ async function defaultBackupStore(): Promise { function readVersion(ydoc: ProjectState): number { const stored = ydoc.metadata().get("version"); - return typeof stored === "number" ? stored : LEGACY_PROJECT_VERSION; + return typeof stored === "number" ? stored : 1; } /** - * Migrate a project Y.Doc to `CURRENT_PROJECT_VERSION`. - * - * Returns an outcome describing what happened. The caller decides UI - * (block load on `future-version`/`failed`, show the doc on `up-to-date`/`migrated`). + * Public helper: peek the version field of a project doc without mutating it. + * Accepts a raw `Y.Doc` so callers that don't carry the `ProjectState` typing + * (e.g. examining a freshly-applied snapshot before constructing one) can use it. */ -export async function migrateProjectDoc({ +export function readProjectDocVersion(ydoc: Y.Doc): number { + const stored = ydoc.getMap("metadata").get("version"); + return typeof stored === "number" ? stored : 1; +} + +/** + * Pure migration: no I/O, safe to call from any environment (browser, DO). + */ +export async function migrateProjectDocCore({ ydoc, - projectId, - backupStore, migrations = PROJECT_MIGRATIONS, currentVersion = CURRENT_PROJECT_VERSION, -}: MigrateProjectDocArgs): Promise { + onBeforeMutate, +}: MigrateProjectDocCoreArgs): Promise { const stored = readVersion(ydoc); if (stored === currentVersion) { @@ -81,10 +116,10 @@ export async function migrateProjectDoc({ return { kind: "migrated", from: stored, to: currentVersion, appliedSteps: [] }; } - // Snapshot the doc before mutating so a failed step can be rolled back. - const snapshot = Y.encodeStateAsUpdate(ydoc); - const store = backupStore ?? (await defaultBackupStore()); - await store.saveMigrationBackup(projectId, snapshot, stored); + if (onBeforeMutate) { + const snapshot = Y.encodeStateAsUpdate(ydoc); + await onBeforeMutate(snapshot, stored); + } const applied: string[] = []; for (const step of steps) { @@ -108,6 +143,29 @@ export async function migrateProjectDoc({ return { kind: "migrated", from: stored, to: currentVersion, appliedSteps: applied }; } +/** + * Client-side migration. Wraps `migrateProjectDocCore` with an IndexedDB + * rollback backup so a failed step can be restored from the + * `ProjectMigrationErrorDialog`. + */ +export async function migrateProjectDoc({ + ydoc, + projectId, + backupStore, + migrations, + currentVersion, +}: MigrateProjectDocArgs): Promise { + return migrateProjectDocCore({ + ydoc, + migrations, + currentVersion, + onBeforeMutate: async (snapshot, fromVersion) => { + const store = backupStore ?? (await defaultBackupStore()); + await store.saveMigrationBackup(projectId, snapshot, fromVersion); + }, + }); +} + /** * Replay a stored backup snapshot into a fresh Y.Doc, then write that doc * back to local persistence. Used when the user picks "Restore from backup" diff --git a/src/lib/project/migrations/project-migrations.ts b/src/lib/project/migrations/project-migrations.ts index 972bd0cb..77d12e53 100644 --- a/src/lib/project/migrations/project-migrations.ts +++ b/src/lib/project/migrations/project-migrations.ts @@ -18,12 +18,18 @@ * 3. Existing projects upgrade lazily on first open. */ -import type { ProjectState } from "../project-state"; +import type { ProjectState } from "../project-doc"; export interface ProjectMigration { from: number; to: number; description: string; + /** + * Mutate the project Yjs document. The runner wraps this call in a + * single `Y.transact` with origin "migration". The argument is a + * `ProjectState` (Y.Doc subclass) so steps get typed accessors: + * `ydoc.metadata()`, `ydoc.comments()`, `ydoc.layout()`, etc. + */ run: (ydoc: ProjectState) => void; } @@ -37,14 +43,7 @@ export const PROJECT_MIGRATIONS: ProjectMigration[] = [ // }, ]; -/** - * Version every project should be at after migration. Legacy projects (no - * version field in metadata) are treated as version 1, matching the schema - * shipped before the migration framework existed. - */ -export const LEGACY_PROJECT_VERSION = 1; - export const CURRENT_PROJECT_VERSION = PROJECT_MIGRATIONS.length === 0 - ? LEGACY_PROJECT_VERSION + ? 1 : PROJECT_MIGRATIONS[PROJECT_MIGRATIONS.length - 1].to; diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts new file mode 100644 index 00000000..8bf590d7 --- /dev/null +++ b/src/lib/project/project-doc.ts @@ -0,0 +1,243 @@ +/** + * Worker-safe Y.Doc subclass for Scriptio projects. + * + * This file deliberately avoids React, tiptap, and any prosemirror imports + * so it can be loaded inside the Cloudflare DurableObject. The browser-only + * helpers that need ProseMirror conversion (e.g. screenplay()/titlepage() + * → JSONContent) live as standalone functions in `./project-state.ts`. + * + * All non-yjs imports here are `import type` so nothing else is pulled into + * the worker bundle at runtime. + */ + +import * as Y from "yjs"; + +import type { JSONContent } from "@tiptap/react"; +import type { PageFormat } from "../utils/enums"; +import type { CharacterItem } from "../screenplay/characters"; +import type { LocationItem } from "../screenplay/locations"; +import type { PersistentScene } from "../screenplay/scenes"; +import type { Comment } from "../utils/types"; + +// -------------------------------- // +// SHELF TYPES // +// -------------------------------- // + +export type ShelfEntryType = "scene" | "character" | "action"; + +export type ShelfVersionMeta = { + id: string; + title: string; +}; + +export type ShelfEntry = { + title: string; + type: ShelfEntryType; + versions: ShelfVersionMeta[]; +}; + +// -------------------------------- // +// METADATA // +// -------------------------------- // + +export type ProjectMetadata = { + version: number; + id: string; + title: string; + author: string; + titlepageInitialized?: boolean; +}; + +// -------------------------------- // +// LAYOUT // +// -------------------------------- // + +export type ElementMargin = { left: number; right: number }; +export type PageMargin = { top: number; bottom: number; left: number; right: number }; + +export const DEFAULT_PAGE_MARGINS: PageMargin = { + top: 1.0, + bottom: 1.0, + left: 1.5, + right: 1.0, +}; + +export const DEFAULT_ELEMENT_MARGINS: Record = { + action: { left: 0, right: 0 }, + scene: { left: 0, right: 0 }, + character: { left: 2.5, right: 0 }, + dialogue: { left: 1.3, right: 1.0 }, + parenthetical: { left: 2.0, right: 2.0 }, + transition: { left: 0, right: 0 }, + section: { left: 0, right: 0 }, +}; + +export type ElementStyle = { + bold?: boolean; + italic?: boolean; + underline?: boolean; + uppercase?: boolean; + align?: "left" | "center" | "right"; + startNewPage?: boolean; +}; + +export const DEFAULT_ELEMENT_STYLES: Record = { + action: { align: "left" }, + scene: { bold: true, align: "left", uppercase: true }, + character: { align: "left", uppercase: true }, + dialogue: { align: "left" }, + parenthetical: { align: "left" }, + transition: { align: "right", uppercase: true }, + section: { align: "center", underline: true, startNewPage: true, uppercase: true }, +}; + +export type LayoutData = { + pageSize: PageFormat; + pageMargins: PageMargin; + displaySceneNumbers: boolean; + sceneHeadingSpacing: number; + sceneNumberOnRight: boolean; + contdLabel: string; + moreLabel: string; + elementMargins: Record; + elementStyles: Record; +}; + +// -------------------------------- // +// BOARD // +// -------------------------------- // + +export interface BoardCardData { + id: string; + title: string; + description: string; + color: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface BoardArrowData { + id: string; + fromCardId: string; + toCardId: string; +} + +export type BoardData = { + cards: string; + arrows: string; +}; + +// -------------------------------- // +// PROJECT DATA // +// -------------------------------- // + +export type ProjectData = { + screenplay: JSONContent[]; + titlepage?: JSONContent[]; + characters: Record; + scenes: Record; + locations: Record; + metadata: ProjectMetadata; + board: BoardData; + layout: LayoutData; + comments?: Record; + shelf?: Record; +}; + +/** + * Helper to provide stronger typing for Y.Map where different keys have different types. + */ +export interface TypedMap> + extends Omit, "get" | "set" | "toJSON"> { + get(key: K): T[K] | undefined; + set(key: K, value: T[K]): T[K]; + toJSON(): T; +} + +// -------------------------------- // +// PROJECT STATE // +// -------------------------------- // + +/** + * Y.Doc subclass with typed accessors for Scriptio's schema. All accessors + * are pure Y.js operations — no ProseMirror, no React. Safe to instantiate + * in the DurableObject. Browser-only ProseMirror conversion lives in + * `project-state.ts` as standalone helpers. + */ +export class ProjectState extends Y.Doc { + KEYS = { + SCREENPLAY: "screenplay", + TITLEPAGE: "titlepage", + CHARACTERS: "characters", + SCENES: "scenes", + LOCATIONS: "locations", + METADATA: "metadata", + BOARD: "board", + LAYOUT: "layout", + COMMENTS: "comments", + DICTIONARY: "dictionary", + SHELF: "shelf", + } as const; + + metadata(): TypedMap { + return this.getMap(this.KEYS.METADATA) as unknown as TypedMap; + } + + screenplayFragment(): Y.XmlFragment { + return this.getXmlFragment(this.KEYS.SCREENPLAY); + } + + titlepageFragment(): Y.XmlFragment { + return this.getXmlFragment(this.KEYS.TITLEPAGE); + } + + characters(): Y.Map { + return this.getMap(this.KEYS.CHARACTERS); + } + + locations(): Y.Map { + return this.getMap(this.KEYS.LOCATIONS); + } + + scenes(): Y.Map { + return this.getMap(this.KEYS.SCENES); + } + + board(): TypedMap { + return this.getMap(this.KEYS.BOARD) as unknown as TypedMap; + } + + layout(): TypedMap { + return this.getMap(this.KEYS.LAYOUT) as unknown as TypedMap; + } + + comments(): Y.Map { + return this.getMap(this.KEYS.COMMENTS); + } + + /** Per-project custom dictionary words (keys are words, values are true). */ + dictionary(): Y.Map { + return this.getMap(this.KEYS.DICTIONARY); + } + + /** Shelf entries keyed by node UUID. */ + shelf(): Y.Map { + return this.getMap(this.KEYS.SHELF); + } + + /** Get the Y.XmlFragment for a specific shelf version's content. */ + shelfFragment(nodeId: string, versionId: string): Y.XmlFragment { + return this.getXmlFragment(`shelf_${nodeId}_${versionId}`); + } +} + +// -------------------------------- // +// MAP HELPERS // +// -------------------------------- // + +export const getCharactersMap = (ydoc: ProjectState): Y.Map => ydoc.characters(); +export const getLocationsMap = (ydoc: ProjectState): Y.Map => ydoc.locations(); +export const getScenesMap = (ydoc: ProjectState): Y.Map => ydoc.scenes(); +export const getBoardMap = (ydoc: ProjectState): TypedMap => ydoc.board(); diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 1e3a78c1..b5e15d74 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -12,6 +12,7 @@ import { ShelfEntry, ShelfEntryType, ShelfVersionMeta, + screenplayOf, } from "./project-state"; import { CharacterMap } from "../screenplay/characters"; import { LocationMap } from "../screenplay/locations"; @@ -65,7 +66,7 @@ export class ProjectRepository { * This converts the Y.js XmlFragment to a ProseMirror document structure. */ get screenplay(): Screenplay { - return this.ydoc.screenplay(); + return screenplayOf(this.ydoc); } /** diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 35a6ead2..6f5bc809 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -5,19 +5,47 @@ import { getRandomColor } from "@src/lib/utils/misc"; import { getCloudToken } from "../utils/requests"; import { JSONContent } from "@tiptap/react"; import { Screenplay } from "../utils/types"; -import { PageFormat } from "../utils/enums"; import * as Y from "yjs"; import type { ThrottledWebsocketProvider } from "../cloud/utils"; import { ScreenplaySchema } from "../screenplay/editor"; import { TitlePageSchema } from "../titlepage/editor"; import { yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; -import type { CharacterItem, CharacterMap } from "../screenplay/characters"; -import type { LocationItem, LocationMap } from "../screenplay/locations"; -import type { PersistentScene, PersistentSceneMap } from "../screenplay/scenes"; -import type { Comment } from "../utils/types"; +import type { CharacterMap } from "../screenplay/characters"; +import type { LocationMap } from "../screenplay/locations"; +import type { PersistentSceneMap } from "../screenplay/scenes"; import type { YjsLocalProvider } from "../persistence/y-local-provider"; import type { ProjectMigrationOutcome } from "./migrations/project-migration-runner"; +import { ProjectState } from "./project-doc"; + +// Re-export all schema types & the class so existing consumers continue to +// import from "@src/lib/project/project-state" without changes. +export { + ProjectState, + DEFAULT_PAGE_MARGINS, + DEFAULT_ELEMENT_MARGINS, + DEFAULT_ELEMENT_STYLES, + getCharactersMap, + getLocationsMap, + getScenesMap, + getBoardMap, +} from "./project-doc"; +export type { + ShelfEntryType, + ShelfVersionMeta, + ShelfEntry, + ProjectMetadata, + ElementMargin, + PageMargin, + ElementStyle, + LayoutData, + BoardCardData, + BoardArrowData, + BoardData, + ProjectData, + TypedMap, +} from "./project-doc"; + // Lazy re-export repository for convenient access (avoid loading yjs at module level) export const getProjectRepository = async () => { const mod = await import("./project-repository"); @@ -33,21 +61,6 @@ export const getProjectRepository = async () => { export type ConnectionStatus = "disconnected" | "connecting" | "connected"; -// ---- Shelf types ---- - -export type ShelfEntryType = "scene" | "character" | "action"; - -export type ShelfVersionMeta = { - id: string; // unique version ID (nanoid) - title: string; // default: today's date on creation -}; - -export type ShelfEntry = { - title: string; // text content of the shelved node - type: ShelfEntryType; - versions: ShelfVersionMeta[]; -}; - export interface ProjectYjsState { ydoc: ProjectState | null; provider: ThrottledWebsocketProvider | null; @@ -59,6 +72,7 @@ export interface ProjectYjsState { isLockedByServer: boolean; isSessionReplaced: boolean; isProjectUnavailable: boolean; + isStaleClient: boolean; migrationOutcome: ProjectMigrationOutcome | null; } @@ -75,117 +89,6 @@ export interface UserInfo { userId?: string; } -export type ProjectMetadata = { - version: number; - id: string; - title: string; - author: string; - titlepageInitialized?: boolean; -}; - -export type ElementMargin = { left: number; right: number }; // values in inches (offset from page margin) - -export type PageMargin = { top: number; bottom: number; left: number; right: number }; // values in inches - -/** Default page margins (in inches). */ -export const DEFAULT_PAGE_MARGINS: PageMargin = { - top: 1.0, - bottom: 1.0, - left: 1.5, - right: 1.0, -}; - -/** Default margins per screenplay element (offset from page margin, in inches). */ -export const DEFAULT_ELEMENT_MARGINS: Record = { - action: { left: 0, right: 0 }, - scene: { left: 0, right: 0 }, - character: { left: 2.5, right: 0 }, - dialogue: { left: 1.3, right: 1.0 }, - parenthetical: { left: 2.0, right: 2.0 }, - transition: { left: 0, right: 0 }, - section: { left: 0, right: 0 }, -}; - -export type ElementStyle = { - bold?: boolean; - italic?: boolean; - underline?: boolean; - uppercase?: boolean; - align?: "left" | "center" | "right"; - startNewPage?: boolean; -}; - -/** Default styling per screenplay element */ -export const DEFAULT_ELEMENT_STYLES: Record = { - action: { align: "left" }, - scene: { bold: true, align: "left", uppercase: true }, - character: { align: "left", uppercase: true }, - dialogue: { align: "left" }, - parenthetical: { align: "left" }, - transition: { align: "right", uppercase: true }, - section: { align: "center", underline: true, startNewPage: true, uppercase: true }, -}; - -export type LayoutData = { - pageSize: PageFormat; - pageMargins: PageMargin; - displaySceneNumbers: boolean; - sceneHeadingSpacing: number; - sceneNumberOnRight: boolean; - contdLabel: string; - moreLabel: string; - elementMargins: Record; - elementStyles: Record; -}; - -export interface BoardCardData { - id: string; - title: string; - description: string; - color: string; - x: number; - y: number; - width: number; - height: number; -} - -export interface BoardArrowData { - id: string; - fromCardId: string; - toCardId: string; -} - -export type BoardData = { - cards: string; // JSON string of BoardCardData[] - arrows: string; // JSON string of BoardArrowData[] -}; - -export type ProjectData = { - screenplay: JSONContent[]; - titlepage?: JSONContent[]; - characters: CharacterMap; - scenes: PersistentSceneMap; - locations: LocationMap; - metadata: ProjectMetadata; - board: BoardData; - layout: LayoutData; - comments?: Record; - shelf?: Record; -}; - -/** - * Helper to provide stronger typing for Y.Map where different keys have different types. - * This avoids manual casts when accessing known keys. - */ -export interface TypedMap> extends Omit< - Y.Map, - "get" | "set" | "toJSON" -> { - get(key: K): T[K] | undefined; - set(key: K, value: T[K]): T[K]; - toJSON(): T; -} - // -------------------------------- // // LAZY-LOADED MODULES // // -------------------------------- // @@ -224,123 +127,27 @@ export async function clearLocalProjectCache(projectId: string): Promise { } // -------------------------------- // -// PROJECT STATE // -// -------------------------------- // - -// ProjectState class - created dynamically to avoid SSR issues -export class ProjectState extends Y.Doc { - KEYS = { - SCREENPLAY: "screenplay", - TITLEPAGE: "titlepage", - CHARACTERS: "characters", - SCENES: "scenes", - LOCATIONS: "locations", - METADATA: "metadata", - BOARD: "board", - LAYOUT: "layout", - COMMENTS: "comments", - DICTIONARY: "dictionary", - SHELF: "shelf", - } as const; - - metadata(): TypedMap { - return this.getMap(this.KEYS.METADATA) as unknown as TypedMap; - } - - screenplay(): Screenplay { - const fragment = this.screenplayFragment(); - const proseMirrorNode = yXmlFragmentToProseMirrorRootNode(fragment, ScreenplaySchema); - return proseMirrorNode.content.toJSON() as Screenplay; - } - - screenplayFragment(): Y.XmlFragment { - return this.getXmlFragment(this.KEYS.SCREENPLAY); - } - - titlepage(): JSONContent[] { - const fragment = this.titlepageFragment(); - const proseMirrorNode = yXmlFragmentToProseMirrorRootNode(fragment, TitlePageSchema); - return proseMirrorNode.content.toJSON() as JSONContent[]; - } - - titlepageFragment(): Y.XmlFragment { - return this.getXmlFragment(this.KEYS.TITLEPAGE); - } - - characters(): Y.Map { - return this.getMap(this.KEYS.CHARACTERS); - } - - locations(): Y.Map { - return this.getMap(this.KEYS.LOCATIONS); - } - - scenes(): Y.Map { - return this.getMap(this.KEYS.SCENES); - } - - board(): TypedMap { - return this.getMap(this.KEYS.BOARD) as unknown as TypedMap; - } - - layout(): TypedMap { - return this.getMap(this.KEYS.LAYOUT) as unknown as TypedMap; - } - - comments(): Y.Map { - return this.getMap(this.KEYS.COMMENTS); - } - - /** Per-project custom dictionary words (keys are words, values are true). */ - dictionary(): Y.Map { - return this.getMap(this.KEYS.DICTIONARY); - } - - /** Shelf entries keyed by node UUID. */ - shelf(): Y.Map { - return this.getMap(this.KEYS.SHELF); - } - - /** Get the Y.XmlFragment for a specific shelf version's content. */ - shelfFragment(nodeId: string, versionId: string): Y.XmlFragment { - return this.getXmlFragment(`shelf_${nodeId}_${versionId}`); - } -} - -// -------------------------------- // -// HELPER FUNCTIONS // +// PROSEMIRROR HELPERS (browser) // // -------------------------------- // /** - * Get the characters Y.Map from a ProjectState. - * Convenience function for direct access without repository. + * Convert the screenplay Y.XmlFragment to ProseMirror JSONContent[]. + * Browser-only: uses tiptap's ScreenplaySchema and y-prosemirror. */ -export const getCharactersMap = (ydoc: ProjectState): Y.Map => { - return ydoc.characters(); +export const screenplayOf = (ydoc: ProjectState): Screenplay => { + const fragment = ydoc.screenplayFragment(); + const proseMirrorNode = yXmlFragmentToProseMirrorRootNode(fragment, ScreenplaySchema); + return proseMirrorNode.content.toJSON() as Screenplay; }; /** - * Get the locations Y.Map from a ProjectState. - * Convenience function for direct access without repository. + * Convert the title-page Y.XmlFragment to ProseMirror JSONContent[]. + * Browser-only: uses tiptap's TitlePageSchema and y-prosemirror. */ -export const getLocationsMap = (ydoc: ProjectState): Y.Map => { - return ydoc.locations(); -}; - -/** - * Get the scenes Y.Map from a ProjectState. - * Convenience function for direct access without repository. - */ -export const getScenesMap = (ydoc: ProjectState): Y.Map => { - return ydoc.scenes(); -}; - -/** - * Get the board Y.Map from a ProjectState. - * Convenience function for direct access without repository. - */ -export const getBoardMap = (ydoc: ProjectState): TypedMap => { - return ydoc.board(); +export const titlepageOf = (ydoc: ProjectState): JSONContent[] => { + const fragment = ydoc.titlepageFragment(); + const proseMirrorNode = yXmlFragmentToProseMirrorRootNode(fragment, TitlePageSchema); + return proseMirrorNode.content.toJSON() as JSONContent[]; }; // -------------------------------- // @@ -349,7 +156,6 @@ export const getBoardMap = (ydoc: ProjectState): TypedMap => { /** * Hook to initialize local persistence for the Yjs document. - * Uses SQLite on desktop (Tauri) and IndexedDB on browser. */ export const useLocalPersistence = (projectId: string | null) => { const [ydoc, setYdoc] = useState(null); @@ -430,6 +236,7 @@ export const useCloudSync = ( const [isLockedByServer] = useState(false); const [isSessionReplaced] = useState(false); const [isProjectUnavailable, setIsProjectUnavailable] = useState(false); + const [isStaleClient, setIsStaleClient] = useState(false); const isMountedRef = useRef(true); const providerRef = useRef(null); @@ -512,8 +319,9 @@ export const useCloudSync = ( // Dynamically import collaboration utils const { ThrottledWebsocketProvider } = await import("../cloud/utils"); + const cloudWsUrl = (process.env.NEXT_PUBLIC_CLOUD_URL || "").replace(/^http/, "ws"); const cloudProvider = new ThrottledWebsocketProvider( - `${process.env.NEXT_PUBLIC_COLLAB_WEBSOCKET_URL}`, + cloudWsUrl, projectId, ydoc, { @@ -596,6 +404,15 @@ export const useCloudSync = ( window.location.reload(); }); + // Server rejected this client as stale — its bundle predates + // the doc's schema version. Surface to the UI so the user is + // prompted to update. + cloudProvider.on("stale-client-version", () => { + if (!isMountedRef.current) return; + console.warn("[ProjectYjs] Server rejected this client as stale"); + setIsStaleClient(true); + }); + // Poll for synced status const checkSynced = () => { if (!isMountedRef.current) return; @@ -679,6 +496,7 @@ export const useCloudSync = ( isLockedByServer, isSessionReplaced, isProjectUnavailable, + isStaleClient, }; }; @@ -725,6 +543,7 @@ export const useProjectYjs = ({ isLockedByServer, isSessionReplaced, isProjectUnavailable, + isStaleClient, } = useCloudSync(projectId, isLocalReady ? ydoc : null, userInfo); // isReady: project is ready when ydoc exists and local storage is synced @@ -745,6 +564,7 @@ export const useProjectYjs = ({ isLockedByServer, isSessionReplaced, isProjectUnavailable, + isStaleClient, migrationOutcome, }; }; diff --git a/src/lib/utils/api-utils.ts b/src/lib/utils/api-utils.ts index efa88cdb..8fede38c 100644 --- a/src/lib/utils/api-utils.ts +++ b/src/lib/utils/api-utils.ts @@ -92,7 +92,6 @@ export function validate(schema: z.ZodSchema, data: unknown): T { * Useful for calling REST endpoints on the collaboration Worker from the Next.js server. */ export function getCollabHttpUrl(path: string): string { - const baseUrl = process.env.NEXT_PUBLIC_COLLAB_WEBSOCKET_URL || ""; - const httpUrl = baseUrl.replace(/^ws/, "http"); - return `${httpUrl}${path}`; + const baseUrl = process.env.NEXT_PUBLIC_CLOUD_URL || ""; + return `${baseUrl}${path}`; } diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index e066a590..c14b8f7e 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -150,6 +150,11 @@ export const submitApplePurchase = async (jwsTransaction: string): Promise => { + const res = await request("/api/apple/transfer-subscription", "POST", { jwsTransaction }); + return res.ok; +}; + export const getAppleSubscriptionOwner = async (jwsTransaction: string): Promise => { const res = await request("/api/apple/subscription-owner", "POST", { jwsTransaction }); if (!res.ok) return null; diff --git a/src/server/repository/transaction-repository.ts b/src/server/repository/transaction-repository.ts index 14c56e32..8dc7c005 100644 --- a/src/server/repository/transaction-repository.ts +++ b/src/server/repository/transaction-repository.ts @@ -39,4 +39,11 @@ export class TransactionRepository { create: { userId, provider, transactionId }, }); } + + reassignToUser(transactionId: string, newUserId: string) { + return prisma.transaction.update({ + where: { transactionId }, + data: { userId: newUserId }, + }); + } } diff --git a/src/server/service/transaction-service.ts b/src/server/service/transaction-service.ts index 9abb44b6..79db5f28 100644 --- a/src/server/service/transaction-service.ts +++ b/src/server/service/transaction-service.ts @@ -40,3 +40,8 @@ export const createTransactionIfNotExists = async ( logger.debug("[TransactionService] Transaction upserted", { id: result.id, transactionId }); return result; }; + +export const reassignTransactionToUser = async (transactionId: string, newUserId: string) => { + logger.debug("[TransactionService] Reassigning transaction", { transactionId, newUserId }); + return repository.reassignToUser(transactionId, newUserId); +}; diff --git a/src/tests/migrations/project-migration-runner.test.ts b/src/tests/migrations/project-migration-runner.test.ts index cf5bb410..b01c92bd 100644 --- a/src/tests/migrations/project-migration-runner.test.ts +++ b/src/tests/migrations/project-migration-runner.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import * as Y from "yjs"; import { ProjectState } from "@src/lib/project/project-state"; -import { migrateProjectDoc } from "@src/lib/project/migrations/project-migration-runner"; +import { + migrateProjectDoc, + migrateProjectDocCore, + readProjectDocVersion, +} from "@src/lib/project/migrations/project-migration-runner"; import type { ProjectMigration } from "@src/lib/project/migrations/project-migrations"; interface FakeBackupStore { @@ -20,6 +24,7 @@ function makeBackupStore(): FakeBackupStore { }; } + describe("migrateProjectDoc", () => { it("returns up-to-date and writes nothing when version equals current", async () => { const ydoc = new ProjectState(); @@ -225,3 +230,154 @@ describe("migrateProjectDoc", () => { ydoc.destroy(); }); }); + +describe("migrateProjectDocCore", () => { + /** Idempotent migration that only sets keys (the contract from project-migrations.ts). */ + const setAuthor: ProjectMigration = { + from: 1, + to: 2, + description: "set-author", + run: (doc) => doc.metadata().set("author", "v2-author"), + }; + + it("runs without a backup store (DO-style invocation)", async () => { + const ydoc = new ProjectState(); + const outcome = await migrateProjectDocCore({ + ydoc, + migrations: [setAuthor], + currentVersion: 2, + }); + expect(outcome.kind).toBe("migrated"); + expect(ydoc.metadata().get("version")).toBe(2); + expect(ydoc.metadata().get("author")).toBe("v2-author"); + ydoc.destroy(); + }); + + it("invokes onBeforeMutate exactly once with the pre-mutation snapshot", async () => { + const ydoc = new ProjectState(); + ydoc.metadata().set("title", "T"); + const beforeBytes = Y.encodeStateAsUpdate(ydoc); + + const calls: Array<{ snapshot: Uint8Array; fromVersion: number }> = []; + await migrateProjectDocCore({ + ydoc, + migrations: [setAuthor], + currentVersion: 2, + onBeforeMutate: async (snapshot, fromVersion) => { + calls.push({ snapshot, fromVersion }); + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0].fromVersion).toBe(1); + expect(calls[0].snapshot).toEqual(beforeBytes); + ydoc.destroy(); + }); + + it("does NOT call onBeforeMutate when there's nothing to do (no steps, version-only bump)", async () => { + const ydoc = new ProjectState(); + // No registered migrations — the runner just brings the version field forward. + let called = false; + await migrateProjectDocCore({ + ydoc, + migrations: [], + currentVersion: 5, + onBeforeMutate: async () => { + called = true; + }, + }); + expect(called).toBe(false); + expect(ydoc.metadata().get("version")).toBe(5); + ydoc.destroy(); + }); +}); + +describe("multi-source migration convergence", () => { + /** + * Simulates the gatekeeper-on-both-sides architecture: the DurableObject + * migrates its in-memory doc; the client also migrates its local cache; + * they exchange Y.js updates and must converge to the same shape. + */ + it("client and server independently migrate then sync to identical state", async () => { + const migrations: ProjectMigration[] = [ + { + from: 1, + to: 2, + description: "set-author", + run: (doc) => doc.metadata().set("author", "post-migration"), + }, + ]; + + // Server side: starts at v1 (legacy), runs migration. + const server = new ProjectState(); + server.metadata().set("title", "shared-title"); + await migrateProjectDocCore({ ydoc: server, migrations, currentVersion: 2 }); + + // Client side: cold cache (empty), runs migration on empty doc, then + // applies the server's update (the race we're closing). + const client = new ProjectState(); + await migrateProjectDocCore({ ydoc: client, migrations, currentVersion: 2 }); + Y.applyUpdate(client, Y.encodeStateAsUpdate(server)); + + // Both must converge to v2 with author + title set. + expect(client.metadata().get("version")).toBe(2); + expect(client.metadata().get("author")).toBe("post-migration"); + expect(client.metadata().get("title")).toBe("shared-title"); + + // Reverse direction also converges — round-trip the client's state to server. + Y.applyUpdate(server, Y.encodeStateAsUpdate(client)); + expect(server.metadata().get("version")).toBe(2); + expect(server.metadata().get("author")).toBe("post-migration"); + server.destroy(); + client.destroy(); + }); + + it("re-migrates when an old-version update arrives after migration", async () => { + const migrations: ProjectMigration[] = [ + { + from: 1, + to: 2, + description: "init-author", + // Idempotent: only sets if missing. + run: (doc) => { + if (!doc.metadata().has("author")) doc.metadata().set("author", "default"); + }, + }, + ]; + + // Doc A starts at v1 with content, never migrated. + const oldDoc = new ProjectState(); + oldDoc.metadata().set("title", "from-old-client"); + const oldUpdate = Y.encodeStateAsUpdate(oldDoc); + + // Doc B migrates fresh. + const migrated = new ProjectState(); + await migrateProjectDocCore({ ydoc: migrated, migrations, currentVersion: 2 }); + // Now an old-version update arrives (e.g., from a client that bypassed the gate). + Y.applyUpdate(migrated, oldUpdate); + + // The doc still has version=2 (LWW with a newer clock) and the title. + expect(migrated.metadata().get("version")).toBe(2); + expect(migrated.metadata().get("title")).toBe("from-old-client"); + // Re-running migration is a no-op (already at v2). + const second = await migrateProjectDocCore({ ydoc: migrated, migrations, currentVersion: 2 }); + expect(second.kind).toBe("up-to-date"); + oldDoc.destroy(); + migrated.destroy(); + }); +}); + +describe("readProjectDocVersion", () => { + it("returns 1 when no version field is set", () => { + const ydoc = new Y.Doc(); + expect(readProjectDocVersion(ydoc)).toBe(1); + ydoc.destroy(); + }); + + it("returns the stored version when set", () => { + const ydoc = new Y.Doc(); + ydoc.getMap("metadata").set("version", 7); + expect(readProjectDocVersion(ydoc)).toBe(7); + ydoc.destroy(); + }); +}); From 5c5091d9973722cbcc7090146ef299d06acc5016 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 May 2026 03:44:55 +0200 Subject: [PATCH 41/76] added missing ci file --- .github/workflows/deploy-staging.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index 5143daae..ff75f74a 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -71,7 +71,7 @@ jobs: env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://staging.scriptio.app - NEXT_PUBLIC_CLOUD_URL: wss://cloud.staging.scriptio.app + NEXT_PUBLIC_CLOUD_URL: https://cloud.staging.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -115,7 +115,7 @@ jobs: run: npm run debug:windows env: NEXT_PUBLIC_API_URL: https://staging.scriptio.app - NEXT_PUBLIC_CLOUD_URL: wss://cloud.staging.scriptio.app + NEXT_PUBLIC_CLOUD_URL: https://cloud.staging.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -185,7 +185,7 @@ jobs: ${{ env.IMAGE_NAME }}:staging-${{ needs.prepare.outputs.version }} build-args: | NEXT_PUBLIC_API_URL=https://staging.scriptio.app - NEXT_PUBLIC_CLOUD_URL=wss://cloud.staging.scriptio.app + NEXT_PUBLIC_CLOUD_URL=https://cloud.staging.scriptio.app NEXT_PUBLIC_COMMIT_SHA=${{ env.COMMIT_SHA }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.version }} From 50a573fdfba6fd3c1a30f94bda4f5af65eb188d5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 May 2026 03:45:20 +0200 Subject: [PATCH 42/76] added missing ci file --- .github/workflows/deploy-release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-release.yaml b/.github/workflows/deploy-release.yaml index 8046662c..d0be15ad 100644 --- a/.github/workflows/deploy-release.yaml +++ b/.github/workflows/deploy-release.yaml @@ -71,7 +71,7 @@ jobs: env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://scriptio.app - NEXT_PUBLIC_CLOUD_URL: wss://cloud.scriptio.app + NEXT_PUBLIC_CLOUD_URL: https://cloud.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -114,7 +114,7 @@ jobs: run: npm run build:windows env: NEXT_PUBLIC_API_URL: https://scriptio.app - NEXT_PUBLIC_CLOUD_URL: wss://cloud.scriptio.app + NEXT_PUBLIC_CLOUD_URL: https://cloud.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} @@ -184,7 +184,7 @@ jobs: ${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }} build-args: | NEXT_PUBLIC_API_URL=https://scriptio.app - NEXT_PUBLIC_CLOUD_URL=wss://cloud.scriptio.app + NEXT_PUBLIC_CLOUD_URL=https://cloud.scriptio.app NEXT_PUBLIC_COMMIT_SHA=${{ env.COMMIT_SHA }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.version }} From 73fde53c903f4fab3ac016c8a66567879513f53e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 May 2026 04:04:05 +0200 Subject: [PATCH 43/76] updated Dockerfile for websocket cloud URL --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index c99ded3e..507afe3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,12 @@ COPY ./ ./ ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" ARG NEXT_PUBLIC_API_URL=https://scriptio.app +ARG NEXT_PUBLIC_CLOUD_URL=https://cloud.scriptio.app ARG NEXT_PUBLIC_COMMIT_SHA ARG NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_CLOUD_URL=$NEXT_PUBLIC_CLOUD_URL ENV NEXT_PUBLIC_COMMIT_SHA=$NEXT_PUBLIC_COMMIT_SHA ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION From 5dacb6751ba560fae77d2ab610bcdf57c27a43b3 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Sun, 3 May 2026 16:02:40 +0200 Subject: [PATCH 44/76] fixing cloud sync issues --- .../projects/[projectId]/cloud-token/route.ts | 2 +- src/lib/cloud/room.ts | 42 ++++---- src/lib/cloud/utils.ts | 98 ++++++++++--------- src/lib/project/project-state.ts | 25 +++-- 4 files changed, 94 insertions(+), 73 deletions(-) diff --git a/src/app/api/projects/[projectId]/cloud-token/route.ts b/src/app/api/projects/[projectId]/cloud-token/route.ts index f78032e7..6b940aa0 100644 --- a/src/app/api/projects/[projectId]/cloud-token/route.ts +++ b/src/app/api/projects/[projectId]/cloud-token/route.ts @@ -27,7 +27,7 @@ async function projectCloudTokenRoute(req: NextRequest, { routeParams, user }: A const secret = new TextEncoder().encode(process.env.JWT_SECRET!); const token = await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("1m") + .setExpirationTime("1h") .sign(secret); return Success(token); diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index ac971fc0..e8e58307 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -18,10 +18,7 @@ import { } from "./types"; import { handleProtocolMessage } from "./protocol"; import { ProjectState } from "../project/project-doc"; -import { - migrateProjectDocCore, - readProjectDocVersion, -} from "../project/migrations/project-migration-runner"; +import { migrateProjectDocCore, readProjectDocVersion } from "../project/migrations/project-migration-runner"; import { CURRENT_PROJECT_VERSION } from "../project/migrations/project-migrations"; export class ProjectRoom extends DurableObject { @@ -31,7 +28,6 @@ export class ProjectRoom extends DurableObject { sessions: Map; userConnections: Map; blacklist: Set; - cleanupInterval: ReturnType | null = null; private isDirty: boolean = false; private alarmScheduled: boolean = false; @@ -50,7 +46,10 @@ export class ProjectRoom extends DurableObject { // (esbuild does not guarantee class-field arrow functions are initialized // before the constructor body runs.) private handleDocUpdate!: (update: Uint8Array, origin: unknown) => void; - private handleAwarenessUpdate!: (changes: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => void; + private handleAwarenessUpdate!: ( + changes: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => void; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -67,7 +66,10 @@ export class ProjectRoom extends DurableObject { this.markDirty(); }; - this.handleAwarenessUpdate = ({ added }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown): void => { + this.handleAwarenessUpdate = ( + { added }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ): void => { if (origin instanceof WebSocket) { const session = this.sessions.get(origin); if (session) { @@ -179,8 +181,7 @@ export class ProjectRoom extends DurableObject { this.docMigrationFailed = true; this.docVersion = outcome.from; console.error( - `[Room] Doc migration failed at step v${outcome.failedAt} ` + - `(stored v${outcome.from}):`, + `[Room] Doc migration failed at step v${outcome.failedAt} ` + `(stored v${outcome.from}):`, outcome.error, ); break; @@ -335,12 +336,16 @@ export class ProjectRoom extends DurableObject { } /** - * Start periodic cleanup of stale awareness states + * Throttled inline cleanup. Called from message/connect paths instead of + * setInterval — a live timer would prevent the DO from hibernating, which + * keeps it billed continuously. With this approach the DO only does + * cleanup work when traffic is already arriving. */ - private startAwarenessCleanup(): void { - this.cleanupInterval = setInterval(() => { - this.cleanupStaleAwareness(); - }, AWARENESS_CLEANUP_INTERVAL_MS); + maybeCleanupStaleAwareness(): void { + const now = Date.now(); + if (now - this.lastAwarenessCleanup < AWARENESS_CLEANUP_INTERVAL_MS) return; + this.lastAwarenessCleanup = now; + this.cleanupStaleAwareness(); } /** @@ -572,9 +577,7 @@ export class ProjectRoom extends DurableObject { const clientVersionParam = url.searchParams.get("clientVersion"); const clientVersion = clientVersionParam !== null ? Number(clientVersionParam) : NaN; if (Number.isFinite(clientVersion) && clientVersion < this.docVersion) { - console.log( - `[Room] Rejecting stale client v${clientVersion} (doc at v${this.docVersion})`, - ); + console.log(`[Room] Rejecting stale client v${clientVersion} (doc at v${this.docVersion})`); try { server.close(4006, `Stale client: update to access v${this.docVersion}`); } catch {} @@ -611,6 +614,10 @@ export class ProjectRoom extends DurableObject { console.log(`[Room] User ${userId} connected. Total sessions: ${this.sessions.size}`); + // Opportunistic cleanup on connect — a new client arriving is the + // best moment to drop awareness for clients that quietly went away. + this.maybeCleanupStaleAwareness(); + // Request all existing clients to re-broadcast their awareness // This ensures the new client receives everyone's current state, // especially important after a DurableObject restart where @@ -802,6 +809,7 @@ export class ProjectRoom extends DurableObject { if (fullMessage.length === 0) return; handleProtocolMessage(this, fullMessage, ws); + this.maybeCleanupStaleAwareness(); } scheduleSave(): void { diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts index 5de26ce2..3151f08a 100644 --- a/src/lib/cloud/utils.ts +++ b/src/lib/cloud/utils.ts @@ -8,7 +8,6 @@ import * as syncProtocol from "y-protocols/sync"; import * as awarenessProtocol from "y-protocols/awareness"; import { CURRENT_PROJECT_VERSION } from "../project/migrations/project-migrations"; - declare const window: Window & typeof globalThis; /** @@ -28,7 +27,10 @@ type WebsocketProviderOptions = { type WSInternals = { _updateHandler: (update: Uint8Array, origin: unknown) => void; - _awarenessUpdateHandler: (changes: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => void; + _awarenessUpdateHandler: ( + changes: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown, + ) => void; messageHandlers: Array<(encoder: encoding.Encoder, ...rest: unknown[]) => void>; ws: WebSocket | null; bcconnected: boolean; @@ -63,6 +65,11 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { private lastMessageTime: number = 0; // Milliseconds (Date.now()) private userIdleTimer: ReturnType | null = null; + // Cap the queue so a long disconnect can't grow it without bound. The + // local Yjs doc is the source of truth — on reconnect, syncStep1/2 will + // re-converge state regardless of what's in the queue. + private readonly MAX_UPDATE_QUEUE = 1000; + // Throttling configuration (all in milliseconds) private readonly SOLO_USER_UPDATE_MS = 1000; // 1s when alone private readonly MULTI_USER_UPDATE_MS = 200; // 200ms with others @@ -113,14 +120,9 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { ...(options.params ?? {}), clientVersion: String(CURRENT_PROJECT_VERSION), }; - super( - serverUrl, - room, - doc, - { ...options, params, connect: false } as unknown as ConstructorParameters< - typeof WebsocketProvider - >[3], - ); + super(serverUrl, room, doc, { ...options, params, connect: false } as unknown as ConstructorParameters< + typeof WebsocketProvider + >[3]); this.localClientId = doc.clientID; this.boundResetIdleTimer = this.resetUserIdleTimer.bind(this); @@ -143,9 +145,7 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { // Handle awareness query (message type 3 = messageQueryAwareness) // When the server requests awareness, immediately send our current state - (this as unknown as WSInternals).messageHandlers[3] = ( - encoder: encoding.Encoder, - ) => { + (this as unknown as WSInternals).messageHandlers[3] = (encoder: encoding.Encoder) => { this.lastMessageTime = Date.now(); // Write awareness update to the encoder (y-websocket will send it) encoding.writeVarUint(encoder, 1); // messageAwareness @@ -256,20 +256,14 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { console.log("[WS] Session was replaced by another connection. Stopping reconnection."); this.isSessionReplaced = true; - // Cancel any pending reconnects from our custom logic if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } - // Tell y-websocket to not reconnect this.shouldConnect = false; - - // Disconnect properly to clean up this.disconnect(); - - // Emit custom event for UI to handle - //this.emit("session-replaced", []); + this.emit("session-replaced", []); } /** @@ -587,11 +581,22 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { /** * Reconnect after wake (visibility restored or network online). - * Only acts if not connected, not idle-disconnected (that case is handled - * by resetUserIdleTimer on next user activity), and not session-replaced. + * + * Visibility/online return is a strong "user is back" signal — strong + * enough that we treat it the same as user input and reset the idle + * state. Previously we bailed out for idle-disconnected sessions, which + * left the user offline (no presence, no incoming updates) until they + * happened to type or move the mouse. */ private handleWakeUp(source: string): void { - if (this.isDestroyed || this.isSessionReplaced || this.isIdleDisconnected) return; + if (this.isDestroyed || this.isSessionReplaced) return; + + // resetUserIdleTimer clears isIdleDisconnected and triggers a + // reconnect when we were idle, so it covers that path. + const wasIdleDisconnected = this.isIdleDisconnected; + this.resetUserIdleTimer(); + if (wasIdleDisconnected) return; + if (!this.wsconnected) { console.log(`[WS] Reconnecting after wake (${source})...`); this.reconnectAttempts = 0; @@ -650,6 +655,11 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { private onThrottledUpdate = (update: Uint8Array, origin: unknown): void => { if (origin !== this) { this.updateQueue.push(update); + // Cap queue length on long disconnects. The local doc still holds + // every change; sync on reconnect will replay them. + if (this.updateQueue.length > this.MAX_UPDATE_QUEUE) { + this.updateQueue.splice(0, this.updateQueue.length - this.MAX_UPDATE_QUEUE); + } } }; @@ -714,18 +724,24 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { } /** - * Flush all pending updates to the server and BroadcastChannel + * Flush all pending updates to the server and BroadcastChannel. + * + * Queues are only cleared if at least one transport (WS or BC) actually + * delivered the message. Otherwise the entries stay queued for the next + * flush — important when the WS is mid-reconnect: the previous code + * dropped the queue regardless, relying on Yjs re-sync to recover. */ public flush(): void { const ws = this.ws; - const isWsConnected = this.wsconnected && ws && ws.readyState === 1; + const internals = this as unknown as WSInternals; + const isWsConnected = this.wsconnected && !!ws && ws.readyState === 1; + const isBcConnected = internals.bcconnected; + const canDeliver = isWsConnected || isBcConnected; // Send document updates - if (this.updateQueue.length > 0) { + if (this.updateQueue.length > 0 && canDeliver) { try { const updates = this.updateQueue; - this.updateQueue = []; - const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, 0); // sync message type for (const update of updates) { @@ -736,21 +752,19 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { if (isWsConnected) { ws.send(message); } - - if ((this as unknown as WSInternals).bcconnected) { - bc.publish((this as unknown as WSInternals).bcChannel, message, this); + if (isBcConnected) { + bc.publish(internals.bcChannel, message, this); } + this.updateQueue = []; } catch (e) { console.error("[WS] Failed to send document updates:", e); } } // Send awareness updates - if (this.awarenessQueue.size > 0) { + if (this.awarenessQueue.size > 0 && canDeliver) { try { const changedClients = Array.from(this.awarenessQueue); - this.awarenessQueue.clear(); - const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, 1); // awareness message type encoding.writeVarUint8Array( @@ -762,10 +776,10 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { if (isWsConnected) { ws.send(message); } - - if ((this as unknown as WSInternals).bcconnected) { - bc.publish((this as unknown as WSInternals).bcChannel, message, this); + if (isBcConnected) { + bc.publish(internals.bcChannel, message, this); } + this.awarenessQueue.clear(); } catch (e) { console.error("[WS] Failed to send awareness updates:", e); } @@ -855,10 +869,7 @@ export const allowOnWebsocket = async (userId: string, projectId: string) => { }; const secret = new TextEncoder().encode(process.env.JWT_SECRET!); - const token = await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("1m") - .sign(secret); + const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1m").sign(secret); await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/allow`, { method: "POST", headers: { @@ -876,10 +887,7 @@ export const blacklistFromWebsocket = async (userId: string, projectId: string) }; const secret = new TextEncoder().encode(process.env.JWT_SECRET!); - const token = await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("1m") - .sign(secret); + const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1m").sign(secret); await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/blacklist`, { method: "POST", headers: { diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 6f5bc809..9563db0b 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -234,7 +234,7 @@ export const useCloudSync = ( const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [isCloudSynced, setIsCloudSynced] = useState(false); const [isLockedByServer] = useState(false); - const [isSessionReplaced] = useState(false); + const [isSessionReplaced, setIsSessionReplaced] = useState(false); const [isProjectUnavailable, setIsProjectUnavailable] = useState(false); const [isStaleClient, setIsStaleClient] = useState(false); @@ -274,6 +274,7 @@ export const useCloudSync = ( useEffect(() => { isMountedRef.current = true; setIsProjectUnavailable(false); + setIsSessionReplaced(false); if (!ydoc || !projectId || typeof window === "undefined") { setConnectionStatus("disconnected"); @@ -375,15 +376,10 @@ export const useCloudSync = ( // Status updates cloudProvider.on("status", (e: { status: string }) => { - if (isMountedRef.current) { - setTimeout(() => { - if (isMountedRef.current) { - setConnectionStatus(e.status as ConnectionStatus); - if (e.status === "connected" && cloudProvider.synced) { - setIsCloudSynced(true); - } - } - }, 0); + if (!isMountedRef.current) return; + setConnectionStatus(e.status as ConnectionStatus); + if (e.status === "connected" && cloudProvider.synced) { + setIsCloudSynced(true); } }); @@ -394,6 +390,15 @@ export const useCloudSync = ( } }); + // Surface session-replaced state to the UI so the connection + // indicator and recovery dialogs reflect the terminal state + // (the provider stops reconnecting after this fires). + cloudProvider.on("session-replaced", () => { + if (!isMountedRef.current) return; + setIsSessionReplaced(true); + setConnectionStatus("disconnected"); + }); + // Handle document restore cloudProvider.on("document-restored", async () => { if (!isMountedRef.current) return; From 6f4ce9e0835e2ca083bdea453a0c0feeb4fea240 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Wed, 6 May 2026 16:40:17 +0200 Subject: [PATCH 45/76] fixed cloud sync issues after idle, fixed wrangler.toml for dev environment --- src/lib/cloud/room.ts | 75 ++++++++++++++++++++++++++++++++----- src/lib/cloud/utils.ts | 17 ++++++--- src/lib/cloud/wrangler.toml | 11 ++++++ 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index e8e58307..9af67d3b 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -32,6 +32,7 @@ export class ProjectRoom extends DurableObject { private isDirty: boolean = false; private alarmScheduled: boolean = false; private projectId: string | null = null; + private lastAwarenessCleanup: number = 0; /** Project schema version of the in-memory doc; the gatekeeper compares * client-advertised versions against this on connect. */ @@ -67,14 +68,23 @@ export class ProjectRoom extends DurableObject { }; this.handleAwarenessUpdate = ( - { added }: { added: number[]; updated: number[]; removed: number[] }, + { added, updated }: { added: number[]; updated: number[]; removed: number[] }, origin: unknown, ): void => { if (origin instanceof WebSocket) { const session = this.sessions.get(origin); if (session) { - added.forEach((id: number) => session.clientIds.add(id)); + let changed = false; + const toAdd = [...added, ...updated]; + toAdd.forEach((id: number) => { + if (!session.clientIds.has(id)) { + session.clientIds.add(id); + changed = true; + } + }); session.lastActivity = Date.now(); + // Persist updated clientIds so they survive DO hibernation. + if (changed) this.persistSessionAttachment(origin); } } }; @@ -91,10 +101,6 @@ export class ProjectRoom extends DurableObject { this.userConnections = new Map(); this.blacklist = new Set(); - // Listen for document updates and handle broadcasting + persistence. - // This is the source of truth for ALL changes to the document. - this.doc.on("update", this.handleDocUpdate); - // Track client IDs when awareness updates come from a WebSocket this.awareness.on("update", this.handleAwarenessUpdate); @@ -113,7 +119,10 @@ export class ProjectRoom extends DurableObject { ); `); - // Restore project state + // Restore project state from SQLite. Attach the update handler AFTER + // the restore so that re-loading persisted bytes on every DO wake-up + // doesn't trigger scheduleSave / markDirty (which would save identical + // bytes and schedule an unnecessary R2 snapshot). const cursor = this.ctx.storage.sql.exec("SELECT data FROM project WHERE id = 1;"); for (const row of cursor) { if (row.data) { @@ -121,6 +130,11 @@ export class ProjectRoom extends DurableObject { } } + // Listen for document updates and handle broadcasting + persistence. + // Attached here (after restore) so only live writes from WS clients + // and server-side migrations trigger the save pipeline. + this.doc.on("update", this.handleDocUpdate); + // Restore blacklist const blacklistRows = this.ctx.storage.sql.exec("SELECT user_id FROM blacklist;").toArray(); for (const row of blacklistRows) { @@ -141,12 +155,52 @@ export class ProjectRoom extends DurableObject { this.observeDocVersion(); }); - // Start periodic stale awareness cleanup - this.startAwarenessCleanup(); + // Restore sessions from hibernated WebSockets. Cloudflare DOs can + // hibernate to save memory while WebSockets stay connected; on the + // next message the constructor runs again with empty maps. Without + // this restoration, incoming messages have no session to attach to, + // session activity tracking breaks, webSocketClose finds nothing to + // clean up, and broadcastAwarenessRequest counts wrongly. + const hibernatedSockets = this.ctx.getWebSockets(); + for (const ws of hibernatedSockets) { + const attachment = ws.deserializeAttachment() as + | { userId: string; clientIds: number[] } + | null; + if (!attachment) continue; + this.sessions.set(ws, { + clientIds: new Set(attachment.clientIds), + userId: attachment.userId, + lastActivity: Date.now(), + }); + this.userConnections.set(attachment.userId, ws); + } + if (hibernatedSockets.length > 0) { + console.log( + `[Room] Restored ${this.sessions.size} session(s) from ${hibernatedSockets.length} hibernated WebSocket(s)`, + ); + // Awareness state was lost when the DO hibernated. Ask all + // restored clients to re-broadcast their awareness so we can + // rebuild room.awareness from scratch. + this.broadcastAwarenessRequest(); + } console.log("[Room] Initialized"); } + /** + * Persist the current session state on the WebSocket so it survives + * Cloudflare DO hibernation. Called whenever clientIds or userId changes + * for a session. + */ + private persistSessionAttachment(ws: WebSocket): void { + const session = this.sessions.get(ws); + if (!session) return; + ws.serializeAttachment({ + userId: session.userId, + clientIds: Array.from(session.clientIds), + }); + } + /** * Apply pending project-doc migrations to the in-memory doc and persist * the result. Idempotent: a no-op when the doc is already at @@ -591,6 +645,9 @@ export class ProjectRoom extends DurableObject { lastActivity: Date.now(), }); this.userConnections.set(userId, server); + // Persist immediately so a hibernation-wake before the first + // awareness message can still identify this socket. + this.persistSessionAttachment(server); // Send current document state (sync step 1) using the same encoder // pattern as all other outgoing messages — avoids fragile manual byte prepend. diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts index 3151f08a..c691399e 100644 --- a/src/lib/cloud/utils.ts +++ b/src/lib/cloud/utils.ts @@ -74,7 +74,7 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { private readonly SOLO_USER_UPDATE_MS = 1000; // 1s when alone private readonly MULTI_USER_UPDATE_MS = 200; // 200ms with others private readonly MAX_SILENCE_DURATION_MS = 20000; // 20s max silence before ping - private readonly MAX_IDLE_DURATION_MS = 10 * 60 * 1000; // 10 minutes idle timeout + private readonly MAX_IDLE_DURATION_MS = 30 * 1000; // 30 seconds idle timeout private readonly FLUSH_CHECK_INTERVAL_MS = 100; // Check flush every 100ms private readonly ACTIVITY_EVENTS = ["mousedown", "mousemove", "keydown", "touchstart", "scroll"]; @@ -134,7 +134,8 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { // Store and set user info BEFORE connecting so awareness is correct from the start if (options.userInfo) { this.userInfo = options.userInfo; - this.awareness.setLocalStateField("user", options.userInfo); + const currentState = this.awareness.getLocalState() || {}; + this.awareness.setLocalState({ ...currentState, user: options.userInfo }); } // Replace default handlers with throttled versions @@ -190,8 +191,10 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { // Restore user info if it was lost during reconnection // y-websocket may clear awareness state internally during reconnect - if (this.userInfo && !this.awareness.getLocalState()?.user) { - this.awareness.setLocalStateField("user", this.userInfo); + const localState = this.awareness.getLocalState(); + if (this.userInfo && !localState?.user) { + const currentState = localState || {}; + this.awareness.setLocalState({ ...currentState, user: this.userInfo }); } // Queue and send our awareness update @@ -341,7 +344,8 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { */ public setUserInfo(userInfo: { name: string; color: string; userId?: string }): void { this.userInfo = userInfo; - this.awareness.setLocalStateField("user", userInfo); + const currentState = this.awareness.getLocalState() || {}; + this.awareness.setLocalState({ ...currentState, user: userInfo }); } /** @@ -409,7 +413,8 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { // Restore user info after cleanup (it will also be restored in onStatusChange) if (this.userInfo) { - this.awareness.setLocalStateField("user", this.userInfo); + const currentState = this.awareness.getLocalState() || {}; + this.awareness.setLocalState({ ...currentState, user: this.userInfo }); } // Clear any pending reconnect diff --git a/src/lib/cloud/wrangler.toml b/src/lib/cloud/wrangler.toml index 7b1c9985..37e7f2db 100644 --- a/src/lib/cloud/wrangler.toml +++ b/src/lib/cloud/wrangler.toml @@ -9,6 +9,17 @@ compatibility_flags = ["nodejs_compat"] tag = "v1" new_sqlite_classes = ["ProjectRoom"] +# --- Local dev (default env, wrangler dev with no --env flag) --- +# Wrangler simulates DOs and R2 locally when bindings are declared here. + +[[durable_objects.bindings]] +name = "PROJECT_ROOM" +class_name = "ProjectRoom" + +[[r2_buckets]] +binding = "SNAPSHOTS" +bucket_name = "scriptio-snapshots-local" + # --- Production --- [env.production] name = "scriptio-cloud" From 3c6ca03cabab7ced313cadc0028548251e9ec9bf Mon Sep 17 00:00:00 2001 From: Lycoon Date: Thu, 7 May 2026 11:33:03 +0200 Subject: [PATCH 46/76] fixed compilation --- src/lib/cloud/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts index c691399e..d9c5adc2 100644 --- a/src/lib/cloud/utils.ts +++ b/src/lib/cloud/utils.ts @@ -40,6 +40,7 @@ type WSInternals = { export class ThrottledWebsocketProvider extends WebsocketProvider { on(event: "document-restored", listener: () => void): this; on(event: "stale-client-version", listener: () => void): this; + on(event: "session-replaced", listener: () => void): this; on(event: Parameters[0], listener: Parameters[1]): this; on(event: string, listener: (...args: unknown[]) => void): this { return super.on( @@ -50,6 +51,7 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { emit(event: "document-restored", args: []): this; emit(event: "stale-client-version", args: []): this; + emit(event: "session-replaced", args: []): this; emit(event: Parameters[0], args: Parameters[1]): this; emit(event: string, args: unknown[]): this { super.emit( From f006765808735dd5d374caba25e73ba5c4089dca Mon Sep 17 00:00:00 2001 From: Lycoon Date: Sat, 9 May 2026 03:44:55 +0200 Subject: [PATCH 47/76] refactoring of project state, cloud logic, configured viewer role limitations --- components/board/BoardCanvas.tsx | 24 +- .../project/CollaboratorsSettings.tsx | 25 +- components/editor/DocumentEditorPanel.tsx | 40 + components/editor/EditorPanel.module.css | 59 +- components/editor/sidebar/ContextMenu.tsx | 95 ++- .../navbar/ScreenplayFormatDropdown.tsx | 6 +- components/projects/ProjectItem.module.css | 17 +- .../projects/ProjectPageContainer.module.css | 4 +- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/ja.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- messages/zh.json | 4 +- src/app/api/auth/magic-link/verify/route.ts | 9 + .../[projectId]/members/[userId]/route.ts | 11 +- src/app/api/projects/accept-invite/route.ts | 11 + src/app/projects/layout.tsx | 48 +- src/context/ProjectContext.tsx | 55 +- src/lib/cloud/index.ts | 9 +- src/lib/cloud/protocol.ts | 43 +- src/lib/cloud/room.ts | 46 +- src/lib/cloud/types.ts | 2 + src/lib/cloud/utils.ts | 120 ++- src/lib/import/import-project.ts | 5 + .../storage-provider/local-persistence.ts | 26 +- .../migrations/project-migration-runner.ts | 6 +- src/lib/project/project-doc.ts | 18 +- src/lib/project/project-repository.ts | 37 + src/lib/project/project-state.ts | 744 +++++++++--------- src/lib/screenplay/characters.ts | 45 +- src/lib/screenplay/locations.ts | 15 +- 34 files changed, 967 insertions(+), 585 deletions(-) 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 */}
- + + {entry.versions.length}
{isExpanded && (
@@ -185,10 +175,7 @@ const ShelfSidebarItem = memo(({ nodeId, entry, isExpanded, onToggle }: ShelfSid onClick={(e) => e.stopPropagation()} /> ) : isConfirmingRestore ? ( -
e.stopPropagation()} - > +
e.stopPropagation()}> {t("confirmRestore")}
diff --git a/components/editor/sidebar/ShelfSidebarView.tsx b/components/editor/sidebar/ShelfSidebarView.tsx index 6aaa621e..17463ced 100644 --- a/components/editor/sidebar/ShelfSidebarView.tsx +++ b/components/editor/sidebar/ShelfSidebarView.tsx @@ -24,15 +24,21 @@ const ShelfSidebarView = () => {

{t("shelf")}

- {entries.map(([nodeId, entry]) => ( - setExpandedId(expandedId === nodeId ? null : nodeId)} - /> - ))} + {entries.length !== 0 ? ( + entries.map(([nodeId, entry]) => ( + setExpandedId(expandedId === nodeId ? null : nodeId)} + /> + )) + ) : ( +
+ {t("shelfEmpty")} +
+ )}
); diff --git a/messages/en.json b/messages/en.json index f6b085c3..797eb080 100644 --- a/messages/en.json +++ b/messages/en.json @@ -198,10 +198,14 @@ }, "editorSidebar": { "scenes": "Scenes", + "scenesEmpty": "No scenes yet", "characters": "Characters", "locations": "Locations", "shelf": "Shelf", - "shelfEmpty": "Select a shelved version to edit", + "shelfEmpty": "No shelved items yet", + "comments": "Comments", + "commentsEmpty": "No active comments", + "shelfEmptySelection": "Select a shelved version to edit", "goTo": "Go to in screenplay", "confirmRestore": "This will replace the matching content in your screenplay.", "restore": "Restore", diff --git a/src/lib/screenplay/extensions/placeholder-extension.ts b/src/lib/screenplay/extensions/placeholder-extension.ts index bac529a5..b10f7381 100644 --- a/src/lib/screenplay/extensions/placeholder-extension.ts +++ b/src/lib/screenplay/extensions/placeholder-extension.ts @@ -70,7 +70,7 @@ export const Placeholder = Extension.create({ emptyEditorClass: 'is-editor-empty', emptyNodeClass: 'is-empty', placeholder: 'Write something …', - showOnlyWhenEditable: true, + showOnlyWhenEditable: false, showOnlyCurrent: false, includeChildren: true, } From d728446c5bba0d0375f98c043e9391da5d92e921 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 11 May 2026 00:17:55 +0200 Subject: [PATCH 50/76] added missing translations --- messages/de.json | 8 +- messages/es.json | 952 ++++++++++++++++++++++++----------------------- messages/fr.json | 8 +- messages/ja.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- messages/zh.json | 8 +- 7 files changed, 514 insertions(+), 486 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2af8fbfd..1471557c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -206,7 +206,11 @@ "goTo": "Im Drehbuch anzeigen", "confirmRestore": "Dies ersetzt den passenden Inhalt in Ihrem Drehbuch.", "restore": "Wiederherstellen", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "scenesEmpty": "Noch keine Szenen", + "comments": "Kommentare", + "commentsEmpty": "Keine aktiven Kommentare", + "shelfEmptySelection": "Wählen Sie eine Version aus der Ablage zur Bearbeitung aus" }, "formatDropdown": { "elements": { @@ -489,4 +493,4 @@ "monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}", "moreThanYearAgo": "Vor über einem Jahr" } -} \ No newline at end of file +} diff --git a/messages/es.json b/messages/es.json index d4bb3972..0cda76fd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1,491 +1,495 @@ { - "navbar": { - "screenplay": "Guión", - "board": "Tablero", - "titlePage": "Portada", - "synced": "Sincronizado en la nube", - "noConnection": "Sin conexión", - "reconnecting": "Reconectando...", - "localProject": "Proyecto local", - "uploadToCloud": "Subir a la nube", - "endlessScroll": "Desplazamiento infinito", - "toggleComments": "Mostrar/ocultar comentarios", - "focusMode": "Modo concentración", - "splitPanel": "Dividir panel", - "unsplitPanel": "Unir panel", - "draftEditor": "Editor de borrador", - "viewOnly": "Solo lectura", - "viewOnlyHint": "Tienes acceso de solo visualización. La edición está desactivada." + "navbar": { + "screenplay": "Guión", + "board": "Tablero", + "titlePage": "Portada", + "synced": "Sincronizado en la nube", + "noConnection": "Sin conexión", + "reconnecting": "Reconectando...", + "localProject": "Proyecto local", + "uploadToCloud": "Subir a la nube", + "endlessScroll": "Desplazamiento infinito", + "toggleComments": "Mostrar/ocultar comentarios", + "focusMode": "Modo concentración", + "splitPanel": "Dividir panel", + "unsplitPanel": "Unir panel", + "draftEditor": "Editor de borrador", + "viewOnly": "Solo lectura", + "viewOnlyHint": "Tienes acceso de solo visualización. La edición está desactivada." + }, + "common": { + "save": "Guardar cambios", + "cancel": "Cancelar", + "loading": "Cargando...", + "resetDefaults": "Restablecer predeterminados" + }, + "sidebar": { + "title": "Panel", + "logOut": "Cerrar sesión", + "auth": "Iniciar sesión", + "logOutConfirmTitle": "Cerrar sesión", + "logOutConfirmDesc": "¿Estás seguro de que quieres cerrar sesión?", + "logOutConfirmBtn": "Cerrar sesión", + "logOutCancelBtn": "Cancelar" + }, + "appearance": { + "theme": "Tema", + "themeHelp": { + "dark": "Tema acogedor y de bajo resplandor, perfecto para noctámbulos y noches de concentración.", + "light": "Tema nítido y aireado que se siente natural y cómodo durante el día.", + "latte": "Tema suave con base crema que combina calidez y legibilidad.", + "wonka": "Tema aterciopelado con base de cacao que combina lujo profundo y descanso visual.", + "mint": "Tema refrescante con esencia de menta que combina serenidad botánica y equilibrio visual.", + "blossom": "Tema delicado con esencia floral que combina calidez de pétalos y suavidad visual.", + "midnight": "Tema azul medianoche profundo, diseñado para el trabajo concentrado bajo un cielo estrellado." }, - "common": { - "save": "Guardar cambios", - "cancel": "Cancelar", - "loading": "Cargando...", - "resetDefaults": "Restablecer predeterminados" + "editor": "Editor", + "themedEditor": "Editor con tema", + "themedEditorDesc": "Usar los colores del tema para el fondo y el texto del editor", + "highlightOnHover": "Resaltar al pasar el cursor", + "highlightOnHoverDesc": "Resalta ligeramente la línea sobre la que se pasa el cursor en el editor" + }, + "keybinds": { + "screenplayElements": "Elementos de guión", + "typing": "Escribir… (Esc para cancelar)", + "notSet": "No asignado", + "modifiersOnly": "Solo modificadores — presiona una tecla normal", + "defaultPrefix": "Por defecto: {combo}", + "reset": "Restablecer", + "resetTitle": "Borrar asignación del usuario", + "resetDefaults": "Restablecer predeterminados", + "save": "Guardar cambios" + }, + "language": { + "label": "Idioma de la interfaz", + "helpText": "Elige el idioma que se usa en toda la aplicación.", + "spellcheckLabel": "Corrector orthográfico", + "spellcheckHelpText": "Descarga un diccionario para la corrección ortográfica en el editor.", + "spellcheckNone": "Desactivado", + "customDictLabel": "Diccionario del proyecto", + "customDictAdd": "Añadir", + "customDictEmpty": "Aún no hay palabras personalizadas.", + "customDictHelpText": "Las palabras añadidas aquí se comparten con todos los colaboradores del proyecto." + }, + "modal": { + "groups": { + "project": "Proyecto", + "preferences": "Preferencias", + "account": "Cuenta" }, - "sidebar": { - "title": "Panel", - "logOut": "Cerrar sesión", - "auth": "Iniciar sesión", - "logOutConfirmTitle": "Cerrar sesión", - "logOutConfirmDesc": "¿Estás seguro de que quieres cerrar sesión?", - "logOutConfirmBtn": "Cerrar sesión", - "logOutCancelBtn": "Cancelar" - }, - "appearance": { - "theme": "Tema", - "themeHelp": { - "dark": "Tema acogedor y de bajo resplandor, perfecto para noctámbulos y noches de concentración.", - "light": "Tema nítido y aireado que se siente natural y cómodo durante el día.", - "latte": "Tema suave con base crema que combina calidez y legibilidad.", - "wonka": "Tema aterciopelado con base de cacao que combina lujo profundo y descanso visual.", - "mint": "Tema refrescante con esencia de menta que combina serenidad botánica y equilibrio visual.", - "blossom": "Tema delicado con esencia floral que combina calidez de pétalos y suavidad visual.", - "midnight": "Tema azul medianoche profundo, diseñado para el trabajo concentrado bajo un cielo estrellado." - }, - "editor": "Editor", - "themedEditor": "Editor con tema", - "themedEditorDesc": "Usar los colores del tema para el fondo y el texto del editor", - "highlightOnHover": "Resaltar al pasar el cursor", - "highlightOnHoverDesc": "Resalta ligeramente la línea sobre la que se pasa el cursor en el editor" - }, - "keybinds": { - "screenplayElements": "Elementos de guión", - "typing": "Escribir… (Esc para cancelar)", - "notSet": "No asignado", - "modifiersOnly": "Solo modificadores — presiona una tecla normal", - "defaultPrefix": "Por defecto: {combo}", - "reset": "Restablecer", - "resetTitle": "Borrar asignación del usuario", - "resetDefaults": "Restablecer predeterminados", - "save": "Guardar cambios" - }, - "language": { - "label": "Idioma de la interfaz", - "helpText": "Elige el idioma que se usa en toda la aplicación.", - "spellcheckLabel": "Corrector orthográfico", - "spellcheckHelpText": "Descarga un diccionario para la corrección ortográfica en el editor.", - "spellcheckNone": "Desactivado", - "customDictLabel": "Diccionario del proyecto", - "customDictAdd": "Añadir", - "customDictEmpty": "Aún no hay palabras personalizadas.", - "customDictHelpText": "Las palabras añadidas aquí se comparten con todos los colaboradores del proyecto." - }, - "modal": { - "groups": { - "project": "Proyecto", - "preferences": "Preferencias", - "account": "Cuenta" - }, - "tabs": { - "General": "General", - "Layout": "Diseño", - "Export": "Importar/Exportar", - "Collaborators": "Colaboradores", - "Keybinds": "Atajos de teclado", - "Appearance": "Apariencia", - "Language": "Idioma", - "Profile": "Perfil", - "Settings": "Ajustes", - "Auth": "Iniciar sesión", - "About": "Acerca de", - "Subscription": "Suscripción" - } - }, - "dangerZone": { - "transferOwnership": "Transferir propiedad", - "transferDesc": "Transfiere tu rol de propietario a otro usuario. Pasarás a tener el rol de editor.", - "transferBtn": "Transferir", - "transferPrompt": "Introduce el correo del nuevo propietario:", - "deleteProject": "Eliminar proyecto", - "deleteProjectDesc": "Once un proyecto es eliminado, no hay vuelta atrás. Por favor, asegúrate.", - "deleteBtn": "Eliminar", - "modalTitle": "Eliminar proyecto", - "modalDesc": "Esta acción es permanente y no se puede deshacer. Se perderán todos los datos asociados a este proyecto.", - "deleting": "Eliminando...", - "confirmDeleteBtn": "Eliminar proyecto" - }, - "profile": { - "email": "Correo electrónico", - "username": "Nombre de usuario", - "usernamePlaceholder": "Introduce tu nombre para mostrar...", - "usernameHelp": "Este nombre será visible para los colaboradores cuando trabajes en proyectos compartidos.", - "color": "Color", - "customColor": "Color personalizado", - "selectColor": "Seleccionar color {color}", - "successMessage": "Perfil actualizado correctamente", - "failedUpdate": "Error al actualizar el perfil", - "errorSaving": "Ocurrió un error al guardar", - "saving": "Guardando...", - "dangerZoneTitle": "Zona de peligro", - "deleteAccount": "Eliminar cuenta", - "deleteAccountDesc": "Elimina permanentemente tu cuenta y todos los datos asociados. Esto no se puede deshacer.", - "deleteBtn": "Eliminar", - "deleteModalTitle": "Eliminar cuenta", - "deleteModalDesc": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.", - "deleteConfirmPhrase": "Confirmo la eliminación de mi cuenta", - "deleteConfirmLabel": "Escribe el siguiente texto para confirmar la eliminación:", - "deleting": "Eliminando...", - "deleteAccountBtn": "Eliminar mi cuenta", - "subscription": { - "title": "Suscripción", - "proBadge": "Pro", - "proTitle": "Plan Pro", - "freeTitle": "Plan Gratuito", - "renewsOn": "Se renueva el {date}", - "perksTitle": "Incluido en tu plan:", - "upgradeTitle": "Mejora para desbloquear:", - "perkProjects": "Crear proyectos en la nube", - "perkSaves": "Guardados manuales e historial de versiones", - "perkCollaborators": "Invitar colaboradores", - "perkAutoSave": "Guardado automático en la nube", - "upgradeBtn": "Actualizar a Pro", - "redirecting": "Redirigiendo...", - "cancel": "Cancelar suscripción", - "cancelConfirm": "Tu acceso Pro permanece activo hasta {date}. ¿Cancelar de todos modos?", - "cancelYes": "Sí, cancelar", - "cancelNo": "Mantener Pro", - "cancelling": "Cancelando...", - "cancelSuccess": "Suscripción cancelada. Acceso Pro hasta {date}.", - "endsOn": "Tu suscripción termina el {date}", - "purchasing": "Comprando...", - "purchaseError": "La compra falló. Inténtalo de nuevo.", - "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", - "manageApple": "Abrir App Store", - "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", - "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta.", - "welcomePro": "Ya estás suscrito a Scriptio Pro. ¡Bienvenido!", - "resubscribe": "Reactivar suscripción", - "restorePurchases": "Restaurar compras", - "transferConfirm": "Tu cuenta de App Store tiene una suscripción activa vinculada a {email}. La restauración transferirá Pro a esta cuenta, y {email} perderá el acceso a Pro. Apple seguirá facturando al mismo ID de Apple. ¿Continuar?", - "transferConfirmUnknown": "Tu cuenta de App Store tiene una suscripción activa vinculada a otra cuenta de Scriptio. La restauración transferirá Pro a esta cuenta, y la otra cuenta perderá el acceso a Pro. Apple seguirá facturando al mismo ID de Apple. ¿Continuar?", - "transferYes": "Sí, transferir", - "transferring": "Transfiriendo...", - "appleStoreFreeInfo": "Las funciones Pro están disponibles para las cuentas actualizadas a través de nuestro sitio web.", - "appleStoreProInfo": "Para actualizar tu método de pago o cancelar, visita la configuración de tu perfil en scriptio.app." - } - }, - "projects": { - "untitled": "Sin título", - "newProject": "Nuevo proyecto", - "pageTitle": "Proyectos", - "importBtn": "Importar...", - "importing": "Importando...", - "createBtn": "Crear", - "empty": { - "title": "Tu Historia Empieza Aquí", - "subtitle": "Crea un nuevo guión o importa un script existente", - "createFirst": "Nuevo proyecto", - "createDesc": "Empezar desde una página en blanco", - "importExisting": "Importar", - "importDesc": "Abrir un archivo de guión existente" - }, - "item": { - "posterAlt": "Póster de la película", - "localOnly": "Solo local", - "syncedToCloud": "Sincronizado en la nube" - }, - "form": { - "formTitle": "Crear proyecto", - "titleField": "Título", - "descriptionField": "Descripción", - "authorField": "Autor", - "posterField": "Póster", - "optional": "opcional", - "submitBtn": "Crear", - "failedToCreate": "Error al crear el proyecto" - } - }, - "editorSidebar": { - "scenes": "Escenas", - "characters": "Personajes", - "locations": "Localizaciones", - "shelf": "Estante", - "shelfEmpty": "Seleccione una versión para editar", - "goTo": "Ir al guión", - "confirmRestore": "Esto reemplazará el contenido correspondiente en su guión.", - "restore": "Restaurar", - "cancel": "Cancelar" - }, - "formatDropdown": { - "elements": { - "scene": "ENCABEZADO DE SCÈNE", - "action": "Acción", - "character": "PERSONAJE", - "dialogue": "Diálogo", - "parenthetical": "(Paréntesis)", - "transition": "TRANSICIÓN:", - "section": "Sección", - "note": "[[Nota]]", - "dual_dialogue": "Doble diálogo", - "none": "Ninguno" - }, - "titlePageElements": { - "title": "Título", - "author": "Autor", - "date": "Fecha", - "none": "Ninguno" - } + "tabs": { + "General": "General", + "Layout": "Diseño", + "Export": "Importar/Exportar", + "Collaborators": "Colaboradores", + "Keybinds": "Atajos de teclado", + "Appearance": "Apariencia", + "Language": "Idioma", + "Profile": "Perfil", + "Settings": "Ajustes", + "Auth": "Iniciar sesión", + "About": "Acerca de", + "Subscription": "Suscripción" + } + }, + "dangerZone": { + "transferOwnership": "Transferir propiedad", + "transferDesc": "Transfiere tu rol de propietario a otro usuario. Pasarás a tener el rol de editor.", + "transferBtn": "Transferir", + "transferPrompt": "Introduce el correo del nuevo propietario:", + "deleteProject": "Eliminar proyecto", + "deleteProjectDesc": "Once un proyecto es eliminado, no hay vuelta atrás. Por favor, asegúrate.", + "deleteBtn": "Eliminar", + "modalTitle": "Eliminar proyecto", + "modalDesc": "Esta acción es permanente y no se puede deshacer. Se perderán todos los datos asociados a este proyecto.", + "deleting": "Eliminando...", + "confirmDeleteBtn": "Eliminar proyecto" + }, + "profile": { + "email": "Correo electrónico", + "username": "Nombre de usuario", + "usernamePlaceholder": "Introduce tu nombre para mostrar...", + "usernameHelp": "Este nombre será visible para los colaboradores cuando trabajes en proyectos compartidos.", + "color": "Color", + "customColor": "Color personalizado", + "selectColor": "Seleccionar color {color}", + "successMessage": "Perfil actualizado correctamente", + "failedUpdate": "Error al actualizar el perfil", + "errorSaving": "Ocurrió un error al guardar", + "saving": "Guardando...", + "dangerZoneTitle": "Zona de peligro", + "deleteAccount": "Eliminar cuenta", + "deleteAccountDesc": "Elimina permanentemente tu cuenta y todos los datos asociados. Esto no se puede deshacer.", + "deleteBtn": "Eliminar", + "deleteModalTitle": "Eliminar cuenta", + "deleteModalDesc": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.", + "deleteConfirmPhrase": "Confirmo la eliminación de mi cuenta", + "deleteConfirmLabel": "Escribe el siguiente texto para confirmar la eliminación:", + "deleting": "Eliminando...", + "deleteAccountBtn": "Eliminar mi cuenta", + "subscription": { + "title": "Suscripción", + "proBadge": "Pro", + "proTitle": "Plan Pro", + "freeTitle": "Plan Gratuito", + "renewsOn": "Se renueva el {date}", + "perksTitle": "Incluido en tu plan:", + "upgradeTitle": "Mejora para desbloquear:", + "perkProjects": "Crear proyectos en la nube", + "perkSaves": "Guardados manuales e historial de versiones", + "perkCollaborators": "Invitar colaboradores", + "perkAutoSave": "Guardado automático en la nube", + "upgradeBtn": "Actualizar a Pro", + "redirecting": "Redirigiendo...", + "cancel": "Cancelar suscripción", + "cancelConfirm": "Tu acceso Pro permanece activo hasta {date}. ¿Cancelar de todos modos?", + "cancelYes": "Sí, cancelar", + "cancelNo": "Mantener Pro", + "cancelling": "Cancelando...", + "cancelSuccess": "Suscripción cancelada. Acceso Pro hasta {date}.", + "endsOn": "Tu suscripción termina el {date}", + "purchasing": "Comprando...", + "purchaseError": "La compra falló. Inténtalo de nuevo.", + "cancelApple": "Las suscripciones de Apple se gestionan a través del App Store.", + "manageApple": "Abrir App Store", + "alreadyBoundTo": "Esta suscripción ya está vinculada a {email}.", + "alreadyBoundUnknown": "Esta suscripción ya está vinculada a otra cuenta.", + "welcomePro": "Ya estás suscrito a Scriptio Pro. ¡Bienvenido!", + "resubscribe": "Reactivar suscripción", + "restorePurchases": "Restaurar compras", + "transferConfirm": "Tu cuenta de App Store tiene una suscripción activa vinculada a {email}. La restauración transferirá Pro a esta cuenta, y {email} perderá el acceso a Pro. Apple seguirá facturando al mismo ID de Apple. ¿Continuar?", + "transferConfirmUnknown": "Tu cuenta de App Store tiene una suscripción activa vinculada a otra cuenta de Scriptio. La restauración transferirá Pro a esta cuenta, y la otra cuenta perderá el acceso a Pro. Apple seguirá facturando al mismo ID de Apple. ¿Continuar?", + "transferYes": "Sí, transferir", + "transferring": "Transfiriendo...", + "appleStoreFreeInfo": "Las funciones Pro están disponibles para las cuentas actualizadas a través de nuestro sitio web.", + "appleStoreProInfo": "Para actualizar tu método de pago o cancelar, visita la configuración de tu perfil en scriptio.app." + } + }, + "projects": { + "untitled": "Sin título", + "newProject": "Nuevo proyecto", + "pageTitle": "Proyectos", + "importBtn": "Importar...", + "importing": "Importando...", + "createBtn": "Crear", + "empty": { + "title": "Tu Historia Empieza Aquí", + "subtitle": "Crea un nuevo guión o importa un script existente", + "createFirst": "Nuevo proyecto", + "createDesc": "Empezar desde una página en blanco", + "importExisting": "Importar", + "importDesc": "Abrir un archivo de guión existente" }, - "search": { - "placeholder": "Buscar...", - "noMatches": "Sin resultados", - "matchCount": "{current} de {total}", - "replacePlaceholder": "Remplazar con...", - "replace": "Remplazar", - "replaceAll": "Remplazar todo", - "filterByElement": "Filtrar por elemento:", - "elements": { - "scene": "Encabezado de escena", - "action": "Acción", - "character": "Personaje", - "dialogue": "Diálogo", - "parenthetical": "Paréntesis", - "transition": "Transición", - "section": "Sección", - "note": "Nota", - "dual_dialogue": "Diálogo dual", - "none": "Ninguno" - } + "item": { + "posterAlt": "Póster de la película", + "localOnly": "Solo local", + "syncedToCloud": "Sincronizado en la nube" }, - "collaborators": { - "projectTeam": "Equipo del proyecto ({count}/{max})", - "teamHelp": "Gestiona los miembros de tu equipo y las invitaciones pendientes. Puedes invitar a cualquier usuario no Pro a participar en tu proyecto. El proyecto permanece colaborativo mientras el propietario tenga el plan Pro.", - "roles": { - "owner": "Propietario", - "admin": "Administrador", - "editor": "Editor", - "viewer": "Espectador" - }, - "roleDesc": { - "owner": "Puede eliminar el proyecto y transferir la propiedad", - "admin": "Puede invitar, ascender, degradar y expulsar colaboradores", - "editor": "Puede modificar el guión y otro contenido del proyecto", - "viewer": "Acceso de solo lectura. No puede realizar cambios" - }, - "you": "(tú)", - "leave": "Salir", - "kick": "Expulsar", - "pending": "Pendiente", - "cancel": "Cancelar", - "emailPlaceholder": "Introduce el correo...", - "invite": "Invitar", - "proRequired": "A Pro subscription is required to invite collaborators.", - "proRequiredInvite": "Upgrade to Pro to invite collaborators", - "upgrade": "Upgrade", - "localProjectOnly": "Collaboration is not available for local projects. Upload your project to the cloud to invite collaborators." + "form": { + "formTitle": "Crear proyecto", + "titleField": "Título", + "descriptionField": "Descripción", + "authorField": "Autor", + "posterField": "Póster", + "optional": "opcional", + "submitBtn": "Crear", + "failedToCreate": "Error al crear el proyecto" + } + }, + "editorSidebar": { + "scenes": "Escenas", + "characters": "Personajes", + "locations": "Localizaciones", + "shelf": "Estante", + "shelfEmpty": "Seleccione una versión para editar", + "goTo": "Ir al guión", + "confirmRestore": "Esto reemplazará el contenido correspondiente en su guión.", + "restore": "Restaurar", + "cancel": "Cancelar", + "scenesEmpty": "Aún no hay escenas", + "comments": "Comentarios", + "commentsEmpty": "No hay comentarios activos", + "shelfEmptySelection": "Selecciona una versión del estante para editar" + }, + "formatDropdown": { + "elements": { + "scene": "ENCABEZADO DE SCÈNE", + "action": "Acción", + "character": "PERSONAJE", + "dialogue": "Diálogo", + "parenthetical": "(Paréntesis)", + "transition": "TRANSICIÓN:", + "section": "Sección", + "note": "[[Nota]]", + "dual_dialogue": "Doble diálogo", + "none": "Ninguno" }, - "export": { - "importLabel": "Importar", - "selectFile": "Seleccionar archivo", - "selectFileDesc": "Sube .fountain, .fdx, .scriptio o .txt", - "exportLabel": "Exportar", - "formatOptions": { - "pdf": "Documento PDF (.pdf)", - "fountain": "Fountain (.fountain)", - "fdx": "Final Draft (.fdx)", - "scriptio": "Scriptio (.scriptio)" - }, - "formatHelp": { - "pdf": "Formato estándar de la industria. Ideal para compartir e imprimir.", - "fountain": "Formato de texto plano basado en Markdown, ideal para compatibilidad.", - "fdx": "Compatible con el software de la industria Final Draft.", - "scriptio": "Formato propio de Scriptio, para mantener el proyecto en local" - }, - "includeNotes": "Incluir notas", - "includeNotesDesc": "Exportar notas en línea.", - "readable": "JSON legible", - "readableDesc": "Exportar como JSON plano en lugar de binario comprimido. Archivo más grande, inspeccionable con cualquier editor de texto.", - "watermark": "Marca de agua", - "watermarkDesc": "Superponer texto en las páginas.", - "watermarkPlaceholder": "Texto de marca de agua", - "passwordProtection": "Protección con contraseña", - "passwordProtectionDesc": "Requerir una contraseña para abrir el PDF.", - "passwordPlaceholder": "Introduce la contraseña", - "exportBtn": "Exportar", - "exporting": "Exportando...", - "exportingProgress": "Exportando ({progress}%)" + "titlePageElements": { + "title": "Título", + "author": "Autor", + "date": "Fecha", + "none": "Ninguno" + } + }, + "search": { + "placeholder": "Buscar...", + "noMatches": "Sin resultados", + "matchCount": "{current} de {total}", + "replacePlaceholder": "Remplazar con...", + "replace": "Remplazar", + "replaceAll": "Remplazar todo", + "filterByElement": "Filtrar por elemento:", + "elements": { + "scene": "Encabezado de escena", + "action": "Acción", + "character": "Personaje", + "dialogue": "Diálogo", + "parenthetical": "Paréntesis", + "transition": "Transición", + "section": "Sección", + "note": "Nota", + "dual_dialogue": "Diálogo dual", + "none": "Ninguno" + } + }, + "collaborators": { + "projectTeam": "Equipo del proyecto ({count}/{max})", + "teamHelp": "Gestiona los miembros de tu equipo y las invitaciones pendientes. Puedes invitar a cualquier usuario no Pro a participar en tu proyecto. El proyecto permanece colaborativo mientras el propietario tenga el plan Pro.", + "roles": { + "owner": "Propietario", + "admin": "Administrador", + "editor": "Editor", + "viewer": "Espectador" }, - "layout": { - "pageFormat": "Formato de página", - "pageFormatHelp": { - "letter": "Formato estándar de Estados Unidos. Estándar de la industria para guiones de Hollywood.", - "a4": "Formato estándar internacional. Común en Europa y la mayoría de los países." - }, - "sceneHeadings": "Encabezados de escena", - "bold": "Negrita", - "boldDesc": "Los encabezados de escena aparecerán en negrita", - "extraSpace": "Espacio extra arriba", - "extraSpaceDesc": "Añadir espacio extra antes de los encabezados de escena", - "sceneNumbering": "Numeración de escenas", - "sceneNumberingDesc": "Mostrar números de escena en el margen izquierdo", - "duplicateRight": "Duplicar en margen derecho", - "duplicateRightDesc": "Mostrar número en ambos lados", - "continuation": "Continuación", - "moreTitle": "(MORE) Etiqueta", - "contdTitle": "(CONT'D) Etiqueta", - "pageMargins": "Márgenes de página", - "vertical": "Vertical", - "horizontal": "Horizontal", - "marginTop": "Superior", - "marginBottom": "Inferior", - "margins": "Márgenes", - "marginLeft": "Izquierda", - "marginRight": "Derecha", - "marginElements": { - "action": "Acción", - "scene": "Encabezado de escena", - "character": "Personaje", - "dialogue": "Diálogo", - "parenthetical": "Paréntesis", - "transition": "Transición", - "section": "Sección" - }, - "elements": "Configuración de elementos", - "style": "Estilo", - "alignment": "Alineación", - "italic": "Cursiva", - "underline": "Subrayado", - "uppercase": "Mayúsculas", - "alignLeft": "Izquierda", - "alignCenter": "Centro", - "alignRight": "Derecha", - "sceneSpacing": "Espaciado", - "sceneSpacingDesc": "Espaciado sobre los encabezados de escena", - "startNewPage": "Iniciar nueva página" + "roleDesc": { + "owner": "Puede eliminar el proyecto y transferir la propiedad", + "admin": "Puede invitar, ascender, degradar y expulsar colaboradores", + "editor": "Puede modificar el guión y otro contenido del proyecto", + "viewer": "Acceso de solo lectura. No puede realizar cambios" }, - "projectSettings": { - "titleLabel": "Título", - "titlePlaceholder": "Introduce el nombre del proyecto...", - "authorLabel": "Autor", - "authorPlaceholder": "Nombre del autor...", - "descriptionLabel": "Descripción", - "descriptionPlaceholder": "¿De qué trata este guión?", - "posterLabel": "Póster", - "noPoster": "Sin póster", - "posterHelp": "Recomendado: 600x900 píxeles (proporción 2:3). Compatible con PNG y JPG.", - "saveChanges": "Guardar cambios", - "dangerZoneTitle": "Zona de peligro" + "you": "(tú)", + "leave": "Salir", + "kick": "Expulsar", + "pending": "Pendiente", + "cancel": "Cancelar", + "emailPlaceholder": "Introduce el correo...", + "invite": "Invitar", + "proRequired": "A Pro subscription is required to invite collaborators.", + "proRequiredInvite": "Upgrade to Pro to invite collaborators", + "upgrade": "Upgrade", + "localProjectOnly": "Collaboration is not available for local projects. Upload your project to the cloud to invite collaborators." + }, + "export": { + "importLabel": "Importar", + "selectFile": "Seleccionar archivo", + "selectFileDesc": "Sube .fountain, .fdx, .scriptio o .txt", + "exportLabel": "Exportar", + "formatOptions": { + "pdf": "Documento PDF (.pdf)", + "fountain": "Fountain (.fountain)", + "fdx": "Final Draft (.fdx)", + "scriptio": "Scriptio (.scriptio)" }, - "contextMenu": { - "goToScene": "Ir a la escena", - "edit": "Editar", - "copy": "Copiar", - "cut": "Cortar", - "selectInEditor": "Seleccionar", - "remove": "Eliminar", - "paste": "Pegar", - "highlight": "Resaltar", - "addCharacter": "Añadir personaje", - "addComment": "Añadir comentario", - "searchOnWeb": "Buscar en la web", - "noSuggestions": "Sin sugerencias", - "addToDictionary": "Añadir al diccionario", - "makeDualDialogue": "Crear doble diálogo", - "shelve": "Archivar", - "shelveScene": "Archivar escena", - "shelveDialogue": "Archivar diálogo", - "shelveAction": "Archivar acción" + "formatHelp": { + "pdf": "Formato estándar de la industria. Ideal para compartir e imprimir.", + "fountain": "Formato de texto plano basado en Markdown, ideal para compatibilidad.", + "fdx": "Compatible con el software de la industria Final Draft.", + "scriptio": "Formato propio de Scriptio, para mantener el proyecto en local" }, - "popup": { - "character": { - "create": "Crear personaje", - "edit": "Editar personaje", - "name": "Nombre", - "gender": "Género", - "genderFemale": "Mujer", - "genderMale": "Hombre", - "genderOther": "Otro", - "color": "Color", - "synopsis": "Sinopsis", - "confirm": "Confirmar", - "takenNameError": "Ya existe un personaje con el nombre {newName}. Por favor, elige un nombre diferente o edita el personaje existente.", - "updateOccurrences": "¿Estás seguro de que quieres actualizar {count} ocurrencias de la palabra {oldName} a {newName}? Ten especial cuidado con las palabras comunes cuya actualización podría ser indeseada.", - "yes": "Sí", - "noDoNotChange": "No, no cambiar" - }, - "import": { - "title": "Confirmar importación", - "warning": "¿Estás seguro de que quieres sobrescribir tu proyecto actual?", - "info": "Puedes exportar tu proyecto antes de importar uno nuevo.", - "yesImport": "Sí, importar", - "no": "No" - }, - "scene": { - "edit": "Editar escena", - "sceneTitle": "Escena", - "color": "Color", - "synopsis": "Sinopsis", - "synopsisPlaceholder": "Escribe una breve descripción de esta escena...", - "save": "Guardar" - }, - "uploadToCloud": { - "title": "Subir a la nube", - "body": "¿Subir este proyecto a la nube? Tu proyecto se respaldará y será accesible desde cualquier dispositivo, y podrás invitar colaboradores.", - "confirm": "Subir", - "cancel": "Cancelar", - "uploading": "Subiendo a la nube...", - "failed": "Error al subir. Por favor, inténtalo de nuevo." - } + "includeNotes": "Incluir notas", + "includeNotesDesc": "Exportar notas en línea.", + "readable": "JSON legible", + "readableDesc": "Exportar como JSON plano en lugar de binario comprimido. Archivo más grande, inspeccionable con cualquier editor de texto.", + "watermark": "Marca de agua", + "watermarkDesc": "Superponer texto en las páginas.", + "watermarkPlaceholder": "Texto de marca de agua", + "passwordProtection": "Protección con contraseña", + "passwordProtectionDesc": "Requerir una contraseña para abrir el PDF.", + "passwordPlaceholder": "Introduce la contraseña", + "exportBtn": "Exportar", + "exporting": "Exportando...", + "exportingProgress": "Exportando ({progress}%)" + }, + "layout": { + "pageFormat": "Formato de página", + "pageFormatHelp": { + "letter": "Formato estándar de Estados Unidos. Estándar de la industria para guiones de Hollywood.", + "a4": "Formato estándar internacional. Común en Europa y la mayoría de los países." }, - "board": { - "duplicate": "Duplicar", - "delete": "Eliminar", - "hints": { - "pan": "Clic central para desplazar", - "select": "Arrastrar para seleccionar tarjetas", - "create": "Doble clic para crear tarjeta", - "move": "Mantener Shift para mover libremente" - }, - "untitled": "Sin título", - "titlePlaceholder": "Título", - "descriptionPlaceholder": "Descripción" + "sceneHeadings": "Encabezados de escena", + "bold": "Negrita", + "boldDesc": "Los encabezados de escena aparecerán en negrita", + "extraSpace": "Espacio extra arriba", + "extraSpaceDesc": "Añadir espacio extra antes de los encabezados de escena", + "sceneNumbering": "Numeración de escenas", + "sceneNumberingDesc": "Mostrar números de escena en el margen izquierdo", + "duplicateRight": "Duplicar en margen derecho", + "duplicateRightDesc": "Mostrar número en ambos lados", + "continuation": "Continuación", + "moreTitle": "(MORE) Etiqueta", + "contdTitle": "(CONT'D) Etiqueta", + "pageMargins": "Márgenes de página", + "vertical": "Vertical", + "horizontal": "Horizontal", + "marginTop": "Superior", + "marginBottom": "Inferior", + "margins": "Márgenes", + "marginLeft": "Izquierda", + "marginRight": "Derecha", + "marginElements": { + "action": "Acción", + "scene": "Encabezado de escena", + "character": "Personaje", + "dialogue": "Diálogo", + "parenthetical": "Paréntesis", + "transition": "Transición", + "section": "Sección" }, - "saves": { - "title": "Historial de versiones", - "saveCurrentVersion": "Guardar versión actual", - "namePlaceholder": "Nombre de la versión...", - "manualSaves": "Guardados manuales", - "autoSaves": "Guardados automáticos", - "restore": "Restaurar", - "rename": "Renombrar", - "delete": "Eliminar", - "cancel": "Cancelar", - "confirmRestore": "Esto reemplazará el documento actual para todos los colaboradores. ¿Estás seguro?", - "confirmDelete": "¿Estás seguro de que quieres eliminar este guardado?", - "noSaves": "Aún no hay guardados", - "proRequired": "Se requiere Pro", - "proRequiredDesc": "El historial de versiones es una función Pro. Actualiza tu suscripción para guardar y restaurar versiones con nombre de tu guion.", - "upgradeBtn": "Actualizar a Pro", - "signInAndUpgrade": "Iniciar sesión y actualizar" + "elements": "Configuración de elementos", + "style": "Estilo", + "alignment": "Alineación", + "italic": "Cursiva", + "underline": "Subrayado", + "uppercase": "Mayúsculas", + "alignLeft": "Izquierda", + "alignCenter": "Centro", + "alignRight": "Derecha", + "sceneSpacing": "Espaciado", + "sceneSpacingDesc": "Espaciado sobre los encabezados de escena", + "startNewPage": "Iniciar nueva página" + }, + "projectSettings": { + "titleLabel": "Título", + "titlePlaceholder": "Introduce el nombre del proyecto...", + "authorLabel": "Autor", + "authorPlaceholder": "Nombre del autor...", + "descriptionLabel": "Descripción", + "descriptionPlaceholder": "¿De qué trata este guión?", + "posterLabel": "Póster", + "noPoster": "Sin póster", + "posterHelp": "Recomendado: 600x900 píxeles (proporción 2:3). Compatible con PNG y JPG.", + "saveChanges": "Guardar cambios", + "dangerZoneTitle": "Zona de peligro" + }, + "contextMenu": { + "goToScene": "Ir a la escena", + "edit": "Editar", + "copy": "Copiar", + "cut": "Cortar", + "selectInEditor": "Seleccionar", + "remove": "Eliminar", + "paste": "Pegar", + "highlight": "Resaltar", + "addCharacter": "Añadir personaje", + "addComment": "Añadir comentario", + "searchOnWeb": "Buscar en la web", + "noSuggestions": "Sin sugerencias", + "addToDictionary": "Añadir al diccionario", + "makeDualDialogue": "Crear doble diálogo", + "shelve": "Archivar", + "shelveScene": "Archivar escena", + "shelveDialogue": "Archivar diálogo", + "shelveAction": "Archivar acción" + }, + "popup": { + "character": { + "create": "Crear personaje", + "edit": "Editar personaje", + "name": "Nombre", + "gender": "Género", + "genderFemale": "Mujer", + "genderMale": "Hombre", + "genderOther": "Otro", + "color": "Color", + "synopsis": "Sinopsis", + "confirm": "Confirmar", + "takenNameError": "Ya existe un personaje con el nombre {newName}. Por favor, elige un nombre diferente o edita el personaje existente.", + "updateOccurrences": "¿Estás seguro de que quieres actualizar {count} ocurrencias de la palabra {oldName} a {newName}? Ten especial cuidado con las palabras comunes cuya actualización podría ser indeseada.", + "yes": "Sí", + "noDoNotChange": "No, no cambiar" }, - "auth": { - "intro": "Introduce tu correo y te enviaremos un enlace de inicio de sesión de un solo uso. No necesitas contraseña.", - "emailLabel": "Correo electrónico", - "sendLink": "Enviar enlace de inicio de sesión", - "sending": "Enviando...", - "checkInbox": "Si existe una cuenta asociada a {email}, se ha enviado un enlace de inicio de sesión. El enlace es válido durante los próximos 10 minutos.", - "useDifferentEmail": "Usar otro correo electrónico", - "waitingForClick": "Esperando a que hagas clic en el enlace de tu bandeja de entrada...", - "requestFailed": "No se pudo enviar el enlace de inicio de sesión. Por favor, vuelve a intentarlo en un momento.", - "desktopTimeout": "El tiempo de inicio de sesión expiró. Por favor, solicita un nuevo enlace." + "import": { + "title": "Confirmar importación", + "warning": "¿Estás seguro de que quieres sobrescribir tu proyecto actual?", + "info": "Puedes exportar tu proyecto antes de importar uno nuevo.", + "yesImport": "Sí, importar", + "no": "No" }, - "oauth": { - "orContinueWith": "o continuar con", - "continueWithGoogle": "Continuar con Google", - "continueWithApple": "Continuar con Apple", - "waiting": "Esperando al navegador…", - "timeout": "Tiempo de espera agotado. Por favor, inténtalo de nuevo.", - "error": "Error al iniciar sesión. Por favor, inténtalo de nuevo." + "scene": { + "edit": "Editar escena", + "sceneTitle": "Escena", + "color": "Color", + "synopsis": "Sinopsis", + "synopsisPlaceholder": "Escribe una breve descripción de esta escena...", + "save": "Guardar" }, - "dates": { - "justNow": "Recién", - "minutesAgo": "{mins, plural, one {hace # minuto} other {hace # minutos}}", - "hoursAgo": "{hours, plural, one {hace # hora} other {hace # horas}}", - "today": "Hoy", - "yesterday": "Ayer", - "daysAgo": "{days, plural, one {hace # día} other {hace # días}}", - "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", - "moreThanYearAgo": "Hace más de 1 año" + "uploadToCloud": { + "title": "Subir a la nube", + "body": "¿Subir este proyecto a la nube? Tu proyecto se respaldará y será accesible desde cualquier dispositivo, y podrás invitar colaboradores.", + "confirm": "Subir", + "cancel": "Cancelar", + "uploading": "Subiendo a la nube...", + "failed": "Error al subir. Por favor, inténtalo de nuevo." } + }, + "board": { + "duplicate": "Duplicar", + "delete": "Eliminar", + "hints": { + "pan": "Clic central para desplazar", + "select": "Arrastrar para seleccionar tarjetas", + "create": "Doble clic para crear tarjeta", + "move": "Mantener Shift para mover libremente" + }, + "untitled": "Sin título", + "titlePlaceholder": "Título", + "descriptionPlaceholder": "Descripción" + }, + "saves": { + "title": "Historial de versiones", + "saveCurrentVersion": "Guardar versión actual", + "namePlaceholder": "Nombre de la versión...", + "manualSaves": "Guardados manuales", + "autoSaves": "Guardados automáticos", + "restore": "Restaurar", + "rename": "Renombrar", + "delete": "Eliminar", + "cancel": "Cancelar", + "confirmRestore": "Esto reemplazará el documento actual para todos los colaboradores. ¿Estás seguro?", + "confirmDelete": "¿Estás seguro de que quieres eliminar este guardado?", + "noSaves": "Aún no hay guardados", + "proRequired": "Se requiere Pro", + "proRequiredDesc": "El historial de versiones es una función Pro. Actualiza tu suscripción para guardar y restaurar versiones con nombre de tu guion.", + "upgradeBtn": "Actualizar a Pro", + "signInAndUpgrade": "Iniciar sesión y actualizar" + }, + "auth": { + "intro": "Introduce tu correo y te enviaremos un enlace de inicio de sesión de un solo uso. No necesitas contraseña.", + "emailLabel": "Correo electrónico", + "sendLink": "Enviar enlace de inicio de sesión", + "sending": "Enviando...", + "checkInbox": "Si existe una cuenta asociada a {email}, se ha enviado un enlace de inicio de sesión. El enlace es válido durante los próximos 10 minutos.", + "useDifferentEmail": "Usar otro correo electrónico", + "waitingForClick": "Esperando a que hagas clic en el enlace de tu bandeja de entrada...", + "requestFailed": "No se pudo enviar el enlace de inicio de sesión. Por favor, vuelve a intentarlo en un momento.", + "desktopTimeout": "El tiempo de inicio de sesión expiró. Por favor, solicita un nuevo enlace." + }, + "oauth": { + "orContinueWith": "o continuar con", + "continueWithGoogle": "Continuar con Google", + "continueWithApple": "Continuar con Apple", + "waiting": "Esperando al navegador…", + "timeout": "Tiempo de espera agotado. Por favor, inténtalo de nuevo.", + "error": "Error al iniciar sesión. Por favor, inténtalo de nuevo." + }, + "dates": { + "justNow": "Recién", + "minutesAgo": "{mins, plural, one {hace # minuto} other {hace # minutos}}", + "hoursAgo": "{hours, plural, one {hace # hora} other {hace # horas}}", + "today": "Hoy", + "yesterday": "Ayer", + "daysAgo": "{days, plural, one {hace # día} other {hace # días}}", + "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", + "moreThanYearAgo": "Hace más de 1 año" + } } diff --git a/messages/fr.json b/messages/fr.json index 84060b90..5253634c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -206,7 +206,11 @@ "goTo": "Aller au scénario", "confirmRestore": "Cela remplacera le contenu correspondant dans votre scénario.", "restore": "Restaurer", - "cancel": "Annuler" + "cancel": "Annuler", + "scenesEmpty": "Aucune scène pour le moment", + "comments": "Commentaires", + "commentsEmpty": "Aucun commentaire actif", + "shelfEmptySelection": "Sélectionnez une version sur l'étagère pour la modifier" }, "formatDropdown": { "elements": { @@ -489,4 +493,4 @@ "monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}", "moreThanYearAgo": "Il y a plus d'un an" } -} \ No newline at end of file +} diff --git a/messages/ja.json b/messages/ja.json index c41ec2f3..2beb8a09 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -205,7 +205,11 @@ "goTo": "脚本内で移動", "confirmRestore": "脚本内の一致するコンテンツが置き換えられます。", "restore": "復元", - "cancel": "キャンセル" + "cancel": "キャンセル", + "scenesEmpty": "まだシーンはありません", + "comments": "コメント", + "commentsEmpty": "アクティブなコメントはありません", + "shelfEmptySelection": "編集するシェルフのバージョンを選択してください" }, "formatDropdown": { "elements": { @@ -488,4 +492,4 @@ "monthsAgo": "{months}ヶ月前", "moreThanYearAgo": "1年以上前" } -} \ No newline at end of file +} diff --git a/messages/ko.json b/messages/ko.json index c34bd0ce..c9edda13 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -205,7 +205,11 @@ "goTo": "각본에서 이동", "confirmRestore": "이것은 각본의 해당 내용을 대체합니다.", "restore": "복원", - "cancel": "취소" + "cancel": "취소", + "scenesEmpty": "아직 씬이 없습니다", + "comments": "코멘트", + "commentsEmpty": "활성 코멘트가 없습니다", + "shelfEmptySelection": "편집할 보관함 버전을 선택하세요" }, "formatDropdown": { "elements": { @@ -488,4 +492,4 @@ "monthsAgo": "{months}달 전", "moreThanYearAgo": "1년 이상 전" } -} \ No newline at end of file +} diff --git a/messages/pl.json b/messages/pl.json index f9b9cc3d..72f512bf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -205,7 +205,11 @@ "goTo": "Przejdź do scenariusza", "confirmRestore": "Spowoduje to zastąpienie pasującej treści w scenariuszu.", "restore": "Przywróć", - "cancel": "Anuluj" + "cancel": "Anuluj", + "scenesEmpty": "Brak scen", + "comments": "Komentarze", + "commentsEmpty": "Brak aktywnych komentarzy", + "shelfEmptySelection": "Wybierz wersję z półki do edycji" }, "formatDropdown": { "elements": { @@ -488,4 +492,4 @@ "monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu", "moreThanYearAgo": "Ponad rok temu" } -} \ No newline at end of file +} diff --git a/messages/zh.json b/messages/zh.json index d130c2cd..ea963b3f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -205,7 +205,11 @@ "goTo": "在剧本中定位", "confirmRestore": "这将替换剧本中匹配的内容。", "restore": "恢复", - "cancel": "取消" + "cancel": "取消", + "scenesEmpty": "暂无场景", + "comments": "评论", + "commentsEmpty": "没有活动的评论", + "shelfEmptySelection": "选择要编辑的搁置版本" }, "formatDropdown": { "elements": { @@ -488,4 +492,4 @@ "monthsAgo": "{months} 个月前", "moreThanYearAgo": "1 年前" } -} \ No newline at end of file +} From b0b6ab40a43292b804e9c89c9cc81cc6eaed2333 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Wed, 13 May 2026 01:00:22 +0200 Subject: [PATCH 51/76] fixed shelving logic for nodes with comments --- src/lib/shelf/shelf-utils.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/shelf/shelf-utils.ts b/src/lib/shelf/shelf-utils.ts index a92ff999..ad3c2562 100644 --- a/src/lib/shelf/shelf-utils.ts +++ b/src/lib/shelf/shelf-utils.ts @@ -11,6 +11,24 @@ export interface ShelveCandidate { content: JSONContent[]; } +/** + * Removes 'comment' marks from a node's JSON to prevent it from being + * dropped by the shelf editor which lacks the comment extension. + */ +function stripComments(nodeJson: JSONContent): JSONContent { + const result = { ...nodeJson }; + if (result.marks) { + result.marks = result.marks.filter((m) => m.type !== "comment"); + if (result.marks.length === 0) { + delete result.marks; + } + } + if (result.content) { + result.content = result.content.map(stripComments); + } + return result; +} + /** * Given a position in the document, determine the shelvable content. * Returns null if the node at the position is not shelvable. @@ -32,7 +50,7 @@ export function extractShelveCandidate(editor: Editor, pos: number): ShelveCandi case ScreenplayElement.Character: return extractDialogueBlockContent(doc, docChildIndex, nodeId, node.textContent); case ScreenplayElement.Action: - return { nodeId, title: node.textContent, type: "action", content: [node.toJSON()] }; + return { nodeId, title: node.textContent, type: "action", content: [stripComments(node.toJSON())] }; default: return null; } @@ -48,12 +66,12 @@ function extractSceneContent( const content: JSONContent[] = []; const count = doc.childCount; - content.push(doc.child(startIndex).toJSON()); + content.push(stripComments(doc.child(startIndex).toJSON())); for (let i = startIndex + 1; i < count; i++) { const child = doc.child(i); if (child.attrs.class === ScreenplayElement.Scene) break; - content.push(child.toJSON()); + content.push(stripComments(child.toJSON())); } return { nodeId, title, type: "scene", content }; @@ -69,7 +87,7 @@ function extractDialogueBlockContent( const content: JSONContent[] = []; const count = doc.childCount; - content.push(doc.child(startIndex).toJSON()); + content.push(stripComments(doc.child(startIndex).toJSON())); for (let i = startIndex + 1; i < count; i++) { const cls = doc.child(i).attrs.class; @@ -77,7 +95,7 @@ function extractDialogueBlockContent( cls === ScreenplayElement.Dialogue || cls === ScreenplayElement.Parenthetical ) { - content.push(doc.child(i).toJSON()); + content.push(stripComments(doc.child(i).toJSON())); } else { break; } From 2ca7d650b9b38a384b6178793f36112ba0112e1c Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 May 2026 17:19:03 +0200 Subject: [PATCH 52/76] added production logic for scene numbering locking --- components/dashboard/DashboardModal.tsx | 5 +- components/dashboard/DashboardSidebar.tsx | 6 +- .../dashboard/account/DashboardAuth.tsx | 1 - .../dashboard/account/ProfileSettings.tsx | 5 +- .../dashboard/project/DangerZone.module.css | 40 -- components/dashboard/project/DangerZone.tsx | 5 +- .../project/ProductionSettings.module.css | 56 +++ .../dashboard/project/ProductionSettings.tsx | 49 ++ components/editor/DocumentEditorPanel.tsx | 8 + components/editor/SuggestionMenu.module.css | 39 -- components/editor/SuggestionMenu.tsx | 25 +- .../editor/sidebar/ContextMenu.module.css | 2 + components/editor/sidebar/ContextMenu.tsx | 81 +++- .../sidebar/EditorSidebarNavigation.tsx | 23 +- .../editor/sidebar/SidebarItem.module.css | 6 + .../editor/sidebar/SidebarSceneItem.tsx | 12 +- components/navbar/ProductionPanel.module.css | 174 +++++++ components/navbar/ProductionPanel.tsx | 235 +++++++++ components/navbar/ProjectNavbar.tsx | 23 + components/popup/Popup.module.css | 34 +- components/popup/Popup.tsx | 4 + components/popup/PopupCharacterItem.tsx | 2 +- components/popup/PopupImportFile.tsx | 6 +- components/popup/PopupSceneItem.tsx | 2 +- components/popup/PopupUnlockScenes.tsx | 52 ++ components/popup/PopupUploadToCloud.tsx | 6 +- components/utils/Dropdown.module.css | 3 +- components/utils/ModalBtn.module.css | 42 ++ components/utils/Switch.module.css | 37 ++ components/utils/Switch.tsx | 34 ++ messages/de.json | 25 +- messages/en.json | 25 +- messages/es.json | 25 +- messages/fr.json | 25 +- messages/ja.json | 25 +- messages/ko.json | 25 +- messages/pl.json | 25 +- messages/zh.json | 25 +- src/context/ProjectContext.tsx | 61 +++ src/lib/adapters/pdf/pdf-adapter.ts | 93 +++- src/lib/editor/document-editor-config.ts | 4 + src/lib/editor/use-document-editor.ts | 29 ++ src/lib/project/project-doc.ts | 10 + src/lib/project/project-repository.ts | 66 ++- src/lib/screenplay/editor.ts | 12 +- .../extensions/fountain-extension.ts | 4 + .../extensions/node-id-dedup-extension.ts | 3 + .../extensions/scene-locking-extension.ts | 244 ++++++++++ src/lib/screenplay/nodes/scene-node.ts | 81 ++++ src/lib/screenplay/popup.ts | 15 +- src/lib/screenplay/scene-locking.ts | 449 ++++++++++++++++++ src/lib/screenplay/scenes.ts | 25 +- src/lib/shelf/shelf-editor-config.ts | 1 + styles/scriptio.css | 59 +++ 54 files changed, 2209 insertions(+), 164 deletions(-) create mode 100644 components/dashboard/project/ProductionSettings.module.css create mode 100644 components/dashboard/project/ProductionSettings.tsx delete mode 100644 components/editor/SuggestionMenu.module.css create mode 100644 components/navbar/ProductionPanel.module.css create mode 100644 components/navbar/ProductionPanel.tsx create mode 100644 components/popup/PopupUnlockScenes.tsx create mode 100644 components/utils/ModalBtn.module.css create mode 100644 components/utils/Switch.module.css create mode 100644 components/utils/Switch.tsx create mode 100644 src/lib/screenplay/extensions/scene-locking-extension.ts create mode 100644 src/lib/screenplay/scene-locking.ts diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index a2d22443..7fd6c06f 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, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; +import { CreditCard, FileDown, Folder, Globe, 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"; @@ -19,6 +19,7 @@ import LanguageSettings from "./preferences/LanguageSettings"; import ProfileSettings from "./account/ProfileSettings"; import SubscriptionSettings from "./account/SubscriptionSettings"; import LayoutSettings from "./project/LayoutSettings"; +import ProductionSettings from "./project/ProductionSettings"; import DashboardAuth from "./account/DashboardAuth"; import AboutSettings from "./AboutSettings"; @@ -33,6 +34,7 @@ const DashboardModal = () => { items: [ { id: "General", label: t("tabs.General"), icon: }, { id: "Layout", label: t("tabs.Layout"), icon: }, + { id: "Production", label: t("tabs.Production"), icon: }, { id: "Export", label: t("tabs.Export"), icon: }, { id: "Collaborators", label: t("tabs.Collaborators"), icon: }, ], @@ -134,6 +136,7 @@ const DashboardModal = () => { {/* Project tabs - only rendered when in project context */} {isInProject && activeTab === "General" && setDangerOpen((v) => !v)} />} {isInProject && activeTab === "Layout" && } + {isInProject && activeTab === "Production" && } {isInProject && activeTab === "Export" && } {isInProject && activeTab === "Collaborators" && } {/* Preferences tabs */} diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index 54db8fa9..73f2b50e 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "next-intl"; import styles from "./DashboardModal.module.css"; import dangerStyles from "./project/DangerZone.module.css"; +import modal from "../utils/ModalBtn.module.css"; import { signOut } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useCookieUser } from "@src/lib/utils/hooks"; @@ -15,6 +16,7 @@ import { useCookieUser } from "@src/lib/utils/hooks"; export type Category = | "General" | "Layout" + | "Production" | "Export" | "Collaborators" | "Profile" @@ -72,13 +74,13 @@ const SidebarMenu = ({ structure, activeTab, onTabChange }: SidebarMenuProps) =>

{t("logOutConfirmDesc")}

+ +
+ +

{t("appliesToNewOnly")}

+
+
+ ); +}; + +export default ProductionSettings; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index cf5dada4..24603169 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -74,6 +74,7 @@ const DocumentEditorPanel = ({ moreLabel, elementMargins, elementStyles, + sceneLocking, setFocusedEditorType, setSelectedTitlePageElement, repository, @@ -172,6 +173,12 @@ const DocumentEditorPanel = ({ editorElement.classList.remove("scene-number-right"); } + if (sceneLocking) { + editorElement.classList.add("production-locked"); + } else { + editorElement.classList.remove("production-locked"); + } + editorElement.style.setProperty("--contd-label", `"${contdLabel}"`); editorElement.style.setProperty("--more-label", `"${moreLabel}"`); @@ -246,6 +253,7 @@ const DocumentEditorPanel = ({ moreLabel, elementMargins, elementStyles, + sceneLocking, ]); // ---- Pagination update (title page only) ---- diff --git a/components/editor/SuggestionMenu.module.css b/components/editor/SuggestionMenu.module.css deleted file mode 100644 index 2f55d3a1..00000000 --- a/components/editor/SuggestionMenu.module.css +++ /dev/null @@ -1,39 +0,0 @@ -.menu { - position: fixed; - display: flex; - flex-direction: column; - padding-block: 6px; - z-index: 100; - border-radius: 12px; - background-color: var(--context-menu-bg); - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); - min-width: 120px; - max-width: 220px; - max-height: 180px; - overflow-y: auto; - overflow-x: hidden; -} - -.menu_item { - display: flex; - align-items: center; - padding-block: 6px; - padding-inline: 12px; - font-size: 14px; - cursor: pointer; -} - -.menu_item:hover { - background-color: var(--context-menu-item-hover); -} - -.selected { - background-color: var(--context-menu-item-hover); -} - -.item { - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin: 0; -} diff --git a/components/editor/SuggestionMenu.tsx b/components/editor/SuggestionMenu.tsx index 0892b9f5..00d48e09 100644 --- a/components/editor/SuggestionMenu.tsx +++ b/components/editor/SuggestionMenu.tsx @@ -2,7 +2,7 @@ import { useContext, useEffect, useState, useCallback, useRef } from "react"; -import styles from "./SuggestionMenu.module.css"; +import styles from "../utils/Dropdown.module.css"; import { pasteTextAt, insertElement } from "@src/lib/screenplay/editor"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { ProjectContext } from "@src/context/ProjectContext"; @@ -124,10 +124,19 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => { return (
{suggestions.map((suggestion: string, index: number) => ( @@ -135,11 +144,19 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => { ref={(el) => { itemRefs.current[index] = el; }} - className={`${styles.menu_item} ${index === selectedIdx ? styles.selected : ""}`} + className={styles.dropdown_item} onClick={() => selectSuggestion(index)} key={index} + style={{ + padding: "6px 12px", + backgroundColor: index === selectedIdx ? "var(--context-menu-item-hover)" : "transparent" + }} > -

{suggestion}

+
+

+ {suggestion} +

+
))}
diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index fa554e32..02a9ffc8 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -18,8 +18,10 @@ align-items: center; gap: 10px; + border-radius: 6px; padding-block: 6px; padding-inline: 12px; + margin-inline: 6px; font-size: 14px; cursor: pointer; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 36a21ac2..0aa60f00 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -21,6 +21,8 @@ import { ClipboardPaste, Columns2, Copy, + EyeOff, + Eye, Highlighter, Loader2, LucideIcon, @@ -32,6 +34,7 @@ import { } from "lucide-react"; import { makeDualDialogue } from "@src/lib/screenplay/dual-dialogue"; import { extractShelveCandidate } from "@src/lib/shelf/shelf-utils"; +import { omitSceneByUuid, unomitSceneByUuid } from "@src/lib/screenplay/scene-locking"; import { ScreenplayElement } from "@src/lib/utils/enums"; /* ==================== */ @@ -107,9 +110,22 @@ export type SceneContextProps = { const SceneItemMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); const userCtx = useContext(UserContext); - const { editor, isReadOnly } = useContext(ProjectContext); + const { editor, isReadOnly, repository } = useContext(ProjectContext); const scene: Scene = props.scene; + const canOmit = !!scene.id && !scene.omitted; + const canUnomit = !!scene.id && !!scene.omitted; + + const handleOmit = () => { + if (!repository || !scene.id) return; + omitSceneByUuid(repository, scene.id); + }; + + const handleUnomit = () => { + if (!repository || !scene.id) return; + unomitSceneByUuid(repository, scene.id); + }; + return ( <> ) => { text={t("cut")} action={() => cutText(editor!, scene.position, scene.nextPosition)} /> + {canOmit && ( + + )} + {canUnomit && ( + + )} )} @@ -456,12 +478,47 @@ export type EditorContextMenuProps = { const EditorContextMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); - const { editor, repository, isReadOnly } = useContext(ProjectContext); + const { editor, repository, isReadOnly, persistentScenes } = + useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); const { from, to, onAddComment, spellError, nodePos, nodeClass } = props; const hasSelection = from !== to; + // Resolve scene UUID + lock state if right-clicked on a scene heading. + // `nodePos` is the cursor position inside the paragraph (from the editor + // dispatcher), so we resolve up to the depth-1 ancestor — the scene

+ // node itself — rather than calling `doc.nodeAt(nodePos)` which would + // return the inner text node. + const sceneInfo = (() => { + if (nodeClass !== ScreenplayElement.Scene || nodePos === undefined || !editor) { + return null; + } + let sceneNode; + try { + sceneNode = editor.state.doc.resolve(nodePos).node(1); + } catch { + return null; + } + if (!sceneNode || sceneNode.attrs?.class !== ScreenplayElement.Scene) return null; + const uuid: string | undefined = sceneNode.attrs?.["data-id"]; + if (!uuid) return null; + const entry = persistentScenes[uuid]; + return { uuid, isOmitted: !!entry?.omitted }; + })(); + + const handleOmitScene = () => { + if (!repository || !sceneInfo) return; + omitSceneByUuid(repository, sceneInfo.uuid); + updateContextMenu(undefined); + }; + + const handleUnomitScene = () => { + if (!repository || !sceneInfo) return; + unomitSceneByUuid(repository, sceneInfo.uuid); + updateContextMenu(undefined); + }; + const [suggestions, setSuggestions] = useState(null); const displaySuggestions = spellError && !worker ? [] : suggestions; @@ -624,6 +681,26 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} )} + + {/* Production: Omit / Unomit on a locked scene heading */} + {sceneInfo && !isReadOnly && ( + <> +

+ {sceneInfo.isOmitted ? ( + + ) : ( + + )} + + )} ); }; diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index dcca095e..4461fb37 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -1,12 +1,13 @@ "use client"; import { join } from "@src/lib/utils/misc"; -import { useContext, useState, useCallback, useRef, useEffect } from "react"; +import { useContext, useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { useViewContext } from "@src/context/ViewContext"; import { Scene } from "@src/lib/screenplay/scenes"; import { focusOnPosition } from "@src/lib/screenplay/editor"; +import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; import { Archive, Clapperboard, MessageSquare } from "lucide-react"; import SidebarSceneItem from "./SidebarSceneItem"; import ShelfSidebarView from "./ShelfSidebarView"; @@ -17,7 +18,7 @@ import sidebar_nav from "./EditorSidebarNavigation.module.css"; const EditorSidebarNavigation = () => { const t = useTranslations("editorSidebar"); - const { scenes, updateScenes, editor } = useContext(ProjectContext); + const { scenes, updateScenes, editor, sceneLocking, sceneNumberingStyle, persistentScenes } = useContext(ProjectContext); const { leftSidebarOpen } = useViewContext(); const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments">("scenes"); @@ -31,6 +32,21 @@ const EditorSidebarNavigation = () => { // Track which scene the cursor is currently in const [currentSceneIndex, setCurrentSceneIndex] = useState(null); + // Compute display labels and omitted flags for every scene. When locking is + // off we fall back to positional numbers so the user always has a number to + // navigate by. + const sceneDisplays = useMemo(() => { + if (sceneLocking) { + const uuids = scenes.map((s) => s.id ?? ""); + const labels = computeSceneLabels(uuids, persistentScenes, sceneNumberingStyle); + return scenes.map((_, i) => ({ + label: labels[i]?.label ?? `${i + 1}`, + isOmitted: labels[i]?.status === "omitted", + })); + } + return scenes.map((_, i) => ({ label: `${i + 1}`, isOmitted: false })); + }, [scenes, sceneLocking, sceneNumberingStyle, persistentScenes]); + const listRef = useRef(null); const currentSceneRef = useRef(null); const scenesRef = useRef(scenes); @@ -202,12 +218,15 @@ const EditorSidebarNavigation = () => { indicatorIndex === dragIndex + 1; const showIndicator = !isNoOp && indicatorIndex === index; const isCurrent = index === currentSceneIndex; + const display = sceneDisplays[index]; return ( ; onPointerDown: (index: number, e: React.PointerEvent) => void; onDoubleClick: (scene: Scene) => void; }; -const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, isCurrent, scrollRef, onPointerDown, onDoubleClick }: SidebarSceneItemProps) => { +const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, isCurrent, label, isOmitted, scrollRef, onPointerDown, onDoubleClick }: SidebarSceneItemProps) => { const { updateContextMenu } = useContext(UserContext); const handleDropdown = (e: React.MouseEvent) => { @@ -43,6 +47,7 @@ const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, is // Show synopsis if available, otherwise show preview const displayText = scene.synopsis || scene.preview; + const titleText = isOmitted ? "OMITTED" : scene.title; const containerClass = join( nav_item.container, @@ -64,8 +69,9 @@ const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, is {scene.color && ( )} -

{scene.title}

- {/*scene.id && */} +

+ {label}. {titleText} +

diff --git a/components/navbar/ProductionPanel.module.css b/components/navbar/ProductionPanel.module.css new file mode 100644 index 00000000..1f45f8e5 --- /dev/null +++ b/components/navbar/ProductionPanel.module.css @@ -0,0 +1,174 @@ +.container { + composes: panel from "./navbar-shared.module.css"; + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 100; + width: 340px; + max-height: 480px; + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--separator); + flex-shrink: 0; +} + +.title { + font-size: 0.85rem; + font-weight: 600; + color: var(--primary-text); +} + +.close_btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--secondary-text); + cursor: pointer; + border-radius: 4px; +} + +.close_btn:hover { + background-color: var(--tertiary); + color: var(--primary-text); +} + +/* Section */ +.section { + padding: 12px 16px; + border-bottom: 1px solid var(--separator); +} + +.section:last-child { + border-bottom: none; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 22px; +} + +.row_main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.row_actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.row_icon { + color: var(--secondary-text); + flex-shrink: 0; +} + +.row_label { + font-size: 0.85rem; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Provisional labels preview — boxed sub-section under the locking toggle. */ +.provisional_box { + margin-top: 10px; + padding: 10px 12px; + background-color: var(--primary); + border: 1px solid var(--separator); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.provisional_title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--secondary-text); + opacity: 0.8; +} + +.provisional_list { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow-y: auto; +} + +.provisional_label { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--primary-text); + background: color-mix(in srgb, var(--primary-text) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-text) 30%, transparent); + border-radius: 20px; +} + +.relock_btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 10px; + border: none; + background-color: var(--tertiary); + color: var(--primary-text); + border-radius: 12px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.relock_btn:hover:not(:disabled) { + opacity: 0.75; +} + +.relock_btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Revision swatches */ +.swatches { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.swatch { + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid var(--separator); + opacity: 0.5; + cursor: not-allowed; +} diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx new file mode 100644 index 00000000..580487b0 --- /dev/null +++ b/components/navbar/ProductionPanel.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Lock, X } from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; +import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; +import { computeSceneItems } from "@src/lib/screenplay/scenes"; +import { unlockScenesPopup } from "@src/lib/screenplay/popup"; +import Switch from "@components/utils/Switch"; + +import styles from "./ProductionPanel.module.css"; + +interface ProductionPanelProps { + isOpen: boolean; + onClose: () => void; +} + +const REVISION_COLORS = [ + "#ffffff", // white + "#bbdfff", // blue + "#ffb6c1", // pink + "#ffea7a", // yellow + "#a5d6a7", // green + "#d4a017", // goldenrod + "#e0c58b", // buff + "#fa8072", // salmon + "#9b1c2a", // cherry +]; + +const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { + const t = useTranslations("production"); + const { sceneLocking, sceneNumberingStyle, persistentScenes, scenes, repository, isReadOnly } = + useContext(ProjectContext); + const userCtx = useContext(UserContext); + + const panelRef = useRef(null); + + // Click outside to close + useEffect(() => { + if (!isOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose]); + + const sceneUuids = useMemo( + () => scenes.map((s) => s.id).filter((id): id is string => !!id), + [scenes], + ); + + const labels = useMemo( + () => + sceneLocking + ? computeSceneLabels(sceneUuids, persistentScenes, sceneNumberingStyle) + : [], + [sceneLocking, sceneUuids, persistentScenes, sceneNumberingStyle], + ); + + const provisionalLabels = useMemo( + () => labels.filter((l) => l.status === "provisional"), + [labels], + ); + + // Stable across renders: the popup keeps a reference to this callback. + const performUnlock = useCallback(() => { + if (!repository) return; + repository.transact(() => { + repository.clearSceneLocks(); + repository.setSceneLocking(false); + }); + }, [repository]); + + const handleSceneLockingToggle = (next: boolean) => { + if (!repository || isReadOnly) return; + if (next) { + repository.transact(() => { + const currentScreenplay = repository.screenplay; + const scenes = computeSceneItems(currentScreenplay); + const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); + + // Idempotent: any scene that already has a token (e.g. left + // over from an earlier session, or that survived an unlock + // in read-only mode) keeps its frozen label. Only scenes + // computed as provisional by `computeSceneLabels` get a new + // token written. On a fresh lock-on with no existing + // tokens, this falls through to baseToken(idx+1) for every + // scene, matching the previous behaviour. + const persistentSnapshot = repository.scenes; + const labels = computeSceneLabels(uuids, persistentSnapshot, sceneNumberingStyle); + + labels.forEach((label) => { + if (label.status === "provisional") { + repository.upsertScene(label.uuid, { token: label.token }); + } + }); + + repository.setSceneLocking(true); + }); + } else { + unlockScenesPopup(performUnlock, userCtx); + } + }; + + const handleRelock = () => { + if (!repository || isReadOnly) return; + repository.transact(() => { + const currentScreenplay = repository.screenplay; + const scenes = computeSceneItems(currentScreenplay); + const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); + + // Re-read fresh persistent data + const persistentSnapshot = repository.scenes; + + const currentLabels = computeSceneLabels( + uuids, + persistentSnapshot, + sceneNumberingStyle, + ); + + console.log("[ProductionPanel] RELOCKING PROVISIONAL. Full snapshot:", currentLabels.map(l => ({ + uuid: l.uuid, + label: l.label, + status: l.status, + token: l.token + }))); + + let relockedCount = 0; + currentLabels.forEach((label) => { + if (label.status === "provisional") { + console.log(`[ProductionPanel] -> Freezing ${label.uuid} as "${label.label}"`); + repository.upsertScene(label.uuid, { token: label.token }); + relockedCount++; + } + }); + + console.log(`[ProductionPanel] Relock complete. Persisted ${relockedCount} tokens.`); + }); + }; + + if (!isOpen) return null; + + return ( +
+
+ {t("title")} + +
+ + {/* Scene Locking */} +
+
+
+ + {t("sceneLocking")} +
+
+ {sceneLocking && provisionalLabels.length > 0 && ( + + )} + +
+
+ + {sceneLocking && provisionalLabels.length > 0 && ( +
+
{t("provisionalTitle")}
+
+ {provisionalLabels.map((l, idx) => ( + + {l.label} + + ))} +
+
+ )} +
+ + {/* Page Locking (inert in v1) */} +
+
+
+ {t("pageLocking")} +
+ {}} ariaLabel={t("pageLocking")} /> +
+
+ + {/* Revisions (inert in v1) */} +
+
+
+ {t("revisions")} +
+ {}} ariaLabel={t("revisions")} /> +
+
+ {REVISION_COLORS.map((color, idx) => ( + + ))} +
+
+
+ ); +}; + +export default ProductionPanel; diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index 839b9011..3d9e8edb 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -20,6 +20,7 @@ import { CircleCheckBig, CloudUpload, History, + Lock, Monitor, Settings, WifiOff, @@ -27,6 +28,7 @@ import { } from "lucide-react"; import AnalyticsModal from "@components/analytics/AnalyticsModal"; import SavesPanel from "./SavesPanel"; +import ProductionPanel from "./ProductionPanel"; import navbar from "./ProjectNavbar.module.css"; import navBtn from "@components/utils/NavbarIconButton.module.css"; @@ -97,6 +99,7 @@ const ProjectNavbar = () => { const [projectTitle, setProjectTitle] = useState(""); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); const [isSavesOpen, setIsSavesOpen] = useState(false); + const [isProductionOpen, setIsProductionOpen] = useState(false); const [isLocalOnly, setIsLocalOnly] = useState(null); const isLocalEdit = useRef(false); @@ -250,6 +253,26 @@ const ProjectNavbar = () => { isPro={isPro} />
+
+
setIsProductionOpen(!isProductionOpen)} + > + +
+ setIsProductionOpen(false)} + /> +
)} diff --git a/components/popup/Popup.module.css b/components/popup/Popup.module.css index 031a81cf..e8759d59 100644 --- a/components/popup/Popup.module.css +++ b/components/popup/Popup.module.css @@ -108,33 +108,25 @@ font-size: 16px !important; } -/* Popup confirm button */ -.import_confirm { - border-width: 2px !important; - border-style: dashed !important; - border-color: var(--error) !important; -} +/* Popup buttons — reuse the rounded "modal button" styling from DangerZone + so every modal/popup shares one consistent look. The popup container + stretches them to fill its width via `width: 100%` (DangerZone uses a + flex-column wrapper instead, so its variant doesn't need that). Use + these classes STANDALONE — do not pair with form.btn, since its + !important border/radius rules would override the composed styling. */ .confirm { - background-color: var(--secondary); - border-radius: 8px !important; - transition: background-color 0.15s ease !important; -} - -.confirm:hover { - background-color: var(--secondary-hover) !important; + composes: modalBtn from "../utils/ModalBtn.module.css"; + width: 100% !important; } -.confirm:disabled { - filter: brightness(60%); - cursor: not-allowed; +.import_confirm { + composes: modalBtn modalBtnDanger from "../utils/ModalBtn.module.css"; + width: 100% !important; } .cancel { + composes: modalBtn modalBtnCancel from "../utils/ModalBtn.module.css"; + width: 100% !important; margin-top: 10px; - background-color: var(--tertiary) !important; -} - -.cancel:hover { - background-color: var(--tertiary-hover) !important; } diff --git a/components/popup/Popup.tsx b/components/popup/Popup.tsx index 570411d9..f0950752 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,12 +7,14 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockScenesData, PopupUploadToCloudData, } from "@src/lib/screenplay/popup"; import { useContext } from "react"; import PopupCharacterItem from "./PopupCharacterItem"; import PopupImportFile from "./PopupImportFile"; import PopupSceneItem from "./PopupSceneItem"; +import PopupUnlockScenes from "./PopupUnlockScenes"; import PopupUploadToCloud from "./PopupUploadToCloud"; export const Popup = () => { @@ -30,6 +32,8 @@ export const Popup = () => { return )} />; case PopupType.UploadToCloud: return )} />; + case PopupType.UnlockScenes: + return )} />; default: return null; } diff --git a/components/popup/PopupCharacterItem.tsx b/components/popup/PopupCharacterItem.tsx index 221804a5..8fe8a818 100644 --- a/components/popup/PopupCharacterItem.tsx +++ b/components/popup/PopupCharacterItem.tsx @@ -274,7 +274,7 @@ export const PopupCharacterItem = ({ type, data: { character } }: PopupData
- -
diff --git a/components/popup/PopupSceneItem.tsx b/components/popup/PopupSceneItem.tsx index 7814df40..72ac3fee 100644 --- a/components/popup/PopupSceneItem.tsx +++ b/components/popup/PopupSceneItem.tsx @@ -74,7 +74,7 @@ export const PopupSceneItem = ({ data: { scene } }: PopupData) = placeholder={t("synopsisPlaceholder")} />
- diff --git a/components/popup/PopupUnlockScenes.tsx b/components/popup/PopupUnlockScenes.tsx new file mode 100644 index 00000000..930b6dcf --- /dev/null +++ b/components/popup/PopupUnlockScenes.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { X, Unlock } from "lucide-react"; + +import popup from "./Popup.module.css"; + +import { useDraggable } from "@src/lib/utils/hooks"; +import { PopupData, PopupUnlockScenesData, closePopup } from "@src/lib/screenplay/popup"; +import { UserContext } from "@src/context/UserContext"; + +const PopupUnlockScenes = ({ data: { confirmUnlock } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("production"); + + const onConfirm = () => { + confirmUnlock(); + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("unlockTitle")}

+ closePopup(userCtx)} /> +
+
+

{t("unlockWarning")}

+
+ + +
+
+ ); +}; + +export default PopupUnlockScenes; diff --git a/components/popup/PopupUploadToCloud.tsx b/components/popup/PopupUploadToCloud.tsx index faf1c3ba..58218b8e 100644 --- a/components/popup/PopupUploadToCloud.tsx +++ b/components/popup/PopupUploadToCloud.tsx @@ -1,10 +1,8 @@ "use client"; import popup from "./Popup.module.css"; -import form from "../utils/Form.module.css"; import { X } from "lucide-react"; -import { join } from "@src/lib/utils/misc"; import { useDraggable } from "@src/lib/utils/hooks"; import { PopupData, PopupUploadToCloudData, closePopup } from "@src/lib/screenplay/popup"; import { useContext, useState } from "react"; @@ -58,14 +56,14 @@ const PopupUploadToCloud = ({ data: { projectId } }: PopupData {info && } + ); +}; + +export default Switch; diff --git a/messages/de.json b/messages/de.json index 1471557c..cc8993df 100644 --- a/messages/de.json +++ b/messages/de.json @@ -81,6 +81,7 @@ "tabs": { "General": "Allgemein", "Layout": "Layout", + "Production": "Produktion", "Export": "Import/Export", "Collaborators": "Mitwirkende", "Keybinds": "Tastenkürzel", @@ -390,7 +391,9 @@ "shelve": "Ablegen", "shelveScene": "Szene ablegen", "shelveDialogue": "Dialog ablegen", - "shelveAction": "Aktion ablegen" + "shelveAction": "Aktion ablegen", + "omitScene": "Szene auslassen", + "unomitScene": "Szene wiederherstellen" }, "popup": { "character": { @@ -446,6 +449,26 @@ "titlePlaceholder": "Titel", "descriptionPlaceholder": "Beschreibung" }, + "production": { + "title": "Produktion", + "sceneLocking": "Szenen sperren", + "pageLocking": "Seiten sperren", + "revisions": "Revisionen", + "relock": "Sperren", + "provisionalTitle": "Nicht gesperrte Szenen", + "unlockTitle": "Szenennummern entsperren?", + "unlockWarning": "Alle gesperrten und ausgelassenen Szenen verlieren ihre fixierte Nummerierung.", + "unlockInfo": "Dies betrifft alle Mitarbeiter. Das Drehbuch kehrt zu positionalen Szenennummern zurück. OMITTED-Szenen bleiben ausgelassen — heben Sie die Auslassung einzeln auf, um den Inhalt wiederherzustellen.", + "unlock": "Entsperren", + "cancel": "Abbrechen", + "numberingStyleTitle": "Szenennummerierung", + "numberingStyleHelp": "Wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden. Suffix verweist auf die vorherige Szene, Präfix auf die nächste.", + "suffixName": "Suffix", + "prefixName": "Präfix", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Bereits gesperrte Szenen behalten ihre Nummer — nur vorläufige Szenen und zukünftige Sperrungen sind betroffen." + }, "saves": { "title": "Versionsverlauf", "saveCurrentVersion": "Aktuelle Version speichern", diff --git a/messages/en.json b/messages/en.json index 797eb080..3c775230 100644 --- a/messages/en.json +++ b/messages/en.json @@ -80,6 +80,7 @@ "tabs": { "General": "General", "Layout": "Layout", + "Production": "Production", "Export": "Import/Export", "Collaborators": "Collaborators", "Keybinds": "Keybinds", @@ -389,7 +390,9 @@ "shelve": "Shelve", "shelveScene": "Shelve scene", "shelveDialogue": "Shelve dialogue", - "shelveAction": "Shelve action" + "shelveAction": "Shelve action", + "omitScene": "Omit scene", + "unomitScene": "Unomit scene" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "Title", "descriptionPlaceholder": "Description" }, + "production": { + "title": "Production", + "sceneLocking": "Scene locking", + "pageLocking": "Page locking", + "revisions": "Revisions", + "relock": "Relock", + "provisionalTitle": "Non-locked scenes", + "unlockTitle": "Unlock scenes", + "unlockWarning": "All scenes will lose their locked numbering, reverting to their initial positional numbering.", + "unlockInfo": "This affects every collaborator. The screenplay will revert to positional scene numbers. OMITTED scenes stay omitted — unomit them individually if you want their content back.", + "unlock": "Unlock", + "cancel": "Cancel", + "numberingStyleTitle": "Scene numbering", + "numberingStyleHelp": "How newly inserted scenes are numbered between two locked scenes. Suffix references the previous scene, prefix references the next.", + "suffixName": "Suffix", + "prefixName": "Prefix", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Already-locked scenes keep their stored number — only provisional scenes and future locks are affected." + }, "saves": { "title": "Version History", "saveCurrentVersion": "Save Current Version", diff --git a/messages/es.json b/messages/es.json index 0cda76fd..d809cea1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -80,6 +80,7 @@ "tabs": { "General": "General", "Layout": "Diseño", + "Production": "Producción", "Export": "Importar/Exportar", "Collaborators": "Colaboradores", "Keybinds": "Atajos de teclado", @@ -389,7 +390,9 @@ "shelve": "Archivar", "shelveScene": "Archivar escena", "shelveDialogue": "Archivar diálogo", - "shelveAction": "Archivar acción" + "shelveAction": "Archivar acción", + "omitScene": "Omitir escena", + "unomitScene": "Restaurar escena" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "Título", "descriptionPlaceholder": "Descripción" }, + "production": { + "title": "Producción", + "sceneLocking": "Bloquear escenas", + "pageLocking": "Bloquear páginas", + "revisions": "Revisiones", + "relock": "Bloquear", + "provisionalTitle": "Escenas no bloqueadas", + "unlockTitle": "¿Desbloquear los números de escena?", + "unlockWarning": "Todas las escenas bloqueadas y omitidas perderán su numeración fija.", + "unlockInfo": "Esto afecta a todos los colaboradores. El guion volverá a una numeración posicional. Las escenas OMITTED siguen omitidas — anula la omisión individualmente si quieres recuperar su contenido.", + "unlock": "Desbloquear", + "cancel": "Cancelar", + "numberingStyleTitle": "Numeración de escenas", + "numberingStyleHelp": "Cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas. El sufijo se refiere a la escena anterior, el prefijo a la siguiente.", + "suffixName": "Sufijo", + "prefixName": "Prefijo", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Las escenas ya bloqueadas conservan su número — solo se ven afectadas las escenas provisionales y los futuros bloqueos." + }, "saves": { "title": "Historial de versiones", "saveCurrentVersion": "Guardar versión actual", diff --git a/messages/fr.json b/messages/fr.json index 5253634c..88569350 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -81,6 +81,7 @@ "tabs": { "General": "Général", "Layout": "Mise en page", + "Production": "Production", "Export": "Import/Export", "Collaborators": "Collaborateurs", "Keybinds": "Raccourcis", @@ -390,7 +391,9 @@ "shelve": "Mettre de côté", "shelveScene": "Mettre la scène de côté", "shelveDialogue": "Mettre le dialogue de côté", - "shelveAction": "Mettre l'action de côté" + "shelveAction": "Mettre l'action de côté", + "omitScene": "Omettre la scène", + "unomitScene": "Restaurer la scène" }, "popup": { "character": { @@ -446,6 +449,26 @@ "titlePlaceholder": "Titre", "descriptionPlaceholder": "Description" }, + "production": { + "title": "Production", + "sceneLocking": "Verrouillage des scènes", + "pageLocking": "Verrouillage des pages", + "revisions": "Révisions", + "relock": "Verrouiller", + "provisionalTitle": "Scènes non verrouillées", + "unlockTitle": "Déverrouiller les numéros de scène ?", + "unlockWarning": "Toutes les scènes verrouillées et omises perdront leur numérotation figée.", + "unlockInfo": "Ceci affecte tous les collaborateurs. Le scénario reviendra à une numérotation positionnelle. Les scènes OMITTED restent omises — démasquez-les individuellement si vous souhaitez en récupérer le contenu.", + "unlock": "Déverrouiller", + "cancel": "Annuler", + "numberingStyleTitle": "Numérotation des scènes", + "numberingStyleHelp": "Comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées. Le suffixe se réfère à la scène précédente, le préfixe à la suivante.", + "suffixName": "Suffixe", + "prefixName": "Préfixe", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Les scènes déjà verrouillées conservent leur numéro — seules les scènes provisoires et les futurs verrouillages sont concernés." + }, "saves": { "title": "Historique des versions", "saveCurrentVersion": "Enregistrer la version actuelle", diff --git a/messages/ja.json b/messages/ja.json index 2beb8a09..2a981d11 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -80,6 +80,7 @@ "tabs": { "General": "全般", "Layout": "レイアウト", + "Production": "プロダクション", "Export": "インポート/エクスポート", "Collaborators": "共同作業者", "Keybinds": "ショートカットキー", @@ -389,7 +390,9 @@ "shelve": "保管する", "shelveScene": "シーンを保管する", "shelveDialogue": "セリフを保管する", - "shelveAction": "アクションを保管する" + "shelveAction": "アクションを保管する", + "omitScene": "シーンを省略", + "unomitScene": "シーンを復元" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "タイトル", "descriptionPlaceholder": "説明" }, + "production": { + "title": "プロダクション", + "sceneLocking": "シーンロック", + "pageLocking": "ページロック", + "revisions": "改訂", + "relock": "ロック", + "provisionalTitle": "未ロックのシーン", + "unlockTitle": "シーン番号のロックを解除しますか?", + "unlockWarning": "ロック済みおよび省略されたシーンの固定番号はすべて失われます。", + "unlockInfo": "この操作はすべてのコラボレーターに影響します。脚本は位置ベースの番号に戻ります。OMITTED シーンは省略のまま残ります — 内容を復元したい場合は個別に省略を解除してください。", + "unlock": "ロック解除", + "cancel": "キャンセル", + "numberingStyleTitle": "シーン番号", + "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法。サフィックスは前のシーン、プレフィックスは次のシーンを参照します。", + "suffixName": "サフィックス", + "prefixName": "プレフィックス", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "すでにロックされているシーンは番号を保持します — 仮シーンと今後のロックのみが影響を受けます。" + }, "saves": { "title": "バージョン履歴", "saveCurrentVersion": "現在のバージョンを保存", diff --git a/messages/ko.json b/messages/ko.json index c9edda13..93fbb859 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -80,6 +80,7 @@ "tabs": { "General": "일반", "Layout": "레이아웃", + "Production": "프로덕션", "Export": "가져오기/내보내기", "Collaborators": "공동 작업자", "Keybinds": "단축키", @@ -389,7 +390,9 @@ "shelve": "보관하기", "shelveScene": "장면 보관하기", "shelveDialogue": "대사 보관하기", - "shelveAction": "행동 보관하기" + "shelveAction": "행동 보관하기", + "omitScene": "씬 생략", + "unomitScene": "씬 복원" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "제목", "descriptionPlaceholder": "설명" }, + "production": { + "title": "프로덕션", + "sceneLocking": "씬 잠금", + "pageLocking": "페이지 잠금", + "revisions": "개정", + "relock": "잠그기", + "provisionalTitle": "잠기지 않은 씬", + "unlockTitle": "씬 번호 잠금을 해제하시겠습니까?", + "unlockWarning": "잠긴 씬과 생략된 씬의 고정 번호가 모두 사라집니다.", + "unlockInfo": "이 작업은 모든 협업자에게 영향을 미칩니다. 시나리오는 위치 기반 씬 번호로 되돌아갑니다. OMITTED 씬은 그대로 유지되며, 내용을 복원하려면 개별적으로 생략을 해제하세요.", + "unlock": "잠금 해제", + "cancel": "취소", + "numberingStyleTitle": "씬 번호", + "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식. 접미사는 이전 씬을, 접두사는 다음 씬을 참조합니다.", + "suffixName": "접미사", + "prefixName": "접두사", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "이미 잠긴 씬은 번호를 유지합니다 — 임시 씬과 향후 잠금에만 영향을 미칩니다." + }, "saves": { "title": "버전 히스토리", "saveCurrentVersion": "현재 버전 저장", diff --git a/messages/pl.json b/messages/pl.json index 72f512bf..e21382c1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -80,6 +80,7 @@ "tabs": { "General": "Ogólne", "Layout": "Układ", + "Production": "Produkcja", "Export": "Import/Eksport", "Collaborators": "Współpracownicy", "Keybinds": "Skróty klawiszowe", @@ -389,7 +390,9 @@ "shelve": "Odłóż", "shelveScene": "Odłóż scenę", "shelveDialogue": "Odłóż dialog", - "shelveAction": "Odłóż akcję" + "shelveAction": "Odłóż akcję", + "omitScene": "Pomiń scenę", + "unomitScene": "Przywróć scenę" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "Tytuł", "descriptionPlaceholder": "Opis" }, + "production": { + "title": "Produkcja", + "sceneLocking": "Blokowanie scen", + "pageLocking": "Blokowanie stron", + "revisions": "Wersje", + "relock": "Zablokuj", + "provisionalTitle": "Niezablokowane sceny", + "unlockTitle": "Odblokować numery scen?", + "unlockWarning": "Wszystkie zablokowane i pominięte sceny utracą zamrożoną numerację.", + "unlockInfo": "Wpłynie to na wszystkich współpracowników. Scenariusz powróci do numeracji pozycyjnej. Sceny OMITTED pozostają pominięte — anuluj pominięcie pojedynczo, aby odzyskać ich zawartość.", + "unlock": "Odblokuj", + "cancel": "Anuluj", + "numberingStyleTitle": "Numeracja scen", + "numberingStyleHelp": "Jak są numerowane nowo wstawione sceny między dwiema zablokowanymi. Sufiks odnosi się do poprzedniej sceny, prefiks do następnej.", + "suffixName": "Sufiks", + "prefixName": "Prefiks", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Już zablokowane sceny zachowują swój numer — wpływa tylko na sceny tymczasowe i przyszłe blokady." + }, "saves": { "title": "Historia wersji", "saveCurrentVersion": "Zapisz aktualną wersję", diff --git a/messages/zh.json b/messages/zh.json index ea963b3f..09623192 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -80,6 +80,7 @@ "tabs": { "General": "常规", "Layout": "布局", + "Production": "制作", "Export": "导入/导出", "Collaborators": "协作", "Keybinds": "键位", @@ -389,7 +390,9 @@ "shelve": "搁置", "shelveScene": "搁置场景", "shelveDialogue": "搁置对白", - "shelveAction": "搁置动作" + "shelveAction": "搁置动作", + "omitScene": "省略场景", + "unomitScene": "恢复场景" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "标题", "descriptionPlaceholder": "描述" }, + "production": { + "title": "制作", + "sceneLocking": "锁定场景", + "pageLocking": "锁定页面", + "revisions": "修订", + "relock": "锁定", + "provisionalTitle": "未锁定的场景", + "unlockTitle": "解锁场景编号?", + "unlockWarning": "所有锁定和省略的场景都将失去固定编号。", + "unlockInfo": "此操作会影响所有协作者。剧本将恢复为按位置编号。OMITTED 场景仍保持省略状态 — 如需恢复内容,请单独取消省略。", + "unlock": "解锁", + "cancel": "取消", + "numberingStyleTitle": "场景编号", + "numberingStyleHelp": "两个锁定场景之间新插入场景的编号方式。后缀参考上一个场景,前缀参考下一个场景。", + "suffixName": "后缀", + "prefixName": "前缀", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "已锁定的场景保留其编号 — 仅影响临时场景和将来的锁定。" + }, "saves": { "title": "版本历史", "saveCurrentVersion": "保存当前版本", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index f13d1f2b..e30c5a05 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -97,6 +97,16 @@ export interface ProjectContextType { elementStyles: Record; setElementStyles: (styles: Record) => void; + // Production + sceneLocking: boolean; + setSceneLocking: (locked: boolean) => void; + sceneNumberingStyle: "suffix" | "prefix"; + setSceneNumberingStyle: (style: "suffix" | "prefix") => void; + /** Raw persistent scene map (UUID → PersistentScene). Includes synopsis, + * color, and production-lock fields (token, omitted) for every scene that + * has been persisted. */ + persistentScenes: PersistentSceneMap; + // Search state searchTerm: string; setSearchTerm: (term: string) => void; @@ -173,6 +183,11 @@ const defaultContextValue: ProjectContextType = { setElementMargins: () => {}, elementStyles: {}, setElementStyles: () => {}, + sceneLocking: false, + setSceneLocking: () => {}, + sceneNumberingStyle: "suffix", + setSceneNumberingStyle: () => {}, + persistentScenes: {}, characters: {}, locations: {}, scenes: [], @@ -292,6 +307,10 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = Record >({}); const [elementStyles, setElementStylesState] = useState>({}); + const [sceneLocking, setSceneLockingState] = useState(false); + const [sceneNumberingStyle, setSceneNumberingStyleState] = + useState<"suffix" | "prefix">("suffix"); + const [persistentScenes, setPersistentScenesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -469,8 +488,17 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialLayout.elementStyles !== undefined) { setElementStylesState(initialLayout.elementStyles); } + if (initialLayout.sceneLocking !== undefined) { + setSceneLockingState(initialLayout.sceneLocking); + } + if (initialLayout.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(initialLayout.sceneNumberingStyle); + } } + // Read initial persistent scenes + setPersistentScenesState(repository.scenes); + // Observe layout changes const unsubscribeLayout = repository.observeLayout((layout: Partial) => { const _pageSize = layout.pageSize; @@ -509,6 +537,12 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (layout.elementStyles !== undefined) { setElementStylesState(layout.elementStyles); } + if (layout.sceneLocking !== undefined) { + setSceneLockingState(layout.sceneLocking); + } + if (layout.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(layout.sceneNumberingStyle); + } }); // Observe character changes - get current screenplay from repository @@ -530,6 +564,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const currentScreenplay = repository.screenplay; const allScenes = mergeScenesData(_scenes, currentScreenplay); updateScenes(allScenes); + setPersistentScenesState(_scenes); }); // Observe metadata changes (for title page placeholders) @@ -707,6 +742,22 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setSceneLocking = useCallback( + (locked: boolean) => { + setSceneLockingState(locked); + repository?.setSceneLocking(locked); + }, + [repository], + ); + + const setSceneNumberingStyle = useCallback( + (style: "suffix" | "prefix") => { + setSceneNumberingStyleState(style); + repository?.setSceneNumberingStyle(style); + }, + [repository], + ); + const setSearchTerm = useCallback((term: string) => { setSearchTermState(term); // Reset to first match when search term changes @@ -791,6 +842,11 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setElementMargins, elementStyles, setElementStyles, + sceneLocking, + setSceneLocking, + sceneNumberingStyle, + setSceneNumberingStyle, + persistentScenes, screenplay, scenes, updateScenes, @@ -855,6 +911,11 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setElementMargins, elementStyles, setElementStyles, + sceneLocking, + setSceneLocking, + sceneNumberingStyle, + setSceneNumberingStyle, + persistentScenes, screenplay, scenes, updateScenes, diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 36ca1821..9b23880c 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -1,11 +1,13 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { ProjectData, ProjectState } from "@src/lib/project/project-state"; +import type { PersistentSceneMap } from "@src/lib/screenplay/scenes"; import { PageFormat } from "@src/lib/utils/enums"; import { getFontForCodePoint, ScriptFont } from "./pdf-utils"; import type { TextRun } from "./pdf.worker"; import { BASE_URL } from "@src/lib/utils/constants"; import { PAGE_SIZES } from "@src/lib/screenplay/extensions/pagination-extension"; +import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -21,6 +23,10 @@ export type PDFExportOptions = BaseExportOptions & { moreLabel?: string; editorElement?: HTMLElement; titlePageElement?: HTMLElement; + /** Production-mode flag + persistent scene entries, set internally from the ProjectState. */ + sceneLocking?: boolean; + sceneNumberingStyle?: "suffix" | "prefix"; + persistentScenes?: PersistentSceneMap; }; import type { WorkerMessage, WorkerPayload, VisualLine } from "./pdf.worker"; @@ -72,13 +78,27 @@ export class PDFAdapter extends ProjectAdapter { const format = options.format; const pdfPageSize = PDF_PAGE_SIZES[format]; + // Resolve production state from the project. Persistent scenes carry + // synopsis/color (irrelevant here) and token/omitted (used for labels). + const layout = project.layout().toJSON() as { + sceneLocking?: boolean; + sceneNumberingStyle?: "suffix" | "prefix"; + }; + const persistentScenes = project.scenes().toJSON() as PersistentSceneMap; + const enrichedOptions: PDFExportOptions = { + ...options, + sceneLocking: !!layout.sceneLocking, + sceneNumberingStyle: layout.sceneNumberingStyle ?? "suffix", + persistentScenes, + }; + // ── Collect all visual lines from the browser DOM ─────────────────── const titlePageEl = options.titlePageElement; - const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, options) : []; + const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, enrichedOptions) : []; const titlePageLeftPx = titlePageEl ? this.getPageLeftPx(titlePageEl) : 0; - const screenplayLines = this.collectLines(editorEl, options); + const screenplayLines = this.collectLines(editorEl, enrichedOptions); const screenplayLeftPx = this.getPageLeftPx(editorEl); return new Promise((resolve, reject) => { @@ -142,6 +162,28 @@ export class PDFAdapter extends ProjectAdapter { let sceneCount = 0; let yOffset = 0; + // Pre-compute label per scene UUID when production lock is on. + const sceneLabels: { label: string; omitted: boolean }[] = []; + let sceneLabelIdx = 0; + if (options.sceneLocking) { + const uuids: string[] = []; + for (let i = 0; i < editorEl.children.length; i++) { + const child = editorEl.children[i] as HTMLElement; + if (child?.tagName === "P" && child.classList.contains("scene")) { + const uuid = child.getAttribute("data-id"); + if (uuid) uuids.push(uuid); + } + } + const computed = computeSceneLabels( + uuids, + options.persistentScenes ?? {}, + options.sceneNumberingStyle ?? "suffix", + ); + for (const l of computed) { + sceneLabels.push({ label: l.label, omitted: l.status === "omitted" }); + } + } + for (let i = 0; i < editorEl.children.length; i++) { const el = editorEl.children[i] as HTMLElement; if (!el) continue; @@ -164,6 +206,11 @@ export class PDFAdapter extends ProjectAdapter { const isScene = el.classList.contains("scene"); if (isScene) sceneCount++; + const sceneInfo = isScene + ? options.sceneLocking + ? sceneLabels[sceneLabelIdx++] ?? { label: String(sceneCount), omitted: false } + : { label: String(sceneCount), omitted: false } + : undefined; // Extract the paragraph type from classList let nodeType: string | undefined; @@ -200,7 +247,7 @@ export class PDFAdapter extends ProjectAdapter { if (yOffset > 0) { for (const line of beforeLines) line.y -= yOffset; } - this.injectPseudoContent(el, beforeLines, options, isScene ? sceneCount : undefined); + this.injectPseudoContent(el, beforeLines, options, sceneInfo); allLines.push(...beforeLines); } @@ -224,7 +271,7 @@ export class PDFAdapter extends ProjectAdapter { for (const line of paragraphLines) line.y -= yOffset; } // ── Pseudo-element content (not captured by TreeWalker) ── - this.injectPseudoContent(el, paragraphLines, options, isScene ? sceneCount : undefined); + this.injectPseudoContent(el, paragraphLines, options, sceneInfo); allLines.push(...paragraphLines); } else { // Empty paragraph — no text nodes, so collectParagraphLines @@ -370,7 +417,24 @@ export class PDFAdapter extends ProjectAdapter { // ── Measure position ───────────────────────────────────── range.setStart(textNode, ci); range.setEnd(textNode, ci + 1); - const rect = range.getBoundingClientRect(); + // WebKit (Safari, Tauri on macOS) has a long-standing quirk: + // for the FIRST character of a wrapped line, the single-char + // range straddles a line boundary because position `ci` is + // bidi-ambiguous between the end of the previous line and the + // start of the new one. `getBoundingClientRect()` returns the + // UNION of both lines — `rect.top` then lands on the *previous* + // line, so we mistakenly attribute the char to it. The visible + // result in PDFs is "one letter at the end of every wrapped + // line plus a leading space on the next". + // + // `getClientRects()` returns one rect per line box the range + // intersects; the LAST rect is always the actual rendering + // line. For normal (single-line) chars only one rect is + // returned, so this is a no-op everywhere else. + const rects = range.getClientRects(); + const rect = rects.length > 0 + ? rects[rects.length - 1] + : range.getBoundingClientRect(); // If height is 0, it is usually a trailing wrapped space or hidden char if (rect.height === 0) { @@ -454,20 +518,21 @@ export class PDFAdapter extends ProjectAdapter { el: HTMLElement, paragraphLines: VisualLine[], options: PDFExportOptions, - sceneNumber?: number, + sceneInfo?: { label: string; omitted: boolean }, ): void { const firstLine = paragraphLines[0]; const lastLine = paragraphLines[paragraphLines.length - 1]; - if (sceneNumber !== undefined && options.displaySceneNumbers) { + if (sceneInfo && options.displaySceneNumbers) { const elStyle = getComputedStyle(el); - // Left scene number — mirrors CSS `right: 100%; margin-right: -120px` on .scene::before: - // right edge lands at scene_element_left + 120px. + const paddingLeft = parseFloat(elStyle.paddingLeft) || 0; + + // Left scene number — mirrors CSS `right: 100%; margin-right: -120px` + // on .scene::before: right edge lands at scene_element_left + 120px. if (firstLine.runs.length > 0) { const leadRun = firstLine.runs[0]; - const paddingLeft = parseFloat(elStyle.paddingLeft) || 0; firstLine.runs.unshift({ - text: String(sceneNumber), + text: sceneInfo.label, x: leadRun.x - paddingLeft + 120, fontFamily: leadRun.fontFamily, bold: leadRun.bold, @@ -478,12 +543,12 @@ export class PDFAdapter extends ProjectAdapter { }); } - // Right scene number — mirrors CSS `left: 100%; margin-left: -85px` on .scene::after: - // left edge lands at scene_element_right - 85px. + // Right scene number — mirrors CSS `left: 100%; margin-left: -85px` + // on .scene::after: left edge lands at scene_element_right - 85px. if (options.sceneNumberOnRight && firstLine.runs.length > 0) { const tailRun = firstLine.runs[firstLine.runs.length - 1]; firstLine.runs.push({ - text: String(sceneNumber), + text: sceneInfo.label, x: el.getBoundingClientRect().right - 85, fontFamily: tailRun.fontFamily, bold: tailRun.bold, diff --git a/src/lib/editor/document-editor-config.ts b/src/lib/editor/document-editor-config.ts index fb456ea0..b1a61dda 100644 --- a/src/lib/editor/document-editor-config.ts +++ b/src/lib/editor/document-editor-config.ts @@ -18,6 +18,8 @@ export interface DocumentEditorFeatures { searchHighlights: boolean; /** Scene color bookmark decorations. */ sceneBookmarks: boolean; + /** Production-mode scene labels + OMITTED placeholders. */ + sceneLocking: boolean; /** Prevent duplicate data-ids on paste. */ nodeIdDedup: boolean; /** Character / location autocomplete menus. */ @@ -67,6 +69,7 @@ export const SCREENPLAY_EDITOR_CONFIG: DocumentEditorConfig = { characterHighlights: true, searchHighlights: true, sceneBookmarks: true, + sceneLocking: true, nodeIdDedup: true, suggestions: true, orphanPrevention: true, @@ -89,6 +92,7 @@ export const TITLEPAGE_EDITOR_CONFIG: DocumentEditorConfig = { characterHighlights: false, searchHighlights: false, sceneBookmarks: false, + sceneLocking: false, nodeIdDedup: false, suggestions: false, orphanPrevention: false, diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 81a9c31b..7a6ac17f 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -27,6 +27,10 @@ import { createSceneBookmarkExtension, refreshSceneBookmarks, } from "@src/lib/screenplay/extensions/scene-bookmark-extension"; +import { + createSceneLockingExtension, + refreshSceneLocking, +} from "@src/lib/screenplay/extensions/scene-locking-extension"; import { createNodeIdDedupExtension } from "@src/lib/screenplay/extensions/node-id-dedup-extension"; import { CommentMark } from "@src/lib/screenplay/extensions/comment-highlight-extension"; import { createSpellcheckExtension, refreshSpellcheck } from "@src/lib/spellcheck/spellcheck-extension"; @@ -71,6 +75,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum setSearchMatches, contdLabel, moreLabel, + sceneLocking, + sceneNumberingStyle, + persistentScenes, } = projectCtx; const projectState = repository?.getState(); @@ -163,6 +170,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum searchFilters, currentSearchIndex, setSearchMatches, + sceneLocking, + sceneNumberingStyle, + persistentScenes, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); ext.highlightedCharacters = highlightedCharacters; @@ -176,6 +186,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.searchFilters = searchFilters; ext.currentSearchIndex = currentSearchIndex; ext.setSearchMatches = setSearchMatches; + ext.sceneLocking = sceneLocking; + ext.sceneNumberingStyle = sceneNumberingStyle; + ext.persistentScenes = persistentScenes; const lastReportedElementRef = useRef(null); @@ -251,6 +264,14 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum }) : null; + const sceneLockingExtension = features.sceneLocking + ? createSceneLockingExtension({ + getSceneLocking: () => !!ext.sceneLocking, + getScenes: () => ext.repository?.scenes ?? {}, + getNumberingStyle: () => ext.sceneNumberingStyle ?? "suffix", + }) + : null; + const commentMarkExtension = features.comments ? CommentMark.configure({ onCommentActivated: (commentId: string | null) => { @@ -363,6 +384,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ...(characterHighlightExtension ? [characterHighlightExtension] : []), ...(searchHighlightExtension ? [searchHighlightExtension] : []), ...(sceneBookmarkExtension ? [sceneBookmarkExtension] : []), + ...(sceneLockingExtension ? [sceneLockingExtension] : []), ...(nodeIdDedupExtension ? [nodeIdDedupExtension] : []), ...(spellcheckExtension ? [spellcheckExtension] : []), ], @@ -569,6 +591,13 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum } }, [editor, scenes, features.sceneBookmarks]); + // Refresh scene locking decorations when the lock map or toggle changes + useEffect(() => { + if (editor && features.sceneLocking) { + refreshSceneLocking(editor); + } + }, [editor, sceneLocking, sceneNumberingStyle, persistentScenes, features.sceneLocking]); + // Refresh search highlights useEffect(() => { if (editor && features.searchHighlights) { diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index 936c2c7f..f729c449 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -101,6 +101,16 @@ export type LayoutData = { moreLabel: string; elementMargins: Record; elementStyles: Record; + sceneLocking?: boolean; + /** + * How provisional scenes inserted under production lock are labeled. + * - "suffix" (default): scene inserted between 3 and 4 → "3A". + * - "prefix": scene inserted between 3 and 4 → "A4". Letters decrease + * going forward (closest to L_next gets "A"). + * Only affects scenes that are computed/locked AFTER this setting is set; + * already-locked scenes keep their stored label. + */ + sceneNumberingStyle?: "suffix" | "prefix"; }; // -------------------------------- // diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 5402e11e..23d6a96a 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -244,20 +244,30 @@ export class ProjectRepository { /** * Create or update a scene's persistent data. + * + * Fields that appear in `data` (including ones explicitly set to undefined) + * overwrite the corresponding existing fields; everything else is preserved. + * Any final undefined values are stripped before writing. + * * Returns the scene id. */ upsertScene(sceneId: string, data: Partial): string { if (this.guardWrite("upsertScene")) return sceneId; const map = this.ydoc.scenes(); - const existing = map.get(sceneId) as PersistentScene | undefined; + const existing = (map.get(sceneId) as PersistentScene | undefined) ?? {}; - const sceneData: PersistentScene = { - synopsis: data.synopsis ?? existing?.synopsis ?? "", - color: "color" in data ? data.color : existing?.color, - }; + const merged: PersistentScene = { ...existing }; + const FIELDS = ["synopsis", "color", "token", "omitted"] as const; + for (const key of FIELDS) { + if (key in data) { + (merged as Record)[key] = data[key]; + } + } + for (const key of FIELDS) { + if (merged[key] === undefined) delete merged[key]; + } - map.set(sceneId, sceneData); - console.log(`[Scenes] Upserted scene: ${sceneId}`); + map.set(sceneId, merged); return sceneId; } @@ -360,6 +370,48 @@ export class ProjectRepository { if (this.guardWrite("setElementStyles")) return; this.ydoc.layout().set("elementStyles", styles); } + setSceneLocking(locked: boolean) { + if (this.guardWrite("setSceneLocking")) return; + this.ydoc.layout().set("sceneLocking", locked); + } + setSceneNumberingStyle(style: "suffix" | "prefix") { + if (this.guardWrite("setSceneNumberingStyle")) return; + this.ydoc.layout().set("sceneNumberingStyle", style); + } + + /** + * Strip the frozen production `token` from every persistent scene entry. + * Entries that have no remaining content (no `synopsis`, `color`, or + * `omitted` flag) are deleted outright. Used by the Production panel + * when the user unlocks scenes. The `omitted` flag is preserved — omit + * is independent of production lock and survives unlock. + */ + clearSceneLocks(): void { + if (this.guardWrite("clearSceneLocks")) return; + const map = this.ydoc.scenes(); + const entries: [string, PersistentScene][] = []; + map.forEach((value, key) => { + entries.push([key, value as PersistentScene]); + }); + for (const [uuid, scene] of entries) { + const next: PersistentScene = { ...scene }; + delete next.token; + if (!next.synopsis && !next.color && !next.omitted) { + map.delete(uuid); + } else { + map.set(uuid, next); + } + } + } + + /** + * Run a function inside a single Y.js transaction. + * Useful for batching multiple repository mutations into one collab update. + */ + transact(fn: () => void): void { + if (this.guardWrite("transact")) return; + this.ydoc.transact(fn); + } // -------------------------------- // // COMMENTS // diff --git a/src/lib/screenplay/editor.ts b/src/lib/screenplay/editor.ts index 8f4eded5..6c2fc7e9 100644 --- a/src/lib/screenplay/editor.ts +++ b/src/lib/screenplay/editor.ts @@ -5,7 +5,7 @@ import { ScreenplayElement, Style, TitlePageElement } from "../utils/enums"; import Document from "@tiptap/extension-document"; import Text from "@tiptap/extension-text"; -import { ScreenplayNodes, ScriptioBold, ScriptioItalic, ScriptioUnderline } from "@src/lib/screenplay/nodes"; +import { ScreenplayNodes, ScriptioBold, ScriptioItalic, ScriptioUnderline, generateNodeId } from "@src/lib/screenplay/nodes"; import { Placeholder } from "./extensions/placeholder-extension"; import { PAGE_SIZES } from "./extensions/pagination-extension"; import { ContdExtension } from "./extensions/contd-extension"; @@ -19,8 +19,14 @@ export const applyMarkToggle = (editor: Editor, style: Style) => { }; export const applyElement = (editor: Editor, element: ScreenplayElement) => { - // Use the element value directly as the node name since they now match - editor.chain().focus().setNode(element, { class: element }).run(); + // Pass a fresh data-id explicitly: Tiptap pre-resolves the schema's + // function defaults at setup time (see @tiptap/core + // helpers/getAttributesFromExtensions.ts), so the data-id default is a + // static string after init. Without this, every type-conversion would + // produce a duplicate that the dedup extension renames — and the + // rename would transfer any locked persistent entry to the new node, + // silently breaking scene locks. + editor.chain().focus().setNode(element, { class: element, "data-id": generateNodeId() }).run(); }; export const focusOnPosition = (editor: Editor, position: number) => { diff --git a/src/lib/screenplay/extensions/fountain-extension.ts b/src/lib/screenplay/extensions/fountain-extension.ts index 41852b4a..5bfa9922 100644 --- a/src/lib/screenplay/extensions/fountain-extension.ts +++ b/src/lib/screenplay/extensions/fountain-extension.ts @@ -3,6 +3,7 @@ import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { ReplaceStep, Step } from "@tiptap/pm/transform"; import { ScreenplayElement } from "../../utils/enums"; +import { generateNodeId } from "../nodes"; const fountainInputRulesPluginKey = new PluginKey("fountainInputRules"); @@ -116,6 +117,7 @@ export const FountainExtension = Extension.create({ // Change the node type to the new element type tr.setNodeMarkup(nodeStart, targetNodeType, { class: forcedElement, + "data-id": generateNodeId(), }); // Then remove the prefix character @@ -138,6 +140,7 @@ export const FountainExtension = Extension.create({ const tr = newState.tr; tr.setNodeMarkup(nodeStart, targetNodeType, { class: ScreenplayElement.Note, + "data-id": generateNodeId(), }); // Remove the [[ prefix @@ -175,6 +178,7 @@ export const FountainExtension = Extension.create({ const tr = newState.tr; tr.setNodeMarkup(nodeStart, targetNodeType, { class: ScreenplayElement.Character, + "data-id": generateNodeId(), }); return tr; diff --git a/src/lib/screenplay/extensions/node-id-dedup-extension.ts b/src/lib/screenplay/extensions/node-id-dedup-extension.ts index 12a89bfa..31bbaab5 100644 --- a/src/lib/screenplay/extensions/node-id-dedup-extension.ts +++ b/src/lib/screenplay/extensions/node-id-dedup-extension.ts @@ -16,6 +16,9 @@ type NodeIdDedupConfig = { * This plugin only handles the duplicate case: when a node is copy-pasted, both the * original and copy share the same data-id. A new ID is generated for the copy, and * for persistent scene headings, the persistent scene data is duplicated as well. + * + * NOTE: production sceneLocks are intentionally NOT duplicated here — a pasted scene + * should start unlocked/provisional, not inherit the source's frozen label. */ export const createNodeIdDedupExtension = (config: NodeIdDedupConfig) => { return Extension.create({ diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts new file mode 100644 index 00000000..9de747ef --- /dev/null +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -0,0 +1,244 @@ +import { Editor, Extension } from "@tiptap/core"; +import { Node } from "@tiptap/pm/model"; +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import type { PersistentScene } from "../scenes"; +import { computeSceneLabels, SceneNumberingStyle } from "../scene-locking"; +import { ScreenplayElement } from "../../utils/enums"; + +const sceneLockingPluginKey = new PluginKey("sceneLocking"); +const REFRESH_META = "sceneLockingRefresh"; + +type SceneLockingConfig = { + getSceneLocking: () => boolean; + getScenes: () => Record; + getNumberingStyle: () => SceneNumberingStyle; +}; + +type SceneEntry = { uuid: string; pos: number; nodeSize: number }; + +const collectSceneEntries = (doc: Node): SceneEntry[] => { + const out: SceneEntry[] = []; + doc.forEach((node, pos) => { + if (node.attrs?.class !== ScreenplayElement.Scene) return; + const uuid: string | undefined = node.attrs?.["data-id"]; + if (!uuid) return; + out.push({ uuid, pos, nodeSize: node.nodeSize }); + }); + return out; +}; + +/** + * Does any step in this transaction touch a Scene node or any node that sits + * between Scene boundaries? Used as a cheap early-exit so we don't rebuild + * decorations on every keystroke inside an action paragraph far away from + * any omitted scene. We have to be conservative when omitted scenes exist + * because hiding the body of an omitted scene means body-paragraph edits + * must trigger decoration recomputation too. + */ +const didSceneNodesChange = (tr: Transaction): boolean => { + if (!tr.docChanged) return false; + for (const step of tr.steps) { + const stepMap = step.getMap(); + let affected = false; + stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { + try { + const oldDoc = tr.docs[0]; + if (oldDoc) { + oldDoc.nodesBetween(oldStart, oldEnd, (node: Node) => { + if (node.attrs?.class === ScreenplayElement.Scene) affected = true; + }); + } + } catch { /* range out of bounds */ } + try { + tr.doc.nodesBetween(newStart, newEnd, (node: Node) => { + if (node.attrs?.class === ScreenplayElement.Scene) affected = true; + }); + } catch { /* range out of bounds */ } + }); + if (affected) return true; + } + return false; +}; + +const buildLabelWidget = (label: string, side: "left" | "right"): HTMLElement => { + const span = document.createElement("span"); + span.className = side === "left" ? "scene-label scene-label-left" : "scene-label scene-label-right"; + span.contentEditable = "false"; + span.textContent = label; + return span; +}; + +const buildOmittedWidget = (): HTMLElement => { + const span = document.createElement("span"); + span.className = "scene-omitted-overlay"; + span.contentEditable = "false"; + span.textContent = "OMITTED"; + return span; +}; + +const computeDecorations = ( + doc: Node, + locking: boolean, + scenes: Record, + style: SceneNumberingStyle, +): DecorationSet => { + const entries = collectSceneEntries(doc); + if (entries.length === 0) return DecorationSet.empty; + + const decorations: Decoration[] = []; + + // Scene-number labels are only meaningful under production lock. + if (locking) { + const labels = computeSceneLabels( + entries.map((e) => e.uuid), + scenes, + style, + ); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const info = labels[i]; + const keyBase = `${entry.uuid}-${info.label}-${info.status}`; + + decorations.push( + Decoration.widget(entry.pos + 1, () => buildLabelWidget(info.label, "left"), { + side: -1, + key: `scene-label-l-${keyBase}`, + }), + ); + decorations.push( + Decoration.widget(entry.pos + 1, () => buildLabelWidget(info.label, "right"), { + side: -1, + key: `scene-label-r-${keyBase}`, + }), + ); + } + } + + // OMITTED decorations are independent of production lock — the user can + // omit any scene at any time and the original heading + body are kept + // in the document; we just hide them visually until they unomit. + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!scenes[entry.uuid]?.omitted) continue; + + decorations.push( + Decoration.node(entry.pos, entry.pos + entry.nodeSize, { + "data-omitted-overlay": "true", + }), + ); + decorations.push( + Decoration.widget(entry.pos + 1, () => buildOmittedWidget(), { + side: -1, + key: `scene-omitted-${entry.uuid}`, + }), + ); + + // Hide the original heading text behind the OMITTED widget. Skip + // empty headings — there's nothing to hide and the inline range + // would be degenerate. + if (entry.nodeSize > 2) { + decorations.push( + Decoration.inline(entry.pos + 1, entry.pos + entry.nodeSize - 1, { + class: "scene-heading-omitted-text", + }), + ); + } + + // Hide every top-level paragraph between this heading and the next + // scene heading. We tag them with `data-omitted-body` so CSS can + // collapse them while leaving the underlying document untouched. + const nextEntry = entries[i + 1]; + const bodyEnd = nextEntry ? nextEntry.pos : doc.content.size; + const bodyStart = entry.pos + entry.nodeSize; + doc.forEach((node, pos) => { + if (pos >= bodyStart && pos < bodyEnd) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + "data-omitted-body": "true", + }), + ); + } + }); + } + + return DecorationSet.create(doc, decorations); +}; + +/** + * Tiptap extension that renders scene-number labels under production lock + * and OMITTED overlays (independent of lock state). + * + * Hot-path notes: + * - `apply` runs on every transaction. We early-exit when no scene nodes + * were touched, simply mapping existing decorations forward through the + * transaction. Full recomputation only happens on an explicit refresh + * signal or when a scene node was actually modified. + */ +export const createSceneLockingExtension = (config: SceneLockingConfig) => { + return Extension.create({ + name: "sceneLocking", + + addProseMirrorPlugins() { + const { getSceneLocking, getScenes, getNumberingStyle } = config; + + return [ + new Plugin({ + key: sceneLockingPluginKey, + state: { + init(_, { doc }) { + return computeDecorations( + doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + ); + }, + apply(tr, oldDecorations, _oldState, newState) { + // Explicit refresh (lock toggle, lock-map change) → recompute. + if (tr.getMeta(REFRESH_META)) { + return computeDecorations( + newState.doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + ); + } + + if (!tr.docChanged) return oldDecorations; + + // Doc edits that don't touch a scene node only shift + // existing decorations — no need to rebuild widgets. + if (!didSceneNodesChange(tr)) { + return oldDecorations.map(tr.mapping, newState.doc); + } + + return computeDecorations( + newState.doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + ); + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }), + ]; + }, + }); +}; + +/** + * Force a recompute of scene label decorations. + * Call when sceneLocking toggles or the sceneLocks map changes. + */ +export const refreshSceneLocking = (editor: Editor | null) => { + if (!editor || !editor.view) return; + editor.view.dispatch(editor.state.tr.setMeta(REFRESH_META, true)); +}; diff --git a/src/lib/screenplay/nodes/scene-node.ts b/src/lib/screenplay/nodes/scene-node.ts index 457d84d3..6a03c3cc 100644 --- a/src/lib/screenplay/nodes/scene-node.ts +++ b/src/lib/screenplay/nodes/scene-node.ts @@ -1,4 +1,5 @@ import { Node, mergeAttributes } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; import { ScreenplayElement } from "../../utils/enums"; import { ALIGN_CLASSES, generateNodeId } from "./index"; @@ -77,4 +78,84 @@ export const SceneNode = Node.create({ renderHTML({ HTMLAttributes }) { return ["p", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { state, view } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + + // Case 1: cursor inside an empty Scene heading → delete the + // Scene itself (unless it's the only block in the document). + // After deletion, drop the cursor at the end of the previous + // block — default PM selection mapping moves it forward into + // the *next* block instead, which feels wrong here. + if ($from.parent.type === this.type) { + if ($from.parent.textContent.length === 0 && state.doc.childCount > 1) { + const pos = $from.before(); + const tr = state.tr.delete(pos, pos + $from.parent.nodeSize); + if (pos > 0) { + tr.setSelection(TextSelection.create(tr.doc, pos - 1)); + } + view.dispatch(tr); + return true; + } + return false; + } + + // Case 2: cursor at the start of an empty block whose + // immediately preceding sibling is a Scene heading. Default + // ProseMirror `joinBackward` would delete the Scene above + // (joinMaybeClear deletes the empty `before` block), leaving + // the empty current block orphaned. Intercept and delete the + // current block instead, dropping the cursor into the Scene. + if ($from.parent.textContent.length !== 0) return false; + + const curStart = $from.before(); + if (curStart === 0) return false; + + const prev = state.doc.resolve(curStart).nodeBefore; + if (!prev || prev.type !== this.type) return false; + + const cursorTarget = curStart - 1; + const tr = state.tr.delete(curStart, $from.after()); + tr.setSelection(TextSelection.create(tr.doc, cursorTarget)); + view.dispatch(tr); + return true; + }, + Enter: ({ editor }) => { + const { state, view } = editor; + const { $from, empty } = state.selection; + + if (empty && $from.parent.type === this.type && $from.parentOffset === 0) { + // If the node is completely empty, let Tiptap handle it (converts to Action) + if ($from.parent.textContent.length === 0) { + return false; + } + + // Split the block. We want the node AFTER the split (which contains the text) + // to keep its original data-id, and the new empty node BEFORE the split + // to get a new data-id. Since tr.split by default copies the original node's + // attributes to both halves, we can split, and then update the attributes of + // the newly created empty node (which will be right before $from.pos). + const originalAttrs = $from.parent.attrs; + let tr = state.tr.split($from.pos, 1, [{ type: this.type, attrs: originalAttrs }]); + + // After the split, the original node with its text is pushed down. + // A new empty node is created above it. The new node starts at $from.pos - 1 + // (the start of the block). We update its data-id. + const newNodePos = $from.pos - 1; + tr = tr.setNodeMarkup(newNodePos, undefined, { + ...originalAttrs, + "data-id": generateNodeId() + }); + + view.dispatch(tr); + return true; + } + return false; + }, + }; + }, }); \ No newline at end of file diff --git a/src/lib/screenplay/popup.ts b/src/lib/screenplay/popup.ts index fcc6c681..e9b54903 100644 --- a/src/lib/screenplay/popup.ts +++ b/src/lib/screenplay/popup.ts @@ -21,6 +21,10 @@ export type PopupUploadToCloudData = { projectId: string; }; +export type PopupUnlockScenesData = { + confirmUnlock: () => void; +}; + // ------------------------------ // // GENERIC POPUP // // ------------------------------ // @@ -28,7 +32,8 @@ export type PopupUnionData = | PopupImportFileData | PopupCharacterData | PopupSceneData - | PopupUploadToCloudData; + | PopupUploadToCloudData + | PopupUnlockScenesData; export enum PopupType { NewCharacter, @@ -36,6 +41,7 @@ export enum PopupType { ImportFile, EditScene, UploadToCloud, + UnlockScenes, } export type PopupData = { @@ -85,3 +91,10 @@ export const uploadToCloudPopup = (projectId: string, userCtx: UserContextType) data: { projectId }, }); }; + +export const unlockScenesPopup = (confirmUnlock: () => void, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UnlockScenes, + data: { confirmUnlock }, + }); +}; diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts new file mode 100644 index 00000000..6e6a33ab --- /dev/null +++ b/src/lib/screenplay/scene-locking.ts @@ -0,0 +1,449 @@ +/** + * Token-based scene labeling for production lock. + * + * Every locked scene stores a `SceneToken` — a structural, mode-independent + * encoding of its logical position in the screenplay. The display label is + * derived from the token via `compileSceneLabel(token)`. Because letter case + * is baked into each level (`lower: true | false`), toggling the global + * `SceneNumberingStyle` setting never alters the label of an already-locked + * scene. + * + * Convention + * ---------- + * - `baseNumber` is the integer anchor (1, 2, 3, …) that the rest of the + * token attaches to. + * - `prefixes` are letter levels rendered BEFORE the base. Stored + * **inner-first** — `prefixes[0]` is the letter closest to the base. + * Rendering reverses the array. + * - `suffixes` are letter levels rendered AFTER the base. Stored + * **shallowest-first** — `suffixes[0]` is the letter immediately after + * the base. + * + * Cases are encoded per-level. Uppercase = "AFTER" depth; lowercase = a + * wedge insertion that goes BEFORE its same-position uppercase sibling in + * production order. + * + * "1" → { base:1 } + * "1A" → { base:1, suffixes:[{1,U}] } + * "1B" → { base:1, suffixes:[{2,U}] } + * "1AA" → { base:1, suffixes:[{1,U},{1,U}] } ← sub-scene of 1A, between 1A and 1B + * "1aA" → { base:1, suffixes:[{1,L},{1,U}] } ← wedge between 1 and 1A + * "A2" → { base:2, prefixes:[{1,U}] } + * "AA2" → { base:2, prefixes:[{1,U},{1,U}] } ← deeper before A2, between 1 and A2 + * + * Production order + * ---------------- + * A total order on tokens: + * 1. Compare `baseNumber`. + * 2. Element-wise on `prefixes`. A SHORTER prefix array is LATER + * (longer prefix = deeper level = comes earlier in the doc). + * At each position, compare `value`, then case (lower < upper). + * 3. Element-wise on `suffixes`. A SHORTER suffix array is EARLIER + * (longer suffix = deeper sub-scene, comes after the parent). + * At each position, compare `value`, then case (lower < upper). + * + * Together these give: 1 < 1aA < 1A < 1AA < 1AB < 1B < 2. + */ + +import type { ProjectRepository } from "../project/project-repository"; + +// -------------------------------------------------------------------------- +// TYPES +// -------------------------------------------------------------------------- + +export type SceneLevel = { value: number; lower: boolean }; + +export type SceneToken = { + baseNumber: number; + prefixes: SceneLevel[]; + suffixes: SceneLevel[]; +}; + +/** Minimal shape needed by `computeSceneLabels`. Persistent scenes match it. */ +export type LockReadable = { + token?: SceneToken; + omitted?: boolean; +}; + +export type SceneLabelStatus = "locked" | "provisional" | "omitted"; + +export type SceneLabel = { + uuid: string; + /** Structural representation. Stable across style toggles when locked. */ + token: SceneToken; + /** Display string, derived from `token`. */ + label: string; + status: SceneLabelStatus; +}; + +export type SceneNumberingStyle = "suffix" | "prefix"; + +// -------------------------------------------------------------------------- +// ENCODING & DISPLAY +// -------------------------------------------------------------------------- + +/** Excel-style alphabetic letter: 1 → A, 26 → Z, 27 → AA, … */ +const letterFromValue = (n: number, lower: boolean): string => { + let out = ""; + const charBase = lower ? 97 : 65; + while (n > 0) { + const m = (n - 1) % 26; + out = String.fromCharCode(charBase + m) + out; + n = Math.floor((n - 1) / 26); + } + return out; +}; + +/** + * Render a token to its display string. Style-independent — the case of + * each level is taken directly from `level.lower`, so the result is the + * same regardless of the project's `SceneNumberingStyle` setting. + */ +export const compileSceneLabel = (token: SceneToken): string => { + // Prefixes are stored inner-first (closest to base at index 0). Render + // outer-to-inner, i.e. reverse before joining. + let out = ""; + for (let i = token.prefixes.length - 1; i >= 0; i--) { + const lvl = token.prefixes[i]; + out += letterFromValue(lvl.value, lvl.lower); + } + out += String(token.baseNumber); + for (let i = 0; i < token.suffixes.length; i++) { + const lvl = token.suffixes[i]; + out += letterFromValue(lvl.value, lvl.lower); + } + return out; +}; + +/** Total order on `SceneToken`. See file header for the rules. */ +export const compareTokens = (a: SceneToken, b: SceneToken): number => { + if (a.baseNumber !== b.baseNumber) return a.baseNumber - b.baseNumber; + + const pLen = Math.max(a.prefixes.length, b.prefixes.length); + for (let i = 0; i < pLen; i++) { + const ai = a.prefixes[i] as SceneLevel | undefined; + const bi = b.prefixes[i] as SceneLevel | undefined; + // For prefixes: longer = earlier ⇒ missing > defined. + if (ai === undefined) return 1; + if (bi === undefined) return -1; + if (ai.value !== bi.value) return ai.value - bi.value; + if (ai.lower !== bi.lower) return ai.lower ? -1 : 1; + } + + const sLen = Math.max(a.suffixes.length, b.suffixes.length); + for (let i = 0; i < sLen; i++) { + const ai = a.suffixes[i] as SceneLevel | undefined; + const bi = b.suffixes[i] as SceneLevel | undefined; + // For suffixes: longer = later ⇒ missing < defined. + if (ai === undefined) return -1; + if (bi === undefined) return 1; + if (ai.value !== bi.value) return ai.value - bi.value; + if (ai.lower !== bi.lower) return ai.lower ? -1 : 1; + } + + return 0; +}; + +// Convenience constructors. +const sceneLevel = (value: number, lower: boolean): SceneLevel => ({ value, lower }); + +/** Token for a bare integer scene number ("1", "2", …). */ +export const baseToken = (baseNumber: number): SceneToken => ({ + baseNumber, + prefixes: [], + suffixes: [], +}); + +const levelEq = (a: SceneLevel, b: SceneLevel): boolean => + a.value === b.value && a.lower === b.lower; + +// -------------------------------------------------------------------------- +// PROVISIONAL TOKEN COMPUTATION +// -------------------------------------------------------------------------- +// +// Each provisional scene gets a token derived from its immediate locked +// neighbours and its 1-based position within the segment (`k`). The rules +// preserve the existing user-visible behaviour: +// +// suffix mode, between locked 1 and 2: 1A, 1B, 1C, … +// suffix mode, between locked 1A and 1B (1 also locked): 1AA, 1AB, 1AC +// suffix mode, between locked 1 and 1A: 1aA, 1aB, 1aC +// prefix mode, between locked 1 and 2: A2, B2, C2 +// prefix mode, before locked 1: A1, B1, C1 +// prefix mode, between locked 1 and A2: AA2, BA2, CA2 +// +// Both modes are duals of one another and share the same three operations +// applied along a single "axis" (suffix or prefix): +// +// 1. CONTINUE: bump the deepest level of an anchor along the axis. +// 2. NEST: append a new uppercase level to an anchor along the axis. +// 3. WEDGE: walk the OTHER token's path along the axis looking for a +// point where a lowercase wedge level slots strictly between +// the two anchors. +// +// SUFFIX mode anchors on `prev` and grows rightward toward `next`; PREFIX +// mode anchors on `next` and grows leftward toward `prev`. Each candidate +// is verified strictly-between by `compareTokens` before being returned — +// if the chosen style's strategies all fall outside the range (as can +// happen with cross-axis anchors, e.g. prefix-mode insertion between plain +// "1" and suffix-bearing "1A"), we fall back to the dual style. + +type Axis = "suffix" | "prefix"; + +const levelsOf = (t: SceneToken, axis: Axis): SceneLevel[] => + axis === "suffix" ? t.suffixes : t.prefixes; + +const withLevels = (t: SceneToken, axis: Axis, levels: SceneLevel[]): SceneToken => + axis === "suffix" + ? { baseNumber: t.baseNumber, prefixes: t.prefixes, suffixes: levels } + : { baseNumber: t.baseNumber, prefixes: levels, suffixes: t.suffixes }; + +// Wedge convention per axis. lowercase < uppercase at the same value (see +// `compareTokens`). Suffix levels count UP, so 'a' (value 1) is the deepest +// wedge — decrementing past it means descending a level. Prefix levels are +// mirrored: 'z' (value 26) is the bound; incrementing past it descends. +const wedgeBound = (axis: Axis): number => (axis === "suffix" ? 1 : 26); +const wedgeStep = (axis: Axis): number => (axis === "suffix" ? -1 : 1); + +/** Bump the deepest level of `anchor` along `axis` by k. */ +const continueAlong = (anchor: SceneToken, k: number, axis: Axis): SceneToken | null => { + const path = levelsOf(anchor, axis); + if (path.length === 0) { + // Suffix axis can fall through to bumping the base. Prefix axis + // has nothing to continue when there's no outermost prefix. + if (axis === "suffix") { + return { baseNumber: anchor.baseNumber + k, prefixes: anchor.prefixes, suffixes: [] }; + } + return null; + } + const last = path[path.length - 1]; + const newPath = path.slice(0, -1).concat([sceneLevel(last.value + k, last.lower)]); + return withLevels(anchor, axis, newPath); +}; + +/** Append a fresh uppercase level (value k) to anchor's path along axis. */ +const nestAlong = (anchor: SceneToken, k: number, axis: Axis): SceneToken => + withLevels(anchor, axis, [...levelsOf(anchor, axis), sceneLevel(k, false)]); + +/** + * Walk `target`'s path (skipping any shared prefix with `from`) and slot in + * a lowercase wedge level just before its first divergent uppercase level. + * Returns a token whose label sorts strictly between `from` and `target`, + * or null if `target`'s path doesn't extend past the shared prefix (caller + * needs a different strategy). + * + * suffix axis: `from` = prev, `target` = next. Wedge bound is 'a'. + * prefix axis: `from` = next, `target` = prev. Wedge bound is 'z'. + */ +const wedgeAlong = ( + from: SceneToken, + target: SceneToken, + k: number, + axis: Axis, +): SceneToken | null => { + const fromLevels = levelsOf(from, axis); + const targetLevels = levelsOf(target, axis); + const bound = wedgeBound(axis); + const step = wedgeStep(axis); + + let i = 0; + while ( + i < fromLevels.length && + i < targetLevels.length && + levelEq(fromLevels[i], targetLevels[i]) + ) { + i++; + } + + if (i >= targetLevels.length) return null; + + const levels = targetLevels.slice(0, i); + while (i < targetLevels.length) { + const div = targetLevels[i]; + if (div.lower && div.value === bound) { + // Already a wedge at this level — descend one deeper. + levels.push(div); + i++; + continue; + } + // We can wedge here. Decrement an existing lowercase level (e.g. + // suffix 'b' → 'a') or convert an uppercase to its lowercase + // wedge equivalent ('A' → 'a'). + const wedgeValue = div.lower ? div.value + step : div.value; + levels.push(sceneLevel(wedgeValue, true)); + return withLevels(target, axis, [...levels, sceneLevel(k, false)]); + } + + // All of target's diverging levels were already at the wedge bound — + // append one more wedge level to land strictly below them. + levels.push(sceneLevel(bound, true)); + return withLevels(target, axis, [...levels, sceneLevel(k, false)]); +}; + +const isStrictlyBetween = ( + prev: SceneToken | null, + next: SceneToken | null, + cand: SceneToken, +): boolean => { + if (prev && compareTokens(prev, cand) >= 0) return false; + if (next && compareTokens(cand, next) >= 0) return false; + return true; +}; + +const computeProvisionalToken = ( + prev: SceneToken | null, + next: SceneToken | null, + k: number, + style: SceneNumberingStyle, +): SceneToken => { + if (!prev && !next) return baseToken(k); + + const pick = (cands: Array): SceneToken | null => { + for (const c of cands) if (c && isStrictlyBetween(prev, next, c)) return c; + return null; + }; + + // Suffix-style candidates grow rightward from prev. + const suffixCandidates = (): Array => { + if (prev) { + return [ + continueAlong(prev, k, "suffix"), + nestAlong(prev, k, "suffix"), + next ? wedgeAlong(prev, next, k, "suffix") : null, + ]; + } + // No prev — nothing to grow from on the suffix axis. The natural + // dual is to nest leftward into next. + return next ? [nestAlong(next, k, "prefix")] : []; + }; + + // Prefix-style candidates grow leftward from next. + const prefixCandidates = (): Array => { + if (next) { + return [ + prev ? continueAlong(prev, k, "prefix") : null, + nestAlong(next, k, "prefix"), + prev ? wedgeAlong(next, prev, k, "prefix") : null, + ]; + } + return prev ? [continueAlong(prev, k, "suffix")] : []; + }; + + const primary = style === "suffix" ? pick(suffixCandidates()) : pick(prefixCandidates()); + if (primary) return primary; + + // Cross-style fallback: anchors don't line up along the requested + // axis (e.g. prefix mode trying to fit something between bases 1 and + // 1A — there's no valid prefix-only token there). + const fallback = style === "suffix" ? pick(prefixCandidates()) : pick(suffixCandidates()); + if (fallback) return fallback; + + // Pathological input (prev >= next). Should never happen for a valid + // scene sequence, but emit a deterministic token rather than throwing. + if (prev) return nestAlong(prev, k, "suffix"); + if (next) return nestAlong(next, k, "prefix"); + return baseToken(k); +}; + +// -------------------------------------------------------------------------- +// MAIN API +// -------------------------------------------------------------------------- + +/** + * Compute display labels (and structural tokens) for an ordered list of + * scene UUIDs. Locked scenes get their persisted token; provisional ones + * get a token computed from their segment's immediate neighbours. + * + * O(N) — two linear passes precompute prev/next/segment-index, one final + * pass emits the result. + */ +export const computeSceneLabels = ( + sceneUuids: string[], + persistent: Record, + style: SceneNumberingStyle = "suffix", +): SceneLabel[] => { + const n = sceneUuids.length; + const result: SceneLabel[] = new Array(n); + + const prevLocked: (SceneToken | null)[] = new Array(n); + const nextLocked: (SceneToken | null)[] = new Array(n); + const segmentIdx: number[] = new Array(n); + + let lastToken: SceneToken | null = null; + let runCount = 0; + for (let i = 0; i < n; i++) { + const entry = persistent[sceneUuids[i]]; + if (entry?.token) { + prevLocked[i] = lastToken; + segmentIdx[i] = 0; + lastToken = entry.token; + runCount = 0; + } else { + prevLocked[i] = lastToken; + runCount++; + segmentIdx[i] = runCount; + } + } + + let upcomingToken: SceneToken | null = null; + for (let i = n - 1; i >= 0; i--) { + const entry = persistent[sceneUuids[i]]; + if (entry?.token) { + nextLocked[i] = upcomingToken; + upcomingToken = entry.token; + } else { + nextLocked[i] = upcomingToken; + } + } + + for (let i = 0; i < n; i++) { + const uuid = sceneUuids[i]; + const entry = persistent[uuid]; + + if (entry?.token) { + result[i] = { + uuid, + token: entry.token, + label: compileSceneLabel(entry.token), + status: entry.omitted ? "omitted" : "locked", + }; + continue; + } + + const token = computeProvisionalToken( + prevLocked[i], + nextLocked[i], + segmentIdx[i], + style, + ); + result[i] = { + uuid, + token, + label: compileSceneLabel(token), + status: "provisional", + }; + } + + return result; +}; + +// -------------------------------------------------------------------------- +// ACTIONS +// -------------------------------------------------------------------------- + +/** + * Mark a scene as OMITTED. The scene's heading text and body content are + * preserved in the document; the editor overlays "OMITTED" and hides the + * underlying content via decorations so the original screenplay survives an + * unomit. Works regardless of production lock state. + */ +export const omitSceneByUuid = (repository: ProjectRepository, uuid: string): void => { + repository.upsertScene(uuid, { omitted: true }); +}; + +/** Clear an OMITTED scene's `omitted` flag, restoring the heading + body. */ +export const unomitSceneByUuid = (repository: ProjectRepository, uuid: string): void => { + const scene = repository.getScene(uuid); + if (!scene?.omitted) return; + repository.upsertScene(uuid, { omitted: undefined }); +}; diff --git a/src/lib/screenplay/scenes.ts b/src/lib/screenplay/scenes.ts index d39e6186..c89fa974 100644 --- a/src/lib/screenplay/scenes.ts +++ b/src/lib/screenplay/scenes.ts @@ -22,6 +22,8 @@ import { getNodeData } from "./screenplay"; import { ScreenplayElement } from "../utils/enums"; import { Screenplay } from "../utils/types"; import { JSONContent } from "@tiptap/react"; +import type { SceneToken } from "./scene-locking"; +import { compileSceneLabel } from "./scene-locking"; /** * Recursively compute the ProseMirror nodeSize of a JSONContent node. @@ -53,12 +55,21 @@ export type TransientScene = { /** * Persistent scene metadata stored in Yjs. - * Only contains user-editable fields. * Keyed by scene id (UUID) in the Yjs map. + * + * Contains both user-editable fields (synopsis, color) and production-mode + * fields (token, omitted). `token` is the structural, mode-independent + * representation of the scene's frozen number under production lock; the + * display label is derived from it via `compileSceneLabel`. `omitted` + * flags the scene as an OMITTED placeholder. */ export type PersistentScene = { synopsis?: string; color?: string; + /** Frozen structural position under production lock. */ + token?: SceneToken; + /** True when the scene is an OMITTED placeholder (only meaningful with `token`). */ + omitted?: boolean; }; /** @@ -69,10 +80,19 @@ export type PersistentSceneMap = { [id: string]: PersistentScene }; /** * Full scene data combining transient and persistent data. * This is what gets exposed to the UI. + * + * `token` is the structural lock (when persisted); `label` is the derived + * display string (compiled from the token). Both are absent for scenes + * that have not been locked. UI code that needs *provisional* labels + * should call `computeSceneLabels()` over the full ordered scene list + * instead of reading `Scene.label` directly. */ export type Scene = TransientScene & { synopsis?: string; color?: string; + token?: SceneToken; + label?: string; + omitted?: boolean; }; // -------------------------------- // @@ -172,6 +192,9 @@ export const mergeScenesData = (persistentScenes: PersistentSceneMap, screenplay ...item, synopsis: persistent.synopsis, color: persistent.color, + token: persistent.token, + label: persistent.token ? compileSceneLabel(persistent.token) : undefined, + omitted: persistent.omitted, }; } diff --git a/src/lib/shelf/shelf-editor-config.ts b/src/lib/shelf/shelf-editor-config.ts index 7af1c86d..a4e23b99 100644 --- a/src/lib/shelf/shelf-editor-config.ts +++ b/src/lib/shelf/shelf-editor-config.ts @@ -13,6 +13,7 @@ export function createShelfEditorConfig(nodeId: string, versionId: string): Docu characterHighlights: false, searchHighlights: false, sceneBookmarks: false, + sceneLocking: false, nodeIdDedup: true, suggestions: false, orphanPrevention: false, diff --git a/styles/scriptio.css b/styles/scriptio.css index a6f359ab..82ed8f4d 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -185,6 +185,65 @@ display: none; } + /* Production lock: suppress CSS counter pseudo-elements — labels come + from widget decorations (scene-locking-extension) so we can render + suffixed / OMITTED labels that CSS counters can't express. */ + &.production-locked .scene::before, + &.production-locked .scene::after { + display: none; + } + + /* Widget decoration for the left scene label (mirrors the ::before slot). + text-transform: none !important is required to defeat the parent + .scene's text-transform: uppercase (matching the same pattern used by + .collab-caret-name above) so lowercase suffix markers like "3aA" + render with their original case. */ + .scene-label-left { + position: absolute; + right: 100%; + margin-right: -120px; + user-select: none; + pointer-events: none; + text-transform: none !important; + } + + /* Widget decoration for the right scene label (mirrors the ::after slot). */ + .scene-label-right { + position: absolute; + top: 0; + left: 100%; + margin-left: -85px; + user-select: none; + pointer-events: none; + display: none; + text-transform: none !important; + } + &.scene-number-right .scene-label-right { + display: block; + } + &.hide-scene-numbers .scene-label-left, + &.hide-scene-numbers .scene-label-right { + display: none; + } + + /* OMITTED scene placeholder. The heading text and body paragraphs are + preserved in the document so the user can restore them; we just hide + them visually and overlay "OMITTED" in place of the heading. */ + .scene[data-omitted-overlay="true"] { + color: var(--secondary-text); + } + .scene-heading-omitted-text { + display: none; + } + [data-omitted-body="true"] { + display: none; + } + .scene-omitted-overlay { + user-select: none; + pointer-events: none; + font-style: normal; + } + /* Normal weight scene headings (when bold disabled) */ &.scene-heading-normal .scene { font-weight: normal; From e57147de5940659e901cb4650cbda19e0898fcde Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 23 May 2026 13:47:21 +0200 Subject: [PATCH 53/76] optimized scene locking --- .../screenplay/extensions/scene-locking-extension.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts index 9de747ef..ea7fd1ec 100644 --- a/src/lib/screenplay/extensions/scene-locking-extension.ts +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -78,12 +78,23 @@ const buildOmittedWidget = (): HTMLElement => { return span; }; +const hasAnyOmitted = (scenes: Record): boolean => { + for (const key in scenes) { + if (scenes[key]?.omitted) return true; + } + return false; +}; + const computeDecorations = ( doc: Node, locking: boolean, scenes: Record, style: SceneNumberingStyle, ): DecorationSet => { + // Nothing to render: no production lock and no omitted scenes. Skip the + // doc traversal entirely — this is the common case for most users. + if (!locking && !hasAnyOmitted(scenes)) return DecorationSet.empty; + const entries = collectSceneEntries(doc); if (entries.length === 0) return DecorationSet.empty; From 68576fec7571068279a4046e157edf326dda8381 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 23 May 2026 15:23:52 +0200 Subject: [PATCH 54/76] moved production settings in its own yjs entry, added letter skipping to scene numbering, tweaked styling of production settings --- .../project/ProductionSettings.module.css | 66 +++++------- .../dashboard/project/ProductionSettings.tsx | 101 +++++++++++++++--- .../sidebar/EditorSidebarNavigation.tsx | 19 +++- components/navbar/ProductionPanel.tsx | 28 ++++- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/ja.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- messages/zh.json | 8 +- src/context/ProjectContext.tsx | 52 +++++++-- src/lib/adapters/pdf/pdf-adapter.ts | 66 ++++-------- src/lib/adapters/scriptio/scriptio-adapter.ts | 5 +- src/lib/editor/use-document-editor.ts | 6 +- src/lib/project/project-doc.ts | 26 +++++ src/lib/project/project-repository.ts | 25 ++++- src/lib/project/project-state.ts | 3 + .../extensions/scene-locking-extension.ts | 8 +- src/lib/screenplay/scene-locking.ts | 60 ++++++++--- 21 files changed, 359 insertions(+), 170 deletions(-) diff --git a/components/dashboard/project/ProductionSettings.module.css b/components/dashboard/project/ProductionSettings.module.css index 6ee695e7..f0669ec1 100644 --- a/components/dashboard/project/ProductionSettings.module.css +++ b/components/dashboard/project/ProductionSettings.module.css @@ -5,52 +5,44 @@ margin-top: 10px; } -.styleOption { - display: flex; - flex-direction: row; - align-items: baseline; - justify-content: center; - gap: 8px; - padding: 8px 12px; - border: 1px solid var(--separator); - border-radius: 6px; - background: var(--main-bg); - color: var(--primary-text); - cursor: pointer; - transition: border-color 0.15s ease, background 0.15s ease; +.styleName { + font-size: 0.7rem; + color: var(--secondary-text); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; } -.styleOption:hover:not(:disabled) { - border-color: var(--primary-text); +.styleExample { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-family: var(--font-screenplay); + font-size: 0.9rem; + font-weight: 600; + color: var(--primary-text); } -.styleOption:disabled { +.arrowIcon { opacity: 0.5; - cursor: not-allowed; } -.styleOptionActive { - border-color: var(--primary-text); - background: color-mix(in srgb, var(--primary-text) 8%, transparent); -} - -.styleExample { - font-family: var(--font-screenplay); - font-size: 0.85rem; - font-weight: 600; +.letterToggles { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-top: 10px; } -.styleName { - font-size: 0.7rem; - color: var(--secondary-text); - text-transform: uppercase; - letter-spacing: 0.04em; +.letterCard { + justify-content: center; + padding: 12px; } -.note { - margin-top: 16px; - font-size: 0.78rem; - color: var(--secondary-text); - font-style: italic; - line-height: 1.4; +.letter { + font-family: var(--font-screenplay); + font-size: 1rem; + font-weight: 600; + color: var(--primary-text); } diff --git a/components/dashboard/project/ProductionSettings.tsx b/components/dashboard/project/ProductionSettings.tsx index 4620bbce..e585576f 100644 --- a/components/dashboard/project/ProductionSettings.tsx +++ b/components/dashboard/project/ProductionSettings.tsx @@ -2,16 +2,39 @@ import { useContext } from "react"; import { useTranslations } from "next-intl"; +import { ArrowRight } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; +import { TOGGLEABLE_SCENE_LETTERS } from "@src/lib/project/project-state"; import form from "./../../utils/Form.module.css"; import sharedStyles from "./ProjectSettings.module.css"; +import optionCard from "./OptionCard.module.css"; import styles from "./ProductionSettings.module.css"; +const Arrow = () => ; + const ProductionSettings = () => { const t = useTranslations("production"); - const { sceneNumberingStyle, setSceneNumberingStyle, isReadOnly } = - useContext(ProjectContext); + const { + sceneNumberingStyle, + setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, + isReadOnly, + } = useContext(ProjectContext); + + const isLetterSkipped = (letter: string) => + skippedSceneLetters.includes(letter.toUpperCase()); + + const toggleLetter = (letter: string) => { + if (isReadOnly) return; + const upper = letter.toUpperCase(); + const next = isLetterSkipped(upper) + ? skippedSceneLetters.filter((l) => l.toUpperCase() !== upper) + : [...skippedSceneLetters, upper]; + next.sort(); + setSkippedSceneLetters(next); + }; return (
@@ -20,27 +43,71 @@ const ProductionSettings = () => {

{t("numberingStyleHelp")}

- -
+
!isReadOnly && setSceneNumberingStyle("prefix")} + role="radio" + aria-checked={sceneNumberingStyle === "prefix"} + aria-label={t("prefixName")} > - {t("prefixExample")} +
+ {sceneNumberingStyle === "prefix" &&
} +
{t("prefixName")} - + + 1 + + A2 + + 2 + +
+
-

{t("appliesToNewOnly")}

+
+ +

{t("skippedLettersHelp")}

+ +
+ {TOGGLEABLE_SCENE_LETTERS.map((letter) => { + const active = isLetterSkipped(letter); + return ( +
toggleLetter(letter)} + role="button" + aria-pressed={active} + aria-label={t("skipLetterAriaLabel", { letter })} + > +
+ {active &&
} +
+ {letter} +
+ ); + })} +
); diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index 4461fb37..dae8d2db 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -18,7 +18,15 @@ import sidebar_nav from "./EditorSidebarNavigation.module.css"; const EditorSidebarNavigation = () => { const t = useTranslations("editorSidebar"); - const { scenes, updateScenes, editor, sceneLocking, sceneNumberingStyle, persistentScenes } = useContext(ProjectContext); + const { + scenes, + updateScenes, + editor, + sceneLocking, + sceneNumberingStyle, + skippedSceneLetters, + persistentScenes, + } = useContext(ProjectContext); const { leftSidebarOpen } = useViewContext(); const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments">("scenes"); @@ -38,14 +46,19 @@ const EditorSidebarNavigation = () => { const sceneDisplays = useMemo(() => { if (sceneLocking) { const uuids = scenes.map((s) => s.id ?? ""); - const labels = computeSceneLabels(uuids, persistentScenes, sceneNumberingStyle); + const labels = computeSceneLabels( + uuids, + persistentScenes, + sceneNumberingStyle, + skippedSceneLetters, + ); return scenes.map((_, i) => ({ label: labels[i]?.label ?? `${i + 1}`, isOmitted: labels[i]?.status === "omitted", })); } return scenes.map((_, i) => ({ label: `${i + 1}`, isOmitted: false })); - }, [scenes, sceneLocking, sceneNumberingStyle, persistentScenes]); + }, [scenes, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes]); const listRef = useRef(null); const currentSceneRef = useRef(null); diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index 580487b0..a334f3b8 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -32,8 +32,15 @@ const REVISION_COLORS = [ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const t = useTranslations("production"); - const { sceneLocking, sceneNumberingStyle, persistentScenes, scenes, repository, isReadOnly } = - useContext(ProjectContext); + const { + sceneLocking, + sceneNumberingStyle, + skippedSceneLetters, + persistentScenes, + scenes, + repository, + isReadOnly, + } = useContext(ProjectContext); const userCtx = useContext(UserContext); const panelRef = useRef(null); @@ -58,9 +65,14 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const labels = useMemo( () => sceneLocking - ? computeSceneLabels(sceneUuids, persistentScenes, sceneNumberingStyle) + ? computeSceneLabels( + sceneUuids, + persistentScenes, + sceneNumberingStyle, + skippedSceneLetters, + ) : [], - [sceneLocking, sceneUuids, persistentScenes, sceneNumberingStyle], + [sceneLocking, sceneUuids, persistentScenes, sceneNumberingStyle, skippedSceneLetters], ); const provisionalLabels = useMemo( @@ -93,7 +105,12 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { // tokens, this falls through to baseToken(idx+1) for every // scene, matching the previous behaviour. const persistentSnapshot = repository.scenes; - const labels = computeSceneLabels(uuids, persistentSnapshot, sceneNumberingStyle); + const labels = computeSceneLabels( + uuids, + persistentSnapshot, + sceneNumberingStyle, + skippedSceneLetters, + ); labels.forEach((label) => { if (label.status === "provisional") { @@ -122,6 +139,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { uuids, persistentSnapshot, sceneNumberingStyle, + skippedSceneLetters, ); console.log("[ProductionPanel] RELOCKING PROVISIONAL. Full snapshot:", currentLabels.map(l => ({ diff --git a/messages/de.json b/messages/de.json index cc8993df..0302bcda 100644 --- a/messages/de.json +++ b/messages/de.json @@ -462,12 +462,12 @@ "unlock": "Entsperren", "cancel": "Abbrechen", "numberingStyleTitle": "Szenennummerierung", - "numberingStyleHelp": "Wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden. Suffix verweist auf die vorherige Szene, Präfix auf die nächste.", + "numberingStyleHelp": "Bestimmt, wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden.", "suffixName": "Suffix", "prefixName": "Präfix", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Bereits gesperrte Szenen behalten ihre Nummer — nur vorläufige Szenen und zukünftige Sperrungen sind betroffen." + "skippedLettersTitle": "Übersprungene Buchstaben", + "skippedLettersHelp": "Buchstaben, die bei der Szenennummerierung weggelassen werden, meist um Verwechslungen zu vermeiden.", + "skipLetterAriaLabel": "Buchstabe {letter} überspringen" }, "saves": { "title": "Versionsverlauf", diff --git a/messages/en.json b/messages/en.json index 3c775230..81b9fb7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -461,12 +461,12 @@ "unlock": "Unlock", "cancel": "Cancel", "numberingStyleTitle": "Scene numbering", - "numberingStyleHelp": "How newly inserted scenes are numbered between two locked scenes. Suffix references the previous scene, prefix references the next.", + "numberingStyleHelp": "Dictates how newly inserted scenes should be numbered between two locked scenes.", "suffixName": "Suffix", "prefixName": "Prefix", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Already-locked scenes keep their stored number — only provisional scenes and future locks are affected." + "skippedLettersTitle": "Skipped letters", + "skippedLettersHelp": "Letters to omit when generating scene numbers usually to avoid confusion.", + "skipLetterAriaLabel": "Skip letter {letter}" }, "saves": { "title": "Version History", diff --git a/messages/es.json b/messages/es.json index d809cea1..365185cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -461,12 +461,12 @@ "unlock": "Desbloquear", "cancel": "Cancelar", "numberingStyleTitle": "Numeración de escenas", - "numberingStyleHelp": "Cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas. El sufijo se refiere a la escena anterior, el prefijo a la siguiente.", + "numberingStyleHelp": "Determina cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas.", "suffixName": "Sufijo", "prefixName": "Prefijo", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Las escenas ya bloqueadas conservan su número — solo se ven afectadas las escenas provisionales y los futuros bloqueos." + "skippedLettersTitle": "Letras omitidas", + "skippedLettersHelp": "Letras que se omiten al generar los números de escena, generalmente para evitar confusiones.", + "skipLetterAriaLabel": "Omitir la letra {letter}" }, "saves": { "title": "Historial de versiones", diff --git a/messages/fr.json b/messages/fr.json index 88569350..0e2b96b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -462,12 +462,12 @@ "unlock": "Déverrouiller", "cancel": "Annuler", "numberingStyleTitle": "Numérotation des scènes", - "numberingStyleHelp": "Comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées. Le suffixe se réfère à la scène précédente, le préfixe à la suivante.", + "numberingStyleHelp": "Détermine comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées.", "suffixName": "Suffixe", "prefixName": "Préfixe", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Les scènes déjà verrouillées conservent leur numéro — seules les scènes provisoires et les futurs verrouillages sont concernés." + "skippedLettersTitle": "Lettres ignorées", + "skippedLettersHelp": "Lettres à omettre lors de la génération des numéros de scène, généralement pour éviter toute confusion.", + "skipLetterAriaLabel": "Ignorer la lettre {letter}" }, "saves": { "title": "Historique des versions", diff --git a/messages/ja.json b/messages/ja.json index 2a981d11..5c473138 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -461,12 +461,12 @@ "unlock": "ロック解除", "cancel": "キャンセル", "numberingStyleTitle": "シーン番号", - "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法。サフィックスは前のシーン、プレフィックスは次のシーンを参照します。", + "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法を決定します。", "suffixName": "サフィックス", "prefixName": "プレフィックス", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "すでにロックされているシーンは番号を保持します — 仮シーンと今後のロックのみが影響を受けます。" + "skippedLettersTitle": "スキップする文字", + "skippedLettersHelp": "シーン番号生成時に省略する文字。通常は混同を避けるために使用します。", + "skipLetterAriaLabel": "{letter} をスキップ" }, "saves": { "title": "バージョン履歴", diff --git a/messages/ko.json b/messages/ko.json index 93fbb859..17d8160e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -461,12 +461,12 @@ "unlock": "잠금 해제", "cancel": "취소", "numberingStyleTitle": "씬 번호", - "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식. 접미사는 이전 씬을, 접두사는 다음 씬을 참조합니다.", + "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식을 결정합니다.", "suffixName": "접미사", "prefixName": "접두사", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "이미 잠긴 씬은 번호를 유지합니다 — 임시 씬과 향후 잠금에만 영향을 미칩니다." + "skippedLettersTitle": "건너뛸 문자", + "skippedLettersHelp": "씬 번호를 생성할 때 제외할 문자입니다. 보통 혼동을 피하기 위해 사용합니다.", + "skipLetterAriaLabel": "{letter} 문자 건너뛰기" }, "saves": { "title": "버전 히스토리", diff --git a/messages/pl.json b/messages/pl.json index e21382c1..bf9f2ea0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -461,12 +461,12 @@ "unlock": "Odblokuj", "cancel": "Anuluj", "numberingStyleTitle": "Numeracja scen", - "numberingStyleHelp": "Jak są numerowane nowo wstawione sceny między dwiema zablokowanymi. Sufiks odnosi się do poprzedniej sceny, prefiks do następnej.", + "numberingStyleHelp": "Określa, jak numerowane są nowo wstawione sceny między dwiema zablokowanymi scenami.", "suffixName": "Sufiks", "prefixName": "Prefiks", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Już zablokowane sceny zachowują swój numer — wpływa tylko na sceny tymczasowe i przyszłe blokady." + "skippedLettersTitle": "Pomijane litery", + "skippedLettersHelp": "Litery pomijane podczas generowania numerów scen, zwykle aby uniknąć pomyłek.", + "skipLetterAriaLabel": "Pomiń literę {letter}" }, "saves": { "title": "Historia wersji", diff --git a/messages/zh.json b/messages/zh.json index 09623192..fec25d70 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -461,12 +461,12 @@ "unlock": "解锁", "cancel": "取消", "numberingStyleTitle": "场景编号", - "numberingStyleHelp": "两个锁定场景之间新插入场景的编号方式。后缀参考上一个场景,前缀参考下一个场景。", + "numberingStyleHelp": "决定两个锁定场景之间新插入场景的编号方式。", "suffixName": "后缀", "prefixName": "前缀", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "已锁定的场景保留其编号 — 仅影响临时场景和将来的锁定。" + "skippedLettersTitle": "跳过的字母", + "skippedLettersHelp": "生成场景编号时省略的字母,通常用于避免混淆。", + "skipLetterAriaLabel": "跳过字母 {letter}" }, "saves": { "title": "版本历史", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index e30c5a05..9fd6b465 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -20,10 +20,12 @@ import { CollaboratorInfo, ConnectionStatus, LayoutData, + ProductionData, useProjectYjs, ElementStyle, PageMargin, DEFAULT_PAGE_MARGINS, + DEFAULT_SKIPPED_SCENE_LETTERS, ShelfEntry, ProjectStatus, } from "@src/lib/project/project-state"; @@ -102,6 +104,8 @@ export interface ProjectContextType { setSceneLocking: (locked: boolean) => void; sceneNumberingStyle: "suffix" | "prefix"; setSceneNumberingStyle: (style: "suffix" | "prefix") => void; + skippedSceneLetters: string[]; + setSkippedSceneLetters: (letters: string[]) => void; /** Raw persistent scene map (UUID → PersistentScene). Includes synopsis, * color, and production-lock fields (token, omitted) for every scene that * has been persisted. */ @@ -187,6 +191,8 @@ const defaultContextValue: ProjectContextType = { setSceneLocking: () => {}, sceneNumberingStyle: "suffix", setSceneNumberingStyle: () => {}, + skippedSceneLetters: DEFAULT_SKIPPED_SCENE_LETTERS, + setSkippedSceneLetters: () => {}, persistentScenes: {}, characters: {}, locations: {}, @@ -310,6 +316,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [sceneLocking, setSceneLockingState] = useState(false); const [sceneNumberingStyle, setSceneNumberingStyleState] = useState<"suffix" | "prefix">("suffix"); + const [skippedSceneLetters, setSkippedSceneLettersState] = + useState(DEFAULT_SKIPPED_SCENE_LETTERS); const [persistentScenes, setPersistentScenesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -488,11 +496,19 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialLayout.elementStyles !== undefined) { setElementStylesState(initialLayout.elementStyles); } - if (initialLayout.sceneLocking !== undefined) { - setSceneLockingState(initialLayout.sceneLocking); + } + + // Read initial production data (separate Y.Map from layout). + const initialProduction = repository.getProduction(); + if (initialProduction) { + if (initialProduction.sceneLocking !== undefined) { + setSceneLockingState(initialProduction.sceneLocking); + } + if (initialProduction.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(initialProduction.sceneNumberingStyle); } - if (initialLayout.sceneNumberingStyle !== undefined) { - setSceneNumberingStyleState(initialLayout.sceneNumberingStyle); + if (initialProduction.skippedSceneLetters !== undefined) { + setSkippedSceneLettersState(initialProduction.skippedSceneLetters); } } @@ -537,11 +553,18 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (layout.elementStyles !== undefined) { setElementStylesState(layout.elementStyles); } - if (layout.sceneLocking !== undefined) { - setSceneLockingState(layout.sceneLocking); + }); + + // Observe production changes + const unsubscribeProduction = repository.observeProduction((production: Partial) => { + if (production.sceneLocking !== undefined) { + setSceneLockingState(production.sceneLocking); + } + if (production.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(production.sceneNumberingStyle); } - if (layout.sceneNumberingStyle !== undefined) { - setSceneNumberingStyleState(layout.sceneNumberingStyle); + if (production.skippedSceneLetters !== undefined) { + setSkippedSceneLettersState(production.skippedSceneLetters); } }); @@ -586,6 +609,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = return () => { repository.unregisterScreenplayCallback(recomputeFromScreenplay); unsubscribeLayout(); + unsubscribeProduction(); unsubscribeCharacters(); unsubscribeLocations(); unsubscribeScenes(); @@ -758,6 +782,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setSkippedSceneLetters = useCallback( + (letters: string[]) => { + setSkippedSceneLettersState(letters); + repository?.setSkippedSceneLetters(letters); + }, + [repository], + ); + const setSearchTerm = useCallback((term: string) => { setSearchTermState(term); // Reset to first match when search term changes @@ -846,6 +878,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setSceneLocking, sceneNumberingStyle, setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, persistentScenes, screenplay, scenes, @@ -915,6 +949,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setSceneLocking, sceneNumberingStyle, setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, persistentScenes, screenplay, scenes, diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 9b23880c..245e84e5 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -1,13 +1,11 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { ProjectData, ProjectState } from "@src/lib/project/project-state"; -import type { PersistentSceneMap } from "@src/lib/screenplay/scenes"; import { PageFormat } from "@src/lib/utils/enums"; import { getFontForCodePoint, ScriptFont } from "./pdf-utils"; import type { TextRun } from "./pdf.worker"; import { BASE_URL } from "@src/lib/utils/constants"; import { PAGE_SIZES } from "@src/lib/screenplay/extensions/pagination-extension"; -import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -23,10 +21,6 @@ export type PDFExportOptions = BaseExportOptions & { moreLabel?: string; editorElement?: HTMLElement; titlePageElement?: HTMLElement; - /** Production-mode flag + persistent scene entries, set internally from the ProjectState. */ - sceneLocking?: boolean; - sceneNumberingStyle?: "suffix" | "prefix"; - persistentScenes?: PersistentSceneMap; }; import type { WorkerMessage, WorkerPayload, VisualLine } from "./pdf.worker"; @@ -71,34 +65,25 @@ export class PDFAdapter extends ProjectAdapter { label = "PDF"; extension = "pdf"; - async convertTo(project: ProjectState, options: PDFExportOptions): Promise { + async convertTo(_project: ProjectState, options: PDFExportOptions): Promise { const editorEl = options.editorElement; if (!editorEl) throw new Error("Editor element is required for DOM-based PDF export"); const format = options.format; const pdfPageSize = PDF_PAGE_SIZES[format]; - // Resolve production state from the project. Persistent scenes carry - // synopsis/color (irrelevant here) and token/omitted (used for labels). - const layout = project.layout().toJSON() as { - sceneLocking?: boolean; - sceneNumberingStyle?: "suffix" | "prefix"; - }; - const persistentScenes = project.scenes().toJSON() as PersistentSceneMap; - const enrichedOptions: PDFExportOptions = { - ...options, - sceneLocking: !!layout.sceneLocking, - sceneNumberingStyle: layout.sceneNumberingStyle ?? "suffix", - persistentScenes, - }; + // Scene labels (under production lock) and OMITTED state are already + // rendered as ProseMirror decoration widgets inside each scene

. + // `collectLines` reads them directly from the DOM, so we don't need to + // re-run the scene-labeling logic here. // ── Collect all visual lines from the browser DOM ─────────────────── const titlePageEl = options.titlePageElement; - const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, enrichedOptions) : []; + const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, options) : []; const titlePageLeftPx = titlePageEl ? this.getPageLeftPx(titlePageEl) : 0; - const screenplayLines = this.collectLines(editorEl, enrichedOptions); + const screenplayLines = this.collectLines(editorEl, options); const screenplayLeftPx = this.getPageLeftPx(editorEl); return new Promise((resolve, reject) => { @@ -162,28 +147,6 @@ export class PDFAdapter extends ProjectAdapter { let sceneCount = 0; let yOffset = 0; - // Pre-compute label per scene UUID when production lock is on. - const sceneLabels: { label: string; omitted: boolean }[] = []; - let sceneLabelIdx = 0; - if (options.sceneLocking) { - const uuids: string[] = []; - for (let i = 0; i < editorEl.children.length; i++) { - const child = editorEl.children[i] as HTMLElement; - if (child?.tagName === "P" && child.classList.contains("scene")) { - const uuid = child.getAttribute("data-id"); - if (uuid) uuids.push(uuid); - } - } - const computed = computeSceneLabels( - uuids, - options.persistentScenes ?? {}, - options.sceneNumberingStyle ?? "suffix", - ); - for (const l of computed) { - sceneLabels.push({ label: l.label, omitted: l.status === "omitted" }); - } - } - for (let i = 0; i < editorEl.children.length; i++) { const el = editorEl.children[i] as HTMLElement; if (!el) continue; @@ -206,10 +169,19 @@ export class PDFAdapter extends ProjectAdapter { const isScene = el.classList.contains("scene"); if (isScene) sceneCount++; + // Label widgets are injected by `scene-locking-extension` when + // production lock is on. Read whichever side is present (left or + // right) and fall back to a positional number when neither is. const sceneInfo = isScene - ? options.sceneLocking - ? sceneLabels[sceneLabelIdx++] ?? { label: String(sceneCount), omitted: false } - : { label: String(sceneCount), omitted: false } + ? { + label: + (el.querySelector(".scene-label-left") as HTMLElement | null)?.textContent + ?.trim() || + (el.querySelector(".scene-label-right") as HTMLElement | null)?.textContent + ?.trim() || + String(sceneCount), + omitted: el.getAttribute("data-omitted-overlay") === "true", + } : undefined; // Extract the paragraph type from classList diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index 21464087..cb1bdcb8 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -1,4 +1,4 @@ -import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; +import { BoardData, LayoutData, ProductionData, ProjectData, ProjectMetadata, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { replaceScreenplay } from "../../screenplay/editor"; import { Editor } from "@tiptap/react"; @@ -79,6 +79,7 @@ export class ScriptioAdapter extends ProjectAdapter { locations: project.locations().toJSON(), board: project.board().toJSON() as BoardData, layout: project.layout().toJSON() as LayoutData, + production: project.production().toJSON() as ProductionData, comments: project.comments().toJSON(), }; payload = new TextEncoder().encode(JSON.stringify(data, null, 2)); @@ -129,6 +130,7 @@ export class ScriptioAdapter extends ProjectAdapter { locations: tmpDoc.locations().toJSON(), board: tmpDoc.board().toJSON() as BoardData, layout: tmpDoc.layout().toJSON() as LayoutData, + production: tmpDoc.production().toJSON() as ProductionData, comments: tmpDoc.comments().toJSON(), }; } catch (error) { @@ -171,6 +173,7 @@ export class ScriptioAdapter extends ProjectAdapter { ydoc.locations().clear(); ydoc.board().clear(); ydoc.layout().clear(); + ydoc.production().clear(); ydoc.comments().clear(); }); diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 7a6ac17f..4d19fa14 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -77,6 +77,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum moreLabel, sceneLocking, sceneNumberingStyle, + skippedSceneLetters, persistentScenes, } = projectCtx; @@ -172,6 +173,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum setSearchMatches, sceneLocking, sceneNumberingStyle, + skippedSceneLetters, persistentScenes, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); @@ -188,6 +190,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.setSearchMatches = setSearchMatches; ext.sceneLocking = sceneLocking; ext.sceneNumberingStyle = sceneNumberingStyle; + ext.skippedSceneLetters = skippedSceneLetters; ext.persistentScenes = persistentScenes; const lastReportedElementRef = useRef(null); @@ -269,6 +272,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum getSceneLocking: () => !!ext.sceneLocking, getScenes: () => ext.repository?.scenes ?? {}, getNumberingStyle: () => ext.sceneNumberingStyle ?? "suffix", + getSkippedLetters: () => ext.skippedSceneLetters ?? [], }) : null; @@ -596,7 +600,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum if (editor && features.sceneLocking) { refreshSceneLocking(editor); } - }, [editor, sceneLocking, sceneNumberingStyle, persistentScenes, features.sceneLocking]); + }, [editor, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes, features.sceneLocking]); // Refresh search highlights useEffect(() => { diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index f729c449..c8ecf342 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -101,6 +101,13 @@ export type LayoutData = { moreLabel: string; elementMargins: Record; elementStyles: Record; +}; + +// -------------------------------- // +// PRODUCTION // +// -------------------------------- // + +export type ProductionData = { sceneLocking?: boolean; /** * How provisional scenes inserted under production lock are labeled. @@ -111,8 +118,21 @@ export type LayoutData = { * already-locked scenes keep their stored label. */ sceneNumberingStyle?: "suffix" | "prefix"; + /** + * Uppercase letters to omit from generated scene labels (e.g. "I" and "O" + * are visually confused with "1" and "0"). Stored explicitly so the user's + * choice survives — when `undefined`, callers fall back to + * `DEFAULT_SKIPPED_SCENE_LETTERS`. + */ + skippedSceneLetters?: string[]; }; +/** Letters skipped by default in newly-created projects. */ +export const DEFAULT_SKIPPED_SCENE_LETTERS: string[] = ["I", "O"]; + +/** Letters the user can toggle via Production Settings. */ +export const TOGGLEABLE_SCENE_LETTERS: readonly string[] = ["I", "O", "Q", "Z"]; + // -------------------------------- // // BOARD // // -------------------------------- // @@ -152,6 +172,7 @@ export type ProjectData = { metadata: ProjectMetadata; board: BoardData; layout: LayoutData; + production: ProductionData; comments?: Record; shelf?: Record; }; @@ -186,6 +207,7 @@ export class ProjectState extends Y.Doc { METADATA: "metadata", BOARD: "board", LAYOUT: "layout", + PRODUCTION: "production", COMMENTS: "comments", DICTIONARY: "dictionary", SHELF: "shelf", @@ -233,6 +255,10 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.LAYOUT) as unknown as TypedMap; } + production(): TypedMap { + return this.getMap(this.KEYS.PRODUCTION) as unknown as TypedMap; + } + comments(): Y.Map { return this.getMap(this.KEYS.COMMENTS); } diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 23d6a96a..bc7f51de 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -5,6 +5,7 @@ import { ScreenplaySchema } from "../screenplay/editor"; import { Comment, CommentReply, Screenplay } from "../utils/types"; import { LayoutData, + ProductionData, ProjectMetadata, ProjectState, ElementStyle, @@ -370,13 +371,33 @@ export class ProjectRepository { if (this.guardWrite("setElementStyles")) return; this.ydoc.layout().set("elementStyles", styles); } + + // -------------------------------- // + // PRODUCTION // + // -------------------------------- // + + getProduction(): Partial { + return this.ydoc.production().toJSON() as Partial; + } + + observeProduction(callback: (production: Partial) => void): () => void { + const map = this.ydoc.production(); + const observer = () => callback(map.toJSON() as Partial); + map.observe(observer); + return () => map.unobserve(observer); + } + setSceneLocking(locked: boolean) { if (this.guardWrite("setSceneLocking")) return; - this.ydoc.layout().set("sceneLocking", locked); + this.ydoc.production().set("sceneLocking", locked); } setSceneNumberingStyle(style: "suffix" | "prefix") { if (this.guardWrite("setSceneNumberingStyle")) return; - this.ydoc.layout().set("sceneNumberingStyle", style); + this.ydoc.production().set("sceneNumberingStyle", style); + } + setSkippedSceneLetters(letters: string[]) { + if (this.guardWrite("setSkippedSceneLetters")) return; + this.ydoc.production().set("skippedSceneLetters", letters); } /** diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 9196005a..aadb2a33 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -22,6 +22,8 @@ export { DEFAULT_PAGE_MARGINS, DEFAULT_ELEMENT_MARGINS, DEFAULT_ELEMENT_STYLES, + DEFAULT_SKIPPED_SCENE_LETTERS, + TOGGLEABLE_SCENE_LETTERS, } from "./project-doc"; export type { ShelfEntryType, @@ -32,6 +34,7 @@ export type { PageMargin, ElementStyle, LayoutData, + ProductionData, BoardCardData, BoardArrowData, BoardData, diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts index ea7fd1ec..fec51525 100644 --- a/src/lib/screenplay/extensions/scene-locking-extension.ts +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -14,6 +14,7 @@ type SceneLockingConfig = { getSceneLocking: () => boolean; getScenes: () => Record; getNumberingStyle: () => SceneNumberingStyle; + getSkippedLetters: () => readonly string[]; }; type SceneEntry = { uuid: string; pos: number; nodeSize: number }; @@ -90,6 +91,7 @@ const computeDecorations = ( locking: boolean, scenes: Record, style: SceneNumberingStyle, + skippedLetters: readonly string[], ): DecorationSet => { // Nothing to render: no production lock and no omitted scenes. Skip the // doc traversal entirely — this is the common case for most users. @@ -106,6 +108,7 @@ const computeDecorations = ( entries.map((e) => e.uuid), scenes, style, + skippedLetters, ); for (let i = 0; i < entries.length; i++) { @@ -193,7 +196,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { name: "sceneLocking", addProseMirrorPlugins() { - const { getSceneLocking, getScenes, getNumberingStyle } = config; + const { getSceneLocking, getScenes, getNumberingStyle, getSkippedLetters } = config; return [ new Plugin({ @@ -205,6 +208,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { getSceneLocking(), getScenes(), getNumberingStyle(), + getSkippedLetters(), ); }, apply(tr, oldDecorations, _oldState, newState) { @@ -215,6 +219,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { getSceneLocking(), getScenes(), getNumberingStyle(), + getSkippedLetters(), ); } @@ -231,6 +236,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { getSceneLocking(), getScenes(), getNumberingStyle(), + getSkippedLetters(), ); }, }, diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts index 6e6a33ab..cc14d023 100644 --- a/src/lib/screenplay/scene-locking.ts +++ b/src/lib/screenplay/scene-locking.ts @@ -78,18 +78,38 @@ export type SceneLabel = { export type SceneNumberingStyle = "suffix" | "prefix"; +const FULL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** + * Build the effective alphabet by removing any letters the user wants to + * skip (e.g. "I" and "O" are commonly skipped because they're confused with + * digits). Always returns at least 2 letters so the labeling math doesn't + * degenerate — pathological skip lists fall back to the full alphabet. + */ +export const buildSceneAlphabet = (skipped: readonly string[] = []): string => { + const skipSet = new Set(skipped.map((s) => s.toUpperCase())); + const filtered = FULL_ALPHABET.split("").filter((c) => !skipSet.has(c)).join(""); + return filtered.length >= 2 ? filtered : FULL_ALPHABET; +}; + // -------------------------------------------------------------------------- // ENCODING & DISPLAY // -------------------------------------------------------------------------- -/** Excel-style alphabetic letter: 1 → A, 26 → Z, 27 → AA, … */ -const letterFromValue = (n: number, lower: boolean): string => { +/** + * Excel-style alphabetic letter over a configurable alphabet: + * 1 → alphabet[0], alphabet.length → last letter, alphabet.length+1 → "AA", … + * The alphabet defaults to A–Z but callers can pass a filtered one (e.g. + * with "I" and "O" removed). + */ +const letterFromValue = (n: number, lower: boolean, alphabet: string = FULL_ALPHABET): string => { + const base = alphabet.length; let out = ""; - const charBase = lower ? 97 : 65; while (n > 0) { - const m = (n - 1) % 26; - out = String.fromCharCode(charBase + m) + out; - n = Math.floor((n - 1) / 26); + const m = (n - 1) % base; + const ch = alphabet[m]; + out = (lower ? ch.toLowerCase() : ch) + out; + n = Math.floor((n - 1) / base); } return out; }; @@ -99,18 +119,18 @@ const letterFromValue = (n: number, lower: boolean): string => { * each level is taken directly from `level.lower`, so the result is the * same regardless of the project's `SceneNumberingStyle` setting. */ -export const compileSceneLabel = (token: SceneToken): string => { +export const compileSceneLabel = (token: SceneToken, alphabet: string = FULL_ALPHABET): string => { // Prefixes are stored inner-first (closest to base at index 0). Render // outer-to-inner, i.e. reverse before joining. let out = ""; for (let i = token.prefixes.length - 1; i >= 0; i--) { const lvl = token.prefixes[i]; - out += letterFromValue(lvl.value, lvl.lower); + out += letterFromValue(lvl.value, lvl.lower, alphabet); } out += String(token.baseNumber); for (let i = 0; i < token.suffixes.length; i++) { const lvl = token.suffixes[i]; - out += letterFromValue(lvl.value, lvl.lower); + out += letterFromValue(lvl.value, lvl.lower, alphabet); } return out; }; @@ -201,8 +221,10 @@ const withLevels = (t: SceneToken, axis: Axis, levels: SceneLevel[]): SceneToken // Wedge convention per axis. lowercase < uppercase at the same value (see // `compareTokens`). Suffix levels count UP, so 'a' (value 1) is the deepest // wedge — decrementing past it means descending a level. Prefix levels are -// mirrored: 'z' (value 26) is the bound; incrementing past it descends. -const wedgeBound = (axis: Axis): number => (axis === "suffix" ? 1 : 26); +// mirrored: the alphabet's last letter (value = alphabet.length) is the +// bound; incrementing past it descends. +const wedgeBound = (axis: Axis, alphabetSize: number): number => + axis === "suffix" ? 1 : alphabetSize; const wedgeStep = (axis: Axis): number => (axis === "suffix" ? -1 : 1); /** Bump the deepest level of `anchor` along `axis` by k. */ @@ -240,10 +262,11 @@ const wedgeAlong = ( target: SceneToken, k: number, axis: Axis, + alphabetSize: number, ): SceneToken | null => { const fromLevels = levelsOf(from, axis); const targetLevels = levelsOf(target, axis); - const bound = wedgeBound(axis); + const bound = wedgeBound(axis, alphabetSize); const step = wedgeStep(axis); let i = 0; @@ -295,6 +318,7 @@ const computeProvisionalToken = ( next: SceneToken | null, k: number, style: SceneNumberingStyle, + alphabetSize: number, ): SceneToken => { if (!prev && !next) return baseToken(k); @@ -309,7 +333,7 @@ const computeProvisionalToken = ( return [ continueAlong(prev, k, "suffix"), nestAlong(prev, k, "suffix"), - next ? wedgeAlong(prev, next, k, "suffix") : null, + next ? wedgeAlong(prev, next, k, "suffix", alphabetSize) : null, ]; } // No prev — nothing to grow from on the suffix axis. The natural @@ -323,7 +347,7 @@ const computeProvisionalToken = ( return [ prev ? continueAlong(prev, k, "prefix") : null, nestAlong(next, k, "prefix"), - prev ? wedgeAlong(next, prev, k, "prefix") : null, + prev ? wedgeAlong(next, prev, k, "prefix", alphabetSize) : null, ]; } return prev ? [continueAlong(prev, k, "suffix")] : []; @@ -361,7 +385,10 @@ export const computeSceneLabels = ( sceneUuids: string[], persistent: Record, style: SceneNumberingStyle = "suffix", + skippedLetters: readonly string[] = [], ): SceneLabel[] => { + const alphabet = buildSceneAlphabet(skippedLetters); + const alphabetSize = alphabet.length; const n = sceneUuids.length; const result: SceneLabel[] = new Array(n); @@ -404,7 +431,7 @@ export const computeSceneLabels = ( result[i] = { uuid, token: entry.token, - label: compileSceneLabel(entry.token), + label: compileSceneLabel(entry.token, alphabet), status: entry.omitted ? "omitted" : "locked", }; continue; @@ -415,11 +442,12 @@ export const computeSceneLabels = ( nextLocked[i], segmentIdx[i], style, + alphabetSize, ); result[i] = { uuid, token, - label: compileSceneLabel(token), + label: compileSceneLabel(token, alphabet), status: "provisional", }; } From 8b0853bd53587cba19db49463318ed74d1b7977d Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 May 2026 11:32:08 +0200 Subject: [PATCH 55/76] started page locking feature --- components/editor/CommentCards.tsx | 40 +- components/navbar/ProductionPanel.tsx | 139 +++++- components/navbar/SavesPanel.tsx | 24 +- components/popup/Popup.tsx | 4 + components/popup/PopupUnlockPages.tsx | 52 ++ messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/ja.json | 5 + messages/ko.json | 5 + messages/pl.json | 5 + messages/zh.json | 5 + src/context/ProjectContext.tsx | 43 +- src/lib/adapters/scriptio/scriptio-adapter.ts | 2 + src/lib/editor/use-document-editor.ts | 21 +- src/lib/project/project-doc.ts | 15 + src/lib/project/project-repository.ts | 72 +++ .../extensions/pagination-extension.ts | 469 ++++++++++++++++-- src/lib/screenplay/page-locking.ts | 28 ++ src/lib/screenplay/popup.ts | 15 +- src/lib/utils/hooks.ts | 30 ++ 22 files changed, 897 insertions(+), 97 deletions(-) create mode 100644 components/popup/PopupUnlockPages.tsx create mode 100644 src/lib/screenplay/page-locking.ts diff --git a/components/editor/CommentCards.tsx b/components/editor/CommentCards.tsx index 29571f3e..6071f4ab 100644 --- a/components/editor/CommentCards.tsx +++ b/components/editor/CommentCards.tsx @@ -3,44 +3,29 @@ import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { Comment, CommentReply } from "@src/lib/utils/types"; import { Send, Trash2, X } from "lucide-react"; -import { useUser } from "@src/lib/utils/hooks"; +import { useFormatTimestamp, useUser } from "@src/lib/utils/hooks"; import { getCommentPositions } from "@src/lib/screenplay/extensions/comment-highlight-extension"; import { useViewContext } from "@src/context/ViewContext"; import { Editor } from "@tiptap/react"; import { Transaction } from "@tiptap/pm/state"; import styles from "./CommentCard.module.css"; -function formatTimestamp(ts: number): string { - const date = new Date(ts); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) return `${diffDays}d ago`; - - return date.toLocaleDateString(); -} - // -------------------------------- // // Reply bubble // // -------------------------------- // -const ReplyBubble = ({ reply }: { reply: CommentReply }) => ( -

-
- {reply.author} - {formatTimestamp(reply.createdAt)} +const ReplyBubble = ({ reply }: { reply: CommentReply }) => { + const formatTimestamp = useFormatTimestamp(); + return ( +
+
+ {reply.author} + {formatTimestamp(reply.createdAt)} +
+
{reply.text}
-
{reply.text}
-
-); + ); +}; // -------------------------------- // // Comment card // @@ -62,6 +47,7 @@ const CommentCard = ({ comment, isActive, onActivate, onDeactivate, onSave, onDe const [draft, setDraft] = useState(comment.text); const [replyDraft, setReplyDraft] = useState(""); const textareaRef = useRef(null); + const formatTimestamp = useFormatTimestamp(); useEffect(() => { if (isEditing && textareaRef.current) { diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index a334f3b8..dee0c271 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -2,13 +2,14 @@ import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Lock, X } from "lucide-react"; +import { Lock, X, Layers } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; import { computeSceneItems } from "@src/lib/screenplay/scenes"; -import { unlockScenesPopup } from "@src/lib/screenplay/popup"; +import { unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; +import { getPageAnchors } from "@src/lib/screenplay/extensions/pagination-extension"; import Switch from "@components/utils/Switch"; import styles from "./ProductionPanel.module.css"; @@ -37,7 +38,11 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { sceneNumberingStyle, skippedSceneLetters, persistentScenes, + pageLocking, + persistentPages, scenes, + screenplay, + editor, repository, isReadOnly, } = useContext(ProjectContext); @@ -131,7 +136,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const currentScreenplay = repository.screenplay; const scenes = computeSceneItems(currentScreenplay); const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); - + // Re-read fresh persistent data const persistentSnapshot = repository.scenes; @@ -142,23 +147,91 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { skippedSceneLetters, ); - console.log("[ProductionPanel] RELOCKING PROVISIONAL. Full snapshot:", currentLabels.map(l => ({ - uuid: l.uuid, - label: l.label, - status: l.status, - token: l.token - }))); - - let relockedCount = 0; currentLabels.forEach((label) => { if (label.status === "provisional") { - console.log(`[ProductionPanel] -> Freezing ${label.uuid} as "${label.label}"`); repository.upsertScene(label.uuid, { token: label.token }); - relockedCount++; } }); + }); + }; + + // -------------- Page locking -------------- + // Pulls anchors from the live pagination state; recomputed whenever the + // screenplay or the persistent maps change. Each render is cheap (a single + // Set traversal over the plugin state); we don't subscribe to pagination + // events because the production panel is only meaningful as a snapshot + // when the user opens it. + const pageAnchors = useMemo(() => { + if (!editor) return []; + return getPageAnchors(editor); + // `screenplay` and `persistentPages` are listed so the memo refreshes + // when content/locks change — they're not used inside the body. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, screenplay, persistentPages, persistentScenes]); + + const pageLabels = useMemo(() => { + if (!pageLocking || pageAnchors.length === 0) return []; + return computeSceneLabels(pageAnchors, persistentPages, "suffix", skippedSceneLetters); + }, [pageLocking, pageAnchors, persistentPages, skippedSceneLetters]); + + const provisionalPageLabels = useMemo( + () => pageLabels.filter((l) => l.status === "provisional"), + [pageLabels], + ); - console.log(`[ProductionPanel] Relock complete. Persisted ${relockedCount} tokens.`); + const performPageUnlock = useCallback(() => { + if (!repository) return; + repository.transact(() => { + repository.clearPageLocks(); + repository.setPageLocking(false); + }); + }, [repository]); + + const handlePageLockingToggle = (next: boolean) => { + if (!repository || isReadOnly) return; + if (next) { + if (!editor) return; + repository.transact(() => { + const anchors = getPageAnchors(editor); + const persistentSnapshot = repository.pages; + // Idempotent: any anchor that already has a token keeps it. + // Only provisional anchors (no token yet) get a freshly-computed + // one. A fresh lock-on with no existing tokens assigns every + // page baseToken(idx+1) — same shape as scene locking. + const computed = computeSceneLabels( + anchors, + persistentSnapshot, + "suffix", + skippedSceneLetters, + ); + computed.forEach((label) => { + if (label.status === "provisional") { + repository.upsertPage(label.uuid, { token: label.token }); + } + }); + repository.setPageLocking(true); + }); + } else { + unlockPagesPopup(performPageUnlock, userCtx); + } + }; + + const handlePageRelock = () => { + if (!repository || isReadOnly || !editor) return; + repository.transact(() => { + const anchors = getPageAnchors(editor); + const persistentSnapshot = repository.pages; + const currentLabels = computeSceneLabels( + anchors, + persistentSnapshot, + "suffix", + skippedSceneLetters, + ); + currentLabels.forEach((label) => { + if (label.status === "provisional") { + repository.upsertPage(label.uuid, { token: label.token }); + } + }); }); }; @@ -217,14 +290,48 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { )}
- {/* Page Locking (inert in v1) */} + {/* Page Locking */}
+ {t("pageLocking")}
- {}} ariaLabel={t("pageLocking")} /> +
+ {pageLocking && provisionalPageLabels.length > 0 && ( + + )} + +
+ + {pageLocking && provisionalPageLabels.length > 0 && ( +
+
{t("pageProvisionalTitle")}
+
+ {provisionalPageLabels.map((l, idx) => ( + + {l.label} + + ))} +
+
+ )}
{/* Revisions (inert in v1) */} diff --git a/components/navbar/SavesPanel.tsx b/components/navbar/SavesPanel.tsx index 77b3d80c..fa04eac1 100644 --- a/components/navbar/SavesPanel.tsx +++ b/components/navbar/SavesPanel.tsx @@ -3,7 +3,7 @@ import { useContext, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { DashboardContext } from "@src/context/DashboardContext"; -import { useCookieUser } from "@src/lib/utils/hooks"; +import { useCookieUser, useFormatTimestamp } from "@src/lib/utils/hooks"; import { X, Save, @@ -35,10 +35,10 @@ interface SavesPanelProps { const SavesPanel = ({ projectId, isOpen, onClose, isPro }: SavesPanelProps) => { const t = useTranslations("saves"); - const tDates = useTranslations("dates"); const { openDashboard } = useContext(DashboardContext); const { user } = useCookieUser(); const isSignedIn = !!user; + const formatDate = useFormatTimestamp(); const handleUpgrade = () => { onClose(); @@ -150,26 +150,6 @@ const SavesPanel = ({ projectId, isOpen, onClose, isPro }: SavesPanelProps) => { setConfirmDeleteKey(null); }; - const formatDate = (iso: string) => { - const date = new Date(iso); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return tDates("justNow"); - if (diffMins < 60) return tDates("minutesAgo", { mins: diffMins }); - if (diffHours < 24) return tDates("hoursAgo", { hours: diffHours }); - if (diffDays < 7) return tDates("daysAgo", { days: diffDays }); - - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, - }); - }; - const formatFullDate = (iso: string) => { return new Date(iso).toLocaleString(undefined, { month: "short", diff --git a/components/popup/Popup.tsx b/components/popup/Popup.tsx index f0950752..f1e40989 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,6 +7,7 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockPagesData, PopupUnlockScenesData, PopupUploadToCloudData, } from "@src/lib/screenplay/popup"; @@ -14,6 +15,7 @@ import { useContext } from "react"; import PopupCharacterItem from "./PopupCharacterItem"; import PopupImportFile from "./PopupImportFile"; import PopupSceneItem from "./PopupSceneItem"; +import PopupUnlockPages from "./PopupUnlockPages"; import PopupUnlockScenes from "./PopupUnlockScenes"; import PopupUploadToCloud from "./PopupUploadToCloud"; @@ -34,6 +36,8 @@ export const Popup = () => { return )} />; case PopupType.UnlockScenes: return )} />; + case PopupType.UnlockPages: + return )} />; default: return null; } diff --git a/components/popup/PopupUnlockPages.tsx b/components/popup/PopupUnlockPages.tsx new file mode 100644 index 00000000..2bcef200 --- /dev/null +++ b/components/popup/PopupUnlockPages.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { X, Unlock } from "lucide-react"; + +import popup from "./Popup.module.css"; + +import { useDraggable } from "@src/lib/utils/hooks"; +import { PopupData, PopupUnlockPagesData, closePopup } from "@src/lib/screenplay/popup"; +import { UserContext } from "@src/context/UserContext"; + +const PopupUnlockPages = ({ data: { confirmUnlock } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("production"); + + const onConfirm = () => { + confirmUnlock(); + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("unlockPagesTitle")}

+ closePopup(userCtx)} /> +
+
+

{t("unlockPagesWarning")}

+
+ + +
+
+ ); +}; + +export default PopupUnlockPages; diff --git a/messages/de.json b/messages/de.json index 0302bcda..889c2462 100644 --- a/messages/de.json +++ b/messages/de.json @@ -460,6 +460,11 @@ "unlockWarning": "Alle gesperrten und ausgelassenen Szenen verlieren ihre fixierte Nummerierung.", "unlockInfo": "Dies betrifft alle Mitarbeiter. Das Drehbuch kehrt zu positionalen Szenennummern zurück. OMITTED-Szenen bleiben ausgelassen — heben Sie die Auslassung einzeln auf, um den Inhalt wiederherzustellen.", "unlock": "Entsperren", + "pageRelock": "Erneut sperren", + "pageProvisionalTitle": "Nicht gesperrte Seiten", + "unlockPagesTitle": "Seiten entsperren", + "unlockPagesWarning": "Alle Seiten verlieren ihre gesperrte Nummerierung und kehren zur natürlichen Paginierung zurück.", + "unlockPages": "Seiten entsperren", "cancel": "Abbrechen", "numberingStyleTitle": "Szenennummerierung", "numberingStyleHelp": "Bestimmt, wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden.", diff --git a/messages/en.json b/messages/en.json index 81b9fb7e..24107f8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -459,6 +459,11 @@ "unlockWarning": "All scenes will lose their locked numbering, reverting to their initial positional numbering.", "unlockInfo": "This affects every collaborator. The screenplay will revert to positional scene numbers. OMITTED scenes stay omitted — unomit them individually if you want their content back.", "unlock": "Unlock", + "pageRelock": "Relock", + "pageProvisionalTitle": "Non-locked pages", + "unlockPagesTitle": "Unlock pages", + "unlockPagesWarning": "All pages will lose their locked numbering, reverting to their natural pagination-based numbering.", + "unlockPages": "Unlock pages", "cancel": "Cancel", "numberingStyleTitle": "Scene numbering", "numberingStyleHelp": "Dictates how newly inserted scenes should be numbered between two locked scenes.", diff --git a/messages/es.json b/messages/es.json index 365185cb..4f1fb4b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -459,6 +459,11 @@ "unlockWarning": "Todas las escenas bloqueadas y omitidas perderán su numeración fija.", "unlockInfo": "Esto afecta a todos los colaboradores. El guion volverá a una numeración posicional. Las escenas OMITTED siguen omitidas — anula la omisión individualmente si quieres recuperar su contenido.", "unlock": "Desbloquear", + "pageRelock": "Rebloquear", + "pageProvisionalTitle": "Páginas no bloqueadas", + "unlockPagesTitle": "Desbloquear páginas", + "unlockPagesWarning": "Todas las páginas perderán su numeración bloqueada y volverán a la paginación natural.", + "unlockPages": "Desbloquear páginas", "cancel": "Cancelar", "numberingStyleTitle": "Numeración de escenas", "numberingStyleHelp": "Determina cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas.", diff --git a/messages/fr.json b/messages/fr.json index 0e2b96b5..2a41c334 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -460,6 +460,11 @@ "unlockWarning": "Toutes les scènes verrouillées et omises perdront leur numérotation figée.", "unlockInfo": "Ceci affecte tous les collaborateurs. Le scénario reviendra à une numérotation positionnelle. Les scènes OMITTED restent omises — démasquez-les individuellement si vous souhaitez en récupérer le contenu.", "unlock": "Déverrouiller", + "pageRelock": "Reverrouiller", + "pageProvisionalTitle": "Pages non verrouillées", + "unlockPagesTitle": "Déverrouiller les pages", + "unlockPagesWarning": "Toutes les pages perdront leur numérotation verrouillée et reviendront à la pagination naturelle.", + "unlockPages": "Déverrouiller les pages", "cancel": "Annuler", "numberingStyleTitle": "Numérotation des scènes", "numberingStyleHelp": "Détermine comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées.", diff --git a/messages/ja.json b/messages/ja.json index 5c473138..f8b5f921 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -459,6 +459,11 @@ "unlockWarning": "ロック済みおよび省略されたシーンの固定番号はすべて失われます。", "unlockInfo": "この操作はすべてのコラボレーターに影響します。脚本は位置ベースの番号に戻ります。OMITTED シーンは省略のまま残ります — 内容を復元したい場合は個別に省略を解除してください。", "unlock": "ロック解除", + "pageRelock": "再ロック", + "pageProvisionalTitle": "未ロックのページ", + "unlockPagesTitle": "ページのロックを解除", + "unlockPagesWarning": "すべてのページのロック番号が失われ、自然なページ番号付けに戻ります。", + "unlockPages": "ページのロック解除", "cancel": "キャンセル", "numberingStyleTitle": "シーン番号", "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法を決定します。", diff --git a/messages/ko.json b/messages/ko.json index 17d8160e..5e3b911f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -459,6 +459,11 @@ "unlockWarning": "잠긴 씬과 생략된 씬의 고정 번호가 모두 사라집니다.", "unlockInfo": "이 작업은 모든 협업자에게 영향을 미칩니다. 시나리오는 위치 기반 씬 번호로 되돌아갑니다. OMITTED 씬은 그대로 유지되며, 내용을 복원하려면 개별적으로 생략을 해제하세요.", "unlock": "잠금 해제", + "pageRelock": "다시 잠그기", + "pageProvisionalTitle": "잠기지 않은 페이지", + "unlockPagesTitle": "페이지 잠금 해제", + "unlockPagesWarning": "모든 페이지의 잠긴 번호가 사라지고 자연스러운 페이지 번호로 되돌아갑니다.", + "unlockPages": "페이지 잠금 해제", "cancel": "취소", "numberingStyleTitle": "씬 번호", "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식을 결정합니다.", diff --git a/messages/pl.json b/messages/pl.json index bf9f2ea0..c78feb68 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -459,6 +459,11 @@ "unlockWarning": "Wszystkie zablokowane i pominięte sceny utracą zamrożoną numerację.", "unlockInfo": "Wpłynie to na wszystkich współpracowników. Scenariusz powróci do numeracji pozycyjnej. Sceny OMITTED pozostają pominięte — anuluj pominięcie pojedynczo, aby odzyskać ich zawartość.", "unlock": "Odblokuj", + "pageRelock": "Zablokuj ponownie", + "pageProvisionalTitle": "Niezablokowane strony", + "unlockPagesTitle": "Odblokować strony?", + "unlockPagesWarning": "Wszystkie strony utracą zablokowaną numerację i powrócą do naturalnej paginacji.", + "unlockPages": "Odblokuj strony", "cancel": "Anuluj", "numberingStyleTitle": "Numeracja scen", "numberingStyleHelp": "Określa, jak numerowane są nowo wstawione sceny między dwiema zablokowanymi scenami.", diff --git a/messages/zh.json b/messages/zh.json index fec25d70..b1fe3406 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -459,6 +459,11 @@ "unlockWarning": "所有锁定和省略的场景都将失去固定编号。", "unlockInfo": "此操作会影响所有协作者。剧本将恢复为按位置编号。OMITTED 场景仍保持省略状态 — 如需恢复内容,请单独取消省略。", "unlock": "解锁", + "pageRelock": "重新锁定", + "pageProvisionalTitle": "未锁定的页面", + "unlockPagesTitle": "解锁页面?", + "unlockPagesWarning": "所有页面将失去锁定的编号,恢复为自然分页。", + "unlockPages": "解锁页面", "cancel": "取消", "numberingStyleTitle": "场景编号", "numberingStyleHelp": "决定两个锁定场景之间新插入场景的编号方式。", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 9fd6b465..bc0622be 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -13,6 +13,7 @@ import { Editor } from "@tiptap/react"; import { CharacterMap, mergeCharactersData } from "@src/lib/screenplay/characters"; import { LocationMap, mergeLocationsData } from "@src/lib/screenplay/locations"; import { mergeScenesData, PersistentSceneMap, Scene } from "@src/lib/screenplay/scenes"; +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"; @@ -111,6 +112,14 @@ export interface ProjectContextType { * has been persisted. */ persistentScenes: PersistentSceneMap; + /** Page-locking master switch (production lock for page numbering). */ + pageLocking: boolean; + setPageLocking: (locked: boolean) => void; + /** Raw persistent page-lock map (anchor data-id → PersistentPage). + * Keyed by `PAGE_ONE_KEY` for page 1, by the top-level node's data-id + * for subsequent pages. */ + persistentPages: PersistentPageMap; + // Search state searchTerm: string; setSearchTerm: (term: string) => void; @@ -194,6 +203,9 @@ const defaultContextValue: ProjectContextType = { skippedSceneLetters: DEFAULT_SKIPPED_SCENE_LETTERS, setSkippedSceneLetters: () => {}, persistentScenes: {}, + pageLocking: false, + setPageLocking: () => {}, + persistentPages: {}, characters: {}, locations: {}, scenes: [], @@ -319,6 +331,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [skippedSceneLetters, setSkippedSceneLettersState] = useState(DEFAULT_SKIPPED_SCENE_LETTERS); const [persistentScenes, setPersistentScenesState] = useState({}); + const [pageLocking, setPageLockingState] = useState(false); + const [persistentPages, setPersistentPagesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -510,10 +524,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialProduction.skippedSceneLetters !== undefined) { setSkippedSceneLettersState(initialProduction.skippedSceneLetters); } + if (initialProduction.pageLocking !== undefined) { + setPageLockingState(initialProduction.pageLocking); + } } - // Read initial persistent scenes + // Read initial persistent scenes & pages setPersistentScenesState(repository.scenes); + setPersistentPagesState(repository.pages); // Observe layout changes const unsubscribeLayout = repository.observeLayout((layout: Partial) => { @@ -566,6 +584,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (production.skippedSceneLetters !== undefined) { setSkippedSceneLettersState(production.skippedSceneLetters); } + if (production.pageLocking !== undefined) { + setPageLockingState(production.pageLocking); + } + }); + + // Observe page-lock changes + const unsubscribePages = repository.observePages((pages: PersistentPageMap) => { + setPersistentPagesState(pages); }); // Observe character changes - get current screenplay from repository @@ -610,6 +636,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = repository.unregisterScreenplayCallback(recomputeFromScreenplay); unsubscribeLayout(); unsubscribeProduction(); + unsubscribePages(); unsubscribeCharacters(); unsubscribeLocations(); unsubscribeScenes(); @@ -774,6 +801,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setPageLocking = useCallback( + (locked: boolean) => { + setPageLockingState(locked); + repository?.setPageLocking(locked); + }, + [repository], + ); + const setSceneNumberingStyle = useCallback( (style: "suffix" | "prefix") => { setSceneNumberingStyleState(style); @@ -881,6 +916,9 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = skippedSceneLetters, setSkippedSceneLetters, persistentScenes, + pageLocking, + setPageLocking, + persistentPages, screenplay, scenes, updateScenes, @@ -952,6 +990,9 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = skippedSceneLetters, setSkippedSceneLetters, persistentScenes, + pageLocking, + setPageLocking, + persistentPages, screenplay, scenes, updateScenes, diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index cb1bdcb8..fbb61d66 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -76,6 +76,7 @@ export class ScriptioAdapter extends ProjectAdapter { metadata: project.metadata().toJSON() as ProjectMetadata, characters: project.characters().toJSON(), scenes: project.scenes().toJSON(), + pages: project.pages().toJSON(), locations: project.locations().toJSON(), board: project.board().toJSON() as BoardData, layout: project.layout().toJSON() as LayoutData, @@ -127,6 +128,7 @@ export class ScriptioAdapter extends ProjectAdapter { metadata: tmpDoc.metadata().toJSON() as ProjectMetadata, characters: tmpDoc.characters().toJSON(), scenes: tmpDoc.scenes().toJSON(), + pages: tmpDoc.pages().toJSON(), locations: tmpDoc.locations().toJSON(), board: tmpDoc.board().toJSON() as BoardData, layout: tmpDoc.layout().toJSON() as LayoutData, diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 4d19fa14..f8ce413b 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -11,7 +11,7 @@ import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums import { getRandomColor } from "@src/lib/utils/misc"; import { useUser } from "@src/lib/utils/hooks"; import { getStylesFromMarks, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor"; -import { ScriptioPagination } from "@src/lib/screenplay/extensions/pagination-extension"; +import { ScriptioPagination, refreshPageLocking } from "@src/lib/screenplay/extensions/pagination-extension"; import { KeybindsExtension } from "@src/lib/screenplay/extensions/keybinds-extension"; import { executeKeybindAction, KeybindId } from "@src/lib/utils/keybinds"; import { @@ -79,6 +79,8 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum sceneNumberingStyle, skippedSceneLetters, persistentScenes, + pageLocking, + persistentPages, } = projectCtx; const projectState = repository?.getState(); @@ -175,6 +177,8 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum sceneNumberingStyle, skippedSceneLetters, persistentScenes, + pageLocking, + persistentPages, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); ext.highlightedCharacters = highlightedCharacters; @@ -192,6 +196,8 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.sceneNumberingStyle = sceneNumberingStyle; ext.skippedSceneLetters = skippedSceneLetters; ext.persistentScenes = persistentScenes; + ext.pageLocking = pageLocking; + ext.persistentPages = persistentPages; const lastReportedElementRef = useRef(null); @@ -362,6 +368,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum }, footerRight: "", ...SCREENPLAY_FORMATS[pageSize], + getPageLocking: () => !!ext.pageLocking, + getPageLocks: () => ext.persistentPages ?? {}, + getSkippedLetters: () => ext.skippedSceneLetters ?? [], } : { pageGap: 20, @@ -602,6 +611,16 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum } }, [editor, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes, features.sceneLocking]); + // Refresh pagination when page locking or the page-lock map changes. + // Pagination only reads these via getter closures on its options, so + // we must explicitly kick it to re-run; otherwise stale labels render + // until the user types. + useEffect(() => { + if (editor && config.features.paginationMode === "screenplay") { + refreshPageLocking(editor); + } + }, [editor, pageLocking, persistentPages, skippedSceneLetters, config.features.paginationMode]); + // Refresh search highlights useEffect(() => { if (editor && features.searchHighlights) { diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index c8ecf342..c9456705 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -17,6 +17,7 @@ import type { PageFormat } from "../utils/enums"; import type { CharacterItem } from "../screenplay/characters"; import type { LocationItem } from "../screenplay/locations"; import type { PersistentScene } from "../screenplay/scenes"; +import type { PersistentPage } from "../screenplay/page-locking"; import type { Comment } from "../utils/types"; // -------------------------------- // @@ -125,6 +126,14 @@ export type ProductionData = { * `DEFAULT_SKIPPED_SCENE_LETTERS`. */ skippedSceneLetters?: string[]; + /** + * Page-locking master switch. When true, pagination freezes the numbering + * of each page using anchors stored in the `pages` Y.Map. Pages inserted + * between locks get suffix-style labels (e.g. "4A"); pages appended after + * the last lock continue the integer sequence; deletion of a locked page's + * content leaves an empty page slot in its place. + */ + pageLocking?: boolean; }; /** Letters skipped by default in newly-created projects. */ @@ -168,6 +177,7 @@ export type ProjectData = { titlepage?: JSONContent[]; characters: Record; scenes: Record; + pages: Record; locations: Record; metadata: ProjectMetadata; board: BoardData; @@ -203,6 +213,7 @@ export class ProjectState extends Y.Doc { TITLEPAGE: "titlepage", CHARACTERS: "characters", SCENES: "scenes", + PAGES: "pages", LOCATIONS: "locations", METADATA: "metadata", BOARD: "board", @@ -247,6 +258,10 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.SCENES); } + pages(): Y.Map { + return this.getMap(this.KEYS.PAGES); + } + board(): TypedMap { return this.getMap(this.KEYS.BOARD) as unknown as TypedMap; } diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index bc7f51de..51f79fe9 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -18,6 +18,7 @@ import { import { CharacterMap } from "../screenplay/characters"; import { LocationMap } from "../screenplay/locations"; import { PersistentScene, PersistentSceneMap } from "../screenplay/scenes"; +import { PersistentPage, PersistentPageMap } from "../screenplay/page-locking"; import { PageFormat } from "../utils/enums"; import { generateNodeId } from "../screenplay/nodes"; import { JSONContent } from "@tiptap/react"; @@ -391,6 +392,10 @@ export class ProjectRepository { if (this.guardWrite("setSceneLocking")) return; this.ydoc.production().set("sceneLocking", locked); } + setPageLocking(locked: boolean) { + if (this.guardWrite("setPageLocking")) return; + this.ydoc.production().set("pageLocking", locked); + } setSceneNumberingStyle(style: "suffix" | "prefix") { if (this.guardWrite("setSceneNumberingStyle")) return; this.ydoc.production().set("sceneNumberingStyle", style); @@ -425,6 +430,73 @@ export class ProjectRepository { } } + /** + * Raw persistent page-lock map keyed by anchor data-id (with the + * sentinel `PAGE_ONE_KEY` for page 1). Empty when page locking has + * never been enabled. + */ + get pages(): PersistentPageMap { + return this.ydoc.pages().toJSON() as PersistentPageMap; + } + + getPage(anchorId: string): PersistentPage | undefined { + const map = this.ydoc.pages(); + return map.get(anchorId) as PersistentPage | undefined; + } + + /** + * Create or update a page lock keyed by its anchor data-id. + * Fields present in `data` (including explicit `undefined`s) overwrite + * the existing fields; everything else is preserved. Final undefined + * values are stripped before writing. + */ + upsertPage(anchorId: string, data: Partial): string { + if (this.guardWrite("upsertPage")) return anchorId; + const map = this.ydoc.pages(); + const existing = (map.get(anchorId) as PersistentPage | undefined) ?? {}; + + const merged: PersistentPage = { ...existing }; + const FIELDS = ["token"] as const; + for (const key of FIELDS) { + if (key in data) { + (merged as Record)[key] = data[key]; + } + } + for (const key of FIELDS) { + if (merged[key] === undefined) delete merged[key]; + } + + map.set(anchorId, merged); + return anchorId; + } + + deletePage(anchorId: string): void { + if (this.guardWrite("deletePage")) return; + const map = this.ydoc.pages(); + if (map.has(anchorId)) { + map.delete(anchorId); + } + } + + /** + * Wipe every persistent page-lock entry. Used when the user toggles + * page locking off — pagination reverts to plain integer numbering. + */ + clearPageLocks(): void { + if (this.guardWrite("clearPageLocks")) return; + const map = this.ydoc.pages(); + const keys: string[] = []; + map.forEach((_, key) => keys.push(key)); + for (const key of keys) map.delete(key); + } + + observePages(callback: (pages: PersistentPageMap) => void): () => void { + const map = this.ydoc.pages(); + const observer = () => callback(map.toJSON() as PersistentPageMap); + map.observe(observer); + return () => map.unobserve(observer); + } + /** * Run a function inside a single Y.js transaction. * Useful for batching multiple repository mutations into one collab update. diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index fd0fe4c8..52c1b1df 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -6,6 +6,9 @@ import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { compareTokens, computeSceneLabels, SceneToken } from "@src/lib/screenplay/scene-locking"; +import { PAGE_ONE_KEY, PersistentPageMap } from "@src/lib/screenplay/page-locking"; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -33,6 +36,8 @@ interface NodeInfo { type: ScreenplayElement; height: number; positionTop: number; + /** data-id of the top-level node, used by page locking to anchor breaks. */ + dataId?: string; } interface BreakLogic { @@ -108,6 +113,15 @@ export interface PaginationOptions { customFooter: Record; /** Element types that force a page break before them. */ startNewPageTypes: Set; + /** + * Production page-lock getters. When the editor is wired with page + * locking, these expose the live toggle and lock map. Optional so test + * harnesses and benchmarks can keep their lean Pagination.configure calls. + */ + getPageLocking?: () => boolean; + getPageLocks?: () => PersistentPageMap; + /** Letters skipped from generated labels (shared with scene locking). */ + getSkippedLetters?: () => readonly string[]; } export interface PageBreakInfo { @@ -116,6 +130,19 @@ export interface PageBreakInfo { freespace: number; // empty space remaining at the bottom of the ending page's content area contdName: string; // non-empty only for dialogue splits: Character cue name for the (CONT'D) label splitNodeType: ScreenplayElement | null; // non-null when the break is mid-node (sentence split); drives overlay escape + /** data-id of the top-level node that begins the page after this break. + * Set on every non-synthetic break; used by page locking to detect orphan locks. */ + anchorId?: string; + /** True for synthetic breaks that represent an entirely empty (orphan-locked) page. + * The widget renders the empty content area + the next page's chrome on top of + * the normal break chrome. */ + isEmpty?: boolean; + /** Display label for the page beginning after this break (e.g. "4", "4A"). + * Equals String(pagenum) when no page-lock is in effect. */ + label?: string; + /** Display label for the page ending before this break — used by the footer of + * the previous page. Undefined for the first break (footer uses page-1 label). */ + prevLabel?: string; } declare module "@tiptap/core" { @@ -183,27 +210,27 @@ function syncVars(dom: HTMLElement, o: PaginationOptions) { // Decoration builders // --------------------------------------------------------------------------- -function renderHeader(pagenum: number, options: PaginationOptions): string { +function renderHeader(pagenum: number, label: string, options: PaginationOptions): string { const custom = options.customHeader[pagenum]; const left = custom?.headerLeft ?? options.headerLeft; - const right = (custom?.headerRight ?? options.headerRight).replace("{page}", `${pagenum}`); + const right = (custom?.headerRight ?? options.headerRight).replace("{page}", label); if (!left && !right) return ""; return ( `${left}` + `${right}` ); } -function renderFooter(pagenum: number, options: PaginationOptions): string { +function renderFooter(pagenum: number, label: string, options: PaginationOptions): string { const custom = options.customFooter[pagenum]; const left = custom?.footerLeft ?? options.footerLeft; - const right = (custom?.footerRight ?? options.footerRight).replace("{page}", `${pagenum}`); + const right = (custom?.footerRight ?? options.footerRight).replace("{page}", label); if (!left && !right) return ""; return ( `${left}` + `${right}` ); } -function createFirstPageWidget(options: PaginationOptions): HTMLElement { +function createFirstPageWidget(firstPageLabel: string, options: PaginationOptions): HTMLElement { const container = document.createElement("div"); container.className = "pagination-first-page"; container.contentEditable = "false"; @@ -220,7 +247,7 @@ function createFirstPageWidget(options: PaginationOptions): HTMLElement { const headerArea = document.createElement("div"); headerArea.className = "pagination-header-area"; headerArea.style.height = `${options.marginTop}px`; - headerArea.innerHTML = renderHeader(1, options); + headerArea.innerHTML = renderHeader(1, firstPageLabel, options); overlay.appendChild(headerArea); container.appendChild(spacer); @@ -244,9 +271,23 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti container.className = "pagination-page-break"; container.contentEditable = "false"; + const contentHeight = options.pageHeight - options.marginTop - options.marginBottom; + const isEmpty = !!breakInfo.isEmpty; + + // Empty (orphan-locked) pages append `contentHeight` worth of blank + // content to the normal break chrome — the prev→empty transition is + // rendered here (footer of prev, gap, header of the empty page, then + // the empty content area). The empty→next transition is handled by + // the break that follows this one in the breaks array (a lock force- + // break, or a subsequent orphan synthetic, or the last-page widget). + // Splitting it this way keeps each page transition rendered exactly + // once and lets the synthetic absorb the previous page's freespace. + const emptyPageExtension = isEmpty ? contentHeight : 0; + // Spacer: pushes text in the document flow past the entire page boundary. // Includes freespace because the spacer is the only thing that moves text. - const spacerHeight = breakInfo.freespace + options.marginBottom + options.pageGap + options.marginTop; + const spacerHeight = + breakInfo.freespace + options.marginBottom + options.pageGap + options.marginTop + emptyPageExtension; const spacer = document.createElement("div"); spacer.className = "pagination-spacer"; spacer.style.height = `${spacerHeight}px`; @@ -270,11 +311,16 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti overlay.style.right = `calc(-1 * ${rightVar})`; } + // Labels for the surrounding pages. Defaults preserve legacy behavior + // (pagenum-1 / pagenum) when no labels were assigned (page locking off). + const prevLabel = breakInfo.prevLabel ?? String(breakInfo.pagenum - 1); + const thisLabel = breakInfo.label ?? String(breakInfo.pagenum); + // Footer area of the ending page (fixed size = marginBottom) const footerArea = document.createElement("div"); footerArea.className = "pagination-footer-area"; footerArea.style.height = `${options.marginBottom}px`; - footerArea.innerHTML = renderFooter(breakInfo.pagenum - 1, options); + footerArea.innerHTML = renderFooter(breakInfo.pagenum - 1, prevLabel, options); // Visual gap between pages (fixed size = pageGap) const divider = document.createElement("div"); @@ -286,12 +332,25 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti const headerArea = document.createElement("div"); headerArea.className = "pagination-header-area"; headerArea.style.height = `${options.marginTop}px`; - headerArea.innerHTML = renderHeader(breakInfo.pagenum, options); + headerArea.innerHTML = renderHeader(breakInfo.pagenum, thisLabel, options); overlay.appendChild(footerArea); overlay.appendChild(divider); overlay.appendChild(headerArea); + if (isEmpty) { + // Empty content area for the orphan-locked page. Renders a faint + // label centred in the page so the user can see which locked + // number is being preserved. The empty→next transition (footer of + // this empty page, gap, header of the next page) is rendered by + // the break that follows this synthetic in the breaks array. + const emptyArea = document.createElement("div"); + emptyArea.className = "pagination-empty-page"; + emptyArea.style.height = `${contentHeight}px`; + emptyArea.textContent = thisLabel; + overlay.appendChild(emptyArea); + } + // For dialogue/parenthetical splits: add (MORE) at the end of the current page // and CHARACTER (CONT'D) at the top of the next page. // Both are position:absolute inside the overlay so they don't affect flow layout. @@ -317,7 +376,12 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti return container; } -function createLastPageWidget(pagenum: number, freespace: number, options: PaginationOptions): HTMLElement { +function createLastPageWidget( + pagenum: number, + label: string, + freespace: number, + options: PaginationOptions, +): HTMLElement { const container = document.createElement("div"); container.className = "pagination-last-page"; container.contentEditable = "false"; @@ -335,7 +399,7 @@ function createLastPageWidget(pagenum: number, freespace: number, options: Pagin const footerArea = document.createElement("div"); footerArea.className = "pagination-footer-area"; footerArea.style.height = `${options.marginBottom}px`; - footerArea.innerHTML = renderFooter(pagenum, options); + footerArea.innerHTML = renderFooter(pagenum, label, options); overlay.appendChild(footerArea); container.appendChild(spacer); @@ -347,39 +411,49 @@ function buildDecorations( doc: Node, breaks: PageBreakInfo[], lastPageFreespace: number, + firstPageLabel: string, options: PaginationOptions, ): DecorationSet { const decorations: Decoration[] = []; // First page top margin / header decorations.push( - Decoration.widget(0, createFirstPageWidget(options), { + Decoration.widget(0, createFirstPageWidget(firstPageLabel, options), { side: -1, - key: "page-1-header", + key: `page-1-header-${firstPageLabel}`, }), ); // Page breaks // The key MUST include every value that affects the widget DOM (freespace, - // contdName, splitNodeType) — not just pagenum. ProseMirror's WidgetType.eq - // short-circuits on matching keys and reuses the old DOM element, so a key - // that omits e.g. freespace causes stale spacer heights after content edits. + // contdName, splitNodeType, label, isEmpty) — not just pagenum. ProseMirror's + // WidgetType.eq short-circuits on matching keys and reuses the old DOM element, + // so a key that omits e.g. freespace causes stale spacer heights after content edits. for (const b of breaks) { decorations.push( Decoration.widget(b.pos, createPageBreakWidget(b, options), { side: -1, - key: `pb-${b.pagenum}-${b.freespace}-${b.contdName}-${b.splitNodeType}`, + key: `pb-${b.pagenum}-${b.freespace}-${b.contdName}-${b.splitNodeType}-${b.label ?? ""}-${b.prevLabel ?? ""}-${b.isEmpty ? "E" : ""}`, }), ); } - // Last page bottom margin / footer + // Last page bottom margin / footer. + // Label of the last page = label of the most recent break (or firstPageLabel + // when no breaks exist). const lastPagenum = breaks.length > 0 ? breaks[breaks.length - 1].pagenum : 1; + const lastPageLabel = breaks.length > 0 + ? breaks[breaks.length - 1].label ?? String(lastPagenum) + : firstPageLabel; decorations.push( - Decoration.widget(doc.content.size, createLastPageWidget(lastPagenum, lastPageFreespace, options), { - side: 1, - key: `lp-${lastPagenum}-${lastPageFreespace}`, - }), + Decoration.widget( + doc.content.size, + createLastPageWidget(lastPagenum, lastPageLabel, lastPageFreespace, options), + { + side: 1, + key: `lp-${lastPagenum}-${lastPageLabel}-${lastPageFreespace}`, + }, + ), ); return DecorationSet.create(doc, decorations); @@ -554,6 +628,34 @@ interface PaginationState { decset: DecorationSet; breaks: PageBreakInfo[]; lastPageFreespace: number; + firstPageLabel: string; +} + +/** + * Compute display labels for every page using the same token math that + * powers scene locking. Page 1 is anchored to the sentinel PAGE_ONE_KEY; + * later pages are anchored to the data-id of the top-level node that + * begins them. Returns one label per page (length = breaks.length + 1). + * + * Synthetic empty-page breaks consume one "logical page" each — their + * anchorId comes from the page-lock map, and the page that physically + * follows the empty slot gets its own label slot in the result. + */ +function computePageLabels( + breaks: PageBreakInfo[], + pageLocks: PersistentPageMap, + skippedLetters: readonly string[], +): string[] { + const anchors: string[] = [PAGE_ONE_KEY]; + for (const b of breaks) { + // Empty pages anchor to the orphan lock's anchorId. Real pages anchor + // to the data-id of the top-level node where the page starts. If + // anchorId is somehow missing, fall back to a unique synthetic key + // so the label-computer still produces a usable result. + anchors.push(b.anchorId ?? `__break_${b.pos}_${b.pagenum}__`); + } + const labels = computeSceneLabels(anchors, pageLocks, "suffix", skippedLetters); + return labels.map((l) => l.label); } const createPaginationPlugin = (extension: { options: PaginationOptions; editor: Editor }) => @@ -564,6 +666,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: decset: DecorationSet.empty, breaks: [], lastPageFreespace: 0, + firstPageLabel: "1", }), apply(tr, value: PaginationState, oldState, newState): PaginationState { const options = extension.options as PaginationOptions; @@ -631,6 +734,21 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: const serializer = DOMSerializer.fromSchema(newState.schema); + // --- Page-lock setup --- + // Hot-path discipline: when locking is off (the common case), + // pageLocks/lockedAnchorIds stay null and the per-node check + // short-circuits on the first `&&` — zero allocations, zero + // map lookups. The set is rebuilt once per pass when locking + // is active; lock counts are typically tens, never thousands. + const pageLocking = options.getPageLocking?.() ?? false; + const pageLocks: PersistentPageMap | null = pageLocking + ? options.getPageLocks?.() ?? null + : null; + const lockedAnchorIds: Set | null = pageLocks + ? new Set(Object.keys(pageLocks).filter((k) => k !== PAGE_ONE_KEY)) + : null; + const skippedLetters = options.getSkippedLetters?.() ?? []; + const contentHeight = options.pageHeight - options.marginTop - options.marginBottom; const breaks: PageBreakInfo[] = []; let pagePos = 0; @@ -673,6 +791,8 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: lastCharName = node.textContent.trim(); } + const dataId: string | undefined = node.attrs["data-id"]; + // --- Force page break for "start new page" elements --- // If this node type is configured to start a new page and we're // not already at the top of a page, insert a break before it. @@ -684,6 +804,24 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: freespace: Math.max(0, freespace), contdName: "", splitNodeType: null, + anchorId: dataId, + }); + pagePos = 0; + lastNodes = new CircularBuffer(3); + } + + // --- Force page break for locked page anchors --- + // O(1) Set.has when locking is on; the leading `lockedAnchorIds &&` + // short-circuits to false when locking is disabled — hot-path safe. + if (lockedAnchorIds && dataId && pagePos > 0 && lockedAnchorIds.has(dataId)) { + const freespace = contentHeight - pagePos; + breaks.push({ + pos, + pagenum: ++pagenum, + freespace: Math.max(0, freespace), + contdName: "", + splitNodeType: null, + anchorId: dataId, }); pagePos = 0; lastNodes = new CircularBuffer(3); @@ -693,7 +831,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: pagePos += height; // We keep the last 3 nodes for orphan resolution on page break - lastNodes.push({ pos, type: nodeType, height, positionTop: pagePos - height }); + lastNodes.push({ pos, type: nodeType, height, positionTop: pagePos - height, dataId }); // Page break needed — record it and reset page position if (pagePos > contentHeight) { @@ -721,11 +859,13 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: contdName: logic.showMoreContd ? lastCharName : "", // splitNodeType drives the overlay padding-escape in createPageBreakWidget. splitNodeType: nodeType, + // Anchor for page locking: the node being split owns both halves. + anchorId: dataId, }); // The bottom half of the split node is the first item on the new page. pagePos = split.bottomHeight; lastNodes = new CircularBuffer(3); - lastNodes.push({ pos, type: nodeType, height: split.bottomHeight, positionTop: 0 }); + lastNodes.push({ pos, type: nodeType, height: split.bottomHeight, positionTop: 0, dataId }); continue; // split handled — skip orphan resolution for this node } } @@ -741,6 +881,16 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: for (let back = 1; back <= 2; back++) { const prev = lastNodes.at(back); // at(1) = last fitted, at(2) = one before if (!prev) break; + // A locked anchor owns its page and must never be displaced by + // walkback — otherwise the next overflow would yank it onto an + // A page and the locked frame would lose its head. + if ( + lockedAnchorIds && + prev.dataId && + lockedAnchorIds.has(prev.dataId) + ) { + break; + } if (BREAK_LOGIC[prev.type]?.keepWithNext) { breakPos = prev.pos; carryHeight += prev.height; @@ -766,12 +916,18 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: (firstMovingType === ScreenplayElement.Dialogue || firstMovingType === ScreenplayElement.Parenthetical); + // Anchor = data-id of the first node that moved to the new page. + // When backCount==0 the current node is the one moving (no walkback); + // otherwise the carried-back node from the buffer owns the anchor. + const anchorDataId = backCount === 0 ? dataId : firstMovingNode?.dataId; + const breakInfo: PageBreakInfo = { pos: breakPos, pagenum: pagenum + 1, freespace: Math.max(0, freespace), contdName: isDialogueSplit ? lastCharName : "", splitNodeType: null, + anchorId: anchorDataId, }; breaks.push(breakInfo); pagenum++; @@ -812,26 +968,218 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: } // Compute remaining space on the last page so the last-page widget - // can pad it to full page height. - const lastPageFreespace = Math.max(0, contentHeight - pagePos); + // can pad it to full page height. Mutable because orphan handling + // may consume it: when an orphan synthetic empty page lands at + // doc end, it absorbs this freespace so the last real page stays + // at its full height and the empty page renders after it. + let lastPageFreespace = Math.max(0, contentHeight - pagePos); + + // --- Orphan page handling --- + // A locked page whose anchor data-id is no longer present in the doc + // becomes an "orphan" — we insert a synthetic empty-page break so the + // page still appears in the layout (preserving its locked number). + // Orphans are placed at the doc position of the next surviving lock + // (or doc end) and consume a full content-height of vertical space. + if (pageLocks) { + const seenAnchors = new Set(); + for (const b of breaks) { + if (b.anchorId) seenAnchors.add(b.anchorId); + } + + // Tokens for ordered comparison. Provisional pages (no token in + // the lock map) aren't relevant here — only locked entries can be + // orphans. We need the orphan list in TOKEN order so insertions + // happen at the right spots. + type OrphanEntry = { anchorId: string; token: SceneToken }; + const orphans: OrphanEntry[] = []; + for (const [anchorId, page] of Object.entries(pageLocks)) { + if (anchorId === PAGE_ONE_KEY) continue; + if (!page?.token) continue; + if (seenAnchors.has(anchorId)) continue; + orphans.push({ anchorId, token: page.token }); + } + + if (orphans.length > 0) { + // Build an ordered list of live-locked anchors keyed by token, + // so we can find the "next live lock after orphan X" quickly. + type LiveLock = { anchorId: string; token: SceneToken; pos: number }; + const liveLocks: LiveLock[] = []; + for (const b of breaks) { + if (!b.anchorId) continue; + const lock = pageLocks[b.anchorId]; + if (lock?.token) liveLocks.push({ anchorId: b.anchorId, token: lock.token, pos: b.pos }); + } + liveLocks.sort((a, b) => compareTokens(a.token, b.token)); + orphans.sort((a, b) => compareTokens(a.token, b.token)); + + const docSize = newState.doc.content.size; + + for (const orphan of orphans) { + // Token-gap segment this orphan belongs in: bounded by the + // greatest live lock with a smaller token (prev) and the + // smallest live lock with a larger token (next). Positions + // of those bounding locks define the doc-position window + // where this orphan can be slotted in. + let prevLive: LiveLock | null = null; + let nextLive: LiveLock | null = null; + for (const l of liveLocks) { + if (compareTokens(l.token, orphan.token) < 0) { + if (!prevLive || compareTokens(prevLive.token, l.token) < 0) { + prevLive = l; + } + } else if (compareTokens(l.token, orphan.token) > 0) { + if (!nextLive || compareTokens(l.token, nextLive.token) < 0) { + nextLive = l; + } + } + } + const segmentStart = prevLive?.pos ?? 0; + const segmentEnd = nextLive?.pos ?? docSize; + + // First try to consume an existing provisional break inside + // this segment. That break is the natural overflow from the + // previous page — by re-anchoring it to the orphan, we make + // the overflow content flow INTO the empty deleted-page slot + // (Final Draft-style) instead of producing a phantom A page + // alongside a separately-rendered empty page. + let consumed = false; + for (let j = 0; j < breaks.length; j++) { + const b = breaks[j]; + if (b.pos < segmentStart) continue; + if (b.pos >= segmentEnd) break; + const bLock = b.anchorId ? pageLocks[b.anchorId] : undefined; + if (bLock?.token) continue; // already a locked break — skip + // Provisional in the orphan's segment: reassign anchorId + // so the label flips from "NA" to the orphan's frozen label. + b.anchorId = orphan.anchorId; + liveLocks.push({ + anchorId: orphan.anchorId, + token: orphan.token, + pos: b.pos, + }); + liveLocks.sort((a, b) => compareTokens(a.token, b.token)); + consumed = true; + break; + } + + if (consumed) continue; + + // No provisional to absorb the orphan — fall back to a + // synthetic empty-page break at the segment's end position. + // + // Insert index walks the breaks list. We want the synthetic + // to land at segmentEnd, AFTER any break at the same pos + // whose token is smaller (so multiple orphans at one + // segmentEnd line up in token order: orphan-2, orphan-3, + // then the live lock that bounds the segment). + let insertIdx = breaks.length; + for (let j = 0; j < breaks.length; j++) { + const b = breaks[j]; + if (b.pos > segmentEnd) { + insertIdx = j; + break; + } + if (b.pos === segmentEnd) { + const bLock = b.anchorId ? pageLocks[b.anchorId] : undefined; + if ( + bLock?.token && + compareTokens(orphan.token, bLock.token) < 0 + ) { + insertIdx = j; + break; + } + } + } + + // Freespace transfer: the synthetic empty page's widget + // renders the prev→empty transition (footer of previous + // page + chrome + empty content area). For the previous + // page to keep its full height, the synthetic must absorb + // its bottom freespace. That freespace currently lives on + // the break that the synthetic is being inserted BEFORE + // (either a lock force-break at the same pos, or the last- + // page widget at doc end). Transfer it, then zero out the + // donor — its "previous page" is now the empty synthetic, + // which already gets a full `contentHeight` slot, so no + // additional freespace is needed there. + let syntheticFreespace = 0; + if ( + insertIdx < breaks.length && + breaks[insertIdx].pos === segmentEnd + ) { + syntheticFreespace = breaks[insertIdx].freespace; + breaks[insertIdx].freespace = 0; + } else if (insertIdx === breaks.length) { + // Doc end — the synthetic is the new "last empty page", + // and the existing last-page widget would have padded + // out the freespace below the previous real page. + // Transfer that to the synthetic. + syntheticFreespace = lastPageFreespace; + lastPageFreespace = 0; + } + + const synthetic: PageBreakInfo = { + pos: segmentEnd, + pagenum: 0, // re-numbered below + freespace: syntheticFreespace, + contdName: "", + splitNodeType: null, + anchorId: orphan.anchorId, + isEmpty: true, + }; + breaks.splice(insertIdx, 0, synthetic); + liveLocks.push({ + anchorId: orphan.anchorId, + token: orphan.token, + pos: segmentEnd, + }); + liveLocks.sort((a, b) => compareTokens(a.token, b.token)); + } + + // Renumber pagenums after insertions (synthetic breaks have pagenum: 0). + for (let i = 0; i < breaks.length; i++) { + breaks[i].pagenum = i + 2; // page 1 has no break; first break starts page 2. + } + } + } + + // --- Label assignment --- + // Run computeSceneLabels over [page1Anchor, ...breakAnchors] so locked + // pages keep their frozen labels, provisional inserts get suffix labels + // (e.g. "4A"), and pages past the last lock continue the integer sequence. + let firstPageLabel = "1"; + if (pageLocks) { + const labels = computePageLabels(breaks, pageLocks, skippedLetters); + firstPageLabel = labels[0]; + for (let i = 0; i < breaks.length; i++) { + const label = labels[i + 1]; + const prevLabel = labels[i]; + breaks[i].label = label; + breaks[i].prevLabel = prevLabel; + } + } // Check if breaks actually changed compared to mapped old breaks. const breaksChanged = fullRemeasure || lastPageFreespace !== value.lastPageFreespace || + firstPageLabel !== value.firstPageLabel || breaks.length !== mappedOldBreaks.length || breaks.some( (b, i) => b.pos !== mappedOldBreaks[i].pos || b.freespace !== mappedOldBreaks[i].freespace || - b.contdName !== mappedOldBreaks[i].contdName, + b.contdName !== mappedOldBreaks[i].contdName || + b.label !== mappedOldBreaks[i].label || + b.prevLabel !== mappedOldBreaks[i].prevLabel || + !!b.isEmpty !== !!mappedOldBreaks[i].isEmpty, ); const decset = breaksChanged - ? buildDecorations(newState.doc, breaks, lastPageFreespace, options) + ? buildDecorations(newState.doc, breaks, lastPageFreespace, firstPageLabel, options) : value.decset.map(tr.mapping, tr.doc); - return { decset, breaks, lastPageFreespace }; + return { decset, breaks, lastPageFreespace, firstPageLabel }; }, }, appendTransaction() { @@ -927,6 +1275,20 @@ export const ScriptioPagination = Extension.create({ .pagination-footer-right { text-align: right; } + + .pagination-empty-page { + display: flex; + align-items: center; + justify-content: center; + background: var(--editor-script-bg); + color: var(--secondary-text); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.08em; + opacity: 0.35; + text-transform: uppercase; + box-sizing: border-box; + } `; document.head.appendChild(style); } @@ -1062,3 +1424,52 @@ export function getPageForPos(editor: Editor, pos: number): number { } return page; } + +/** + * Returns the display label (e.g. "4", "4A") for the page containing + * the given document position. Falls back to the integer pagenum when + * page locking isn't active. + */ +export function getPageLabelForPos(editor: Editor, pos: number): string { + const state = paginationKey.getState(editor.state) as PaginationState | undefined; + if (!state) return "1"; + if (state.breaks.length === 0) return state.firstPageLabel; + let label = state.firstPageLabel; + for (const b of state.breaks) { + if (b.pos > pos) break; + label = b.label ?? String(b.pagenum); + } + return label; +} + +/** + * Returns the ordered list of page anchors for the current document + * (page 1 sentinel first, then the data-id of each subsequent page's + * first top-level node). Used by the ProductionPanel to snapshot the + * current layout when locking pages and to compute provisional labels. + * + * Synthetic empty-page breaks contribute their orphan anchor id, so the + * sequence stays aligned with what the user sees in the editor. + */ +export function getPageAnchors(editor: Editor): string[] { + const state = paginationKey.getState(editor.state) as PaginationState | undefined; + if (!state) return [PAGE_ONE_KEY]; + const out: string[] = [PAGE_ONE_KEY]; + for (const b of state.breaks) { + if (b.anchorId) out.push(b.anchorId); + } + return out; +} + +/** + * Force a pagination recompute. Call when the page-lock map or the + * page-locking toggle changes — layout may shift even though the + * document content did not. + */ +export function refreshPageLocking(editor: Editor | null): void { + if (!editor || !editor.view) return; + const tr = editor.state.tr; + tr.setMeta("forcePaginationUpdate", true); + tr.setMeta("addToHistory", false); + editor.view.dispatch(tr); +} diff --git a/src/lib/screenplay/page-locking.ts b/src/lib/screenplay/page-locking.ts new file mode 100644 index 00000000..5729c932 --- /dev/null +++ b/src/lib/screenplay/page-locking.ts @@ -0,0 +1,28 @@ +/** + * Page-locking primitives. + * + * Page locks are anchored to the top-level node that begins each locked page + * (every screenplay node already carries a stable `data-id`). The first page + * has no anchor node — it is keyed by the sentinel `PAGE_ONE_KEY` so the lock + * map can always describe page 1 explicitly. + * + * Numbering uses the same `SceneToken` machinery as scene locking. We pass + * the ordered list of page anchors to `computeSceneLabels`; locked pages get + * their frozen token, intermediate provisional pages get suffix labels + * ("4A", "4B"), and pages appended after the last lock get the next integer. + * + * Token math, label compilation, and order comparison all live in + * `scene-locking.ts` and are re-used here unchanged. + */ + +import type { SceneToken } from "./scene-locking"; + +/** Sentinel key used for the first page (which has no anchor node). */ +export const PAGE_ONE_KEY = "__page1__"; + +export type PersistentPage = { + /** Frozen structural position under production page-lock. */ + token?: SceneToken; +}; + +export type PersistentPageMap = { [anchorId: string]: PersistentPage }; diff --git a/src/lib/screenplay/popup.ts b/src/lib/screenplay/popup.ts index e9b54903..0cb26320 100644 --- a/src/lib/screenplay/popup.ts +++ b/src/lib/screenplay/popup.ts @@ -25,6 +25,10 @@ export type PopupUnlockScenesData = { confirmUnlock: () => void; }; +export type PopupUnlockPagesData = { + confirmUnlock: () => void; +}; + // ------------------------------ // // GENERIC POPUP // // ------------------------------ // @@ -33,7 +37,8 @@ export type PopupUnionData = | PopupCharacterData | PopupSceneData | PopupUploadToCloudData - | PopupUnlockScenesData; + | PopupUnlockScenesData + | PopupUnlockPagesData; export enum PopupType { NewCharacter, @@ -42,6 +47,7 @@ export enum PopupType { EditScene, UploadToCloud, UnlockScenes, + UnlockPages, } export type PopupData = { @@ -98,3 +104,10 @@ export const unlockScenesPopup = (confirmUnlock: () => void, userCtx: UserContex data: { confirmUnlock }, }); }; + +export const unlockPagesPopup = (confirmUnlock: () => void, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UnlockPages, + data: { confirmUnlock }, + }); +}; diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 4d6c8b17..2f8be8f6 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -13,6 +13,7 @@ import { DEFAULT_KEYBINDS, executeKeybindAction, KeybindId } from "./keybinds"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ProjectRole } from "../../generated/client/browser"; import { isTauri } from "@tauri-apps/api/core"; +import { useTranslations } from "next-intl"; interface Position { x: number; @@ -502,6 +503,34 @@ const useDesktopBridgeAuth = () => { return { completeBridgeAuth }; }; +const useFormatTimestamp = () => { + const t = useTranslations("dates"); + return useCallback( + (ts: number | string | Date): string => { + const date = new Date(ts); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return t("justNow"); + if (diffMins < 60) return t("minutesAgo", { mins: diffMins }); + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return t("hoursAgo", { hours: diffHours }); + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return t("daysAgo", { days: diffDays }); + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + }, + [t], + ); +}; + export { useDraggable, useUser, @@ -518,4 +547,5 @@ export { useCachedProjectInfo, useProjectIdFromUrl, useDesktopBridgeAuth, + useFormatTimestamp, }; From 000808973b1574a147fcaa7b16c1ce169913159c Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Tue, 26 May 2026 01:36:59 +0200 Subject: [PATCH 56/76] fixed page locking, fixed page numbering in pdf export --- src/lib/adapters/pdf/pdf-adapter.ts | 32 +++++++- src/lib/adapters/pdf/pdf.worker.ts | 23 +++++- .../extensions/pagination-extension.ts | 82 ++++++++++++++++++- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 245e84e5..b3fb8875 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -153,7 +153,12 @@ export class PDFAdapter extends ProjectAdapter { // ── Direct-child pagination widget → explicit page break ── if (el.classList.contains("pagination-page-break")) { - allLines.push({ runs: [], y: 0, type: "__page_break__" }); + allLines.push({ + runs: [], + y: 0, + type: "__page_break__", + pageLabel: this.extractPageLabel(el), + }); continue; } @@ -224,7 +229,12 @@ export class PDFAdapter extends ProjectAdapter { } // Emit page break sentinel - allLines.push({ runs: [], y: 0, type: "__page_break__" }); + allLines.push({ + runs: [], + y: 0, + type: "__page_break__", + pageLabel: this.extractPageLabel(splitWidget), + }); // Collect lines AFTER the split widget const afterLines = this.collectParagraphLines(el, nodeType, splitWidget, "after"); @@ -574,6 +584,24 @@ export class PDFAdapter extends ProjectAdapter { } } + /** + * Read the user-visible page label out of a `.pagination-page-break` + * widget. The widget renders its destination page's header inside + * `.pagination-header-area > .pagination-header-right` (the configured + * headerRight template, with `{page}` already substituted) — so the + * textContent IS the final label string the user sees. Under page + * locking this string is the frozen "4A." form; otherwise it's the + * default sequential "4.". Returns undefined when no header is + * present so the worker falls back to its integer pageNumber. + */ + private extractPageLabel(widget: HTMLElement): string | undefined { + const right = widget.querySelector( + ".pagination-header-area .pagination-header-right", + ) as HTMLElement | null; + if (!right) return undefined; + return right.textContent?.trim() ?? undefined; + } + // ── VisualLine[] → PDF ─────────────────────────────────────────────────── /** diff --git a/src/lib/adapters/pdf/pdf.worker.ts b/src/lib/adapters/pdf/pdf.worker.ts index 4314db7a..5bd6f7d8 100644 --- a/src/lib/adapters/pdf/pdf.worker.ts +++ b/src/lib/adapters/pdf/pdf.worker.ts @@ -19,6 +19,12 @@ export interface VisualLine { runs: TextRun[]; y: number; // browser Y position in pixels (for line-spacing within a page) type?: string; // e.g. "dialogue", "character", "scene", "__page_break__" + /** Header text for the page that begins AFTER this sentinel. + * Only set on `__page_break__` lines. Carries the user-visible page + * label ("4.", "4A.", a custom-templated string) read straight from + * the pagination widget's DOM, so page-lock labels propagate to PDF + * exports unchanged. */ + pageLabel?: string; } /** Font file descriptor for registration in jsPDF. */ @@ -284,7 +290,7 @@ async function renderLines( // Page number header on pages 2+ if (showPageNumbers) { - drawPageHeader(doc, currentPage, pageSize); + drawPageHeader(doc, currentPage, pageSize, line.pageLabel); } // Draw Character Name (CONT'D) at the top of the new page @@ -415,12 +421,23 @@ async function drawMultiFontText( } } -function drawPageHeader(doc: jsPDF, pageNumber: number, pageSize: { width: number; height: number }): void { +function drawPageHeader( + doc: jsPDF, + pageNumber: number, + pageSize: { width: number; height: number }, + label?: string, +): void { if (pageNumber <= 1) return; + // Prefer the label captured from the pagination widget — under page + // locking this carries the frozen "4A." style label, otherwise it's the + // sequential "4." rendered by the default headerRight template. An empty + // string means the editor's custom header is intentionally blank (e.g. + // page 1's customHeader override) — honour that and skip drawing. + const text = label ?? `${pageNumber}.`; + if (!text) return; doc.setFont("CourierPrime", "normal"); doc.setFontSize(FONT_SIZE); doc.setTextColor(0, 0, 0); - const text = `${pageNumber}.`; doc.text(text, pageSize.width - PAGE_RIGHT, HEADER_Y, { align: "right", baseline: "top", diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 52c1b1df..108f0166 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -3,8 +3,9 @@ import { CircularBuffer } from "@src/lib/utils/circular-buffer"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { Editor, Extension } from "@tiptap/core"; import { Node } from "@tiptap/pm/model"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { ySyncPluginKey } from "@tiptap/y-tiptap"; import { compareTokens, computeSceneLabels, SceneToken } from "@src/lib/screenplay/scene-locking"; import { PAGE_ONE_KEY, PersistentPageMap } from "@src/lib/screenplay/page-locking"; @@ -1185,6 +1186,46 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: appendTransaction() { return null; }, + filterTransaction(tr) { + // Page-lock guard: prevent content from spilling upward out of a + // locked page. The signature of that spill — Backspace at the + // start of a locked anchor, Delete at the end of the node before + // it, or a selection delete that swallows the anchor — is the + // locked anchor's data-id disappearing from the top-level node + // list. If that would happen, reject the transaction so the + // cursor stays put and no content moves across the lock. + if (!tr.docChanged) return true; + + // Allow yjs sync (remote updates and yjs-based undo/redo) through + // unconditionally — cross-peer consistency must not be blocked by + // local lock enforcement, and the lock map itself lives in the + // Yjs doc so peers agree on which pages are locked. + if (tr.getMeta(ySyncPluginKey)) return true; + + const opts = extension.options as PaginationOptions; + if (!opts.getPageLocking?.()) return true; + + const pageLocks = opts.getPageLocks?.(); + if (!pageLocks) return true; + + // PAGE_ONE_KEY has no node to defend — page 1 can't lose its + // lock through doc edits. + const lockedAnchors: string[] = []; + for (const key of Object.keys(pageLocks)) { + if (key !== PAGE_ONE_KEY) lockedAnchors.push(key); + } + if (lockedAnchors.length === 0) return true; + + const present = new Set(); + tr.doc.forEach((node) => { + const dataId = node.attrs?.["data-id"]; + if (typeof dataId === "string") present.add(dataId); + }); + for (const anchor of lockedAnchors) { + if (!present.has(anchor)) return false; + } + return true; + }, props: { decorations(state) { return (paginationKey.getState(state) as PaginationState)?.decset ?? DecorationSet.empty; @@ -1316,6 +1357,45 @@ export const ScriptioPagination = Extension.create({ return [createPaginationPlugin(this)]; }, + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + // joinBackward has a variant — joinMaybeClear — that deletes + // the PREVIOUS block instead of the current one. It fires when + // both blocks are empty. If that previous block is a locked + // page anchor, the plugin's filterTransaction rejects the + // resulting transaction (the anchor's data-id would go + // missing), and the user sees the cursor stuck on the second + // empty line. Patch the case by deleting the current empty + // block ourselves and parking the cursor inside the preserved + // anchor — the natural "step up one line" behavior. + const { state, view } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + if ($from.parent.textContent.length !== 0) return false; + + const opts = this.options as PaginationOptions; + if (!opts.getPageLocking?.()) return false; + const pageLocks = opts.getPageLocks?.(); + if (!pageLocks) return false; + + const curStart = $from.before(); + if (curStart === 0) return false; + + const prev = state.doc.resolve(curStart).nodeBefore; + if (!prev || prev.textContent.length !== 0) return false; + + const prevDataId = prev.attrs?.["data-id"]; + if (typeof prevDataId !== "string" || !pageLocks[prevDataId]) return false; + + const tr = state.tr.delete(curStart, $from.after()); + tr.setSelection(TextSelection.create(tr.doc, curStart - 1)); + view.dispatch(tr); + return true; + }, + }; + }, + addCommands() { return { updatePageSize: From 4e716f40b43dfa086b24c6ee6cd5c3656454b430 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Thu, 28 May 2026 01:07:13 +0200 Subject: [PATCH 57/76] fixes to page locking, fixing to last page in pagination, fix to redirect --- README.md | 8 +- components/navbar/ProductionPanel.tsx | 26 +- src/lib/editor/use-document-editor.ts | 9 +- src/lib/project/project-repository.ts | 2 +- .../extensions/pagination-extension.ts | 249 ++++++++++++++++-- src/lib/screenplay/page-locking.ts | 6 + src/lib/utils/redirects.ts | 6 +- styles/scriptio.css | 9 + 8 files changed, 273 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 0eaac3cd..9f139bfa 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,18 @@ - **Cloud Synchronization** - **Real-time Collaboration** - **Cross-platform** (Windows, MacOS, browser) -- **Industry-standard Formats** (PDF, Fountain, Final Draft) +- **Industry Formatting** (layout, page breaks, dual dialogue) +- **Compatibility Formats** (PDF, Fountain, Final Draft) +- **Production Ready** (revisions, scene & page locking) - **Scene Management** (easy navigation, reordering) - **Character Management** (color highlighting, synopsis) - **Beat Board** (story cards, outlining) -- **Smart formatting** (context aware auto-completion) +- **Smart Editor** (context aware auto-completion, spellchecker) - **Customization** (themes & custom keybinds) - **Statistics** (distribution, frequency) - **Search & Replace** (advanced filtering) - **Script Comments** (inline annotations) -- **Focus mode** (distraction-free writing) +- **Focus Mode** (distraction-free writing) # Core Values diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index dee0c271..afc9c664 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -9,7 +9,7 @@ import { UserContext } from "@src/context/UserContext"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; import { computeSceneItems } from "@src/lib/screenplay/scenes"; import { unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; -import { getPageAnchors } from "@src/lib/screenplay/extensions/pagination-extension"; +import { getPageAnchors, getPageAnchorInfo } from "@src/lib/screenplay/extensions/pagination-extension"; import Switch from "@components/utils/Switch"; import styles from "./ProductionPanel.module.css"; @@ -192,7 +192,8 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { if (next) { if (!editor) return; repository.transact(() => { - const anchors = getPageAnchors(editor); + const anchorInfos = getPageAnchorInfo(editor); + const anchors = anchorInfos.map((a) => a.anchorId); const persistentSnapshot = repository.pages; // Idempotent: any anchor that already has a token keeps it. // Only provisional anchors (no token yet) get a freshly-computed @@ -204,9 +205,16 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { "suffix", skippedSceneLetters, ); - computed.forEach((label) => { + computed.forEach((label, idx) => { if (label.status === "provisional") { - repository.upsertPage(label.uuid, { token: label.token }); + // splitOffset is captured alongside the token so the + // pagination plugin can reproduce mid-node splits + // (straddling dialogues) on recompute instead of + // force-pushing the whole anchor node forward. + repository.upsertPage(label.uuid, { + token: label.token, + splitOffset: anchorInfos[idx]?.splitOffset, + }); } }); repository.setPageLocking(true); @@ -219,7 +227,8 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const handlePageRelock = () => { if (!repository || isReadOnly || !editor) return; repository.transact(() => { - const anchors = getPageAnchors(editor); + const anchorInfos = getPageAnchorInfo(editor); + const anchors = anchorInfos.map((a) => a.anchorId); const persistentSnapshot = repository.pages; const currentLabels = computeSceneLabels( anchors, @@ -227,9 +236,12 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { "suffix", skippedSceneLetters, ); - currentLabels.forEach((label) => { + currentLabels.forEach((label, idx) => { if (label.status === "provisional") { - repository.upsertPage(label.uuid, { token: label.token }); + repository.upsertPage(label.uuid, { + token: label.token, + splitOffset: anchorInfos[idx]?.splitOffset, + }); } }); }); diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index f8ce413b..c1eb331f 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -371,6 +371,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum getPageLocking: () => !!ext.pageLocking, getPageLocks: () => ext.persistentPages ?? {}, getSkippedLetters: () => ext.skippedSceneLetters ?? [], + getScenes: () => ext.repository?.scenes ?? {}, } : { pageGap: 20, @@ -615,11 +616,17 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum // Pagination only reads these via getter closures on its options, so // we must explicitly kick it to re-run; otherwise stale labels render // until the user types. + // + // persistentScenes is included so toggling a scene's `omitted` flag + // re-runs pagination immediately: omitted body paragraphs collapse to + // zero height in the layout (mirroring the visual display:none from + // scene-locking-extension), so the page they sit on must shrink/grow + // without waiting for the next keystroke. useEffect(() => { if (editor && config.features.paginationMode === "screenplay") { refreshPageLocking(editor); } - }, [editor, pageLocking, persistentPages, skippedSceneLetters, config.features.paginationMode]); + }, [editor, pageLocking, persistentPages, persistentScenes, skippedSceneLetters, config.features.paginationMode]); // Refresh search highlights useEffect(() => { diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 51f79fe9..f8b37bc7 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -456,7 +456,7 @@ export class ProjectRepository { const existing = (map.get(anchorId) as PersistentPage | undefined) ?? {}; const merged: PersistentPage = { ...existing }; - const FIELDS = ["token"] as const; + const FIELDS = ["token", "splitOffset"] as const; for (const key of FIELDS) { if (key in data) { (merged as Record)[key] = data[key]; diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 108f0166..7df545d9 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -9,6 +9,7 @@ import { ySyncPluginKey } from "@tiptap/y-tiptap"; import { compareTokens, computeSceneLabels, SceneToken } from "@src/lib/screenplay/scene-locking"; import { PAGE_ONE_KEY, PersistentPageMap } from "@src/lib/screenplay/page-locking"; +import type { PersistentSceneMap } from "@src/lib/screenplay/scenes"; // --------------------------------------------------------------------------- // Constants @@ -123,6 +124,11 @@ export interface PaginationOptions { getPageLocks?: () => PersistentPageMap; /** Letters skipped from generated labels (shared with scene locking). */ getSkippedLetters?: () => readonly string[]; + /** Persistent scene map. Used to detect omitted scenes so their hidden + * body paragraphs contribute zero height to the page layout — without + * this, omission would visually collapse the body but pagination would + * still allocate full height, leaving the page short on real content. */ + getScenes?: () => PersistentSceneMap; } export interface PageBreakInfo { @@ -144,6 +150,11 @@ export interface PageBreakInfo { /** Display label for the page ending before this break — used by the footer of * the previous page. Undefined for the first break (footer uses page-1 label). */ prevLabel?: string; + /** Character offset within the anchor node where the break occurs. + * Set for sentence-split breaks (mid-node) — both the original split and + * the locked re-application of it — and read by the production panel when + * freezing page locks so the split can be reproduced on later recomputes. */ + splitOffset?: number; } declare module "@tiptap/core" { @@ -551,6 +562,8 @@ const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement interface SplitResult { /** Absolute document position of the split point (inside the straddling node's text). */ pos: number; + /** Character offset within the node's text where the split occurs (= pos - nodeDocPos - 1). */ + offset: number; /** Rendered height of the portion staying on the current page. */ topHeight: number; /** Rendered height of the portion moving to the next page. */ @@ -611,7 +624,12 @@ function trySplitNode( // The split position in document space: // nodeDocPos + 1 skips the node's opening token; topText.length then walks // through the text characters (marks are zero-width in ProseMirror's position space). - return { pos: nodeDocPos + 1 + topText.length, topHeight, bottomHeight }; + return { + pos: nodeDocPos + 1 + topText.length, + offset: topText.length, + topHeight, + bottomHeight, + }; } } @@ -748,7 +766,16 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: const lockedAnchorIds: Set | null = pageLocks ? new Set(Object.keys(pageLocks).filter((k) => k !== PAGE_ONE_KEY)) : null; + // Tracks locked anchors already consumed by a break in this pass. + // ProseMirror's split (Enter at position 0 of a node) duplicates + // node attrs across both halves — including data-id — so until + // node-id-dedup-extension runs in appendTransaction we transiently + // see the same locked anchor twice. Without this set, both halves + // would each force a page break, briefly rendering a phantom page + // with the same locked label until the dedup transaction fires. + const consumedAnchors = new Set(); const skippedLetters = options.getSkippedLetters?.() ?? []; + const scenesMap = options.getScenes?.() ?? null; const contentHeight = options.pageHeight - options.marginTop - options.marginBottom; const breaks: PageBreakInfo[] = []; @@ -761,6 +788,24 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // with "CHARACTER (CONT'D)" on the next page. let lastCharName = ""; + // Tracks whether the currently-open scene is OMITTED. Body + // paragraphs under an omitted scene are hidden via decorations + // (display:none) but still present in the document — pagination + // must mirror the visual collapse by treating them as zero- + // height, otherwise the page they sit on appears short while + // the layout still allocates their original height. + let currentSceneOmitted = false; + + // Set when the short-circuit exits the per-node loop early. + // pagePos at that point reflects only the carry node(s) sitting + // on the new page right after the matched break — not the real + // last page — so the post-loop freespace computation must NOT + // derive from pagePos. The previous pass's lastPageFreespace is + // still authoritative because the short-circuit condition (matching + // pos / freespace / contdName past maxChangedPos) guarantees every + // subsequent page is byte-identical to the previous layout. + let shortCircuited = false; + let lastNodes: CircularBuffer = new CircularBuffer(3); for (let i = 0; i < childCount; i++) { const node = newState.doc.child(i); @@ -794,6 +839,15 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: const dataId: string | undefined = node.attrs["data-id"]; + // Update the omitted-scene flag at each Scene boundary. Body + // paragraphs that follow stay flagged until the next Scene + // resets it. Scene headings themselves always render (with + // the OMITTED overlay sitting on top) so their height is + // kept regardless. + if (nodeType === ScreenplayElement.Scene) { + currentSceneOmitted = !!(scenesMap && dataId && scenesMap[dataId]?.omitted); + } + // --- Force page break for "start new page" elements --- // If this node type is configured to start a new page and we're // not already at the top of a page, insert a break before it. @@ -814,25 +868,109 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // --- Force page break for locked page anchors --- // O(1) Set.has when locking is on; the leading `lockedAnchorIds &&` // short-circuits to false when locking is disabled — hot-path safe. - if (lockedAnchorIds && dataId && pagePos > 0 && lockedAnchorIds.has(dataId)) { - const freespace = contentHeight - pagePos; - breaks.push({ - pos, - pagenum: ++pagenum, - freespace: Math.max(0, freespace), - contdName: "", - splitNodeType: null, - anchorId: dataId, - }); - pagePos = 0; - lastNodes = new CircularBuffer(3); + // The `consumedAnchors` guard ignores the transient duplicate + // data-id that appears after Enter splits a locked anchor — only + // the first occurrence in doc order is honored as the lock site, + // matching the post-dedup state and avoiding a phantom break. + if ( + lockedAnchorIds && + dataId && + lockedAnchorIds.has(dataId) && + !consumedAnchors.has(dataId) + ) { + consumedAnchors.add(dataId); + const lockInfo = pageLocks?.[dataId]; + const splitOffset = lockInfo?.splitOffset; + const textLen = node.textContent?.length ?? 0; + + if ( + pagePos > 0 && + splitOffset != null && + splitOffset > 0 && + splitOffset < textLen + ) { + // The lock was originally created on a mid-node sentence split + // (straddling dialogue or action). Reproduce that split here: + // top portion stays on the current page, break goes at the + // stored offset, bottom portion starts the locked page. Without + // this branch the whole node would be force-pushed to the next + // page, making the locked page taller than its frozen layout + // and forcing a phantom "A" page to be inserted before it. + if (!element) element = serializer.serializeNode(node) as HTMLElement; + + const topText = node.textContent.slice(0, splitOffset); + const topElement = element.cloneNode(false) as HTMLElement; + topElement.textContent = topText; + const topHeight = getHTMLHeight( + topElement, + editorDOM, + node.type.name, + options, + ); + const bottomHeight = Math.max(0, height - topHeight); + + pagePos += topHeight; + const freespace = contentHeight - pagePos; + + breaks.push({ + pos: pos + 1 + splitOffset, + pagenum: ++pagenum, + freespace: Math.max(0, freespace), + contdName: logic?.showMoreContd ? lastCharName : "", + splitNodeType: nodeType, + anchorId: dataId, + splitOffset, + }); + + pagePos = bottomHeight; + lastNodes = new CircularBuffer(3); + lastNodes.push({ + pos, + type: nodeType, + height: bottomHeight, + positionTop: 0, + dataId, + }); + continue; + } + + if (pagePos > 0) { + // Whole-node lock: force break before the anchor so the locked + // page begins exactly with this node. + const freespace = contentHeight - pagePos; + breaks.push({ + pos, + pagenum: ++pagenum, + freespace: Math.max(0, freespace), + contdName: "", + splitNodeType: null, + anchorId: dataId, + }); + pagePos = 0; + lastNodes = new CircularBuffer(3); + } } + // Omitted-scene body paragraphs are visually hidden by + // scene-locking decorations (display:none) but still live in + // the document. Treat them as zero-height so pagination + // matches what the user actually sees on the page. The + // measured height stays cached for cheap restoration when + // the scene is un-omitted. + const effectiveHeight = + currentSceneOmitted && nodeType !== ScreenplayElement.Scene ? 0 : height; + // Accumulate height on current page - pagePos += height; + pagePos += effectiveHeight; // We keep the last 3 nodes for orphan resolution on page break - lastNodes.push({ pos, type: nodeType, height, positionTop: pagePos - height, dataId }); + lastNodes.push({ + pos, + type: nodeType, + height: effectiveHeight, + positionTop: pagePos - effectiveHeight, + dataId, + }); // Page break needed — record it and reset page position if (pagePos > contentHeight) { @@ -862,6 +1000,10 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: splitNodeType: nodeType, // Anchor for page locking: the node being split owns both halves. anchorId: dataId, + // splitOffset is captured by the production panel when locking, + // so the same mid-node split can be reproduced on later recomputes + // instead of force-pushing the whole node onto the locked page. + splitOffset: split.offset, }); // The bottom half of the split node is the first item on the new page. pagePos = split.bottomHeight; @@ -962,6 +1104,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // Spread preserves all fields (contdName, splitNodeType, …); override pagenum only. breaks.push({ ...mappedOldBreaks[j], pagenum }); } + shortCircuited = true; break; } } @@ -973,7 +1116,17 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // may consume it: when an orphan synthetic empty page lands at // doc end, it absorbs this freespace so the last real page stays // at its full height and the empty page renders after it. - let lastPageFreespace = Math.max(0, contentHeight - pagePos); + // + // When the per-node loop short-circuited, pagePos is the carry + // height after the matched break — NOT the height of the real + // last page — so deriving from it would make the last page's + // spacer grow or shrink with every edit on earlier pages. The + // short-circuit condition guarantees content past that break + // is identical to the previous pass, so the previously stored + // freespace is still the correct answer. + let lastPageFreespace = shortCircuited + ? value.lastPageFreespace + : Math.max(0, contentHeight - pagePos); // --- Orphan page handling --- // A locked page whose anchor data-id is no longer present in the doc @@ -1360,19 +1513,18 @@ export const ScriptioPagination = Extension.create({ addKeyboardShortcuts() { return { Backspace: ({ editor }) => { - // joinBackward has a variant — joinMaybeClear — that deletes - // the PREVIOUS block instead of the current one. It fires when - // both blocks are empty. If that previous block is a locked - // page anchor, the plugin's filterTransaction rejects the - // resulting transaction (the anchor's data-id would go - // missing), and the user sees the cursor stuck on the second - // empty line. Patch the case by deleting the current empty - // block ourselves and parking the cursor inside the preserved - // anchor — the natural "step up one line" behavior. + // ProseMirror's joinMaybeClear (a joinBackward variant) deletes + // the PREVIOUS block instead of the current one whenever the + // previous block is empty and the two blocks share a type. If + // that empty previous block is a locked page anchor the plugin's + // filterTransaction rejects the resulting transaction — the + // anchor's data-id would disappear — and the cursor appears + // stuck. Patch both flavors of the case (empty current and + // non-empty current) so the locked anchor survives and the user + // still gets the natural "step up one line" / "merge up" feel. const { state, view } = editor; const { $from, empty } = state.selection; if (!empty || $from.parentOffset !== 0) return false; - if ($from.parent.textContent.length !== 0) return false; const opts = this.options as PaginationOptions; if (!opts.getPageLocking?.()) return false; @@ -1388,7 +1540,22 @@ export const ScriptioPagination = Extension.create({ const prevDataId = prev.attrs?.["data-id"]; if (typeof prevDataId !== "string" || !pageLocks[prevDataId]) return false; - const tr = state.tr.delete(curStart, $from.after()); + const tr = state.tr; + if ($from.parent.textContent.length === 0) { + // Both blocks empty: drop the current empty block — the + // locked anchor stays put and the cursor parks inside it. + tr.delete(curStart, $from.after()); + } else { + // Current has text: merge it INTO the empty previous block + // via tr.join, which keeps the before node's structure + // (and its locked data-id) and absorbs after's content. + // join requires both children to share a type; for cross- + // type cases we bail out and let the default chain do + // whatever fallback it has — those cases don't trip + // joinMaybeClear in the first place. + if (prev.type !== $from.parent.type) return false; + tr.join(curStart); + } tr.setSelection(TextSelection.create(tr.doc, curStart - 1)); view.dispatch(tr); return true; @@ -1541,6 +1708,34 @@ export function getPageAnchors(editor: Editor): string[] { return out; } +export interface PageAnchorInfo { + anchorId: string; + /** Character offset within the anchor node where the page begins. + * Set when the page starts on the bottom half of a sentence-split node; + * undefined for whole-node anchors. Frozen into the page lock so the + * split survives recomputes. */ + splitOffset?: number; +} + +/** + * Same ordering as {@link getPageAnchors} but each entry also carries the + * splitOffset (when the page begins mid-node). Used by the production panel + * when first locking pages so the lock map can reproduce mid-node splits + * on subsequent recomputes. + */ +export function getPageAnchorInfo(editor: Editor): PageAnchorInfo[] { + const state = paginationKey.getState(editor.state) as PaginationState | undefined; + if (!state) return [{ anchorId: PAGE_ONE_KEY }]; + const out: PageAnchorInfo[] = [{ anchorId: PAGE_ONE_KEY }]; + for (const b of state.breaks) { + if (!b.anchorId) continue; + const entry: PageAnchorInfo = { anchorId: b.anchorId }; + if (b.splitOffset != null) entry.splitOffset = b.splitOffset; + out.push(entry); + } + return out; +} + /** * Force a pagination recompute. Call when the page-lock map or the * page-locking toggle changes — layout may shift even though the diff --git a/src/lib/screenplay/page-locking.ts b/src/lib/screenplay/page-locking.ts index 5729c932..8c538add 100644 --- a/src/lib/screenplay/page-locking.ts +++ b/src/lib/screenplay/page-locking.ts @@ -23,6 +23,12 @@ export const PAGE_ONE_KEY = "__page1__"; export type PersistentPage = { /** Frozen structural position under production page-lock. */ token?: SceneToken; + /** Character offset within the anchor node where the locked page begins. + * Set when the page was originally split mid-node at a sentence boundary + * (straddling dialogue/action). Preserves the split on recompute so the + * locked page doesn't inherit the whole anchor node and overflow into a + * phantom "A" page. */ + splitOffset?: number; }; export type PersistentPageMap = { [anchorId: string]: PersistentPage }; diff --git a/src/lib/utils/redirects.ts b/src/lib/utils/redirects.ts index d1da9414..f3b8921d 100644 --- a/src/lib/utils/redirects.ts +++ b/src/lib/utils/redirects.ts @@ -1,11 +1,11 @@ -import { redirect } from "next/navigation"; +import { redirect, RedirectType } from "next/navigation"; export const redirectHome = () => { - redirect("/projects"); + redirect("/projects", RedirectType.replace); }; export const redirectProject = (projectId: string) => { - redirect(`/projects?projectId=${projectId}`); + redirect(`/projects?projectId=${projectId}`, RedirectType.replace); }; // Legacy aliases for backwards compatibility during migration diff --git a/styles/scriptio.css b/styles/scriptio.css index 82ed8f4d..ebfeaa2e 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -238,6 +238,15 @@ [data-omitted-body="true"] { display: none; } + /* Both the OMITTED overlay and the scene-number widgets are spans inside + the scene heading, which the `.ProseMirror > p span` rule above pins to + --editor-text. Re-grey them here so they share the omitted scene's + --secondary-text color instead of standing out at full text color. */ + .scene-omitted-overlay, + .scene[data-omitted-overlay="true"] .scene-label-left, + .scene[data-omitted-overlay="true"] .scene-label-right { + color: var(--secondary-text); + } .scene-omitted-overlay { user-select: none; pointer-events: none; From 08215530b8d28b51d337c7c2872f4e25ae481cf1 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Thu, 28 May 2026 15:32:31 +0200 Subject: [PATCH 58/76] scene omit tweaks, tweaked translations, etc --- .../project/ProductionSettings.module.css | 7 +- .../editor/sidebar/ContextMenu.module.css | 2 - components/editor/sidebar/ContextMenu.tsx | 16 ++-- components/popup/Popup.module.css | 19 +++- components/popup/PopupImportFile.tsx | 14 +-- components/popup/PopupUnlockPages.tsx | 16 ++-- components/popup/PopupUnlockScenes.tsx | 16 ++-- components/popup/PopupUploadToCloud.tsx | 30 ++++--- components/utils/ColorPicker.module.css | 4 +- components/utils/Form.module.css | 1 + components/utils/ModalBtn.module.css | 14 ++- messages/de.json | 2 +- messages/en.json | 4 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/ja.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- messages/zh.json | 2 +- src/lib/adapters/pdf/pdf-adapter.ts | 3 +- src/lib/editor/use-document-editor.ts | 24 +++-- src/lib/project/project-repository.ts | 11 ++- .../extensions/scene-locking-extension.ts | 38 ++------ src/lib/screenplay/scene-locking.ts | 87 +++++++++++++++++-- src/lib/screenplay/scenes.ts | 2 + styles/scriptio.css | 30 ++----- 26 files changed, 215 insertions(+), 137 deletions(-) diff --git a/components/dashboard/project/ProductionSettings.module.css b/components/dashboard/project/ProductionSettings.module.css index f0669ec1..ea66a6c8 100644 --- a/components/dashboard/project/ProductionSettings.module.css +++ b/components/dashboard/project/ProductionSettings.module.css @@ -6,7 +6,7 @@ } .styleName { - font-size: 0.7rem; + font-size: 0.85rem; color: var(--secondary-text); text-transform: uppercase; letter-spacing: 0.06em; @@ -18,12 +18,15 @@ align-items: center; gap: 6px; margin-left: auto; - font-family: var(--font-screenplay); font-size: 0.9rem; font-weight: 600; color: var(--primary-text); } +.styleExample span { + font-family: var(--font-screenplay); +} + .arrowIcon { opacity: 0.5; } diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index 02a9ffc8..fa554e32 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -18,10 +18,8 @@ align-items: center; gap: 10px; - border-radius: 6px; padding-block: 6px; padding-inline: 12px; - margin-inline: 6px; font-size: 14px; cursor: pointer; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 0aa60f00..387eb583 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -117,13 +117,13 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { const canUnomit = !!scene.id && !!scene.omitted; const handleOmit = () => { - if (!repository || !scene.id) return; - omitSceneByUuid(repository, scene.id); + if (!editor || !repository || !scene.id) return; + omitSceneByUuid(editor, repository, scene.id); }; const handleUnomit = () => { - if (!repository || !scene.id) return; - unomitSceneByUuid(repository, scene.id); + if (!editor || !repository || !scene.id) return; + unomitSceneByUuid(editor, repository, scene.id); }; return ( @@ -508,14 +508,14 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { })(); const handleOmitScene = () => { - if (!repository || !sceneInfo) return; - omitSceneByUuid(repository, sceneInfo.uuid); + if (!editor || !repository || !sceneInfo) return; + omitSceneByUuid(editor, repository, sceneInfo.uuid); updateContextMenu(undefined); }; const handleUnomitScene = () => { - if (!repository || !sceneInfo) return; - unomitSceneByUuid(repository, sceneInfo.uuid); + if (!editor || !repository || !sceneInfo) return; + unomitSceneByUuid(editor, repository, sceneInfo.uuid); updateContextMenu(undefined); }; diff --git a/components/popup/Popup.module.css b/components/popup/Popup.module.css index e8759d59..96ac22b3 100644 --- a/components/popup/Popup.module.css +++ b/components/popup/Popup.module.css @@ -1,11 +1,11 @@ .container { position: absolute; width: 500px; - min-height: 250px; - padding: 16px; + padding: 20px; + padding-bottom: 24px; + background-color: var(--primary); border: 2px solid var(--separator); box-shadow: 0 5px 30px rgba(0, 0, 0, 0.3); - background-color: var(--primary); border-radius: 12px; pointer-events: auto; } @@ -49,7 +49,7 @@ display: flex; flex-direction: column; gap: 18px; - margin-bottom: 32px; + margin-bottom: 20px; } .info_btns { @@ -115,6 +115,17 @@ these classes STANDALONE — do not pair with form.btn, since its !important border/radius rules would override the composed styling. */ +.buttons { + display: flex; + gap: 10px; +} + +.buttons > * { + flex: 1; + width: auto !important; + margin-top: 0 !important; +} + .confirm { composes: modalBtn from "../utils/ModalBtn.module.css"; width: 100% !important; diff --git a/components/popup/PopupImportFile.tsx b/components/popup/PopupImportFile.tsx index d788ebf3..2eb1c11c 100644 --- a/components/popup/PopupImportFile.tsx +++ b/components/popup/PopupImportFile.tsx @@ -37,12 +37,14 @@ const PopupImportFile = ({ data: { confirmImport } }: PopupData
- - +
+ + +
); diff --git a/components/popup/PopupUnlockPages.tsx b/components/popup/PopupUnlockPages.tsx index 2bcef200..e9b2fc29 100644 --- a/components/popup/PopupUnlockPages.tsx +++ b/components/popup/PopupUnlockPages.tsx @@ -37,13 +37,15 @@ const PopupUnlockPages = ({ data: { confirmUnlock } }: PopupData

{t("unlockPagesWarning")}

- - +
+ + +
); diff --git a/components/popup/PopupUnlockScenes.tsx b/components/popup/PopupUnlockScenes.tsx index 930b6dcf..607a0bf1 100644 --- a/components/popup/PopupUnlockScenes.tsx +++ b/components/popup/PopupUnlockScenes.tsx @@ -37,13 +37,15 @@ const PopupUnlockScenes = ({ data: { confirmUnlock } }: PopupData

{t("unlockWarning")}

- - +
+ + +
); diff --git a/components/popup/PopupUploadToCloud.tsx b/components/popup/PopupUploadToCloud.tsx index 58218b8e..08f8d28f 100644 --- a/components/popup/PopupUploadToCloud.tsx +++ b/components/popup/PopupUploadToCloud.tsx @@ -55,20 +55,22 @@ const PopupUploadToCloud = ({ data: { projectId } }: PopupData{t("body")}

{info && } - - +
+ + +
); diff --git a/components/utils/ColorPicker.module.css b/components/utils/ColorPicker.module.css index 784bb451..3ae29462 100644 --- a/components/utils/ColorPicker.module.css +++ b/components/utils/ColorPicker.module.css @@ -6,7 +6,7 @@ .trigger { width: 26px; height: 26px; - border-radius: 6px; + border-radius: 50%; cursor: pointer; display: flex; align-items: center; @@ -20,7 +20,7 @@ } .trigger_empty { - border: 3px solid var(--secondary-text); + border: 4px solid var(--secondary-text); position: relative; } diff --git a/components/utils/Form.module.css b/components/utils/Form.module.css index 3f1571f5..1181c450 100644 --- a/components/utils/Form.module.css +++ b/components/utils/Form.module.css @@ -112,6 +112,7 @@ font-size: 1.15rem; border-radius: 7px; padding: 8px; + background-color: var(--secondary); } .input::placeholder { diff --git a/components/utils/ModalBtn.module.css b/components/utils/ModalBtn.module.css index ad9928ad..2d38fd12 100644 --- a/components/utils/ModalBtn.module.css +++ b/components/utils/ModalBtn.module.css @@ -3,16 +3,22 @@ align-items: center; justify-content: center; gap: 8px; + width: 100%; padding: 10px 16px; border-radius: 40px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; + font-weight: bold; transition: opacity 0.2s ease; - transform: translateZ(0); backface-visibility: hidden; - width: 100%; + background-color: var(--secondary); + color: var(--primary-text); +} + +.modalBtn:hover:not(:disabled) { + opacity: 0.85; } .modalBtn:disabled { @@ -22,6 +28,7 @@ .modalBtnDanger { background: var(--error); + font-weight: bold; color: white; } @@ -37,6 +44,5 @@ } .modalBtnCancel:hover:not(:disabled) { - color: var(--primary-text); - border-color: var(--primary-text); + opacity: 0.7; } diff --git a/messages/de.json b/messages/de.json index 889c2462..5350ca71 100644 --- a/messages/de.json +++ b/messages/de.json @@ -383,7 +383,7 @@ "paste": "Einfügen", "highlight": "Hervorheben", "addCharacter": "Charakter hinzufügen", - "addComment": "Kommentar hinzufügen", + "addComment": "Kommentieren", "searchOnWeb": "Im Web suchen", "noSuggestions": "Keine Vorschläge", "addToDictionary": "Zum Wörterbuch hinzufügen", diff --git a/messages/en.json b/messages/en.json index 24107f8a..4aade80b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -382,10 +382,10 @@ "paste": "Paste", "highlight": "Highlight", "addCharacter": "Add character", - "addComment": "Add Comment", + "addComment": "Comment", "searchOnWeb": "Search on web", "noSuggestions": "No suggestions", - "addToDictionary": "Add to Dictionary", + "addToDictionary": "Add to dictionary", "makeDualDialogue": "Make dual dialogue", "shelve": "Shelve", "shelveScene": "Shelve scene", diff --git a/messages/es.json b/messages/es.json index 4f1fb4b1..534460e6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -382,7 +382,7 @@ "paste": "Pegar", "highlight": "Resaltar", "addCharacter": "Añadir personaje", - "addComment": "Añadir comentario", + "addComment": "Comentar", "searchOnWeb": "Buscar en la web", "noSuggestions": "Sin sugerencias", "addToDictionary": "Añadir al diccionario", diff --git a/messages/fr.json b/messages/fr.json index 2a41c334..b38866ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -383,7 +383,7 @@ "paste": "Coller", "highlight": "Surligner", "addCharacter": "Ajouter un personnage", - "addComment": "Ajouter un commentaire", + "addComment": "Commenter", "searchOnWeb": "Rechercher sur le web", "noSuggestions": "Aucune suggestion", "addToDictionary": "Ajouter au dictionnaire", diff --git a/messages/ja.json b/messages/ja.json index f8b5f921..997b31f8 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -382,7 +382,7 @@ "paste": "貼り付け", "highlight": "ハイライト", "addCharacter": "登場人物を追加", - "addComment": "コメントを追加", + "addComment": "コメント", "searchOnWeb": "ウェブ検索", "noSuggestions": "候補なし", "addToDictionary": "辞書に追加", diff --git a/messages/ko.json b/messages/ko.json index 5e3b911f..98e6f3f8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -382,7 +382,7 @@ "paste": "붙여넣기", "highlight": "하이라이트", "addCharacter": "인물 추가", - "addComment": "댓글 추가", + "addComment": "댓글", "searchOnWeb": "웹 검색", "noSuggestions": "제안 없음", "addToDictionary": "사전에 추가", diff --git a/messages/pl.json b/messages/pl.json index c78feb68..1caf69ba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -382,7 +382,7 @@ "paste": "Wklej", "highlight": "Wyróżnij", "addCharacter": "Dodaj postać", - "addComment": "Dodaj komentarz", + "addComment": "Komentuj", "searchOnWeb": "Szukaj w sieci", "noSuggestions": "Brak sugestii", "addToDictionary": "Dodaj do słownika", diff --git a/messages/zh.json b/messages/zh.json index b1fe3406..ac973006 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -382,7 +382,7 @@ "paste": "粘贴", "highlight": "高亮", "addCharacter": "添加角色", - "addComment": "添加评论", + "addComment": "评论", "searchOnWeb": "网页搜索", "noSuggestions": "无建议", "addToDictionary": "添加到字典", diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index b3fb8875..2e91f293 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -185,7 +185,6 @@ export class PDFAdapter extends ProjectAdapter { (el.querySelector(".scene-label-right") as HTMLElement | null)?.textContent ?.trim() || String(sceneCount), - omitted: el.getAttribute("data-omitted-overlay") === "true", } : undefined; @@ -500,7 +499,7 @@ export class PDFAdapter extends ProjectAdapter { el: HTMLElement, paragraphLines: VisualLine[], options: PDFExportOptions, - sceneInfo?: { label: string; omitted: boolean }, + sceneInfo?: { label: string }, ): void { const firstLine = paragraphLines[0]; const lastLine = paragraphLines[paragraphLines.length - 1]; diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index c1eb331f..81429b13 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -31,6 +31,7 @@ import { createSceneLockingExtension, refreshSceneLocking, } from "@src/lib/screenplay/extensions/scene-locking-extension"; +import { SCENE_OMIT_UNDO_ORIGIN } from "@src/lib/screenplay/scene-locking"; import { createNodeIdDedupExtension } from "@src/lib/screenplay/extensions/node-id-dedup-extension"; import { CommentMark } from "@src/lib/screenplay/extensions/comment-highlight-extension"; import { createSpellcheckExtension, refreshSpellcheck } from "@src/lib/spellcheck/spellcheck-extension"; @@ -553,11 +554,20 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum } }, [userInfo, provider]); - // Fix Yjs undo cursor restoration: y-tiptap's stack-item-popped fires AFTER - // the undo transaction commits, so beforeTransactionSelection is captured wrong - // by beforeAllTransactions. Patch undo/redo to pre-set it from the stack item. + // Post-mount UndoManager setup. y-tiptap's UndoManager is constructed to + // track only the editor XmlFragment (scope) and only `ySyncPluginKey` + // (origin), and y-tiptap's stack-item-popped fires AFTER the undo + // transaction commits — so a few tweaks are needed: + // - addToScope(scenes): scene metadata lives in a separate Y.Map; without + // this the UndoManager silently ignores every mutation to it. + // - trackedOrigins.add(SCENE_OMIT_UNDO_ORIGIN): omit/unomit bundle a PM + // dispatch and a Map.set into one Yjs transaction tagged with this + // symbol so Ctrl+Z reverts both halves atomically. + // - undo/redo patch: pre-seed beforeTransactionSelection from the + // popped stack item so the cursor restores correctly (y-tiptap + // otherwise captures it after the fact). useEffect(() => { - if (!editor || !isYjsReady) return; + if (!editor || !isYjsReady || !projectState) return; const state = editor.state; const yUndoState = yUndoPluginKey.getState(state); @@ -566,6 +576,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum const um = yUndoState.undoManager; const binding = ySyncState.binding; + um.addToScope([projectState.scenes()]); + um.trackedOrigins.add(SCENE_OMIT_UNDO_ORIGIN); + const originalUndo = um.undo.bind(um); const originalRedo = um.redo.bind(um); @@ -588,8 +601,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum return () => { um.undo = originalUndo; um.redo = originalRedo; + um.trackedOrigins.delete(SCENE_OMIT_UNDO_ORIGIN); }; - }, [editor, isYjsReady]); + }, [editor, isYjsReady, projectState]); // Refresh character highlights useEffect(() => { diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index f8b37bc7..4ea322ac 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -259,7 +259,7 @@ export class ProjectRepository { const existing = (map.get(sceneId) as PersistentScene | undefined) ?? {}; const merged: PersistentScene = { ...existing }; - const FIELDS = ["synopsis", "color", "token", "omitted"] as const; + const FIELDS = ["synopsis", "color", "token", "omitted", "originalHeading"] as const; for (const key of FIELDS) { if (key in data) { (merged as Record)[key] = data[key]; @@ -500,10 +500,15 @@ export class ProjectRepository { /** * Run a function inside a single Y.js transaction. * Useful for batching multiple repository mutations into one collab update. + * + * Pass `origin` to tag the transaction — required for the Y.UndoManager + * to track the changes (the manager ignores transactions whose origin is + * not in its `trackedOrigins` set). Custom origins must also be added to + * the editor's `trackedOrigins` set; see `use-document-editor.ts`. */ - transact(fn: () => void): void { + transact(fn: () => void, origin?: unknown): void { if (this.guardWrite("transact")) return; - this.ydoc.transact(fn); + this.ydoc.transact(fn, origin); } // -------------------------------- // diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts index fec51525..24c445da 100644 --- a/src/lib/screenplay/extensions/scene-locking-extension.ts +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -71,14 +71,6 @@ const buildLabelWidget = (label: string, side: "left" | "right"): HTMLElement => return span; }; -const buildOmittedWidget = (): HTMLElement => { - const span = document.createElement("span"); - span.className = "scene-omitted-overlay"; - span.contentEditable = "false"; - span.textContent = "OMITTED"; - return span; -}; - const hasAnyOmitted = (scenes: Record): boolean => { for (const key in scenes) { if (scenes[key]?.omitted) return true; @@ -131,39 +123,21 @@ const computeDecorations = ( } } - // OMITTED decorations are independent of production lock — the user can - // omit any scene at any time and the original heading + body are kept - // in the document; we just hide them visually until they unomit. + // OMITTED decorations are independent of production lock. The heading + // text itself is replaced with "OMITTED" inside the document by + // `omitSceneByUuid` (the original is preserved in scene metadata), so + // here we only need to grey the heading via `data-scene-omitted` and + // collapse the body paragraphs via `data-omitted-body`. for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (!scenes[entry.uuid]?.omitted) continue; decorations.push( Decoration.node(entry.pos, entry.pos + entry.nodeSize, { - "data-omitted-overlay": "true", + "data-scene-omitted": "true", }), ); - decorations.push( - Decoration.widget(entry.pos + 1, () => buildOmittedWidget(), { - side: -1, - key: `scene-omitted-${entry.uuid}`, - }), - ); - - // Hide the original heading text behind the OMITTED widget. Skip - // empty headings — there's nothing to hide and the inline range - // would be degenerate. - if (entry.nodeSize > 2) { - decorations.push( - Decoration.inline(entry.pos + 1, entry.pos + entry.nodeSize - 1, { - class: "scene-heading-omitted-text", - }), - ); - } - // Hide every top-level paragraph between this heading and the next - // scene heading. We tag them with `data-omitted-body` so CSS can - // collapse them while leaving the underlying document untouched. const nextEntry = entries[i + 1]; const bodyEnd = nextEntry ? nextEntry.pos : doc.content.size; const bodyStart = entry.pos + entry.nodeSize; diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts index cc14d023..acf6cd8f 100644 --- a/src/lib/screenplay/scene-locking.ts +++ b/src/lib/screenplay/scene-locking.ts @@ -45,7 +45,21 @@ * Together these give: 1 < 1aA < 1A < 1AA < 1AB < 1B < 2. */ +import type { Editor } from "@tiptap/core"; +import type { Node } from "@tiptap/pm/model"; + import type { ProjectRepository } from "../project/project-repository"; +import { ScreenplayElement } from "../utils/enums"; + +const OMITTED_HEADING_TEXT = "OMITTED"; + +/** + * Yjs transaction origin used by `omitSceneByUuid` / `unomitSceneByUuid` so + * the editor's UndoManager records both the document edit and the scene + * metadata change as a single, atomic undo step. `use-document-editor` + * registers this symbol in the UndoManager's `trackedOrigins`. + */ +export const SCENE_OMIT_UNDO_ORIGIN = Symbol("scene-omit-undo"); // -------------------------------------------------------------------------- // TYPES @@ -459,19 +473,74 @@ export const computeSceneLabels = ( // ACTIONS // -------------------------------------------------------------------------- +/** Locate the scene heading node in the document by its `data-id` UUID. */ +const findSceneHeadingByUuid = ( + editor: Editor, + uuid: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; + editor.state.doc.descendants((node, pos) => { + if (result) return false; + if (node.attrs?.class === ScreenplayElement.Scene && node.attrs?.["data-id"] === uuid) { + result = { node, pos }; + return false; + } + return true; + }); + return result; +}; + /** - * Mark a scene as OMITTED. The scene's heading text and body content are - * preserved in the document; the editor overlays "OMITTED" and hides the - * underlying content via decorations so the original screenplay survives an - * unomit. Works regardless of production lock state. + * Mark a scene as OMITTED. The heading's current text is saved into the + * scene's `originalHeading` metadata and the heading content in the + * document is replaced with "OMITTED" — so the heading remains a normal + * editable paragraph (cursor + Enter behave naturally) while the body + * paragraphs are still visually collapsed via decorations. + * + * Both the document edit and the metadata update are wrapped in a single + * Yjs transaction so Ctrl+Z reverts them atomically. */ -export const omitSceneByUuid = (repository: ProjectRepository, uuid: string): void => { - repository.upsertScene(uuid, { omitted: true }); +export const omitSceneByUuid = ( + editor: Editor, + repository: ProjectRepository, + uuid: string, +): void => { + const heading = findSceneHeadingByUuid(editor, uuid); + if (!heading) return; + + const currentText = heading.node.textContent; + repository.transact(() => { + const headingStart = heading.pos + 1; + const headingEnd = heading.pos + heading.node.nodeSize - 1; + const tr = editor.state.tr.insertText(OMITTED_HEADING_TEXT, headingStart, headingEnd); + editor.view.dispatch(tr); + repository.upsertScene(uuid, { omitted: true, originalHeading: currentText }); + }, SCENE_OMIT_UNDO_ORIGIN); }; -/** Clear an OMITTED scene's `omitted` flag, restoring the heading + body. */ -export const unomitSceneByUuid = (repository: ProjectRepository, uuid: string): void => { +/** + * Clear an OMITTED scene's flag and restore its original heading text from + * `originalHeading` metadata. Inverse of `omitSceneByUuid`, batched in a + * single Yjs transaction for atomic undo. + */ +export const unomitSceneByUuid = ( + editor: Editor, + repository: ProjectRepository, + uuid: string, +): void => { const scene = repository.getScene(uuid); if (!scene?.omitted) return; - repository.upsertScene(uuid, { omitted: undefined }); + const heading = findSceneHeadingByUuid(editor, uuid); + if (!heading) return; + + const restoreText = scene.originalHeading ?? ""; + repository.transact(() => { + const headingStart = heading.pos + 1; + const headingEnd = heading.pos + heading.node.nodeSize - 1; + const tr = restoreText.length > 0 + ? editor.state.tr.insertText(restoreText, headingStart, headingEnd) + : editor.state.tr.delete(headingStart, headingEnd); + editor.view.dispatch(tr); + repository.upsertScene(uuid, { omitted: undefined, originalHeading: undefined }); + }, SCENE_OMIT_UNDO_ORIGIN); }; diff --git a/src/lib/screenplay/scenes.ts b/src/lib/screenplay/scenes.ts index c89fa974..ce1b89b0 100644 --- a/src/lib/screenplay/scenes.ts +++ b/src/lib/screenplay/scenes.ts @@ -70,6 +70,8 @@ export type PersistentScene = { token?: SceneToken; /** True when the scene is an OMITTED placeholder (only meaningful with `token`). */ omitted?: boolean; + /** Original heading text saved when the scene was omitted, restored on unomit. */ + originalHeading?: string; }; /** diff --git a/styles/scriptio.css b/styles/scriptio.css index ebfeaa2e..1629bc7a 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -226,32 +226,20 @@ display: none; } - /* OMITTED scene placeholder. The heading text and body paragraphs are - preserved in the document so the user can restore them; we just hide - them visually and overlay "OMITTED" in place of the heading. */ - .scene[data-omitted-overlay="true"] { + /* OMITTED scene: heading text is rewritten to "OMITTED" in the document + by `omitSceneByUuid` (original preserved in scene metadata), so the + heading is a normal editable paragraph. We just grey it and collapse + the body paragraphs; the scene-number widgets are re-greyed because + the generic `.ProseMirror > p span` rule above pins them to + --editor-text. */ + .scene[data-scene-omitted="true"], + .scene[data-scene-omitted="true"] .scene-label-left, + .scene[data-scene-omitted="true"] .scene-label-right { color: var(--secondary-text); } - .scene-heading-omitted-text { - display: none; - } [data-omitted-body="true"] { display: none; } - /* Both the OMITTED overlay and the scene-number widgets are spans inside - the scene heading, which the `.ProseMirror > p span` rule above pins to - --editor-text. Re-grey them here so they share the omitted scene's - --secondary-text color instead of standing out at full text color. */ - .scene-omitted-overlay, - .scene[data-omitted-overlay="true"] .scene-label-left, - .scene[data-omitted-overlay="true"] .scene-label-right { - color: var(--secondary-text); - } - .scene-omitted-overlay { - user-select: none; - pointer-events: none; - font-style: normal; - } /* Normal weight scene headings (when bold disabled) */ &.scene-heading-normal .scene { From 79e089a7f6e6ffb21e25f41215cfa8f44742df5f Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 1 Jun 2026 01:44:59 +0200 Subject: [PATCH 59/76] fixed pagination issue because of font lazy loading, removed top margin of top-page nodes --- .../extensions/pagination-extension.ts | 157 ++++++++++++------ styles/scriptio.css | 11 ++ 2 files changed, 119 insertions(+), 49 deletions(-) diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 7df545d9..cc8a29ed 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -454,9 +454,7 @@ function buildDecorations( // Label of the last page = label of the most recent break (or firstPageLabel // when no breaks exist). const lastPagenum = breaks.length > 0 ? breaks[breaks.length - 1].pagenum : 1; - const lastPageLabel = breaks.length > 0 - ? breaks[breaks.length - 1].label ?? String(lastPagenum) - : firstPageLabel; + const lastPageLabel = breaks.length > 0 ? (breaks[breaks.length - 1].label ?? String(lastPagenum)) : firstPageLabel; decorations.push( Decoration.widget( doc.content.size, @@ -677,7 +675,11 @@ function computePageLabels( return labels.map((l) => l.label); } -const createPaginationPlugin = (extension: { options: PaginationOptions; editor: Editor }) => +const createPaginationPlugin = (extension: { + options: PaginationOptions; + editor: Editor; + storage: { fontsReady: boolean }; +}) => new Plugin({ key: paginationKey, state: { @@ -688,6 +690,14 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: firstPageLabel: "1", }), apply(tr, value: PaginationState, oldState, newState): PaginationState { + // Wait for the screenplay fonts to finish loading before doing + // anything. Measuring against the OS monospace fallback writes + // wrong heights into the cache; gating here keeps the cache + // empty until the real font is in play. onCreate dispatches a + // forcePaginationUpdate once fonts.ready resolves, which is + // what eventually pulls us past this guard. + if (!extension.storage.fontsReady) return value; + const options = extension.options as PaginationOptions; const formatUpdate = tr.getMeta("pageFormatUpdate"); const forceUpdate = tr.getMeta("forcePaginationUpdate"); @@ -760,9 +770,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // map lookups. The set is rebuilt once per pass when locking // is active; lock counts are typically tens, never thousands. const pageLocking = options.getPageLocking?.() ?? false; - const pageLocks: PersistentPageMap | null = pageLocking - ? options.getPageLocks?.() ?? null - : null; + const pageLocks: PersistentPageMap | null = pageLocking ? (options.getPageLocks?.() ?? null) : null; const lockedAnchorIds: Set | null = pageLocks ? new Set(Object.keys(pageLocks).filter((k) => k !== PAGE_ONE_KEY)) : null; @@ -872,23 +880,13 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // data-id that appears after Enter splits a locked anchor — only // the first occurrence in doc order is honored as the lock site, // matching the post-dedup state and avoiding a phantom break. - if ( - lockedAnchorIds && - dataId && - lockedAnchorIds.has(dataId) && - !consumedAnchors.has(dataId) - ) { + if (lockedAnchorIds && dataId && lockedAnchorIds.has(dataId) && !consumedAnchors.has(dataId)) { consumedAnchors.add(dataId); const lockInfo = pageLocks?.[dataId]; const splitOffset = lockInfo?.splitOffset; const textLen = node.textContent?.length ?? 0; - if ( - pagePos > 0 && - splitOffset != null && - splitOffset > 0 && - splitOffset < textLen - ) { + if (pagePos > 0 && splitOffset != null && splitOffset > 0 && splitOffset < textLen) { // The lock was originally created on a mid-node sentence split // (straddling dialogue or action). Reproduce that split here: // top portion stays on the current page, break goes at the @@ -901,12 +899,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: const topText = node.textContent.slice(0, splitOffset); const topElement = element.cloneNode(false) as HTMLElement; topElement.textContent = topText; - const topHeight = getHTMLHeight( - topElement, - editorDOM, - node.type.name, - options, - ); + const topHeight = getHTMLHeight(topElement, editorDOM, node.type.name, options); const bottomHeight = Math.max(0, height - topHeight); pagePos += topHeight; @@ -957,8 +950,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // matches what the user actually sees on the page. The // measured height stays cached for cheap restoration when // the scene is un-omitted. - const effectiveHeight = - currentSceneOmitted && nodeType !== ScreenplayElement.Scene ? 0 : height; + const effectiveHeight = currentSceneOmitted && nodeType !== ScreenplayElement.Scene ? 0 : height; // Accumulate height on current page pagePos += effectiveHeight; @@ -1008,7 +1000,13 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // The bottom half of the split node is the first item on the new page. pagePos = split.bottomHeight; lastNodes = new CircularBuffer(3); - lastNodes.push({ pos, type: nodeType, height: split.bottomHeight, positionTop: 0, dataId }); + lastNodes.push({ + pos, + type: nodeType, + height: split.bottomHeight, + positionTop: 0, + dataId, + }); continue; // split handled — skip orphan resolution for this node } } @@ -1027,11 +1025,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // A locked anchor owns its page and must never be displaced by // walkback — otherwise the next overflow would yank it onto an // A page and the locked frame would lose its head. - if ( - lockedAnchorIds && - prev.dataId && - lockedAnchorIds.has(prev.dataId) - ) { + if (lockedAnchorIds && prev.dataId && lockedAnchorIds.has(prev.dataId)) { break; } if (BREAK_LOGIC[prev.type]?.keepWithNext) { @@ -1124,9 +1118,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // short-circuit condition guarantees content past that break // is identical to the previous pass, so the previously stored // freespace is still the correct answer. - let lastPageFreespace = shortCircuited - ? value.lastPageFreespace - : Math.max(0, contentHeight - pagePos); + let lastPageFreespace = shortCircuited ? value.lastPageFreespace : Math.max(0, contentHeight - pagePos); // --- Orphan page handling --- // A locked page whose anchor data-id is no longer present in the doc @@ -1235,10 +1227,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: } if (b.pos === segmentEnd) { const bLock = b.anchorId ? pageLocks[b.anchorId] : undefined; - if ( - bLock?.token && - compareTokens(orphan.token, bLock.token) < 0 - ) { + if (bLock?.token && compareTokens(orphan.token, bLock.token) < 0) { insertIdx = j; break; } @@ -1257,10 +1246,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // which already gets a full `contentHeight` slot, so no // additional freespace is needed there. let syntheticFreespace = 0; - if ( - insertIdx < breaks.length && - breaks[insertIdx].pos === segmentEnd - ) { + if (insertIdx < breaks.length && breaks[insertIdx].pos === segmentEnd) { syntheticFreespace = breaks[insertIdx].freespace; breaks[insertIdx].freespace = 0; } else if (insertIdx === breaks.length) { @@ -1398,7 +1384,15 @@ export const ScriptioPagination = Extension.create({ }, addStorage() { - return { initTimer: null as ReturnType | null }; + return { + initTimer: null as ReturnType | null, + /** False until the screenplay @font-face fonts have finished loading. + * The plugin's `apply` checks this flag and skips height measurement + * while it is false, so the cache never picks up bad heights taken + * with a fallback monospace font (Consolas etc.) that produces a + * different line-wrap from CourierPrime. */ + fontsReady: false, + }; }, onCreate() { @@ -1489,14 +1483,79 @@ export const ScriptioPagination = Extension.create({ setupTestDiv(editorDOM, this.options); - // Trigger initial pagination after editor is ready - this.storage.initTimer = setTimeout(() => { - this.storage.initTimer = null; + // The screenplay @font-face fonts (CourierPrime + fallbacks) load + // asynchronously. Until the real font is applied, the test div lays + // text out in the OS monospace fallback (Consolas on Windows), whose + // slightly different character widths cause text to wrap to a + // different number of lines. Heights measured against the fallback + // disagree with what the editor will eventually render — and once + // cached, they keep that disagreement alive (hence the cold-open vs + // hard-refresh mismatch, and the shrink-on-edit symptom when a + // cache-miss re-measures against the now-loaded real font). + // + // We defer the first pagination run until the font is genuinely + // usable; until then the plugin's `apply` returns the empty initial + // state (no measurement, no cache writes). Once ready we flip the flag + // and dispatch a single force update — every subsequent measurement + // happens against the real font, so the heightCache fills with correct + // values from the start. + // + // IMPORTANT: `document.fonts.ready` is NOT enough on Chrome. Chrome + // loads @font-face fonts lazily (only once a rendered element needs + // them), so at the moment the editor mounts nothing has triggered the + // CourierPrime fetch yet — `fonts.ready` resolves reporting + // status:"loaded" while `fonts.check('12pt "CourierPrime"')` is still + // false and zero faces are actually loaded. Firefox eagerly starts the + // load, which is why it worked there but not here. The fix is to + // ACTIVELY request the faces with `document.fonts.load(...)`, which + // forces the fetch and resolves only once they are usable for layout. + const triggerInitialPagination = () => { + if (this.editor.isDestroyed) return; + this.storage.fontsReady = true; const tr = this.editor.state.tr; tr.setMeta("forcePaginationUpdate", true); tr.setMeta("addToHistory", false); this.editor.view.dispatch(tr); - }, 0); + }; + + const fontsApi = typeof document !== "undefined" ? document.fonts : null; + if (fontsApi && typeof fontsApi.load === "function") { + // Actively request every CourierPrime variant the screenplay uses. + // `load()` forces the fetch (even on Chrome's lazy loader) and + // resolves once the faces are usable for measurement. `.catch` per + // spec keeps one failed variant from blocking the others, and a + // safety timeout guarantees pagination still runs if the network + // never delivers a font — better a fallback-font layout than a + // permanently blank document. + const specs = [ + '12pt "CourierPrime"', + 'bold 12pt "CourierPrime"', + 'italic 12pt "CourierPrime"', + 'bold italic 12pt "CourierPrime"', + ]; + let fired = false; + const fireOnce = () => { + if (fired) return; + fired = true; + if (this.storage.initTimer != null) { + clearTimeout(this.storage.initTimer); + this.storage.initTimer = null; + } + triggerInitialPagination(); + }; + Promise.all(specs.map((s) => fontsApi.load(s).catch(() => undefined))) + .then(() => fireOnce()) + .catch(() => fireOnce()); + // Safety net: never wait more than 3s on the font fetch. + this.storage.initTimer = setTimeout(() => fireOnce(), 3000); + } else { + // No FontFaceSet API (SSR, very old browsers): fall back to the + // legacy setTimeout(0) trigger so pagination still runs. + this.storage.initTimer = setTimeout(() => { + this.storage.initTimer = null; + triggerInitialPagination(); + }, 0); + } }, onDestroy() { diff --git a/styles/scriptio.css b/styles/scriptio.css index 1629bc7a..d27d2064 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -68,6 +68,17 @@ box-sizing: border-box; } + /* Suppress the first paragraph's margin-top when it sits directly under + a pagination widget. Without this, the page-break div's margin-bottom + (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, + > .pagination-first-page + p { + margin-top: 0 !important; + } + /* Spans inside paragraphs - NO display/width overrides These must remain inline (default) to not break text flow */ > p span, From 8e618cff895d7591aa53ddbc8fe106232e471ff9 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 1 Jun 2026 02:50:47 +0200 Subject: [PATCH 60/76] added a centralized draft locking button, added a settings button to production panel --- components/navbar/ProductionPanel.module.css | 6 + components/navbar/ProductionPanel.tsx | 258 ++++++++++++------- components/navbar/ScreenplaySearch.tsx | 17 ++ components/popup/Popup.tsx | 4 + components/popup/PopupUnlockDraft.tsx | 54 ++++ components/popup/PopupUnlockScenes.tsx | 2 +- messages/de.json | 8 +- messages/en.json | 6 + messages/es.json | 8 +- messages/fr.json | 8 +- messages/ja.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- messages/zh.json | 8 +- src/lib/screenplay/popup.ts | 15 +- 15 files changed, 315 insertions(+), 103 deletions(-) create mode 100644 components/popup/PopupUnlockDraft.tsx diff --git a/components/navbar/ProductionPanel.module.css b/components/navbar/ProductionPanel.module.css index 1f45f8e5..0de9f091 100644 --- a/components/navbar/ProductionPanel.module.css +++ b/components/navbar/ProductionPanel.module.css @@ -27,6 +27,12 @@ color: var(--primary-text); } +.header_actions { + display: flex; + align-items: center; + gap: 4px; +} + .close_btn { display: flex; align-items: center; diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index afc9c664..b2297d47 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -2,13 +2,14 @@ import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Lock, X, Layers } from "lucide-react"; +import { X, BookOpen, Clapperboard, PencilLine, Settings } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; +import { DashboardContext } from "@src/context/DashboardContext"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; import { computeSceneItems } from "@src/lib/screenplay/scenes"; -import { unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; +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"; @@ -47,9 +48,15 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { isReadOnly, } = useContext(ProjectContext); const userCtx = useContext(UserContext); + const { openDashboard } = useContext(DashboardContext); const panelRef = useRef(null); + const handleOpenSettings = () => { + onClose(); + openDashboard("Production"); + }; + // Click outside to close useEffect(() => { if (!isOpen) return; @@ -94,36 +101,45 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { }); }, [repository]); + // Writes-only scene relock: assigns a frozen token to every scene that + // `computeSceneLabels` reports as provisional. Idempotent — scenes that + // already hold a token keep it. Must be called inside `repository.transact` + // so it can be composed with the page writes for a combined draft lock. + const relockScenesWrites = useCallback(() => { + if (!repository) return; + const currentScreenplay = repository.screenplay; + const scenes = computeSceneItems(currentScreenplay); + const uuids = scenes.map((s) => s.id).filter((id): id is string => !!id); + + // Re-read fresh persistent data + const persistentSnapshot = repository.scenes; + + const currentLabels = computeSceneLabels( + uuids, + persistentSnapshot, + sceneNumberingStyle, + skippedSceneLetters, + ); + + currentLabels.forEach((label) => { + if (label.status === "provisional") { + repository.upsertScene(label.uuid, { token: label.token }); + } + }); + }, [repository, sceneNumberingStyle, skippedSceneLetters]); + + // Lock = freeze the current provisional tokens, then flip the flag on. + const lockScenesWrites = useCallback(() => { + if (!repository) return; + relockScenesWrites(); + repository.setSceneLocking(true); + }, [repository, relockScenesWrites]); + const handleSceneLockingToggle = (next: boolean) => { if (!repository || isReadOnly) return; if (next) { repository.transact(() => { - const currentScreenplay = repository.screenplay; - const scenes = computeSceneItems(currentScreenplay); - const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); - - // Idempotent: any scene that already has a token (e.g. left - // over from an earlier session, or that survived an unlock - // in read-only mode) keeps its frozen label. Only scenes - // computed as provisional by `computeSceneLabels` get a new - // token written. On a fresh lock-on with no existing - // tokens, this falls through to baseToken(idx+1) for every - // scene, matching the previous behaviour. - const persistentSnapshot = repository.scenes; - const labels = computeSceneLabels( - uuids, - persistentSnapshot, - sceneNumberingStyle, - skippedSceneLetters, - ); - - labels.forEach((label) => { - if (label.status === "provisional") { - repository.upsertScene(label.uuid, { token: label.token }); - } - }); - - repository.setSceneLocking(true); + lockScenesWrites(); }); } else { unlockScenesPopup(performUnlock, userCtx); @@ -133,25 +149,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const handleRelock = () => { if (!repository || isReadOnly) return; repository.transact(() => { - const currentScreenplay = repository.screenplay; - const scenes = computeSceneItems(currentScreenplay); - const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); - - // Re-read fresh persistent data - const persistentSnapshot = repository.scenes; - - const currentLabels = computeSceneLabels( - uuids, - persistentSnapshot, - sceneNumberingStyle, - skippedSceneLetters, - ); - - currentLabels.forEach((label) => { - if (label.status === "provisional") { - repository.upsertScene(label.uuid, { token: label.token }); - } - }); + relockScenesWrites(); }); }; @@ -187,37 +185,45 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { }); }, [repository]); + // Writes-only page relock. Idempotent: any anchor that already has a token + // keeps it; only provisional anchors (no token yet) get a freshly-computed + // one. splitOffset is captured alongside the token so the pagination plugin + // can reproduce mid-node splits (straddling dialogues) on recompute instead + // of force-pushing the whole anchor node forward. Must be called inside + // `repository.transact`. + const relockPagesWrites = useCallback(() => { + if (!repository || !editor) return; + const anchorInfos = getPageAnchorInfo(editor); + const anchors = anchorInfos.map((a) => a.anchorId); + const persistentSnapshot = repository.pages; + const currentLabels = computeSceneLabels( + anchors, + persistentSnapshot, + "suffix", + skippedSceneLetters, + ); + currentLabels.forEach((label, idx) => { + if (label.status === "provisional") { + repository.upsertPage(label.uuid, { + token: label.token, + splitOffset: anchorInfos[idx]?.splitOffset, + }); + } + }); + }, [repository, editor, skippedSceneLetters]); + + const lockPagesWrites = useCallback(() => { + if (!repository || !editor) return; + relockPagesWrites(); + repository.setPageLocking(true); + }, [repository, editor, relockPagesWrites]); + const handlePageLockingToggle = (next: boolean) => { if (!repository || isReadOnly) return; if (next) { if (!editor) return; repository.transact(() => { - const anchorInfos = getPageAnchorInfo(editor); - const anchors = anchorInfos.map((a) => a.anchorId); - const persistentSnapshot = repository.pages; - // Idempotent: any anchor that already has a token keeps it. - // Only provisional anchors (no token yet) get a freshly-computed - // one. A fresh lock-on with no existing tokens assigns every - // page baseToken(idx+1) — same shape as scene locking. - const computed = computeSceneLabels( - anchors, - persistentSnapshot, - "suffix", - skippedSceneLetters, - ); - computed.forEach((label, idx) => { - if (label.status === "provisional") { - // splitOffset is captured alongside the token so the - // pagination plugin can reproduce mid-node splits - // (straddling dialogues) on recompute instead of - // force-pushing the whole anchor node forward. - repository.upsertPage(label.uuid, { - token: label.token, - splitOffset: anchorInfos[idx]?.splitOffset, - }); - } - }); - repository.setPageLocking(true); + lockPagesWrites(); }); } else { unlockPagesPopup(performPageUnlock, userCtx); @@ -227,23 +233,49 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const handlePageRelock = () => { if (!repository || isReadOnly || !editor) return; repository.transact(() => { - const anchorInfos = getPageAnchorInfo(editor); - const anchors = anchorInfos.map((a) => a.anchorId); - const persistentSnapshot = repository.pages; - const currentLabels = computeSceneLabels( - anchors, - persistentSnapshot, - "suffix", - skippedSceneLetters, - ); - currentLabels.forEach((label, idx) => { - if (label.status === "provisional") { - repository.upsertPage(label.uuid, { - token: label.token, - splitOffset: anchorInfos[idx]?.splitOffset, - }); - } + relockPagesWrites(); + }); + }; + + // -------------- Draft locking (scenes + pages together) -------------- + // The draft toggle reflects whether *everything* is locked. Turning it on + // locks scenes and pages in a single transaction (each write is idempotent, + // so a partially-locked draft is brought fully in line); turning it off + // clears both via one confirmation popup. + const draftLocking = sceneLocking && pageLocking; + + const hasProvisionalDraft = + (sceneLocking && provisionalLabels.length > 0) || + (pageLocking && provisionalPageLabels.length > 0); + + const performDraftUnlock = useCallback(() => { + if (!repository) return; + repository.transact(() => { + repository.clearSceneLocks(); + repository.setSceneLocking(false); + repository.clearPageLocks(); + repository.setPageLocking(false); + }); + }, [repository]); + + const handleDraftLockingToggle = (next: boolean) => { + if (!repository || isReadOnly) return; + if (next) { + if (!editor) return; + repository.transact(() => { + lockScenesWrites(); + lockPagesWrites(); }); + } else { + unlockDraftPopup(performDraftUnlock, userCtx); + } + }; + + const handleDraftRelock = () => { + if (!repository || isReadOnly) return; + repository.transact(() => { + if (sceneLocking) relockScenesWrites(); + if (pageLocking) relockPagesWrites(); }); }; @@ -253,16 +285,54 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
{t("title")} - +
+ + +
+
+ + {/* Draft Locking (scenes + pages together) */} +
+
+
+ + {t("draftLocking")} +
+
+ {draftLocking && hasProvisionalDraft && ( + + )} + +
+
{/* Scene Locking */}
- + {t("sceneLocking")}
@@ -306,7 +376,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
- + {t("pageLocking")}
diff --git a/components/navbar/ScreenplaySearch.tsx b/components/navbar/ScreenplaySearch.tsx index c91b71ca..b6337444 100644 --- a/components/navbar/ScreenplaySearch.tsx +++ b/components/navbar/ScreenplaySearch.tsx @@ -88,6 +88,23 @@ const ScreenplaySearch = () => { setReplaceValue(""); }, [setSearchTerm]); + // Click outside to close — only when the search field is empty. If there's + // an in-progress search, keep the panel open so it isn't lost on a stray + // click. Reads the live input value (uncontrolled) rather than the debounced + // context term so it stays accurate before the debounce fires. + useEffect(() => { + if (!isOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + if (!inputRef.current?.value) { + handleClose(); + } + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, handleClose]); + // Use uncontrolled input with debounced updates to context const handleSearchChange = useCallback( (e: React.ChangeEvent) => { diff --git a/components/popup/Popup.tsx b/components/popup/Popup.tsx index f1e40989..9555e7a5 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,6 +7,7 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockDraftData, PopupUnlockPagesData, PopupUnlockScenesData, PopupUploadToCloudData, @@ -15,6 +16,7 @@ import { useContext } from "react"; import PopupCharacterItem from "./PopupCharacterItem"; import PopupImportFile from "./PopupImportFile"; import PopupSceneItem from "./PopupSceneItem"; +import PopupUnlockDraft from "./PopupUnlockDraft"; import PopupUnlockPages from "./PopupUnlockPages"; import PopupUnlockScenes from "./PopupUnlockScenes"; import PopupUploadToCloud from "./PopupUploadToCloud"; @@ -38,6 +40,8 @@ export const Popup = () => { return )} />; case PopupType.UnlockPages: return )} />; + case PopupType.UnlockDraft: + return )} />; default: return null; } diff --git a/components/popup/PopupUnlockDraft.tsx b/components/popup/PopupUnlockDraft.tsx new file mode 100644 index 00000000..6cca7f57 --- /dev/null +++ b/components/popup/PopupUnlockDraft.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { X, Unlock } from "lucide-react"; + +import popup from "./Popup.module.css"; + +import { useDraggable } from "@src/lib/utils/hooks"; +import { PopupData, PopupUnlockDraftData, closePopup } from "@src/lib/screenplay/popup"; +import { UserContext } from "@src/context/UserContext"; + +const PopupUnlockDraft = ({ data: { confirmUnlock } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("production"); + + const onConfirm = () => { + confirmUnlock(); + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("unlockDraftTitle")}

+ closePopup(userCtx)} /> +
+
+

{t("unlockDraftWarning")}

+
+
+ + +
+
+
+ ); +}; + +export default PopupUnlockDraft; diff --git a/components/popup/PopupUnlockScenes.tsx b/components/popup/PopupUnlockScenes.tsx index 607a0bf1..dcedfafe 100644 --- a/components/popup/PopupUnlockScenes.tsx +++ b/components/popup/PopupUnlockScenes.tsx @@ -35,7 +35,7 @@ const PopupUnlockScenes = ({ data: { confirmUnlock } }: PopupData closePopup(userCtx)} />
-

{t("unlockWarning")}

+

{t.rich("unlockWarning", { b: (chunks) => {chunks} })}

+
)}
diff --git a/components/project/SplitPanelContainer.tsx b/components/project/SplitPanelContainer.tsx index 33776f47..c2156b81 100644 --- a/components/project/SplitPanelContainer.tsx +++ b/components/project/SplitPanelContainer.tsx @@ -13,6 +13,7 @@ import DragHandle from "./DragHandle"; import { SuggestionData } from "@components/editor/SuggestionMenu"; import { Archive, + ArrowLeftRight, ChevronLeft, ChevronRight, Clapperboard, @@ -82,6 +83,7 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si isSplit, primaryPanel, setSecondaryPanel, + swapPanels, isEndlessScroll, setIsEndlessScroll, showComments, @@ -177,6 +179,17 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si {isSplit ? : } {isSplit ? t("unsplitPanel") : t("splitPanel")} +
{SWITCHABLE_PANELS.map(({ type, icon: Icon, labelKey }) => ( + +
+
+ ) : ( + <> + {node.title} +
+ {isFolder && ( + <> + + + + )} + + +
+ + )} +
+ + {isFolder && + isOpen && + childrenOf(node.id).map((child) => ( + + ))} + + ); +}; + +export default DocumentTreeItem; diff --git a/components/editor/sidebar/DocumentTreeSidebarView.tsx b/components/editor/sidebar/DocumentTreeSidebarView.tsx new file mode 100644 index 00000000..9da6ed06 --- /dev/null +++ b/components/editor/sidebar/DocumentTreeSidebarView.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useCallback, useContext, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { useViewContext } from "@src/context/ViewContext"; +import { DocumentNode } from "@src/lib/project/project-state"; +import { join } from "@src/lib/utils/misc"; +import { FilePlus, FolderPlus, FolderTree, LayoutDashboard } from "lucide-react"; +import DocumentTreeItem, { DropPosition } from "./DocumentTreeItem"; + +import form from "./../../utils/Form.module.css"; +import sidebar_nav from "./EditorSidebarNavigation.module.css"; + +const DocumentTreeSidebarView = () => { + const t = useTranslations("editorSidebar"); + const { documents, repository, activeDocument, setActiveDocument } = useContext(ProjectContext); + const { setSecondaryPanel } = useViewContext(); + + const [expanded, setExpanded] = useState>(new Set()); + const [draggingId, setDraggingId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); + const [rootDrop, setRootDrop] = useState(false); + + // Children of a parent (null = root), sorted by fractional `order`. + const childrenOf = useCallback( + (parentId: string | null): DocumentNode[] => + Object.values(documents) + .filter((n) => (n.parentId ?? null) === (parentId ?? null)) + .sort((a, b) => a.order - b.order), + [documents], + ); + + const roots = useMemo(() => childrenOf(null), [childrenOf]); + + const appendOrder = useCallback( + (parentId: string | null, excludeId?: string) => { + const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); + return kids.length ? kids[kids.length - 1].order + 1 : 0; + }, + [childrenOf], + ); + + const toggle = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const openDocument = useCallback( + (node: DocumentNode) => { + if (node.type === "board") { + setActiveDocument({ docId: node.id, type: "board" }); + setSecondaryPanel("board"); + } else if (node.type === "editor") { + setActiveDocument({ docId: node.id, type: "editor" }); + setSecondaryPanel("document"); + } + }, + [setActiveDocument, setSecondaryPanel], + ); + + const createChild = useCallback( + (parentId: string | null, type: "folder" | "editor") => { + if (!repository) return; + if (type === "folder") repository.createFolder(t("untitledFolder"), parentId); + else repository.createEditorDocument(t("untitledDocument"), parentId); + }, + [repository, t], + ); + + const createBoard = useCallback(() => { + repository?.createBoardDocument(t("boardTitle"), null); + }, [repository, t]); + + const renameDocument = useCallback( + (id: string, title: string) => repository?.renameDocument(id, title), + [repository], + ); + + const deleteDocument = useCallback( + (id: string) => { + if (!repository) return; + // Clear the open document if it (or one of its descendants) is being removed. + const removed = new Set(); + const stack = [id]; + while (stack.length) { + const cur = stack.pop()!; + removed.add(cur); + for (const n of Object.values(documents)) if (n.parentId === cur) stack.push(n.id); + } + if (activeDocument && removed.has(activeDocument.docId)) setActiveDocument(null); + repository.deleteDocument(id); + }, + [repository, documents, activeDocument, setActiveDocument], + ); + + // ---- Drag & drop ---- + const onDragStart = useCallback((id: string) => setDraggingId(id), []); + + const onDragOverNode = useCallback( + (id: string, pos: DropPosition) => { + setRootDrop(false); + setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + }, + [], + ); + + const resetDrag = useCallback(() => { + setDraggingId(null); + setDropTarget(null); + setRootDrop(false); + }, []); + + const onDropNode = useCallback( + (targetId: string) => { + const dragId = draggingId; + const target = documents[targetId]; + const pos = dropTarget?.pos; + resetDrag(); + if (!repository || !dragId || !target || !pos || dragId === targetId) return; + + if (pos === "into") { + repository.moveDocument(dragId, target.id, appendOrder(target.id, dragId)); + setExpanded((prev) => new Set(prev).add(target.id)); + return; + } + + const parentId = target.parentId ?? null; + const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); + const idx = siblings.findIndex((n) => n.id === targetId); + let order: number; + if (pos === "before") { + const prev = siblings[idx - 1]; + order = prev ? (prev.order + target.order) / 2 : target.order - 1; + } else { + const next = siblings[idx + 1]; + order = next ? (target.order + next.order) / 2 : target.order + 1; + } + repository.moveDocument(dragId, parentId, order); + }, + [draggingId, documents, dropTarget, repository, appendOrder, childrenOf, resetDrag], + ); + + const onDropRoot = useCallback(() => { + const dragId = draggingId; + resetDrag(); + if (!repository || !dragId) return; + repository.moveDocument(dragId, null, appendOrder(null, dragId)); + }, [draggingId, repository, appendOrder, resetDrag]); + + return ( + <> +
+ +

{t("documents")}

+
+
+ + + +
+
+
{ + if (!draggingId) return; + e.preventDefault(); + setDropTarget(null); + setRootDrop(true); + }} + onDrop={(e) => { + e.preventDefault(); + onDropRoot(); + }} + > + {roots.length !== 0 ? ( + roots.map((node) => ( + + )) + ) : ( +
{t("documentsEmpty")}
+ )} +
+ + ); +}; + +export default DocumentTreeSidebarView; diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index d10e97fa..bf020344 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -82,6 +82,45 @@ font-size: 1rem; } +/* Header action buttons (e.g. document-tree create buttons) */ +.header_spacer { + flex: 1; +} + +.header_actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + padding-right: 16px; +} + +.header_btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border-radius: 6px; + border: none; + background: none; + color: var(--secondary-text); + cursor: pointer; + transition: + color 0.15s, + background-color 0.15s; +} + +.header_btn:hover { + background-color: var(--editor-sidebar-hover); + color: var(--primary-text); +} + +/* Drop-to-root target highlight */ +.tree_root_drop { + box-shadow: inset 0 0 0 1px var(--primary-text); + border-radius: 8px; +} + .list_fill { min-height: 30px; height: 100%; diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index dae8d2db..a4f5d422 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -8,10 +8,11 @@ import { useViewContext } from "@src/context/ViewContext"; import { Scene } from "@src/lib/screenplay/scenes"; import { focusOnPosition } from "@src/lib/screenplay/editor"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; -import { Archive, Clapperboard, MessageSquare } from "lucide-react"; +import { Archive, Clapperboard, FolderTree, MessageSquare } from "lucide-react"; import SidebarSceneItem from "./SidebarSceneItem"; import ShelfSidebarView from "./ShelfSidebarView"; import CommentSidebarView from "./CommentSidebarView"; +import DocumentTreeSidebarView from "./DocumentTreeSidebarView"; import form from "./../../utils/Form.module.css"; import sidebar_nav from "./EditorSidebarNavigation.module.css"; @@ -29,7 +30,7 @@ const EditorSidebarNavigation = () => { } = useContext(ProjectContext); const { leftSidebarOpen } = useViewContext(); - const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments">("scenes"); + const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments" | "documents">("scenes"); const [dragIndex, setDragIndex] = useState(null); // indicatorIndex represents the gap where the item will be inserted. @@ -256,6 +257,8 @@ const EditorSidebarNavigation = () => { ) : activeTab === "shelf" ? ( + ) : activeTab === "documents" ? ( + ) : ( )} @@ -266,6 +269,12 @@ const EditorSidebarNavigation = () => { > +
+
handleSendToOutline(cardContextMenu.card)} + > + +

{t("sendToOutline")}

+
handleDeleteCard(cardContextMenu.card.id)} diff --git a/components/editor/BoardPanel.tsx b/components/editor/BoardPanel.tsx index 0424e93c..5bf8d9c6 100644 --- a/components/editor/BoardPanel.tsx +++ b/components/editor/BoardPanel.tsx @@ -1,8 +1,6 @@ "use client"; -import { useContext } from "react"; import { useTranslations } from "next-intl"; -import { ProjectContext } from "@src/context/ProjectContext"; import BoardCanvas from "@components/board/BoardCanvas"; import { LayoutDashboard } from "lucide-react"; @@ -20,19 +18,16 @@ const EmptyBoardState = () => { }; /** - * Renders the board document currently selected in the document tree. The - * "board" panel is doc-aware: it reads `activeDocument` and mounts a fresh - * BoardCanvas (keyed by id) for the active board, or an empty state when the - * active document isn't a board. + * Renders the board bound to this panel side (`docId`). Each side carries its + * own docId, so two boards can be open at once. A fresh BoardCanvas is mounted + * per docId; an empty state shows when the side has no document. */ -const BoardPanel = ({ isVisible }: { isVisible: boolean }) => { - const { activeDocument } = useContext(ProjectContext); - - if (!activeDocument || activeDocument.type !== "board") { +const BoardPanel = ({ isVisible, docId }: { isVisible: boolean; docId: string | null }) => { + if (!docId) { return ; } - return ; + return ; }; export default BoardPanel; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 24603169..60a053a6 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -500,6 +500,24 @@ const DocumentEditorPanel = ({ } } + // Detect a scene heading at the caret to offer "Send to outline". + // Independent of `shelving` so it works in editor documents too. + let outlineScene: { refDocId: string; refId: string; title: string } | undefined; + if (config.documentId) { + const $pos = editor.state.doc.resolve(from); + if ($pos.depth >= 1) { + const node = $pos.node(1); + const dataId = node.attrs?.["data-id"] as string | undefined; + if (node.attrs?.class === ScreenplayElement.Scene && dataId) { + outlineScene = { + refDocId: config.documentId, + refId: dataId, + title: node.textContent.toUpperCase(), + }; + } + } + } + const onAddComment = () => { if (!editor) return; const commentId = commentOps.addComment({ @@ -516,10 +534,10 @@ const DocumentEditorPanel = ({ updateContextMenu({ type: ContextMenuType.EditorContextMenu, position: { x: e.clientX, y: e.clientY }, - typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass }, + typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene }, }); }, - [editor, updateContextMenu, commentOps, user, config.features.shelving], + [editor, updateContextMenu, commentOps, user, config.features.shelving, config.documentId], ); // Clear active comment on mousedown diff --git a/components/editor/TreeDocumentPanel.tsx b/components/editor/TreeDocumentPanel.tsx index b0149521..bae21f7b 100644 --- a/components/editor/TreeDocumentPanel.tsx +++ b/components/editor/TreeDocumentPanel.tsx @@ -20,13 +20,10 @@ const EmptyDocumentState = () => { ); }; -const TreeDocumentPanel = ({ isVisible }: { isVisible: boolean }) => { - const { activeDocument, updateDocumentEditor } = useContext(ProjectContext); +const TreeDocumentPanel = ({ isVisible, docId }: { isVisible: boolean; docId: string | null }) => { + const { updateDocumentEditor } = useContext(ProjectContext); - const config = useMemo(() => { - if (!activeDocument || activeDocument.type !== "editor") return null; - return createDocumentTreeConfig(activeDocument.docId); - }, [activeDocument]); + const config = useMemo(() => (docId ? createDocumentTreeConfig(docId) : null), [docId]); const handleEditorCreated = useCallback( (editor: import("@tiptap/react").Editor | null) => { @@ -35,13 +32,13 @@ const TreeDocumentPanel = ({ isVisible }: { isVisible: boolean }) => { [updateDocumentEditor], ); - if (!config || !activeDocument || activeDocument.type !== "editor") { + if (!config || !docId) { return ; } return ( OutlineItemType[]; + resolved: Record; + onNavigate: (node: OutlineItemType) => void; + onRemove: (id: string) => void; + // Drag & drop + draggingId: string | null; + dropTarget: { id: string; pos: DropPosition } | null; + onDragStart: (id: string) => void; + onDragOverNode: (id: string, pos: DropPosition) => void; + onDropNode: (id: string) => void; + onDragEnd: () => void; +} + +const INDENT = 36; + +const OutlineItem = ({ + node, + depth, + childrenOf, + resolved, + onNavigate, + onRemove, + draggingId, + dropTarget, + onDragStart, + onDragOverNode, + onDropNode, + onDragEnd, +}: OutlineItemProps) => { + const t = useTranslations("outline"); + + const r = resolved[node.id] ?? { title: node.title, preview: node.preview, color: node.color, missing: true }; + const isCard = node.source === "card"; + const children = childrenOf(node.id); + + // Every block can nest children, so use folder-style drop thresholds: + // top 25% = before, middle 50% = into, bottom 25% = after. + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!draggingId || draggingId === node.id) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + let pos: DropPosition; + if (y < rect.height * 0.25) pos = "before"; + else if (y > rect.height * 0.75) pos = "after"; + else pos = "into"; + onDragOverNode(node.id, pos); + }, + [draggingId, node.id, onDragOverNode], + ); + + const isDropTarget = dropTarget?.id === node.id; + const blockClass = join( + styles.block, + r.missing ? styles.block_missing : "", + isDropTarget && dropTarget?.pos === "into" ? styles.block_drop_into : "", + isDropTarget && dropTarget?.pos === "before" ? styles.block_drop_before : "", + isDropTarget && dropTarget?.pos === "after" ? styles.block_drop_after : "", + ); + + return ( + <> +
+
onNavigate(node)} + draggable + onDragStart={(e) => { + e.dataTransfer.effectAllowed = "move"; + onDragStart(node.id); + }} + onDragOver={handleDragOver} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + onDropNode(node.id); + }} + onDragEnd={onDragEnd} + > + {isCard && node.color && ( + + )} +
+
+ {!isCard && } + {r.title || t("untitled")} + {r.missing && ( + + + {t("unlinked")} + + )} + +
+ {r.preview &&

{r.preview}

} +
+
+
+ + {children.map((child) => ( + + ))} + + ); +}; + +export default OutlineItem; diff --git a/components/editor/outline/OutlinePanel.tsx b/components/editor/outline/OutlinePanel.tsx new file mode 100644 index 00000000..fdd99f74 --- /dev/null +++ b/components/editor/outline/OutlinePanel.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { ProjectContext } from "@src/context/ProjectContext"; +import OutlineView from "./OutlineView"; +import { ListTree } from "lucide-react"; + +import styles from "../EditorPanel.module.css"; + +const EmptyOutlineState = () => { + const t = useTranslations("outline"); + + return ( +
+ +

{t("empty")}

+
+ ); +}; + +/** + * The Outline view: a project-wide, ordered, nestable list of blocks that + * reference scene headings and board cards. Writers reorder/indent blocks to + * sequence their story beats. Shows an empty state until something is sent here. + */ +const OutlinePanel = ({ isVisible }: { isVisible: boolean }) => { + const { outline } = useContext(ProjectContext); + + if (Object.keys(outline).length === 0) { + return ; + } + + return ; +}; + +export default OutlinePanel; diff --git a/components/editor/outline/OutlineView.module.css b/components/editor/outline/OutlineView.module.css new file mode 100644 index 00000000..33758909 --- /dev/null +++ b/components/editor/outline/OutlineView.module.css @@ -0,0 +1,28 @@ +.outline_panel { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background-color: var(--main-bg); +} + +.outline_scroll { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: clip; + scrollbar-gutter: stable; +} + +.outline_list { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: 16px; + box-sizing: border-box; +} diff --git a/components/editor/outline/OutlineView.tsx b/components/editor/outline/OutlineView.tsx new file mode 100644 index 00000000..ea245791 --- /dev/null +++ b/components/editor/outline/OutlineView.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { SplitSide, useViewContext } from "@src/context/ViewContext"; +import { BoardCardData, MAIN_SCREENPLAY_REF, OutlineItem } from "@src/lib/project/project-state"; +import { TransientScene } from "@src/lib/screenplay/scenes"; +import { focusOnPosition } from "@src/lib/screenplay/editor"; +import OutlineBlock, { DropPosition } from "./OutlineItem"; + +import styles from "./OutlineView.module.css"; + +/** Live-resolved display data for an outline block. */ +export type ResolvedOutline = { + title: string; + preview: string; + color?: string; + /** True when the referenced source element no longer exists. */ + missing: boolean; + /** Editor position of a resolved scene (for navigation). */ + position?: number; +}; + +const PREVIEW_MAX = 80; + +const truncate = (text: string) => { + const clean = text.replace(/\s+/g, " ").trim(); + return clean.length > PREVIEW_MAX ? clean.slice(0, PREVIEW_MAX).trimEnd() + "…" : clean; +}; + +const parseCards = (raw: unknown): BoardCardData[] => { + if (!raw) return []; + try { + return typeof raw === "string" ? (JSON.parse(raw) as BoardCardData[]) : (raw as BoardCardData[]); + } catch { + return []; + } +}; + +const OutlineView = ({ isVisible }: { isVisible: boolean }) => { + const { outline, repository, scenes, editor, documentEditor, setBoardFocusCardId } = + useContext(ProjectContext); + const { isSplit, primaryPanel, setSidePanel, setFocusedSide, setSideDocument } = useViewContext(); + const projectState = repository?.getState(); + + const [draggingId, setDraggingId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); + + // Children of a parent (null = root), sorted by fractional `order`. + const childrenOf = useCallback( + (parentId: string | null): OutlineItem[] => + Object.values(outline) + .filter((n) => (n.parentId ?? null) === (parentId ?? null)) + .sort((a, b) => a.order - b.order), + [outline], + ); + + const roots = useMemo(() => childrenOf(null), [childrenOf]); + + const appendOrder = useCallback( + (parentId: string | null, excludeId?: string) => { + const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); + return kids.length ? kids[kids.length - 1].order + 1 : 0; + }, + [childrenOf], + ); + + // ---- Live resolution of references ---- + + // Distinct source documents referenced by current blocks. + const cardBoardIds = useMemo( + () => [...new Set(Object.values(outline).filter((i) => i.source === "card").map((i) => i.refDocId))], + [outline], + ); + const editorDocIds = useMemo( + () => + [ + ...new Set( + Object.values(outline) + .filter((i) => i.source === "scene" && i.refDocId !== MAIN_SCREENPLAY_REF) + .map((i) => i.refDocId), + ), + ], + [outline], + ); + + // Bump a counter whenever a referenced board / editor-doc changes, so the + // resolver recomputes. Main-screenplay scenes come from `scenes` (reactive). + const [sourceVersion, setSourceVersion] = useState(0); + const cardKey = cardBoardIds.join(","); + const editorKey = editorDocIds.join(","); + useEffect(() => { + if (!projectState) return; + const bump = () => setSourceVersion((v) => v + 1); + const unsubs: (() => void)[] = []; + for (const id of cardBoardIds) { + const map = projectState.boardData(id); + map.observe(bump); + unsubs.push(() => map.unobserve(bump)); + } + for (const id of editorDocIds) { + const frag = projectState.documentFragment(id); + frag.observe(bump); + unsubs.push(() => frag.unobserve(bump)); + } + return () => unsubs.forEach((u) => u()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectState, cardKey, editorKey]); + + const resolved = useMemo>(() => { + const out: Record = {}; + + // Lookups for the main screenplay scenes (already live via context). + const mainScenes = new Map(scenes.filter((s) => s.id).map((s) => [s.id as string, s])); + + // Parse each referenced editor document's scenes once. + const editorScenes = new Map>(); + for (const id of editorDocIds) { + const list = repository?.getEditorDocumentScenes(id) ?? []; + editorScenes.set(id, new Map(list.filter((s) => s.id).map((s) => [s.id as string, s]))); + } + + // Parse each referenced board's cards once. + const boardCards = new Map>(); + for (const id of cardBoardIds) { + const cards = parseCards(projectState?.boardData(id).get("cards")); + boardCards.set(id, new Map(cards.map((c) => [c.id, c]))); + } + + for (const item of Object.values(outline)) { + if (item.source === "card") { + const card = boardCards.get(item.refDocId)?.get(item.refId); + out[item.id] = card + ? { title: card.title, preview: truncate(card.description), color: card.color, missing: false } + : { title: item.title, preview: item.preview, color: item.color, missing: true }; + } else { + const scene = + item.refDocId === MAIN_SCREENPLAY_REF + ? mainScenes.get(item.refId) + : editorScenes.get(item.refDocId)?.get(item.refId); + if (scene) { + const synopsis = "synopsis" in scene ? (scene.synopsis as string | undefined) : undefined; + const color = "color" in scene ? (scene.color as string | undefined) : undefined; + out[item.id] = { + title: scene.title, + preview: truncate(synopsis || scene.preview), + color, + missing: false, + position: scene.position, + }; + } else { + out[item.id] = { title: item.title, preview: item.preview, color: item.color, missing: true }; + } + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [outline, scenes, sourceVersion, cardKey, editorKey, repository, projectState]); + + // Keep each block's cached snapshot fresh so the "unlinked" fallback shows + // the last-known title/preview/color. Only writes when a resolved value + // actually changed (and the source still exists), so it settles quickly. + // Gated on visibility to avoid background writes for an unseen panel. + useEffect(() => { + if (!repository || !isVisible) return; + for (const item of Object.values(outline)) { + const r = resolved[item.id]; + if (!r || r.missing) continue; + if (r.title !== item.title || r.preview !== item.preview || (r.color ?? undefined) !== (item.color ?? undefined)) { + repository.refreshOutlineSnapshot(item.id, { title: r.title, preview: r.preview, color: r.color }); + } + } + }, [repository, outline, resolved, isVisible]); + + // ---- Navigation ---- + + // Best-effort focus of a freshly opened document editor needs the latest + // instance, so track it in a ref. + const documentEditorRef = useRef(documentEditor); + documentEditorRef.current = documentEditor; + + const navigate = useCallback( + (item: OutlineItem) => { + const r = resolved[item.id]; + if (!r || r.missing) return; + + // Open in the panel next to the Outline, leaving the Outline in place. + // When unsplit there's only one panel, so the target takes it over. + const targetSide: SplitSide = isSplit ? (primaryPanel === "outline" ? "secondary" : "primary") : "primary"; + + if (item.source === "card") { + setSideDocument(targetSide, item.refDocId, "board"); + setBoardFocusCardId(item.refId); + return; + } + + // Scene + if (item.refDocId === MAIN_SCREENPLAY_REF) { + setSidePanel(targetSide, "screenplay"); + setFocusedSide(targetSide); + if (editor && r.position !== undefined) focusOnPosition(editor, r.position); + return; + } + + // Scene inside an editor document. + setSideDocument(targetSide, item.refDocId, "document"); + if (r.position !== undefined) { + const pos = r.position; + window.setTimeout(() => { + const ed = documentEditorRef.current; + if (ed) focusOnPosition(ed, pos); + }, 350); + } + }, + [resolved, editor, isSplit, primaryPanel, setSideDocument, setSidePanel, setFocusedSide, setBoardFocusCardId], + ); + + const remove = useCallback( + (id: string) => { + repository?.deleteOutlineItem(id); + }, + [repository], + ); + + // ---- Drag & drop (mirrors DocumentTreeSidebarView) ---- + + const onDragStart = useCallback((id: string) => setDraggingId(id), []); + + const onDragOverNode = useCallback((id: string, pos: DropPosition) => { + setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + }, []); + + const resetDrag = useCallback(() => { + setDraggingId(null); + setDropTarget(null); + }, []); + + const onDropNode = useCallback( + (targetId: string) => { + const dragId = draggingId; + const target = outline[targetId]; + const pos = dropTarget?.pos; + resetDrag(); + if (!repository || !dragId || !target || !pos || dragId === targetId) return; + + if (pos === "into") { + repository.moveOutlineItem(dragId, target.id, appendOrder(target.id, dragId)); + return; + } + + const parentId = target.parentId ?? null; + const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); + const idx = siblings.findIndex((n) => n.id === targetId); + let order: number; + if (pos === "before") { + const prev = siblings[idx - 1]; + order = prev ? (prev.order + target.order) / 2 : target.order - 1; + } else { + const next = siblings[idx + 1]; + order = next ? (target.order + next.order) / 2 : target.order + 1; + } + repository.moveOutlineItem(dragId, parentId, order); + }, + [draggingId, outline, dropTarget, repository, appendOrder, childrenOf, resetDrag], + ); + + const onDropRoot = useCallback(() => { + const dragId = draggingId; + resetDrag(); + if (!repository || !dragId) return; + repository.moveOutlineItem(dragId, null, appendOrder(null, dragId)); + }, [draggingId, repository, appendOrder, resetDrag]); + + return ( +
+
{ + if (!draggingId) return; + // Over empty space (not a block): clear the block indicator; + // dropping here still moves the item to the root level. + e.preventDefault(); + setDropTarget(null); + }} + onDrop={(e) => { + e.preventDefault(); + onDropRoot(); + }} + > +
+ {roots.map((node) => ( + + ))} +
+
+
+ ); +}; + +export default OutlineView; diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index fa554e32..3911f80a 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -56,6 +56,35 @@ margin: 2px 0; } +/* Right-click color picker (document tree items) */ +.colors { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 12px; +} + +.color_swatch { + width: 20px; + height: 20px; + padding: 0; + border: 2px solid transparent; + border-radius: 50%; + cursor: pointer; + transition: + transform 0.15s ease, + border-color 0.15s ease; +} + +.color_swatch:hover { + transform: scale(1.15); +} + +.color_swatch_active { + border-color: var(--primary-text); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); +} + .suggestion_item { display: flex; align-items: center; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 387eb583..b2ffe784 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -24,6 +24,7 @@ import { EyeOff, Eye, Highlighter, + ListTree, Loader2, LucideIcon, MessageSquarePlus, @@ -36,6 +37,7 @@ import { makeDualDialogue } from "@src/lib/screenplay/dual-dialogue"; import { extractShelveCandidate } from "@src/lib/shelf/shelf-utils"; import { omitSceneByUuid, unomitSceneByUuid } from "@src/lib/screenplay/scene-locking"; import { ScreenplayElement } from "@src/lib/utils/enums"; +import { MAIN_SCREENPLAY_REF } from "@src/lib/project/project-state"; /* ==================== */ /* Context menu */ @@ -126,6 +128,19 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { unomitSceneByUuid(editor, repository, scene.id); }; + const handleSendToOutline = () => { + if (!repository || !scene.id) return; + repository.addOutlineItem({ + source: "scene", + refDocId: MAIN_SCREENPLAY_REF, + refId: scene.id, + title: scene.title, + preview: scene.synopsis || scene.preview, + color: scene.color, + parentId: null, + }); + }; + return ( <> ) => { {!isReadOnly && ( <>
+ {scene.id && ( + + )} editScenePopup(scene, userCtx)} /> ) => { @@ -482,9 +506,22 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); - const { from, to, onAddComment, spellError, nodePos, nodeClass } = props; + const { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene } = props; const hasSelection = from !== to; + const handleSendToOutline = () => { + if (!repository || !outlineScene) return; + repository.addOutlineItem({ + source: "scene", + refDocId: outlineScene.refDocId, + refId: outlineScene.refId, + title: outlineScene.title, + preview: "", + parentId: null, + }); + updateContextMenu(undefined); + }; + // Resolve scene UUID + lock state if right-clicked on a scene heading. // `nodePos` is the cursor position inside the paragraph (from the editor // dispatcher), so we resolve up to the depth-1 ancestor — the scene

@@ -682,6 +719,14 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} + {/* Send a scene heading to the Outline */} + {outlineScene && !isReadOnly && ( + <> +

+ + + )} + {/* Production: Omit / Unomit on a locked scene heading */} {sceneInfo && !isReadOnly && ( <> diff --git a/components/editor/sidebar/DocumentTreeItem.module.css b/components/editor/sidebar/DocumentTreeItem.module.css index 58f2e07d..85223270 100644 --- a/components/editor/sidebar/DocumentTreeItem.module.css +++ b/components/editor/sidebar/DocumentTreeItem.module.css @@ -24,10 +24,21 @@ background-color: var(--editor-sidebar-hover); } +/* Slim accent bar on the left edge showing the node's color. */ +.color_bar { + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + border-radius: 0 3px 3px 0; + pointer-events: none; +} + /* Drop indicators */ .row_drop_into { background-color: var(--editor-style-bg-hover); - box-shadow: inset 0 0 0 1px var(--primary-text); + box-shadow: inset 0 0 0 1px var(--tertiary-hover); } .row_drop_before::before, @@ -37,16 +48,19 @@ left: 8px; right: 8px; height: 2px; - background-color: var(--primary-text); + background-color: var(--tertiary-hover); pointer-events: none; } +/* Centered on the row boundary so "after A" and "before B" (the same gap, which + the cursor alternates between) render at the exact same position — one line, + not two offset by a pixel. */ .row_drop_before::before { - top: 0; + top: -1px; } .row_drop_after::after { - bottom: 0; + bottom: -1px; } .chevron { diff --git a/components/editor/sidebar/DocumentTreeItem.tsx b/components/editor/sidebar/DocumentTreeItem.tsx index 35ba2cc0..4960f132 100644 --- a/components/editor/sidebar/DocumentTreeItem.tsx +++ b/components/editor/sidebar/DocumentTreeItem.tsx @@ -1,19 +1,10 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { DocumentNode, DocumentNodeType } from "@src/lib/project/project-state"; import { join } from "@src/lib/utils/misc"; -import { - ChevronRight, - FilePlus, - FileText, - Folder, - FolderPlus, - LayoutDashboard, - Pencil, - Trash2, -} from "lucide-react"; +import { ChevronRight, FileText, Folder, LayoutDashboard } from "lucide-react"; import styles from "./DocumentTreeItem.module.css"; @@ -25,17 +16,30 @@ const TYPE_ICONS: Record = { export type DropPosition = "into" | "before" | "after"; +/** + * dataTransfer MIME used when dragging an `editor`/`board` document out of the + * tree onto a panel to open it there. Reordering within the tree uses internal + * React state, so this only matters for cross-target (panel) drops. + */ +export const DOC_DND_MIME = "application/x-scriptio-doc"; + export interface DocumentTreeItemProps { node: DocumentNode; depth: number; childrenOf: (parentId: string) => DocumentNode[]; expanded: Set; onToggle: (id: string) => void; - activeDocId: string | null; + openDocIds: Set; onOpen: (node: DocumentNode) => void; - onCreateChild: (parentId: string, type: "folder" | "editor") => void; - onRename: (id: string, title: string) => void; - onDelete: (id: string) => void; + onContextMenu: (node: DocumentNode, e: React.MouseEvent) => void; + // Inline rename (state lifted to the view so the context menu can trigger it). + renamingId: string | null; + onRenameCommit: (id: string, title: string) => void; + onRenameCancel: () => void; + // Inline delete confirmation (state lifted to the view). + confirmingDeleteId: string | null; + onConfirmDelete: (id: string) => void; + onCancelDelete: () => void; // Drag & drop draggingId: string | null; dropTarget: { id: string; pos: DropPosition } | null; @@ -53,11 +57,15 @@ const DocumentTreeItem = ({ childrenOf, expanded, onToggle, - activeDocId, + openDocIds, onOpen, - onCreateChild, - onRename, - onDelete, + onContextMenu, + renamingId, + onRenameCommit, + onRenameCancel, + confirmingDeleteId, + onConfirmDelete, + onCancelDelete, draggingId, dropTarget, onDragStart, @@ -66,15 +74,14 @@ const DocumentTreeItem = ({ onDragEnd, }: DocumentTreeItemProps) => { const t = useTranslations("editorSidebar"); - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(""); - const [confirmingDelete, setConfirmingDelete] = useState(false); const renameInputRef = useRef(null); const Icon = TYPE_ICONS[node.type]; const isFolder = node.type === "folder"; const isOpen = expanded.has(node.id); - const isActive = node.type === "editor" && activeDocId === node.id; + const isActive = openDocIds.has(node.id); + const isRenaming = renamingId === node.id; + const confirmingDelete = confirmingDeleteId === node.id; const busy = isRenaming || confirmingDelete; const handleRowClick = useCallback(() => { @@ -83,21 +90,9 @@ const DocumentTreeItem = ({ else onOpen(node); }, [busy, isFolder, node, onToggle, onOpen]); - const startRename = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - setRenameValue(node.title); - setIsRenaming(true); - setTimeout(() => renameInputRef.current?.select(), 0); - }, - [node.title], - ); - const commitRename = useCallback(() => { - const trimmed = renameValue.trim(); - if (trimmed) onRename(node.id, trimmed); - setIsRenaming(false); - }, [renameValue, node.id, onRename]); + onRenameCommit(node.id, (renameInputRef.current?.value ?? "").trim()); + }, [node.id, onRenameCommit]); const handleDragOver = useCallback( (e: React.DragEvent) => { @@ -134,9 +129,19 @@ const DocumentTreeItem = ({ className={rowClass} style={{ paddingLeft: 12 + depth * INDENT, opacity: draggingId === node.id ? 0.4 : 1 }} onClick={handleRowClick} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(node, e); + }} draggable={!busy} onDragStart={(e) => { - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; + // Openable documents carry their id/type so they can be + // dropped onto a panel to open there (folders cannot). + if (node.type === "editor" || node.type === "board") { + e.dataTransfer.setData(DOC_DND_MIME, JSON.stringify({ id: node.id, type: node.type })); + } onDragStart(node.id); }} onDragOver={handleDragOver} @@ -147,6 +152,7 @@ const DocumentTreeItem = ({ }} onDragEnd={onDragEnd} > + {node.color && } {isFolder ? ( setRenameValue(e.target.value)} + autoFocus + onFocus={(e) => e.currentTarget.select()} onBlur={commitRename} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Enter") commitRename(); - else if (e.key === "Escape") setIsRenaming(false); + else if (e.key === "Escape") onRenameCancel(); }} /> ) : confirmingDelete ? ( @@ -178,65 +185,16 @@ const DocumentTreeItem = ({ {isFolder ? t("confirmDeleteFolder") : t("confirmDelete")}
- -
) : ( - <> - {node.title} -
- {isFolder && ( - <> - - - - )} - - -
- + {node.title} )}
@@ -250,11 +208,15 @@ const DocumentTreeItem = ({ childrenOf={childrenOf} expanded={expanded} onToggle={onToggle} - activeDocId={activeDocId} + openDocIds={openDocIds} onOpen={onOpen} - onCreateChild={onCreateChild} - onRename={onRename} - onDelete={onDelete} + onContextMenu={onContextMenu} + renamingId={renamingId} + onRenameCommit={onRenameCommit} + onRenameCancel={onRenameCancel} + confirmingDeleteId={confirmingDeleteId} + onConfirmDelete={onConfirmDelete} + onCancelDelete={onCancelDelete} draggingId={draggingId} dropTarget={dropTarget} onDragStart={onDragStart} diff --git a/components/editor/sidebar/DocumentTreeSidebarView.tsx b/components/editor/sidebar/DocumentTreeSidebarView.tsx index 9da6ed06..c1a4de20 100644 --- a/components/editor/sidebar/DocumentTreeSidebarView.tsx +++ b/components/editor/sidebar/DocumentTreeSidebarView.tsx @@ -1,26 +1,41 @@ "use client"; -import { useCallback, useContext, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { useViewContext } from "@src/context/ViewContext"; import { DocumentNode } from "@src/lib/project/project-state"; +import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; import { join } from "@src/lib/utils/misc"; -import { FilePlus, FolderPlus, FolderTree, LayoutDashboard } from "lucide-react"; +import { FilePlus, FolderPlus, FolderTree, LayoutDashboard, Pencil, Trash2 } from "lucide-react"; import DocumentTreeItem, { DropPosition } from "./DocumentTreeItem"; +import { ContextMenuItem } from "./ContextMenu"; import form from "./../../utils/Form.module.css"; import sidebar_nav from "./EditorSidebarNavigation.module.css"; +import context from "./ContextMenu.module.css"; + +type MenuState = { x: number; y: number; node: DocumentNode | null }; const DocumentTreeSidebarView = () => { const t = useTranslations("editorSidebar"); - const { documents, repository, activeDocument, setActiveDocument } = useContext(ProjectContext); - const { setSecondaryPanel } = useViewContext(); + const { documents, repository } = useContext(ProjectContext); + const { setSideDocument, closeDocument, primaryDocId, secondaryDocId } = useViewContext(); + + // Documents currently open in a panel — highlighted in the tree. + const openDocIds = useMemo( + () => new Set([primaryDocId, secondaryDocId].filter((id): id is string => !!id)), + [primaryDocId, secondaryDocId], + ); const [expanded, setExpanded] = useState>(new Set()); const [draggingId, setDraggingId] = useState(null); const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); - const [rootDrop, setRootDrop] = useState(false); + + // Right-click menu + inline edit state (lifted here so the menu can drive it). + const [menu, setMenu] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); // Children of a parent (null = root), sorted by fractional `order`. const childrenOf = useCallback( @@ -52,39 +67,27 @@ const DocumentTreeSidebarView = () => { const openDocument = useCallback( (node: DocumentNode) => { - if (node.type === "board") { - setActiveDocument({ docId: node.id, type: "board" }); - setSecondaryPanel("board"); - } else if (node.type === "editor") { - setActiveDocument({ docId: node.id, type: "editor" }); - setSecondaryPanel("document"); - } + if (node.type === "board") setSideDocument("secondary", node.id, "board"); + else if (node.type === "editor") setSideDocument("secondary", node.id, "document"); }, - [setActiveDocument, setSecondaryPanel], + [setSideDocument], ); - const createChild = useCallback( - (parentId: string | null, type: "folder" | "editor") => { + const createInside = useCallback( + (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); }, [repository, t], ); - const createBoard = useCallback(() => { - repository?.createBoardDocument(t("boardTitle"), null); - }, [repository, t]); - - const renameDocument = useCallback( - (id: string, title: string) => repository?.renameDocument(id, title), - [repository], - ); - const deleteDocument = useCallback( (id: string) => { if (!repository) return; - // Clear the open document if it (or one of its descendants) is being removed. + // Clear the open document if it (or a descendant) is being removed. const removed = new Set(); const stack = [id]; while (stack.length) { @@ -92,27 +95,66 @@ const DocumentTreeSidebarView = () => { removed.add(cur); for (const n of Object.values(documents)) if (n.parentId === cur) stack.push(n.id); } - if (activeDocument && removed.has(activeDocument.docId)) setActiveDocument(null); + removed.forEach((rid) => closeDocument(rid)); repository.deleteDocument(id); + setConfirmingDeleteId(null); }, - [repository, documents, activeDocument, setActiveDocument], + [repository, documents, closeDocument], ); - // ---- Drag & drop ---- - const onDragStart = useCallback((id: string) => setDraggingId(id), []); + const commitRename = useCallback( + (id: string, title: string) => { + if (title) repository?.renameDocument(id, title); + setRenamingId(null); + }, + [repository], + ); - const onDragOverNode = useCallback( - (id: string, pos: DropPosition) => { - setRootDrop(false); - setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + // Right-click color picker. Clicking the active color again clears it. + const setColor = useCallback( + (id: string, color: string) => { + const current = documents[id]?.color; + repository?.setDocumentColor(id, current === color ? undefined : color); + setMenu(null); }, - [], + [repository, documents], ); + // ---- Right-click menu ---- + const openMenu = useCallback((node: DocumentNode | null, e: React.MouseEvent) => { + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, node }); + }, []); + + // Close the menu on any left-click, scroll, resize, or Escape. + useEffect(() => { + if (!menu) return; + const close = () => setMenu(null); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenu(null); + }; + window.addEventListener("click", close); + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + window.removeEventListener("keydown", onKey); + }; + }, [menu]); + + // ---- Drag & drop ---- + const onDragStart = useCallback((id: string) => setDraggingId(id), []); + + const onDragOverNode = useCallback((id: string, pos: DropPosition) => { + setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + }, []); + const resetDrag = useCallback(() => { setDraggingId(null); setDropTarget(null); - setRootDrop(false); }, []); const onDropNode = useCallback( @@ -152,39 +194,89 @@ const DocumentTreeSidebarView = () => { repository.moveDocument(dragId, null, appendOrder(null, dragId)); }, [draggingId, repository, appendOrder, resetDrag]); + const renderMenu = () => { + if (!menu) return null; + const node = menu.node; + const left = Math.min(menu.x, window.innerWidth - 230); + const top = Math.min(menu.y, window.innerHeight - 220); + // Where create actions should put new nodes: inside a right-clicked + // folder, otherwise at the root. + const parentId = node?.type === "folder" ? node.id : null; + const showCreate = !node || node.type === "folder"; + const showEdit = !!node; + + return ( +
e.preventDefault()} + > + {showCreate && ( + <> + createInside(parentId, "editor")} + /> + createInside(parentId, "folder")} + /> + createInside(parentId, "board")} + /> + + )} + {showCreate && showEdit &&
} + {showEdit && node && ( + <> +
+ {DEFAULT_ITEM_COLORS.map((color) => ( +
+ setRenamingId(node.id)} + /> + setConfirmingDeleteId(node.id)} + /> + + )} +
+ ); + }; + return ( <>

{t("documents")}

-
-
- - - -
openMenu(null, e)} onDragOver={(e) => { if (!draggingId) return; + // Over empty list space (not a row): clear the row indicator; + // dropping here still moves the item to the root level. e.preventDefault(); setDropTarget(null); - setRootDrop(true); }} onDrop={(e) => { e.preventDefault(); @@ -200,11 +292,15 @@ const DocumentTreeSidebarView = () => { childrenOf={childrenOf} expanded={expanded} onToggle={toggle} - activeDocId={activeDocument?.docId ?? null} + openDocIds={openDocIds} onOpen={openDocument} - onCreateChild={createChild} - onRename={renameDocument} - onDelete={deleteDocument} + onContextMenu={openMenu} + renamingId={renamingId} + onRenameCommit={commitRename} + onRenameCancel={() => setRenamingId(null)} + confirmingDeleteId={confirmingDeleteId} + onConfirmDelete={deleteDocument} + onCancelDelete={() => setConfirmingDeleteId(null)} draggingId={draggingId} dropTarget={dropTarget} onDragStart={onDragStart} @@ -217,6 +313,7 @@ const DocumentTreeSidebarView = () => {
{t("documentsEmpty")}
)}
+ {renderMenu()} ); }; diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index bf020344..d10e97fa 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -82,45 +82,6 @@ font-size: 1rem; } -/* Header action buttons (e.g. document-tree create buttons) */ -.header_spacer { - flex: 1; -} - -.header_actions { - display: flex; - flex-direction: row; - align-items: center; - gap: 2px; - padding-right: 16px; -} - -.header_btn { - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - border-radius: 6px; - border: none; - background: none; - color: var(--secondary-text); - cursor: pointer; - transition: - color 0.15s, - background-color 0.15s; -} - -.header_btn:hover { - background-color: var(--editor-sidebar-hover); - color: var(--primary-text); -} - -/* Drop-to-root target highlight */ -.tree_root_drop { - box-shadow: inset 0 0 0 1px var(--primary-text); - border-radius: 8px; -} - .list_fill { min-height: 30px; height: 100%; diff --git a/components/editor/sidebar/SidebarItem.module.css b/components/editor/sidebar/SidebarItem.module.css index c4e9b35d..55e1d4aa 100644 --- a/components/editor/sidebar/SidebarItem.module.css +++ b/components/editor/sidebar/SidebarItem.module.css @@ -1,4 +1,5 @@ .container { + position: relative; display: flex; flex-direction: column; justify-content: space-between; @@ -17,6 +18,17 @@ border-bottom: 1px solid var(--separator); } +/* Slim accent bar on the left edge showing the scene's color. */ +.color_bar { + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + border-radius: 0 3px 3px 0; + pointer-events: none; +} + .data { display: flex; flex-direction: row; diff --git a/components/editor/sidebar/SidebarSceneItem.tsx b/components/editor/sidebar/SidebarSceneItem.tsx index 50742965..feba9a69 100644 --- a/components/editor/sidebar/SidebarSceneItem.tsx +++ b/components/editor/sidebar/SidebarSceneItem.tsx @@ -64,11 +64,9 @@ const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, is onDoubleClick={handleDoubleClick} className={containerClass} > + {scene.color && }
- {scene.color && ( - - )}

{label}. {titleText}

diff --git a/components/navbar/ViewOptionsDropdown.tsx b/components/navbar/ViewOptionsDropdown.tsx index a0aa77b4..4684915c 100644 --- a/components/navbar/ViewOptionsDropdown.tsx +++ b/components/navbar/ViewOptionsDropdown.tsx @@ -15,6 +15,7 @@ import { PanelRightClose, ArrowLeftRight, Eye, + ListTree, } from "lucide-react"; import styles from "./ViewOptionsDropdown.module.css"; @@ -33,16 +34,17 @@ const ViewOptionsDropdown = () => { primaryPanel, setSecondaryPanel, swapPanels, + focusedPanel, + setFocusedPanel, } = useViewContext(); const handleSplitToggle = useCallback(() => { if (isSplit) { setSecondaryPanel(null); } else { - const other: PanelType = - primaryPanel === "screenplay" ? "board" - : primaryPanel === "title" ? "screenplay" - : "screenplay"; + // Default the new side to a singleton view (documents need a docId, + // which only opening from the sidebar/outline provides). + const other: PanelType = primaryPanel === "screenplay" ? "title" : "screenplay"; setSecondaryPanel(other); } }, [isSplit, primaryPanel, setSecondaryPanel]); @@ -112,6 +114,16 @@ const ViewOptionsDropdown = () => { {isOpen && (
+ {Math.round(scale * 100)}%
diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 60a053a6..0ebdd658 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 4960f132..0c5ceaea 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 d10e97fa..30118f7a 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 0de9f091..db741973 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 b2297d47..d00b815e 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 4684915c..78e77d81 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 de55bfe5..05aa309a 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 7911d9c7..ef54dcf8 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 6ae9698c..550b88d3 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 00000000..46b4f7ba --- /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 00000000..2fa0dfdc --- /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 20a69cd3..5cd39367 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 6dcdfedc..c72afd3c 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 449d2c74..d212320a 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 7e11c07b..df70cb12 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 039fc425..02b93ce6 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 9b4336b2..8d464cfc 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 f5bf623a..d91a6f95 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 d4153ccb..3bd3101e 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 6dd24902..5229e0c0 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 520f3e8a..ef08bbea 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 2c7ae64d..9e2d0fe7 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; } From a622dc8456f0bd58e71b6337a5742d31f1bae99c Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Wed, 10 Jun 2026 17:27:18 +0200 Subject: [PATCH 65/76] asset handling wip for boards, added advanced section to production panel --- components/board/BoardCanvas.module.css | 29 +++ components/board/BoardCanvas.tsx | 123 ++++++++++-- components/board/BoardCard.tsx | 109 +++++----- components/navbar/ProductionPanel.module.css | 102 ++++++++++ components/navbar/ProductionPanel.tsx | 87 ++++---- components/project/WritingTimer.tsx | 2 +- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/ja.json | 2 + messages/ko.json | 2 + messages/pl.json | 2 + messages/zh.json | 2 + src/context/ProjectContext.tsx | 9 + src/lib/adapters/scriptio/scriptio-adapter.ts | 73 ++----- src/lib/assets/asset-gc.ts | 79 ++++++++ src/lib/assets/asset-hash.ts | 19 ++ src/lib/assets/asset-store.ts | 88 +++++++++ src/lib/assets/use-asset-gc.ts | 31 +++ src/lib/assets/use-asset-url.ts | 71 +++++++ src/lib/import/import-project.ts | 73 ++----- .../indexeddb-storage-provider.ts | 121 +++++++++++- .../storage-provider/local-persistence.ts | 10 +- .../migrations/store-migrations.ts | 23 ++- .../storage-provider/storage-provider.ts | 35 ++++ src/lib/project/project-doc.ts | 16 ++ src/lib/project/project-state.ts | 187 +++++++++++++++++- src/tests/assets/assets.test.ts | 183 +++++++++++++++++ .../migrations/store-migration-runner.test.ts | 1 + src/tests/project/scriptio-roundtrip.test.ts | 172 ++++++++++++++++ 31 files changed, 1425 insertions(+), 234 deletions(-) create mode 100644 src/lib/assets/asset-gc.ts create mode 100644 src/lib/assets/asset-hash.ts create mode 100644 src/lib/assets/asset-store.ts create mode 100644 src/lib/assets/use-asset-gc.ts create mode 100644 src/lib/assets/use-asset-url.ts create mode 100644 src/tests/assets/assets.test.ts create mode 100644 src/tests/project/scriptio-roundtrip.test.ts diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index e943ad0e..0b566473 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -32,6 +32,12 @@ cursor: grabbing; } +/* Highlight the canvas while an image file is dragged over it. */ +.container.drag_over { + outline: 2px dashed var(--secondary-text); + outline-offset: -8px; +} + .grid { position: absolute; inset: 0; @@ -76,6 +82,29 @@ z-index: 1000; } +/* Image resource card: bare image, no colored header/border chrome. */ +.card.image_card { + border: 1px solid rgba(0, 0, 0, 0.12); + background-color: var(--main-bg); + padding: 0; + overflow: hidden; +} + +.card_image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; +} + +.card_image_placeholder { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); +} + .card_header { position: relative; display: flex; diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index ad1ed280..e83497f4 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -9,10 +9,14 @@ import { v7 as uuidv7 } from "uuid"; import { Trash2, Plus, Minus, Copy, ListTree } from "lucide-react"; import { useTranslations } from "next-intl"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; +import { importImageFile } from "@src/lib/assets/asset-store"; +import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; const GRID_SIZE = 20; const MIN_SCALE = 0.25; const MAX_SCALE = 2; +/** Largest edge (in canvas px) an image card is sized to on first drop. */ +const MAX_IMAGE_CARD_SIZE = 400; interface CardContextMenuState { position: { x: number; y: number }; @@ -25,7 +29,7 @@ interface ArrowContextMenuState { } const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }) => { - const { repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = + const { projectId, repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = useContext(ProjectContext); const t = useTranslations("board"); const projectState = repository?.getState(); @@ -37,6 +41,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const [offset, setOffset] = useState({ x: 0, y: 0 }); const [scale, setScale] = useState(1); const [isPanning, setIsPanning] = useState(false); + const [isDraggingFile, setIsDraggingFile] = useState(false); const [isSnapping, setIsSnapping] = useState(true); const [cardContextMenu, setCardContextMenu] = useState(null); const [arrowContextMenu, setArrowContextMenu] = useState(null); @@ -496,6 +501,73 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } [cards, offset, scale, isSnapping, saveCards], ); + // Highlight the canvas while an OS file drag hovers over it. + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (isReadOnly) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDraggingFile(true); + }, + [isReadOnly], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + // Ignore leave events fired when moving between the container's children. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false); + }, []); + + // Drop image files → store each in IndexedDB (deduped) and drop an image + // card referencing its hash at the cursor. + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingFile(false); + if (isReadOnly || !projectId) return; + + const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); + if (files.length === 0) return; + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const dropX = (e.clientX - rect.left - offset.x) / scale; + const dropY = (e.clientY - rect.top - offset.y) / scale; + + const created: BoardCardData[] = []; + for (const file of files) { + try { + const { hash, width, height } = await importImageFile(projectId, file); + const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(width, height, 1)); + const i = created.length; + created.push({ + id: uuidv7(), + type: "image", + assetId: hash, + title: "", + description: "", + color: "transparent", + x: dropX + i * 24, + y: dropY + i * 24, + width: Math.max(60, Math.round(width * fit)), + height: Math.max(60, Math.round(height * fit)), + }); + } catch (err) { + console.error("[BoardCanvas] Failed to import image:", err); + } + } + if (created.length === 0) return; + + setCards((prev) => { + const next = [...prev, ...created]; + saveCards(next); + return next; + }); + }, + [isReadOnly, projectId, offset, scale, saveCards], + ); + // Update card (with multi-drag support) const handleUpdateCard = useCallback( (updatedCard: BoardCardData) => { @@ -540,8 +612,10 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } setArrows(newArrows); saveArrows(newArrows); setCardContextMenu(null); + // Deleting an image card may orphan its asset — reconcile (debounced). + if (projectId && projectState) scheduleAssetGc(projectId, projectState); }, - [cards, arrows, saveCards, saveArrows], + [cards, arrows, saveCards, saveArrows, projectId, projectState], ); // Change card color @@ -769,10 +843,13 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }
@@ -899,6 +976,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } -
- {DEFAULT_ITEM_COLORS.map((color) => ( -
+ {/* Color + outline apply to text notes; image cards have neither. */} + {cardContextMenu.card.type !== "image" && ( +
+ {DEFAULT_ITEM_COLORS.map((color) => ( +
+ )}
handleDuplicateCard(cardContextMenu.card)} @@ -951,13 +1032,15 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }

{t("duplicate")}

-
handleSendToOutline(cardContextMenu.card)} - > - -

{t("sendToOutline")}

-
+ {cardContextMenu.card.type !== "image" && ( +
handleSendToOutline(cardContextMenu.card)} + > + +

{t("sendToOutline")}

+
+ )}
handleDeleteCard(cardContextMenu.card.id)} diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index d8de0bb4..60166e9f 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -4,9 +4,11 @@ import { useRef, useState, useCallback, useEffect } from "react"; import styles from "./BoardCanvas.module.css"; import { useTranslations } from "next-intl"; import { BoardCardData } from "@src/lib/project/project-state"; +import { useAssetUrl } from "@src/lib/assets/use-asset-url"; interface BoardCardProps { card: BoardCardData; + projectId: string; scale: number; isSnapping: boolean; gridSize: number; @@ -20,6 +22,7 @@ interface BoardCardProps { const BoardCard = ({ card, + projectId, scale, isSnapping, gridSize, @@ -30,6 +33,9 @@ const BoardCard = ({ isConnecting, isSelected, }: BoardCardProps) => { + const isImage = card.type === "image"; + // Only resolves bytes for image cards (null assetId is a no-op for text notes). + const imageUrl = useAssetUrl(projectId, isImage ? card.assetId : null); const t = useTranslations("board"); const cardRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -237,63 +243,74 @@ const BoardCard = ({ return (
-
- {isEditingTitle ? ( - setLocalTitle(e.target.value)} - onBlur={handleTitleBlur} - onKeyDown={handleTitleKeyDown} - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - placeholder={t("titlePlaceholder")} - autoFocus - /> + {isImage ? ( + imageUrl ? ( + // Blob object URLs can't be optimized by next/image; a plain is correct here. + // eslint-disable-next-line @next/next/no-img-element + ) : ( - - {card.title || t("untitled")} - - )} -
- -
-

- {card.description} -

- {isEditing && ( -