0.9.0: scenes — multi-page projects with right-side drawer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 07:17:06 +02:00
1 parent 517705bf55
commit b57994c754
12 files changed
+1193 -176

No files matched your search

+35 -11
View File
@@ -1,6 +1,7 @@
import type { Scene } from '@/types/scene'
import type { Project, Scene } from '@/types/scene'
import { projectFromLegacyScene } from '@/stores/scene'
const FILE_VERSION = 1
const FILE_VERSION = 2
// The on-disk magic stays `animatr-project` so older exports keep loading.
// Only user-visible filenames & strings have rebranded to RUWIO.
const MAGIC = 'animatr-project'
@@ -9,15 +10,19 @@ export interface ProjectFile {
magic: typeof MAGIC
fileVersion: number
savedAt: string
scene: Scene
/** v2+: the new top-level container. Always present in current saves. */
project?: Project
/** v1 (legacy): a single Scene was the project. Kept around so old
* files keep loading; new saves never set this. */
scene?: Scene
}
export function serializeProject(scene: Scene): string {
export function serializeProject(project: Project): string {
const payload: ProjectFile = {
magic: MAGIC,
fileVersion: FILE_VERSION,
savedAt: new Date().toISOString(),
scene,
project,
}
return JSON.stringify(payload, null, 2)
}
@@ -29,7 +34,9 @@ export class ProjectFileError extends Error {
}
}
export function parseProject(text: string): Scene {
/** Parse a project file. Returns a Project regardless of whether the file
* was saved in the v1 (single Scene) or v2+ (Project) format. */
export function parseProject(text: string): Project {
let data: unknown
try {
data = JSON.parse(text)
@@ -43,9 +50,26 @@ export function parseProject(text: string): Scene {
if (obj.magic !== MAGIC) {
throw new ProjectFileError('Not a RUWIO project file (magic missing).')
}
// v2+ payload: project field present.
if (obj.project && typeof obj.project === 'object') {
const p = obj.project as Record<string, unknown>
if (!p.canvas || typeof p.canvas !== 'object') {
throw new ProjectFileError('Project data incomplete: canvas missing.')
}
if (!Array.isArray(p.scenes)) {
throw new ProjectFileError('Project data incomplete: scenes missing.')
}
if (!Array.isArray(p.assets)) {
throw new ProjectFileError('Project data incomplete: assets missing.')
}
return obj.project as Project
}
// v1 payload: lift scene into a single-page project.
const scene = obj.scene
if (!scene || typeof scene !== 'object') {
throw new ProjectFileError('Project file has no scene.')
throw new ProjectFileError('Project file has no project or scene.')
}
const s = scene as Record<string, unknown>
if (!s.canvas || typeof s.canvas !== 'object') {
@@ -57,11 +81,11 @@ export function parseProject(text: string): Scene {
if (!Array.isArray(s.assets)) {
throw new ProjectFileError('Scene data incomplete: assets missing.')
}
return scene as Scene
return projectFromLegacyScene(scene as Scene)
}
export function downloadProject(scene: Scene, filename = 'projekt.ruwio.json') {
const source = serializeProject(scene)
export function downloadProject(project: Project, filename = 'projekt.ruwio.json') {
const source = serializeProject(project)
const blob = new Blob([source], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -87,7 +111,7 @@ export function pickProjectFile(): Promise<File | null> {
})
}
export async function readProjectFile(file: File): Promise<Scene> {
export async function readProjectFile(file: File): Promise<Project> {
const text = await file.text()
return parseProject(text)
}
+21 -5
View File
@@ -1,4 +1,5 @@
import type { Scene } from '@/types/scene'
import type { Project, Scene } from '@/types/scene'
import { projectFromLegacyScene } from '@/stores/scene'
const DB_NAME = 'animatr'
const DB_VERSION = 1
@@ -8,7 +9,11 @@ const MAX_RECENT = 8
export interface StoredProject {
id: string
name: string
scene: Scene
/** v2+: the full project. Always populated for new saves. */
project?: Project
/** v1 (legacy): older records held a single Scene. Loader migrates
* these into a one-page Project on read; new saves never write this. */
scene?: Scene
savedAt: number
thumbnail?: string
}
@@ -48,11 +53,22 @@ function uid(): string {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4)
}
/** In-place migration: older records stored `scene`; newer ones hold
* `project`. Normalise to `project` so callers don't have to branch. */
function migrate(record: StoredProject): StoredProject {
if (!record.project && record.scene) {
record.project = projectFromLegacyScene(record.scene, record.name || 'Scene 1')
delete record.scene
}
return record
}
export async function listRecentProjects(): Promise<StoredProject[]> {
try {
const all = await tx<StoredProject[]>('readonly', (s) => s.getAll() as IDBRequest<StoredProject[]>)
return (all ?? [])
.slice()
.map(migrate)
.sort((a, b) => b.savedAt - a.savedAt)
.slice(0, MAX_RECENT)
} catch {
@@ -67,13 +83,13 @@ export interface SaveOptions {
}
export async function saveProjectToStore(
scene: Scene,
project: Project,
opts: SaveOptions = {},
): Promise<StoredProject> {
const record: StoredProject = {
id: opts.id ?? uid(),
name: opts.name?.trim() || defaultName(),
scene: JSON.parse(JSON.stringify(scene)),
project: JSON.parse(JSON.stringify(project)),
savedAt: Date.now(),
thumbnail: opts.thumbnail,
}
@@ -90,7 +106,7 @@ export async function getStoredProject(id: string): Promise<StoredProject | null
const rec = await tx<StoredProject | undefined>('readonly', (s) =>
s.get(id) as IDBRequest<StoredProject | undefined>,
)
return rec ?? null
return rec ? migrate(rec) : null
} catch {
return null
}