first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 & 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 & drop here, or click to browse</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--apg-text-secondary)' }}>
|
||||
Images & 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 double‑arrow
|
||||
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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
},
|
||||
satellite: {
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: 'Tiles © 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],
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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}`;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -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.
|
||||
});
|
||||
Reference in New Issue
Block a user