Adds first version of the app. Local storage only.

This commit is contained in:
2026-09-01 14:44:49 -05:00
commit 9710f1ee5a
44 changed files with 5865 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import type { Project } from '../domain/types'
import { normalizeProject } from './exportImport'
import type { ProjectRepository } from './ProjectRepository'
const STORAGE_KEY = 'av-planner:project:v1'
export class LocalStorageProjectRepository implements ProjectRepository {
async load(): Promise<Project | null> {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return null
return normalizeProject(JSON.parse(raw) as Project)
} catch (err) {
console.error('Failed to load project from localStorage', err)
return null
}
}
async save(project: Project): Promise<void> {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(project))
}
async clear(): Promise<void> {
window.localStorage.removeItem(STORAGE_KEY)
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. Today only
* `LocalStorageProjectRepository` implements it; a future backend would add
* e.g. `ApiProjectRepository` (REST/GraphQL) behind this same interface so
* the store/UI layers wouldn't need to change.
*/
export interface ProjectRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
+64
View File
@@ -0,0 +1,64 @@
import type { Project } from '../domain/types'
export function projectToJson(project: Project): string {
return JSON.stringify(project, null, 2)
}
export function downloadProjectFile(project: Project): void {
const json = projectToJson(project)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const safeName = project.name.trim().replace(/[^a-z0-9-_]+/gi, '_') || 'project'
a.href = url
a.download = `${safeName}.avplan.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
class ProjectImportError extends Error {}
/** Minimal structural check — enough to catch "wrong file" without pulling
* in a schema validation library for a single call site. */
function assertLooksLikeProject(value: unknown): asserts value is Project {
if (typeof value !== 'object' || value === null) {
throw new ProjectImportError('File does not contain a JSON object.')
}
const p = value as Partial<Project>
if (typeof p.id !== 'string' || typeof p.name !== 'string') {
throw new ProjectImportError('File is missing required project fields.')
}
if (!Array.isArray(p.devices) || !Array.isArray(p.connections)) {
throw new ProjectImportError('File is missing devices/connections arrays.')
}
}
export async function readProjectFile(file: File): Promise<Project> {
const text = await file.text()
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new ProjectImportError('File is not valid JSON.')
}
assertLooksLikeProject(parsed)
return normalizeProject(parsed)
}
/** Fills in collections added after a project may have been saved/exported,
* so older data (from localStorage or an older export file) doesn't crash
* code that expects these arrays to always exist. */
export function normalizeProject(project: Project): Project {
return {
...project,
customDeviceTemplates: project.customDeviceTemplates ?? [],
customPortTypes: project.customPortTypes ?? [],
customCableTypes: project.customCableTypes ?? [],
customDeviceCategories: project.customDeviceCategories ?? [],
hiddenBuiltInDeviceTemplateIds: project.hiddenBuiltInDeviceTemplateIds ?? [],
}
}
export { ProjectImportError }