From d7961f8a6731d75820bcac07549a6ef4c79171ac Mon Sep 17 00:00:00 2001 From: Lycoon Date: Mon, 20 Apr 2026 21:50:06 +0200 Subject: [PATCH] fixed more eslint errors --- components/projects/ProjectItem.module.css | 7 +- .../projects/ProjectPageContainer.module.css | 5 + components/projects/ProjectPageContainer.tsx | 13 +- .../projects/ProjectUnavailableDialog.tsx | 2 +- src/lib/adapters/screenplay-adapter.ts | 8 +- src/lib/import/import-project.ts | 8 +- src/lib/mail/mail.ts | 2 +- .../indexeddb-storage-provider.ts | 2 +- src/lib/persistence/y-local-provider.ts | 2 +- src/lib/project/project-repository.ts | 29 ++--- src/lib/project/project-state.ts | 117 +++++++++--------- 11 files changed, 103 insertions(+), 92 deletions(-) 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) {