first commit

This commit is contained in:
2026-07-18 01:34:52 +05:30
commit d1308bf149
113 changed files with 20593 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# @photo-gallery/sdk
A reusable, macOS Photos-style photo gallery for React / Next.js — light & dark, fully responsive,
pluggable storage and AI. One component, no required CSS framework.
```tsx
import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
export default () => <PhotoGallery photos={myPhotos} theme="system" />;
```
See the [repository README](../../README.md) for full documentation, props, adapters and the
AI-provider interface.
## Build
```bash
pnpm build # → dist/ : index.js (ESM), index.cjs (CJS), index.d.ts, styles.css
```
The package exports source from `src/` for zero-build use inside the monorepo (via
`transpilePackages`), and `dist/` is produced for external publishing.
MIT · Original implementation, not affiliated with Apple.
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@photo-gallery/sdk",
"version": "0.1.0",
"description": "A reusable, macOS Photos-style photo gallery for React / Next.js. The shadcn of photo galleries.",
"license": "MIT",
"type": "module",
"sideEffects": [
"*.css"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles/sdk.css"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsup && node -e \"require('fs').copyFileSync('src/styles/sdk.css','dist/styles.css')\"",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@tanstack/react-virtual": "^3.10.8",
"clsx": "^2.1.1",
"exifr": "^7.1.3",
"framer-motion": "^11.11.9",
"leaflet": "^1.9.4",
"nanoid": "^5.0.7",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/leaflet": "^1.9.12",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tsup": "^8.3.0",
"typescript": "^5.6.3"
}
}
@@ -0,0 +1,108 @@
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')
: [],
}))
: [];
return {
media,
albums,
people: Array.isArray(parsed.people) ? parsed.people : [],
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;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Album, MediaItem, Person } from '../types';
export interface PersistedState {
media: MediaItem[];
albums: Album[];
people: Person[];
version: number;
}
/**
* 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>;
}
+62
View File
@@ -0,0 +1,62 @@
import type { DetectedFace, DetectedObject, MediaItem } from '../types';
/**
* Pluggable AI provider. The UI never calls a model directly — it calls this
* interface, so any backend (free in-browser TensorFlow.js / transformers.js,
* a self-hosted model, or a cloud API like Gemini) can be swapped in.
*
* Every method is optional; features degrade gracefully when a capability is
* absent. Implementations should be lazy (load models on first use).
*/
export interface AIProvider {
readonly name: string;
/** Detect objects (table, chair, laptop, dog, ...). Enables click-to-find. */
detectObjects?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<DetectedObject[]>;
/** Detect (and optionally embed) faces for clustering into People. */
detectFaces?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<DetectedFace[]>;
/** Generate a one-line natural-language caption / description. */
caption?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<string>;
/** Extract printed/handwritten text (OCR) for document search. */
ocr?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<string>;
/** Produce an embedding vector for semantic similarity & NL search. */
embedImage?(item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<number[]>;
embedText?(query: string): Promise<number[]>;
/** Generative edits (sky replace, magic eraser, generative fill, restore). */
generativeEdit?(
item: MediaItem,
image: ImageBitmap | HTMLImageElement,
op: GenerativeEditOp,
): Promise<Blob>;
}
export type GenerativeEditOp =
| { type: 'remove-background' }
| { type: 'replace-sky'; prompt?: string }
| { type: 'magic-eraser'; mask: ImageData }
| { type: 'generative-fill'; prompt: string; mask: ImageData }
| { type: 'restore' }
| { type: 'upscale'; factor: 2 | 4 }
| { type: 'colorize' }
/** Free-form natural-language edit instruction. */
| { type: 'prompt'; prompt: string };
/** Cosine similarity between two equal-length vectors. */
export function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return 0;
let dot = 0;
let na = 0;
let nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i]! * b[i]!;
na += a[i]! * a[i]!;
nb += b[i]! * b[i]!;
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
@@ -0,0 +1,175 @@
'use client';
import { useEffect, useRef } from 'react';
import type { AIProvider } from '../ai/types';
import { PET_LABELS } from '../constants';
import { sanitizeOcrText } from '../lib/text';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaItem } from '../types';
const CONCURRENCY = 2;
const CONFIDENCE = 0.5;
const START_DELAY_MS = 1200;
/**
* Headless background worker: runs the configured AIProvider across un-analyzed
* images and writes results back into the store — objects + objectLabels (powers
* "click an object → find every photo with it" + the Objects browser), faces
* (clustered into People), and OCR text (searchable + the Documents album).
*
* The SDK ships no ML dependency — the provider (TensorFlow.js / face-api /
* tesseract.js) is supplied by the host app, so this stays tiny and tree-shakeable.
*/
export function AIAnalyzer({ provider }: { provider: AIProvider }) {
const api = useGalleryStoreApi();
const ready = useGallery((s) => s.ready);
// Re-evaluate only when the library size changes (not on every metadata write).
const mediaCount = useGallery((s) => s.media.length);
const runningRef = useRef(false);
useEffect(() => {
const canDetect =
provider.detectObjects || provider.detectFaces || provider.ocr || provider.embedImage;
if (!ready || !canDetect || runningRef.current) return;
// Each capability has its OWN backfill gate (not the shared analyzedAt), so a
// capability added in a later session re-processes items analyzed before it
// existed: objects key off analyzedAt; faces off `faces === undefined`; OCR
// off `ocrText === undefined`.
const needsObjects = (m: MediaItem) => !!provider.detectObjects && !m.analyzedAt;
const needsFaces = (m: MediaItem) => !!provider.detectFaces && m.faces === undefined;
const needsOcr = (m: MediaItem) => !!provider.ocr && m.ocrText === undefined;
const needsEmbedding = (m: MediaItem) => !!provider.embedImage && m.embedding === undefined;
// Collect items still needing analysis, NEWEST FIRST — so a freshly uploaded or
// captured photo is tagged immediately instead of waiting behind the whole library.
const collectPending = () =>
api
.getState()
.media.filter(
(m) =>
m.kind === 'image' &&
!m.deletedAt &&
m.src &&
(needsObjects(m) || needsFaces(m) || needsOcr(m) || needsEmbedding(m)),
)
.sort((a, b) => (b.importedAt ?? 0) - (a.importedAt ?? 0));
const pending = collectPending();
if (pending.length === 0) {
// Nothing to analyze, but faces may already exist (e.g. loaded from the
// backend) while People is empty — cluster them once so People populates.
const st = api.getState();
const hasFaces = st.media.some((m) => (m.faces?.length ?? 0) > 0);
const hasObjects = st.media.some((m) => m.objectLabels.length > 0);
// Also (re)build if pets are present but not yet grouped — so libraries
// analyzed before pet grouping existed pick them up on next load.
const petsPresent = st.media.some((m) =>
m.objectLabels.some((l) => (PET_LABELS as readonly string[]).includes(l)),
);
const hasPetGroups = st.people.some((p) => p.isPet);
if ((st.people.length === 0 && (hasFaces || hasObjects)) || (petsPresent && !hasPetGroups)) {
st.rebuildPeople();
}
// Ensure object smart albums exist for an already-analyzed library (e.g. loaded
// from the backend), even when there's nothing new to analyze.
if (hasObjects) st.syncObjectAlbums();
return;
}
runningRef.current = true;
let cancelled = false;
let done = 0;
const analyzeOne = async (item: MediaItem) => {
try {
const img = await loadImage(item.src);
// The three capabilities are independent; only run what's missing for this item.
const wantObjects = needsObjects(item);
const wantFaces = needsFaces(item);
const wantOcr = needsOcr(item);
const wantEmbedding = needsEmbedding(item);
const [objects, faces, ocrText, embedding] = await Promise.all([
wantObjects ? (provider.detectObjects!(item, img) ?? []) : Promise.resolve(null),
wantFaces ? (provider.detectFaces!(item, img) ?? []) : Promise.resolve(null),
// OCR + embedding are the slowest/most failure-prone legs — isolate each
// failure so one can't reject the whole Promise.all and abort the others.
wantOcr ? provider.ocr!(item, img).catch(() => '') : Promise.resolve(null),
wantEmbedding ? provider.embedImage!(item, img).catch(() => []) : Promise.resolve(null),
]);
const patch: Partial<MediaItem> = { analyzedAt: Date.now() };
if (objects) {
patch.objects = objects;
patch.objectLabels = [
...new Set(objects.filter((o) => o.confidence >= CONFIDENCE).map((o) => o.label)),
];
}
if (faces) patch.faces = faces;
// Always patch ocrText/embedding (even '' / []) so their `=== undefined`
// gates flip and the item isn't reprocessed forever.
if (ocrText !== null) patch.ocrText = sanitizeOcrText(ocrText);
if (embedding !== null) patch.embedding = embedding;
api.getState().updateMedia(item.id, patch);
} catch {
// Mark analyzed (incl. faces + ocrText + embedding) on failure so a broken image isn't retried forever.
api.getState().updateMedia(item.id, {
analyzedAt: Date.now(),
faces: item.faces ?? [],
ocrText: item.ocrText ?? '',
embedding: item.embedding ?? [],
});
} finally {
done += 1;
api.getState().setAiStatus({ done });
}
};
const run = async () => {
api.getState().setAiStatus({ running: true, done: 0, total: pending.length });
// Drain in rounds: any items imported/captured WHILE a pass runs are picked up
// by the next round, so uploads get analyzed exactly like captures (no starvation,
// nothing left behind). Each round re-scans and prioritizes the newest items.
while (!cancelled) {
const round = collectPending();
if (round.length === 0) break;
api.getState().setAiStatus({ total: done + round.length });
const queue = [...round];
const worker = async () => {
while (!cancelled && queue.length) {
const item = queue.shift();
if (item) await analyzeOne(item);
}
};
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
}
runningRef.current = false;
if (!cancelled) {
api.getState().setAiStatus({ running: false });
// Faces and/or objects detected this pass → (re)build People & Pets + object albums.
if (provider.detectFaces || provider.detectObjects) {
api.getState().rebuildPeople();
api.getState().syncObjectAlbums();
}
}
};
const timer = setTimeout(() => void run(), START_DELAY_MS);
return () => {
cancelled = true;
runningRef.current = false;
clearTimeout(timer);
};
}, [ready, mediaCount, provider, api]);
return null;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
// Required so the model can read pixels from a cross-origin image.
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
@@ -0,0 +1,45 @@
'use client';
import { useEffect, useRef } from 'react';
import { useIsMobile } from '../hooks/useMediaQuery';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { Sidebar } from './Sidebar';
import { SelectionBar } from './SelectionBar';
import { TopToolbar } from './TopToolbar';
import { ViewRouter } from './ViewRouter';
export function AppShell() {
const api = useGalleryStoreApi();
const sidebarOpen = useGallery((s) => s.sidebarOpen);
const isMobile = useIsMobile();
useKeyboardShortcuts();
// Collapse the sidebar by default on phones; expand on larger screens.
const lastMobile = useRef<boolean | null>(null);
useEffect(() => {
if (lastMobile.current === isMobile) return;
lastMobile.current = isMobile;
api.getState().setSidebar(!isMobile);
}, [isMobile, api]);
return (
<>
<Sidebar />
<div
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
.filter(Boolean)
.join(' ')}
onClick={() => api.getState().setSidebar(false)}
/>
<div className="apg-main">
<TopToolbar />
<div className="apg-viewport">
<ViewRouter />
<SelectionBar />
</div>
</div>
</>
);
}
@@ -0,0 +1,381 @@
'use client';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../icons';
import { createMediaItem } from '../lib/media';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { Annotation, MediaItem } from '../types';
import { Annotations, type AnnotationTool } from './editor/Annotations';
type Mode = 'photo' | 'video';
type Facing = 'user' | 'environment';
const ANN_TOOLS: Array<{ tool: AnnotationTool; label: string; icon: 'check' | 'aspect' | 'chevron-right' | 'crop' | 'tag' | 'wand' }> = [
{ tool: 'select', label: 'Off', icon: 'check' },
{ tool: 'rect', label: 'Box', icon: 'aspect' },
{ tool: 'arrow', label: 'Arrow', icon: 'chevron-right' },
{ tool: 'double-arrow', label: 'Measure', icon: 'crop' },
{ tool: 'text', label: 'Text', icon: 'tag' },
{ tool: 'freehand', label: 'Draw', icon: 'wand' },
];
const ANN_COLORS = ['#ff3b30', '#ffd60a', '#34c759', '#0a84ff', '#ffffff'];
export function Camera() {
const api = useGalleryStoreApi();
const open = useGallery((s) => s.cameraOpen);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const recordStartRef = useRef<number>(0);
const locationRef = useRef<MediaItem['location']>(undefined);
const deviceRef = useRef<{ label?: string; width?: number; height?: number }>({});
const [mode, setMode] = useState<Mode>('photo');
const [facing, setFacing] = useState<Facing>('environment');
const [grid, setGrid] = useState(false);
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState('#ff3b30');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [recording, setRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const [review, setReview] = useState<{ url: string; blob: Blob; kind: 'image' | 'video'; w: number; h: number } | null>(null);
// Start / restart the camera stream when open or facing changes.
useEffect(() => {
if (!open) return;
let cancelled = false;
setError(null);
(async () => {
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Camera API not available in this browser.');
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: true,
}).catch(async () =>
navigator.mediaDevices.getUserMedia({ video: { facingMode: facing } }),
);
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const track = stream.getVideoTracks()[0];
const settings = track?.getSettings?.() ?? {};
deviceRef.current = { label: track?.label || 'Web Camera', width: settings.width, height: settings.height };
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play().catch(() => undefined);
}
} catch (e) {
setError(
e instanceof Error && e.name === 'NotAllowedError'
? 'Camera permission denied. Allow camera access and try again.'
: 'Could not start the camera.',
);
}
})();
return () => {
cancelled = true;
stopStream();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, facing]);
// Prefetch location when the camera opens so a fix is ready by capture time.
useEffect(() => {
if (!open) return;
let cancelled = false;
void getCurrentLocation().then((loc) => {
if (!cancelled && loc) locationRef.current = loc;
});
return () => {
cancelled = true;
};
}, [open]);
// Reset transient state when closing.
useEffect(() => {
if (!open) {
setReview((r) => {
if (r) URL.revokeObjectURL(r.url);
return null;
});
setAnnotations([]);
setRecording(false);
}
}, [open]);
if (!open) return null;
const stopStream = () => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
const close = () => {
if (review) URL.revokeObjectURL(review.url);
stopStream();
api.getState().closeCamera();
};
const capturePhoto = () => {
const video = videoRef.current;
if (!video || !video.videoWidth) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Mirror the front camera to match the on-screen preview.
if (facing === 'user') {
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(video, 0, 0);
canvas.toBlob(
(blob) => {
if (!blob) return;
setReview({ url: URL.createObjectURL(blob), blob, kind: 'image', w: canvas.width, h: canvas.height });
},
'image/jpeg',
0.92,
);
};
const toggleRecord = () => {
if (recording) {
recorderRef.current?.stop();
return;
}
const stream = streamRef.current;
if (!stream) return;
chunksRef.current = [];
const types = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
const mimeType = types.find((t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t));
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'video/webm' });
const v = videoRef.current;
setReview({
url: URL.createObjectURL(blob),
blob,
kind: 'video',
w: v?.videoWidth ?? 1280,
h: v?.videoHeight ?? 720,
});
setRecording(false);
};
recorder.start();
recorderRef.current = recorder;
recordStartRef.current = Date.now();
setRecording(true);
};
const usePhoto = async () => {
if (!review) return;
const now = Date.now();
const id = nanoid(10);
const location = locationRef.current ?? (await getCurrentLocation());
const device = deviceRef.current;
const exif: Record<string, string | number> = {
Make: 'Web Camera',
Model: device.label || 'Web Camera',
};
if (device.width && device.height) exif.Resolution = `${device.width}×${device.height}`;
const duration =
review.kind === 'video'
? Math.max(1, Math.round((Date.now() - recordStartRef.current) / 1000))
: undefined;
// Prefer the backend (Supabase Storage) for a durable URL; otherwise fall back
// to a data URL (durable across reloads for BOTH photos and video). Only if that
// fails do we keep the in-session object URL as a last resort.
const uploaded = await api.getState().uploadBlob(id, review.blob);
const src = uploaded ?? (await blobToDataUrl(review.blob).catch(() => review.url));
const item: MediaItem = createMediaItem({
id,
src,
name: `${review.kind === 'video' ? 'VID' : 'IMG'}_${formatStamp(now)}.${review.kind === 'video' ? 'webm' : 'jpg'}`,
kind: review.kind,
mime: review.kind === 'video' ? (review.blob.type || 'video/webm') : 'image/jpeg',
bytes: review.blob.size,
width: review.w,
height: review.h,
takenAt: now,
source: 'camera',
duration,
location,
exif,
edits: annotations.length ? { adjustments: {}, annotations } : undefined,
});
api.getState().addMedia([item]);
setReview(null);
setAnnotations([]);
close();
api.getState().setView('library');
// Auto object-detection (AIAnalyzer) will tag the new capture when an AI provider is configured.
};
const retake = () => {
if (review) URL.revokeObjectURL(review.url);
setReview(null);
setAnnotations([]);
};
return (
<div className="apg-camera" role="dialog" aria-modal="true" aria-label="Camera">
<div className="apg-camera__bar">
<button type="button" className="apg-iconbtn" aria-label="Close camera" onClick={close}>
<Icon name="close" />
</button>
<div className="apg-segmented" role="tablist" style={{ margin: '0 auto' }}>
{(['photo', 'video'] as Mode[]).map((m) => (
<button
key={m}
type="button"
className={['apg-segmented__item', mode === m ? 'apg-segmented__item--active' : ''].join(' ')}
onClick={() => !recording && setMode(m)}
>
{m === 'photo' ? 'Photo' : 'Video'}
</button>
))}
</div>
<button type="button" className={['apg-iconbtn', grid ? 'apg-iconbtn--on' : ''].join(' ')} aria-label="Grid" onClick={() => setGrid((g) => !g)}>
<Icon name="aspect" />
</button>
<button type="button" className="apg-iconbtn" aria-label="Switch camera" onClick={() => setFacing((f) => (f === 'user' ? 'environment' : 'user'))}>
<Icon name="rotate" />
</button>
</div>
<div className="apg-camera__stage">
{error ? (
<div className="apg-camera__error">
<Icon name="camera" size={42} />
<p>{error}</p>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => setFacing((f) => f)}>
Retry
</button>
</div>
) : review ? (
<div className="apg-camera__preview">
{review.kind === 'image' ? (
<div style={{ position: 'relative', display: 'inline-flex', maxWidth: '100%', maxHeight: '78vh' }}>
<img src={review.url} alt="Captured" />
<Annotations
annotations={annotations}
editable
tool={annTool}
color={annColor}
onChange={setAnnotations}
/>
</div>
) : (
<video src={review.url} controls autoPlay loop />
)}
</div>
) : (
<div className="apg-camera__videowrap" style={{ transform: facing === 'user' ? 'scaleX(-1)' : undefined }}>
<video ref={videoRef} playsInline muted autoPlay />
{grid ? <div className="apg-camera__grid" /> : null}
</div>
)}
</div>
{review?.kind === 'image' ? (
<div className="apg-camera__markup">
{ANN_TOOLS.map((t) => (
<button
key={t.tool}
type="button"
className={['apg-camera__tool', annTool === t.tool ? 'apg-camera__tool--active' : ''].join(' ')}
onClick={() => setAnnTool(t.tool)}
>
<Icon name={t.icon} size={15} /> {t.label}
</button>
))}
<span style={{ width: 1, background: 'rgba(255,255,255,0.2)', margin: '0 4px' }} />
{ANN_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setAnnColor(c)}
className="apg-camera__swatch"
style={{ background: c, outline: annColor === c ? '2px solid #fff' : 'none' }}
/>
))}
<span style={{ width: 1, background: 'rgba(255,255,255,0.2)', margin: '0 4px' }} />
<button
type="button"
className="apg-camera__tool"
disabled={annotations.length === 0}
onClick={() => setAnnotations((a) => a.slice(0, -1))}
>
<Icon name="rotate" size={15} /> Undo
</button>
<button
type="button"
className="apg-camera__tool"
disabled={annotations.length === 0}
onClick={() => setAnnotations([])}
>
<Icon name="trash" size={15} /> Clear
</button>
</div>
) : null}
<div className="apg-camera__controls">
{review ? (
<>
<button type="button" className="apg-btn" onClick={retake}>
Retake
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void usePhoto()}>
Use {review.kind === 'video' ? 'Video' : 'Photo'}
</button>
</>
) : (
<button
type="button"
className={['apg-camera__shutter', mode === 'video' ? 'apg-camera__shutter--video' : '', recording ? 'apg-camera__shutter--recording' : ''].join(' ')}
aria-label={mode === 'video' ? (recording ? 'Stop recording' : 'Record') : 'Capture photo'}
disabled={Boolean(error)}
onClick={mode === 'photo' ? capturePhoto : toggleRecord}
/>
)}
</div>
</div>
);
}
function getCurrentLocation(): Promise<MediaItem['location'] | undefined> {
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) return resolve(undefined);
navigator.geolocation.getCurrentPosition(
(pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => resolve(undefined),
{ enableHighAccuracy: true, timeout: 10_000, maximumAge: 300_000 },
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('read failed'));
reader.readAsDataURL(blob);
});
}
function formatStamp(ms: number): string {
const d = new Date(ms);
const p = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
@@ -0,0 +1,166 @@
'use client';
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Icon, type IconName } from '../icons';
export interface MenuItem {
type?: 'item';
label: string;
icon?: IconName;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
checked?: boolean;
}
export interface MenuSeparator {
type: 'separator';
}
export interface MenuLabel {
type: 'label';
label: string;
}
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel;
interface MenuState {
x: number;
y: number;
entries: MenuEntry[];
}
// Module-level emitter — typically one gallery is interactive at a time.
let current: MenuState | null = null;
const listeners = new Set<() => void>();
function emit() {
for (const l of listeners) l();
}
export function openContextMenu(x: number, y: number, entries: MenuEntry[]) {
current = { x, y, entries };
emit();
}
export function closeContextMenu() {
if (current) {
current = null;
emit();
}
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
function getSnapshot() {
return current;
}
/** Renders the active context menu (mounted once near the gallery root). */
export function ContextMenuHost() {
const state = useSyncExternalStore(subscribe, getSnapshot, () => null);
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
useLayoutEffect(() => {
if (!state || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
const pad = 8;
const left = Math.min(state.x, window.innerWidth - rect.width - pad);
const top = Math.min(state.y, window.innerHeight - rect.height - pad);
setPos({ left: Math.max(pad, left), top: Math.max(pad, top) });
// Move keyboard focus to the first actionable item when the menu opens.
ref.current.querySelector<HTMLButtonElement>('button.apg-menu__item:not([disabled])')?.focus();
}, [state]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const items = Array.from(
ref.current?.querySelectorAll<HTMLButtonElement>('button.apg-menu__item:not([disabled])') ?? [],
);
if (items.length === 0) return;
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
if (e.key === 'ArrowDown') {
e.preventDefault();
items[(idx + 1) % items.length]!.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items[(idx - 1 + items.length) % items.length]!.focus();
} else if (e.key === 'Home') {
e.preventDefault();
items[0]!.focus();
} else if (e.key === 'End') {
e.preventDefault();
items[items.length - 1]!.focus();
}
};
useEffect(() => {
if (!state) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) closeContextMenu();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu();
};
const onScroll = () => closeContextMenu();
window.addEventListener('mousedown', onDown, true);
window.addEventListener('keydown', onKey);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('mousedown', onDown, true);
window.removeEventListener('keydown', onKey);
window.removeEventListener('scroll', onScroll, true);
};
}, [state]);
if (!state) return null;
return (
<div
ref={ref}
className="apg-menu"
role="menu"
style={{ left: pos.left, top: pos.top }}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={onMenuKeyDown}
>
{state.entries.map((entry, i) => {
if (entry.type === 'separator') return <div key={i} className="apg-menu__sep" />;
if (entry.type === 'label')
return (
<div key={i} className="apg-menu__label">
{entry.label}
</div>
);
return (
<button
key={i}
type="button"
role="menuitem"
className={['apg-menu__item', entry.danger ? 'apg-menu__item--danger' : '']
.filter(Boolean)
.join(' ')}
disabled={entry.disabled}
onClick={() => {
closeContextMenu();
entry.onClick();
}}
>
{entry.icon ? (
<span className="apg-menu__icon">
<Icon name={entry.icon} size={16} />
</span>
) : (
<span className="apg-menu__icon" />
)}
<span>{entry.label}</span>
{entry.checked ? (
<span className="apg-menu__check">
<Icon name="check" size={15} />
</span>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,407 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { sourceLabel } from '../lib/classify';
import { editFilterCss, editTransformCss } from '../lib/edits';
import { formatBytes, formatDate, formatDuration, formatTime } from '../lib/format';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaItem } from '../types';
export function InfoPanel() {
const api = useGalleryStoreApi();
const open = useGallery((s) => s.infoOpen);
const media = useGallery((s) => s.media);
const lightboxId = useGallery((s) => s.lightboxId);
const selection = useGallery((s) => s.selection);
if (!open) return null;
// Target: the lightbox item, else the single selected item.
const targetId = lightboxId ?? (selection.size === 1 ? [...selection][0] : undefined);
const item = targetId ? (media.find((m) => m.id === targetId) ?? null) : null;
const ext = item?.name.includes('.') ? item.name.split('.').pop()!.toUpperCase() : '—';
const mp = item ? ((item.width * item.height) / 1_000_000).toFixed(1) : '0';
return (
<aside className="apg-info" aria-label="Info">
<div className="apg-info__head">
<span style={{ fontWeight: 700, fontSize: 15 }}>Info</span>
<button
type="button"
className="apg-iconbtn"
aria-label="Close info"
onClick={() => api.getState().setInfoOpen(false)}
>
<Icon name="close" size={18} />
</button>
</div>
{!item ? (
<div className="apg-info__empty">Select a single photo to see its details.</div>
) : (
<div className="apg-info__body">
<img className="apg-info__thumb" src={item.thumbnail ?? item.src} alt={item.name} />
<div className="apg-info__name">{item.name}</div>
<div className="apg-info__sub">
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
</div>
<Row label="Kind" value={item.kind === 'video' ? 'Video' : 'Photo'} />
<Row label="Source" value={sourceLabel(item.source)} />
<Row label="Format" value={`${item.mime} (${ext})`} />
<Row label="Dimensions" value={`${item.width} × ${item.height} · ${mp} MP`} />
<Row label="Size" value={formatBytes(item.bytes)} />
{item.duration ? <Row label="Duration" value={formatDuration(item.duration)} /> : null}
{item.exif?.Make || item.exif?.Model ? (
<Row label="Camera" value={`${item.exif.Make ?? ''} ${item.exif.Model ?? ''}`.trim()} />
) : null}
{item.exif?.FNumber ? (
<Row
label="Exposure"
value={[
item.exif.FNumber ? `ƒ${item.exif.FNumber}` : '',
item.exif.ISO ? `ISO ${item.exif.ISO}` : '',
item.exif.FocalLength ? `${item.exif.FocalLength}mm` : '',
]
.filter(Boolean)
.join(' · ')}
/>
) : null}
{item.objectLabels.length ? (
<Chips
label="Objects"
items={item.objectLabels}
onClick={(o) => {
const s = api.getState();
s.setObjectFocus(o);
s.closeLightbox();
s.setInfoOpen(false);
}}
/>
) : null}
{item.tags.length ? (
<Chips
label="Tags"
items={item.tags}
onClick={(t) => {
const s = api.getState();
s.setTagFocus(t);
s.closeLightbox();
s.setInfoOpen(false);
}}
/>
) : null}
{item.location ? (
<>
<Row
label="Location"
value={
item.location.place ??
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
}
/>
<div className="apg-info__coords">
{item.location.lat.toFixed(5)}, {item.location.lng.toFixed(5)}
</div>
<MiniMap
lat={item.location.lat}
lng={item.location.lng}
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
/>
<div style={{ fontSize: 11, color: 'var(--apg-text-tertiary)', marginTop: 4 }}>
Click the map to open it in full.
</div>
</>
) : null}
<Versions item={item} />
<Comments item={item} />
</div>
)}
</aside>
);
}
/** Version history + audit log for a photo/video (v1 = original). */
function Versions({ item }: { item: MediaItem }) {
const api = useGalleryStoreApi();
const [openId, setOpenId] = useState<string | null>(null);
const versions = item.versions ?? [];
// Newest first; the last entry is the current one.
const ordered = [...versions].reverse();
const currentId = versions.length ? versions[versions.length - 1]!.id : null;
return (
<div className="apg-info__section">
<div className="apg-info__section-head">
<Icon name="clock" size={15} />
<span>Version history</span>
<span className="apg-info__count">{versions.length || 1}</span>
</div>
{versions.length === 0 ? (
<div className="apg-info__hint">
Only the original exists. Edits create new versions the original is never overwritten.
</div>
) : (
<ol className="apg-versions">
{ordered.map((v) => {
const isOpen = openId === v.id;
const isCurrent = v.id === currentId;
const isOriginal = v.version === 1;
return (
<li key={v.id} className="apg-version">
<button
type="button"
className={`apg-version__row${isCurrent ? ' is-current' : ''}`}
onClick={() => setOpenId(isOpen ? null : v.id)}
aria-expanded={isOpen}
>
<span className="apg-version__thumb-wrap">
<img
className="apg-version__thumb"
src={v.thumbnail ?? v.src ?? item.thumbnail ?? item.src}
alt=""
style={{
filter: editFilterCss(v.edits) || undefined,
transform: editTransformCss(v.edits) || undefined,
}}
/>
</span>
<span className="apg-version__meta">
<span className="apg-version__title">
{isOriginal ? 'Original' : `Version ${v.version}`}
{isCurrent ? <span className="apg-version__badge">Current</span> : null}
</span>
<span className="apg-version__time">
{formatDate(v.createdAt)} · {formatTime(v.createdAt)}
</span>
</span>
<Icon name={isOpen ? 'chevron-down' : 'chevron-right'} size={14} />
</button>
{isOpen ? (
<div className="apg-version__detail">
<div className="apg-version__changes-label">What changed</div>
<ul className="apg-version__changes">
{v.changes.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
{!isCurrent ? (
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() => api.getState().restoreVersion(item.id, v.id)}
>
Restore this version
</button>
) : null}
</div>
) : null}
</li>
);
})}
</ol>
)}
</div>
);
}
/** Threaded comments on a photo/video. */
function Comments({ item }: { item: MediaItem }) {
const api = useGalleryStoreApi();
const comments = item.comments ?? [];
const [text, setText] = useState('');
const [author, setAuthor] = useState(() => {
if (typeof window === 'undefined') return 'You';
return window.localStorage.getItem('apg:comment-author') || 'You';
});
const post = () => {
const t = text.trim();
if (!t) return;
const name = author.trim() || 'You';
try {
window.localStorage.setItem('apg:comment-author', name);
} catch {
/* ignore */
}
api.getState().addComment(item.id, t, name);
setText('');
};
return (
<div className="apg-info__section">
<div className="apg-info__section-head">
<Icon name="chat" size={15} />
<span>Comments</span>
<span className="apg-info__count">{comments.length}</span>
</div>
{comments.length === 0 ? (
<div className="apg-info__hint">No comments yet. Start the conversation below.</div>
) : (
<ul className="apg-comments">
{comments.map((c) => (
<li key={c.id} className="apg-comment">
<div className="apg-comment__avatar" aria-hidden>
{(c.author ?? 'You').slice(0, 1).toUpperCase()}
</div>
<div className="apg-comment__body">
<div className="apg-comment__meta">
<span className="apg-comment__author">{c.author ?? 'You'}</span>
<span className="apg-comment__time">
{formatDate(c.createdAt)} · {formatTime(c.createdAt)}
</span>
</div>
<div className="apg-comment__text">{c.text}</div>
</div>
<button
type="button"
className="apg-comment__delete"
aria-label="Delete comment"
onClick={() => api.getState().deleteComment(item.id, c.id)}
>
<Icon name="close" size={13} />
</button>
</li>
))}
</ul>
)}
<div className="apg-comment-form">
<input
className="apg-comment-form__author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="Your name"
aria-label="Your name"
maxLength={40}
/>
<textarea
className="apg-comment-form__input"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') post();
}}
placeholder="Add a comment…"
aria-label="Add a comment"
rows={2}
maxLength={2000}
/>
<button
type="button"
className="apg-btn apg-btn--small apg-comment-form__post"
onClick={post}
disabled={!text.trim()}
>
Post
</button>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="apg-info__row">
<span className="apg-info__row-label">{label}</span>
<span className="apg-info__row-value">{value}</span>
</div>
);
}
function Chips({
label,
items,
onClick,
}: {
label: string;
items: string[];
onClick?: (item: string) => void;
}) {
return (
<div className="apg-info__chips">
<span className="apg-info__row-label">{label}</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{items.map((t) => (
<button
key={t}
type="button"
className="apg-chip"
onClick={onClick ? () => onClick(t) : undefined}
style={{ cursor: onClick ? 'pointer' : 'default', textTransform: 'capitalize' }}
>
{t}
</button>
))}
</div>
</div>
);
}
/** Small non-interactive Leaflet map for the location preview (click to open full Map). */
function MiniMap({ lat, lng, onOpen }: { lat: number; lng: number; onOpen?: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
useEffect(() => {
let cancelled = false;
void import('leaflet').then((mod) => {
const L = (mod as any).default ?? mod;
if (cancelled || !ref.current || mapRef.current) return;
const map = L.map(ref.current, {
zoomControl: false,
attributionControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
tap: false,
}).setView([lat, lng], 11);
mapRef.current = map;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
L.circleMarker([lat, lng], {
radius: 7,
color: '#fff',
weight: 2,
fillColor: '#0a84ff',
fillOpacity: 1,
}).addTo(map);
});
return () => {
cancelled = true;
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
};
}, [lat, lng]);
return (
<div style={{ position: 'relative' }}>
<div ref={ref} className="apg-info__map" />
{onOpen ? (
<button
type="button"
aria-label="Open in Map"
onClick={onOpen}
style={{
position: 'absolute',
inset: 0,
background: 'transparent',
border: 'none',
cursor: 'pointer',
zIndex: 500,
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,199 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useRef } from 'react';
import { downloadMedia } from '../lib/download';
import { editFilterCss, editTransformCss } from '../lib/edits';
import { formatDate, formatTime } from '../lib/format';
import { Icon } from '../icons';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { useViewMedia } from '../hooks/useViewMedia';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { Annotations } from './editor/Annotations';
import { openShareModal } from './modals';
import { VideoPlayer } from './VideoPlayer';
export function Lightbox() {
const api = useGalleryStoreApi();
const lightboxId = useGallery((s) => s.lightboxId);
const media = useGallery((s) => s.media);
const features = useGallery((s) => s.config.features);
const ordered = useViewMedia();
const item = media.find((m) => m.id === lightboxId) ?? null;
const index = ordered.findIndex((m) => m.id === lightboxId);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape is handled by the window listener below; trap only manages Tab + focus.
useFocusTrap(dialogRef, Boolean(item));
const go = useCallback(
(dir: -1 | 1) => {
if (index === -1) return;
const next = ordered[index + dir];
if (next) api.getState().openLightbox(next.id);
},
[api, index, ordered],
);
useEffect(() => {
if (!lightboxId) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') api.getState().closeLightbox();
else if (e.key === 'ArrowLeft') go(-1);
else if (e.key === 'ArrowRight') go(1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [lightboxId, api, go]);
return (
<AnimatePresence>
{item ? (
<motion.div
ref={dialogRef}
className="apg-lightbox"
role="dialog"
aria-modal="true"
aria-label={`Photo: ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<div className="apg-lightbox__bar">
<button
type="button"
className="apg-iconbtn"
aria-label="Close"
onClick={() => api.getState().closeLightbox()}
>
<Icon name="chevron-left" />
</button>
<div>
<div className="apg-lightbox__title">{item.name}</div>
<div className="apg-lightbox__sub">
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
{item.location?.place ? ` · ${item.location.place}` : ''}
</div>
</div>
<div style={{ flex: 1 }} />
<button
type="button"
className="apg-iconbtn"
aria-label="Info"
onClick={() => api.getState().toggleInfo()}
>
<Icon name="info" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label={item.favorite ? 'Unfavourite' : 'Favourite'}
aria-pressed={item.favorite}
style={item.favorite ? { color: '#ff3b30' } : undefined}
onClick={() => api.getState().toggleFavorite([item.id])}
>
<Icon name={item.favorite ? 'heart-fill' : 'heart'} />
</button>
{features.editor ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Edit"
onClick={() => api.getState().openEditor(item.id)}
>
<Icon name="adjust" />
</button>
) : null}
{features.sharing ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Share"
onClick={() => openShareModal([item.id])}
>
<Icon name="share" />
</button>
) : null}
{features.export ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Download"
onClick={() => downloadMedia(item)}
>
<Icon name="download" />
</button>
) : null}
<button
type="button"
className="apg-iconbtn"
aria-label="Delete"
onClick={() => {
api.getState().trash([item.id]);
api.getState().closeLightbox();
}}
>
<Icon name="trash" />
</button>
</div>
<div className="apg-lightbox__stage">
{index > 0 ? (
<button
type="button"
className="apg-lightbox__nav apg-lightbox__nav--prev"
aria-label="Previous"
onClick={() => go(-1)}
>
<Icon name="chevron-left" />
</button>
) : null}
<motion.div
key={item.id}
initial={{ opacity: 0.4, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.16 }}
style={{ maxWidth: '100%', maxHeight: '100%', display: 'flex' }}
>
{item.kind === 'video' ? (
<VideoPlayer src={item.src} poster={item.poster} filter={editFilterCss(item.edits)} />
) : (
<div style={{ position: 'relative', display: 'inline-block', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={item.src}
alt={item.name}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
filter: editFilterCss(item.edits),
transform: editTransformCss(item.edits),
}}
/>
{item.edits?.annotations?.length ? (
<Annotations annotations={item.edits.annotations} editable={false} />
) : null}
</div>
)}
</motion.div>
{index < ordered.length - 1 && index !== -1 ? (
<button
type="button"
className="apg-lightbox__nav apg-lightbox__nav--next"
aria-label="Next"
onClick={() => go(1)}
>
<Icon name="chevron-right" />
</button>
) : null}
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
@@ -0,0 +1,45 @@
'use client';
import { useMemo } from 'react';
import { GRID_ZOOM_STEPS } from '../constants';
import { useGallery } from '../store/context';
import type { MediaItem } from '../types';
import { PhotoTile } from './PhotoTile';
/** Minimum tile width (px) for each zoom step — drives responsive auto-fill. */
const TILE_MIN_BY_ZOOM = [104, 124, 150, 188, 232, 300, 420];
export interface MediaGridProps {
items: MediaItem[];
/** Optional sticky section title rendered above the grid. */
title?: string;
}
/**
* Adaptive, responsive photo grid. Column count follows the zoom level via CSS
* `auto-fill`, so it reflows naturally from ultra-wide screens down to a phone.
* Images are lazy-decoded for performance with large libraries.
*/
export function MediaGrid({ items, title }: MediaGridProps) {
const zoomIndex = useGallery((s) => s.zoomIndex);
const orderedIds = useMemo(() => items.map((i) => i.id), [items]);
const tileMin = TILE_MIN_BY_ZOOM[Math.min(zoomIndex, TILE_MIN_BY_ZOOM.length - 1)] ?? 188;
return (
<div>
{title ? <div className="apg-grid__section-title">{title}</div> : null}
<div
className="apg-grid"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileMin}px, 1fr))` }}
>
{items.map((item) => (
<PhotoTile key={item.id} item={item} orderedIds={orderedIds} />
))}
</div>
</div>
);
}
export { GRID_ZOOM_STEPS };
@@ -0,0 +1,56 @@
'use client';
import { type ReactNode, useEffect, useRef, useSyncExternalStore } from 'react';
import { useFocusTrap } from '../hooks/useFocusTrap';
// Module-level modal emitter (mirrors the context-menu host pattern).
let current: ReactNode | null = null;
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
export function openModal(node: ReactNode) {
current = node;
emit();
}
export function closeModal() {
current = null;
emit();
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
export function ModalHost() {
const node = useSyncExternalStore(
subscribe,
() => current,
() => null,
);
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, Boolean(node), closeModal);
useEffect(() => {
if (!node) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [node]);
if (!node) return null;
return (
<div
ref={ref}
className="apg-modal__backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
{node}
</div>
);
}
@@ -0,0 +1,244 @@
'use client';
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react';
import { createLocalStorageAdapter } from '../adapters/localStorage';
import type { StorageAdapter } from '../adapters/types';
import type { AIProvider } from '../ai/types';
import { normalizeMediaItem, type MediaInput } from '../lib/media';
import { GalleryStoreContext, useGallery, useGalleryStoreApi } from '../store/context';
import {
createGalleryStore,
DEFAULT_FEATURES,
type GalleryConfig,
type GalleryFeatures,
type GalleryStore,
type ThemeTokens,
} from '../store/store';
import type { Album, MediaItem, ThemePreference } from '../types';
import { AIAnalyzer } from './AIAnalyzer';
import { SemanticSearch } from './SemanticSearch';
import { AIProviderContext } from './aiContext';
import { AppShell } from './AppShell';
import { Camera } from './Camera';
import { ContextMenuHost } from './ContextMenu';
import { PhotoEditor } from './editor/PhotoEditor';
import { VideoEditor } from './editor/VideoEditor';
import { InfoPanel } from './InfoPanel';
import { Lightbox } from './Lightbox';
import { ModalHost } from './Modal';
export interface PhotoGalleryProps {
/** Initial media. Accepts full MediaItems or loose `{ src, name?, ... }` inputs. */
photos?: Array<MediaItem | MediaInput>;
/** Initial user albums. */
albums?: Album[];
/** Storage backend. Defaults to a zero-config localStorage adapter. */
adapter?: StorageAdapter;
/** AI provider for object/face/caption/search. Pass `false` to disable. */
ai?: AIProvider | boolean;
theme?: ThemePreference;
accentColor?: string;
/** Base corner radius in px (default 10). Drives all rounded UI. */
borderRadius?: number;
/** Per-theme color / gradient / radius overrides (mapped to CSS variables). */
themeTokens?: ThemeTokens;
features?: Partial<GalleryFeatures>;
/** Render the macOS-style traffic-light title bar. */
showWindowChrome?: boolean;
title?: string;
className?: string;
style?: CSSProperties;
onReady?: () => void;
}
export function PhotoGallery(props: PhotoGalleryProps) {
const {
photos,
albums,
adapter,
ai,
theme = 'system',
accentColor,
borderRadius,
themeTokens,
features,
showWindowChrome = false,
title = 'Photos',
className,
style,
onReady,
} = props;
// Resolve adapter / AI once.
const adapterRef = useRef<StorageAdapter | null>(null);
if (!adapterRef.current) {
adapterRef.current = adapter ?? createLocalStorageAdapter();
}
const aiProvider: AIProvider | null = useMemo(
() => (ai && typeof ai === 'object' ? ai : null),
[ai],
);
const config: GalleryConfig = useMemo(
() => ({
features: { ...DEFAULT_FEATURES, ...features },
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
borderRadius: borderRadius ?? 10,
showWindowChrome,
title,
themeTokens,
}),
[features, accentColor, borderRadius, themeTokens, showWindowChrome, title],
);
// Create the store exactly once for this gallery instance.
const [store] = useState<GalleryStore>(() =>
createGalleryStore({
config,
// Normalize + sanitize every input (loose or full MediaItem) through one path.
initialMedia: (photos ?? [])
.map((p) => normalizeMediaItem(p))
.filter((m): m is MediaItem => m !== null),
initialAlbums: albums ?? [],
}),
);
// Apply the initial theme preference into the store.
useEffect(() => {
store.getState().setTheme(theme);
}, [store, theme]);
return (
<GalleryStoreContext.Provider value={store}>
<GalleryRoot
adapter={adapterRef.current}
ai={aiProvider}
className={className}
style={style}
onReady={onReady}
/>
</GalleryStoreContext.Provider>
);
}
interface GalleryRootProps {
adapter: StorageAdapter;
ai: AIProvider | null;
className?: string;
style?: CSSProperties;
onReady?: () => void;
}
function GalleryRoot({ adapter, ai, className, style, onReady }: GalleryRootProps) {
const api = useGalleryStoreApi();
const ready = useGallery((s) => s.ready);
const theme = useGallery((s) => s.theme);
const resolvedTheme = useGallery((s) => s.resolvedTheme);
const accent = useGallery((s) => s.config.accentColor);
const radius = useGallery((s) => s.config.borderRadius);
const tokens = useGallery((s) => s.config.themeTokens);
const showChrome = useGallery((s) => s.config.showWindowChrome);
const aiEnabled = useGallery((s) => s.config.features.ai);
const cameraEnabled = useGallery((s) => s.config.features.camera);
// Initialize (load persisted state) EXACTLY once — the ref guard prevents a
// double init() (React Strict Mode / remounts) from re-seeding an empty backend.
const initedRef = useRef(false);
useEffect(() => {
if (initedRef.current) return;
initedRef.current = true;
void api.getState().init(adapter, ai);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (ready) onReady?.();
}, [ready, onReady]);
// Resolve "system" theme and react to OS changes.
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) {
api.getState().setResolvedTheme(theme === 'system' ? 'light' : theme);
return;
}
if (theme !== 'system') {
api.getState().setResolvedTheme(theme);
return;
}
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => api.getState().setResolvedTheme(mq.matches ? 'dark' : 'light');
apply();
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}, [api, theme]);
// Map optional theme tokens → CSS variables for the active theme. Dark values
// apply in dark mode and to the semi-dark sidebar; light values elsewhere.
const dark = resolvedTheme === 'dark';
const tokenVars: Record<string, string> = {};
if (tokens) {
// Colors/gradients only — reject url()/expression()/JS or CSS breakout chars
// (defense-in-depth; tokens normally come from build-time env, not user input).
const CSS_UNSAFE = /(url\(|expression\(|javascript:|[<>{}])/i;
const set = (v: string | undefined, name: string) => {
if (v && !CSS_UNSAFE.test(v)) tokenVars[name] = v;
};
const bg = dark ? tokens.bgDark : tokens.bgLight;
set(bg, '--apg-bg');
set(bg, '--apg-bg-content');
set(dark ? tokens.elevatedDark : tokens.elevatedLight, '--apg-bg-elevated');
// In semi-dark the sidebar is always the dark glass value.
set(resolvedTheme === 'semi-dark' ? tokens.sidebarBgDark : dark ? tokens.sidebarBgDark : tokens.sidebarBgLight, '--apg-sidebar-bg');
set(dark ? tokens.textDark : tokens.textLight, '--apg-text');
if (typeof tokens.sidebarRadius === 'number') tokenVars['--apg-sidebar-radius'] = `${tokens.sidebarRadius}px`;
}
const rootStyle: CSSProperties = {
['--apg-accent' as string]: accent,
['--apg-radius' as string]: `${radius}px`,
['--apg-radius-sm' as string]: `${Math.max(2, Math.round(radius * 0.6))}px`,
['--apg-radius-lg' as string]: `${Math.round(radius * 1.4)}px`,
['--apg-radius-xl' as string]: `${Math.round(radius * 2)}px`,
...tokenVars,
...style,
};
return (
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
<div
className={['apg', className].filter(Boolean).join(' ')}
data-theme={resolvedTheme}
style={rootStyle}
>
{showChrome ? <WindowChrome /> : null}
<div className="apg__body">
<AppShell />
</div>
<Lightbox />
<PhotoEditor />
<VideoEditor />
<InfoPanel />
{cameraEnabled ? <Camera /> : null}
<ModalHost />
<ContextMenuHost />
{ai && aiEnabled ? <AIAnalyzer provider={ai} /> : null}
{ai && aiEnabled ? <SemanticSearch provider={ai} /> : null}
</div>
</AIProviderContext.Provider>
);
}
function WindowChrome() {
const title = useGallery((s) => s.config.title);
return (
<div className="apg-titlebar">
<div className="apg-traffic">
<span className="apg-traffic__dot apg-traffic__dot--red" />
<span className="apg-traffic__dot apg-traffic__dot--yellow" />
<span className="apg-traffic__dot apg-traffic__dot--green" />
</div>
<div className="apg-titlebar__title">{title}</div>
</div>
);
}
@@ -0,0 +1,194 @@
'use client';
import { memo } from 'react';
import { downloadMedia } from '../lib/download';
import { editFilterCss, editTransformCss } from '../lib/edits';
import { formatDuration } from '../lib/format';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { MediaId, MediaItem } from '../types';
import { openContextMenu } from './ContextMenu';
import { addToAlbumPicker, confirmAction, moveToAlbumPicker, openShareModal } from './modals';
interface PhotoTileProps {
item: MediaItem;
orderedIds: MediaId[];
}
function PhotoTileImpl({ item, orderedIds }: PhotoTileProps) {
const api = useGalleryStoreApi();
const selected = useGallery((s) => s.selection.has(item.id));
const view = useGallery((s) => s.view);
const features = useGallery((s) => s.config.features);
const inTrash = view === 'recently-deleted';
const targetIds = () => {
const sel = api.getState().selection;
return sel.has(item.id) && sel.size > 1 ? [...sel] : [item.id];
};
const onClick = (e: React.MouseEvent) => {
if (e.shiftKey) {
api.getState().select(item.id, { range: true, orderedIds });
} else if (e.metaKey || e.ctrlKey) {
api.getState().select(item.id, { additive: true });
} else if (api.getState().selection.size > 0) {
// In selection mode a plain click extends/toggles the selection.
api.getState().select(item.id, { additive: true });
} else {
// Otherwise a plain click opens the photo (macOS-style); use the checkmark
// or right-click → Select to enter selection mode.
api.getState().openLightbox(item.id);
}
};
const onContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
// Right-click shows the menu WITHOUT changing the selection; actions target
// the current selection if this item is part of it, else just this item.
const sel = api.getState().selection;
const selected = sel.has(item.id);
const ids = targetIds();
if (inTrash) {
openContextMenu(e.clientX, e.clientY, [
{ label: 'Restore', icon: 'rotate', onClick: () => api.getState().restore(ids) },
{
label: 'Delete Permanently',
icon: 'trash',
danger: true,
onClick: () =>
confirmAction({
title: `Delete ${ids.length} item${ids.length === 1 ? '' : 's'}?`,
message: 'This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
onConfirm: () => api.getState().deletePermanently(ids),
}),
},
]);
return;
}
openContextMenu(e.clientX, e.clientY, [
{ label: 'Open', icon: 'image', onClick: () => api.getState().openLightbox(item.id) },
{
label: selected ? 'Deselect' : 'Select',
icon: selected ? 'close' : 'check',
onClick: () => api.getState().select(item.id, { additive: true }),
},
...(features.editor
? [{ label: 'Edit', icon: 'adjust' as const, onClick: () => api.getState().openEditor(item.id) }]
: []),
{
label: item.favorite ? 'Unfavourite' : 'Favourite',
icon: 'heart',
onClick: () => api.getState().toggleFavorite(ids),
},
...(features.sharing
? [{ label: 'Share…', icon: 'share' as const, onClick: () => openShareModal(ids) }]
: []),
{ label: 'Copy to Album…', icon: 'collections', onClick: () => addToAlbumPicker(ids) },
...(view.startsWith('album:')
? [
{
label: 'Move to Album…',
icon: 'collections' as const,
onClick: () => moveToAlbumPicker(view, ids),
},
{
label: 'Remove from Album',
icon: 'close' as const,
onClick: () => api.getState().removeFromAlbum(view, ids),
},
]
: []),
{ type: 'separator' },
...(item.objectLabels.length
? [
{
label: `Find similar: ${item.objectLabels[0]}`,
icon: 'search' as const,
onClick: () => api.getState().setObjectFocus(item.objectLabels[0]!),
},
]
: []),
...(features.export
? [{ label: 'Download', icon: 'download' as const, onClick: () => downloadMedia(item) }]
: []),
{ type: 'separator' },
{
label: ids.length > 1 ? `Delete ${ids.length} Items` : 'Delete',
icon: 'trash',
danger: true,
onClick: () => api.getState().trash(ids),
},
]);
};
const toggleCheck = (e: React.MouseEvent) => {
e.stopPropagation();
api.getState().select(item.id, { additive: true });
};
return (
<div
className={['apg-tile', selected ? 'apg-tile--selected' : ''].join(' ')}
onClick={onClick}
onDoubleClick={() => api.getState().openLightbox(item.id)}
onContextMenu={onContextMenu}
role="button"
aria-label={item.name}
aria-pressed={selected}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') api.getState().openLightbox(item.id);
}}
>
{item.kind === 'video' ? (
<video
src={item.src}
poster={item.poster}
muted
preload="metadata"
playsInline
style={{ filter: editFilterCss(item.edits), transform: editTransformCss(item.edits) }}
/>
) : (
<img
src={item.thumbnail ?? item.src}
alt={item.name}
loading="lazy"
decoding="async"
draggable={false}
style={{ filter: editFilterCss(item.edits), transform: editTransformCss(item.edits) }}
/>
)}
{item.kind === 'video' ? (
<span className="apg-tile__badge">
<Icon name="play" size={12} />
{formatDuration(item.duration)}
</span>
) : null}
{item.favorite ? (
<span className="apg-tile__fav">
<Icon name="heart-fill" size={14} />
</span>
) : null}
<button
type="button"
className="apg-tile__check"
aria-label={selected ? 'Deselect' : 'Select'}
onClick={toggleCheck}
>
{selected ? <Icon name="check" size={13} /> : null}
</button>
</div>
);
}
export const PhotoTile = memo(PhotoTileImpl);
@@ -0,0 +1,127 @@
'use client';
import { downloadMedia, exportMetadata } from '../lib/download';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { addToAlbumPicker, confirmAction, openShareModal } from './modals';
export function SelectionBar() {
const api = useGalleryStoreApi();
const selection = useGallery((s) => s.selection);
const view = useGallery((s) => s.view);
const media = useGallery((s) => s.media);
const features = useGallery((s) => s.config.features);
const count = selection.size;
if (count === 0) return null;
const ids = [...selection];
const inTrash = view === 'recently-deleted';
const exportSelection = () => {
const items = media.filter((m) => selection.has(m.id));
items.forEach((m) => downloadMedia(m));
exportMetadata(items);
};
return (
<div className="apg-actionbar" role="toolbar" aria-label="Selection actions">
<span className="apg-actionbar__count">
{count} Selected
</span>
{inTrash ? (
<>
<button
type="button"
className="apg-iconbtn"
aria-label="Restore"
title="Restore"
onClick={() => api.getState().restore(ids)}
>
<Icon name="rotate" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label="Delete permanently"
title="Delete Permanently"
onClick={() =>
confirmAction({
title: `Delete ${count} item${count === 1 ? '' : 's'}?`,
message: 'These items will be permanently deleted. This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
onConfirm: () => api.getState().deletePermanently(ids),
})
}
>
<Icon name="trash" />
</button>
</>
) : (
<>
<button
type="button"
className="apg-iconbtn"
aria-label="Favourite"
title="Favourite"
onClick={() => api.getState().toggleFavorite(ids)}
>
<Icon name="heart" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label="Add to album"
title="Add to Album"
onClick={() => addToAlbumPicker(ids)}
>
<Icon name="collections" />
</button>
{features.sharing ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Share"
title="Share"
onClick={() => openShareModal(ids)}
>
<Icon name="share" />
</button>
) : null}
{features.export ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Download"
title="Download"
onClick={exportSelection}
>
<Icon name="download" />
</button>
) : null}
<button
type="button"
className="apg-iconbtn"
aria-label="Delete"
title="Delete"
onClick={() => api.getState().trash(ids)}
>
<Icon name="trash" />
</button>
</>
)}
<button
type="button"
className="apg-iconbtn"
aria-label="Clear selection"
title="Clear selection"
onClick={() => api.getState().clearSelection()}
>
<Icon name="close" size={18} />
</button>
</div>
);
}
@@ -0,0 +1,59 @@
'use client';
import { useEffect } from 'react';
import { cosineSimilarity, type AIProvider } from '../ai/types';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { liveMedia } from '../store/selectors';
const DEBOUNCE_MS = 350;
const MIN_QUERY_LEN = 2;
/** Cosine-similarity floor for a photo to count as a semantic match. CLIP ViT-B/16
* scores strong matches ~0.25+, unrelated ~0.15 — 0.22 keeps it crisp. */
const MATCH_THRESHOLD = 0.22;
const MAX_RESULTS = 60;
/**
* Headless worker: when the user types a query, embeds it with the provider's
* text encoder (CLIP) and ranks every photo that has an image embedding by
* cosine similarity, writing the ordered ids to the store. The selector blends
* these "looks like" matches with the keyword results. Runs only if the provider
* supports embedText; otherwise search stays purely keyword-based.
*/
export function SemanticSearch({ provider }: { provider: AIProvider }) {
const api = useGalleryStoreApi();
const query = useGallery((s) => s.searchQuery);
useEffect(() => {
if (!provider.embedText) return;
const q = query.trim();
if (q.length < MIN_QUERY_LEN) {
api.getState().setSemanticResults(null);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
const embedded = liveMedia(api.getState().media).filter((m) => (m.embedding?.length ?? 0) > 0);
if (embedded.length === 0) {
if (!cancelled) api.getState().setSemanticResults(null);
return;
}
const qvec = await provider.embedText!(q).catch(() => [] as number[]);
if (cancelled || qvec.length === 0) return;
const ranked = embedded
.map((m) => ({ id: m.id, score: cosineSimilarity(qvec, m.embedding!) }))
.filter((r) => r.score >= MATCH_THRESHOLD)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_RESULTS)
.map((r) => r.id);
if (!cancelled) api.getState().setSemanticResults(ranked);
}, DEBOUNCE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [query, provider, api]);
return null;
}
@@ -0,0 +1,289 @@
'use client';
import { useState } from 'react';
import { useIsMobile } from '../hooks/useMediaQuery';
import { Icon, type IconName } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { Album, ViewId } from '../types';
import { closeContextMenu, openContextMenu } from './ContextMenu';
import { openSecuritySettings, promptAlbumName } from './modals';
interface RowProps {
icon: IconName;
label: string;
view?: ViewId;
trailing?: IconName;
indent?: boolean;
disclosure?: boolean;
open?: boolean;
/** Suppress active highlight (for duplicate rows that share a view). */
noActive?: boolean;
onToggle?: () => void;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
/** When set, the trailing icon becomes its own clickable control. */
onTrailingClick?: () => void;
trailingLabel?: string;
}
function Row({
icon,
label,
view,
trailing,
indent,
disclosure,
open,
noActive,
onToggle,
onClick,
onContextMenu,
onTrailingClick,
trailingLabel,
}: RowProps) {
const api = useGalleryStoreApi();
const isMobile = useIsMobile();
const active = useGallery((s) => (view && !noActive ? s.view === view : false));
const handleClick = () => {
if (onClick) onClick();
else if (view) api.getState().setView(view);
if (isMobile) api.getState().setSidebar(false);
};
return (
<button
type="button"
className={[
'apg-sidebar__item',
indent ? 'apg-sidebar__child' : '',
active ? 'apg-sidebar__item--active' : '',
]
.filter(Boolean)
.join(' ')}
onClick={handleClick}
onContextMenu={onContextMenu}
aria-current={active ? 'page' : undefined}
>
{disclosure ? (
<span
className={['apg-sidebar__disclosure', open ? 'apg-sidebar__disclosure--open' : ''].join(
' ',
)}
onClick={(e) => {
e.stopPropagation();
onToggle?.();
}}
>
<Icon name="chevron-right" size={13} />
</span>
) : null}
<span className="apg-sidebar__item-icon">
<Icon name={icon} size={17} />
</span>
<span className="apg-sidebar__item-label">{label}</span>
{trailing ? (
onTrailingClick ? (
<span
className="apg-sidebar__item-trail apg-sidebar__item-trail--btn"
role="button"
tabIndex={0}
aria-label={trailingLabel}
title={trailingLabel}
onClick={(e) => {
e.stopPropagation();
onTrailingClick();
}}
>
<Icon name={trailing} size={14} />
</span>
) : (
<span className="apg-sidebar__item-trail">
<Icon name={trailing} size={14} />
</span>
)
) : null}
</button>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <div className="apg-sidebar__section-label">{children}</div>;
}
export function Sidebar() {
const api = useGalleryStoreApi();
const sidebarOpen = useGallery((s) => s.sidebarOpen);
const albums = useGallery((s) => s.albums);
// Recently Deleted lock state → closed lock when protected & not yet opened.
const locked = useGallery((s) => s.lock.hash !== null && !s.lockUnlocked);
const lockIcon: IconName = locked ? 'lock' : 'unlock';
const [sharingOpen, setSharingOpen] = useState(true);
const [albumsOpen, setAlbumsOpen] = useState(true);
const [objectsOpen, setObjectsOpen] = useState(true);
// Sidebar lock toggle: no password → set one; unlocked → re-lock; locked → go unlock.
const toggleLock = () => {
const s = api.getState();
if (!s.lock.hash) openSecuritySettings();
else if (s.lockUnlocked) s.relock();
else s.setView('recently-deleted');
};
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
// Auto "one album per detected object" (chair, table, person, car…), sorted by count.
const objectAlbums = albums.filter((a) => a.id.startsWith('sys:obj:'));
const albumMenu = (album: Album) => (e: React.MouseEvent) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, [
{
label: 'Rename Album',
icon: 'collections',
onClick: () =>
promptAlbumName('Rename Album', album.name, (name) =>
api.getState().renameAlbum(album.id, name),
),
},
{
label: 'Duplicate Album',
icon: 'duplicates',
onClick: () => api.getState().duplicateAlbum(album.id),
},
{ type: 'separator' },
{
label: 'Delete Album',
icon: 'trash',
danger: true,
onClick: () => api.getState().deleteAlbum(album.id),
},
]);
};
const newAlbum = () =>
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
api.getState().setView(`album:${id}` as ViewId);
});
return (
<nav
className={['apg-sidebar', sidebarOpen ? '' : 'apg-sidebar--collapsed'].join(' ')}
aria-label="Library navigation"
>
<Row icon="library" label="Library" view="library" />
<Row icon="collections" label="Collections" view="collections" />
<SectionLabel>Pinned</SectionLabel>
<Row icon="heart" label="Favourites" view="favourites" />
<Row icon="download" label="Recently Saved" view="recently-saved" />
<Row icon="map" label="Map" view="map" />
<Row icon="video" label="Videos" view="videos" />
<Row icon="screenshot" label="Screenshots" view="screenshots" />
<Row icon="document" label="Documents" view="sys:documents" />
<Row icon="person-circle" label="People & Pets" view="people" />
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
<div
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, [
{ label: 'New Album', icon: 'collections', onClick: newAlbum },
]);
}}
>
<SectionLabel>Albums</SectionLabel>
</div>
<Row
icon="collections"
label="All Albums"
view="albums"
disclosure
open={albumsOpen}
onToggle={() => {
setAlbumsOpen((v) => !v);
closeContextMenu();
}}
/>
{albumsOpen ? (
<>
<Row icon="plus" label="New Album" onClick={newAlbum} indent />
{userAlbums.map((a) => (
<Row
key={a.id}
icon={a.kind === 'folder' ? 'folder' : 'image'}
label={a.name}
view={a.id as ViewId}
indent
onContextMenu={albumMenu(a)}
/>
))}
</>
) : null}
{objectAlbums.length ? (
<>
<SectionLabel>Objects</SectionLabel>
<Row
icon="tag"
label="All Objects"
view="collections"
disclosure
open={objectsOpen}
onToggle={() => {
setObjectsOpen((v) => !v);
closeContextMenu();
}}
/>
{objectsOpen
? objectAlbums.map((a) => (
<Row
key={a.id}
icon={(a.icon as IconName) ?? 'tag'}
label={a.name}
view={a.id as ViewId}
indent
/>
))
: null}
</>
) : null}
<SectionLabel>Sharing</SectionLabel>
<Row
icon="people"
label="Shared Albums"
view="shared-albums"
disclosure
open={sharingOpen}
onToggle={() => {
setSharingOpen((v) => !v);
closeContextMenu();
}}
/>
{sharingOpen ? <Row icon="chat" label="Activity" view="activity" indent /> : null}
<SectionLabel>Utilities</SectionLabel>
<Row
icon="trash"
label="Recently Deleted"
view="recently-deleted"
trailing={lockIcon}
noActive
onTrailingClick={toggleLock}
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
/>
<Row icon="duplicates" label="Duplicates" view="duplicates" />
<Row icon="clock" label="Versions & Audit" view="versions" />
<Row icon="map" label="Map" view="map" noActive />
</nav>
);
}
@@ -0,0 +1,339 @@
'use client';
import { useState } from 'react';
import { GRID_ZOOM_STEPS } from '../constants';
import { useGallery, useGalleryStoreApi } from '../store/context';
import { mediaForView } from '../store/selectors';
import type { GridFilter } from '../store/store';
import type { AlbumId, LibraryScale, MapMode, ViewId } from '../types';
import { confirmAction, openSecuritySettings, openShareModal } from './modals';
import { openUploadModal } from './UploadModal';
import { openContextMenu } from './ContextMenu';
import { Icon } from '../icons';
function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="apg-segmented" role="tablist">
{options.map((o) => (
<button
key={o.value}
type="button"
role="tab"
aria-selected={value === o.value}
className={['apg-segmented__item', value === o.value ? 'apg-segmented__item--active' : '']
.filter(Boolean)
.join(' ')}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
const GRID_VIEWS = new Set<string>([
'library',
'favourites',
'recently-saved',
'videos',
'screenshots',
'recently-deleted',
'duplicates',
'search',
]);
const FILTER_OPTIONS: Array<{ value: GridFilter; label: string }> = [
{ value: 'all', label: 'All Items' },
{ value: 'favourites', label: 'Favourites' },
{ value: 'edited', label: 'Edited' },
{ value: 'photos', label: 'Photos' },
{ value: 'videos', label: 'Videos' },
{ value: 'screenshots', label: 'Screenshots' },
{ value: 'not-in-album', label: 'Not in an Album' },
];
export function TopToolbar() {
const api = useGalleryStoreApi();
const view = useGallery((s) => s.view);
const libraryScale = useGallery((s) => s.libraryScale);
const mapMode = useGallery((s) => s.mapMode);
const gridFilter = useGallery((s) => s.gridFilter);
const searchQuery = useGallery((s) => s.searchQuery);
const zoomIndex = useGallery((s) => s.zoomIndex);
const features = useGallery((s) => s.config.features);
const theme = useGallery((s) => s.theme);
const aiStatus = useGallery((s) => s.aiStatus);
const infoOpen = useGallery((s) => s.infoOpen);
const selectionCount = useGallery((s) => s.selection.size);
const [searchExpanded, setSearchExpanded] = useState(false);
const isGrid = GRID_VIEWS.has(view) || view.startsWith('album:');
const isAlbumish = view !== 'library' && view !== 'collections';
// Uploads default into the album you're viewing (else Library).
const currentAlbumId = view.startsWith('album:') ? (view as unknown as AlbumId) : undefined;
const openFilterMenu = (e: React.MouseEvent) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
openContextMenu(
r.left,
r.bottom + 4,
FILTER_OPTIONS.map((o) => ({
label: o.label,
checked: gridFilter === o.value,
onClick: () => api.getState().setGridFilter(o.value),
})),
);
};
const openMoreMenu = (e: React.MouseEvent) => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
const state = api.getState();
const viewIds = mediaForView(state).map((m) => m.id);
const allSelected = viewIds.length > 0 && viewIds.every((id) => state.selection.has(id));
openContextMenu(r.left - 150, r.bottom + 4, [
allSelected
? {
label: 'Unselect All',
icon: 'close',
onClick: () => state.clearSelection(),
}
: {
label: 'Select All',
icon: 'check',
onClick: () => state.selectMany(viewIds),
},
...(features.import
? [{ label: 'Import…', icon: 'download' as const, onClick: () => openUploadModal(currentAlbumId) }]
: []),
{ type: 'separator' as const },
{ type: 'label' as const, label: 'Appearance' },
{ label: 'Light', icon: 'adjust' as const, checked: theme === 'light', onClick: () => state.setTheme('light') },
{ label: 'Dark', icon: 'adjust' as const, checked: theme === 'dark', onClick: () => state.setTheme('dark') },
{
label: 'Semi-Dark (Glass)',
icon: 'adjust' as const,
checked: theme === 'semi-dark',
onClick: () => state.setTheme('semi-dark'),
},
{ label: 'System', icon: 'adjust' as const, checked: theme === 'system', onClick: () => state.setTheme('system') },
{ type: 'separator' as const },
{
label: state.lock.hash ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
icon: state.lock.hash ? 'lock' : 'unlock',
onClick: () => openSecuritySettings(),
},
...(view === 'recently-deleted'
? [
{ type: 'separator' as const },
{
label: 'Delete All',
icon: 'trash' as const,
danger: true,
onClick: () =>
confirmAction({
title: 'Delete All Items?',
message: 'These items will be permanently deleted. This cannot be undone.',
confirmLabel: 'Delete All',
danger: true,
onConfirm: () => api.getState().emptyTrash(),
}),
},
]
: []),
]);
};
return (
<div className="apg-toolbar">
<button
type="button"
className="apg-iconbtn"
aria-label="Toggle sidebar"
onClick={() => api.getState().toggleSidebar()}
>
<Icon name="sidebar" />
</button>
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Back"
disabled={!isAlbumish}
onClick={() => api.getState().setView('library')}
>
<Icon name="chevron-left" size={18} />
</button>
{features.import ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Add photos"
onClick={() => openUploadModal(currentAlbumId)}
>
<Icon name="plus" />
</button>
) : null}
{features.camera ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Open camera"
onClick={() => api.getState().openCamera()}
>
<Icon name="camera" />
</button>
) : null}
{/* Center segmented control (context dependent). */}
<div className="apg-toolbar__center">
{view === 'library' || view === 'search' ? (
<Segmented<LibraryScale>
value={libraryScale}
onChange={(v) => api.getState().setLibraryScale(v)}
options={[
{ value: 'years', label: 'Years' },
{ value: 'months', label: 'Months' },
{ value: 'all', label: 'All Photos' },
]}
/>
) : view === 'map' ? (
<Segmented<MapMode>
value={mapMode}
onChange={(v) => api.getState().setMapMode(v)}
options={[
{ value: 'map', label: 'Map' },
{ value: 'satellite', label: 'Satellite' },
{ value: 'grid', label: 'Grid' },
]}
/>
) : null}
</div>
<div className="apg-toolbar__spacer" />
{isGrid ? (
<>
<button
type="button"
className="apg-iconbtn"
aria-label="Zoom out"
disabled={zoomIndex === 0}
onClick={() => api.getState().zoomOut()}
>
<Icon name="minus" />
</button>
<button
type="button"
className="apg-iconbtn"
aria-label="Zoom in"
disabled={zoomIndex === GRID_ZOOM_STEPS.length - 1}
onClick={() => api.getState().zoomIn()}
>
<Icon name="plus" />
</button>
<button type="button" className="apg-iconbtn" aria-label="Filter" onClick={openFilterMenu}>
<Icon name="filter" />
</button>
</>
) : null}
{features.sharing && (selectionCount > 0 || view.startsWith('album:')) ? (
<button
type="button"
className="apg-iconbtn"
aria-label="Share"
onClick={() =>
openShareModal(
[...api.getState().selection],
view.startsWith('album:') ? view : undefined,
)
}
>
<Icon name="share" />
</button>
) : null}
<button type="button" className="apg-iconbtn" aria-label="More" onClick={openMoreMenu}>
<Icon name="ellipsis" />
</button>
<button
type="button"
className={['apg-iconbtn', infoOpen ? 'apg-iconbtn--on' : ''].join(' ')}
aria-label="Info"
aria-pressed={infoOpen}
onClick={() => api.getState().toggleInfo()}
>
<Icon name="info" />
</button>
{aiStatus.running ? (
<span
className="apg-ai-status"
title="Analyzing your library with AI"
aria-live="polite"
>
<span className="apg-ai-spinner" aria-hidden="true" />
{aiStatus.done}/{aiStatus.total}
</span>
) : null}
<div
className={['apg-search', searchExpanded ? 'apg-search--expanded' : ''].join(' ')}
onClick={() => setSearchExpanded(true)}
>
<Icon name="search" size={16} />
<input
aria-label="Search"
placeholder="Search"
value={searchQuery}
onFocus={() => setSearchExpanded(true)}
onChange={(e) => {
const q = e.target.value;
api.getState().setSearch(q);
api.getState().setView((q ? 'search' : 'library') as ViewId);
}}
onBlur={() => setTimeout(() => setSearchExpanded(false), 120)}
/>
{searchExpanded && !searchQuery ? (
<div className="apg-search__recents" role="listbox">
<div className="apg-menu__label">Recents</div>
{(
[
['viewed', 'Recently Viewed'],
['edited', 'Recently Edited'],
['added', 'Recently Added'],
] as Array<['viewed' | 'edited' | 'added', string]>
).map(([key, label]) => (
<button
key={key}
type="button"
className="apg-menu__item"
onMouseDown={(e) => {
e.preventDefault();
api.getState().setSearchPreset(key);
setSearchExpanded(false);
}}
>
<span className="apg-menu__icon">
<Icon name="clock" size={15} />
</span>
{label}
</button>
))}
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,142 @@
'use client';
import { useRef, useState } from 'react';
import { Icon } from '../icons';
import { isAcceptedMediaFile } from '../lib/media';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { AlbumId } from '../types';
import { closeModal, openModal } from './Modal';
/** Use the SAME allow-list the import pipeline enforces (mediaFromFile), so the
* "N files ready" count matches what will actually be accepted no silent skips. */
const acceptFile = (f: File) => isAcceptedMediaFile(f);
function UploadModal({ defaultAlbumId }: { defaultAlbumId?: AlbumId }) {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums.filter((a) => a.kind === 'user' || a.kind === 'folder'));
const [files, setFiles] = useState<File[]>([]);
const [rejected, setRejected] = useState(0);
const [album, setAlbum] = useState<string>(defaultAlbumId ?? '');
const [busy, setBusy] = useState(false);
const [drag, setDrag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const add = (list: FileList | File[] | null) => {
if (!list) return;
const all = Array.from(list);
const ok = all.filter(acceptFile);
setRejected((r) => r + (all.length - ok.length));
if (ok.length) setFiles((prev) => [...prev, ...ok]);
};
// Open the native OS file picker. A real, explicit control triggering input.click()
// is the most reliable cross-browser way to open the file dialog.
const browse = () => inputRef.current?.click();
const upload = async () => {
if (!files.length) return;
setBusy(true);
try {
await api.getState().importFiles(files, album || undefined);
closeModal();
} finally {
setBusy(false);
}
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Add photos and videos" style={{ minWidth: 400 }}>
<div className="apg-modal__title">Add Photos &amp; Videos</div>
<button
type="button"
className={['apg-upload__drop', drag ? 'apg-upload__drop--over' : ''].join(' ')}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
add(e.dataTransfer.files);
}}
onClick={browse}
>
<Icon name="download" size={28} />
<div style={{ fontWeight: 600 }}>Drag &amp; drop here, or click to browse</div>
<div style={{ fontSize: 12, color: 'var(--apg-text-secondary)' }}>
Images &amp; videos only · multiple allowed
</div>
{files.length ? (
<div style={{ fontSize: 13, marginTop: 6, color: 'var(--apg-accent)' }}>
{files.length} file{files.length === 1 ? '' : 's'} ready
</div>
) : null}
{rejected ? (
<div style={{ fontSize: 12, marginTop: 2, color: 'var(--apg-danger)' }}>
{rejected} unsupported file{rejected === 1 ? '' : 's'} skipped
</div>
) : null}
</button>
{/* Explicit, always-visible browse control (the dropzone click can be flaky on
some setups; a real button reliably opens the OS file dialog). */}
<button
type="button"
className="apg-btn"
style={{ width: '100%', marginTop: 8, cursor: 'pointer' }}
onClick={browse}
>
Choose files
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
// visually hidden (not the `hidden` attribute — some browsers refuse a
// programmatic .click() on a display:none file input).
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
onChange={(e) => {
add(e.target.files);
// Reset so choosing the SAME file again still fires onChange.
e.target.value = '';
}}
/>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 12, fontSize: 12 }}>
<span style={{ color: 'var(--apg-text-secondary)' }}>Add to album</span>
<select className="apg-modal__input" value={album} onChange={(e) => setAlbum(e.target.value)}>
<option value="">Library only</option>
{albums.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal} disabled={busy}>
Cancel
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => void upload()}
disabled={busy || files.length === 0}
>
{busy ? 'Uploading…' : `Upload${files.length ? ` ${files.length}` : ''}`}
</button>
</div>
</div>
);
}
/** Open the upload modal, defaulting the album to the currently-open one. */
export function openUploadModal(defaultAlbumId?: AlbumId) {
openModal(<UploadModal defaultAlbumId={defaultAlbumId} />);
}
@@ -0,0 +1,248 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Icon } from '../icons';
const fmt = (s: number) => {
if (!Number.isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
};
interface VideoPlayerProps {
src: string;
poster?: string;
/** CSS filter string applied to the video (for edited clips). */
filter?: string;
autoPlay?: boolean;
}
/**
* Custom macOS-style video player: a glass transport bar (play/pause, scrubber
* with buffered + played tracks, time, volume, mute, PiP, fullscreen), a centre
* play affordance, auto-hiding controls while playing, and keyboard shortcuts.
* Replaces the browser's native controls for a consistent, on-brand look.
*/
export function VideoPlayer({ src, poster, filter, autoPlay = true }: VideoPlayerProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [controlsShown, setControlsShown] = useState(true);
const v = () => videoRef.current;
const togglePlay = () => {
const el = v();
if (!el) return;
if (el.paused) void el.play();
else el.pause();
};
const seek = (t: number) => {
const el = v();
if (el) el.currentTime = Math.max(0, Math.min(duration || 0, t));
};
const toggleMute = () => {
const el = v();
if (!el) return;
el.muted = !el.muted;
setMuted(el.muted);
};
const changeVolume = (val: number) => {
const el = v();
if (!el) return;
el.volume = val;
el.muted = val === 0;
setVolume(val);
setMuted(val === 0);
};
const toggleFullscreen = () => {
const wrap = wrapRef.current;
if (!wrap) return;
if (document.fullscreenElement) void document.exitFullscreen();
else void wrap.requestFullscreen?.();
};
const togglePip = () => {
const el = v() as HTMLVideoElement & { requestPictureInPicture?: () => Promise<unknown> };
if (!el) return;
if (document.pictureInPictureElement) void document.exitPictureInPicture();
else void el.requestPictureInPicture?.().catch(() => {});
};
// Auto-hide controls while playing; always show when paused / on activity.
const nudge = () => {
setControlsShown(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => {
if (!v()?.paused) setControlsShown(false);
}, 2600);
};
useEffect(() => {
const onFs = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('fullscreenchange', onFs);
return () => {
document.removeEventListener('fullscreenchange', onFs);
if (hideTimer.current) clearTimeout(hideTimer.current);
};
}, []);
const onKeyDown = (e: React.KeyboardEvent) => {
const el = v();
if (!el) return;
// Keys the player owns. For these, fully stop the native event so the
// Lightbox's window-level keydown listener (prev/next/Escape) doesn't ALSO
// fire — React's stopPropagation alone can't stop a separate window listener.
const owned = [' ', 'k', 'ArrowLeft', 'ArrowRight', 'm', 'f'];
if (owned.includes(e.key)) {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation?.();
}
switch (e.key) {
case ' ':
case 'k':
togglePlay();
break;
case 'ArrowLeft':
seek(el.currentTime - 5);
break;
case 'ArrowRight':
seek(el.currentTime + 5);
break;
case 'm':
toggleMute();
break;
case 'f':
toggleFullscreen();
break;
}
nudge();
};
const pct = duration ? (time / duration) * 100 : 0;
const bufPct = duration ? (buffered / duration) * 100 : 0;
return (
<div
ref={wrapRef}
className={['apg-vp', controlsShown ? '' : 'apg-vp--idle', fullscreen ? 'apg-vp--fs' : ''].join(' ')}
onMouseMove={nudge}
onKeyDown={onKeyDown}
tabIndex={0}
role="group"
aria-label="Video player"
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
className="apg-vp__video"
src={src}
poster={poster}
autoPlay={autoPlay}
playsInline
style={filter ? { filter } : undefined}
onClick={togglePlay}
onPlay={() => {
setPlaying(true);
nudge();
}}
onPause={() => {
setPlaying(false);
setControlsShown(true);
}}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration || 0)}
onTimeUpdate={(e) => {
const el = e.currentTarget;
setTime(el.currentTime);
try {
if (el.buffered.length) setBuffered(el.buffered.end(el.buffered.length - 1));
} catch {
/* ignore */
}
}}
onVolumeChange={(e) => {
setVolume(e.currentTarget.volume);
setMuted(e.currentTarget.muted);
}}
/>
{!playing ? (
<button type="button" className="apg-vp__center" aria-label="Play" onClick={togglePlay}>
<Icon name="play" size={34} />
</button>
) : null}
<div className="apg-vp__controls" onClick={(e) => e.stopPropagation()}>
<button type="button" className="apg-vp__btn" aria-label={playing ? 'Pause' : 'Play'} onClick={togglePlay}>
<Icon name={playing ? 'pause' : 'play'} size={18} />
</button>
<span className="apg-vp__time">{fmt(time)}</span>
<div
className="apg-vp__scrub"
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(time)}
onPointerDown={(e) => {
if (!duration) return; // metadata not loaded yet — nothing to seek
const bar = e.currentTarget;
const move = (clientX: number) => {
const r = bar.getBoundingClientRect();
seek(((clientX - r.left) / r.width) * duration);
};
move(e.clientX);
bar.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => move(ev.clientX);
// Release on BOTH pointerup and pointercancel (touch interruptions,
// system gestures) so capture/listeners never leak.
const onUp = () => {
bar.removeEventListener('pointermove', onMove);
bar.removeEventListener('pointerup', onUp);
bar.removeEventListener('pointercancel', onUp);
};
bar.addEventListener('pointermove', onMove);
bar.addEventListener('pointerup', onUp);
bar.addEventListener('pointercancel', onUp);
}}
>
<span className="apg-vp__track" />
<span className="apg-vp__buffered" style={{ width: `${bufPct}%` }} />
<span className="apg-vp__played" style={{ width: `${pct}%` }}>
<span className="apg-vp__knob" />
</span>
</div>
<span className="apg-vp__time">{fmt(duration)}</span>
<button type="button" className="apg-vp__btn" aria-label={muted ? 'Unmute' : 'Mute'} onClick={toggleMute}>
<Icon name={muted || volume === 0 ? 'mute' : 'volume'} size={18} />
</button>
<input
className="apg-vp__vol"
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
aria-label="Volume"
onChange={(e) => changeVolume(Number(e.target.value))}
/>
<button type="button" className="apg-vp__btn" aria-label="Picture in picture" onClick={togglePip}>
<Icon name="pip" size={18} />
</button>
<button type="button" className="apg-vp__btn" aria-label="Fullscreen" onClick={toggleFullscreen}>
<Icon name="expand" size={18} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
'use client';
import { useGallery } from '../store/context';
import { CollectionsView } from './views/CollectionsView';
import {
ActivityView,
AlbumView,
DuplicatesView,
GridScreen,
SharedAlbumsView,
} from './views/GridScreens';
import { LibraryView } from './views/LibraryView';
import { MapView } from './views/MapView';
import { PeopleView } from './views/PeopleView';
import { AlbumsOverview } from './views/ProjectsView';
import { RecentlyDeletedView } from './views/RecentlyDeletedView';
import { VersionsView } from './views/VersionsView';
export function ViewRouter() {
const view = useGallery((s) => s.view);
switch (view) {
case 'collections':
return <CollectionsView />;
case 'map':
return <MapView />;
case 'people':
return <PeopleView />;
case 'recently-deleted':
return <RecentlyDeletedView />;
case 'albums':
return <AlbumsOverview />;
case 'shared-albums':
return <SharedAlbumsView />;
case 'activity':
return <ActivityView />;
case 'duplicates':
return <DuplicatesView />;
case 'versions':
return <VersionsView />;
case 'library':
case 'search':
return <LibraryView />;
default:
if (view.startsWith('album:') || view.startsWith('sys:')) return <AlbumView />;
return <GridScreen />;
}
}
@@ -0,0 +1,12 @@
'use client';
import { createContext, useContext } from 'react';
import type { AIProvider } from '../ai/types';
/** Makes the configured AIProvider available to UI (e.g. the editor's AI tools). */
export const AIProviderContext = createContext<AIProvider | null>(null);
export function useAIProvider(): AIProvider | null {
return useContext(AIProviderContext);
}
@@ -0,0 +1,282 @@
'use client';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import type { Annotation, AnnotationShape } from '../../types';
export type AnnotationTool = AnnotationShape | 'select';
interface AnnotationsProps {
annotations: Annotation[];
editable?: boolean;
tool?: AnnotationTool;
color?: string;
strokeWidth?: number;
onChange?: (next: Annotation[]) => void;
}
/**
* SVG markup overlay. Renders (and, when editable, lets you draw) rectangles,
* ellipses, lines, arrows, double-arrows with a centered measurement label,
* text and freehand strokes. Geometry is stored normalized (0..1) so it scales
* with the image. Read-only mode (editable=false) ignores pointer events.
*/
export function Annotations({
annotations,
editable = false,
tool = 'select',
color = '#ff3b30',
strokeWidth = 3,
onChange,
}: AnnotationsProps) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
const [draft, setDraft] = useState<Annotation | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const drawing = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const norm = (e: React.PointerEvent) => {
const rect = ref.current!.getBoundingClientRect();
return {
x: clamp01((e.clientX - rect.left) / rect.width),
y: clamp01((e.clientY - rect.top) / rect.height),
};
};
const commit = (next: Annotation[]) => onChange?.(next);
const onPointerDown = (e: React.PointerEvent) => {
if (!editable || tool === 'select') return;
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
const p = norm(e);
drawing.current = true;
const base: Annotation = {
id: nanoid(8),
shape: tool,
color,
strokeWidth,
x1: p.x,
y1: p.y,
x2: p.x,
y2: p.y,
};
if (tool === 'freehand') base.points = [{ x: p.x, y: p.y }];
if (tool === 'text') {
// Place immediately and open the inline editor.
base.text = '';
commit([...annotations, base]);
setEditingId(base.id);
drawing.current = false;
return;
}
setDraft(base);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!drawing.current || !draft) return;
const p = norm(e);
setDraft((d) =>
d
? {
...d,
x2: p.x,
y2: p.y,
points: d.shape === 'freehand' ? [...(d.points ?? []), { x: p.x, y: p.y }] : d.points,
}
: d,
);
};
const onPointerUp = () => {
if (!drawing.current || !draft) return;
drawing.current = false;
const moved = Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) > 0.01 || draft.shape === 'freehand';
if (moved) {
commit([...annotations, draft]);
if (draft.shape === 'double-arrow') {
draft.text = '';
setEditingId(draft.id);
}
}
setDraft(null);
};
const updateText = (id: string, text: string) =>
commit(annotations.map((a) => (a.id === id ? { ...a, text } : a)));
const finishEditing = (id: string) => {
const a = annotations.find((x) => x.id === id);
if (a && a.shape === 'text' && !a.text?.trim()) commit(annotations.filter((x) => x.id !== id));
setEditingId(null);
};
const all = draft ? [...annotations, draft] : annotations;
const editingAnn = editingId ? annotations.find((a) => a.id === editingId) : null;
return (
<div
ref={ref}
style={{
position: 'absolute',
inset: 0,
cursor: editable && tool !== 'select' ? 'crosshair' : 'default',
pointerEvents: editable ? 'auto' : 'none',
touchAction: 'none',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{size.w > 0 ? (
<svg width={size.w} height={size.h} style={{ position: 'absolute', inset: 0 }}>
{all.map((a) => (
<Shape key={a.id} a={a} w={size.w} h={size.h} />
))}
</svg>
) : null}
{editable && editingAnn ? (
<input
autoFocus
value={editingAnn.text ?? ''}
onChange={(e) => updateText(editingAnn.id, e.target.value)}
onBlur={() => finishEditing(editingAnn.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Escape') finishEditing(editingAnn.id);
}}
placeholder={editingAnn.shape === 'double-arrow' ? 'e.g. 12 cm' : 'Text'}
style={{
position: 'absolute',
left: `${((editingAnn.x1 + (editingAnn.shape === 'double-arrow' ? editingAnn.x2 : editingAnn.x1)) / 2) * 100}%`,
top: `${((editingAnn.y1 + (editingAnn.shape === 'double-arrow' ? editingAnn.y2 : editingAnn.y1)) / 2) * 100}%`,
transform: 'translate(-50%, -50%)',
font: '600 14px system-ui, sans-serif',
color: editingAnn.color,
background: 'rgba(255,255,255,0.95)',
border: `2px solid ${editingAnn.color}`,
borderRadius: 6,
padding: '2px 6px',
minWidth: 60,
outline: 'none',
zIndex: 2,
}}
/>
) : null}
</div>
);
}
function Shape({ a, w, h }: { a: Annotation; w: number; h: number }) {
const x1 = a.x1 * w;
const y1 = a.y1 * h;
const x2 = a.x2 * w;
const y2 = a.y2 * h;
const common = { stroke: a.color, strokeWidth: a.strokeWidth, fill: 'none' as const };
const headSize = 9 + a.strokeWidth * 2;
switch (a.shape) {
case 'rect':
return (
<rect
x={Math.min(x1, x2)}
y={Math.min(y1, y2)}
width={Math.abs(x2 - x1)}
height={Math.abs(y2 - y1)}
rx={4}
{...common}
/>
);
case 'ellipse':
return (
<ellipse
cx={(x1 + x2) / 2}
cy={(y1 + y2) / 2}
rx={Math.abs(x2 - x1) / 2}
ry={Math.abs(y2 - y1) / 2}
{...common}
/>
);
case 'line':
return <line x1={x1} y1={y1} x2={x2} y2={y2} {...common} strokeLinecap="round" />;
case 'arrow':
return (
<g>
<line x1={x1} y1={y1} x2={x2} y2={y2} {...common} strokeLinecap="round" />
<polygon points={arrowHead(x1, y1, x2, y2, headSize)} fill={a.color} />
</g>
);
case 'double-arrow':
return (
<g>
<line x1={x1} y1={y1} x2={x2} y2={y2} {...common} strokeLinecap="round" />
<polygon points={arrowHead(x2, y2, x1, y1, headSize)} fill={a.color} />
<polygon points={arrowHead(x1, y1, x2, y2, headSize)} fill={a.color} />
{a.text ? (
<text
x={(x1 + x2) / 2}
y={(y1 + y2) / 2 - 6}
fill={a.color}
stroke="#fff"
strokeWidth={3}
paintOrder="stroke"
textAnchor="middle"
style={{ font: '700 15px system-ui, sans-serif' }}
>
{a.text}
</text>
) : null}
</g>
);
case 'text':
return a.text ? (
<text
x={x1}
y={y1}
fill={a.color}
stroke="#fff"
strokeWidth={3}
paintOrder="stroke"
dominantBaseline="middle"
style={{ font: '700 16px system-ui, sans-serif' }}
>
{a.text}
</text>
) : null;
case 'freehand':
return (
<polyline
points={(a.points ?? []).map((p) => `${p.x * w},${p.y * h}`).join(' ')}
{...common}
strokeLinecap="round"
strokeLinejoin="round"
/>
);
default:
return null;
}
}
/** Triangle polygon for an arrowhead pointing from (fx,fy) toward (tx,ty). */
function arrowHead(fx: number, fy: number, tx: number, ty: number, size: number): string {
const ang = Math.atan2(ty - fy, tx - fx);
const a1 = ang + Math.PI - 0.45;
const a2 = ang + Math.PI + 0.45;
const p1 = `${tx},${ty}`;
const p2 = `${tx + size * Math.cos(a1)},${ty + size * Math.sin(a1)}`;
const p3 = `${tx + size * Math.cos(a2)},${ty + size * Math.sin(a2)}`;
return `${p1} ${p2} ${p3}`;
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
@@ -0,0 +1,173 @@
'use client';
import { useEffect, useRef, useState } from 'react';
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
}
type Handle = 'nw' | 'ne' | 'sw' | 'se' | 'move';
/**
* Interactive crop overlay. Geometry is normalized (0..1) relative to the image
* box. When `ratio` is set (pixel w:h), resizing keeps that aspect so a 1:1
* box stays square even as you drag. `ratio = null` is free-form.
*/
export function CropBox({
rect,
ratio,
onChange,
}: {
rect: CropRect;
ratio: number | null;
onChange: (r: CropRect) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [box, setBox] = useState({ w: 0, h: 0 });
const drag = useRef<{ handle: Handle; startRect: CropRect; startX: number; startY: number } | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => setBox({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Normalized aspect (width:height in 0..1 space) for the target pixel ratio.
const normAspect = ratio && box.w && box.h ? ratio * (box.h / box.w) : null;
const pointer = (e: React.PointerEvent) => {
const r = ref.current!.getBoundingClientRect();
return { x: clamp01((e.clientX - r.left) / r.width), y: clamp01((e.clientY - r.top) / r.height) };
};
const onDown = (handle: Handle) => (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
(e.target as Element).setPointerCapture?.(e.pointerId);
const p = pointer(e);
drag.current = { handle, startRect: rect, startX: p.x, startY: p.y };
};
const onMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const p = pointer(e);
const dx = p.x - d.startX;
const dy = p.y - d.startY;
if (d.handle === 'move') {
const nx = clamp(d.startRect.x + dx, 0, 1 - d.startRect.width);
const ny = clamp(d.startRect.y + dy, 0, 1 - d.startRect.height);
onChange({ ...d.startRect, x: nx, y: ny });
return;
}
// Corner resize: the opposite corner stays anchored.
const s = d.startRect;
const anchor = {
x: d.handle === 'nw' || d.handle === 'sw' ? s.x + s.width : s.x,
y: d.handle === 'nw' || d.handle === 'ne' ? s.y + s.height : s.y,
};
let cx = clamp01(p.x);
let cy = clamp01(p.y);
let w = Math.abs(cx - anchor.x);
let h = Math.abs(cy - anchor.y);
if (normAspect) {
// Enforce aspect: derive the dimension pair from the dominant drag axis.
if (w / (normAspect || 1) > h) h = w / normAspect;
else w = h * normAspect;
// Re-clamp so the box stays inside [0,1] without breaking aspect.
const dirX = cx >= anchor.x ? 1 : -1;
const dirY = cy >= anchor.y ? 1 : -1;
const maxW = dirX > 0 ? 1 - anchor.x : anchor.x;
const maxH = dirY > 0 ? 1 - anchor.y : anchor.y;
if (w > maxW) {
w = maxW;
h = w / normAspect;
}
if (h > maxH) {
h = maxH;
w = h * normAspect;
}
cx = anchor.x + dirX * w;
cy = anchor.y + dirY * h;
}
const x = Math.min(cx, anchor.x);
const y = Math.min(cy, anchor.y);
if (w < 0.04 || h < 0.04) return; // ignore degenerate boxes
onChange({ x, y, width: w, height: h });
};
const onUp = () => {
drag.current = null;
};
const pct = (v: number) => `${v * 100}%`;
return (
<div
ref={ref}
style={{ position: 'absolute', inset: 0, touchAction: 'none' }}
onPointerMove={onMove}
onPointerUp={onUp}
>
{/* Darken outside the crop region (4 masks). */}
<div style={maskStyle({ left: 0, top: 0, width: '100%', height: pct(rect.y) })} />
<div style={maskStyle({ left: 0, top: pct(rect.y + rect.height), width: '100%', bottom: 0 })} />
<div style={maskStyle({ left: 0, top: pct(rect.y), width: pct(rect.x), height: pct(rect.height) })} />
<div style={maskStyle({ left: pct(rect.x + rect.width), top: pct(rect.y), right: 0, height: pct(rect.height) })} />
{/* Crop rectangle */}
<div
onPointerDown={onDown('move')}
style={{
position: 'absolute',
left: pct(rect.x),
top: pct(rect.y),
width: pct(rect.width),
height: pct(rect.height),
border: '1px solid rgba(255,255,255,0.9)',
boxShadow: '0 0 0 1px rgba(0,0,0,0.3)',
cursor: 'move',
backgroundImage:
'linear-gradient(rgba(255,255,255,0.35) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.35) 1px, transparent 1px)',
backgroundSize: '33.33% 33.33%',
}}
>
{(['nw', 'ne', 'sw', 'se'] as Handle[]).map((h) => (
<div key={h} onPointerDown={onDown(h)} style={handleStyle(h)} />
))}
</div>
</div>
);
}
function maskStyle(pos: Record<string, string | number>): React.CSSProperties {
return { position: 'absolute', background: 'rgba(0,0,0,0.55)', pointerEvents: 'none', ...pos };
}
function handleStyle(h: Handle): React.CSSProperties {
const base: React.CSSProperties = {
position: 'absolute',
width: 16,
height: 16,
background: '#fff',
borderRadius: '50%',
boxShadow: '0 1px 3px rgba(0,0,0,0.5)',
};
const off = -8;
if (h === 'nw') return { ...base, left: off, top: off, cursor: 'nwse-resize' };
if (h === 'ne') return { ...base, right: off, top: off, cursor: 'nesw-resize' };
if (h === 'sw') return { ...base, left: off, bottom: off, cursor: 'nesw-resize' };
return { ...base, right: off, bottom: off, cursor: 'nwse-resize' };
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const clamp01 = (v: number) => clamp(v, 0, 1);
@@ -0,0 +1,670 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import type { GenerativeEditOp } from '../../ai/types';
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../../constants';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { editFilterCss, editTransformCss } from '../../lib/edits';
import { summarizeEdits } from '../../lib/versions';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import type { Annotation, EditAdjustments, EditState, MediaId, MediaItem } from '../../types';
import { bakeEdits, hasGeometry } from '../../lib/bake';
import { addToAlbumPicker, confirmAction } from '../modals';
import { useAIProvider } from '../aiContext';
import { Annotations, type AnnotationTool } from './Annotations';
import { CropBox, type CropRect } from './CropBox';
const RATIOS: Array<{ label: string; value: number | null }> = [
{ label: 'Free', value: null },
{ label: 'Square', value: 1 },
{ label: '4:3', value: 4 / 3 },
{ label: '3:4', value: 3 / 4 },
{ label: '16:9', value: 16 / 9 },
{ label: '9:16', value: 9 / 16 },
];
function centeredCrop(ratio: number | null, imgAspect: number): CropRect {
if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 };
let w = 1;
let h = imgAspect / ratio;
if (h > 1) {
h = 1;
w = ratio / imgAspect;
}
return { x: (1 - w) / 2, y: (1 - h) / 2, width: w, height: h };
}
type Tab = 'adjust' | 'filters' | 'crop' | 'markup' | 'ai';
const ANN_TOOLS: Array<{ tool: AnnotationTool; label: string; icon: IconName }> = [
{ tool: 'select', label: 'Select', icon: 'check' },
{ tool: 'rect', label: 'Rectangle', icon: 'aspect' },
{ tool: 'ellipse', label: 'Oval', icon: 'filters' },
{ tool: 'line', label: 'Line', icon: 'minus' },
{ tool: 'arrow', label: 'Arrow', icon: 'chevron-right' },
{ tool: 'double-arrow', label: 'Measure', icon: 'crop' },
{ tool: 'text', label: 'Text', icon: 'tag' },
{ tool: 'freehand', label: 'Draw', icon: 'wand' },
];
const ANN_COLORS = ['#ff3b30', '#ff9f0a', '#ffd60a', '#34c759', '#0a84ff', '#bf5af2', '#ffffff', '#000000'];
const ADJUST_FIELDS: Array<{ key: keyof EditAdjustments; label: string }> = [
{ key: 'exposure', label: 'Exposure' },
{ key: 'brilliance', label: 'Brilliance' },
{ key: 'highlights', label: 'Highlights' },
{ key: 'shadows', label: 'Shadows' },
{ key: 'contrast', label: 'Contrast' },
{ key: 'brightness', label: 'Brightness' },
{ key: 'blackPoint', label: 'Black Point' },
{ key: 'saturation', label: 'Saturation' },
{ key: 'vibrance', label: 'Vibrance' },
{ key: 'warmth', label: 'Warmth' },
{ key: 'tint', label: 'Tint' },
{ key: 'sharpness', label: 'Sharpness' },
{ key: 'definition', label: 'Definition' },
{ key: 'vignette', label: 'Vignette' },
];
const AI_OPS: Array<{ label: string; op: GenerativeEditOp; icon: 'wand' | 'image' | 'crop' }> = [
{ label: 'Remove Background', op: { type: 'remove-background' }, icon: 'wand' },
{ label: 'Restore & Enhance', op: { type: 'restore' }, icon: 'wand' },
{ label: 'Colorize', op: { type: 'colorize' }, icon: 'wand' },
{ label: 'Replace Sky', op: { type: 'replace-sky' }, icon: 'image' },
];
export function PhotoEditor() {
const api = useGalleryStoreApi();
const editorId = useGallery((s) => s.editorId);
const media = useGallery((s) => s.media);
const provider = useAIProvider();
const aiAvailable = Boolean(provider?.generativeEdit);
const item = media.find((m) => m.id === editorId) ?? null;
const [tab, setTab] = useState<Tab>('adjust');
const [edits, setEdits] = useState<EditState>({ adjustments: {} });
const [aiBusy, setAiBusy] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [aiResultUrl, setAiResultUrl] = useState<string | null>(null);
const [aiPrompt, setAiPrompt] = useState('');
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState<string>('#ff3b30');
const [cropRatio, setCropRatio] = useState<number | null>(null);
const [baking, setBaking] = useState(false);
const aiBlobRef = useRef<Blob | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Escape routes through the same dirty-check as the Cancel button (assigned below).
const cancelRef = useRef<() => void>(() => api.getState().closeEditor());
useFocusTrap(dialogRef, Boolean(item), () => cancelRef.current());
// Reset state when the editor opens on a new item.
useEffect(() => {
if (item) {
setEdits(item.edits ?? { adjustments: {} });
setTab('adjust');
setAiError(null);
setAiPrompt('');
setCropRatio(null);
aiBlobRef.current = null;
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}
}, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Videos are handled by the dedicated VideoEditor.
if (!item || item.kind === 'video') return null;
const adj = { ...ZERO_ADJUSTMENTS, ...edits.adjustments };
const setAdj = (key: keyof EditAdjustments, value: number) =>
setEdits((e) => ({ ...e, adjustments: { ...e.adjustments, [key]: value } }));
const annotations = edits.annotations ?? [];
const setAnnotations = (next: Annotation[]) => setEdits((e) => ({ ...e, annotations: next }));
const imgAspect = item.width / Math.max(1, item.height);
const cropRect: CropRect = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
const setCrop = (r: CropRect) => setEdits((e) => ({ ...e, crop: r }));
const pickRatio = (value: number | null) => {
setCropRatio(value);
setCrop(centeredCrop(value, imgAspect));
};
const filterCss = aiResultUrl ? undefined : editFilterCss(edits);
// Apply rotate / straighten (tilt) / flip live in ALL tabs (incl. Crop) so the
// user sees them immediately; the crop box overlays the transformed frame.
const transformCss = aiResultUrl ? undefined : editTransformCss(edits);
const runAI = async (op: GenerativeEditOp) => {
if (!provider?.generativeEdit) return;
setAiBusy(true);
setAiError(null);
try {
const img = await loadCrossOriginImage(item.src);
const blob = await provider.generativeEdit(item, img, op);
aiBlobRef.current = blob;
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch (err) {
setAiError(err instanceof Error ? err.message : 'AI edit failed. Please try again.');
} finally {
setAiBusy(false);
}
};
const clearAI = () => {
aiBlobRef.current = null;
setAiError(null);
setAiResultUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
/** True when there are unsaved changes worth confirming before discard. */
const isDirty = () =>
aiBlobRef.current !== null ||
!!edits.filter ||
hasGeometry(edits) ||
(edits.annotations?.length ?? 0) > 0 ||
Object.keys(edits.adjustments ?? {}).length > 0;
/**
* Build the result patch for a target id. Uploads a new blob (AI / baked
* geometry) under that id when needed; returns the MediaItem patch to apply.
*/
const buildPatch = async (targetId: MediaId): Promise<Partial<MediaItem>> => {
if (aiBlobRef.current) {
const uploaded = await api.getState().uploadBlob(targetId, aiBlobRef.current);
const src = uploaded ?? (await blobToDataUrl(aiBlobRef.current));
return { src, thumbnail: undefined, edits: undefined, editedAt: Date.now(), analyzedAt: undefined };
}
if (hasGeometry(edits)) {
const img = await loadCrossOriginImage(item.src);
const { blob, width, height, annotations: remapped } = await bakeEdits(img, edits);
const uploaded = await api.getState().uploadBlob(targetId, blob);
const src = uploaded ?? (await blobToDataUrl(blob));
return {
src,
thumbnail: undefined,
width,
height,
edits: remapped.length ? { adjustments: {}, annotations: remapped } : undefined,
editedAt: Date.now(),
analyzedAt: undefined,
};
}
// Non-destructive (filter/adjust/markup only): keep the same src, store edits.
return { edits, editedAt: Date.now() };
};
const save = async (asCopy: boolean) => {
setBaking(true);
// Human-readable audit log for this edit (same for copy + overwrite).
const changes = aiBlobRef.current
? ['AI edit', ...summarizeEdits(edits).filter((c) => c !== 'Edited')]
: summarizeEdits(edits);
try {
if (asCopy) {
// Upload (if any) under a fresh id, create the copy, then let the user file it.
const copyId = nanoid(10) as MediaId;
const patch = await buildPatch(copyId);
const newId = api.getState().duplicateWithEdits(item.id, { ...patch, id: copyId }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
const patch = await buildPatch(item.id);
// Non-destructive save: preserve the original as v1 and append a new
// version with an audit log of what changed instead of overwriting.
api.getState().addVersion(item.id, patch, changes);
api.getState().closeEditor();
}
} catch {
// Bake/upload failed (e.g. cross-origin) — still record a version so history
// is never lost. Copies still get their own 2-entry history via the fallback.
if (asCopy) {
const copyId = nanoid(10) as MediaId;
const newId = api
.getState()
.duplicateWithEdits(item.id, { id: copyId, edits, editedAt: Date.now() }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
api.getState().addVersion(item.id, { edits, editedAt: Date.now() }, changes);
api.getState().closeEditor();
}
} finally {
setBaking(false);
}
};
const cancel = () => {
if (!isDirty()) {
api.getState().closeEditor();
return;
}
confirmAction({
title: 'Discard changes?',
message: 'Your edits to this photo have not been saved.',
confirmLabel: 'Discard',
danger: true,
onConfirm: () => api.getState().closeEditor(),
});
};
cancelRef.current = cancel;
const revert = () => {
clearAI();
setEdits({ adjustments: {} });
};
const tabs: Tab[] = aiAvailable
? ['adjust', 'filters', 'crop', 'markup', 'ai']
: ['adjust', 'filters', 'crop', 'markup'];
const tabLabel = (t: Tab) =>
t === 'adjust' ? 'Adjust' : t === 'filters' ? 'Filters' : t === 'crop' ? 'Crop' : t === 'markup' ? 'Markup' : 'AI';
const previewSrc = aiResultUrl ?? item.src;
return (
<AnimatePresence>
<motion.div
ref={dialogRef}
className="apg-editor"
role="dialog"
aria-modal="true"
aria-label={`Edit ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
>
<div className="apg-editor__bar">
<button
type="button"
className="apg-iconbtn"
aria-label="Cancel"
onClick={cancel}
>
<Icon name="close" />
</button>
<div style={{ fontWeight: 600 }}>Edit · {item.name}</div>
<div style={{ flex: 1 }} />
<button type="button" className="apg-btn" onClick={revert} disabled={baking}>
Revert
</button>
<button
type="button"
className="apg-btn"
onClick={() => void save(true)}
disabled={baking || aiBusy}
title="Save the result as a new photo (you choose the album)"
>
Save as Copy
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => void save(false)}
disabled={baking || aiBusy}
title="Overwrite this photo with your edits"
>
{baking ? 'Saving…' : 'Save'}
</button>
</div>
<div className="apg-editor__body">
<div className="apg-editor__canvaswrap">
<div style={{ position: 'relative', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={previewSrc}
alt={item.name}
style={{
display: 'block',
maxWidth: '100%',
maxHeight: '78vh',
borderRadius: 4,
filter: filterCss,
transform: transformCss,
transition: 'filter 0.08s linear',
// Checkerboard shows through transparent (background-removed) results.
background: aiResultUrl
? 'repeating-conic-gradient(#3a3a3c 0% 25%, #2a2a2c 0% 50%) 50% / 20px 20px'
: undefined,
}}
/>
{tab !== 'crop' ? (
<Annotations
annotations={annotations}
editable={tab === 'markup'}
tool={annTool}
color={annColor}
onChange={setAnnotations}
/>
) : null}
{tab === 'crop' ? (
<CropBox rect={cropRect} ratio={cropRatio} onChange={setCrop} />
) : null}
{!aiResultUrl && tab !== 'crop' && adj.vignette > 0 ? (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: 4,
boxShadow: `inset 0 0 ${60 + adj.vignette * 140}px rgba(0,0,0,${(
adj.vignette * 0.8
).toFixed(2)})`,
}}
/>
) : null}
{aiBusy ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'grid',
placeItems: 'center',
background: 'rgba(0,0,0,0.45)',
borderRadius: 4,
color: '#fff',
gap: 10,
flexDirection: 'column',
}}
>
<span className="apg-ai-spinner" style={{ width: 26, height: 26 }} />
<span style={{ fontSize: 13 }}>Generating with Gemini</span>
</div>
) : null}
</div>
</div>
<div className="apg-editor__panel">
<div className="apg-editor__tabs">
{tabs.map((t) => (
<button
key={t}
type="button"
className={['apg-editor__tab', tab === t ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setTab(t)}
>
{tabLabel(t)}
</button>
))}
</div>
{tab === 'adjust' ? (
<div>
{ADJUST_FIELDS.map((f) => (
<div className="apg-slider-row" key={f.key}>
<div className="apg-slider-row__head">
<span>{f.label}</span>
<span>{Math.round((adj[f.key] ?? 0) * 100)}</span>
</div>
<input
className="apg-slider"
type="range"
min={-1}
max={1}
step={0.01}
value={adj[f.key] ?? 0}
onChange={(e) => setAdj(f.key, Number(e.target.value))}
/>
</div>
))}
</div>
) : null}
{tab === 'filters' ? (
<div className="apg-editor__filters">
{Object.entries(FILTER_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className={[
'apg-editor__filter',
edits.filter === key || (!edits.filter && key === 'original')
? 'apg-editor__filter--active'
: '',
].join(' ')}
onClick={() =>
setEdits((e) => ({ ...e, filter: key === 'original' ? undefined : key }))
}
>
{preset.label}
</button>
))}
</div>
) : null}
{tab === 'crop' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Drag the box to crop. Pick a ratio to lock the shape (a square stays square). Applied
when you press Save.
</p>
<div style={{ fontSize: 12, color: '#9b9ba1' }}>Aspect ratio</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{RATIOS.map((r) => (
<button
key={r.label}
type="button"
className={['apg-editor__tab', cropRatio === r.value ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => pickRatio(r.value)}
>
{r.label}
</button>
))}
<button
type="button"
className={['apg-editor__tab', cropRatio === imgAspect ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => pickRatio(imgAspect)}
>
Original
</button>
</div>
{edits.crop ? (
<button
type="button"
className="apg-editor__tab"
onClick={() => {
setCropRatio(null);
setEdits((e) => ({ ...e, crop: undefined }));
}}
>
Reset Crop
</button>
) : null}
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, rotation: ((e.rotation ?? 0) + 90) % 360 }))}
>
<Icon name="rotate" size={16} /> Rotate 90°
</button>
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, flipH: !e.flipH }))}
>
Flip Horizontal
</button>
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, flipV: !e.flipV }))}
>
Flip Vertical
</button>
<div className="apg-slider-row" style={{ marginTop: 8 }}>
<div className="apg-slider-row__head">
<span>Straighten</span>
<span>{Math.round(edits.straighten ?? 0)}°</span>
</div>
<input
className="apg-slider"
type="range"
min={-45}
max={45}
step={1}
value={edits.straighten ?? 0}
onChange={(e) => setEdits((prev) => ({ ...prev, straighten: Number(e.target.value) }))}
/>
</div>
{(edits.straighten ?? 0) !== 0 ? (
<button
type="button"
className="apg-editor__tab"
onClick={() => setEdits((e) => ({ ...e, straighten: 0 }))}
>
Reset Straighten
</button>
) : null}
</div>
) : null}
{tab === 'markup' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
Draw shapes, arrows and measurements. Use <strong>Measure</strong> for a doublearrow
with a centered label (e.g. 12 cm). Pick a tool, then drag on the photo.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{ANN_TOOLS.map((t) => (
<button
key={t.tool}
type="button"
className={['apg-editor__tab', annTool === t.tool ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setAnnTool(t.tool)}
>
<Icon name={t.icon} size={15} /> {t.label}
</button>
))}
</div>
<div>
<div style={{ fontSize: 12, color: '#9b9ba1', marginBottom: 6 }}>Color</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{ANN_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => setAnnColor(c)}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: c,
border: annColor === c ? '2px solid #fff' : '2px solid rgba(255,255,255,0.25)',
outline: annColor === c ? '2px solid var(--apg-accent)' : 'none',
cursor: 'pointer',
}}
/>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="apg-editor__tab"
style={{ flex: 1 }}
disabled={annotations.length === 0}
onClick={() => setAnnotations(annotations.slice(0, -1))}
>
<Icon name="rotate" size={15} /> Undo
</button>
<button
type="button"
className="apg-editor__tab"
style={{ flex: 1 }}
disabled={annotations.length === 0}
onClick={() => setAnnotations([])}
>
<Icon name="trash" size={15} /> Clear
</button>
</div>
<div style={{ fontSize: 12, color: '#9b9ba1' }}>{annotations.length} annotation(s)</div>
</div>
) : null}
{tab === 'ai' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ color: '#9b9ba1', fontSize: 12, margin: '0 0 4px' }}>
Generative edits powered by Gemini. Results replace the photo when you press Save.
</p>
{AI_OPS.map((a) => (
<button
key={a.label}
type="button"
className="apg-editor__tab"
disabled={aiBusy}
onClick={() => void runAI(a.op)}
>
<Icon name={a.icon} size={16} /> {a.label}
</button>
))}
<div style={{ marginTop: 6 }}>
<input
className="apg-modal__input"
style={{ width: '100%', background: 'rgba(255,255,255,0.08)', color: '#fff', borderColor: 'rgba(255,255,255,0.15)' }}
placeholder="Describe an edit, e.g. 'make it golden hour'"
value={aiPrompt}
disabled={aiBusy}
onChange={(e) => setAiPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && aiPrompt.trim()) void runAI({ type: 'prompt', prompt: aiPrompt.trim() });
}}
/>
<button
type="button"
className="apg-btn apg-btn--primary"
style={{ width: '100%', marginTop: 8 }}
disabled={aiBusy || !aiPrompt.trim()}
onClick={() => void runAI({ type: 'prompt', prompt: aiPrompt.trim() })}
>
Apply Prompt
</button>
</div>
{aiResultUrl ? (
<button type="button" className="apg-editor__tab" onClick={clearAI} disabled={aiBusy}>
Discard AI result
</button>
) : null}
{aiError ? (
<p style={{ color: '#ff6b6b', fontSize: 12 }}>{aiError}</p>
) : null}
</div>
) : null}
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
function loadCrossOriginImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not load the image for editing.'));
img.src = src;
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('Failed to read edited image.'));
reader.readAsDataURL(blob);
});
}
@@ -0,0 +1,869 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { nanoid } from 'nanoid';
import { useEffect, useRef, useState } from 'react';
import { FILTER_PRESETS } from '../../constants';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { editFilterCss } from '../../lib/edits';
import { summarizeEdits } from '../../lib/versions';
import { bakeVideo } from '../../lib/videoBake';
import {
normalizeSegments,
outputDuration,
sampleOverlay,
sourceToOutputTime,
} from '../../lib/videoTimeline';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import type { EditState, MediaId, MediaItem, VideoOverlay, VideoSegment } from '../../types';
import { Annotations, type AnnotationTool } from './Annotations';
import { addToAlbumPicker, confirmAction } from '../modals';
type Tab = 'trim' | 'crop' | 'overlay' | 'filters' | 'adjust' | 'markup' | 'audio' | 'export';
const TABS: Tab[] = ['trim', 'crop', 'overlay', 'filters', 'adjust', 'markup', 'audio', 'export'];
const TAB_LABEL: Record<Tab, string> = {
trim: 'Trim & Split',
crop: 'Crop & Rotate',
overlay: 'Overlays & Text',
filters: 'Filters',
adjust: 'Adjust',
markup: 'Markup',
audio: 'Audio',
export: 'Export',
};
const ANN_TOOLS: Array<{ tool: AnnotationTool; icon: IconName; label: string }> = [
{ tool: 'rect', icon: 'crop', label: 'Rectangle' },
{ tool: 'ellipse', icon: 'info', label: 'Oval' },
{ tool: 'arrow', icon: 'chevron-right', label: 'Arrow' },
{ tool: 'double-arrow', icon: 'aspect', label: 'Measure' },
{ tool: 'text', icon: 'tag', label: 'Text' },
{ tool: 'freehand', icon: 'adjust', label: 'Draw' },
];
const COLORS = ['#ff3b30', '#ffcc00', '#34c759', '#0a84ff', '#ffffff', '#000000'];
const CROP_PRESETS: Array<{ label: string; ratio: number | null }> = [
{ label: 'Original', ratio: null },
{ label: '1:1', ratio: 1 },
{ label: '16:9', ratio: 16 / 9 },
{ label: '9:16', ratio: 9 / 16 },
{ label: '4:3', ratio: 4 / 3 },
];
const QUALITIES: Array<{ label: string; maxDim: number }> = [
{ label: '480p', maxDim: 854 },
{ label: '720p', maxDim: 1280 },
{ label: '1080p', maxDim: 1920 },
];
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result as string);
fr.onerror = rej;
fr.readAsDataURL(blob);
});
}
const fmt = (s: number) =>
`${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`;
/** Centered crop rect for an aspect ratio within a WxH frame (fractions 0..1). */
function centeredCrop(ratio: number | null, aspect: number) {
if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 };
if (ratio > aspect) {
const h = aspect / ratio;
return { x: 0, y: (1 - h) / 2, width: 1, height: h };
}
const w = ratio / aspect;
return { x: (1 - w) / 2, y: 0, width: w, height: 1 };
}
export function VideoEditor() {
const api = useGalleryStoreApi();
const editorId = useGallery((s) => s.editorId);
const media = useGallery((s) => s.media);
const item = media.find((m) => m.id === editorId) ?? null;
const [tab, setTab] = useState<Tab>('trim');
const [edits, setEdits] = useState<EditState>({ adjustments: {} });
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
const [annColor, setAnnColor] = useState('#ff3b30');
const [duration, setDuration] = useState(0);
const [playhead, setPlayhead] = useState(0);
const [selOverlay, setSelOverlay] = useState<string | null>(null);
const [previewH, setPreviewH] = useState(360);
const [baking, setBaking] = useState(false);
const [progress, setProgress] = useState(0);
const [exportError, setExportError] = useState<string | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const annoWrapRef = useRef<HTMLDivElement>(null);
const overlayFileRef = useRef<HTMLInputElement>(null);
const watermarkFileRef = useRef<HTMLInputElement>(null);
const musicFileRef = useRef<HTMLInputElement>(null);
const cancelRef = useRef<() => void>(() => api.getState().closeEditor());
useFocusTrap(dialogRef, Boolean(item) && item?.kind === 'video', () => cancelRef.current());
// Reset when a different video opens.
useEffect(() => {
setEdits(item?.edits ? { ...item.edits } : { adjustments: {} });
setTab('trim');
setProgress(0);
setPlayhead(0);
setSelOverlay(null);
setExportError(null);
}, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Track the preview box height (drives text-overlay font sizing).
useEffect(() => {
const el = annoWrapRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setPreviewH(el.clientHeight || 360));
ro.observe(el);
return () => ro.disconnect();
}, [item?.id]);
// Keep preview playback within the first→last segment span; track the playhead.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onTime = () => {
setPlayhead(v.currentTime);
const segs = edits.segments && edits.segments.length ? edits.segments : null;
const lo = segs ? segs[0]!.start : (edits.trim?.start ?? 0);
const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration));
if (v.currentTime >= hi) v.currentTime = lo;
};
v.addEventListener('timeupdate', onTime);
return () => v.removeEventListener('timeupdate', onTime);
}, [edits.trim, edits.segments, duration]);
useEffect(() => {
if (videoRef.current) videoRef.current.muted = !!edits.audio?.muted;
}, [edits.audio?.muted]);
if (!item || item.kind !== 'video') return null;
const update = (patch: Partial<EditState>) => setEdits((e) => ({ ...e, ...patch }));
const setAdj = (key: string, val: number) =>
update({ adjustments: { ...edits.adjustments, [key]: val } });
// ---- segments ----
const segments: VideoSegment[] =
edits.segments && edits.segments.length
? edits.segments
: duration
? [{ id: 'seg0', start: 0, end: duration, speed: 1 }]
: [];
const setSegments = (segs: VideoSegment[]) => update({ segments: segs, trim: undefined });
const splitAtPlayhead = () => {
const t = playhead;
const next: VideoSegment[] = [];
for (const s of segments) {
if (t > s.start + 0.1 && t < s.end - 0.1) {
next.push({ ...s, end: t }, { id: nanoid(6), start: t, end: s.end, speed: s.speed });
} else next.push(s);
}
setSegments(next);
};
const outDur = outputDuration(normalizeSegments(edits, duration || 0));
// ---- overlays ----
const overlays: VideoOverlay[] = edits.overlays ?? [];
const setOverlays = (list: VideoOverlay[]) => update({ overlays: list });
const outputTime = sourceToOutputTime(normalizeSegments(edits, duration || 0), playhead);
const addOverlay = (o: VideoOverlay) => {
setOverlays([...overlays, o]);
setSelOverlay(o.id);
};
const patchOverlay = (id: string, patch: Partial<VideoOverlay>) =>
setOverlays(overlays.map((o) => (o.id === id ? { ...o, ...patch } : o)));
const addImageOverlay = (file?: File, watermark = false) => {
if (!file) return;
void blobToDataUrl(file).then((src) =>
addOverlay({
id: nanoid(6),
kind: 'image',
src,
x: 0.05,
y: 0.05,
scale: watermark ? 0.22 : 0.3,
opacity: watermark ? 0.85 : 1,
rotation: 0,
watermark,
}),
);
};
const addTextOverlay = () =>
addOverlay({
id: nanoid(6),
kind: 'text',
text: 'Your text',
color: '#ffffff',
fontSize: 0.09,
bold: true,
x: 0.1,
y: 0.8,
scale: 0.3,
opacity: 1,
rotation: 0,
});
const addKeyframe = (id: string) => {
const o = overlays.find((x) => x.id === id);
if (!o) return;
const s = sampleOverlay(o, outputTime);
const kf = {
t: Math.round(outputTime * 100) / 100,
x: s.x,
y: s.y,
scale: s.scale,
rotation: s.rotation,
opacity: s.opacity,
};
const rest = (o.keyframes ?? []).filter((k) => Math.abs(k.t - kf.t) > 0.05);
patchOverlay(id, { keyframes: [...rest, kf].sort((a, b) => a.t - b.t) });
};
const isDirty = () =>
!!edits.filter ||
(edits.annotations?.length ?? 0) > 0 ||
Object.keys(edits.adjustments ?? {}).length > 0 ||
!!edits.trim ||
!!edits.segments ||
!!edits.overlay ||
(edits.overlays?.length ?? 0) > 0 ||
!!edits.crop ||
!!edits.rotation ||
!!edits.flipH ||
!!edits.flipV ||
!!edits.audio;
const cancel = () => {
if (!isDirty()) return api.getState().closeEditor();
confirmAction({
title: 'Discard changes?',
message: 'Your edits to this video have not been saved.',
confirmLabel: 'Discard',
danger: true,
onConfirm: () => api.getState().closeEditor(),
});
};
cancelRef.current = cancel;
const pickMusic = (file?: File) => {
if (!file) return;
void blobToDataUrl(file).then((src) =>
update({
audio: { ...edits.audio, musicSrc: src, musicVolume: edits.audio?.musicVolume ?? 0.8 },
}),
);
};
const save = async (asCopy: boolean) => {
setBaking(true);
setProgress(0);
setExportError(null);
const changes = summarizeEdits(edits);
try {
const svg = annoWrapRef.current?.querySelector('svg') as SVGSVGElement | null;
const { blob, durationSec, poster } = await bakeVideo(item.src, edits, {
annotationsSvg: edits.annotations?.length ? svg : null,
onProgress: setProgress,
});
const targetId = asCopy ? (nanoid(10) as MediaId) : item.id;
const uploaded = await api.getState().uploadBlob(targetId, blob);
const src = uploaded ?? (await blobToDataUrl(blob));
const patch: Partial<MediaItem> = {
src,
thumbnail: undefined,
poster,
duration: durationSec,
mime: blob.type,
edits: undefined,
editedAt: Date.now(),
analyzedAt: undefined,
};
if (asCopy) {
const newId = api.getState().duplicateWithEdits(item.id, { ...patch, id: targetId }, changes);
api.getState().closeEditor();
addToAlbumPicker([newId]);
} else {
api.getState().addVersion(item.id, patch, changes);
api.getState().closeEditor();
}
} catch (err) {
// Surface the failure instead of silently pretending success.
// eslint-disable-next-line no-console
console.error('[VideoEditor] export failed:', err);
setExportError(
err instanceof Error && /cross-origin|tainted|secur/i.test(err.message)
? "This video can't be exported in-browser (cross-origin source without CORS). Re-import it, or host it with CORS enabled."
: 'Export failed. Your edits were saved as a new version so nothing is lost.',
);
// Preserve the edit intent as a version so history is never lost.
if (asCopy) {
const copyId = nanoid(10) as MediaId;
api.getState().duplicateWithEdits(item.id, { id: copyId, edits, editedAt: Date.now() }, changes);
} else {
api.getState().addVersion(item.id, { edits, editedAt: Date.now() }, changes);
}
} finally {
setBaking(false);
}
};
const filterCss = editFilterCss(edits);
const sel = overlays.find((o) => o.id === selOverlay) ?? null;
return (
<AnimatePresence>
<motion.div
ref={dialogRef}
className="apg-editor"
role="dialog"
aria-modal="true"
aria-label={`Edit ${item.name}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16 }}
>
<div className="apg-editor__bar">
<button type="button" className="apg-iconbtn" aria-label="Cancel" onClick={cancel}>
<Icon name="close" />
</button>
<div style={{ fontWeight: 600 }}>Edit Video · {item.name}</div>
<div style={{ flex: 1 }} />
{baking ? (
<span style={{ fontSize: 13, color: 'var(--apg-text-secondary)' }}>
Exporting {Math.round(progress * 100)}%
</span>
) : null}
<button type="button" className="apg-btn" onClick={() => setEdits({ adjustments: {} })} disabled={baking}>
Revert
</button>
<button type="button" className="apg-btn" onClick={() => void save(true)} disabled={baking} title="Export as a new video">
Save as Copy
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save(false)} disabled={baking}>
{baking ? 'Exporting…' : 'Save'}
</button>
</div>
<div className="apg-editor__body">
<div className="apg-editor__canvaswrap">
<div ref={annoWrapRef} style={{ position: 'relative', maxWidth: '100%', maxHeight: '100%' }}>
<video
ref={videoRef}
src={item.src}
controls
playsInline
crossOrigin="anonymous"
style={{ maxWidth: '100%', maxHeight: '70vh', display: 'block', filter: filterCss || undefined }}
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration || 0;
setDuration(d);
setPreviewH(annoWrapRef.current?.clientHeight || 360);
e.currentTarget.currentTime = segments[0]?.start ?? 0;
}}
/>
{/* Live overlay preview (keyframe-interpolated at the current output time). */}
{overlays.map((o) => {
const s = sampleOverlay(o, outputTime);
if (!s.visible) return null;
const selectedRing = o.id === selOverlay ? '0 0 0 2px var(--apg-accent)' : undefined;
if (o.kind === 'image') {
return (
<img
key={o.id}
src={o.src}
alt=""
onClick={() => setSelOverlay(o.id)}
style={{
position: 'absolute',
left: `${s.x * 100}%`,
top: `${s.y * 100}%`,
width: `${s.scale * 100}%`,
opacity: s.opacity,
transform: `rotate(${s.rotation}deg)`,
transformOrigin: 'top left',
boxShadow: selectedRing,
cursor: 'pointer',
}}
/>
);
}
return (
<div
key={o.id}
onClick={() => setSelOverlay(o.id)}
style={{
position: 'absolute',
left: `${s.x * 100}%`,
top: `${s.y * 100}%`,
opacity: s.opacity,
transform: `rotate(${s.rotation}deg)`,
transformOrigin: 'top left',
color: o.color ?? '#fff',
fontSize: `${(o.fontSize ?? 0.08) * previewH}px`,
fontWeight: o.bold ? 700 : 500,
lineHeight: 1,
whiteSpace: 'pre',
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
outline: o.id === selOverlay ? '2px solid var(--apg-accent)' : undefined,
cursor: 'pointer',
}}
>
{o.text}
</div>
);
})}
<Annotations
annotations={edits.annotations ?? []}
editable={tab === 'markup'}
tool={annTool}
color={annColor}
onChange={(annotations) => update({ annotations })}
/>
</div>
</div>
<div className="apg-editor__panel apg-scroll">
<div className="apg-editor__tabs">
{TABS.map((t) => (
<button
key={t}
type="button"
className={['apg-editor__tab', tab === t ? 'apg-editor__tab--active' : ''].join(' ')}
onClick={() => setTab(t)}
>
{TAB_LABEL[t]}
</button>
))}
</div>
{exportError ? (
<div className="apg-editor__error" role="alert">
{exportError}
</div>
) : null}
{/* ---------- TRIM & SPLIT (multi-segment) ---------- */}
{tab === 'trim' ? (
<div className="apg-vedit__panel">
<div className="apg-vedit__hint">
Keep multiple parts at different speeds. Split at the playhead, then trim or delete
each segment. Output length: <strong>{fmt(outDur)}</strong>.
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<button type="button" className="apg-btn apg-btn--primary apg-btn--small" onClick={splitAtPlayhead}>
<Icon name="crop" size={13} /> Split at {fmt(playhead)}
</button>
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() =>
setSegments([
...segments,
{ id: nanoid(6), start: 0, end: Math.min(duration, 3), speed: 1 },
])
}
>
<Icon name="plus" size={13} /> Add
</button>
</div>
{segments.map((s, i) => (
<div key={s.id} className="apg-vedit__seg">
<div className="apg-vedit__seg-head">
<strong>Segment {i + 1}</strong>
<span className="apg-vedit__seg-dur">{fmt(Math.max(0, s.end - s.start))}</span>
{segments.length > 1 ? (
<button
type="button"
className="apg-iconbtn apg-iconbtn--sm"
aria-label="Delete segment"
onClick={() => setSegments(segments.filter((x) => x.id !== s.id))}
>
<Icon name="trash" size={14} />
</button>
) : null}
</div>
<label className="apg-vedit__row">
<span>Start {fmt(s.start)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={s.start}
onChange={(e) => {
const start = Math.min(Number(e.target.value), s.end - 0.2);
setSegments(segments.map((x) => (x.id === s.id ? { ...x, start } : x)));
if (videoRef.current) videoRef.current.currentTime = start;
}}
/>
</label>
<label className="apg-vedit__row">
<span>End {fmt(s.end)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={s.end}
onChange={(e) => {
const end = Math.max(Number(e.target.value), s.start + 0.2);
setSegments(segments.map((x) => (x.id === s.id ? { ...x, end } : x)));
}}
/>
</label>
<label className="apg-vedit__row">
<span>Speed {(s.speed ?? 1).toFixed(2)}×</span>
<input
type="range"
min={0.25}
max={3}
step={0.05}
value={s.speed ?? 1}
onChange={(e) =>
setSegments(
segments.map((x) => (x.id === s.id ? { ...x, speed: Number(e.target.value) } : x)),
)
}
/>
</label>
</div>
))}
</div>
) : null}
{/* ---------- CROP & ROTATE ---------- */}
{tab === 'crop' ? (
<div className="apg-vedit__panel">
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="apg-btn apg-btn--small"
onClick={() => update({ rotation: (((edits.rotation ?? 0) + 90) % 360) })}
>
<Icon name="rotate" size={14} /> Rotate 90°
</button>
<button
type="button"
className={['apg-btn apg-btn--small', edits.flipH ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ flipH: !edits.flipH })}
>
Flip H
</button>
<button
type="button"
className={['apg-btn apg-btn--small', edits.flipV ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ flipV: !edits.flipV })}
>
Flip V
</button>
</div>
<div className="apg-vedit__hint" style={{ marginTop: 10 }}>Crop aspect</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{CROP_PRESETS.map((p) => (
<button
key={p.label}
type="button"
className="apg-btn apg-btn--small"
onClick={() => {
const aspect = (item.width || 16) / (item.height || 9);
update({ crop: p.ratio == null ? undefined : centeredCrop(p.ratio, aspect) });
}}
>
{p.label}
</button>
))}
</div>
<div className="apg-vedit__hint" style={{ marginTop: 8 }}>
Crop + rotation + flips are baked into the exported video.
</div>
</div>
) : null}
{/* ---------- OVERLAYS & TEXT (with keyframe animation) ---------- */}
{tab === 'overlay' ? (
<div className="apg-vedit__panel">
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" className="apg-btn apg-btn--small apg-btn--primary" onClick={() => overlayFileRef.current?.click()}>
<Icon name="image" size={13} /> Image
</button>
<button type="button" className="apg-btn apg-btn--small" onClick={addTextOverlay}>
<Icon name="tag" size={13} /> Text
</button>
<button type="button" className="apg-btn apg-btn--small" onClick={() => watermarkFileRef.current?.click()}>
<Icon name="image" size={13} /> Watermark
</button>
</div>
<input ref={overlayFileRef} type="file" accept="image/*" hidden onChange={(e) => { addImageOverlay(e.target.files?.[0] ?? undefined, false); e.target.value = ''; }} />
<input ref={watermarkFileRef} type="file" accept="image/*" hidden onChange={(e) => { addImageOverlay(e.target.files?.[0] ?? undefined, true); e.target.value = ''; }} />
{overlays.length === 0 ? (
<div className="apg-vedit__hint">
Add a logo, sticker, watermark, or animated title. Select one to animate it with
keyframes (move / fade / scale over time).
</div>
) : (
<div className="apg-vedit__ovlist">
{overlays.map((o) => (
<button
key={o.id}
type="button"
className={['apg-vedit__ovitem', o.id === selOverlay ? 'is-sel' : ''].join(' ')}
onClick={() => setSelOverlay(o.id)}
>
<Icon name={o.kind === 'text' ? 'tag' : 'image'} size={13} />
<span className="apg-vedit__ovlabel">
{o.kind === 'text' ? o.text || 'Text' : o.watermark ? 'Watermark' : 'Image'}
</span>
{o.keyframes?.length ? <span className="apg-vedit__kfbadge">{o.keyframes.length}</span> : null}
<span
className="apg-iconbtn apg-iconbtn--sm"
role="button"
aria-label="Delete overlay"
onClick={(e) => {
e.stopPropagation();
setOverlays(overlays.filter((x) => x.id !== o.id));
if (selOverlay === o.id) setSelOverlay(null);
}}
>
<Icon name="trash" size={13} />
</span>
</button>
))}
</div>
)}
{sel ? (
<div className="apg-vedit__ovedit">
{sel.kind === 'text' ? (
<>
<label className="apg-vedit__row">
<span>Text</span>
<input
className="apg-modal__input"
value={sel.text ?? ''}
onChange={(e) => patchOverlay(sel.id, { text: e.target.value })}
/>
</label>
<div style={{ display: 'flex', gap: 6, margin: '4px 0' }}>
{COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`Color ${c}`}
onClick={() => patchOverlay(sel.id, { color: c })}
style={{
width: 22, height: 22, borderRadius: '50%', background: c,
border: sel.color === c ? '2px solid var(--apg-accent)' : '1px solid var(--apg-separator)',
cursor: 'pointer',
}}
/>
))}
</div>
<label className="apg-vedit__row">
<span>Size {Math.round((sel.fontSize ?? 0.09) * 100)}</span>
<input type="range" min={0.03} max={0.3} step={0.005} value={sel.fontSize ?? 0.09}
onChange={(e) => patchOverlay(sel.id, { fontSize: Number(e.target.value) })} />
</label>
</>
) : null}
{(['x', 'y', 'scale', 'opacity'] as const).map((k) => (
<label key={k} className="apg-vedit__row">
<span style={{ textTransform: 'capitalize' }}>{k} {(sel[k] ?? (k === 'opacity' ? 1 : 0)).toFixed(2)}</span>
<input
type="range"
min={k === 'scale' ? 0.05 : 0}
max={1}
step={0.01}
value={sel[k] ?? (k === 'opacity' ? 1 : 0)}
onChange={(e) => patchOverlay(sel.id, { [k]: Number(e.target.value) } as Partial<VideoOverlay>)}
/>
</label>
))}
<label className="apg-vedit__row">
<span>Rotation {Math.round(sel.rotation ?? 0)}°</span>
<input type="range" min={-180} max={180} step={1} value={sel.rotation ?? 0}
onChange={(e) => patchOverlay(sel.id, { rotation: Number(e.target.value) })} />
</label>
<div style={{ display: 'flex', gap: 6 }}>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Appear {fmt(sel.in ?? 0)}</span>
<input type="range" min={0} max={outDur} step={0.1} value={sel.in ?? 0}
onChange={(e) => patchOverlay(sel.id, { in: Number(e.target.value) })} />
</label>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Hide {fmt(sel.out ?? outDur)}</span>
<input type="range" min={0} max={outDur} step={0.1} value={sel.out ?? outDur}
onChange={(e) => patchOverlay(sel.id, { out: Number(e.target.value) })} />
</label>
</div>
<div className="apg-vedit__hint" style={{ marginTop: 6 }}>
Keyframes (animate over time) position the playhead, set the transform, then
Add keyframe. Two+ keyframes animate between them.
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="apg-btn apg-btn--small apg-btn--primary" onClick={() => addKeyframe(sel.id)}>
Add keyframe @ {fmt(outputTime)}
</button>
{(sel.keyframes ?? []).map((k, i) => (
<span key={i} className="apg-vedit__kf">
{fmt(k.t)}
<button
type="button"
aria-label="Delete keyframe"
onClick={() =>
patchOverlay(sel.id, { keyframes: (sel.keyframes ?? []).filter((_, j) => j !== i) })
}
>
×
</button>
</span>
))}
</div>
</div>
) : null}
</div>
) : null}
{/* ---------- FILTERS ---------- */}
{tab === 'filters' ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 12 }}>
{Object.entries(FILTER_PRESETS).map(([key, preset]) => (
<button
key={key}
type="button"
className={['apg-btn', edits.filter === key || (key === 'original' && !edits.filter) ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ filter: key === 'original' ? undefined : key })}
>
{preset.label}
</button>
))}
</div>
) : null}
{/* ---------- ADJUST ---------- */}
{tab === 'adjust' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: 12 }}>
{(['brightness', 'contrast', 'saturation', 'warmth'] as const).map((k) => (
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
<span style={{ textTransform: 'capitalize', color: 'var(--apg-text-secondary)' }}>{k}</span>
<input type="range" min={-1} max={1} step={0.01} value={edits.adjustments[k] ?? 0}
onChange={(e) => setAdj(k, Number(e.target.value))} />
</label>
))}
</div>
) : null}
{/* ---------- MARKUP ---------- */}
{tab === 'markup' ? (
<div style={{ padding: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
{ANN_TOOLS.map((t) => (
<button key={t.tool} type="button"
className={['apg-btn', annTool === t.tool ? 'apg-btn--primary' : ''].join(' ')}
style={{ padding: '6px 8px' }} title={t.label} onClick={() => setAnnTool(t.tool)}>
<Icon name={t.icon} size={15} />
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
{COLORS.map((c) => (
<button key={c} type="button" aria-label={`Color ${c}`} onClick={() => setAnnColor(c)}
style={{ width: 24, height: 24, borderRadius: '50%', background: c,
border: annColor === c ? '2px solid var(--apg-accent)' : '1px solid var(--apg-separator)', cursor: 'pointer' }} />
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="apg-btn" onClick={() => update({ annotations: (edits.annotations ?? []).slice(0, -1) })}>Undo</button>
<button type="button" className="apg-btn" onClick={() => update({ annotations: [] })}>Clear</button>
</div>
</div>
) : null}
{/* ---------- AUDIO ---------- */}
{tab === 'audio' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 12, fontSize: 13 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={!!edits.audio?.muted}
onChange={(e) => update({ audio: { ...edits.audio, muted: e.target.checked } })} />
Mute original audio
</label>
{!edits.audio?.muted ? (
<label className="apg-vedit__row">
<span>Original volume {Math.round((edits.audio?.originalVolume ?? 1) * 100)}%</span>
<input type="range" min={0} max={1} step={0.05} value={edits.audio?.originalVolume ?? 1}
onChange={(e) => update({ audio: { ...edits.audio, originalVolume: Number(e.target.value) } })} />
</label>
) : null}
<div style={{ display: 'flex', gap: 8 }}>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Fade in {(edits.audio?.fadeIn ?? 0).toFixed(1)}s</span>
<input type="range" min={0} max={5} step={0.1} value={edits.audio?.fadeIn ?? 0}
onChange={(e) => update({ audio: { ...edits.audio, fadeIn: Number(e.target.value) } })} />
</label>
<label className="apg-vedit__row" style={{ flex: 1 }}>
<span>Fade out {(edits.audio?.fadeOut ?? 0).toFixed(1)}s</span>
<input type="range" min={0} max={5} step={0.1} value={edits.audio?.fadeOut ?? 0}
onChange={(e) => update({ audio: { ...edits.audio, fadeOut: Number(e.target.value) } })} />
</label>
</div>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => musicFileRef.current?.click()}>
<Icon name="play" size={14} /> {edits.audio?.musicSrc ? 'Replace Music' : 'Add Music'}
</button>
<input ref={musicFileRef} type="file" accept="audio/*" hidden
onChange={(e) => { pickMusic(e.target.files?.[0] ?? undefined); e.target.value = ''; }} />
{edits.audio?.musicSrc ? (
<>
<label className="apg-vedit__row">
<span>Music volume {Math.round((edits.audio.musicVolume ?? 0.8) * 100)}%</span>
<input type="range" min={0} max={1} step={0.05} value={edits.audio.musicVolume ?? 0.8}
onChange={(e) => update({ audio: { ...edits.audio, musicVolume: Number(e.target.value) } })} />
</label>
<button type="button" className="apg-btn" onClick={() => update({ audio: { ...edits.audio, musicSrc: undefined } })}>Remove Music</button>
</>
) : null}
</div>
) : null}
{/* ---------- EXPORT (quality + poster) ---------- */}
{tab === 'export' ? (
<div className="apg-vedit__panel">
<div className="apg-vedit__hint">Resolution (longest side)</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{QUALITIES.map((q) => (
<button
key={q.label}
type="button"
className={['apg-btn apg-btn--small', (edits.export?.maxDim ?? 1280) === q.maxDim ? 'apg-btn--primary' : ''].join(' ')}
onClick={() => update({ export: { ...edits.export, maxDim: q.maxDim } })}
>
{q.label}
</button>
))}
</div>
<label className="apg-vedit__row" style={{ marginTop: 10 }}>
<span>Frame rate {edits.export?.fps ?? 30} fps</span>
<input type="range" min={15} max={60} step={1} value={edits.export?.fps ?? 30}
onChange={(e) => update({ export: { ...edits.export, fps: Number(e.target.value) } })} />
</label>
<div className="apg-vedit__hint" style={{ marginTop: 10 }}>Poster / thumbnail</div>
<button type="button" className="apg-btn apg-btn--small" onClick={() => update({ posterTime: outputTime })}>
<Icon name="image" size={13} /> Use current frame ({fmt(outputTime)})
</button>
{edits.posterTime != null ? (
<div className="apg-vedit__hint">Poster set at {fmt(edits.posterTime)}.</div>
) : null}
</div>
) : null}
</div>
</div>
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,388 @@
'use client';
import { useState } from 'react';
import { downloadMedia } from '../lib/download';
import { Icon } from '../icons';
import { useGallery, useGalleryStoreApi } from '../store/context';
import type { AlbumId, MediaId, ShareRecord, ViewId } from '../types';
import { closeModal, openModal } from './Modal';
/* ----------------------------- Rename / name prompt ----------------------------- */
function NamePrompt({
title,
initial,
confirmLabel,
placeholder,
onConfirm,
}: {
title: string;
initial: string;
confirmLabel: string;
placeholder?: string;
onConfirm: (name: string) => void;
}) {
const [value, setValue] = useState(initial);
const submit = () => {
const trimmed = value.trim();
if (trimmed) onConfirm(trimmed);
closeModal();
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label={title}>
<div className="apg-modal__title">{title}</div>
<input
className="apg-modal__input"
autoFocus
value={value}
maxLength={120}
placeholder={placeholder ?? 'Album name'}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit();
}}
/>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={submit}>
{confirmLabel}
</button>
</div>
</div>
);
}
export function promptAlbumName(
title: string,
initial: string,
onConfirm: (name: string) => void,
opts?: { placeholder?: string; confirmLabel?: string },
) {
const confirmLabel =
opts?.confirmLabel ?? (title.toLowerCase().includes('rename') ? 'Rename' : 'Create');
openModal(
<NamePrompt
title={title}
initial={initial}
confirmLabel={confirmLabel}
placeholder={opts?.placeholder}
onConfirm={onConfirm}
/>,
);
}
/* ----------------------------- Album picker ----------------------------- */
function AlbumPickerModal({
ids,
mode = 'copy',
fromAlbumId,
}: {
ids: MediaId[];
mode?: 'copy' | 'move';
fromAlbumId?: string;
}) {
const api = useGalleryStoreApi();
const albums = useGallery((s) =>
s.albums.filter((a) => (a.kind === 'user' || a.kind === 'folder') && a.id !== fromAlbumId),
);
const moving = mode === 'move' && !!fromAlbumId;
const place = (albumId: string) => {
if (moving) api.getState().moveToAlbum(fromAlbumId!, albumId, ids);
else api.getState().addToAlbum(albumId, ids);
closeModal();
};
const createAndPlace = () => {
closeModal();
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
if (moving) api.getState().moveToAlbum(fromAlbumId!, id, ids);
else api.getState().addToAlbum(id, ids);
api.getState().setView(`album:${id}` as ViewId);
});
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label={moving ? 'Move to album' : 'Add to album'}>
<div className="apg-modal__title">
{moving ? 'Move' : 'Add'} {ids.length} item{ids.length === 1 ? '' : 's'} to
</div>
<div className="apg-modal__list">
<button type="button" className="apg-modal__list-item" onClick={createAndPlace}>
New Album
</button>
{albums.map((a) => (
<button key={a.id} type="button" className="apg-modal__list-item" onClick={() => place(a.id)}>
{a.name}
</button>
))}
{albums.length === 0 ? (
<div className="apg-empty-card__text" style={{ padding: '8px 10px' }}>
No albums yet create one above.
</div>
) : null}
</div>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
</div>
</div>
);
}
export function addToAlbumPicker(ids: MediaId[]) {
if (!ids.length) return;
openModal(<AlbumPickerModal ids={ids} />);
}
/** Move items out of `fromAlbumId` into a chosen album. */
export function moveToAlbumPicker(fromAlbumId: string, ids: MediaId[]) {
if (!ids.length) return;
openModal(<AlbumPickerModal ids={ids} mode="move" fromAlbumId={fromAlbumId} />);
}
/* ----------------------------- Confirm dialog ----------------------------- */
export function confirmAction(opts: {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
}) {
openModal(
<div className="apg-modal" role="alertdialog" aria-modal="true" aria-label={opts.title}>
<div className="apg-modal__title">{opts.title}</div>
<div className="apg-empty-card__text">{opts.message}</div>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button
type="button"
className={['apg-btn', opts.danger ? '' : 'apg-btn--primary'].join(' ')}
style={opts.danger ? { background: 'var(--apg-danger)', color: '#fff' } : undefined}
onClick={() => {
closeModal();
opts.onConfirm();
}}
>
{opts.confirmLabel ?? 'Confirm'}
</button>
</div>
</div>,
);
}
/* ----------------------------- Security (lock) settings ----------------------------- */
function SecuritySettingsModal() {
const api = useGalleryStoreApi();
const hasPw = useGallery((s) => s.lock.hash !== null);
const [p1, setP1] = useState('');
const [p2, setP2] = useState('');
const [err, setErr] = useState<string | null>(null);
const save = async () => {
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
if (p1 !== p2) return setErr('Passwords do not match.');
await api.getState().setLockPassword(p1.trim());
closeModal();
};
const remove = () => {
api.getState().removeLockPassword();
closeModal();
};
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Recently Deleted lock">
<div className="apg-modal__title">
{hasPw ? 'Recently Deleted is Locked' : 'Lock Recently Deleted'}
</div>
<div className="apg-empty-card__text" style={{ marginBottom: 4 }}>
{hasPw
? 'Set a new password, or remove the lock. The password is stored only on this device.'
: 'Protect Recently Deleted with a password. It is stored only on this device (not uploaded).'}
</div>
<input
className="apg-modal__input"
type="password"
autoFocus
placeholder={hasPw ? 'New password' : 'Password'}
value={p1}
onChange={(e) => {
setP1(e.target.value);
setErr(null);
}}
/>
<input
className="apg-modal__input"
type="password"
placeholder="Confirm password"
value={p2}
onChange={(e) => {
setP2(e.target.value);
setErr(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void save();
}}
/>
{err ? <div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>{err}</div> : null}
<div className="apg-modal__actions">
{hasPw ? (
<button
type="button"
className="apg-btn"
style={{ marginRight: 'auto', color: 'var(--apg-danger)' }}
onClick={remove}
>
Remove Lock
</button>
) : null}
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save()}>
{hasPw ? 'Change' : 'Set Password'}
</button>
</div>
</div>
);
}
export function openSecuritySettings() {
openModal(<SecuritySettingsModal />);
}
/* ----------------------------- Share ----------------------------- */
function ShareModal({ selectionIds, albumId }: { selectionIds: MediaId[]; albumId?: AlbumId }) {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums.filter((a) => a.kind === 'user' || a.kind === 'folder'));
const media = useGallery((s) => s.media);
const [picked, setPicked] = useState<Set<AlbumId>>(new Set(albumId ? [albumId] : []));
const [created, setCreated] = useState<ShareRecord | null>(null);
const [copied, setCopied] = useState(false);
const albumMediaIds = (id: AlbumId) =>
media.filter((m) => !m.deletedAt && m.albumIds.includes(id)).map((m) => m.id);
const finish = (share: ShareRecord) => {
setCreated(share);
void navigator.clipboard
?.writeText(share.url)
.then(() => setCopied(true))
.catch(() => setCopied(false));
};
const sharePhotos = () => {
finish(api.getState().createShare(selectionIds.length === 1 ? 'photo' : 'photos', selectionIds));
};
const shareAlbums = () => {
const ids = [...picked];
if (ids.length === 0) return;
const union = [...new Set(ids.flatMap(albumMediaIds))];
finish(api.getState().createShare('album', union, ids.length === 1 ? ids[0] : undefined));
};
const togglePick = (id: AlbumId) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const download = () => {
const ids = created?.mediaIds ?? [];
for (const id of ids) {
const m = media.find((x) => x.id === id);
if (m) downloadMedia(m);
}
};
if (created) {
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Share link">
<div className="apg-modal__title">Link Ready</div>
<div className="apg-empty-card__text">
{created.title} anyone with this link can view {created.mediaIds.length} item
{created.mediaIds.length === 1 ? '' : 's'}.
</div>
<input className="apg-modal__input" readOnly value={created.url} onFocus={(e) => e.currentTarget.select()} />
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={download}>
<Icon name="download" size={14} /> Download
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => {
void navigator.clipboard?.writeText(created.url).then(() => setCopied(true));
}}
>
{copied ? 'Copied ✓' : 'Copy Link'}
</button>
<button type="button" className="apg-btn" onClick={closeModal}>
Done
</button>
</div>
</div>
);
}
return (
<div className="apg-modal" role="dialog" aria-modal="true" aria-label="Share">
<div className="apg-modal__title">Share</div>
{selectionIds.length > 0 ? (
<button type="button" className="apg-modal__list-item" onClick={sharePhotos}>
<Icon name="image" size={15} />{' '}
{selectionIds.length === 1 ? 'This photo' : `${selectionIds.length} selected photos`}
</button>
) : null}
<div className="apg-empty-card__text" style={{ margin: '10px 2px 4px' }}>
Or share album{albums.length === 1 ? '' : 's'} (tick one or more):
</div>
<div className="apg-modal__list" style={{ maxHeight: 200 }}>
{albums.map((a) => (
<button
key={a.id}
type="button"
className="apg-modal__list-item"
onClick={() => togglePick(a.id)}
style={picked.has(a.id) ? { background: 'var(--apg-accent)', color: 'var(--apg-accent-contrast)' } : undefined}
>
<Icon name={picked.has(a.id) ? 'check' : 'collections'} size={15} /> {a.name}
</button>
))}
{albums.length === 0 ? (
<div className="apg-empty-card__text" style={{ padding: '8px 10px' }}>No albums yet.</div>
) : null}
</div>
<div className="apg-modal__actions">
<button type="button" className="apg-btn" onClick={closeModal}>
Cancel
</button>
<button
type="button"
className="apg-btn apg-btn--primary"
disabled={picked.size === 0}
onClick={shareAlbums}
>
Share {picked.size > 0 ? `${picked.size} album${picked.size === 1 ? '' : 's'}` : 'Album'}
</button>
</div>
</div>
);
}
/** Open the Share modal. Pass selected media ids and/or the current album id. */
export function openShareModal(selectionIds: MediaId[] = [], albumId?: AlbumId) {
openModal(<ShareModal selectionIds={selectionIds} albumId={albumId} />);
}
@@ -0,0 +1,297 @@
'use client';
import { useState } from 'react';
import { formatDay } from '../../lib/format';
import { groupByTime } from '../../lib/grouping';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { albumMedia, liveMedia, objectLabelCounts } from '../../store/selectors';
import type { MediaItem, ViewId } from '../../types';
import { promptAlbumName } from '../modals';
function SectionHeader({
title,
chevronTo,
action,
}: {
title: string;
chevronTo?: () => void;
action?: { label: string; onClick: () => void };
}) {
return (
<div className="apg-collections__header">
<div
className="apg-collections__title"
onClick={chevronTo}
role={chevronTo ? 'button' : undefined}
tabIndex={chevronTo ? 0 : undefined}
onKeyDown={
chevronTo
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
chevronTo();
}
}
: undefined
}
>
{title}
{chevronTo ? <Icon name="chevron-right" size={18} /> : null}
</div>
{action ? (
<button type="button" className="apg-collections__action" onClick={action.onClick}>
{action.label}
</button>
) : null}
</div>
);
}
function PinnedCard({
label,
cover,
badge,
onClick,
}: {
label: string;
cover?: MediaItem;
badge?: IconName;
onClick: () => void;
}) {
return (
<button type="button" className="apg-pinned-card" onClick={onClick} aria-label={label}>
{cover ? <img src={cover.thumbnail ?? cover.src} alt="" draggable={false} /> : null}
{badge ? (
<span className="apg-pinned-card__badge">
<Icon name={badge} size={16} />
</span>
) : null}
<span className="apg-pinned-card__label">{label}</span>
</button>
);
}
function EmptyCard({
icon,
title,
text,
}: {
icon: IconName;
title: string;
text: string;
}) {
return (
<div className="apg-empty-card">
<span className="apg-empty-card__icon">
<Icon name={icon} size={30} />
</span>
<div className="apg-empty-card__title">{title}</div>
<div className="apg-empty-card__text">{text}</div>
</div>
);
}
export function CollectionsView() {
const api = useGalleryStoreApi();
const media = useGallery((s) => s.media);
const albums = useGallery((s) => s.albums);
const live = liveMedia(media);
const first = (pred: (m: MediaItem) => boolean) => live.find(pred);
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
const go = (v: ViewId) => () => api.getState().setView(v);
const recentDays = groupByTime(live, 'day').slice(0, 8);
const featured = [...live].sort((a, b) => b.takenAt - a.takenAt).slice(0, 12);
const objectEntries = [...objectLabelCounts(media).entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 14);
return (
<div className="apg-scroll">
<div className="apg-collections">
{/* Memories */}
<section className="apg-collections__section">
<SectionHeader title="Memories" />
<EmptyCard
icon="clock"
title="No Memories Available"
text="Memories will appear here when more photos and videos are added to the library."
/>
</section>
{/* Pinned */}
<section className="apg-collections__section">
<SectionHeader title="Pinned" chevronTo={go('collections')} />
<div className="apg-pinned-row">
<PinnedCard label="Favourites" badge="heart-fill" cover={first((m) => m.favorite)} onClick={go('favourites')} />
<PinnedCard label="Recently Saved" cover={first((m) => m.source === 'download' || m.source === 'social')} onClick={go('recently-saved')} />
<PinnedCard label="Map" cover={first((m) => Boolean(m.location))} onClick={go('map')} />
<PinnedCard label="Videos" cover={first((m) => m.kind === 'video')} onClick={go('videos')} />
<PinnedCard label="Screenshots" cover={first((m) => m.source === 'screenshot')} onClick={go('screenshots')} />
<PinnedCard label="Documents" badge="document" cover={first((m) => Boolean(m.ocrText && m.ocrText.trim()))} onClick={go('sys:documents')} />
<PinnedCard label="People & Pets" onClick={go('people')} />
<PinnedCard label="Recently Deleted" badge="trash" onClick={go('recently-deleted')} />
</div>
</section>
{/* Albums */}
<section className="apg-collections__section">
<SectionHeader
title="Albums"
action={{
label: 'Create',
onClick: () =>
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
api.getState().setView(`album:${id}` as ViewId);
}),
}}
/>
{userAlbums.length === 0 ? (
<EmptyCard
icon="collections"
title="No Albums Available"
text="Albums will appear here when they are added to the library or synced."
/>
) : (
<div className="apg-pinned-row">
{userAlbums.map((a) => {
const am = albumMedia(a, media);
return (
<PinnedCard
key={a.id}
label={a.name}
cover={am.find((m) => m.id === a.coverId) ?? am[0]}
onClick={go(a.id as ViewId)}
/>
);
})}
</div>
)}
</section>
{/* Objects (AI) — auto categories from on-device object detection */}
{objectEntries.length > 0 ? (
<section className="apg-collections__section">
<SectionHeader title="Objects" />
<div className="apg-pinned-row">
{objectEntries.map(([label, count]) => (
<button
key={label}
type="button"
className="apg-pinned-card"
onClick={() => api.getState().setView(`sys:obj:${label}` as ViewId)}
aria-label={`${count} photos containing ${label}`}
>
{(() => {
const cover = live.find((m) => m.objectLabels.includes(label));
return cover ? <img src={cover.thumbnail ?? cover.src} alt="" draggable={false} /> : null;
})()}
<span className="apg-pinned-card__label" style={{ textTransform: 'capitalize' }}>
{label}
<span style={{ opacity: 0.8, fontWeight: 500 }}> · {count}</span>
</span>
</button>
))}
</div>
</section>
) : null}
{/* People & Pets */}
<section className="apg-collections__section">
<SectionHeader title="People & Pets" chevronTo={go('people')} />
<EmptyCard
icon="person-circle"
title="Finding People…"
text="Photos creates albums and groups of people and pets found in your library."
/>
</section>
{/* Featured Photos */}
<section className="apg-collections__section">
<SectionHeader title="Featured Photos" />
{featured.length === 0 ? (
<EmptyCard
icon="image"
title="No Featured Photos Available"
text="Featured Photos will appear here when more photos and videos are added."
/>
) : (
<div className="apg-pinned-row">
{featured.map((m) => (
<PinnedCard key={m.id} label="" cover={m} onClick={() => api.getState().openLightbox(m.id)} />
))}
</div>
)}
</section>
{/* Shared Albums */}
<section className="apg-collections__section">
<SectionHeader title="Shared Albums" action={{ label: 'Start Sharing', onClick: () => api.getState().setView('shared-albums') }} />
<EmptyCard
icon="people"
title="Shared Albums"
text="Share photos and videos with just the people you choose, and let them add photos, videos and comments."
/>
</section>
{/* Recent Days */}
<section className="apg-collections__section">
<SectionHeader title="Recent Days" />
{recentDays.length === 0 ? (
<EmptyCard
icon="clock"
title="No Days Available"
text="Days will appear here when more photos and videos are added to the library."
/>
) : (
<div className="apg-pinned-row">
{recentDays.map((d) => (
<PinnedCard
key={d.key}
label={formatDay(d.items[0]!.takenAt)}
cover={d.items[0]}
onClick={() => api.getState().openLightbox(d.items[0]!.id)}
/>
))}
</div>
)}
</section>
{/* Trips */}
<section className="apg-collections__section">
<SectionHeader title="Trips" />
<EmptyCard
icon="suitcase"
title="No Trips Available"
text="Trips will appear here when more photos and videos are added to the library."
/>
</section>
{/* Utilities */}
<section className="apg-collections__section">
<SectionHeader title="Utilities" chevronTo={go('recently-deleted')} />
<button type="button" className="apg-list-row" onClick={go('recently-deleted')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
<Icon name="trash" size={18} />
Recently Deleted
<span className="apg-list-row__trail">
<Icon name="lock" size={15} />
</span>
</button>
<button type="button" className="apg-list-row" onClick={go('duplicates')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
<Icon name="duplicates" size={18} />
Duplicates
<span className="apg-list-row__trail">{live.length ? 0 : 0}</span>
</button>
<button type="button" className="apg-list-row" onClick={go('map')} style={{ width: '100%', border: 'none', cursor: 'default' }}>
<Icon name="map" size={18} />
Map
</button>
</section>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
'use client';
import { Icon, type IconName } from '../../icons';
export function EmptyState({
icon,
title,
subtitle,
action,
}: {
icon?: IconName;
title: string;
subtitle?: string;
action?: { label: string; onClick: () => void };
}) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
{icon ? (
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name={icon} size={42} />
</span>
) : null}
<div className="apg-empty__title" style={{ fontSize: 26 }}>
{title}
</div>
{subtitle ? <div className="apg-empty__subtitle">{subtitle}</div> : null}
{action ? (
<button type="button" className="apg-btn" onClick={action.onClick}>
{action.label}
</button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,235 @@
'use client';
import { useViewMedia } from '../../hooks/useViewMedia';
import { findDuplicateGroups } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { albumById } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
import { PhotoTile } from '../PhotoTile';
import { EmptyState } from './EmptyState';
const EMPTY_COPY: Record<string, { title: string; subtitle: string; icon: any }> = {
favourites: { title: 'No Favourites', subtitle: 'Tap the heart on a photo to add it here.', icon: 'heart' },
'recently-saved': { title: 'Nothing Saved Yet', subtitle: 'Downloaded and shared media will appear here.', icon: 'download' },
videos: { title: 'No Videos', subtitle: 'Imported videos will appear here.', icon: 'video' },
screenshots: { title: 'No Screenshots', subtitle: 'Screenshots are detected automatically on import.', icon: 'screenshot' },
search: { title: 'Search Your Library', subtitle: 'Search by name, place, tag or detected object.', icon: 'search' },
};
/** Generic grid screen for the simple filtered views. */
export function GridScreen() {
const items = useViewMedia();
const view = useGallery((s) => s.view);
if (items.length === 0) {
const copy = EMPTY_COPY[view] ?? { title: 'No Items', subtitle: '', icon: 'image' };
return <EmptyState icon={copy.icon} title={copy.title} subtitle={copy.subtitle} />;
}
return (
<div className="apg-scroll">
<MediaGrid items={items} />
</div>
);
}
export function AlbumView() {
const view = useGallery((s) => s.view);
const albums = useGallery((s) => s.albums);
const items = useViewMedia();
const album = albumById(albums, view);
return (
<div className="apg-scroll">
<div style={{ padding: '18px 14px 4px' }}>
<div style={{ fontSize: 26, fontWeight: 700 }}>{album?.name ?? 'Album'}</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 13 }}>
{items.length} item{items.length === 1 ? '' : 's'}
</div>
</div>
{items.length === 0 ? (
<EmptyState
icon="collections"
title="No Photos"
subtitle="Select photos in your library and add them to this album."
/>
) : (
<MediaGrid items={items} />
)}
</div>
);
}
export function SharedAlbumsView() {
const api = useGalleryStoreApi();
const shares = useGallery((s) => s.shares);
const media = useGallery((s) => s.media);
if (shares.length === 0) {
return (
<EmptyState
icon="people"
title="Shared Albums"
subtitle="Select photos or an album and choose Share to create a link. Your shares appear here."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16 }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Shared Albums</div>
<div className="apg-pinned-row">
{shares.map((sh) => {
const cover = media.find((m) => m.id === sh.mediaIds[0]);
return (
<div key={sh.id} className="apg-pinned-card" style={{ cursor: 'default' }}>
{cover ? <img src={cover.thumbnail ?? cover.src} alt="" /> : <Icon name="people" size={28} />}
<div className="apg-pinned-card__label">{sh.title}</div>
<div className="apg-pinned-card__badge">{sh.mediaIds.length}</div>
<div
style={{
position: 'absolute',
inset: 'auto 0 0 0',
display: 'flex',
gap: 6,
padding: 6,
background: 'linear-gradient(transparent, rgba(0,0,0,0.55))',
}}
>
<button
type="button"
className="apg-btn"
style={{ flex: 1, padding: '3px 6px', fontSize: 11 }}
onClick={() => void navigator.clipboard?.writeText(sh.url)}
>
Copy Link
</button>
<button
type="button"
className="apg-btn"
style={{ padding: '3px 6px', fontSize: 11, color: 'var(--apg-danger)' }}
onClick={() => api.getState().revokeShare(sh.id)}
>
Revoke
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
export function ActivityView() {
const shares = useGallery((s) => s.shares);
if (shares.length === 0) {
return (
<EmptyState
icon="chat"
title="Activity"
subtitle="Your sharing activity — links you create — will appear here."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16, maxWidth: 640 }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>Activity</div>
{shares.map((sh) => (
<div
key={sh.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
marginBottom: 8,
background: 'var(--apg-bg-elevated)',
borderRadius: 12,
}}
>
<span style={{ color: 'var(--apg-accent)' }}>
<Icon name="share" size={18} />
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>
You shared {sh.title}
</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
{sh.mediaIds.length} item{sh.mediaIds.length === 1 ? '' : 's'} ·{' '}
{formatRelative(sh.createdAt)}
</div>
</div>
</div>
))}
</div>
);
}
function formatRelative(ts: number): string {
const diff = Date.now() - ts;
const min = Math.round(diff / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min} min ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr} hr ago`;
return `${Math.round(hr / 24)} day(s) ago`;
}
export function DuplicatesView() {
const api = useGalleryStoreApi();
const media = useGallery((s) => s.media);
const groups = findDuplicateGroups(media);
if (groups.length === 0) {
return (
<EmptyState
icon="duplicates"
title="No Duplicates"
subtitle="Exact and near-duplicate items will be grouped here so you can merge them."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16 }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 12 }}>
{groups.length} Duplicate Group{groups.length === 1 ? '' : 's'}
</div>
{groups.map((group, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
marginBottom: 10,
background: 'var(--apg-bg-elevated)',
borderRadius: 12,
}}
>
<div
className="apg-grid"
style={{
gridTemplateColumns: `repeat(${Math.min(group.length, 4)}, 64px)`,
gap: 4,
padding: 0,
flex: 1,
}}
>
{group.slice(0, 4).map((m) => (
<PhotoTile key={m.id} item={m} orderedIds={group.map((g) => g.id)} />
))}
</div>
<button
type="button"
className="apg-btn apg-btn--primary"
onClick={() => api.getState().trash(group.slice(1).map((m) => m.id))}
>
<Icon name="duplicates" size={14} /> Keep 1, Trash {group.length - 1}
</button>
</div>
))}
</div>
);
}
@@ -0,0 +1,158 @@
'use client';
import { useRef, useState } from 'react';
import { useViewMedia } from '../../hooks/useViewMedia';
import { groupByTime } from '../../lib/grouping';
import { Icon, type IconName } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { liveMedia } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
function Welcome({ onImport }: { onImport: () => void }) {
const hints: Array<{ icon: IconName; text: string }> = [
{ icon: 'camera', text: 'Connect a camera or memory card.' },
{ icon: 'duplicates', text: 'Drag pictures directly into Photos.' },
{ icon: 'download', text: 'Click the + button to import.' },
{ icon: 'image', text: 'Turn on iCloud Photos in Settings.' },
];
return (
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
<div className="apg-empty__title">Welcome to Photos</div>
<div className="apg-empty__subtitle">To get started with Photos, do any of the following:</div>
<div className="apg-empty__hints">
{hints.map((h, i) => (
<div className="apg-empty__hint" key={i}>
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name={h.icon} size={40} />
</span>
<span>{h.text}</span>
</div>
))}
</div>
</div>
);
}
export function LibraryView() {
const api = useGalleryStoreApi();
const items = useViewMedia();
const scale = useGallery((s) => s.libraryScale);
const searchQuery = useGallery((s) => s.searchQuery);
const objectFocus = useGallery((s) => s.objectFocus);
const tagFocus = useGallery((s) => s.tagFocus);
const personFocus = useGallery((s) => s.personFocus);
const personName = useGallery((s) => {
if (!s.personFocus) return null;
const p = s.people.find((x) => x.id === s.personFocus);
return p?.name ?? 'Unnamed person';
});
const totalLive = useGallery((s) => liveMedia(s.media).length);
const fileRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const importFiles = async (files: FileList | null) => {
if (!files?.length) return;
await api.getState().importFiles(files);
if (fileRef.current) fileRef.current.value = '';
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
void importFiles(e.dataTransfer.files);
};
let content: React.ReactNode;
if (items.length === 0) {
if (totalLive === 0) {
content = <Welcome onImport={() => fileRef.current?.click()} />;
} else {
content = (
<div className="apg-empty">
<div className="apg-empty__card">
<div className="apg-empty__title" style={{ fontSize: 24 }}>
No Results
</div>
<div className="apg-empty__subtitle">
{objectFocus
? `No photos containing “${objectFocus}”.`
: searchQuery
? 'Try a different search term.'
: 'No items match this filter.'}
</div>
</div>
</div>
);
}
} else if (scale === 'all') {
content = <MediaGrid items={items} />;
} else {
const granularity = scale === 'years' ? 'year' : scale === 'months' ? 'month' : 'day';
const sections = groupByTime(items, granularity);
content = (
<>
{sections.map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))}
</>
);
}
return (
<div
className="apg-scroll"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
style={dragging ? { outline: '3px dashed var(--apg-accent)', outlineOffset: -8 } : undefined}
>
{objectFocus || tagFocus || personFocus ? (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 12px 0',
fontSize: 14,
fontWeight: 600,
textTransform: 'capitalize',
}}
>
<Icon name={objectFocus ? 'search' : personFocus ? 'person-circle' : 'tag'} size={16} />
{objectFocus
? `Object: ${objectFocus}`
: personFocus
? `Person: ${personName}`
: `Tag: ${tagFocus}`}
<button
type="button"
className="apg-btn"
style={{ marginLeft: 8, padding: '3px 10px', textTransform: 'none' }}
onClick={() =>
objectFocus
? api.getState().setObjectFocus(null)
: personFocus
? api.getState().setPersonFocus(null)
: api.getState().setTagFocus(null)
}
>
Clear
</button>
</div>
) : null}
{content}
<input
ref={fileRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => void importFiles(e.target.files)}
/>
</div>
);
}
@@ -0,0 +1,300 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { MAP_TILES } from '../../constants';
import { groupByTime } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { clusterByLocation, type LocationCluster, liveMedia, locatedMedia } from '../../store/selectors';
import { MediaGrid } from '../MediaGrid';
import { MosaicGrid } from './MosaicGrid';
export function MapView() {
const api = useGalleryStoreApi();
const mapMode = useGallery((s) => s.mapMode);
const media = useGallery((s) => s.media);
const mapFocus = useGallery((s) => s.mapFocus);
const located = locatedMedia(media);
// The location pin the user tapped → its photos shown date-grouped in a sheet.
const [cluster, setCluster] = useState<LocationCluster | null>(null);
// Sheet height as a fraction of the viewport (drag the handle up → toward full).
const [sheetH, setSheetH] = useState(0.5);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
const onHandleDown = (e: React.PointerEvent) => {
dragRef.current = { startY: e.clientY, startH: sheetH };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onHandleMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const dy = dragRef.current.startY - e.clientY; // dragging up is positive
const next = dragRef.current.startH + dy / window.innerHeight;
setSheetH(Math.max(0.18, Math.min(0.98, next)));
};
const onHandleUp = () => {
if (dragRef.current && sheetH <= 0.2) setCluster(null); // dragged down → dismiss
dragRef.current = null;
};
const containerRef = useRef<HTMLDivElement>(null);
// Leaflet instances kept in refs (typed loosely to avoid an SSR import).
const mapRef = useRef<any>(null);
const tileRef = useRef<any>(null);
const markersRef = useRef<any>(null);
const leafletRef = useRef<any>(null);
// addMarkers reads the latest `located` + setCluster via refs (Leaflet callbacks
// are created once, so closing over state directly would go stale).
const locatedRef = useRef(located);
locatedRef.current = located;
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
openClusterRef.current = (c) => {
setCluster(c);
setSheetH(0.5); // reset to half height each time a pin is opened
};
// Create the map once when entering a map tile mode.
useEffect(() => {
if (mapMode === 'grid' || !containerRef.current || mapRef.current) return;
let cancelled = false;
void import('leaflet').then((mod) => {
const L = (mod as any).default ?? mod;
if (cancelled || !containerRef.current) return;
leafletRef.current = L;
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: true,
worldCopyJump: true,
}).setView([20, 0], 2);
mapRef.current = map;
addTiles();
addMarkers();
// Re-cluster pins each time the zoom changes (precision is zoom-dependent).
map.on('zoomend', addMarkers);
// Center on a focused photo (from the Info mini-map), else fit to all markers.
if (mapFocus) {
map.setView([mapFocus.lat, mapFocus.lng], 12, { animate: false });
} else if (located.length) {
const bounds = L.latLngBounds(located.map((m) => [m.location!.lat, m.location!.lng]));
map.fitBounds(bounds.pad(0.3), { animate: false });
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapMode]);
// Tear the map down when leaving map tile modes.
useEffect(() => {
if (mapMode === 'grid' && mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
tileRef.current = null;
markersRef.current = null;
}
}, [mapMode]);
// Destroy on unmount.
useEffect(() => {
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
tileRef.current = null;
markersRef.current = null;
}
};
}, []);
const addTiles = () => {
const L = leafletRef.current;
const map = mapRef.current;
if (!L || !map) return;
if (tileRef.current) {
map.removeLayer(tileRef.current);
tileRef.current = null;
}
const cfg = mapMode === 'satellite' ? MAP_TILES.satellite : MAP_TILES.map;
tileRef.current = L.tileLayer(cfg.url, {
attribution: cfg.attribution,
maxZoom: cfg.maxZoom,
}).addTo(map);
};
const addMarkers = () => {
const L = leafletRef.current;
const map = mapRef.current;
if (!L || !map) return;
// Keep markers in a dedicated layer group so they can be re-synced wholesale.
if (!markersRef.current) markersRef.current = L.layerGroup().addTo(map);
markersRef.current.clearLayers();
// Tighter clustering as you zoom in, so pins split apart (macOS behaviour).
const zoom = map.getZoom?.() ?? 2;
const precision = zoom < 4 ? 0 : zoom < 7 ? 1 : zoom < 11 ? 2 : 3;
const clusters = clusterByLocation(locatedRef.current, precision);
for (const c of clusters) {
const cover = c.items[0]!;
// Static markup only (no interpolated data) so Leaflet's innerHTML can't be
// injected; the thumbnail src + count are set via DOM after the marker mounts.
const icon = L.divIcon({
className: 'apg-pin-wrap',
// Static markup only (data set via DOM after mount → XSS-safe). Includes a
// hover tooltip with an image preview + caption.
html:
'<span class="apg-pin"><img class="apg-pin__img" alt=""/>' +
'<span class="apg-pin__count"></span>' +
'<span class="apg-pin__tip"><img class="apg-pin__tip-img" alt=""/>' +
'<span class="apg-pin__tip-cap"></span></span></span>',
iconSize: [56, 64],
iconAnchor: [28, 64],
});
const marker = L.marker([c.lat, c.lng], { icon, keyboard: false });
marker.on('add', () => {
const el: HTMLElement | null = marker.getElement();
if (!el) return;
const thumb = cover.thumbnail ?? cover.src;
const img = el.querySelector('.apg-pin__img') as HTMLImageElement | null;
if (img) img.src = thumb;
const tipImg = el.querySelector('.apg-pin__tip-img') as HTMLImageElement | null;
if (tipImg) tipImg.src = thumb;
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
if (cap) {
// textContent (never innerHTML) — place names are attacker-influencable.
const place = cover.location?.place ?? '';
const n = c.items.length;
cap.textContent = place ? `${place}${n > 1 ? ` · ${n} photos` : ''}` : `${n} photo${n === 1 ? '' : 's'}`;
}
const badge = el.querySelector('.apg-pin__count') as HTMLElement | null;
if (badge) {
badge.textContent = String(c.items.length);
if (c.items.length < 2) badge.style.display = 'none';
}
});
marker.on('click', () => openClusterRef.current(c));
markersRef.current.addLayer(marker);
}
};
// Swap tile layer when Map/Satellite toggles.
useEffect(() => {
if (mapRef.current) addTiles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapMode]);
// Re-sync markers when the located set changes while the map stays mounted.
useEffect(() => {
if (mapRef.current) addMarkers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [media]);
// Recenter when a photo's location is focused (from the Info mini-map).
useEffect(() => {
if (mapFocus && mapRef.current) {
mapRef.current.setView([mapFocus.lat, mapFocus.lng], 12, { animate: true });
}
}, [mapFocus]);
if (mapMode === 'grid') {
// The Grid tab shows ALL photos in the macOS Memories-style mosaic (not just
// located ones) so it's a rich date-grouped collage, per the design.
const all = liveMedia(media);
if (all.length === 0) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
<div className="apg-empty__title" style={{ fontSize: 22 }}>
No Photos
</div>
<div className="apg-empty__subtitle">Imported photos appear here as a mosaic.</div>
</div>
</div>
);
}
return <MosaicGrid items={all} groupBy="month" />;
}
return (
<div className="apg-map">
<div ref={containerRef} className="apg-map__leaflet" />
<div className="apg-map__controls">
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Zoom in"
onClick={() => mapRef.current?.zoomIn()}
>
<Icon name="plus" />
</button>
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Zoom out"
onClick={() => mapRef.current?.zoomOut()}
>
<Icon name="minus" />
</button>
<button
type="button"
className="apg-iconbtn apg-iconbtn--circle"
aria-label="Reset north"
onClick={() => mapRef.current?.setView([20, 0], 2)}
>
<Icon name="compass" size={22} />
</button>
</div>
<a className="apg-map__legal" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer noopener">
Legal
</a>
{cluster ? (
<div
className="apg-map__sheet"
role="dialog"
aria-label="Photos at this location"
style={{ height: `${Math.round(sheetH * 100)}%` }}
>
<div
className="apg-map__grip"
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={onHandleUp}
title="Drag to resize"
aria-hidden
>
<span className="apg-map__grip-bar" />
</div>
<div className="apg-map__sheet-head">
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>
{cluster.items[0]?.location?.place ?? 'This location'}
</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 12 }}>
{cluster.items.length} photo{cluster.items.length === 1 ? '' : 's'}
</div>
</div>
<button
type="button"
className="apg-iconbtn"
aria-label="Close"
onClick={() => setCluster(null)}
>
<Icon name="close" />
</button>
</div>
<div className="apg-map__sheet-body apg-scroll">
{groupByTime(cluster.items, 'day').map((s) => (
<MediaGrid key={s.key} items={s.items} title={s.title} />
))}
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,104 @@
'use client';
import { useEffect, useState } from 'react';
import { editFilterCss } from '../../lib/edits';
import { groupByTime } from '../../lib/grouping';
import { useGalleryStoreApi } from '../../store/context';
import type { MediaItem } from '../../types';
/**
* macOS "Grid" / Memories-style mosaic: date-grouped sections, each led by a
* full-width auto-sliding hero, followed by an irregular grid of tiles with
* varied spans (portrait/landscape/square) rather than a uniform square grid.
*/
// (col-span, row-span) pattern for the tiles under the hero → deliberate variety.
const SPANS: ReadonlyArray<readonly [number, number]> = [
[1, 2],
[1, 2],
[2, 1],
[1, 1],
[2, 2],
[1, 2],
[1, 1],
[2, 1],
];
function Tile({ item, onOpen, span }: { item: MediaItem; onOpen: () => void; span: readonly [number, number] }) {
return (
<button
type="button"
className="apg-mosaic__tile"
style={{ gridColumn: `span ${span[0]}`, gridRow: `span ${span[1]}` }}
onClick={onOpen}
aria-label={item.name}
>
{item.kind === 'video' ? (
<video src={item.src} poster={item.poster} muted preload="metadata" playsInline />
) : (
<img
src={item.thumbnail ?? item.src}
alt=""
loading="lazy"
style={{ filter: editFilterCss(item.edits) }}
/>
)}
</button>
);
}
/** Full-width hero that cross-fades through the section's first few photos. */
function SlideHero({ items, onOpen }: { items: MediaItem[]; onOpen: (id: string) => void }) {
const [i, setI] = useState(0);
const slides = items.slice(0, 6);
useEffect(() => {
if (slides.length < 2) return;
const t = setInterval(() => setI((x) => (x + 1) % slides.length), 3500);
return () => clearInterval(t);
}, [slides.length]);
const current = slides[i % slides.length] ?? items[0]!;
return (
<button type="button" className="apg-mosaic__hero" onClick={() => onOpen(current.id)} aria-label="Open photo">
{slides.map((m, idx) => (
<img
key={m.id}
src={m.thumbnail ?? m.src}
alt=""
style={{ opacity: idx === i % slides.length ? 1 : 0, filter: editFilterCss(m.edits) }}
/>
))}
{slides.length > 1 ? (
<span className="apg-mosaic__dots" aria-hidden>
{slides.map((m, idx) => (
<span key={m.id} className={idx === i % slides.length ? 'is-active' : ''} />
))}
</span>
) : null}
</button>
);
}
export function MosaicGrid({ items, groupBy = 'month' }: { items: MediaItem[]; groupBy?: 'month' | 'day' }) {
const api = useGalleryStoreApi();
const open = (id: string) => api.getState().openLightbox(id);
const sections = groupByTime(items, groupBy);
return (
<div className="apg-scroll apg-mosaic-wrap">
{sections.map((s) => (
<section key={s.key} className="apg-mosaic-section">
<div className="apg-mosaic__title">{s.title}</div>
<SlideHero items={s.items} onOpen={open} />
{s.items.length > 1 ? (
<div className="apg-mosaic">
{s.items.slice(1).map((m, idx) => (
<Tile key={m.id} item={m} span={SPANS[idx % SPANS.length]!} onOpen={() => open(m.id)} />
))}
</div>
) : null}
</section>
))}
</div>
);
}
@@ -0,0 +1,102 @@
'use client';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { promptAlbumName } from '../modals';
export function PeopleView() {
const api = useGalleryStoreApi();
const people = useGallery((s) => s.people);
const media = useGallery((s) => s.media);
if (people.length === 0) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name="person-circle" size={44} />
</span>
<div className="apg-empty__title" style={{ fontSize: 24 }}>
Finding People
</div>
<div className="apg-empty__subtitle">
Photos creates albums and groups of people and pets found in your library when you are
not using the app.
</div>
</div>
</div>
);
}
return (
<div className="apg-scroll">
<div
className="apg-grid"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 18, padding: 18 }}
>
{people.map((p) => {
const cover = media.find((m) => m.id === (p.coverId ?? p.mediaIds[0]));
const rename = () =>
promptAlbumName(
p.name ? `Rename ${p.isPet ? 'pet' : 'person'}` : `Name this ${p.isPet ? 'pet' : 'person'}`,
p.name ?? '',
(name) => api.getState().renamePerson(p.id, name),
{ placeholder: p.isPet ? 'Pet name' : 'Name', confirmLabel: 'Save' },
);
return (
<div
key={p.id}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}
>
<button
type="button"
aria-label={`View photos of ${p.name ?? 'this ' + (p.isPet ? 'pet' : 'person')}`}
style={{
width: 100,
height: 100,
borderRadius: '50%',
overflow: 'hidden',
background: 'var(--apg-bg-elevated)',
display: 'grid',
placeItems: 'center',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
onClick={() => api.getState().setPersonFocus(p.id)}
>
{cover ? (
<img
src={cover.thumbnail ?? cover.src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Icon name={p.isPet ? 'tag' : 'person-circle'} size={40} />
)}
</button>
<button
type="button"
onClick={rename}
title="Click to name"
style={{
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: 13,
fontWeight: 500,
color: p.name ? 'var(--apg-text)' : 'var(--apg-accent)',
}}
>
{p.name ?? (p.isPet ? '+ Name pet' : '+ Add Name')}
</button>
<span style={{ fontSize: 11, color: 'var(--apg-text-tertiary)' }}>
{p.mediaIds.length} {p.mediaIds.length === 1 ? 'photo' : 'photos'}
</span>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,113 @@
'use client';
import { groupByTime } from '../../lib/grouping';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { albumMedia, liveMedia } from '../../store/selectors';
import type { ViewId } from '../../types';
import { promptAlbumName } from '../modals';
import { EmptyState } from './EmptyState';
/**
* "All Albums" overview a meaningful web-gallery view (replaces macOS's
* iOS-only "Projects / App Store" screen). Shows every album as a cover card,
* plus auto "Recent Days" groupings, and a Create action.
*/
export function AlbumsOverview() {
const api = useGalleryStoreApi();
const albums = useGallery((s) => s.albums);
const media = useGallery((s) => s.media);
const userAlbums = albums.filter((a) => a.kind === 'user' || a.kind === 'folder');
const live = liveMedia(media);
const recentDays = groupByTime(live, 'day').slice(0, 12);
const create = () =>
promptAlbumName('New Album', '', (name) => {
const id = api.getState().createAlbum(name);
api.getState().setView(`album:${id}` as ViewId);
});
if (userAlbums.length === 0 && live.length === 0) {
return (
<EmptyState
icon="collections"
title="No Albums Yet"
subtitle="Create an album to organize your photos, or import some to get started."
action={{ label: 'Create Album', onClick: create }}
/>
);
}
return (
<div className="apg-scroll">
<div className="apg-collections">
<section className="apg-collections__section">
<div className="apg-collections__header">
<div className="apg-collections__title">My Albums</div>
<button type="button" className="apg-collections__action" onClick={create}>
Create
</button>
</div>
{userAlbums.length === 0 ? (
<div className="apg-empty-card">
<span className="apg-empty-card__icon">
<Icon name="collections" size={28} />
</span>
<div className="apg-empty-card__title">No albums yet</div>
<div className="apg-empty-card__text">Click Create to make your first album.</div>
</div>
) : (
<div className="apg-pinned-row">
{userAlbums.map((a) => {
const am = albumMedia(a, media);
return (
<button
key={a.id}
type="button"
className="apg-pinned-card"
onClick={() => api.getState().setView(a.id as ViewId)}
aria-label={a.name}
>
{(am.find((m) => m.id === a.coverId) ?? am[0]) ? (
<img
src={(am.find((m) => m.id === a.coverId) ?? am[0])!.thumbnail ?? (am.find((m) => m.id === a.coverId) ?? am[0])!.src}
alt=""
draggable={false}
/>
) : null}
<span className="apg-pinned-card__label">
{a.name}
<span style={{ opacity: 0.8, fontWeight: 500 }}> · {am.length}</span>
</span>
</button>
);
})}
</div>
)}
</section>
{recentDays.length > 0 ? (
<section className="apg-collections__section">
<div className="apg-collections__header">
<div className="apg-collections__title">Recent Days</div>
</div>
<div className="apg-pinned-row">
{recentDays.map((d) => (
<button
key={d.key}
type="button"
className="apg-pinned-card"
onClick={() => api.getState().openLightbox(d.items[0]!.id)}
aria-label={d.title}
>
<img src={d.items[0]!.thumbnail ?? d.items[0]!.src} alt="" draggable={false} />
<span className="apg-pinned-card__label">{d.title}</span>
</button>
))}
</div>
</section>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { TRASH_RETENTION_MS } from '../../constants';
import { daysUntilPermanentDelete } from '../../lib/format';
import { Icon } from '../../icons';
import { useViewMedia } from '../../hooks/useViewMedia';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { MediaGrid } from '../MediaGrid';
import { openSecuritySettings } from '../modals';
export function RecentlyDeletedView() {
const api = useGalleryStoreApi();
const items = useViewMedia();
const lockHash = useGallery((s) => s.lock.hash);
const lockUnlocked = useGallery((s) => s.lockUnlocked);
const [pw, setPw] = useState('');
const [error, setError] = useState(false);
// Locked: a password is set and the user hasn't unlocked this session.
if (lockHash && !lockUnlocked) {
const submit = async () => {
const ok = await api.getState().unlockLock(pw);
if (!ok) setError(true);
setPw('');
};
return (
<div className="apg-empty">
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name="lock" size={56} />
</span>
<div className="apg-empty__title" style={{ fontSize: 21 }}>
Enter Your Password to View Recently Deleted
</div>
<input
className="apg-modal__input"
type="password"
autoFocus
value={pw}
placeholder="Password"
style={{ maxWidth: 280 }}
onChange={(e) => {
setPw(e.target.value);
setError(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') void submit();
}}
/>
{error ? (
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }}>Incorrect password.</div>
) : null}
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void submit()}>
Unlock
</button>
</div>
);
}
if (items.length === 0) {
return (
<div className="apg-empty">
<div className="apg-empty__card">
<span style={{ color: 'var(--apg-text-tertiary)' }}>
<Icon name="trash" size={40} />
</span>
<div className="apg-empty__title" style={{ fontSize: 22 }}>
No Recently Deleted Items
</div>
<div className="apg-empty__subtitle">
Deleted items are kept here for {Math.round(TRASH_RETENTION_MS / 86400000)} days.
</div>
</div>
</div>
);
}
// Soonest expiry = the smallest remaining days across all trashed items
// (independent of display order, which is sorted by capture date).
const nextExpiry = items.reduce((min, m) => {
if (!m.deletedAt) return min;
return Math.min(min, daysUntilPermanentDelete(m.deletedAt, TRASH_RETENTION_MS));
}, Infinity);
const expiryDays = Number.isFinite(nextExpiry) ? nextExpiry : 0;
return (
<div className="apg-scroll">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '14px 14px 0',
color: 'var(--apg-text-secondary)',
fontSize: 13,
}}
>
<span>
Items are deleted permanently after 30 days. Oldest expires in ~{expiryDays} day
{expiryDays === 1 ? '' : 's'}.
</span>
<button
type="button"
className="apg-btn"
style={{ flexShrink: 0, padding: '4px 10px' }}
onClick={openSecuritySettings}
>
<Icon name={lockHash ? 'lock' : 'unlock'} size={13} />
{lockHash ? 'Locked' : 'Lock…'}
</button>
</div>
<MediaGrid items={items} />
</div>
);
}
@@ -0,0 +1,88 @@
'use client';
import { formatDate, formatTime } from '../../lib/format';
import { Icon } from '../../icons';
import { useGallery, useGalleryStoreApi } from '../../store/context';
import { EmptyState } from './EmptyState';
/**
* Audit browser every photo/video that has an edit history or comments.
* Click an item to open it with the Info panel showing the full version
* timeline (v1 = original, never overwritten) and comment thread.
*/
export function VersionsView() {
const api = useGalleryStoreApi();
const media = useGallery((s) => s.media);
const tracked = media
.filter((m) => !m.deletedAt && ((m.versions?.length ?? 0) > 0 || (m.comments?.length ?? 0) > 0))
.sort((a, b) => (b.editedAt ?? b.importedAt) - (a.editedAt ?? a.importedAt));
const open = (id: string) => {
const s = api.getState();
s.openLightbox(id);
s.setInfoOpen(true);
};
if (tracked.length === 0) {
return (
<EmptyState
icon="clock"
title="No Version History Yet"
subtitle="Edit a photo or video (the original is always kept as version 1) or add a comment, and it will appear here with its full audit log."
/>
);
}
return (
<div className="apg-scroll" style={{ padding: 16, maxWidth: 760 }}>
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 4 }}>Versions &amp; Audit Log</div>
<div style={{ color: 'var(--apg-text-secondary)', fontSize: 13, marginBottom: 14 }}>
{tracked.length} item{tracked.length === 1 ? '' : 's'} with edit history or comments. Originals are
never overwritten.
</div>
<div className="apg-audit-list">
{tracked.map((m) => {
const versions = m.versions ?? [];
const latest = versions[versions.length - 1];
const comments = m.comments?.length ?? 0;
return (
<button key={m.id} type="button" className="apg-audit-card" onClick={() => open(m.id)}>
<span className="apg-audit-card__thumb-wrap">
<img className="apg-audit-card__thumb" src={m.thumbnail ?? m.src} alt="" />
{m.kind === 'video' ? (
<span className="apg-audit-card__play">
<Icon name="play" size={14} />
</span>
) : null}
</span>
<span className="apg-audit-card__body">
<span className="apg-audit-card__name">{m.name}</span>
<span className="apg-audit-card__tags">
<span className="apg-audit-card__tag">
<Icon name="clock" size={12} />
{versions.length || 1} version{(versions.length || 1) === 1 ? '' : 's'}
</span>
{comments > 0 ? (
<span className="apg-audit-card__tag">
<Icon name="chat" size={12} />
{comments} comment{comments === 1 ? '' : 's'}
</span>
) : null}
</span>
{latest ? (
<span className="apg-audit-card__latest">
Latest: {latest.changes.join(', ')} · {formatDate(latest.createdAt)}{' '}
{formatTime(latest.createdAt)}
</span>
) : null}
</span>
<Icon name="chevron-right" size={16} />
</button>
);
})}
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { EditAdjustments } from './types';
/** Recycle bin retention before items are eligible for permanent deletion. */
export const TRASH_RETENTION_DAYS = 30;
export const TRASH_RETENTION_MS = TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
/** Library grid zoom presets: column count per density step. */
export const GRID_ZOOM_STEPS = [10, 8, 6, 5, 4, 3, 2] as const;
export const DEFAULT_ZOOM_INDEX = 3; // 5 columns
export const ZERO_ADJUSTMENTS: EditAdjustments = {
exposure: 0,
brilliance: 0,
highlights: 0,
shadows: 0,
contrast: 0,
brightness: 0,
blackPoint: 0,
saturation: 0,
vibrance: 0,
warmth: 0,
tint: 0,
sharpness: 0,
definition: 0,
vignette: 0,
};
/** Built-in, one-tap filter presets (Apple-style). Values feed the editor. */
export const FILTER_PRESETS: Record<
string,
{ label: string; adjustments: Partial<EditAdjustments> }
> = {
original: { label: 'Original', adjustments: {} },
vivid: { label: 'Vivid', adjustments: { saturation: 0.35, contrast: 0.15, vibrance: 0.25 } },
vividWarm: { label: 'Vivid Warm', adjustments: { saturation: 0.3, warmth: 0.25, contrast: 0.1 } },
vividCool: { label: 'Vivid Cool', adjustments: { saturation: 0.3, warmth: -0.25, contrast: 0.1 } },
dramatic: { label: 'Dramatic', adjustments: { contrast: 0.4, shadows: -0.25, highlights: -0.15 } },
dramaticWarm: {
label: 'Dramatic Warm',
adjustments: { contrast: 0.4, warmth: 0.2, shadows: -0.2 },
},
dramaticCool: {
label: 'Dramatic Cool',
adjustments: { contrast: 0.4, warmth: -0.2, shadows: -0.2 },
},
mono: { label: 'Mono', adjustments: { saturation: -1, contrast: 0.1 } },
silvertone: { label: 'Silvertone', adjustments: { saturation: -1, brightness: 0.05, contrast: 0.2 } },
noir: { label: 'Noir', adjustments: { saturation: -1, contrast: 0.45, blackPoint: 0.2 } },
};
/** Object labels that auto-generate "AI albums" (like face clustering, for things). */
export const AUTO_ALBUM_OBJECTS = [
'person',
'dog',
'cat',
'car',
'laptop',
'cell phone',
'chair',
'dining table',
'tv',
'bottle',
'cup',
'book',
'potted plant',
'bicycle',
'food',
] as const;
/** Object-detection labels treated as pets/animals — grouped into "People & Pets". */
export const PET_LABELS = ['dog', 'cat', 'bird', 'horse', 'rabbit'] as const;
/** Map tile sources (all free, no API key required). */
export const MAP_TILES = {
map: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri',
maxZoom: 19,
},
} as const;
export const STORAGE_KEY = 'photo-gallery-sdk:v1';
@@ -0,0 +1,79 @@
'use client';
import { type RefObject, useEffect, useRef } from 'react';
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* Accessible modal focus management:
* - moves focus into the container when it opens,
* - traps Tab / Shift+Tab within it,
* - restores focus to the previously-focused element on close.
* Optionally calls `onEscape` when Escape is pressed inside the container.
*/
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
onEscape?: () => void,
) {
const onEscapeRef = useRef(onEscape);
onEscapeRef.current = onEscape;
useEffect(() => {
if (!active || typeof document === 'undefined') return;
const container = ref.current;
if (!container) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement,
);
// Move focus inside on open.
const first = focusables()[0];
if (first) first.focus();
else {
container.setAttribute('tabindex', '-1');
container.focus();
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onEscapeRef.current) {
e.stopPropagation();
onEscapeRef.current();
return;
}
if (e.key !== 'Tab') return;
const list = focusables();
if (list.length === 0) {
e.preventDefault();
return;
}
const firstEl = list[0]!;
const lastEl = list[list.length - 1]!;
const activeEl = document.activeElement;
if (e.shiftKey) {
if (activeEl === firstEl || activeEl === container) {
e.preventDefault();
lastEl.focus();
}
} else if (activeEl === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
container.addEventListener('keydown', onKey);
return () => {
container.removeEventListener('keydown', onKey);
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
};
// ref / onEscapeRef are stable; only re-run when open state flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}
@@ -0,0 +1,69 @@
'use client';
import { useEffect } from 'react';
import { useGalleryStoreApi } from '../store/context';
import { mediaForView } from '../store/selectors';
/** Wire Apple-Photos-style global keyboard shortcuts. */
export function useKeyboardShortcuts() {
const api = useGalleryStoreApi();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
// Never hijack typing in inputs / editable fields.
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
const state = api.getState();
const selection = [...state.selection];
const meta = e.metaKey || e.ctrlKey;
// Cmd/Ctrl+A — select all in the current view.
if (meta && (e.key === 'a' || e.key === 'A')) {
if (state.lightboxId || state.editorId) return;
e.preventDefault();
const ids = mediaForView(state).map((m) => m.id);
state.selectMany(ids);
return;
}
// Delete / Backspace — trash (or permanently delete in the bin).
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
e.preventDefault();
if (state.view === 'recently-deleted') state.deletePermanently(selection);
else state.trash(selection);
return;
}
// F — toggle favourite.
if ((e.key === 'f' || e.key === 'F') && !meta && selection.length) {
e.preventDefault();
state.toggleFavorite(selection);
return;
}
// Escape — clear selection / focus.
if (e.key === 'Escape') {
if (state.objectFocus) state.setObjectFocus(null);
else if (state.selection.size) state.clearSelection();
}
// Enter / Space — open the (single) selection in the lightbox.
if ((e.key === 'Enter' || e.key === ' ') && selection.length === 1 && !state.lightboxId) {
e.preventDefault();
state.openLightbox(selection[0]!);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
}
@@ -0,0 +1,24 @@
'use client';
import { useEffect, useState } from 'react';
/** SSR-safe media-query hook. */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mq = window.matchMedia(query);
const update = () => setMatches(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [query]);
return matches;
}
/** True on phone-sized viewports (the sidebar becomes an overlay drawer). */
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 760px)');
}
@@ -0,0 +1,41 @@
'use client';
import { useMemo } from 'react';
import { useGallery } from '../store/context';
import { mediaForView } from '../store/selectors';
import type { GalleryState } from '../store/store';
import type { MediaItem } from '../types';
/** The media items for the active view (filters + search + object-focus applied). */
export function useViewMedia(): MediaItem[] {
const media = useGallery((s) => s.media);
const albums = useGallery((s) => s.albums);
const view = useGallery((s) => s.view);
const gridFilter = useGallery((s) => s.gridFilter);
const searchQuery = useGallery((s) => s.searchQuery);
const objectFocus = useGallery((s) => s.objectFocus);
const tagFocus = useGallery((s) => s.tagFocus);
const personFocus = useGallery((s) => s.personFocus);
const searchPreset = useGallery((s) => s.searchPreset);
const semanticResults = useGallery((s) => s.semanticResults);
const recentlyViewed = useGallery((s) => s.recentlyViewed);
return useMemo(
() =>
mediaForView({
media,
albums,
view,
gridFilter,
searchQuery,
objectFocus,
tagFocus,
personFocus,
searchPreset,
semanticResults,
recentlyViewed,
} as GalleryState),
[media, albums, view, gridFilter, searchQuery, objectFocus, tagFocus, personFocus, searchPreset, semanticResults, recentlyViewed],
);
}
+334
View File
@@ -0,0 +1,334 @@
'use client';
import type { SVGProps } from 'react';
/**
* Original SF-symbol-inspired icon set drawn from scratch (no Apple assets).
* All icons inherit `currentColor` and a shared 24x24 grid.
*/
export type IconName =
| 'library'
| 'collections'
| 'heart'
| 'heart-fill'
| 'download'
| 'map'
| 'video'
| 'screenshot'
| 'person-circle'
| 'people'
| 'chat'
| 'trash'
| 'lock'
| 'unlock'
| 'duplicates'
| 'folder'
| 'sidebar'
| 'chevron-left'
| 'chevron-right'
| 'chevron-down'
| 'minus'
| 'plus'
| 'aspect'
| 'filter'
| 'ellipsis'
| 'info'
| 'share'
| 'search'
| 'check'
| 'close'
| 'compass'
| 'camera'
| 'crop'
| 'adjust'
| 'filters'
| 'rotate'
| 'wand'
| 'image'
| 'clock'
| 'suitcase'
| 'play'
| 'pause'
| 'volume'
| 'mute'
| 'expand'
| 'pip'
| 'tag'
| 'document'
| 'pin';
export interface IconProps extends SVGProps<SVGSVGElement> {
name: IconName;
size?: number;
}
export function Icon({ name, size = 20, ...rest }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{paths[name]}
</svg>
);
}
const paths: Record<IconName, JSX.Element> = {
library: (
<>
<rect x="3" y="6" width="18" height="13" rx="2.5" />
<path d="M6 3.5h12M4.5 5h15" opacity="0.55" />
<circle cx="9" cy="11" r="1.6" />
<path d="M4 17l4.5-4 3 2.5L15 11l5 5.5" />
</>
),
collections: (
<>
<rect x="4" y="7" width="16" height="12" rx="2.5" />
<path d="M6.5 7V5.5A1.5 1.5 0 0 1 8 4h8a1.5 1.5 0 0 1 1.5 1.5V7" opacity="0.6" />
</>
),
heart: (
<path d="M12 20.6 10.6 19.3C5.7 14.9 2.5 12 2.5 8.4 2.5 5.5 4.8 3.2 7.7 3.2c1.6 0 3.2.8 4.3 2.1 1.1-1.3 2.7-2.1 4.3-2.1 2.9 0 5.2 2.3 5.2 5.2 0 3.6-3.2 6.5-8.1 10.9L12 20.6Z" />
),
'heart-fill': (
<path
d="M12 20.6 10.6 19.3C5.7 14.9 2.5 12 2.5 8.4 2.5 5.5 4.8 3.2 7.7 3.2c1.6 0 3.2.8 4.3 2.1 1.1-1.3 2.7-2.1 4.3-2.1 2.9 0 5.2 2.3 5.2 5.2 0 3.6-3.2 6.5-8.1 10.9L12 20.6Z"
fill="currentColor"
stroke="none"
/>
),
download: (
<>
<path d="M12 4v10m0 0 3.5-3.5M12 14l-3.5-3.5" />
<path d="M5 16v2.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V16" />
</>
),
map: (
<>
<path d="m9 4-5 2v14l5-2 6 2 5-2V4l-5 2-6-2Z" />
<path d="M9 4v14M15 6v14" opacity="0.6" />
</>
),
video: (
<>
<rect x="3" y="7" width="13" height="10" rx="2.5" />
<path d="m16 11 5-3v8l-5-3Z" />
</>
),
screenshot: (
<>
<path d="M4 8V6.5A2.5 2.5 0 0 1 6.5 4H8M16 4h1.5A2.5 2.5 0 0 1 20 6.5V8M20 16v1.5a2.5 2.5 0 0 1-2.5 2.5H16M8 20H6.5A2.5 2.5 0 0 1 4 17.5V16" />
<rect x="8.5" y="8.5" width="7" height="7" rx="1.5" opacity="0.6" />
</>
),
document: (
<>
<path d="M6 3.5h7L18 8v11.5A1.5 1.5 0 0 1 16.5 21h-9A1.5 1.5 0 0 1 6 19.5v-16Z" />
<path d="M13 3.5V8h5" opacity="0.7" />
<path d="M9 12.5h6M9 15.5h6M9 9.5h2.5" />
</>
),
'person-circle': (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="10" r="2.6" />
<path d="M6.8 18a5.4 5.4 0 0 1 10.4 0" />
</>
),
people: (
<>
<circle cx="9" cy="9" r="3" />
<path d="M3.5 18a5.5 5.5 0 0 1 11 0" />
<path d="M15.5 6.2A3 3 0 0 1 18 11M16.5 13.4A5.5 5.5 0 0 1 20.5 18" opacity="0.7" />
</>
),
chat: (
<>
<path d="M4 11a5 5 0 0 1 5-5h3a5 5 0 0 1 0 10H9l-4 3v-3.5A5 5 0 0 1 4 11Z" />
<path d="M9 11h.01M12 11h.01" opacity="0.7" />
</>
),
trash: (
<>
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7" />
<path d="M6.5 7l.8 11A2 2 0 0 0 9.3 20h5.4a2 2 0 0 0 2-1.9L17.5 7" />
</>
),
lock: (
<>
<rect x="5.5" y="10.5" width="13" height="9" rx="2.5" />
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
</>
),
unlock: (
<>
<rect x="5.5" y="10.5" width="13" height="9" rx="2.5" />
<path d="M8 10.5V8a4 4 0 0 1 7.5-1.9" />
</>
),
duplicates: (
<>
<rect x="8" y="8" width="11" height="11" rx="2.5" />
<path d="M5 16V6.5A1.5 1.5 0 0 1 6.5 5H16" opacity="0.7" />
</>
),
folder: (
<path d="M3.5 7.5A1.5 1.5 0 0 1 5 6h4l2 2.5h8A1.5 1.5 0 0 1 20.5 10v7A1.5 1.5 0 0 1 19 18.5H5A1.5 1.5 0 0 1 3.5 17V7.5Z" />
),
sidebar: (
<>
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
<path d="M9.5 5v14" />
</>
),
'chevron-left': <path d="M14.5 6 9 12l5.5 6" />,
'chevron-right': <path d="M9.5 6 15 12l-5.5 6" />,
'chevron-down': <path d="M6 9.5 12 15l6-5.5" />,
minus: <path d="M6 12h12" />,
plus: <path d="M12 6v12M6 12h12" />,
aspect: (
<>
<rect x="4" y="6" width="16" height="12" rx="2.5" />
<path d="M9 6v12" opacity="0.6" />
</>
),
filter: <path d="M5 7h14M8 12h8M10.5 17h3" />,
ellipsis: (
<>
<circle cx="6" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="18" cy="12" r="1.3" fill="currentColor" stroke="none" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 11v5M12 8h.01" />
</>
),
share: (
<>
<path d="M12 4v11M12 4 8.5 7.5M12 4l3.5 3.5" />
<path d="M7 11H6a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5a2 2 0 0 0-2-2h-1" />
</>
),
search: (
<>
<circle cx="11" cy="11" r="6" />
<path d="m20 20-3.5-3.5" />
</>
),
check: <path d="m5 12.5 4.5 4.5L19 7" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
compass: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m9 15 2-5 4-2-2 5-4 2Z" fill="currentColor" stroke="none" />
</>
),
camera: (
<>
<path d="M4 9.5A2.5 2.5 0 0 1 6.5 7h1l1-2h7l1 2h1A2.5 2.5 0 0 1 20 9.5V17a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9.5Z" />
<circle cx="12" cy="13" r="3.2" />
</>
),
crop: (
<>
<path d="M7 3v14h14" />
<path d="M3 7h14v14" opacity="0.7" />
</>
),
adjust: (
<>
<path d="M5 8h9M18 8h1M5 16h1M10 16h9" />
<circle cx="16" cy="8" r="2" />
<circle cx="8" cy="16" r="2" />
</>
),
filters: (
<>
<circle cx="9" cy="10" r="5" />
<circle cx="15" cy="14" r="5" opacity="0.7" />
</>
),
rotate: (
<>
<path d="M4.5 12a7.5 7.5 0 1 1 2.2 5.3" />
<path d="M4 13.5 4.5 18l4.5-.5" />
</>
),
wand: (
<>
<path d="m6 18 9-9 1.8 1.8-9 9zM15 6l1.2-1.2M19 8l1.4-1.4M17 4l.4-1.4M20.5 11l1.4-.4" />
</>
),
image: (
<>
<rect x="4" y="5" width="16" height="14" rx="2.5" />
<circle cx="9" cy="10" r="1.6" />
<path d="m5 17 4.5-4 3 2.5L16 12l4 4" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</>
),
suitcase: (
<>
<rect x="4" y="8" width="16" height="11" rx="2.5" />
<path d="M9 8V6.5A1.5 1.5 0 0 1 10.5 5h3A1.5 1.5 0 0 1 15 6.5V8" />
</>
),
play: <path d="M8 6.5 18 12 8 17.5Z" fill="currentColor" stroke="none" />,
pause: (
<>
<rect x="7" y="6" width="3.2" height="12" rx="1" fill="currentColor" stroke="none" />
<rect x="13.8" y="6" width="3.2" height="12" rx="1" fill="currentColor" stroke="none" />
</>
),
volume: (
<>
<path d="M4 9.5v5h3l4.5 3.5v-12L7 9.5H4Z" fill="currentColor" stroke="none" />
<path d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11" />
</>
),
mute: (
<>
<path d="M4 9.5v5h3l4.5 3.5v-12L7 9.5H4Z" fill="currentColor" stroke="none" />
<path d="m15 10 4 4m0-4-4 4" />
</>
),
expand: (
<path d="M9 4H5a1 1 0 0 0-1 1v4m11-5h4a1 1 0 0 1 1 1v4M9 20H5a1 1 0 0 1-1-1v-4m11 5h4a1 1 0 0 0 1-1v-4" />
),
pip: (
<>
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
<rect x="12" y="12" width="7" height="5.5" rx="1.2" fill="currentColor" stroke="none" />
</>
),
tag: (
<>
<path d="M4 10.5 11 3.5h7.5V11l-7 7L4 10.5Z" />
<circle cx="15" cy="9" r="1.2" fill="currentColor" stroke="none" />
</>
),
pin: (
<>
<path d="M12 21v-7" />
<path d="M8 3h8l-1 6 2 2H7l2-2-1-6Z" />
</>
),
};
+89
View File
@@ -0,0 +1,89 @@
// ============================================================================
// @photo-gallery/sdk — public API
// A reusable, macOS Photos-style photo gallery for React / Next.js.
// ============================================================================
// Main component + props
export { PhotoGallery } from './components/PhotoGallery';
export type { PhotoGalleryProps } from './components/PhotoGallery';
// Headless store access (for advanced composition / external control)
export { useGallery, useGalleryStoreApi, GalleryStoreContext } from './store/context';
export { createGalleryStore, DEFAULT_FEATURES } from './store/store';
export type {
GalleryState,
GalleryStore,
GalleryConfig,
GalleryFeatures,
ThemeTokens,
GridFilter,
} from './store/store';
export * as selectors from './store/selectors';
// Domain types
export type {
MediaItem,
MediaId,
MediaKind,
MediaSource,
Album,
AlbumId,
AlbumKind,
Person,
PersonId,
GeoLocation,
EditState,
EditAdjustments,
Annotation,
AnnotationShape,
DetectedObject,
DetectedFace,
SmartRule,
SmartRuleSet,
ThemePreference,
ResolvedTheme,
LibraryScale,
MapMode,
ViewId,
} from './types';
// Storage adapters
export { createLocalStorageAdapter } from './adapters/localStorage';
export type { StorageAdapter, PersistedState } from './adapters/types';
// AI provider interface (pluggable; free in-browser or cloud)
export type { AIProvider, GenerativeEditOp } from './ai/types';
export { cosineSimilarity } from './ai/types';
// Helpers
export { createMediaItem, mediaFromFile, isAcceptedMediaFile } from './lib/media';
export type { MediaInput } from './lib/media';
export { classifyMediaSource, sourceLabel } from './lib/classify';
export { matchesRuleSet, resolveSmartAlbum, defaultSystemAlbums, objectSmartAlbums } from './lib/smartAlbums';
export { editFilterCss, editTransformCss } from './lib/edits';
export { summarizeEdits } from './lib/versions';
export {
normalizeSegments,
outputDuration,
sourceToOutputTime,
videoOutputSize,
resolveOverlays,
sampleOverlay,
} from './lib/videoTimeline';
export { bakeVideo } from './lib/videoBake';
// Pure, dependency-free AI post-processing utils (reusable + unit-testable).
export { clusterFaces } from './lib/cluster';
export type { FaceCluster } from './lib/cluster';
export { sanitizeOcrText, OCR_TEXT_MAX_CHARS } from './lib/text';
// Icons (re-usable in host apps)
export { Icon } from './icons';
export type { IconName } from './icons';
// Constants
export {
FILTER_PRESETS,
GRID_ZOOM_STEPS,
TRASH_RETENTION_DAYS,
AUTO_ALBUM_OBJECTS,
} from './constants';
+109
View File
@@ -0,0 +1,109 @@
import type { Annotation, EditState } from '../types';
import { editFilterCss } from './edits';
export interface BakeResult {
blob: Blob;
width: number;
height: number;
annotations: Annotation[];
}
/** True if the edit stack contains geometry that must be flattened into pixels. */
export function hasGeometry(edits?: EditState): boolean {
if (!edits) return false;
const c = edits.crop;
const cropped = !!c && (c.x > 0.001 || c.y > 0.001 || c.width < 0.999 || c.height < 0.999);
return cropped || !!edits.rotation || !!edits.straighten || !!edits.flipH || !!edits.flipV;
}
/**
* Flatten the visual edit stack (crop flips filter rotation/straighten) of
* a (CORS-clean) image into a new JPEG Blob. Annotations are re-mapped into the
* cropped space (when un-rotated) so they stay aligned.
*/
export async function bakeEdits(
img: HTMLImageElement,
edits: EditState,
): Promise<BakeResult> {
const natW = img.naturalWidth || img.width;
const natH = img.naturalHeight || img.height;
const c = edits.crop ?? { x: 0, y: 0, width: 1, height: 1 };
const sx = Math.round(c.x * natW);
const sy = Math.round(c.y * natH);
const sw = Math.max(1, Math.round(c.width * natW));
const sh = Math.max(1, Math.round(c.height * natH));
// Canvas A: cropped region with flips + colour filter baked in.
const a = document.createElement('canvas');
a.width = sw;
a.height = sh;
const actx = a.getContext('2d');
if (!actx) throw new Error('Canvas unavailable');
const filter = editFilterCss(edits);
if (filter) actx.filter = filter;
const fH = edits.flipH ? -1 : 1;
const fV = edits.flipV ? -1 : 1;
actx.save();
actx.translate(fH < 0 ? sw : 0, fV < 0 ? sh : 0);
actx.scale(fH, fV);
actx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
actx.restore();
// Optional vignette.
const vig = edits.adjustments?.vignette ?? 0;
if (vig > 0) {
actx.filter = 'none';
const g = actx.createRadialGradient(sw / 2, sh / 2, Math.min(sw, sh) * 0.35, sw / 2, sh / 2, Math.max(sw, sh) * 0.75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, `rgba(0,0,0,${Math.min(0.85, vig * 0.85).toFixed(2)})`);
actx.fillStyle = g;
actx.fillRect(0, 0, sw, sh);
}
// Rotation (90/180/270 + fine straighten).
const deg = ((edits.rotation ?? 0) + (edits.straighten ?? 0)) % 360;
let out = a;
if (deg !== 0) {
const rad = (deg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad));
const sin = Math.abs(Math.sin(rad));
const ow = Math.round(sw * cos + sh * sin);
const oh = Math.round(sw * sin + sh * cos);
const o = document.createElement('canvas');
o.width = ow;
o.height = oh;
const octx = o.getContext('2d');
if (!octx) throw new Error('Canvas unavailable');
octx.translate(ow / 2, oh / 2);
octx.rotate(rad);
octx.drawImage(a, -sw / 2, -sh / 2);
out = o;
}
const blob = await new Promise<Blob>((resolve, reject) =>
out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.92),
);
// Re-map annotations into the cropped coordinate space (skip when rotated).
let annotations: Annotation[] = [];
if (edits.annotations?.length && deg === 0) {
const remap = (x: number, y: number) => ({
x: (x - c.x) / c.width,
y: (y - c.y) / c.height,
});
annotations = edits.annotations.map((an) => {
const p1 = remap(an.x1, an.y1);
const p2 = remap(an.x2, an.y2);
return {
...an,
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
points: an.points?.map((pt) => remap(pt.x, pt.y)),
};
});
}
return { blob, width: out.width, height: out.height, annotations };
}
+78
View File
@@ -0,0 +1,78 @@
import type { MediaSource } from '../types';
/**
* Heuristic media-source classification no AI model required.
*
* Uses filename patterns, dimensions, mime type and the presence of camera EXIF
* data to guess whether a file is a screenshot, a download, a social-app save,
* a scanned document, or a real camera capture. This mirrors how Photos
* auto-populates its "Screenshots" / "Recents" smart albums.
*/
export interface ClassifyInput {
name: string;
width: number;
height: number;
mime: string;
hasCameraExif?: boolean;
/** Common device screen sizes help confirm screenshots. */
knownScreenSizes?: Array<[number, number]>;
}
const SCREENSHOT_NAME = /(screen[\s_-]?shot|screenshot|screen recording|capture)/i;
const SOCIAL_NAME = /(whatsapp|img-\d{8}-wa\d+|telegram|instagram|insta|fb_img|facebook|messenger|signal|snapchat)/i;
const DOWNLOAD_NAME = /(download|untitled|unsplash|pexels|pixabay|getty|shutterstock|wallpaper)/i;
const SCAN_NAME = /(scan|scanned|cam[\s_-]?scanner|doc[\s_-]?scan|adobe[\s_-]?scan)/i;
const AI_NAME = /(midjourney|dall[\s_-]?e|stable[\s_-]?diffusion|sdxl|ai[\s_-]?generated|upscayl|gigapixel|remaster)/i;
const CAMERA_NAME = /(img_\d+|dsc[_-]?\d+|dscf\d+|p\d{7}|gopr\d+|dji_\d+|_mg_\d+|pxl_\d+)/i;
const DEFAULT_SCREEN_SIZES: Array<[number, number]> = [
[1170, 2532], [1284, 2778], [1080, 2400], [1080, 1920], [1440, 3200],
[2532, 1170], [2778, 1284], [2400, 1080], [1920, 1080],
[2560, 1440], [3840, 2160], [1280, 800], [2880, 1800], [1366, 768],
];
export function classifyMediaSource(input: ClassifyInput): MediaSource {
const { name, width, height, mime, hasCameraExif } = input;
const lower = name.toLowerCase();
if (mime.startsWith('video/')) {
if (SCREENSHOT_NAME.test(lower)) return 'screenshot'; // screen recording
if (SOCIAL_NAME.test(lower)) return 'social';
return hasCameraExif ? 'camera' : 'imported';
}
if (AI_NAME.test(lower)) return 'ai';
if (SCAN_NAME.test(lower)) return 'scanned';
if (SCREENSHOT_NAME.test(lower)) return 'screenshot';
if (SOCIAL_NAME.test(lower)) return 'social';
// An image at an exact device resolution with no camera EXIF -> almost certainly a screenshot.
const sizes = input.knownScreenSizes ?? DEFAULT_SCREEN_SIZES;
const matchesScreen = sizes.some(([w, h]) => w === width && h === height);
if (!hasCameraExif && matchesScreen) {
return 'screenshot';
}
if (CAMERA_NAME.test(lower) || hasCameraExif) return 'camera';
if (DOWNLOAD_NAME.test(lower)) return 'download';
// PNGs without camera data are usually web downloads or graphics, not captures.
if (mime === 'image/png' && !hasCameraExif) return 'download';
return 'imported';
}
/** Human-friendly label for a media source. */
export function sourceLabel(source: MediaSource): string {
switch (source) {
case 'camera': return 'Camera';
case 'screenshot': return 'Screenshot';
case 'download': return 'Downloaded';
case 'social': return 'Social';
case 'scanned': return 'Scanned';
case 'ai': return 'AI Generated';
case 'imported': return 'Imported';
default: return 'Unknown';
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { MediaId, MediaItem } from '../types';
/**
* Face clustering groups detected faces across the library into people.
*
* Faces carry a 128-D embedding (from the provider's face-recognition model).
* Two faces of the same person sit close in that space; different people sit far
* apart. We do a simple, deterministic online clustering: process faces largest
* first (big, frontal faces make better seeds), and assign each to the nearest
* existing centroid within `threshold`, else start a new cluster.
*
* Kept dependency-free and pure so it runs anywhere and is trivially testable.
*/
/** Max Euclidean distance between L2-ish descriptors for "same person".
* face-api's recommended cut-off is 0.6; we use a slightly tighter 0.55 to
* favour precision (fewer wrong merges) over recall. */
const DEFAULT_THRESHOLD = 0.55;
export interface FaceCluster {
/** Unique media ids in this cluster, ordered by first appearance. */
mediaIds: MediaId[];
/** Item whose face is the most prominent (largest box) — used as the cover. */
coverId: MediaId;
/** Mean descriptor of the cluster. */
centroid: number[];
/** Number of individual faces (not items) merged into this cluster. */
faceCount: number;
}
function euclidean(a: number[], b: number[]): number {
let sum = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const d = a[i]! - b[i]!;
sum += d * d;
}
return Math.sqrt(sum);
}
export function clusterFaces(media: MediaItem[], threshold = DEFAULT_THRESHOLD): FaceCluster[] {
// Flatten every embedded face; bigger faces first for stabler seed centroids.
const faces: { itemId: MediaId; emb: number[]; area: number }[] = [];
for (const m of media) {
if (m.deletedAt) continue;
for (const f of m.faces ?? []) {
if (f.embedding && f.embedding.length > 0) {
faces.push({ itemId: m.id, emb: f.embedding, area: f.box.width * f.box.height });
}
}
}
faces.sort((a, b) => b.area - a.area);
interface Acc {
sum: number[];
n: number;
centroid: number[];
members: { itemId: MediaId; area: number }[];
}
const clusters: Acc[] = [];
for (const f of faces) {
let best = -1;
let bestD = Infinity;
for (let i = 0; i < clusters.length; i++) {
const d = euclidean(clusters[i]!.centroid, f.emb);
if (d < bestD) {
bestD = d;
best = i;
}
}
if (best >= 0 && bestD <= threshold) {
const c = clusters[best]!;
for (let k = 0; k < f.emb.length; k++) c.sum[k] = (c.sum[k] ?? 0) + f.emb[k]!;
c.n += 1;
c.centroid = c.sum.map((s) => s / c.n);
c.members.push({ itemId: f.itemId, area: f.area });
} else {
clusters.push({
sum: [...f.emb],
n: 1,
centroid: [...f.emb],
members: [{ itemId: f.itemId, area: f.area }],
});
}
}
return clusters.map((c) => {
const seen = new Set<MediaId>();
const mediaIds: MediaId[] = [];
let coverId = c.members[0]!.itemId;
let coverArea = -1;
for (const m of c.members) {
if (!seen.has(m.itemId)) {
seen.add(m.itemId);
mediaIds.push(m.itemId);
}
if (m.area > coverArea) {
coverArea = m.area;
coverId = m.itemId;
}
}
return { mediaIds, coverId, centroid: c.centroid, faceCount: c.n };
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Lightweight password hashing for the client-side album lock.
*
* NOTE: this is a convenience/privacy UX feature, not server-grade auth. The
* hash is computed with the Web Crypto API (SHA-256 over a fixed app salt + the
* password) and stored locally. It keeps casual onlookers out of the Recently
* Deleted view on a shared device; it is NOT a substitute for real authz.
*/
const APP_SALT = 'apg::recently-deleted::v1';
function toHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Returns `sha256:<hex>` for the given password, or '' if Web Crypto is absent. */
export async function hashPassword(password: string): Promise<string> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return '';
const data = new TextEncoder().encode(`${APP_SALT}:${password}`);
const digest = await subtle.digest('SHA-256', data);
return `sha256:${toHex(digest)}`;
}
/** Constant-time-ish comparison of a candidate password against a stored hash. */
export async function verifyPassword(password: string, storedHash: string | null): Promise<boolean> {
if (!storedHash) return false;
const candidate = await hashPassword(password);
if (candidate.length !== storedHash.length) return false;
let diff = 0;
for (let i = 0; i < candidate.length; i++) diff |= candidate.charCodeAt(i) ^ storedHash.charCodeAt(i);
return diff === 0;
}
+52
View File
@@ -0,0 +1,52 @@
import type { MediaItem } from '../types';
/** Trigger a browser download of a single media item by URL. */
export function downloadMedia(item: MediaItem): void {
if (typeof document === 'undefined') return;
const a = document.createElement('a');
a.href = item.src;
a.download = sanitizeFilename(item.name);
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
/** Export a metadata-only JSON sidecar for the current selection. */
export function exportMetadata(items: MediaItem[]): void {
if (typeof document === 'undefined') return;
const payload = items.map((i) => ({
name: i.name,
takenAt: new Date(i.takenAt).toISOString(),
source: i.source,
tags: i.tags,
objects: i.objectLabels,
location: i.location,
favorite: i.favorite,
width: i.width,
height: i.height,
}));
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'photos-metadata.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Sanitize a filename to prevent path traversal / illegal characters when it is
* used as a download target. Security-relevant: never trust an imported name.
*/
export function sanitizeFilename(name: string): string {
const cleaned = name
// Strip path separators and characters illegal on common filesystems.
.replace(/[/\\?%*:|"<>]/g, '_')
// Collapse parent-directory sequences (path traversal guard).
.replace(/\.\.+/g, '_')
.trim();
return cleaned.slice(0, 200) || 'photo';
}
+56
View File
@@ -0,0 +1,56 @@
import { FILTER_PRESETS, ZERO_ADJUSTMENTS } from '../constants';
import type { EditAdjustments, EditState } from '../types';
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
function resolveAdjustments(edits: EditState): EditAdjustments {
const preset = edits.filter ? FILTER_PRESETS[edits.filter]?.adjustments ?? {} : {};
return { ...ZERO_ADJUSTMENTS, ...preset, ...edits.adjustments };
}
/** Build a CSS `filter` string approximating the adjustment stack. */
export function editFilterCss(edits?: EditState): string | undefined {
if (!edits) return undefined;
const a = resolveAdjustments(edits);
const brightness = clamp(
1 + a.exposure * 0.4 + a.brightness * 0.4 + a.shadows * 0.12 - a.highlights * 0.06,
0.2,
2.2,
);
const contrast = clamp(
1 + a.contrast * 0.5 + a.definition * 0.2 + a.sharpness * 0.15 + a.blackPoint * 0.25,
0.2,
2.6,
);
const saturate = clamp(1 + a.saturation * 0.85 + a.vibrance * 0.35, 0, 3);
const sepia = clamp(Math.max(0, a.warmth) * 0.5, 0, 1);
const hue = (a.warmth < 0 ? a.warmth * 14 : 0) + a.tint * 14;
const parts = [
`brightness(${brightness.toFixed(3)})`,
`contrast(${contrast.toFixed(3)})`,
];
// Heavily desaturated presets read as true monochrome.
if (saturate <= 0.2) parts.push('grayscale(1)');
else parts.push(`saturate(${saturate.toFixed(3)})`);
if (sepia > 0.01) parts.push(`sepia(${sepia.toFixed(3)})`);
if (Math.abs(hue) > 0.5) parts.push(`hue-rotate(${hue.toFixed(1)}deg)`);
return parts.join(' ');
}
/** Build a CSS `transform` for rotation + straighten + flips. */
export function editTransformCss(edits?: EditState): string | undefined {
if (!edits) return undefined;
const straighten = edits.straighten ?? 0;
const rot = (edits.rotation ?? 0) + straighten;
// Scale up slightly when straightening so the rotated image still covers the frame.
const cover = straighten ? 1 + Math.min(0.5, Math.abs(straighten) / 90) : 1;
const sx = (edits.flipH ? -1 : 1) * cover;
const sy = (edits.flipV ? -1 : 1) * cover;
if (!rot && sx === 1 && sy === 1) return undefined;
return `rotate(${rot}deg) scale(${sx}, ${sy})`;
}
export { ZERO_ADJUSTMENTS };
+76
View File
@@ -0,0 +1,76 @@
/** Formatting helpers for dates, sizes and durations. */
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
export function formatBytes(bytes?: number): string {
if (!bytes || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatDuration(seconds?: number): string {
if (!seconds || seconds < 0) return '';
const s = Math.floor(seconds % 60);
const m = Math.floor((seconds / 60) % 60);
const h = Math.floor(seconds / 3600);
const pad = (n: number) => n.toString().padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function formatDate(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
export function formatDay(ms: number): string {
const d = new Date(ms);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (sameDay(d, today)) return 'Today';
if (sameDay(d, yesterday)) return 'Yesterday';
const weekday = d.toLocaleDateString(undefined, { weekday: 'long' });
return `${weekday}, ${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
export function formatMonth(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
export function formatYear(ms: number): string {
return new Date(ms).getFullYear().toString();
}
export function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
export function relativeTime(ms: number): string {
const diff = Date.now() - ms;
const day = 24 * 60 * 60 * 1000;
if (diff < 0) return 'in the future';
if (diff < day) return 'today';
const days = Math.floor(diff / day);
if (days === 1) return 'yesterday';
if (days < 30) return `${days} days ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} month${months > 1 ? 's' : ''} ago`;
const years = Math.floor(days / 365);
return `${years} year${years > 1 ? 's' : ''} ago`;
}
/** Days remaining before a trashed item is permanently removed. */
export function daysUntilPermanentDelete(deletedAt: number, retentionMs: number): number {
const remaining = deletedAt + retentionMs - Date.now();
return Math.max(0, Math.ceil(remaining / (24 * 60 * 60 * 1000)));
}
+63
View File
@@ -0,0 +1,63 @@
import type { MediaItem } from '../types';
import { formatDay, formatMonth, formatYear } from './format';
export interface MediaSection<T = MediaItem> {
key: string;
title: string;
subtitle?: string;
items: T[];
}
type Granularity = 'day' | 'month' | 'year';
function bucketKey(ms: number, granularity: Granularity): string {
const d = new Date(ms);
switch (granularity) {
case 'day': return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
case 'month': return `${d.getFullYear()}-${d.getMonth()}`;
case 'year': return `${d.getFullYear()}`;
}
}
function bucketTitle(ms: number, granularity: Granularity): string {
switch (granularity) {
case 'day': return formatDay(ms);
case 'month': return formatMonth(ms);
case 'year': return formatYear(ms);
}
}
/**
* Group media into reverse-chronological sections by day/month/year.
* Used by the Library's Years / Months / All Photos views.
*/
export function groupByTime(
items: MediaItem[],
granularity: Granularity,
): MediaSection[] {
const sorted = [...items].sort((a, b) => b.takenAt - a.takenAt);
const map = new Map<string, MediaSection>();
for (const item of sorted) {
const key = bucketKey(item.takenAt, granularity);
let section = map.get(key);
if (!section) {
section = { key, title: bucketTitle(item.takenAt, granularity), items: [] };
map.set(key, section);
}
section.items.push(item);
}
return [...map.values()];
}
/** Find probable duplicates by (name, bytes, dimensions) signature. */
export function findDuplicateGroups(items: MediaItem[]): MediaItem[][] {
const map = new Map<string, MediaItem[]>();
for (const item of items) {
if (item.deletedAt) continue;
const sig = `${item.name.toLowerCase()}|${item.bytes ?? 0}|${item.width}x${item.height}`;
const arr = map.get(sig) ?? [];
arr.push(item);
map.set(sig, arr);
}
return [...map.values()].filter((g) => g.length > 1);
}
+253
View File
@@ -0,0 +1,253 @@
import { nanoid } from 'nanoid';
import type { MediaItem, MediaKind } from '../types';
import { classifyMediaSource } from './classify';
export type MediaInput = Partial<MediaItem> & { src: string };
/** Normalize loose input into a fully-formed MediaItem with sensible defaults. */
export function createMediaItem(input: MediaInput): MediaItem {
const now = Date.now();
const kind: MediaKind = input.kind ?? (input.mime?.startsWith('video/') ? 'video' : 'image');
const name = input.name ?? 'Untitled';
const width = input.width ?? 1600;
const height = input.height ?? 1067;
const mime = input.mime ?? (kind === 'video' ? 'video/mp4' : 'image/jpeg');
const source =
input.source ??
classifyMediaSource({
name,
width,
height,
mime,
hasCameraExif: Boolean(input.exif && Object.keys(input.exif).length > 0),
});
return {
id: input.id ?? nanoid(10),
kind,
src: input.src,
thumbnail: input.thumbnail,
poster: input.poster,
name,
width,
height,
mime,
bytes: input.bytes,
takenAt: input.takenAt ?? now,
importedAt: input.importedAt ?? now,
editedAt: input.editedAt,
deletedAt: input.deletedAt,
duration: input.duration,
favorite: input.favorite ?? false,
hidden: input.hidden ?? false,
source,
albumIds: input.albumIds ?? [],
tags: input.tags ?? [],
objectLabels: input.objectLabels ?? [],
personIds: input.personIds ?? [],
location: input.location,
exif: input.exif,
caption: input.caption,
objects: input.objects,
faces: input.faces,
analyzedAt: input.analyzedAt,
colorPalette: input.colorPalette,
blurScore: input.blurScore,
qualityScore: input.qualityScore,
embedding: input.embedding,
isLivePhoto: input.isLivePhoto,
isRaw: input.isRaw,
isPanorama: input.isPanorama,
edits: input.edits,
// Preserve edit history + comments through the single normalization path so
// versions/comments survive a reload (localStorage adapter re-normalizes on load).
versions: input.versions,
comments: input.comments,
};
}
/**
* Validate a media URL. Allows http(s), data:image/video, blob, and same-origin
* relative paths; rejects dangerous schemes (javascript:, vbscript:, file:, ).
* Security: untrusted `src`/`thumbnail`/`poster` must never carry an active scheme.
*/
export function safeMediaUrl(value: unknown): string | undefined {
const s = typeof value === 'string' ? value.trim() : '';
if (!s) return undefined;
if (/^(https?:|blob:)/i.test(s)) return s;
if (/^data:(image|video)\//i.test(s)) return s;
// No scheme at all → treat as a relative same-origin path.
if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return s;
return undefined; // unknown / disallowed scheme
}
const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
const asStringArray = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
const asFiniteNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
/**
* Coerce an untrusted object (from props or persisted storage) into a safe
* MediaItem. Returns null if it has no usable source. This is the single
* normalization path so persisted and prop-supplied data are validated alike.
*/
export function normalizeMediaItem(raw: unknown): MediaItem | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const src = safeMediaUrl(r.src);
if (!src) return null;
let location: MediaItem['location'];
if (r.location && typeof r.location === 'object') {
const loc = r.location as Record<string, unknown>;
const lat = asFiniteNumber(loc.lat);
const lng = asFiniteNumber(loc.lng);
if (lat !== undefined && lng !== undefined) {
location = { lat, lng, place: asString(loc.place), city: asString(loc.city), country: asString(loc.country) };
}
}
return createMediaItem({
...(r as MediaInput),
src,
thumbnail: safeMediaUrl(r.thumbnail),
poster: safeMediaUrl(r.poster),
name: asString(r.name),
caption: asString(r.caption),
tags: asStringArray(r.tags),
objectLabels: asStringArray(r.objectLabels),
albumIds: asStringArray(r.albumIds),
personIds: asStringArray(r.personIds),
location,
});
}
/** Accepted upload types — guards against importing arbitrary/executable files. */
const ACCEPTED = /^(image\/(jpeg|png|gif|webp|heic|heif|avif|bmp|tiff)|video\/(mp4|quicktime|webm|x-matroska|3gpp))$/;
export function isAcceptedMediaFile(file: File): boolean {
return ACCEPTED.test(file.type) || /\.(jpe?g|png|gif|webp|heic|heif|avif|bmp|tiff?|mp4|mov|webm|mkv|3gp)$/i.test(file.name);
}
/** Read a Blob/File into a base64 data: URL. Durable across reloads (unlike blob: URLs). */
export function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
/** Build a MediaItem from a browser File (reads real dimensions / duration). */
export async function mediaFromFile(file: File): Promise<MediaItem | null> {
if (!isAcceptedMediaFile(file)) return null;
const isVideo = file.type.startsWith('video/') || /\.(mp4|mov|webm|mkv|3gp)$/i.test(file.name);
const url = URL.createObjectURL(file);
try {
if (isVideo) {
const meta = await readVideoMeta(url);
return createMediaItem({
src: url,
name: file.name,
mime: file.type || 'video/mp4',
bytes: file.size,
kind: 'video',
takenAt: file.lastModified || Date.now(),
width: meta.width,
height: meta.height,
duration: meta.duration,
});
}
const dims = await readImageMeta(url);
const meta = await readExif(file);
return createMediaItem({
src: url,
name: file.name,
mime: file.type || 'image/jpeg',
bytes: file.size,
kind: 'image',
takenAt: meta.takenAt ?? file.lastModified ?? Date.now(),
width: dims.width,
height: dims.height,
isPanorama: dims.width / Math.max(1, dims.height) > 2.2,
location: meta.location,
exif: meta.exif,
tags: meta.tags,
});
} catch {
URL.revokeObjectURL(url);
return null;
}
}
interface ExifMeta {
takenAt?: number;
location?: MediaItem['location'];
exif?: Record<string, string | number>;
/** Existing keywords/tags from IPTC/XMP. */
tags?: string[];
}
/** Parse EXIF + IPTC/XMP (date, GPS, camera, keywords) from an image File. Lazy-loads `exifr`. */
async function readExif(file: File): Promise<ExifMeta> {
try {
const exifr = (await import('exifr')).default;
// Parse GPS + IPTC + XMP so existing keywords/tags and location are imported.
const data = await exifr.parse(file, { gps: true, iptc: true, xmp: true });
if (!data) return {};
const out: ExifMeta = {};
const dt = data.DateTimeOriginal ?? data.CreateDate;
if (dt) {
const t = new Date(dt).getTime();
if (Number.isFinite(t)) out.takenAt = t;
}
if (typeof data.latitude === 'number' && typeof data.longitude === 'number') {
out.location = { lat: data.latitude, lng: data.longitude };
}
const exif: Record<string, string | number> = {};
for (const k of ['Make', 'Model', 'LensModel', 'ISO', 'FNumber', 'FocalLength'] as const) {
const v = data[k];
if (typeof v === 'string' || typeof v === 'number') exif[k] = v;
}
if (Object.keys(exif).length) out.exif = exif;
// Import existing keywords/tags from IPTC (Keywords) and XMP (dc:subject).
const raw: unknown[] = [];
const push = (v: unknown) => {
if (Array.isArray(v)) raw.push(...v);
else if (typeof v === 'string') raw.push(...v.split(/[;,]/));
};
push(data.Keywords);
push(data.subject);
const tags = [...new Set(raw.map((s) => String(s).trim().toLowerCase()).filter(Boolean))];
if (tags.length) out.tags = tags;
return out;
} catch {
return {};
}
}
function readImageMeta(url: string): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = reject;
img.src = url;
});
}
function readVideoMeta(url: string): Promise<{ width: number; height: number; duration: number }> {
return new Promise((resolve, reject) => {
const v = document.createElement('video');
v.preload = 'metadata';
v.onloadedmetadata = () =>
resolve({ width: v.videoWidth, height: v.videoHeight, duration: v.duration });
v.onerror = reject;
v.src = url;
});
}
+169
View File
@@ -0,0 +1,169 @@
import type { Album, MediaItem, SmartRule, SmartRuleSet } from '../types';
/** Evaluate a single smart rule against a media item. */
function evalRule(item: MediaItem, rule: SmartRule): boolean {
const { field, op, value } = rule;
const get = (): unknown => {
switch (field) {
case 'source': return item.source;
case 'kind': return item.kind;
case 'favorite': return item.favorite;
case 'mime': return item.mime;
case 'name': return item.name;
case 'takenAt': return item.takenAt;
case 'hasLocation': return Boolean(item.location);
case 'hasText': return Boolean(item.ocrText && item.ocrText.trim().length > 0);
case 'isRaw': return Boolean(item.isRaw);
case 'isLivePhoto': return Boolean(item.isLivePhoto);
case 'isPanorama': return Boolean(item.isPanorama);
case 'tag': return item.tags;
case 'object': return item.objectLabels;
case 'person': return item.personIds;
default: return undefined;
}
};
const actual = get();
switch (op) {
case 'eq': return actual === value;
case 'neq': return actual !== value;
case 'isTrue': return actual === true;
case 'isFalse': return actual === false || actual === undefined;
case 'gt': return typeof actual === 'number' && typeof value === 'number' && actual > value;
case 'lt': return typeof actual === 'number' && typeof value === 'number' && actual < value;
case 'contains':
if (Array.isArray(actual)) {
return actual.some(
(v) => typeof v === 'string' && typeof value === 'string' && v.toLowerCase().includes(value.toLowerCase()),
);
}
if (typeof actual === 'string' && typeof value === 'string') {
return actual.toLowerCase().includes(value.toLowerCase());
}
return false;
default: return false;
}
}
/** Does an item satisfy a rule set (AND/OR over its rules)? */
export function matchesRuleSet(item: MediaItem, ruleSet: SmartRuleSet): boolean {
if (item.deletedAt) return false; // trashed items never appear in smart albums
if (ruleSet.rules.length === 0) return true;
return ruleSet.match === 'all'
? ruleSet.rules.every((r) => evalRule(item, r))
: ruleSet.rules.some((r) => evalRule(item, r));
}
/** Resolve the live member ids of a smart album from the full library. */
export function resolveSmartAlbum(album: Album, items: MediaItem[]): string[] {
if (!album.ruleSet) return [];
return items.filter((i) => matchesRuleSet(i, album.ruleSet!)).map((i) => i.id);
}
/**
* The system smart albums Photos ships with. These are recomputed from the
* library whenever it changes, so they always reflect current content.
*/
export function defaultSystemAlbums(now: number): Album[] {
const base = (
id: string,
name: string,
icon: string,
ruleSet: SmartRuleSet,
pinned = true,
): Album => ({
id,
name,
kind: 'smart',
mediaIds: [],
ruleSet,
createdAt: now,
pinned,
system: true,
icon,
});
return [
base('sys:favourites', 'Favourites', 'heart', {
match: 'all',
rules: [{ field: 'favorite', op: 'isTrue' }],
}),
base('sys:videos', 'Videos', 'video', {
match: 'all',
rules: [{ field: 'kind', op: 'eq', value: 'video' }],
}),
base('sys:screenshots', 'Screenshots', 'screenshot', {
match: 'all',
rules: [{ field: 'source', op: 'eq', value: 'screenshot' }],
}),
base('sys:documents', 'Documents', 'document', {
match: 'all',
rules: [{ field: 'hasText', op: 'isTrue' }],
}),
base('sys:selfies', 'Selfies', 'person', {
match: 'any',
rules: [{ field: 'tag', op: 'contains', value: 'selfie' }],
}, false),
base('sys:recently-saved', 'Recently Saved', 'download', {
match: 'any',
rules: [
{ field: 'source', op: 'eq', value: 'download' },
{ field: 'source', op: 'eq', value: 'social' },
],
}),
base('sys:raw', 'RAW', 'raw', {
match: 'all',
rules: [{ field: 'isRaw', op: 'isTrue' }],
}, false),
base('sys:live', 'Live Photos', 'live', {
match: 'all',
rules: [{ field: 'isLivePhoto', op: 'isTrue' }],
}, false),
base('sys:panoramas', 'Panoramas', 'pano', {
match: 'all',
rules: [{ field: 'isPanorama', op: 'isTrue' }],
}, false),
];
}
/** Title-case a detected object label for display ("dining table" → "Dining Table"). */
function titleCase(s: string): string {
return s.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Auto-generate one live smart album per DETECTED OBJECT label e.g. every photo
* containing a "chair" is grouped into a "Chair" album, "table" into "Table", etc.
* Membership is resolved live (via resolveSmartAlbum) as detection tags more photos;
* these are system albums (never persisted regenerated on load).
*
* @param minCount only surface a label once it appears on at least this many photos.
*/
export function objectSmartAlbums(media: MediaItem[], now: number, minCount = 1): Album[] {
const counts = new Map<string, number>();
for (const m of media) {
if (m.deletedAt || m.hidden) continue;
// De-dupe labels within one item so a single photo counts once per label.
for (const label of new Set(m.objectLabels)) {
const key = label.trim().toLowerCase();
if (!key) continue;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
return [...counts.entries()]
.filter(([, n]) => n >= minCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([label]) => ({
id: `sys:obj:${label}`,
name: titleCase(label),
kind: 'smart' as const,
mediaIds: [],
ruleSet: { match: 'all', rules: [{ field: 'object', op: 'contains', value: label }] } as SmartRuleSet,
createdAt: now,
pinned: false,
system: true,
icon: 'tag',
}));
}
+28
View File
@@ -0,0 +1,28 @@
/** Hard cap on stored OCR text prevents a pathological image from bloating
* the store / backend row (DoS) and keeps search tokenization cheap. */
export const OCR_TEXT_MAX_CHARS = 10_000;
// C0 control characters + DEL. Built from a string of \u escapes so the source
// stays pure ASCII. Replaced with spaces (then collapsed) so a newline between
// two words doesn't fuse them into a single search token.
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001F\\u007F]', 'g');
/**
* Clean OCR output before it is stored or searched.
*
* OCR text is attacker-influencable (it comes from pixels inside an imported
* image), so we treat it as untrusted input at the write boundary: strip
* control characters, collapse whitespace into single spaces, and length-cap.
* No HTML stripping is required because the value is only ever rendered through
* React/textContent (never innerHTML) but if a future search-highlight feature
* is added it MUST build nodes with createElement + textContent, never innerHTML.
*/
export function sanitizeOcrText(input: string): string {
const cleaned = input.replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
if (cleaned.length <= OCR_TEXT_MAX_CHARS) return cleaned;
// Truncate on a word boundary so a word straddling the cap isn't split into an
// unsearchable fragment (search does substring matching on whole tokens).
const cut = cleaned.slice(0, OCR_TEXT_MAX_CHARS);
const lastSpace = cut.lastIndexOf(' ');
return lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
}
+37
View File
@@ -0,0 +1,37 @@
import type { EditState } from '../types';
/**
* Human-readable audit log of what an edit changed shown per version in the
* photo/video details and the Versions browser.
*/
export function summarizeEdits(edits?: EditState): string[] {
if (!edits) return ['Edited'];
const c: string[] = [];
if (edits.crop) c.push('Cropped');
if (edits.rotation) c.push(`Rotated ${edits.rotation}°`);
if (typeof edits.straighten === 'number' && Math.round(edits.straighten) !== 0)
c.push(`Straightened ${Math.round(edits.straighten)}°`);
if (edits.flipH) c.push('Flipped horizontally');
if (edits.flipV) c.push('Flipped vertically');
if (edits.filter) c.push(`Filter: ${edits.filter}`);
const adj = Object.entries(edits.adjustments ?? {}).filter(([, v]) => v).map(([k]) => k);
if (adj.length) c.push(`Adjusted ${adj.slice(0, 3).join(', ')}${adj.length > 3 ? '…' : ''}`);
const n = edits.annotations?.length ?? 0;
if (n) c.push(`${n} annotation${n === 1 ? '' : 's'}`);
// ---- video ----
const segCount = edits.segments?.length ?? 0;
if (segCount > 1) c.push(`Split into ${segCount} segments`);
else if (edits.trim || segCount === 1) c.push('Trimmed');
if (edits.segments?.some((s) => (s.speed ?? 1) !== 1)) c.push('Speed change');
const ovCount = (edits.overlays?.length ?? 0) + (edits.overlay ? 1 : 0);
if (ovCount) {
c.push(`${ovCount} overlay${ovCount === 1 ? '' : 's'}`);
if (edits.overlays?.some((o) => o.keyframes && o.keyframes.length > 1)) c.push('Keyframe animation');
if (edits.overlays?.some((o) => o.watermark)) c.push('Watermark');
if (edits.overlays?.some((o) => o.kind === 'text')) c.push('Text');
}
if (edits.audio?.muted) c.push('Muted original audio');
if (edits.audio?.musicSrc) c.push('Added music');
if (edits.audio?.fadeIn || edits.audio?.fadeOut) c.push('Audio fade');
return c.length ? c : ['Edited'];
}
+337
View File
@@ -0,0 +1,337 @@
import type { EditState } from '../types';
import { editFilterCss } from './edits';
import {
normalizeSegments,
outputDuration,
resolveOverlays,
sampleOverlay,
videoOutputSize,
} from './videoTimeline';
/**
* Bake a video's edits into a NEW clip entirely in the browser, no server and
* no ffmpeg/SharedArrayBuffer (which would break the strict CSP).
*
* Timeline engine: ONE MediaRecorder runs continuously while an offscreen <video>
* is driven across the ordered keep-segments (seek set playbackRate play draw
* until the segment's end next). Because the recorder never stops between segments,
* they concatenate automatically. Each frame is drawn to a <canvas> applying crop +
* 90° rotation + flips + the CSS filter, then image/text overlays resolved at the
* current OUTPUT time (keyframe-interpolated), then legacy annotations. Audio (original
* ± music, with master fade in/out) is mixed through WebAudio into the recorded stream.
*
* Real-time: exporting N output-seconds takes ~N seconds. Progress is reported 0..1.
*/
export interface VideoBakeResult {
blob: Blob;
durationSec: number;
mime: string;
width: number;
height: number;
/** JPEG data URL poster grabbed from the chosen frame (edits.posterTime). */
poster?: string;
}
function pickMime(): string {
const candidates = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm',
'video/mp4',
];
const MR = typeof MediaRecorder !== 'undefined' ? MediaRecorder : null;
for (const m of candidates) {
if (MR && MR.isTypeSupported(m)) return m;
}
return 'video/webm';
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
/** Rasterize a live annotations <svg> at the export resolution (vector → bitmap). */
function rasterizeSvg(svg: SVGSVGElement, w: number, h: number): Promise<HTMLImageElement> {
const clone = svg.cloneNode(true) as SVGSVGElement;
const vbW = svg.clientWidth || w;
const vbH = svg.clientHeight || h;
clone.setAttribute('viewBox', `0 0 ${vbW} ${vbH}`);
clone.setAttribute('width', String(w));
clone.setAttribute('height', String(h));
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const xml = new XMLSerializer().serializeToString(clone);
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
return loadImage(url);
}
const seekTo = (video: HTMLVideoElement, t: number) =>
new Promise<void>((res) => {
const done = () => {
video.removeEventListener('seeked', done);
res();
};
video.addEventListener('seeked', done);
// Nudge so a seek to the current time still fires 'seeked'.
video.currentTime = Math.max(0, t);
// Fallback in case 'seeked' never fires (some codecs at exact boundaries).
setTimeout(done, 600);
});
/** Map an output-timeline time to the corresponding SOURCE time (for poster grab). */
function outputToSourceTime(
segs: { start: number; end: number; speed?: number }[],
outT: number,
): number {
let acc = 0;
for (const s of segs) {
const d = (s.end - s.start) / (s.speed || 1);
if (outT <= acc + d) return s.start + (outT - acc) * (s.speed || 1);
acc += d;
}
const last = segs[segs.length - 1];
return last ? last.end : outT;
}
export interface VideoBakeOptions {
/** Live annotations SVG from the preview, rasterized over every frame. */
annotationsSvg?: SVGSVGElement | null;
onProgress?: (fraction: number) => void;
signal?: AbortSignal;
maxDim?: number;
}
export async function bakeVideo(
src: string,
edits: EditState,
opts: VideoBakeOptions = {},
): Promise<VideoBakeResult> {
const maxDim = edits.export?.maxDim ?? opts.maxDim ?? 1280;
const fps = edits.export?.fps ?? 30;
// 1) Offscreen source video (separate from the on-screen preview).
const video = document.createElement('video');
video.src = src;
video.crossOrigin = 'anonymous';
video.playsInline = true;
video.muted = true; // reliable autoplay; audio is tapped via WebAudio below
await new Promise<void>((res, rej) => {
video.onloadedmetadata = () => res();
video.onerror = () => rej(new Error('Could not load video for export.'));
});
const srcW = video.videoWidth || 1280;
const srcH = video.videoHeight || 720;
const totalDur = video.duration || 0;
// 2) Geometry (crop + 90° rotation + flips) → output canvas size.
const geo = videoOutputSize(srcW, srcH, edits, maxDim);
const { W, H, contentW, contentH, cropX, cropY, cropW, cropH, rot } = geo;
const flipH = !!edits.flipH;
const flipV = !!edits.flipV;
// 3) Timeline: ordered keep-segments + total output duration.
const segs = normalizeSegments(edits, totalDur);
const outDur = Math.max(0.1, outputDuration(segs));
// 4) Overlays: preload images; resolve legacy single overlay too.
const overlays = resolveOverlays(edits);
const imgMap = new Map<string, HTMLImageElement>();
await Promise.all(
overlays
.filter((o) => o.kind === 'image' && o.src)
.map(async (o) => {
const img = await loadImage(o.src!).catch(() => null);
if (img) imgMap.set(o.id, img);
}),
);
const annoImg = opts.annotationsSvg
? await rasterizeSvg(opts.annotationsSvg, W, H).catch(() => null)
: null;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
const filter = editFilterCss(edits) || 'none';
// 5) Audio graph: original (volume/mute) + music → master gain (fades) → stream.
const AC: typeof AudioContext =
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext ??
AudioContext;
const ac = new AC();
const dest = ac.createMediaStreamDestination();
const master = ac.createGain();
master.connect(dest);
let music: HTMLAudioElement | null = null;
try {
const vNode = ac.createMediaElementSource(video);
const vGain = ac.createGain();
vGain.gain.value = edits.audio?.muted ? 0 : (edits.audio?.originalVolume ?? 1);
vNode.connect(vGain).connect(master);
} catch {
/* element may have no audio track */
}
if (edits.audio?.musicSrc) {
music = new Audio(edits.audio.musicSrc);
music.crossOrigin = 'anonymous';
music.loop = true;
try {
const mNode = ac.createMediaElementSource(music);
const gain = ac.createGain();
gain.gain.value = edits.audio.musicVolume ?? 0.8;
mNode.connect(gain).connect(master);
} catch {
/* ignore */
}
}
// 6) Recorder over canvas video + mixed audio.
const stream = canvas.captureStream(fps);
const audioTrack = dest.stream.getAudioTracks()[0];
if (audioTrack) stream.addTrack(audioTrack);
const mime = pickMime();
const recorderOpts: MediaRecorderOptions = { mimeType: mime };
if (edits.export?.bitrate) recorderOpts.videoBitsPerSecond = edits.export.bitrate;
const recorder = new MediaRecorder(stream, recorderOpts);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
const stopped = new Promise<void>((res) => {
recorder.onstop = () => res();
});
// Per-frame compositor. `outClock` is the elapsed OUTPUT time (drives keyframes).
const drawFrame = (outClock: number, withOverlays = true) => {
ctx.clearRect(0, 0, W, H);
ctx.save();
ctx.translate(W / 2, H / 2);
if (rot) ctx.rotate((rot * Math.PI) / 180);
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
ctx.filter = filter;
ctx.drawImage(video, cropX, cropY, cropW, cropH, -contentW / 2, -contentH / 2, contentW, contentH);
ctx.filter = 'none';
ctx.restore();
if (withOverlays) {
for (const o of overlays) {
const s = sampleOverlay(o, outClock);
if (!s.visible || s.opacity <= 0) continue;
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, s.opacity));
if (o.kind === 'image') {
const img = imgMap.get(o.id);
if (img) {
const ow = W * s.scale;
const oh = ow * (img.height / Math.max(1, img.width));
ctx.translate(s.x * W + ow / 2, s.y * H + oh / 2);
if (s.rotation) ctx.rotate((s.rotation * Math.PI) / 180);
ctx.drawImage(img, -ow / 2, -oh / 2, ow, oh);
}
} else {
const fsize = Math.max(8, (o.fontSize ?? 0.08) * H);
ctx.font = `${o.bold ? '700 ' : ''}${fsize}px system-ui, -apple-system, sans-serif`;
ctx.textBaseline = 'top';
ctx.translate(s.x * W, s.y * H);
if (s.rotation) ctx.rotate((s.rotation * Math.PI) / 180);
ctx.lineWidth = Math.max(2, fsize * 0.12);
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.fillStyle = o.color ?? '#ffffff';
ctx.strokeText(o.text ?? '', 0, 0);
ctx.fillText(o.text ?? '', 0, 0);
}
ctx.restore();
}
}
if (annoImg) ctx.drawImage(annoImg, 0, 0, W, H);
};
// 7) Prime first segment, start recorder + audio + fades.
await seekTo(video, segs[0]!.start);
drawFrame(0);
recorder.start();
await ac.resume().catch(() => {});
const t0 = ac.currentTime;
const fadeIn = edits.audio?.fadeIn ?? 0;
const fadeOut = edits.audio?.fadeOut ?? 0;
master.gain.setValueAtTime(fadeIn > 0 ? 0.0001 : 1, t0);
if (fadeIn > 0) master.gain.linearRampToValueAtTime(1, t0 + Math.min(fadeIn, outDur));
if (fadeOut > 0) {
const fs = Math.max(t0 + fadeIn, t0 + outDur - fadeOut);
master.gain.setValueAtTime(1, fs);
master.gain.linearRampToValueAtTime(0.0001, t0 + outDur);
}
let aborted = false;
const onAbort = () => {
aborted = true;
};
opts.signal?.addEventListener('abort', onAbort);
// 8) Drive the offscreen video across every segment (continuous recording).
let baseOut = 0; // output seconds completed by prior segments
for (const seg of segs) {
if (aborted) break;
await seekTo(video, seg.start);
video.playbackRate = seg.speed || 1;
if (music && seg === segs[0]) await music.play().catch(() => {});
await video.play().catch(() => {});
const segOut = (seg.end - seg.start) / (seg.speed || 1);
// Guard against a stalled decoder (autoplay blocked, boundary glitch).
const deadline = performance.now() + (segOut + 4) * 1000;
await new Promise<void>((res) => {
const draw = () => {
if (
aborted ||
video.currentTime >= seg.end ||
video.ended ||
performance.now() > deadline
) {
res();
return;
}
const outClock = baseOut + (video.currentTime - seg.start) / (seg.speed || 1);
drawFrame(outClock);
opts.onProgress?.(Math.min(0.99, outClock / outDur));
requestAnimationFrame(draw);
};
requestAnimationFrame(draw);
});
video.pause();
baseOut += segOut;
}
music?.pause();
if (recorder.state !== 'inactive') recorder.stop();
await stopped;
opts.signal?.removeEventListener('abort', onAbort);
// 9) Poster frame (before closing the audio context / disposing the video).
let poster: string | undefined;
try {
const posterOut = Math.max(0, Math.min(outDur, edits.posterTime ?? 0));
await seekTo(video, outputToSourceTime(segs, posterOut));
drawFrame(posterOut);
const pscale = Math.min(1, 640 / Math.max(W, H));
const pc = document.createElement('canvas');
pc.width = Math.max(2, Math.round(W * pscale));
pc.height = Math.max(2, Math.round(H * pscale));
pc.getContext('2d')!.drawImage(canvas, 0, 0, pc.width, pc.height);
poster = pc.toDataURL('image/jpeg', 0.8);
} catch {
/* poster is best-effort */
}
await ac.close().catch(() => {});
opts.onProgress?.(1);
return { blob: new Blob(chunks, { type: mime }), durationSec: outDur, mime, width: W, height: H, poster };
}
+165
View File
@@ -0,0 +1,165 @@
import type { EditState, VideoOverlay, VideoSegment } from '../types';
/**
* Pure helpers shared by the video bake engine (lib/videoBake.ts) and the editor's
* live preview (VideoEditor.tsx), so the on-screen preview matches the export exactly.
*/
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** Resolve the ordered keep-segments (falls back to legacy trim, then the whole clip). */
export function normalizeSegments(edits: EditState, totalDur: number): VideoSegment[] {
const raw: VideoSegment[] =
edits.segments && edits.segments.length
? edits.segments
: edits.trim
? [{ id: 'legacy', start: edits.trim.start, end: edits.trim.end }]
: [{ id: 'full', start: 0, end: totalDur }];
const out = raw
.map((s) => ({
...s,
start: clamp(s.start, 0, totalDur),
end: clamp(s.end, 0, totalDur),
speed: s.speed && s.speed > 0 ? s.speed : 1,
}))
.filter((s) => s.end - s.start > 0.05);
return out.length ? out : [{ id: 'full', start: 0, end: totalDur, speed: 1 }];
}
/** Total duration of the exported clip (sum of segment durations ÷ their speed). */
export function outputDuration(segs: VideoSegment[]): number {
return segs.reduce((n, s) => n + (s.end - s.start) / (s.speed || 1), 0);
}
/** Map a SOURCE time to the OUTPUT-timeline time (for previewing keyframed overlays). */
export function sourceToOutputTime(segs: VideoSegment[], srcT: number): number {
let acc = 0;
for (const s of segs) {
const dur = (s.end - s.start) / (s.speed || 1);
if (srcT >= s.start && srcT <= s.end) return acc + (srcT - s.start) / (s.speed || 1);
if (srcT < s.start) return acc; // in a gap before this segment
acc += dur;
}
return acc;
}
export interface VideoOutputSize {
W: number;
H: number;
/** Content dims BEFORE the 90°/270° swap (how the frame is drawn pre-rotation). */
contentW: number;
contentH: number;
cropX: number;
cropY: number;
cropW: number;
cropH: number;
rot: number;
}
/** Compute the exported frame size from crop + 90° rotation, capped to `maxDim`. */
export function videoOutputSize(
srcW: number,
srcH: number,
edits: EditState,
maxDim: number,
): VideoOutputSize {
const crop = edits.crop;
const cropW = crop ? Math.max(1, Math.round(crop.width * srcW)) : srcW;
const cropH = crop ? Math.max(1, Math.round(crop.height * srcH)) : srcH;
const rot = ((((edits.rotation ?? 0) % 360) + 360) % 360);
const swap = rot === 90 || rot === 270;
const scale = Math.min(1, maxDim / Math.max(cropW, cropH));
const contentW = Math.max(2, Math.round(cropW * scale));
const contentH = Math.max(2, Math.round(cropH * scale));
return {
W: swap ? contentH : contentW,
H: swap ? contentW : contentH,
contentW,
contentH,
cropX: crop ? crop.x * srcW : 0,
cropY: crop ? crop.y * srcH : 0,
cropW,
cropH,
rot,
};
}
/** Merge the overlays list with any legacy single `overlay` (as a watermark). */
export function resolveOverlays(edits: EditState): VideoOverlay[] {
const list = [...(edits.overlays ?? [])];
if (edits.overlay?.src && !list.some((o) => o.src === edits.overlay!.src)) {
list.push({
id: 'legacy-overlay',
kind: 'image',
src: edits.overlay.src,
x: edits.overlay.x,
y: edits.overlay.y,
scale: edits.overlay.scale,
opacity: 1,
watermark: true,
});
}
return list;
}
export interface SampledOverlay {
x: number;
y: number;
scale: number;
rotation: number;
opacity: number;
visible: boolean;
}
/** Resolve an overlay's transform at output-time `t` by interpolating its keyframes. */
export function sampleOverlay(o: VideoOverlay, t: number): SampledOverlay {
const base = {
x: o.x,
y: o.y,
scale: o.scale,
rotation: o.rotation ?? 0,
opacity: o.opacity ?? 1,
};
const inT = o.in ?? -Infinity;
const outT = o.out ?? Infinity;
const visible = t >= inT && t <= outT;
const kfs = o.keyframes;
if (!kfs || kfs.length === 0) return { ...base, visible };
const sorted = [...kfs].sort((a, b) => a.t - b.t);
let prev: (typeof sorted)[number] | null = null;
let next: (typeof sorted)[number] | null = null;
for (const k of sorted) {
if (k.t <= t) prev = k;
if (k.t >= t && !next) next = k;
}
const pick = (k: (typeof sorted)[number], key: 'x' | 'y' | 'scale' | 'rotation' | 'opacity') =>
k[key] ?? base[key];
if (prev && next && prev !== next) {
const span = next.t - prev.t || 1;
const f = (t - prev.t) / span;
const lerp = (key: 'x' | 'y' | 'scale' | 'rotation' | 'opacity') => {
const a = pick(prev!, key);
const b = pick(next!, key);
return a + (b - a) * f;
};
return {
x: lerp('x'),
y: lerp('y'),
scale: lerp('scale'),
rotation: lerp('rotation'),
opacity: lerp('opacity'),
visible,
};
}
const k = prev ?? next!;
return {
x: pick(k, 'x'),
y: pick(k, 'y'),
scale: pick(k, 'scale'),
rotation: pick(k, 'rotation'),
opacity: pick(k, 'opacity'),
visible,
};
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { createContext, useContext } from 'react';
import { useStore } from 'zustand';
import type { GalleryState, GalleryStore } from './store';
export const GalleryStoreContext = createContext<GalleryStore | null>(null);
/** Access the raw store API (for imperative reads / subscriptions). */
export function useGalleryStoreApi(): GalleryStore {
const store = useContext(GalleryStoreContext);
if (!store) {
throw new Error('useGallery must be used within a <PhotoGallery /> (GalleryProvider).');
}
return store;
}
/** Subscribe to a slice of gallery state. */
export function useGallery<T>(selector: (state: GalleryState) => T): T {
const store = useGalleryStoreApi();
return useStore(store, selector);
}
+202
View File
@@ -0,0 +1,202 @@
import { matchesRuleSet, resolveSmartAlbum } from '../lib/smartAlbums';
import type { Album, MediaItem } from '../types';
import type { GalleryState, GridFilter } from './store';
/** Items that are not in the recycle bin and not hidden. */
export function liveMedia(media: MediaItem[]): MediaItem[] {
return media.filter((m) => !m.deletedAt && !m.hidden);
}
export function trashedMedia(media: MediaItem[]): MediaItem[] {
return media
.filter((m) => m.deletedAt)
.sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0));
}
export function albumById(albums: Album[], id: string): Album | undefined {
return albums.find((a) => a.id === id);
}
/** Resolve the members of any album (smart albums are computed live). */
export function albumMedia(album: Album, media: MediaItem[]): MediaItem[] {
if (album.kind === 'smart' && album.ruleSet) {
const ids = new Set(resolveSmartAlbum(album, media));
return liveMedia(media)
.filter((m) => ids.has(m.id))
.sort((a, b) => b.takenAt - a.takenAt);
}
const order = new Map(album.mediaIds.map((id, i) => [id, i]));
return liveMedia(media)
.filter((m) => order.has(m.id))
.sort((a, b) => (order.get(a.id)! - order.get(b.id)!));
}
function applyGridFilter(items: MediaItem[], filter: GridFilter): MediaItem[] {
switch (filter) {
case 'favourites': return items.filter((m) => m.favorite);
case 'edited': return items.filter((m) => Boolean(m.edits));
case 'photos': return items.filter((m) => m.kind === 'image');
case 'videos': return items.filter((m) => m.kind === 'video');
case 'screenshots': return items.filter((m) => m.source === 'screenshot');
case 'not-in-album': return items.filter((m) => m.albumIds.length === 0);
default: return items;
}
}
/** Full-text-ish search across the most useful fields. */
export function searchMedia(items: MediaItem[], query: string): MediaItem[] {
const q = query.trim().toLowerCase();
if (!q) return items;
const tokens = q.split(/\s+/).filter(Boolean);
return items.filter((m) => {
const haystack = [
m.name,
m.source,
m.kind,
m.caption ?? '',
m.ocrText ?? '',
m.location?.place ?? '',
m.location?.city ?? '',
m.location?.country ?? '',
...m.tags,
...m.objectLabels,
]
.join(' ')
.toLowerCase();
// Every token must match somewhere (AND semantics).
return tokens.every((t) => haystack.includes(t));
});
}
/**
* The media shown for the current view, after applying trash exclusion,
* grid filter, search and object-focus. Returns reverse-chronological order.
*/
export function mediaForView(state: GalleryState): MediaItem[] {
const { media, albums, view, gridFilter, searchQuery, objectFocus, tagFocus, personFocus, searchPreset, semanticResults, recentlyViewed } =
state;
let items: MediaItem[];
// When a search ranks results (keyword + semantic), preserve that order
// instead of re-sorting chronologically.
let ranked = false;
// Quick search presets (from the search dropdown) take precedence on the search view.
if (view === 'search' && searchPreset) {
const live = liveMedia(media);
if (searchPreset === 'edited') {
// Edited = has an edit timestamp (baked crops/AI) or a live edit stack (filters/markup).
return live
.filter((m) => m.editedAt || m.edits)
.sort((a, b) => (b.editedAt ?? 0) - (a.editedAt ?? 0));
}
if (searchPreset === 'added') {
return [...live].sort((a, b) => b.importedAt - a.importedAt);
}
// viewed
const order = new Map(recentlyViewed.map((id, i) => [id, i]));
return live.filter((m) => order.has(m.id)).sort((a, b) => order.get(a.id)! - order.get(b.id)!);
}
if (view === 'recently-deleted') {
items = trashedMedia(media);
} else if (view === 'duplicates') {
items = liveMedia(media);
} else if (view.startsWith('album:') || view.startsWith('sys:')) {
const album = albumById(albums, view);
items = album ? albumMedia(album, media) : [];
} else if (view === 'favourites') {
items = liveMedia(media).filter((m) => m.favorite);
} else if (view === 'recently-saved') {
items = liveMedia(media).filter((m) => m.source === 'download' || m.source === 'social');
} else if (view === 'videos') {
items = liveMedia(media).filter((m) => m.kind === 'video');
} else if (view === 'screenshots') {
items = liveMedia(media).filter((m) => m.source === 'screenshot');
} else {
items = liveMedia(media);
}
if (view !== 'recently-deleted') {
items = applyGridFilter(items, gridFilter);
}
if (objectFocus) {
items = items.filter((m) => m.objectLabels.includes(objectFocus));
}
if (tagFocus) {
items = items.filter((m) => m.tags.includes(tagFocus));
}
if (personFocus) {
items = items.filter((m) => m.personIds.includes(personFocus));
}
if (searchQuery && (view === 'search' || view === 'library')) {
const keyword = searchMedia(items, searchQuery);
// Blend in semantic ("looks like") matches after the keyword hits, in
// similarity order, skipping any already matched by keyword.
if (semanticResults && semanticResults.length) {
const inView = new Map(items.map((m) => [m.id, m]));
const kwIds = new Set(keyword.map((m) => m.id));
const semantic = semanticResults
.filter((id) => !kwIds.has(id) && inView.has(id))
.map((id) => inView.get(id)!);
items = [...keyword, ...semantic];
} else {
items = keyword;
}
ranked = true;
}
// Default reverse-chronological unless an album imposed its own order or the
// results are relevance-ranked by search.
if (!view.startsWith('album:') && !ranked) {
items = [...items].sort((a, b) => b.takenAt - a.takenAt);
}
return items;
}
/** Distinct object labels across the live library (for AI auto-albums). */
export function objectLabelCounts(media: MediaItem[]): Map<string, number> {
const counts = new Map<string, number>();
for (const m of liveMedia(media)) {
for (const label of m.objectLabels) {
counts.set(label, (counts.get(label) ?? 0) + 1);
}
}
return counts;
}
/** Items with a geolocation (for the Map view). Any source — incl. screenshots. */
export function locatedMedia(media: MediaItem[]): MediaItem[] {
return liveMedia(media).filter((m) => m.location);
}
export interface LocationCluster {
/** Centroid of the grouped photos. */
lat: number;
lng: number;
/** Photos at this location, most-recent first (first = pin cover). */
items: MediaItem[];
}
/**
* Group located media into pins by rounding coordinates to `precision` decimals
* (1 11 km, 2 1.1 km). The Map view raises precision as you zoom in, so
* clusters split apart macOS-style.
*/
export function clusterByLocation(items: MediaItem[], precision = 1): LocationCluster[] {
const groups = new Map<string, MediaItem[]>();
for (const m of items) {
if (!m.location) continue;
const key = `${m.location.lat.toFixed(precision)},${m.location.lng.toFixed(precision)}`;
const arr = groups.get(key);
if (arr) arr.push(m);
else groups.set(key, [m]);
}
return [...groups.values()].map((arr) => {
const lat = arr.reduce((s, m) => s + m.location!.lat, 0) / arr.length;
const lng = arr.reduce((s, m) => s + m.location!.lng, 0) / arr.length;
const sorted = [...arr].sort((a, b) => b.takenAt - a.takenAt);
return { lat, lng, items: sorted };
});
}
export { matchesRuleSet };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
/**
* Core domain types for the Photo Gallery SDK.
*
* These are intentionally framework-agnostic so the same model can be reused by
* a Node backend, a React UI, or a CLI importer.
*/
export type MediaId = string;
export type AlbumId = string;
export type PersonId = string;
/** How a media item most likely entered the library. Auto-detected on import. */
export type MediaSource =
| 'camera' // shot on a phone/DSLR (has EXIF capture data)
| 'screenshot' // device screen capture
| 'download' // saved from the web / a browser
| 'social' // WhatsApp / Instagram / Telegram etc.
| 'scanned' // document scan
| 'ai' // AI generated / upscaled
| 'imported' // generic file import, source unknown
| 'unknown';
export type MediaKind = 'image' | 'video';
/** A geographic location attached to a media item. */
export interface GeoLocation {
lat: number;
lng: number;
/** Reverse-geocoded human label, e.g. "Mumbai, India". */
place?: string;
city?: string;
country?: string;
}
/** Non-destructive edit adjustments (all normalized -1..1 unless noted). */
export interface EditAdjustments {
exposure: number;
brilliance: number;
highlights: number;
shadows: number;
contrast: number;
brightness: number;
blackPoint: number;
saturation: number;
vibrance: number;
warmth: number;
tint: number;
sharpness: number;
definition: number;
vignette: number;
}
export interface EditState {
adjustments: Partial<EditAdjustments>;
/** Named filter preset, e.g. "vivid", "dramatic", "mono". */
filter?: string;
/** Clockwise rotation in degrees (0/90/180/270). */
rotation?: number;
/** Fine straighten/tilt angle in degrees (-45..45). */
straighten?: number;
flipH?: boolean;
flipV?: boolean;
/** Crop as fractions of the source dimensions (0..1). */
crop?: { x: number; y: number; width: number; height: number };
/** Markup overlay (shapes, arrows, measurement labels, text). */
annotations?: Annotation[];
// ---- video-only edits ----
/** Legacy single trim range in seconds. Superseded by `segments`; still honored. */
trim?: { start: number; end: number };
/**
* Multi-segment trim: an ordered list of keep-ranges (cut unwanted middles,
* reorder, or speed up parts). The exported clip is the segments concatenated.
* When present this takes precedence over `trim`.
*/
segments?: VideoSegment[];
/** Legacy single static image overlay. Superseded by `overlays`; still honored. */
overlay?: { src: string; x: number; y: number; scale: number };
/** Image/text/watermark overlays with timing + keyframe animation. */
overlays?: VideoOverlay[];
/** Frame (output-timeline seconds) to grab as the poster/thumbnail on export. */
posterTime?: number;
/** Export quality knobs. */
export?: { maxDim?: number; fps?: number; bitrate?: number };
/** Audio mix: mute/volume the original, add a music track, and fade in/out. */
audio?: {
muted?: boolean;
originalVolume?: number;
musicSrc?: string;
musicVolume?: number;
/** Master fade in / out over the whole exported clip, in seconds. */
fadeIn?: number;
fadeOut?: number;
};
}
/** One keep-range of a multi-segment video trim. */
export interface VideoSegment {
id: string;
/** Range within the SOURCE video, in seconds. */
start: number;
end: number;
/** Playback speed for this segment (1 = normal, 0.5 = slow-mo, 2 = fast). */
speed?: number;
}
/** A single animation keyframe for a video overlay. Times are OUTPUT-timeline seconds. */
export interface OverlayKeyframe {
t: number;
/** Position of the overlay's top-left as 0..1 fractions of the frame. */
x?: number;
y?: number;
/** Width as a 0..1 fraction of the frame width. */
scale?: number;
/** Rotation in degrees. */
rotation?: number;
/** 0..1 opacity. */
opacity?: number;
}
/** An image or text overlay composited onto a video, with timing + keyframes. */
export interface VideoOverlay {
id: string;
kind: 'image' | 'text';
/** Image data/URL (kind 'image'). */
src?: string;
/** Text content (kind 'text'). */
text?: string;
color?: string;
/** Text size as a 0..1 fraction of the frame height. */
fontSize?: number;
bold?: boolean;
// Base transform — used when no keyframe brackets the current time.
x: number;
y: number;
scale: number;
rotation?: number;
opacity?: number;
/** Visible window (output-timeline seconds). Absent = whole clip. */
in?: number;
out?: number;
keyframes?: OverlayKeyframe[];
/** Marks a persistent watermark (visible for the whole clip). */
watermark?: boolean;
}
export type AnnotationShape =
| 'rect'
| 'ellipse'
| 'line'
| 'arrow'
| 'double-arrow'
| 'text'
| 'freehand';
/** A single markup annotation. All geometry is normalized 0..1 relative to the image box. */
export interface Annotation {
id: string;
shape: AnnotationShape;
color: string;
strokeWidth: number; // display px
x1: number;
y1: number;
x2: number;
y2: number;
/** Text content / measurement label (text + double-arrow shapes). */
text?: string;
/** Freehand path points (normalized). */
points?: Array<{ x: number; y: number }>;
}
/** A detected object/region inside an image (for click-to-find-similar). */
export interface DetectedObject {
label: string;
confidence: number;
/** Bounding box as fractions of the image (0..1). */
box: { x: number; y: number; width: number; height: number };
}
/** A detected face region, optionally linked to a person cluster. */
export interface DetectedFace {
personId?: PersonId;
confidence: number;
box: { x: number; y: number; width: number; height: number };
/** Embedding vector for clustering / similarity (optional). */
embedding?: number[];
}
/** The central media entity (photo or video). */
export interface MediaItem {
id: MediaId;
kind: MediaKind;
/** Full-resolution source (URL, blob URL, or data URL). */
src: string;
/** Optional smaller thumbnail; falls back to `src`. */
thumbnail?: string;
/** Optional poster frame for videos. */
poster?: string;
name: string;
width: number;
height: number;
mime: string;
bytes?: number;
/** Capture time (ms epoch). Used for grouping by year/month/day. */
takenAt: number;
importedAt: number;
editedAt?: number;
/** When moved to the recycle bin (ms epoch). Absent = not deleted. */
deletedAt?: number;
/** Video duration in seconds. */
duration?: number;
favorite: boolean;
hidden: boolean;
source: MediaSource;
/** User album ids this item belongs to. */
albumIds: AlbumId[];
/** Free-form + auto-generated tags / keywords. */
tags: string[];
/** Detected object labels (denormalized from `objects` for fast filtering). */
objectLabels: string[];
/** Person cluster ids present in this item. */
personIds: PersonId[];
location?: GeoLocation;
exif?: Record<string, string | number>;
// AI-derived metadata (optional, filled lazily by providers).
caption?: string;
/**
* Printed/handwritten text extracted from the image by OCR.
* @security Attacker-influencable (text inside an imported image). Render ONLY
* via React JSX / textContent never innerHTML. Sanitized + length-capped on write.
* `undefined` = not yet processed; `''` = OCR ran, found no text (so it isn't retried).
*/
ocrText?: string;
objects?: DetectedObject[];
faces?: DetectedFace[];
/** When AI analysis last ran on this item (ms epoch); absent = not analyzed. */
analyzedAt?: number;
colorPalette?: string[];
/** 0..1 blur estimate (higher = blurrier). */
blurScore?: number;
/** 0..1 aesthetic/quality estimate. */
qualityScore?: number;
/** Embedding for semantic search (optional). */
embedding?: number[];
isLivePhoto?: boolean;
isRaw?: boolean;
isPanorama?: boolean;
edits?: EditState;
/** Edit history — v1 is always the original; each save appends a version. */
versions?: MediaVersion[];
/** User comments on this photo/video (chronological). */
comments?: MediaComment[];
}
/** A saved snapshot of a photo/video at one point in its edit history. */
export interface MediaVersion {
id: string;
/** 1 = original; increments on every saved edit. */
version: number;
createdAt: number;
/** The media source for this version. */
src: string;
thumbnail?: string;
width?: number;
height?: number;
/** Edit stack applied to produce this version (from the original). */
edits?: EditState;
/** Human-readable audit log of what changed in this version. */
changes: string[];
/** Optional user label / note. */
note?: string;
}
/** A comment left on a photo/video. */
export interface MediaComment {
id: string;
author?: string;
text: string;
createdAt: number;
}
export type AlbumKind = 'user' | 'smart' | 'folder' | 'shared';
/** A field/operator/value rule used by smart albums. */
export interface SmartRule {
field:
| 'source'
| 'kind'
| 'favorite'
| 'tag'
| 'object'
| 'person'
| 'mime'
| 'name'
| 'takenAt'
| 'hasLocation'
| 'hasText'
| 'isRaw'
| 'isLivePhoto'
| 'isPanorama';
op: 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'isTrue' | 'isFalse';
value?: string | number | boolean;
}
export interface SmartRuleSet {
/** 'all' = AND, 'any' = OR. */
match: 'all' | 'any';
rules: SmartRule[];
}
export interface Album {
id: AlbumId;
name: string;
kind: AlbumKind;
/** Parent folder id for nesting. */
parentId?: AlbumId;
/** Ordered media ids (user albums). */
mediaIds: MediaId[];
/** Rules for smart albums. */
ruleSet?: SmartRuleSet;
coverId?: MediaId;
createdAt: number;
pinned?: boolean;
/** Whether this is a system-provided album (cannot be deleted/renamed). */
system?: boolean;
/** Icon key for system albums. */
icon?: string;
}
export interface Person {
id: PersonId;
name?: string;
coverId?: MediaId;
mediaIds: MediaId[];
isPet?: boolean;
}
/** A share created by the user (single photo, several photos, or a whole album). */
export interface ShareRecord {
id: string;
/** Short public token embedded in the share link. */
token: string;
scope: 'photo' | 'photos' | 'album';
mediaIds: MediaId[];
albumId?: AlbumId;
title: string;
/** Shareable deep link (also the download entry point). */
url: string;
createdAt: number;
}
/** Theme preference. `semi-dark` = dark glass sidebar with light content. */
export type ThemePreference = 'system' | 'light' | 'dark' | 'semi-dark';
/** A concrete resolved theme applied to the DOM. */
export type ResolvedTheme = 'light' | 'dark' | 'semi-dark';
/** Library zoom density (Apple-style years/months/days/all). */
export type LibraryScale = 'years' | 'months' | 'days' | 'all';
export type MapMode = 'map' | 'satellite' | 'grid';
/** The active view inside the gallery. Album views encode the id. */
export type ViewId =
| 'library'
| 'collections'
| 'favourites'
| 'recently-saved'
| 'map'
| 'videos'
| 'screenshots'
| 'people'
| 'recently-deleted'
| 'duplicates'
| 'albums'
| 'shared-albums'
| 'activity'
| 'search'
| 'versions'
| `album:${string}`
| `sys:${string}`;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"baseUrl": "."
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
treeshake: true,
splitting: false,
external: ['react', 'react-dom'],
// CSS is shipped separately (copied in the build script) so any consumer
// can `import '@photo-gallery/sdk/styles.css'` regardless of their bundler.
});