feat: add project management features including open/save functionality and recovery support

This commit is contained in:
syntaxbullet
2026-07-10 23:46:01 +02:00
parent 6d652c3264
commit 9697075d29
15 changed files with 613 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect } from "react";
import { DownloadSimple, FolderOpen, Sparkle, Stack } from "@phosphor-icons/react";
import { DownloadSimple, FloppyDisk, FolderOpen, FolderSimple, Sparkle, Stack } from "@phosphor-icons/react";
import type { ImageStudioApp } from "@app/app";
import { commandIds } from "@commands/ids";
import { BottomControlsIsland } from "./BottomControlsIsland";
@@ -19,6 +19,7 @@ import { downloadArtboardPng } from "@operations/export/downloadArtboard";
import { useImageImport } from "./useImageImport";
import { useViewportActivityIsland } from "./useViewportActivityIsland";
import { loadGenerationResources } from "@operations/generation/loadResources";
import { useProjectLifecycle } from "./useProjectLifecycle";
import "./index.css";
export type AppProps = {
@@ -30,6 +31,7 @@ export function App({ app }: AppProps) {
const { document, selection, viewport, tools, generation, commandPalette, transformSession, maskEdit, workspace } = shellState;
const viewportActivityIsland = useViewportActivityIsland(viewport);
const imageImport = useImageImport(app.store);
const project = useProjectLifecycle(app.store);
const transformTarget = transformSession?.target ?? selectedTransformTarget(document, selection);
const activeArtboard = document.artboards.find((artboard) => artboard.id === selection.artboardId) ?? document.artboards[0];
const generateOpen = workspace.panel === "generate";
@@ -133,6 +135,7 @@ export function App({ app }: AppProps) {
return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
{imageImport.input}
{project.input}
<CommandPalette
state={commandPalette}
document={document}
@@ -156,6 +159,12 @@ export function App({ app }: AppProps) {
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker}>
<FolderOpen size={24} />
</button>
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} title="Open project (Cmd/Ctrl+Shift+O)">
<FolderSimple size={24} />
</button>
<button type="button" className={topBarButtonClass()} onClick={project.save} title="Save project (Cmd/Ctrl+S)">
<FloppyDisk size={24} />
</button>
<button type="button" className={topBarButtonClass(generateOpen)} aria-pressed={generateOpen} onClick={toggleGenerate}>
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
</button>

View File

@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef } from "react";
import type { AppStore } from "@editor/store";
import { createProjectLifecycle } from "@operations/project/lifecycle";
import { projectFileName } from "@operations/project/format";
import { browserRecoveryStorage, downloadProjectFile, readProjectFile } from "@platform/browser/projectFiles";
export function useProjectLifecycle(store: AppStore) {
const inputRef = useRef<HTMLInputElement | null>(null);
const lifecycleRef = useRef<ReturnType<typeof createProjectLifecycle> | null>(null);
useEffect(() => {
const lifecycle = createProjectLifecycle({ store, storage: browserRecoveryStorage });
lifecycleRef.current = lifecycle;
void lifecycle.recover();
return () => {
lifecycle.dispose();
lifecycleRef.current = null;
};
}, [store]);
const save = useCallback(() => {
const lifecycle = lifecycleRef.current;
if (!lifecycle) return;
const document = store.getState().document;
downloadProjectFile(lifecycle.snapshot(), projectFileName(document.name));
void lifecycle.markSaved();
}, [store]);
const openFilePicker = useCallback(() => inputRef.current?.click(), []);
const openFile = useCallback(async (file: File) => {
const lifecycle = lifecycleRef.current;
if (!lifecycle) return;
try {
await lifecycle.open(await readProjectFile(file));
} catch (error) {
window.alert(error instanceof Error ? error.message : "The project could not be opened.");
}
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || (!event.metaKey && !event.ctrlKey)) return;
if (event.key.toLowerCase() === "s") {
event.preventDefault();
save();
} else if (event.shiftKey && event.key.toLowerCase() === "o") {
event.preventDefault();
openFilePicker();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [openFilePicker, save]);
const input = (
<input
ref={inputRef}
type="file"
accept=".json,.image-studio.json,application/json"
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void openFile(file);
}}
/>
);
return { input, save, openFilePicker };
}