diff --git a/apps/web/src/components/project-controls.tsx b/apps/web/src/components/project-controls.tsx new file mode 100644 index 0000000..1d6dd1b --- /dev/null +++ b/apps/web/src/components/project-controls.tsx @@ -0,0 +1,78 @@ +import { useRef, useState } from 'react' +import { Download, Upload } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { downloadProject, importProject } from '@/lib/project' +import { usePhotoStore } from '@/stores/photo-store' +import { settingsSnapshot, useSettingsStore } from '@/stores/settings-store' + +export function ProjectControls() { + const photos = usePhotoStore((state) => state.photos) + const replaceAll = usePhotoStore((state) => state.replaceAll) + const inputRef = useRef(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const handleSave = async () => { + setBusy(true) + try { + await downloadProject( + photos, + settingsSnapshot(useSettingsStore.getState()), + ) + } finally { + setBusy(false) + } + } + + const handleOpen = async (file: File) => { + setBusy(true) + setError(null) + try { + const { photos: imported, settings } = await importProject(file) + replaceAll(imported) + useSettingsStore.setState(settings) + } catch { + setError('Could not open that project file.') + } finally { + setBusy(false) + } + } + + return ( +
+
+ { + const file = event.target.files?.[0] + if (file) void handleOpen(file) + event.target.value = '' + }} + /> + + +
+ {error && {error}} +
+ ) +} diff --git a/apps/web/src/lib/project.ts b/apps/web/src/lib/project.ts new file mode 100644 index 0000000..060bf70 --- /dev/null +++ b/apps/web/src/lib/project.ts @@ -0,0 +1,112 @@ +import { + type Crop, + DEFAULT_CROP, + DEFAULT_ORIENTATION, + type Orientation, +} from '@/lib/crop' +import { type Photo } from '@/lib/photos' +import { type SettingsSnapshot } from '@/stores/settings-store' + +// A portable project file: the layout, captions, settings and the images +// themselves, all in one self-contained JSON. No account, no cloud — just a +// file you own, that re-opens the exact same sheet anywhere. + +const PROJECT_VERSION = 1 + +interface ProjectPhoto { + name: string + captionTop: string + captionBottom: string + place?: { city: string; country: string } + takenAt?: number + cameraLine?: string + crop: Crop + orientation: Orientation + /** The image embedded as a data URL (base64). */ + dataUrl: string +} + +interface ProjectFile { + version: number + settings: SettingsSnapshot + photos: ProjectPhoto[] +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) + }) +} + +export async function buildProjectFile( + photos: Photo[], + settings: SettingsSnapshot, +): Promise { + const projectPhotos = await Promise.all( + photos.map(async (photo) => ({ + name: photo.name, + captionTop: photo.captionTop, + captionBottom: photo.captionBottom, + place: photo.place, + takenAt: photo.takenAt, + cameraLine: photo.cameraLine, + crop: photo.crop, + orientation: photo.orientation, + dataUrl: await fileToDataUrl(photo.file), + })), + ) + return { version: PROJECT_VERSION, settings, photos: projectPhotos } +} + +export async function downloadProject( + photos: Photo[], + settings: SettingsSnapshot, + filename = 'polaroid-project.json', +): Promise { + const project = await buildProjectFile(photos, settings) + const blob = new Blob([JSON.stringify(project)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} + +/** Parses a project file back into live photos (new ids, fresh object URLs). */ +export async function importProject(file: File): Promise<{ + photos: Photo[] + settings: Partial +}> { + const parsed = JSON.parse(await file.text()) as ProjectFile + if (!parsed || !Array.isArray(parsed.photos)) { + throw new Error('Invalid project file') + } + const photos = await Promise.all( + parsed.photos.map(async (entry) => { + const blob = await (await fetch(entry.dataUrl)).blob() + const rebuilt = new File([blob], entry.name || 'photo', { + type: blob.type, + }) + return { + id: crypto.randomUUID(), + file: rebuilt, + url: URL.createObjectURL(rebuilt), + name: rebuilt.name, + size: rebuilt.size, + captionTop: entry.captionTop ?? '', + captionBottom: entry.captionBottom ?? '', + place: entry.place, + takenAt: entry.takenAt, + cameraLine: entry.cameraLine, + crop: entry.crop ?? DEFAULT_CROP, + orientation: entry.orientation ?? DEFAULT_ORIENTATION, + enriching: false, + } satisfies Photo + }), + ) + return { photos, settings: parsed.settings ?? {} } +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 7d880e4..db26fb8 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from '@tanstack/react-router' import { A4Preview } from '@/components/a4-preview' import { PhotoDropzone } from '@/components/photo-dropzone' import { PhotoGrid } from '@/components/photo-grid' +import { ProjectControls } from '@/components/project-controls' import { usePhotoStore } from '@/stores/photo-store' export const Route = createFileRoute('/')({ @@ -14,12 +15,15 @@ function Home() { return (
-
-

Polaroid

-

- Add your photos to start building polaroid-style A4 sheets. Everything - stays on your device. -

+
+
+

Polaroid

+

+ Add your photos to start building polaroid-style print sheets. + Everything stays on your device. +

+
+
diff --git a/apps/web/src/stores/photo-store.ts b/apps/web/src/stores/photo-store.ts index b8ce424..82eed49 100644 --- a/apps/web/src/stores/photo-store.ts +++ b/apps/web/src/stores/photo-store.ts @@ -30,6 +30,8 @@ interface PhotoState { /** Re-derives auto date captions when the date format changes. */ applyDateFormat: (format: DateFormat) => void remove: (id: string) => void + /** Replaces the whole set (e.g. opening a project file), revoking old URLs. */ + replaceAll: (photos: Photo[]) => void clear: () => void } @@ -172,6 +174,11 @@ export const usePhotoStore = create((set) => { if (target) URL.revokeObjectURL(target.url) return { photos: state.photos.filter((photo) => photo.id !== id) } }), + replaceAll: (photos) => + set((state) => { + state.photos.forEach((photo) => URL.revokeObjectURL(photo.url)) + return { photos } + }), clear: () => set((state) => { state.photos.forEach((photo) => URL.revokeObjectURL(photo.url)) diff --git a/apps/web/src/stores/settings-store.ts b/apps/web/src/stores/settings-store.ts index e1b5b27..2f21393 100644 --- a/apps/web/src/stores/settings-store.ts +++ b/apps/web/src/stores/settings-store.ts @@ -26,6 +26,14 @@ export interface SettingsSnapshot { showCaptions: boolean } +/** Extracts the persistable settings (no action functions) from store state. */ +export function settingsSnapshot(state: SettingsState): SettingsSnapshot { + return PERSISTED_SETTINGS_KEYS.reduce( + (snapshot, key) => ({ ...snapshot, [key]: state[key] }), + {} as SettingsSnapshot, + ) +} + export const PERSISTED_SETTINGS_KEYS: (keyof SettingsSnapshot)[] = [ 'framePadding', 'captionFontId',