import { useEffect, useMemo, useRef, useState } from 'react'; import { ipcChannels, type ScenePreviewImportEvent } from '../../../shared/ipc/contracts'; import type { NpcImportResolution, SceneImportResolution, StorylineImportMergeReport, StorylineLabels, StorylineListItem, StorylineSelection, } from '../../../shared/graph/storylineExportImport'; import type { AssetId, GraphNodeId, MaterialId, Project, ProjectId, Scene, SceneId, } from '../../../shared/types'; import { getDndApi } from '../../shared/dndApi'; import { invalidateAssetUrlCache } from '../../shared/useAssetImageUrl'; type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string }; type State = { projects: ProjectSummary[]; project: Project | null; selectedSceneId: SceneId | null; openingProjectId: ProjectId | null; creatingScene: boolean; sceneBatchImport: { current: number; total: number; fileName: string } | null; zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null; scenePreviewImports: Record< SceneId, { assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string } >; }; type Actions = { refreshProjects: () => Promise; createProject: (name: string) => Promise; openProject: (id: ProjectId) => Promise; closeProject: () => Promise; createScene: () => Promise; createScenesFromMediaPaths: ( items: { filePath: string; title: string }[], ) => Promise<{ created: number; lastSceneId: SceneId | null }>; selectScene: (id: SceneId) => Promise; importCampaignAudio: () => Promise; importCampaignAudioFromPaths: (filePaths: string[]) => Promise; updateCampaignAudios: (next: Project['campaignAudios']) => Promise; upsertMaterial: (input: { materialId?: MaterialId; name: string; filePath?: string; }) => Promise; deleteMaterial: (materialId: MaterialId) => Promise; setMaterialsOrder: (materialIds: MaterialId[]) => Promise; setMaterialRotation: (materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270) => Promise; setMaterialLegend: ( materialId: MaterialId, legend: import('../../shared/types').MaterialLegend | null, ) => Promise; pickMaterialImage: () => Promise<{ filePath: string; previewDataUrl: string } | null>; updateScene: ( sceneId: SceneId, patch: { title?: string; description?: string; previewAssetId?: AssetId | null; previewThumbAssetId?: AssetId | null; previewAssetType?: 'image' | 'video' | null; previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; }, ) => Promise; updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise; importMediaToScene: (sceneId: SceneId) => Promise; importMediaToSceneFromPaths: (sceneId: SceneId, filePaths: string[]) => Promise; importScenePreview: (sceneId: SceneId) => Promise<{ assetId: AssetId | null; background: boolean }>; importScenePreviewFromPath: ( sceneId: SceneId, filePath: string, ) => Promise<{ assetId: AssetId | null; background: boolean }>; clearScenePreview: (sceneId: SceneId) => Promise; updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise; addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise; removeSceneGraphNode: (nodeId: GraphNodeId) => Promise; addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise; removeSceneGraphEdge: (edgeId: string) => Promise; setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise; setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise; updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise; deleteScene: (sceneId: SceneId) => Promise; setSceneListOrder: (sceneListOrder: SceneId[]) => Promise; renameProject: (name: string, fileBaseName: string) => Promise; importProject: () => Promise; pickFoundrySource: ( mode: 'folder' | 'archive', ) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>; importFoundryProject: (sourcePath: string) => Promise; peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< | { canceled: true } | { canceled: false; filePath: string; projectName: string; storylines: StorylineListItem[]; sourceProject: Project; } >; pickImportZipFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>; peekImportZipPath: ( filePath: string, labels: StorylineLabels, targetHasMainStart: boolean, ) => Promise<{ filePath: string; projectName: string; storylines: StorylineListItem[]; sourceProject: Project; }>; peekImportFromProject: ( sourceProjectId: ProjectId, labels: StorylineLabels, targetHasMainStart: boolean, ) => Promise<{ sourceProjectId: ProjectId; projectName: string; storylines: StorylineListItem[]; sourceProject: Project; }>; mergeImportZip: ( filePath: string, storylineSelections: StorylineSelection[], sceneResolutions: SceneImportResolution[], npcResolutions?: NpcImportResolution[], ) => Promise<{ project: Project; report: StorylineImportMergeReport }>; mergeImportFromProject: ( sourceProjectId: ProjectId, storylineSelections: StorylineSelection[], sceneResolutions: SceneImportResolution[], npcResolutions?: NpcImportResolution[], ) => Promise<{ project: Project; report: StorylineImportMergeReport }>; importProjectFromPath: (filePath: string) => Promise; getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise; exportProject: ( projectId: ProjectId, storylineSelections: StorylineSelection[], labels: StorylineLabels, ) => Promise; deleteProject: (projectId: ProjectId) => Promise; }; function randomId(prefix: string): string { return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`; } export type ProjectNoticeCode = 'campaign_audio_empty'; export type ProjectStateOpts = { onNotice?: (code: ProjectNoticeCode) => void; }; export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts): readonly [State, Actions] { const api = getDndApi(); const onNoticeRef = useRef(opts?.onNotice); useEffect(() => { onNoticeRef.current = opts?.onNotice; }, [opts?.onNotice]); const [state, setState] = useState({ projects: [], project: null, selectedSceneId: null, openingProjectId: null, creatingScene: false, sceneBatchImport: null, zipProgress: null, scenePreviewImports: {} as Record< SceneId, { assetId: AssetId; phase: ScenePreviewImportEvent['phase']; message?: string } >, }); const projectRef = useRef(null); const openInFlightRef = useRef | null>(null); const createSceneInFlightRef = useRef | null>(null); /** Bumps on mutations / refresh; initial license load only applies if still current (avoids racing late list/get over newer state). */ const projectDataEpochRef = useRef(0); useEffect(() => { projectRef.current = state.project; }, [state.project]); useEffect(() => { const offImport = api.on(ipcChannels.project.importZipProgress, (evt) => { const e = evt as unknown as { percent: number; stage: string; detail?: string }; setState((s) => ({ ...s, zipProgress: { kind: 'import', percent: e.percent, stage: e.stage, ...(e.detail ? { detail: e.detail } : null), }, })); if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) { const delay = e.stage === 'error' ? 0 : 450; setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay); } }); const offExport = api.on(ipcChannels.project.exportZipProgress, (evt) => { const e = evt as unknown as { percent: number; stage: string; detail?: string }; setState((s) => ({ ...s, zipProgress: { kind: 'export', percent: e.percent, stage: e.stage, ...(e.detail ? { detail: e.detail } : null), }, })); if (e.stage === 'done' || e.stage === 'error' || e.percent >= 100) { const delay = e.stage === 'error' ? 0 : 450; setTimeout(() => setState((s) => ({ ...s, zipProgress: null })), delay); } }); const offPreview = api.on(ipcChannels.project.scenePreviewImportProgress, (evt) => { setState((s) => { const nextImports = { ...s.scenePreviewImports }; nextImports[evt.sceneId] = { assetId: evt.assetId, phase: evt.phase, ...(evt.message ? { message: evt.message } : null), }; return { ...s, ...(evt.project ? { project: evt.project } : null), scenePreviewImports: nextImports, }; }); if (evt.phase === 'done' || evt.phase === 'error') { setTimeout( () => { setState((s) => { const cur = s.scenePreviewImports[evt.sceneId]; if (cur?.assetId !== evt.assetId || cur.phase !== evt.phase) return s; const nextImports = Object.fromEntries( Object.entries(s.scenePreviewImports).filter(([sceneId]) => sceneId !== evt.sceneId), ) as State['scenePreviewImports']; return { ...s, scenePreviewImports: nextImports }; }); }, evt.phase === 'done' ? 1000 : 4000, ); } }); return () => { offImport(); offExport(); offPreview(); }; }, [api]); const actions = useMemo(() => { const refreshProjects = async () => { projectDataEpochRef.current += 1; const res = await api.invoke(ipcChannels.project.list, {}); setState((s) => ({ ...s, projects: res.projects })); }; const createProject = async (name: string) => { const res = await api.invoke(ipcChannels.project.create, { name }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId })); await refreshProjects(); }; const openProject = async (id: ProjectId) => { projectDataEpochRef.current += 1; const epoch = projectDataEpochRef.current; openInFlightRef.current = null; // URL ассетов зависят от открытого проекта — сбрасываем renderer-кэш. invalidateAssetUrlCache(); const job = (async () => { setState((s) => ({ ...s, openingProjectId: id })); try { const res = await api.invoke(ipcChannels.project.open, { projectId: id }); if (projectDataEpochRef.current !== epoch) { setState((s) => ({ ...s, openingProjectId: null })); return; } setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId, openingProjectId: null, })); } catch { if (projectDataEpochRef.current === epoch) { setState((s) => ({ ...s, openingProjectId: null })); } } })(); openInFlightRef.current = job; void job.finally(() => { if (openInFlightRef.current === job) openInFlightRef.current = null; }); return job; }; const closeProject = async () => { projectDataEpochRef.current += 1; openInFlightRef.current = null; invalidateAssetUrlCache(); try { await api.invoke(ipcChannels.project.close, {}); } finally { setState((s) => ({ ...s, project: null, selectedSceneId: null, openingProjectId: null, zipProgress: null, })); await refreshProjects(); } }; const createScene = async () => { if (createSceneInFlightRef.current) return createSceneInFlightRef.current; const p = projectRef.current; if (!p) return; const job = (async () => { setState((s) => ({ ...s, creatingScene: true })); try { const sceneId = randomId('scene') as SceneId; const scene: Scene = { id: sceneId, title: `Новая сцена`, description: '', previewAssetId: null, previewThumbAssetId: null, previewAssetType: null, previewVideoAutostart: false, previewRotationDeg: 0, darkenScene: false, traps: [], media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, connections: [], layout: { x: 0, y: 0 }, }; await api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { title: scene.title, description: scene.description, media: scene.media, settings: scene.settings, layout: scene.layout, previewAssetId: scene.previewAssetId, previewAssetType: scene.previewAssetType, previewVideoAutostart: scene.previewVideoAutostart, }, }); await api.invoke(ipcChannels.project.setCurrentScene, { sceneId }); const res = await api.invoke(ipcChannels.project.get, {}); setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId })); } finally { setState((s) => ({ ...s, creatingScene: false })); } })(); createSceneInFlightRef.current = job; void job.finally(() => { if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null; }); return job; }; const createScenesFromMediaPaths = async ( items: { filePath: string; title: string }[], ): Promise<{ created: number; lastSceneId: SceneId | null }> => { if (items.length === 0) return { created: 0, lastSceneId: null }; if (createSceneInFlightRef.current) { await createSceneInFlightRef.current; } const p = projectRef.current; if (!p) return { created: 0, lastSceneId: null }; let created = 0; let lastSceneId: SceneId | null = null; const job = (async () => { setState((s) => ({ ...s, creatingScene: true, sceneBatchImport: { current: 0, total: items.length, fileName: items[0]?.title ?? '' }, })); try { for (let i = 0; i < items.length; i += 1) { const item = items[i]!; setState((s) => ({ ...s, sceneBatchImport: { current: i + 1, total: items.length, fileName: item.title, }, })); const sceneId = randomId('scene') as SceneId; await api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { title: item.title, description: '', media: { videos: [], audios: [] }, settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true, }, layout: { x: 0, y: 0 }, previewAssetId: null, previewAssetType: null, previewVideoAutostart: false, }, }); const previewRes = await api.invoke(ipcChannels.project.importScenePreview, { sceneId, filePath: item.filePath, }); setState((s) => { const nextImports = { ...s.scenePreviewImports }; if (previewRes.assetId !== null && previewRes.background) { nextImports[sceneId] = { assetId: previewRes.assetId, phase: 'queued' }; } return { ...s, project: previewRes.project, scenePreviewImports: nextImports, }; }); created += 1; lastSceneId = sceneId; } if (lastSceneId) { await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: lastSceneId }); const res = await api.invoke(ipcChannels.project.get, {}); setState((s) => ({ ...s, project: res.project, selectedSceneId: lastSceneId, })); } await refreshProjects(); } finally { setState((s) => ({ ...s, creatingScene: false, sceneBatchImport: null, })); } })(); createSceneInFlightRef.current = job; void job.finally(() => { if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null; }); await job; return { created, lastSceneId }; }; const selectScene = async (id: SceneId) => { setState((s) => ({ ...s, selectedSceneId: id })); await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id }); }; const importCampaignAudio = async () => { const res = await api.invoke(ipcChannels.project.importCampaignAudio, {}); if (res.canceled) return; if (res.imported.length === 0) { onNoticeRef.current?.('campaign_audio_empty'); } setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const importCampaignAudioFromPaths = async (filePaths: string[]) => { if (filePaths.length === 0) return; const res = await api.invoke(ipcChannels.project.importCampaignAudio, { filePaths }); if (res.imported.length === 0) return; setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const updateCampaignAudios = async (next: Project['campaignAudios']) => { const res = await api.invoke(ipcChannels.project.updateCampaignAudios, { audios: next }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const upsertMaterial = async (input: { materialId?: MaterialId; name: string; filePath?: string; }) => { const res = await api.invoke(ipcChannels.project.upsertMaterial, input); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const deleteMaterial = async (materialId: MaterialId) => { const res = await api.invoke(ipcChannels.project.deleteMaterial, { materialId }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const setMaterialsOrder = async (materialIds: MaterialId[]) => { const res = await api.invoke(ipcChannels.project.setMaterialsOrder, { materialIds }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const setMaterialRotation = async ( materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270, ) => { const res = await api.invoke(ipcChannels.project.setMaterialRotation, { materialId, rotationDeg, }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const setMaterialLegend = async ( materialId: MaterialId, legend: import('../../shared/types').MaterialLegend | null, ) => { const res = await api.invoke(ipcChannels.project.setMaterialLegend, { materialId, legend }); // Список проектов не меняется — не дергаем refreshProjects на каждое обновление легенды. setState((s) => ({ ...s, project: res.project })); }; const pickMaterialImage = async () => { const res = await api.invoke(ipcChannels.project.pickMaterialImage, {}); if (res.canceled) return null; return { filePath: res.filePath, previewDataUrl: res.previewDataUrl }; }; const updateScene = async ( sceneId: SceneId, patch: { title?: string; description?: string; previewAssetId?: AssetId | null; previewThumbAssetId?: AssetId | null; previewAssetType?: 'image' | 'video' | null; previewVideoAutostart?: boolean; previewRotationDeg?: 0 | 90 | 180 | 270; darkenScene?: boolean; traps?: import('../../shared/types').SceneTrap[]; settings?: Partial; media?: Partial; layout?: { x: number; y: number }; }, ) => { setState((s) => { const p = s.project; if (!p) return s; const scene = p.scenes[sceneId]; if (!scene) return s; const next: Scene = { ...scene, ...(patch.title !== undefined ? { title: patch.title } : null), ...(patch.description !== undefined ? { description: patch.description } : null), ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), ...(patch.previewThumbAssetId !== undefined ? { previewThumbAssetId: patch.previewThumbAssetId } : null), ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null), ...(patch.previewVideoAutostart !== undefined ? { previewVideoAutostart: patch.previewVideoAutostart } : null), ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), ...(patch.traps !== undefined ? { traps: patch.traps } : null), ...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null), ...(patch.media ? { media: { ...scene.media, ...patch.media } } : null), layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout, }; const scenes = { ...p.scenes, [sceneId]: next }; const project: Project = { ...p, scenes }; return { ...s, project }; }); await api.invoke(ipcChannels.project.updateScene, { sceneId, patch }); }; const updateConnections = async (sceneId: SceneId, connections: SceneId[]) => { setState((s) => { const p = s.project; if (!p) return s; const scene = p.scenes[sceneId]; if (!scene) return s; const next: Scene = { ...scene, connections }; const scenes = { ...p.scenes, [sceneId]: next }; const project: Project = { ...p, scenes }; return { ...s, project }; }); await api.invoke(ipcChannels.project.updateConnections, { sceneId, connections }); }; const importMediaToScene = async (sceneId: SceneId) => { const res = await api.invoke(ipcChannels.project.importMedia, { sceneId }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const importMediaToSceneFromPaths = async (sceneId: SceneId, filePaths: string[]) => { if (filePaths.length === 0) return; const res = await api.invoke(ipcChannels.project.importMedia, { sceneId, filePaths }); if (res.imported.length === 0) return; setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const importScenePreview = async (sceneId: SceneId) => { const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId }); setState((s) => ({ ...s, project: res.project })); if (res.assetId !== null && res.background) { setState((s) => ({ ...s, scenePreviewImports: { ...s.scenePreviewImports, [sceneId]: { assetId: res.assetId, phase: 'queued' }, }, })); } await refreshProjects(); return { assetId: res.assetId, background: res.background }; }; const importScenePreviewFromPath = async (sceneId: SceneId, filePath: string) => { const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId, filePath }); setState((s) => ({ ...s, project: res.project })); if (res.assetId !== null && res.background) { setState((s) => ({ ...s, scenePreviewImports: { ...s.scenePreviewImports, [sceneId]: { assetId: res.assetId, phase: 'queued' }, }, })); } await refreshProjects(); return { assetId: res.assetId, background: res.background }; }; const clearScenePreview = async (sceneId: SceneId) => { const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const updateSceneGraphNodePosition = async (nodeId: GraphNodeId, x: number, y: number) => { setState((s) => { const p = s.project; if (!p) return s; return { ...s, project: { ...p, sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)), }, }; }); const res = await api.invoke(ipcChannels.project.updateSceneGraphNodePosition, { nodeId, x, y }); setState((s) => ({ ...s, project: res.project })); }; const addSceneGraphNode = async (sceneId: SceneId, x: number, y: number) => { const res = await api.invoke(ipcChannels.project.addSceneGraphNode, { sceneId, x, y }); setState((s) => ({ ...s, project: res.project })); }; const removeSceneGraphNode = async (nodeId: GraphNodeId) => { const res = await api.invoke(ipcChannels.project.removeSceneGraphNode, { nodeId }); setState((s) => ({ ...s, project: res.project })); }; const addSceneGraphEdge = async (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => { const res = await api.invoke(ipcChannels.project.addSceneGraphEdge, { sourceGraphNodeId, targetGraphNodeId, }); setState((s) => ({ ...s, project: res.project })); }; const removeSceneGraphEdge = async (edgeId: string) => { const res = await api.invoke(ipcChannels.project.removeSceneGraphEdge, { edgeId }); setState((s) => ({ ...s, project: res.project })); }; const setSceneGraphNodeStart = async (graphNodeId: GraphNodeId | null) => { const res = await api.invoke(ipcChannels.project.setSceneGraphNodeStart, { graphNodeId }); setState((s) => ({ ...s, project: res.project })); }; const setSceneGraphNodeSideStoryStart = async (graphNodeId: GraphNodeId) => { const res = await api.invoke(ipcChannels.project.setSceneGraphNodeSideStoryStart, { graphNodeId }); setState((s) => ({ ...s, project: res.project })); }; const updateSideStoryLineTitle = async (graphNodeId: GraphNodeId, title: string) => { const res = await api.invoke(ipcChannels.project.updateSideStoryLineTitle, { graphNodeId, title }); setState((s) => ({ ...s, project: res.project })); }; const deleteScene = async (sceneId: SceneId) => { const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId ?? null, })); await refreshProjects(); }; const setSceneListOrder = async (sceneListOrder: SceneId[]) => { setState((s) => { const p = s.project; if (!p) return s; return { ...s, project: { ...p, sceneListOrder } }; }); const res = await api.invoke(ipcChannels.project.setSceneListOrder, { sceneListOrder }); setState((s) => ({ ...s, project: res.project })); }; const renameProject = async (name: string, fileBaseName: string) => { const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName }); setState((s) => ({ ...s, project: res.project })); await refreshProjects(); }; const importProject = async () => { try { const res = await api.invoke(ipcChannels.project.importZip, {}); if (res.canceled) return; setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId, })); await refreshProjects(); } finally { setState((s) => ({ ...s, zipProgress: null })); } }; const importProjectFromPath = async (filePath: string) => { try { const res = await api.invoke(ipcChannels.project.importZipFromPath, { filePath }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId, })); await refreshProjects(); } finally { setState((s) => ({ ...s, zipProgress: null })); } }; const pickFoundrySource = async (mode: 'folder' | 'archive') => { return api.invoke(ipcChannels.project.pickFoundrySource, { mode }); }; const importFoundryProject = async (sourcePath: string) => { try { const res = await api.invoke(ipcChannels.project.importFoundry, { sourcePath }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId, })); await refreshProjects(); } finally { setState((s) => ({ ...s, zipProgress: null })); } }; const peekImportZip = async (labels: StorylineLabels, targetHasMainStart: boolean) => { return api.invoke(ipcChannels.project.peekImportZip, { labels, targetHasMainStart }); }; const pickImportZipFile = async () => { return api.invoke(ipcChannels.project.pickImportZipFile, {}); }; const peekImportZipPath = async ( filePath: string, labels: StorylineLabels, targetHasMainStart: boolean, ) => { return api.invoke(ipcChannels.project.peekImportZipPath, { filePath, labels, targetHasMainStart }); }; const peekImportFromProject = async ( sourceProjectId: ProjectId, labels: StorylineLabels, targetHasMainStart: boolean, ) => { return api.invoke(ipcChannels.project.peekImportFromProject, { sourceProjectId, labels, targetHasMainStart, }); }; const mergeImportZip = async ( filePath: string, storylineSelections: StorylineSelection[], sceneResolutions: SceneImportResolution[], npcResolutions?: NpcImportResolution[], ) => { const res = await api.invoke(ipcChannels.project.mergeImportZip, { filePath, storylineSelections, sceneResolutions, ...(npcResolutions ? { npcResolutions } : {}), }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId, })); return res; }; const mergeImportFromProject = async ( sourceProjectId: ProjectId, storylineSelections: StorylineSelection[], sceneResolutions: SceneImportResolution[], npcResolutions?: NpcImportResolution[], ) => { const res = await api.invoke(ipcChannels.project.mergeImportFromProject, { sourceProjectId, storylineSelections, sceneResolutions, ...(npcResolutions ? { npcResolutions } : {}), }); setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId ?? s.selectedSceneId, })); return res; }; const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => { const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels }); return res.storylines; }; const exportProject = async ( projectId: ProjectId, storylineSelections: StorylineSelection[], labels: StorylineLabels, ) => { try { const res = await api.invoke(ipcChannels.project.exportZip, { projectId, storylineSelections, labels, }); if (res.canceled) return; } finally { setState((s) => ({ ...s, zipProgress: null })); } }; const deleteProject = async (projectId: ProjectId) => { projectDataEpochRef.current += 1; await api.invoke(ipcChannels.project.deleteProject, { projectId }); const listRes = await api.invoke(ipcChannels.project.list, {}); const res = await api.invoke(ipcChannels.project.get, {}); setState((s) => ({ ...s, projects: listRes.projects, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null, })); }; return { refreshProjects, createProject, openProject, closeProject, createScene, createScenesFromMediaPaths, selectScene, importCampaignAudio, importCampaignAudioFromPaths, updateCampaignAudios, upsertMaterial, deleteMaterial, setMaterialsOrder, setMaterialRotation, setMaterialLegend, pickMaterialImage, updateScene, updateConnections, importMediaToScene, importMediaToSceneFromPaths, importScenePreview, importScenePreviewFromPath, clearScenePreview, updateSceneGraphNodePosition, addSceneGraphNode, removeSceneGraphNode, addSceneGraphEdge, removeSceneGraphEdge, setSceneGraphNodeStart, setSceneGraphNodeSideStoryStart, updateSideStoryLineTitle, deleteScene, setSceneListOrder, renameProject, importProject, importProjectFromPath, pickFoundrySource, importFoundryProject, peekImportZip, pickImportZipFile, peekImportZipPath, peekImportFromProject, mergeImportZip, mergeImportFromProject, getProjectStorylines, exportProject, deleteProject, }; }, [api]); useEffect(() => { const epoch = ++projectDataEpochRef.current; void (async () => { try { const listRes = await api.invoke(ipcChannels.project.list, {}); if (projectDataEpochRef.current !== epoch) return; if (!licenseActive) { setState((s) => ({ ...s, projects: listRes.projects, project: null, selectedSceneId: null, })); return; } setState((s) => ({ ...s, projects: listRes.projects })); const res = await api.invoke(ipcChannels.project.get, {}); if (projectDataEpochRef.current !== epoch) return; setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null, })); } catch { if (projectDataEpochRef.current !== epoch) return; if (!licenseActive) { setState((s) => ({ ...s, project: null, selectedSceneId: null })); } } })(); }, [licenseActive, api]); return [state, actions] as const; }