feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations

This commit is contained in:
2026-07-23 07:39:01 +05:30
parent 7bcd1a2a2d
commit 2613c767a6
107 changed files with 20699 additions and 7 deletions
+122
View File
@@ -0,0 +1,122 @@
import { STORAGE_KEY } from '../constants';
import { normalizeMediaItem } from '../lib/media';
import type { Album, MediaItem } from '../types';
import type { PersistedState, StorageAdapter } from './types';
const CURRENT_VERSION = 1;
/**
* Default zero-config adapter. Persists library metadata to localStorage and
* (optionally) binary blobs to IndexedDB so imported files survive reloads.
*
* Safe under SSR: all browser APIs are guarded with `typeof window` checks.
*/
export function createLocalStorageAdapter(key: string = STORAGE_KEY): StorageAdapter {
const hasWindow = typeof window !== 'undefined';
return {
name: 'localStorage',
async load(): Promise<PersistedState | null> {
if (!hasWindow) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedState;
if (!parsed || typeof parsed !== 'object') return null;
// Field-level validation — never trust persisted data blindly. Every media
// item is re-normalized (URL scheme allow-list, string coercion) so a
// tampered localStorage record cannot inject unsafe values into the UI.
const media: MediaItem[] = Array.isArray(parsed.media)
? parsed.media
.map((m) => normalizeMediaItem(m))
.filter((m): m is MediaItem => m !== null)
: [];
const albums: Album[] = Array.isArray(parsed.albums)
? parsed.albums
.filter((a): a is Album => Boolean(a) && typeof a === 'object')
.map((a) => ({
...a,
name: typeof a.name === 'string' ? a.name : 'Album',
mediaIds: Array.isArray(a.mediaIds)
? a.mediaIds.filter((id): id is string => typeof id === 'string')
: [],
}))
: [];
// Keep only string→string entries — never trust persisted data blindly.
const labelAliases: Record<string, string> =
parsed.labelAliases && typeof parsed.labelAliases === 'object'
? Object.fromEntries(
Object.entries(parsed.labelAliases).filter(
([k, v]) => typeof k === 'string' && typeof v === 'string',
),
)
: {};
const deletedLabels: string[] = Array.isArray(parsed.deletedLabels)
? (parsed.deletedLabels as unknown[]).filter((l): l is string => typeof l === 'string')
: [];
return {
media,
albums,
people: Array.isArray(parsed.people) ? parsed.people : [],
labelAliases,
deletedLabels,
version: typeof parsed.version === 'number' ? parsed.version : CURRENT_VERSION,
};
} catch {
return null;
}
},
async save(state: PersistedState): Promise<void> {
if (!hasWindow) return;
try {
window.localStorage.setItem(
key,
JSON.stringify({ ...state, version: CURRENT_VERSION }),
);
} catch {
// Quota exceeded or storage disabled — fail silently, app still works in-memory.
}
},
async putBlob(id: string, blob: Blob): Promise<string> {
if (!hasWindow) return '';
const db = await openBlobDb();
if (!db) return URL.createObjectURL(blob);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').put(blob, id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return URL.createObjectURL(blob);
},
async clear(): Promise<void> {
if (!hasWindow) return;
try {
window.localStorage.removeItem(key);
} catch {
/* noop */
}
},
};
}
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openBlobDb(): Promise<IDBDatabase | null> {
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
const req = indexedDB.open('photo-gallery-sdk-blobs', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('blobs')) db.createObjectStore('blobs');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
return dbPromise;
}
+84
View File
@@ -0,0 +1,84 @@
import type { Album, AlbumId, MediaId, MediaItem, Person, PersonId } from '../types';
export interface PersistedState {
media: MediaItem[];
albums: Album[];
people: Person[];
/**
* User's permanent object-tag renames (canonical lowercased detector label →
* chosen label). Optional for backward compatibility with pre-rename data.
*/
labelAliases?: Record<string, string>;
deletedLabels?: string[];
version: number;
}
/**
* An incremental diff of the library, produced by the store on every persist.
*
* Only the entities that actually changed since the previous successful persist
* are included: an entity appears in `upsert*` when it is new or its object
* reference changed, and in `remove*` when it disappeared from state. The label
* maps are included only when they differ from the last persisted values.
* Every field is optional — an empty object means "nothing changed".
*/
export interface StateChanges {
/** Media items that were created or modified since the last persist. */
upsertMedia?: MediaItem[];
/** Ids of media items that no longer exist (permanently deleted). */
removeMedia?: MediaId[];
/** User albums (never system albums) created or modified since the last persist. */
upsertAlbums?: Album[];
/** Ids of user albums that no longer exist. */
removeAlbums?: AlbumId[];
/** People/pet clusters created or modified since the last persist. */
upsertPeople?: Person[];
/** Ids of people/pet clusters that no longer exist. */
removePeople?: PersonId[];
/** Whole label-alias map — sent only when it changed (shallow compare). */
labelAliases?: Record<string, string>;
/** Whole deleted-label list — sent only when it changed (shallow compare). */
deletedLabels?: string[];
}
/** The result of a durable byte upload: an adapter-owned ref plus a readable URL. */
export interface StoredBlob {
/** Durable, adapter-owned reference for the bytes. Survives reloads. */
ref: string;
/** A URL the browser can render right now (may be a short-lived signed URL). */
url: string;
}
/**
* Storage adapter contract. Implement this to back the gallery with anything:
* localStorage (default), IndexedDB, a REST/GraphQL API, S3, Postgres, etc.
*
* The default UI calls `load` once on mount and `save` (debounced) on change.
* `putBlob` is optional and only used when importing local File objects that
* need durable URLs.
*/
export interface StorageAdapter {
readonly name: string;
load(): Promise<PersistedState | null>;
save(state: PersistedState): Promise<void>;
/** Persist a binary blob and return a stable URL to it. */
putBlob?(id: string, blob: Blob): Promise<string>;
clear?(): Promise<void>;
/**
* Incremental persistence. When present the store calls this INSTEAD of
* `save()` on every (debounced) change, passing only the entities that
* differ from the previously persisted snapshot. Implement it when the
* backend can apply partial writes — it avoids re-uploading the whole
* library on every favourite toggle. If this rejects, the store rolls its
* snapshot back so the next persist retries the same changes.
*/
applyChanges?(changes: StateChanges): Promise<void>;
/**
* Durable byte storage. When present it is preferred over `putBlob()` for
* imports and camera captures: the store sets `item.src = url` (renderable
* now) and `item.storageRef = ref` (what survives a reload, so the host can
* re-sign the URL later). Falls back to `putBlob()` and finally to an
* inlined `data:` URL when absent.
*/
putMedia?(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob>;
}