diff --git a/components/projects/ProjectItem.module.css b/components/projects/ProjectItem.module.css
index 85b7363..889d50e 100644
--- a/components/projects/ProjectItem.module.css
+++ b/components/projects/ProjectItem.module.css
@@ -5,7 +5,7 @@
.container {
cursor: pointer;
width: 100%;
- padding: 16px 16px;
+ padding: 24px;
display: flex;
flex-direction: row;
@@ -13,10 +13,9 @@
gap: 16px;
border-radius: 18px;
- border-style: solid;
- border-width: 3px;
- border-color: var(--tertiary);
+ border: none;
background-color: var(--project-item-bg);
+ box-shadow: var(--panel-shadow);
transition: border-color 0.2s;
}
diff --git a/components/projects/ProjectPageContainer.module.css b/components/projects/ProjectPageContainer.module.css
index 204b9e9..55239e0 100644
--- a/components/projects/ProjectPageContainer.module.css
+++ b/components/projects/ProjectPageContainer.module.css
@@ -17,6 +17,11 @@
width: 100%;
}
+.header_title {
+ position: relative;
+ top: 0.8rem;
+}
+
.header_info {
display: flex;
flex-direction: row;
diff --git a/components/projects/ProjectPageContainer.tsx b/components/projects/ProjectPageContainer.tsx
index e4495d1..e8727d1 100644
--- a/components/projects/ProjectPageContainer.tsx
+++ b/components/projects/ProjectPageContainer.tsx
@@ -1,7 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { useCookieUser, useIsPro, useProjectMemberships, ExtendedProjectMembershipPayload } from "@src/lib/utils/hooks";
+import {
+ useCookieUser,
+ useIsPro,
+ useProjectMemberships,
+ ExtendedProjectMembershipPayload,
+} from "@src/lib/utils/hooks";
import { join } from "@src/lib/utils/misc";
import { importFileAsProject, getSupportedImportExtensions } from "@src/lib/import/import-project";
import { redirectScreenplay } from "@src/lib/utils/redirects";
@@ -99,7 +104,7 @@ const ProjectPageContainer = () => {
-
{t("pageTitle")}
+
{t("pageTitle")}
-
+
diff --git a/components/projects/ProjectUnavailableDialog.tsx b/components/projects/ProjectUnavailableDialog.tsx
index 70a8db8..c9ebc85 100644
--- a/components/projects/ProjectUnavailableDialog.tsx
+++ b/components/projects/ProjectUnavailableDialog.tsx
@@ -22,7 +22,7 @@ const ProjectUnavailableDialog = () => {
const { getCachedProject, migrateToCachedProject } =
await import("@src/lib/persistence/storage-provider/local-persistence");
const cachedProject = await getCachedProject(projectId);
- const metadataTitle = repository?.getState().metadata().get("title");
+ 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}`);
diff --git a/src/lib/adapters/screenplay-adapter.ts b/src/lib/adapters/screenplay-adapter.ts
index 62b28b7..432f0ce 100644
--- a/src/lib/adapters/screenplay-adapter.ts
+++ b/src/lib/adapters/screenplay-adapter.ts
@@ -2,7 +2,7 @@ import FileSaver from "file-saver";
import { isTauri } from "@tauri-apps/api/core";
import { replaceScreenplay } from "../screenplay/editor";
import { Editor } from "@tiptap/react";
-import { ProjectData, ProjectState } from "../project/project-state";
+import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState } from "../project/project-state";
import { ProjectRepository } from "../project/project-repository";
export type BaseExportOptions = {
@@ -80,7 +80,7 @@ export abstract class ProjectAdapter {
- metadataMap.set(key, value);
+ metadataMap.set(key as keyof ProjectMetadata, value);
});
}
@@ -112,14 +112,14 @@ export abstract class ProjectAdapter {
- boardMap.set(key, value);
+ boardMap.set(key as keyof BoardData, value);
});
}
if (project.layout) {
const layoutMap = ydoc.layout();
Object.entries(project.layout).forEach(([key, value]) => {
- layoutMap.set(key, value);
+ layoutMap.set(key as keyof LayoutData, value);
});
}
diff --git a/src/lib/import/import-project.ts b/src/lib/import/import-project.ts
index 3c466bb..f96f4b9 100644
--- a/src/lib/import/import-project.ts
+++ b/src/lib/import/import-project.ts
@@ -3,7 +3,7 @@
* Creates remote projects for logged-in users, local projects for offline/desktop.
*/
-import { ProjectData, ProjectState } from "@src/lib/project/project-state";
+import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState } from "@src/lib/project/project-state";
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";
@@ -90,7 +90,7 @@ async function createLocalYjsDocument(projectId: string, projectData: ProjectDat
// Maps
if (projectData.metadata) {
const metadataMap = ydoc.metadata();
- Object.entries(projectData.metadata).forEach(([key, value]) => metadataMap.set(key, value));
+ Object.entries(projectData.metadata).forEach(([key, value]) => metadataMap.set(key as keyof ProjectMetadata, value));
}
if (projectData.characters) {
@@ -110,12 +110,12 @@ async function createLocalYjsDocument(projectId: string, projectData: ProjectDat
if (projectData.board) {
const boardMap = ydoc.board();
- Object.entries(projectData.board).forEach(([key, value]) => boardMap.set(key, value));
+ Object.entries(projectData.board).forEach(([key, value]) => boardMap.set(key as keyof BoardData, value));
}
if (projectData.layout) {
const layoutMap = ydoc.layout();
- Object.entries(projectData.layout).forEach(([key, value]) => layoutMap.set(key, value));
+ Object.entries(projectData.layout).forEach(([key, value]) => layoutMap.set(key as keyof LayoutData, value));
}
if (projectData.comments) {
diff --git a/src/lib/mail/mail.ts b/src/lib/mail/mail.ts
index 3330d32..6a9f308 100644
--- a/src/lib/mail/mail.ts
+++ b/src/lib/mail/mail.ts
@@ -1,7 +1,7 @@
import nodemailer from "nodemailer";
import * as fs from "fs";
import { BASE_URL } from "../utils/constants";
-var hogan = require("hogan.js");
+import hogan from "hogan.js";
const transporter = nodemailer.createTransport({
pool: true,
diff --git a/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts b/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts
index a39e551..7a5a970 100644
--- a/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts
+++ b/src/lib/persistence/storage-provider/indexeddb-storage-provider.ts
@@ -248,7 +248,7 @@ export class IndexedDBStorageProvider implements StorageProvider {
const req = db.transaction(DICTIONARIES_STORE, "readonly").objectStore(DICTIONARIES_STORE).getAll();
req.onsuccess = () =>
resolve(
- (req.result as any[]).map((row) => ({
+ (req.result as InstalledDictionary[]).map((row) => ({
code: row.code,
size: row.size,
installedAt: row.installedAt,
diff --git a/src/lib/persistence/y-local-provider.ts b/src/lib/persistence/y-local-provider.ts
index 03a7c93..3d9be8b 100644
--- a/src/lib/persistence/y-local-provider.ts
+++ b/src/lib/persistence/y-local-provider.ts
@@ -9,7 +9,7 @@ import type * as Y from "yjs";
export const yjsDbKey = (projectId: string) => `scriptio-${projectId}`;
export interface YjsLocalProvider {
- on(event: "synced", callback: (provider: any) => void): void;
+ on(event: "synced", callback: (provider: YjsLocalProvider) => void): void;
destroy(): void;
/** Clear all stored data for this project (used when server restores a snapshot). */
clearData?(): Promise;
diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts
index 853987b..1e3a78c 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,
+ ProjectMetadata,
ProjectState,
ElementStyle,
PageMargin,
@@ -123,9 +124,9 @@ export class ProjectRepository {
this.ydoc.metadata().set("author", author);
}
- observeMetadata(callback: (metadata: Record) => void): () => void {
+ observeMetadata(callback: (metadata: Partial) => void): () => void {
const map = this.ydoc.metadata();
- const observer = () => callback(map.toJSON());
+ const observer = () => callback(map.toJSON() as Partial);
map.observe(observer);
return () => map.unobserve(observer);
}
@@ -335,36 +336,36 @@ export class ProjectRepository {
// -------------------------------- //
/**
- * Generic comment operations — work on any Y.Map keyed by comment UUID.
+ * Generic comment operations — work on any Y.Map keyed by comment UUID.
* Use the convenience wrappers below for the main screenplay comments.
*/
- getCommentsFromMap(map: Y.Map): Record {
+ getCommentsFromMap(map: Y.Map): Record {
return map.toJSON() as Record;
}
- getCommentFromMap(map: Y.Map, commentId: string): Comment | undefined {
- return map.get(commentId) as Comment | undefined;
+ getCommentFromMap(map: Y.Map, commentId: string): Comment | undefined {
+ return map.get(commentId);
}
- addCommentToMap(map: Y.Map, comment: Omit): string {
+ addCommentToMap(map: Y.Map, comment: Omit): string {
const id = uuidv7();
map.set(id, { ...comment, id });
return id;
}
- updateCommentInMap(map: Y.Map, commentId: string, data: Partial): void {
- const existing = map.get(commentId) as Comment | undefined;
+ updateCommentInMap(map: Y.Map, commentId: string, data: Partial): void {
+ const existing = map.get(commentId);
if (!existing) return;
map.set(commentId, { ...existing, ...data });
}
- resolveCommentInMap(map: Y.Map, commentId: string): void {
+ resolveCommentInMap(map: Y.Map, commentId: string): void {
this.updateCommentInMap(map, commentId, { resolved: true });
}
- addReplyToMap(map: Y.Map, commentId: string, reply: Omit): string | undefined {
- const existing = map.get(commentId) as Comment | undefined;
+ addReplyToMap(map: Y.Map, commentId: string, reply: Omit): string | undefined {
+ const existing = map.get(commentId);
if (!existing) return undefined;
const id = uuidv7();
const replies = [...(existing.replies ?? []), { ...reply, id }];
@@ -372,13 +373,13 @@ export class ProjectRepository {
return id;
}
- deleteCommentFromMap(map: Y.Map, commentId: string): void {
+ deleteCommentFromMap(map: Y.Map, commentId: string): void {
if (map.has(commentId)) {
map.delete(commentId);
}
}
- observeCommentsMap(map: Y.Map, callback: (comments: Record) => void): () => void {
+ observeCommentsMap(map: Y.Map, callback: (comments: Record) => void): () => void {
const observer = () => callback(map.toJSON() as Record);
map.observe(observer);
return () => map.unobserve(observer);
diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts
index 5ec822c..3668aed 100644
--- a/src/lib/project/project-state.ts
+++ b/src/lib/project/project-state.ts
@@ -6,6 +6,16 @@ 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 "../collaboration/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 { YjsLocalProvider } from "../persistence/y-local-provider";
// Lazy re-export repository for convenient access (avoid loading yjs at module level)
export const getProjectRepository = async () => {
@@ -22,17 +32,6 @@ export const getProjectRepository = async () => {
export type ConnectionStatus = "disconnected" | "connecting" | "connected";
-// Import types only (these don't cause SSR issues)
-import * as Y from "yjs";
-import type { ThrottledWebsocketProvider } from "../collaboration/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";
-
// ---- Shelf types ----
export type ShelfEntryType = "scene" | "character" | "action";
@@ -79,6 +78,7 @@ export type ProjectMetadata = {
id: string;
title: string;
author: string;
+ titlepageInitialized?: boolean;
};
export type ElementMargin = { left: number; right: number }; // values in inches (offset from page margin)
@@ -171,6 +171,16 @@ export type ProjectData = {
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, "get" | "set" | "toJSON"> {
+ get(key: K): T[K] | undefined;
+ set(key: K, value: T[K]): T[K];
+ toJSON(): T;
+}
+
// -------------------------------- //
// LAZY-LOADED MODULES //
// -------------------------------- //
@@ -209,6 +219,29 @@ async function getScreenplayEditor() {
return screenplayEditorModule;
}
+/**
+ * Utility to clear the local IndexedDB cache for a specific project.
+ * Used when the server restores a document from a snapshot to avoid merge conflicts.
+ */
+export async function clearLocalProjectCache(projectId: string): Promise {
+ try {
+ const { IndexeddbPersistence } = await import("y-indexeddb");
+ const tmpDoc = new Y.Doc();
+ const tmpPersistence = new IndexeddbPersistence(`scriptio-${projectId}`, tmpDoc);
+
+ // Check if clearData is available on the persistence instance
+ const provider = tmpPersistence as unknown as { clearData?: () => Promise };
+ if (typeof provider.clearData === "function") {
+ await provider.clearData();
+ }
+
+ tmpPersistence.destroy();
+ tmpDoc.destroy();
+ } catch (e) {
+ console.warn(`[ProjectState] Failed to clear local cache for ${projectId}:`, e);
+ }
+}
+
// -------------------------------- //
// PROJECT STATE //
// -------------------------------- //
@@ -229,8 +262,8 @@ export class ProjectState extends Y.Doc {
SHELF: "shelf",
} as const;
- metadata(): Y.Map {
- return this.getMap(this.KEYS.METADATA);
+ metadata(): TypedMap {
+ return this.getMap(this.KEYS.METADATA) as unknown as TypedMap;
}
screenplay(): Screenplay {
@@ -265,12 +298,12 @@ export class ProjectState extends Y.Doc {
return this.getMap(this.KEYS.SCENES);
}
- board(): Y.Map {
- return this.getMap(this.KEYS.BOARD);
+ board(): TypedMap {
+ return this.getMap(this.KEYS.BOARD) as unknown as TypedMap;
}
- layout(): Y.Map {
- return this.getMap(this.KEYS.LAYOUT);
+ layout(): TypedMap {
+ return this.getMap(this.KEYS.LAYOUT) as unknown as TypedMap;
}
comments(): Y.Map {
@@ -325,7 +358,7 @@ export const getScenesMap = (ydoc: ProjectState): Y.Map => {
* Get the board Y.Map from a ProjectState.
* Convenience function for direct access without repository.
*/
-export const getBoardMap = (ydoc: ProjectState): Y.Map => {
+export const getBoardMap = (ydoc: ProjectState): TypedMap => {
return ydoc.board();
};
@@ -340,7 +373,7 @@ export const getBoardMap = (ydoc: ProjectState): Y.Map => {
export const useLocalPersistence = (projectId: string | null) => {
const [ydoc, setYdoc] = useState(null);
const [isLocalReady, setIsLocalReady] = useState(false);
- const persistenceRef = useRef<{ on: any; destroy(): void } | null>(null);
+ const persistenceRef = useRef(null);
useEffect(() => {
if (!projectId || typeof window === "undefined") {
@@ -449,7 +482,7 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
return;
}
- const initializeProvider = async () => {
+ const setupProvider = async () => {
setConnectionStatus("connecting");
try {
@@ -490,8 +523,6 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
clientId: ydoc.clientID.toString(),
},
userInfo: userInfoRef.current,
- // Disable BroadcastChannel in Tauri - it can interfere with sync
- // See: https://github.com/tauri-apps/tauri/issues/10226
disableBc: isDesktop,
},
);
@@ -506,8 +537,6 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
for (const state of states) {
if (state.user) {
const user = state.user as CollaboratorInfo;
- // Use userId as the primary unique key for deduplication,
- // fallback to name for anonymous/legacy sessions.
const key = user.userId || user.name;
if (!uniqueUsersMap.has(key)) {
uniqueUsersMap.set(key, user);
@@ -516,8 +545,6 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
}
const connectedUsers = Array.from(uniqueUsersMap.values());
-
- // Only update if users changed to avoid unnecessary re-renders
const usersJson = JSON.stringify(connectedUsers);
if (usersJson !== lastUsersJsonRef.current) {
lastUsersJsonRef.current = usersJson;
@@ -527,15 +554,10 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
// Handle connection errors
cloudProvider.on("connection-error", async () => {
- // Don't try to reconnect if session was replaced
- if (cloudProvider.wasSessionReplaced) {
- return;
- }
-
+ if (cloudProvider.wasSessionReplaced) return;
console.warn("[ProjectYjs] Connection error, attempting to refresh token...");
if (isMountedRef.current) {
setConnectionStatus("connecting");
- // Refresh the token before reconnecting
if (refreshAndReconnectRef.current) {
await refreshAndReconnectRef.current();
} else {
@@ -547,13 +569,10 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
// Status updates
cloudProvider.on("status", (e: { status: string }) => {
if (isMountedRef.current) {
- // Use setTimeout to avoid state update during render
setTimeout(() => {
if (isMountedRef.current) {
setConnectionStatus(e.status as ConnectionStatus);
- // Check synced status when connected (might have synced already)
if (e.status === "connected" && cloudProvider.synced) {
- console.log("[ProjectYjs] Already synced on connect");
setIsCloudSynced(true);
}
}
@@ -562,48 +581,29 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
});
// Track when initial cloud sync completes
- // This is crucial for desktop clients where local IndexedDB may be empty
- // y-websocket sets .synced property when sync step 2 is complete
cloudProvider.on("sync", (isSynced: boolean) => {
if (isMountedRef.current && isSynced) {
setIsCloudSynced(true);
}
});
- // Handle document restore — server replaced the doc with a snapshot.
- // We must clear the local IndexedDB so the old state doesn't
- // merge back when we reconnect, then reload to get a clean slate.
+ // Handle document restore
cloudProvider.on("document-restored", async () => {
if (!isMountedRef.current) return;
console.log("[ProjectYjs] Document restored — clearing local cache and reloading");
-
- try {
- const { IndexeddbPersistence } = await import("y-indexeddb");
- const Y = await getYjs();
- const tmpDoc = new Y.Doc();
- const tmpPersistence = new IndexeddbPersistence(`scriptio-${projectId}`, tmpDoc);
- await (tmpPersistence as any).clearData();
- tmpPersistence.destroy();
- tmpDoc.destroy();
- } catch (e) {
- console.warn("[ProjectYjs] Failed to clear local cache:", e);
- }
-
+ await clearLocalProjectCache(projectId);
window.location.reload();
});
- // Poll for synced status since the event might fire before listener is attached
- // This is a safety net for race conditions
+ // Poll for synced status
const checkSynced = () => {
if (!isMountedRef.current) return;
if (cloudProvider.synced) {
setIsCloudSynced(true);
} else {
- // Check again after a short delay
setTimeout(checkSynced, 100);
}
};
- // Start checking after connection is established
setTimeout(checkSynced, 50);
providerRef.current = cloudProvider;
@@ -612,13 +612,12 @@ export const useCloudSync = (projectId: string | null, ydoc: ProjectState | null
console.error("[ProjectYjs] Failed to initialize provider:", e);
if (isMountedRef.current) {
setConnectionStatus("disconnected");
- // Allow proceeding with local data when cloud sync fails
setIsCloudSynced(true);
}
}
};
- initializeProvider();
+ setupProvider();
const handleUnload = async () => {
if (providerRef.current && ydoc) {