forked from Goutam/lynkeduppro-crm
feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { AIProvider } from '../ai/types';
|
||||
import { PET_LABELS } from '../constants';
|
||||
import { resolveLabel } from '../lib/smartAlbums';
|
||||
import { sanitizeOcrText } from '../lib/text';
|
||||
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||||
import type { MediaItem } from '../types';
|
||||
|
||||
const CONCURRENCY = 2;
|
||||
const CONFIDENCE = 0.15;
|
||||
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;
|
||||
|
||||
// Analyze each image EXACTLY ONCE: every capability is gated on `!analyzedAt`,
|
||||
// so once an item has been analyzed (and analyzedAt persisted) it is never
|
||||
// re-processed on later reloads — even if an individual result field didn't
|
||||
// round-trip through storage. Fresh uploads (no analyzedAt) run everything.
|
||||
const needsObjects = (m: MediaItem) => !!provider.detectObjects && !m.analyzedAt;
|
||||
const needsFaces = (m: MediaItem) =>
|
||||
!!provider.detectFaces && !m.analyzedAt && m.faces === undefined;
|
||||
const needsOcr = (m: MediaItem) => !!provider.ocr && !m.analyzedAt && m.ocrText === undefined;
|
||||
const needsEmbedding = (m: MediaItem) =>
|
||||
!!provider.embedImage && !m.analyzedAt && 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;
|
||||
// Map each detected label through the user's rename map so future uploads
|
||||
// are stored/grouped under the renamed label instead of a fresh class.
|
||||
const aliases = api.getState().labelAliases;
|
||||
const deleted = api.getState().deletedLabels;
|
||||
patch.objectLabels = [
|
||||
...new Set(
|
||||
objects
|
||||
.filter((o) => o.confidence >= CONFIDENCE)
|
||||
.map((o) => resolveLabel(o.label, aliases))
|
||||
.filter((l) => !deleted.includes(l)),
|
||||
),
|
||||
];
|
||||
}
|
||||
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,61 @@
|
||||
'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 showSidebar = useGallery((s) => s.config.chrome.sidebar);
|
||||
const showToolbar = useGallery((s) => s.config.chrome.toolbar);
|
||||
const shortcutsEnabled = useGallery((s) => s.config.keyboardShortcuts);
|
||||
const view = useGallery((s) => s.view);
|
||||
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// If the active view becomes hidden (host toggled `hiddenViews`, or a deep link
|
||||
// landed on one), fall back to the Library instead of rendering a dead view.
|
||||
useEffect(() => {
|
||||
if (hiddenViews && hiddenViews.includes(view) && view !== 'library') {
|
||||
api.getState().setView('library');
|
||||
}
|
||||
}, [api, view, hiddenViews]);
|
||||
// The hook is always called (stable hook order); it binds nothing when disabled.
|
||||
useKeyboardShortcuts(shortcutsEnabled);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{showSidebar ? <Sidebar /> : null}
|
||||
{showSidebar ? (
|
||||
<div
|
||||
className={['apg-scrim', isMobile && sidebarOpen ? 'apg-scrim--show' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => api.getState().setSidebar(false)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="apg-main">
|
||||
{showToolbar ? <TopToolbar /> : null}
|
||||
<div className="apg-viewport">
|
||||
<ViewRouter />
|
||||
<SelectionBar />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
'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,
|
||||
// Stamp who captured this (display identity) when the host supplies a user.
|
||||
uploadedBy: api.getState().config.currentUser,
|
||||
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,171 @@
|
||||
'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();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a context menu is open (it owns Escape while it is). */
|
||||
export function isContextMenuOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
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,839 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, 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 { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from '../lib/audioCapture';
|
||||
import { Icon } from '../icons';
|
||||
import { useAIProvider } from './aiContext';
|
||||
import { useGallery, useGalleryStoreApi } from '../store/context';
|
||||
import type { GalleryUser, GeoLocation, 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">
|
||||
{item.kind === 'video' ? (
|
||||
// Videos get a real, playable preview at the top of the panel — the same
|
||||
// affordance images have, not a frozen poster frame.
|
||||
<video
|
||||
className="apg-info__thumb apg-info__thumb--video"
|
||||
src={item.src}
|
||||
poster={item.poster ?? item.thumbnail}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Editable caption (single line) + note (multi-line) — persisted via updateMedia. */}
|
||||
<EditableField
|
||||
key={`cap-${item.id}`}
|
||||
label="Caption"
|
||||
value={item.caption}
|
||||
placeholder="Add a caption…"
|
||||
onSave={(v) => api.getState().updateMedia(item.id, { caption: v })}
|
||||
/>
|
||||
<EditableField
|
||||
key={`note-${item.id}`}
|
||||
label="Note"
|
||||
value={item.note}
|
||||
placeholder="Add a note…"
|
||||
multiline
|
||||
onSave={(v) => api.getState().updateMedia(item.id, { note: v })}
|
||||
/>
|
||||
|
||||
{item.uploadedBy ? <UploadedBy user={item.uploadedBy} /> : null}
|
||||
|
||||
{item.kind === 'image' && !item.analyzedAt ? (
|
||||
<div className="apg-info__analyzing">
|
||||
<span className="apg-info__spinner" aria-hidden />
|
||||
Analyzing image…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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.exif?.LensModel ? <Row label="Lens" value={String(item.exif.LensModel)} /> : null}
|
||||
{item.exif?.Orientation ? (
|
||||
<Row label="Orientation" value={orientationLabel(item.exif.Orientation)} />
|
||||
) : 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.formatted ??
|
||||
item.location.place ??
|
||||
`${item.location.lat.toFixed(4)}, ${item.location.lng.toFixed(4)}`
|
||||
}
|
||||
/>
|
||||
<MiniMap
|
||||
lat={item.location.lat}
|
||||
lng={item.location.lng}
|
||||
onOpen={() => api.getState().focusMap({ lat: item.location!.lat, lng: item.location!.lng })}
|
||||
/>
|
||||
<AddressBlock
|
||||
item={item}
|
||||
onOpenMap={() => 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 or address 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>
|
||||
{v.author ? (
|
||||
<span className="apg-version__author">
|
||||
{v.authorAvatar ? (
|
||||
<img className="apg-version__author-avatar" src={v.authorAvatar} alt="" />
|
||||
) : (
|
||||
<span className="apg-version__author-avatar apg-version__author-avatar--initial" aria-hidden>
|
||||
{v.author.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="apg-version__author-name">{v.author}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</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 currentUser = useGallery((s) => s.config.currentUser);
|
||||
const comments = item.comments ?? [];
|
||||
const [text, setText] = useState('');
|
||||
// Without a host-provided user we keep the legacy free-text author field
|
||||
// (remembered in localStorage). With one, identity comes from the host.
|
||||
const [author, setAuthor] = useState(() => {
|
||||
if (typeof window === 'undefined') return 'You';
|
||||
return window.localStorage.getItem('apg:comment-author') || 'You';
|
||||
});
|
||||
|
||||
const provider = useAIProvider();
|
||||
const canVoice = Boolean(provider?.transcribeAudio);
|
||||
const canDenoise = Boolean(provider?.denoiseAudio);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [denoise, setDenoise] = useState(false);
|
||||
const [voiceStatus, setVoiceStatus] = useState<string | null>(null);
|
||||
const recorderRef = useRef<Recorder | null>(null);
|
||||
|
||||
const startVoice = async () => {
|
||||
setVoiceStatus(null);
|
||||
try {
|
||||
recorderRef.current = await startRecording();
|
||||
setRecording(true);
|
||||
} catch (e) {
|
||||
setVoiceStatus(e instanceof Error ? e.message : 'Microphone unavailable.');
|
||||
}
|
||||
};
|
||||
|
||||
const stopVoice = async () => {
|
||||
const rec = recorderRef.current;
|
||||
recorderRef.current = null;
|
||||
setRecording(false);
|
||||
if (!rec || !provider?.transcribeAudio) return;
|
||||
try {
|
||||
const blob = await rec.stop();
|
||||
let wav16: string;
|
||||
if (denoise && provider.denoiseAudio) {
|
||||
setVoiceStatus('Reducing noise…');
|
||||
const wav48 = await blobToWavBase64(blob, 48000);
|
||||
const cleaned = await provider.denoiseAudio(wav48);
|
||||
wav16 = await blobToWavBase64(wavBase64ToBlob(cleaned), 16000);
|
||||
} else {
|
||||
wav16 = await blobToWavBase64(blob, 16000);
|
||||
}
|
||||
setVoiceStatus('Transcribing…');
|
||||
const spoken = (await provider.transcribeAudio(wav16)).trim();
|
||||
if (spoken) setText((prev) => (prev ? `${prev} ${spoken}` : spoken));
|
||||
setVoiceStatus(null);
|
||||
} catch (e) {
|
||||
setVoiceStatus(e instanceof Error ? e.message : 'Could not transcribe audio.');
|
||||
}
|
||||
};
|
||||
|
||||
const post = () => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
if (currentUser) {
|
||||
// The store stamps authorId/author/avatar from config.currentUser.
|
||||
api.getState().addComment(item.id, t);
|
||||
setText('');
|
||||
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) => {
|
||||
// With a configured user, only their OWN comments are deletable
|
||||
// (mirrors the server rule enforced on authorId).
|
||||
const canDelete = !currentUser || c.authorId === currentUser.id;
|
||||
return (
|
||||
<li key={c.id} className="apg-comment">
|
||||
{c.authorAvatar ? (
|
||||
<img className="apg-comment__avatar-img" src={c.authorAvatar} alt="" />
|
||||
) : (
|
||||
<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>
|
||||
{canDelete ? (
|
||||
<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>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="apg-comment-form">
|
||||
{currentUser ? (
|
||||
<div className="apg-comment-form__identity">
|
||||
{currentUser.avatarUrl ? (
|
||||
<img className="apg-comment__avatar-img" src={currentUser.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="apg-comment__avatar" aria-hidden>
|
||||
{(currentUser.name || '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="apg-comment__author">{currentUser.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
{canVoice ? (
|
||||
<div className="apg-voice">
|
||||
<button
|
||||
type="button"
|
||||
className={`apg-btn apg-btn--small apg-voice__mic${recording ? ' apg-voice__mic--rec' : ''}`}
|
||||
onClick={recording ? stopVoice : startVoice}
|
||||
aria-label={recording ? 'Stop recording' : 'Record a voice comment'}
|
||||
title={recording ? 'Stop & transcribe' : 'Speak your comment'}
|
||||
>
|
||||
<Icon name={recording ? 'check' : 'mic'} size={14} />
|
||||
{recording ? 'Stop' : 'Speak'}
|
||||
</button>
|
||||
{canDenoise ? (
|
||||
<label
|
||||
className="apg-voice__denoise"
|
||||
title="Clean up background noise before transcribing"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={denoise}
|
||||
onChange={(e) => setDenoise(e.target.checked)}
|
||||
/>
|
||||
Reduce noise
|
||||
</label>
|
||||
) : null}
|
||||
<span className="apg-voice__status" aria-live="polite">
|
||||
{recording ? '● Listening…' : (voiceStatus ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// EXIF Orientation codes 1–8 → human text (construction shots are often sideways).
|
||||
const ORIENTATION_LABELS: Record<number, string> = {
|
||||
1: 'Normal',
|
||||
2: 'Mirrored horizontal',
|
||||
3: 'Rotated 180°',
|
||||
4: 'Mirrored vertical',
|
||||
5: 'Mirrored + 90° CCW',
|
||||
6: 'Rotated 90° CW',
|
||||
7: 'Mirrored + 90° CW',
|
||||
8: 'Rotated 90° CCW',
|
||||
};
|
||||
function orientationLabel(v: string | number): string {
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
return ORIENTATION_LABELS[n] ?? String(v);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-instance guards so re-opening a panel (or a second panel) never double-
|
||||
// fetches the same item: ids currently in flight, and ids that already failed
|
||||
// (so the auto-fetch doesn't loop — the user must press Retry).
|
||||
const geocodeInFlight = new Set<string>();
|
||||
const geocodeFailed = new Set<string>();
|
||||
|
||||
/** Shape of Nominatim's `address` object (addressdetails=1) — all fields optional. */
|
||||
interface NominatimAddress {
|
||||
road?: string;
|
||||
neighbourhood?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
village?: string;
|
||||
county?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-geocodes an item's GPS coords to a full postal address via free
|
||||
* OpenStreetMap Nominatim, then PERSISTS the parsed fields onto the item
|
||||
* (`updateMedia`) so it saves through the adapter and survives a reload. Runs
|
||||
* automatically the first time a located item's Info panel opens (only when
|
||||
* `location.formatted` isn't already cached); manual Retry on error.
|
||||
*
|
||||
* NOTE: a hardened host CSP must allow `nominatim.openstreetmap.org` in
|
||||
* `connect-src` for this fetch to succeed.
|
||||
*/
|
||||
function AddressBlock({ item, onOpenMap }: { item: MediaItem; onOpenMap: () => void }) {
|
||||
const api = useGalleryStoreApi();
|
||||
const loc = item.location!;
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
|
||||
|
||||
const lookup = useCallback(async () => {
|
||||
// One request per id at a time (etiquette + no double-fetch across panels).
|
||||
if (geocodeInFlight.has(item.id)) return;
|
||||
geocodeInFlight.add(item.id);
|
||||
setStatus('loading');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${loc.lat}&lon=${loc.lng}&format=json&zoom=18&addressdetails=1`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
);
|
||||
const data = (await res.json()) as { display_name?: string; address?: NominatimAddress };
|
||||
const a = data.address ?? {};
|
||||
if (data.display_name) {
|
||||
const parsed: Partial<GeoLocation> = {
|
||||
road: a.road,
|
||||
neighbourhood: a.neighbourhood,
|
||||
suburb: a.suburb,
|
||||
city: a.city ?? a.town ?? a.village,
|
||||
county: a.county,
|
||||
state: a.state,
|
||||
postcode: a.postcode,
|
||||
country: a.country,
|
||||
countryCode: a.country_code ? a.country_code.toUpperCase() : undefined,
|
||||
formatted: data.display_name,
|
||||
};
|
||||
// Persist through the adapter (→ be-crm) so it survives reload + isn't refetched.
|
||||
api.getState().updateMedia(item.id, { location: { ...loc, ...parsed } });
|
||||
geocodeFailed.delete(item.id);
|
||||
setStatus('idle');
|
||||
} else {
|
||||
geocodeFailed.add(item.id);
|
||||
setStatus('error');
|
||||
}
|
||||
} catch {
|
||||
geocodeFailed.add(item.id);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
geocodeInFlight.delete(item.id);
|
||||
}
|
||||
}, [api, item.id, loc]);
|
||||
|
||||
// Auto-fetch once when a located item without a cached address opens. The
|
||||
// `!loc.formatted` guard (and the failed-set) prevent a re-fetch loop: once
|
||||
// persisted, the item re-renders WITH `formatted` and this no-ops.
|
||||
useEffect(() => {
|
||||
if (!loc.formatted && !geocodeFailed.has(item.id) && !geocodeInFlight.has(item.id)) {
|
||||
void lookup();
|
||||
}
|
||||
}, [item.id, loc.formatted, lookup]);
|
||||
|
||||
const retry = () => {
|
||||
geocodeFailed.delete(item.id);
|
||||
void lookup();
|
||||
};
|
||||
|
||||
if (loc.formatted) {
|
||||
const cells: Array<[string, string | undefined]> = [
|
||||
['City', loc.city],
|
||||
['State', loc.state],
|
||||
['Postcode', loc.postcode],
|
||||
['Country', loc.country],
|
||||
];
|
||||
return (
|
||||
<div className="apg-address">
|
||||
<button type="button" className="apg-address__line" onClick={onOpenMap} title="Open in Map">
|
||||
<Icon name="map" size={14} />
|
||||
<span>{loc.formatted}</span>
|
||||
</button>
|
||||
<dl className="apg-address__grid">
|
||||
{cells
|
||||
.filter(([, v]) => Boolean(v))
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="apg-address__cell">
|
||||
<dt>{k}</dt>
|
||||
<dd>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="apg-info__coords">
|
||||
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="apg-address">
|
||||
<div className="apg-info__coords">
|
||||
{loc.lat.toFixed(5)}, {loc.lng.toFixed(5)}
|
||||
</div>
|
||||
{status === 'loading' ? (
|
||||
<div className="apg-info__hint" aria-live="polite">
|
||||
Looking up address…
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<button type="button" className="apg-btn apg-btn--small" onClick={retry} style={{ marginTop: 6 }}>
|
||||
Retry address
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Uploaded by" identity block: avatar + name + (muted) email. */
|
||||
function UploadedBy({ user }: { user: GalleryUser }) {
|
||||
return (
|
||||
<div className="apg-info__uploader">
|
||||
<span className="apg-info__uploader-label">Uploaded by</span>
|
||||
<div className="apg-info__uploader-row">
|
||||
{user.avatarUrl ? (
|
||||
<img className="apg-info__uploader-avatar" src={user.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="apg-info__uploader-avatar apg-info__uploader-avatar--initial" aria-hidden>
|
||||
{(user.name || '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="apg-info__uploader-meta">
|
||||
<span className="apg-info__uploader-name">{user.name}</span>
|
||||
{user.email ? <span className="apg-info__uploader-email">{user.email}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-edit metadata field (caption / note). Saves on blur or Enter (Cmd/Ctrl+
|
||||
* Enter for the multi-line note) via the supplied `onSave`, which persists through
|
||||
* `updateMedia`. Shows an unobtrusive "edited" hint once a value is present.
|
||||
*/
|
||||
function EditableField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
multiline,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
placeholder: string;
|
||||
multiline?: boolean;
|
||||
onSave: (value: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
|
||||
// Re-sync the draft when the underlying value changes and we're not editing.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value ?? '');
|
||||
}, [value, editing]);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
const next = draft.trim();
|
||||
if (next !== (value ?? '').trim()) onSave(next);
|
||||
};
|
||||
|
||||
const hasValue = Boolean((value ?? '').trim());
|
||||
|
||||
if (editing) {
|
||||
const shared = {
|
||||
className: 'apg-editable__input',
|
||||
value: draft,
|
||||
autoFocus: true,
|
||||
placeholder,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setDraft(e.target.value),
|
||||
onBlur: commit,
|
||||
};
|
||||
return (
|
||||
<div className="apg-editable apg-editable--editing">
|
||||
<span className="apg-editable__label">{label}</span>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
{...shared}
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') commit();
|
||||
else if (e.key === 'Escape') {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
{...shared}
|
||||
maxLength={280}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
else if (e.key === 'Escape') {
|
||||
setDraft(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="apg-editable" onClick={() => setEditing(true)}>
|
||||
<span className="apg-editable__label">
|
||||
{label}
|
||||
{hasValue ? <span className="apg-editable__edited">edited</span> : null}
|
||||
<Icon name="pencil" size={12} />
|
||||
</span>
|
||||
<span className={['apg-editable__value', hasValue ? '' : 'apg-editable__value--empty'].filter(Boolean).join(' ')}>
|
||||
{hasValue ? value : placeholder}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,62 @@
|
||||
'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();
|
||||
}
|
||||
|
||||
/** Whether a modal is currently mounted. Read by global Escape handling so the
|
||||
* modal (which owns Escape) is never fought over by another handler. */
|
||||
export function isModalOpen(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
|
||||
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,403 @@
|
||||
'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_CHROME,
|
||||
DEFAULT_FEATURES,
|
||||
type GalleryConfig,
|
||||
type GalleryFeatures,
|
||||
type GalleryStore,
|
||||
type ThemeTokens,
|
||||
} from '../store/store';
|
||||
import type { Album, GalleryUser, MediaItem, ThemePreference, ViewId } 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. Maps onto `chrome.titlebar`. */
|
||||
showWindowChrome?: boolean;
|
||||
title?: string;
|
||||
/**
|
||||
* The host app's signed-in user. When set, comments are stamped with this
|
||||
* identity (avatar + name + id) instead of a free-text author field, and only
|
||||
* the user's own comments show a delete affordance.
|
||||
*/
|
||||
currentUser?: GalleryUser;
|
||||
/** Base URL for generated share links (default `${location.origin}/gallery`). */
|
||||
shareBaseUrl?: string;
|
||||
/** Suppress pieces of the gallery's own chrome that the host already provides. */
|
||||
chrome?: Partial<GalleryConfig['chrome']>;
|
||||
/**
|
||||
* Sidebar rows + Collections sections to hide (by ViewId).
|
||||
* e.g. `['screenshots', 'sys:documents']`. Default `[]` (nothing hidden).
|
||||
*/
|
||||
hiddenViews?: ViewId[];
|
||||
/** Bind global keyboard shortcuts to `window` (default true). */
|
||||
keyboardShortcuts?: boolean;
|
||||
/**
|
||||
* Embedded mode: the gallery fills its host container (height 100%) instead of
|
||||
* assuming a full-viewport parent, and interactive elements use `cursor: pointer`.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/**
|
||||
* Server-backed, per-user lock for the Recently Deleted view. When supplied the
|
||||
* SDK uses this INSTEAD of its device-local localStorage hash.
|
||||
*/
|
||||
lockProvider?: {
|
||||
status(): Promise<{ hasPassword: boolean }>;
|
||||
set(password: string | null): Promise<void>; // null clears it
|
||||
verify(password: string): Promise<boolean>;
|
||||
};
|
||||
/** Start the gallery maximised (default false). */
|
||||
defaultFullscreen?: boolean;
|
||||
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',
|
||||
currentUser,
|
||||
shareBaseUrl,
|
||||
chrome,
|
||||
hiddenViews,
|
||||
keyboardShortcuts = true,
|
||||
embedded = false,
|
||||
lockProvider,
|
||||
defaultFullscreen = false,
|
||||
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],
|
||||
);
|
||||
|
||||
// Destructure chrome to primitives so an inline `chrome={{…}}` literal doesn't
|
||||
// recompute the config on every host render. `showWindowChrome` is the legacy
|
||||
// spelling of `chrome.titlebar`; an explicit `chrome.titlebar` wins.
|
||||
const chromeTitlebar = chrome?.titlebar ?? showWindowChrome;
|
||||
const chromeSidebar = chrome?.sidebar ?? DEFAULT_CHROME.sidebar;
|
||||
const chromeToolbar = chrome?.toolbar ?? DEFAULT_CHROME.toolbar;
|
||||
const chromeThemeSwitcher = chrome?.themeSwitcher ?? DEFAULT_CHROME.themeSwitcher;
|
||||
const userId = currentUser?.id;
|
||||
const userName = currentUser?.name;
|
||||
const userEmail = currentUser?.email;
|
||||
const userAvatar = currentUser?.avatarUrl;
|
||||
// Stable key so an inline `hiddenViews={['screenshots']}` literal doesn't
|
||||
// recompute the config on every host render.
|
||||
const hiddenViewsKey = (hiddenViews ?? []).join(',');
|
||||
|
||||
const config: GalleryConfig = useMemo(
|
||||
() => ({
|
||||
features: { ...DEFAULT_FEATURES, ...features },
|
||||
accentColor: themeTokens?.accent ?? accentColor ?? '#0a84ff',
|
||||
borderRadius: borderRadius ?? 10,
|
||||
showWindowChrome: chromeTitlebar,
|
||||
title,
|
||||
themeTokens,
|
||||
currentUser:
|
||||
userId !== undefined
|
||||
? { id: userId, name: userName ?? '', email: userEmail, avatarUrl: userAvatar }
|
||||
: undefined,
|
||||
shareBaseUrl,
|
||||
chrome: {
|
||||
titlebar: chromeTitlebar,
|
||||
sidebar: chromeSidebar,
|
||||
toolbar: chromeToolbar,
|
||||
themeSwitcher: chromeThemeSwitcher,
|
||||
},
|
||||
hiddenViews: hiddenViewsKey ? (hiddenViewsKey.split(',') as ViewId[]) : [],
|
||||
keyboardShortcuts,
|
||||
embedded,
|
||||
lockProvider,
|
||||
defaultFullscreen,
|
||||
}),
|
||||
[
|
||||
features,
|
||||
accentColor,
|
||||
borderRadius,
|
||||
themeTokens,
|
||||
title,
|
||||
userId,
|
||||
userName,
|
||||
userEmail,
|
||||
userAvatar,
|
||||
shareBaseUrl,
|
||||
chromeTitlebar,
|
||||
chromeSidebar,
|
||||
chromeToolbar,
|
||||
chromeThemeSwitcher,
|
||||
hiddenViewsKey,
|
||||
keyboardShortcuts,
|
||||
embedded,
|
||||
lockProvider,
|
||||
defaultFullscreen,
|
||||
],
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Keep the live config in sync with the props. The store is created once, so
|
||||
// without this a host changing theme tokens / user / chrome would have no effect.
|
||||
useEffect(() => {
|
||||
store.getState().setConfig(config);
|
||||
}, [store, config]);
|
||||
|
||||
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.chrome.titlebar);
|
||||
const embedded = useGallery((s) => s.config.embedded);
|
||||
const fullscreen = useGallery((s) => s.fullscreen);
|
||||
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;
|
||||
};
|
||||
/** Pick the light/dark member of a token pair for the active theme. */
|
||||
const pick = (light: string | undefined, darkValue: string | undefined) =>
|
||||
dark ? darkValue : light;
|
||||
const px = (v: number | undefined, name: string) => {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) tokenVars[name] = `${v}px`;
|
||||
};
|
||||
|
||||
const bg = pick(tokens.bgLight, tokens.bgDark);
|
||||
set(bg, '--apg-bg');
|
||||
set(bg, '--apg-bg-content');
|
||||
set(pick(tokens.elevatedLight, tokens.elevatedDark), '--apg-bg-elevated');
|
||||
// In semi-dark the sidebar is always the dark glass value.
|
||||
set(
|
||||
resolvedTheme === 'semi-dark'
|
||||
? tokens.sidebarBgDark
|
||||
: pick(tokens.sidebarBgLight, tokens.sidebarBgDark),
|
||||
'--apg-sidebar-bg',
|
||||
);
|
||||
set(pick(tokens.textLight, tokens.textDark), '--apg-text');
|
||||
px(tokens.sidebarRadius, '--apg-sidebar-radius');
|
||||
|
||||
// ---- extended token map ----
|
||||
set(pick(tokens.accentStrongLight, tokens.accentStrongDark), '--apg-accent-strong');
|
||||
set(tokens.accentContrast, '--apg-accent-contrast');
|
||||
set(pick(tokens.dangerLight, tokens.dangerDark), '--apg-danger');
|
||||
set(pick(tokens.cardLight, tokens.cardDark), '--apg-card');
|
||||
set(pick(tokens.cardHoverLight, tokens.cardHoverDark), '--apg-card-hover');
|
||||
set(pick(tokens.toolbarBgLight, tokens.toolbarBgDark), '--apg-toolbar-bg');
|
||||
set(pick(tokens.menuBgLight, tokens.menuBgDark), '--apg-menu-bg');
|
||||
set(pick(tokens.separatorLight, tokens.separatorDark), '--apg-separator');
|
||||
set(
|
||||
pick(tokens.separatorStrongLight, tokens.separatorStrongDark),
|
||||
'--apg-separator-strong',
|
||||
);
|
||||
set(pick(tokens.hoverLight, tokens.hoverDark), '--apg-hover');
|
||||
set(pick(tokens.activeLight, tokens.activeDark), '--apg-active');
|
||||
set(
|
||||
pick(tokens.sidebarSelectedLight, tokens.sidebarSelectedDark),
|
||||
'--apg-sidebar-selected',
|
||||
);
|
||||
set(pick(tokens.textSecondaryLight, tokens.textSecondaryDark), '--apg-text-secondary');
|
||||
set(pick(tokens.textTertiaryLight, tokens.textTertiaryDark), '--apg-text-tertiary');
|
||||
set(pick(tokens.glassBorderLight, tokens.glassBorderDark), '--apg-glass-border');
|
||||
set(tokens.fontFamily, '--apg-font');
|
||||
px(tokens.radiusMenu, '--apg-radius-menu');
|
||||
set(tokens.shadowSm, '--apg-shadow-sm');
|
||||
set(pick(tokens.shadowMdLight, tokens.shadowMdDark), '--apg-shadow-md');
|
||||
set(pick(tokens.shadowLgLight, tokens.shadowLgDark), '--apg-shadow-lg');
|
||||
set(tokens.tileFav, '--apg-tile-fav');
|
||||
set(tokens.overlayBg, '--apg-overlay-bg');
|
||||
set(tokens.editorBg, '--apg-editor-bg');
|
||||
set(tokens.segmentedActive, '--apg-segmented-active');
|
||||
px(tokens.sidebarWidth, '--apg-sidebar-w');
|
||||
px(tokens.toolbarHeight, '--apg-toolbar-h');
|
||||
}
|
||||
|
||||
// Where full-screen overlays (the Info panel) start. A host with its own header
|
||||
// bar can push this down; default matches the standalone toolbar offset.
|
||||
const overlayTop = tokens?.toolbarHeight ? `${tokens.toolbarHeight + 12}px` : '64px';
|
||||
|
||||
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`,
|
||||
['--apg-overlay-top' as string]: overlayTop,
|
||||
...tokenVars,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<AIProviderContext.Provider value={aiEnabled ? ai : null}>
|
||||
<div
|
||||
className={[
|
||||
'apg',
|
||||
embedded ? 'apg--embedded' : '',
|
||||
fullscreen ? 'apg--fullscreen' : '',
|
||||
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;
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
'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 { confirmAction, 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);
|
||||
// Views the host asked to hide (e.g. Screenshots, Documents). A row is dropped
|
||||
// when its `view` is listed; a section label is dropped when every row under it
|
||||
// would be hidden.
|
||||
const hiddenViews = useGallery((s) => s.config.hiddenViews);
|
||||
const hidden = new Set<ViewId>(hiddenViews ?? []);
|
||||
const shown = (view: ViewId) => !hidden.has(view);
|
||||
const anyShown = (...views: ViewId[]) => views.some(shown);
|
||||
// Recently Deleted lock state → closed lock when protected & not yet opened.
|
||||
const locked = useGallery((s) => s.lockConfigured && !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.lockConfigured) 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),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Right-click an auto object album → permanently rename its tag (e.g. car → excavator).
|
||||
const objectMenu = (album: Album) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const label = album.id.slice('sys:obj:'.length);
|
||||
openContextMenu(e.clientX, e.clientY, [
|
||||
{
|
||||
label: 'Rename Tag',
|
||||
icon: 'tag',
|
||||
onClick: () =>
|
||||
promptAlbumName(
|
||||
'Rename Tag',
|
||||
album.name,
|
||||
(name) => api.getState().renameLabel(label, name),
|
||||
{ placeholder: 'Tag name' },
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Delete Tag',
|
||||
icon: 'trash',
|
||||
danger: true,
|
||||
onClick: () =>
|
||||
confirmAction({
|
||||
title: 'Delete Tag',
|
||||
message: `Remove the "${album.name}" tag? It's deleted from all photos and won't be created again.`,
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
onConfirm: () => api.getState().deleteLabel(label),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
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" />
|
||||
|
||||
{anyShown(
|
||||
'favourites',
|
||||
'recently-saved',
|
||||
'map',
|
||||
'videos',
|
||||
'screenshots',
|
||||
'sys:documents',
|
||||
'people',
|
||||
'recently-deleted',
|
||||
) ? (
|
||||
<SectionLabel>Pinned</SectionLabel>
|
||||
) : null}
|
||||
{shown('favourites') ? <Row icon="heart" label="Favourites" view="favourites" /> : null}
|
||||
{shown('recently-saved') ? (
|
||||
<Row icon="download" label="Recently Saved" view="recently-saved" />
|
||||
) : null}
|
||||
{shown('map') ? <Row icon="map" label="Map" view="map" /> : null}
|
||||
{shown('videos') ? <Row icon="video" label="Videos" view="videos" /> : null}
|
||||
{shown('screenshots') ? (
|
||||
<Row icon="screenshot" label="Screenshots" view="screenshots" />
|
||||
) : null}
|
||||
{shown('sys:documents') ? (
|
||||
<Row icon="document" label="Documents" view="sys:documents" />
|
||||
) : null}
|
||||
{shown('people') ? <Row icon="person-circle" label="People" view="people" /> : null}
|
||||
{shown('recently-deleted') ? (
|
||||
<Row
|
||||
icon="trash"
|
||||
label="Recently Deleted"
|
||||
view="recently-deleted"
|
||||
trailing={lockIcon}
|
||||
onTrailingClick={toggleLock}
|
||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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
|
||||
onContextMenu={objectMenu(a)}
|
||||
/>
|
||||
))
|
||||
: 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}
|
||||
|
||||
{anyShown('recently-deleted', 'duplicates', 'versions', 'map') ? (
|
||||
<SectionLabel>Utilities</SectionLabel>
|
||||
) : null}
|
||||
{shown('recently-deleted') ? (
|
||||
<Row
|
||||
icon="trash"
|
||||
label="Recently Deleted"
|
||||
view="recently-deleted"
|
||||
trailing={lockIcon}
|
||||
noActive
|
||||
onTrailingClick={toggleLock}
|
||||
trailingLabel={locked ? 'Unlock Recently Deleted' : 'Lock Recently Deleted'}
|
||||
/>
|
||||
) : null}
|
||||
{shown('duplicates') ? <Row icon="duplicates" label="Duplicates" view="duplicates" /> : null}
|
||||
{shown('versions') ? <Row icon="clock" label="Versions & Audit" view="versions" /> : null}
|
||||
{shown('map') ? <Row icon="map" label="Map" view="map" noActive /> : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
'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 SCALE_OPTIONS: Array<{ value: LibraryScale; label: string }> = [
|
||||
{ value: 'years', label: 'Years' },
|
||||
{ value: 'months', label: 'Months' },
|
||||
{ value: 'all', label: 'All Photos' },
|
||||
];
|
||||
|
||||
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);
|
||||
// A host that owns light/dark must not be overridden from inside the gallery.
|
||||
const showThemeSwitcher = useGallery((s) => s.config.chrome.themeSwitcher);
|
||||
const aiStatus = useGallery((s) => s.aiStatus);
|
||||
const infoOpen = useGallery((s) => s.infoOpen);
|
||||
const fullscreen = useGallery((s) => s.fullscreen);
|
||||
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),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
// Library-scale as a compact dropdown (replaces the wide 3-item segmented that
|
||||
// overlapped the zoom-out button on narrow toolbars). Reuses the `.apg-menu`
|
||||
// popover, which supplies the menu role + checkmarks + arrow-key navigation.
|
||||
const scaleLabel = SCALE_OPTIONS.find((o) => o.value === libraryScale)?.label ?? 'All Photos';
|
||||
const openScaleMenu = (e: React.MouseEvent) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
openContextMenu(
|
||||
r.left,
|
||||
r.bottom + 4,
|
||||
SCALE_OPTIONS.map((o) => ({
|
||||
label: o.label,
|
||||
checked: libraryScale === o.value,
|
||||
onClick: () => api.getState().setLibraryScale(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) }]
|
||||
: []),
|
||||
...(showThemeSwitcher
|
||||
? [
|
||||
{ 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.lockConfigured ? 'Recently Deleted Lock…' : 'Lock Recently Deleted…',
|
||||
icon: state.lockConfigured ? '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 control (context dependent). Flanked by two flex spacers so it centres on
|
||||
wide toolbars and simply sits in flow (never overlapping the right group) when the
|
||||
toolbar is narrow — e.g. embedded, where the host + gallery sidebars eat the width. */}
|
||||
<div className="apg-toolbar__spacer" />
|
||||
<div className="apg-toolbar__center">
|
||||
{view === 'library' || view === 'search' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-scalemenu"
|
||||
aria-haspopup="menu"
|
||||
aria-label={`Library scale: ${scaleLabel}`}
|
||||
onClick={openScaleMenu}
|
||||
>
|
||||
<span className="apg-scalemenu__label">{scaleLabel}</span>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
) : 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', fullscreen ? 'apg-iconbtn--on' : ''].join(' ')}
|
||||
aria-label={fullscreen ? 'Exit full screen' : 'Enter full screen'}
|
||||
title={fullscreen ? 'Exit full screen' : 'Enter full screen'}
|
||||
aria-pressed={fullscreen}
|
||||
onClick={() => api.getState().toggleFullscreen()}
|
||||
>
|
||||
<Icon name={fullscreen ? 'minimize' : 'maximize'} />
|
||||
</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"
|
||||
// Discoverability only: `searchMedia` already matches objectLabels,
|
||||
// OCR text, tags, captions and place names.
|
||||
placeholder="Search photos, objects, text…"
|
||||
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,171 @@
|
||||
'use client';
|
||||
|
||||
import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '../../icons';
|
||||
|
||||
/**
|
||||
* Full-screen brush overlay for masked AI edits (Magic Eraser / Generative Fill).
|
||||
* The user paints over a region; on Apply we emit a binary ImageData mask where
|
||||
* painted pixels are WHITE (regenerate) and everything else is BLACK (keep) —
|
||||
* exactly what the inpaint backend expects (see maskToBase64 / rpInpaint).
|
||||
*
|
||||
* The paint canvas is sized to the image's aspect ratio (long side = BASE), then
|
||||
* scaled with CSS to fit the viewport, so the returned mask lines up with the
|
||||
* photo regardless of screen size.
|
||||
*/
|
||||
const BASE = 640;
|
||||
|
||||
export function MaskBrush({
|
||||
src,
|
||||
aspect,
|
||||
title,
|
||||
onCancel,
|
||||
onApply,
|
||||
}: {
|
||||
src: string;
|
||||
aspect: number; // width / height
|
||||
title: string;
|
||||
onCancel: () => void;
|
||||
onApply: (mask: ImageData) => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const paintingRef = useRef(false);
|
||||
const lastRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const [brush, setBrush] = useState(48);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const cw = aspect >= 1 ? BASE : Math.round(BASE * aspect);
|
||||
const ch = aspect >= 1 ? Math.round(BASE / aspect) : BASE;
|
||||
|
||||
// Escape cancels.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
|
||||
|
||||
const toCanvas = (e: ReactPointerEvent) => {
|
||||
const c = canvasRef.current;
|
||||
if (!c) return { x: 0, y: 0 };
|
||||
const r = c.getBoundingClientRect();
|
||||
return {
|
||||
x: ((e.clientX - r.left) / r.width) * c.width,
|
||||
y: ((e.clientY - r.top) / r.height) * c.height,
|
||||
};
|
||||
};
|
||||
|
||||
const paintTo = (x: number, y: number) => {
|
||||
const c = ctx();
|
||||
if (!c) return;
|
||||
c.fillStyle = 'rgba(255,60,60,0.55)';
|
||||
c.strokeStyle = 'rgba(255,60,60,0.55)';
|
||||
c.lineWidth = brush;
|
||||
c.lineCap = 'round';
|
||||
const last = lastRef.current;
|
||||
if (last) {
|
||||
c.beginPath();
|
||||
c.moveTo(last.x, last.y);
|
||||
c.lineTo(x, y);
|
||||
c.stroke();
|
||||
}
|
||||
c.beginPath();
|
||||
c.arc(x, y, brush / 2, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
lastRef.current = { x, y };
|
||||
};
|
||||
|
||||
const onDown = (e: ReactPointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
paintingRef.current = true;
|
||||
lastRef.current = null;
|
||||
const p = toCanvas(e);
|
||||
paintTo(p.x, p.y);
|
||||
setDirty(true);
|
||||
};
|
||||
const onMove = (e: ReactPointerEvent) => {
|
||||
if (!paintingRef.current) return;
|
||||
const p = toCanvas(e);
|
||||
paintTo(p.x, p.y);
|
||||
};
|
||||
const onUp = () => {
|
||||
paintingRef.current = false;
|
||||
lastRef.current = null;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
const c = ctx();
|
||||
if (c && canvasRef.current) c.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const c = ctx();
|
||||
const canvas = canvasRef.current;
|
||||
if (!c || !canvas) return;
|
||||
const painted = c.getImageData(0, 0, canvas.width, canvas.height);
|
||||
// Binarize: any painted (alpha) pixel → opaque white, else opaque black.
|
||||
const out = new ImageData(canvas.width, canvas.height);
|
||||
for (let i = 0; i < painted.data.length; i += 4) {
|
||||
const on = painted.data[i + 3]! > 10;
|
||||
const v = on ? 255 : 0;
|
||||
out.data[i] = v;
|
||||
out.data[i + 1] = v;
|
||||
out.data[i + 2] = v;
|
||||
out.data[i + 3] = 255;
|
||||
}
|
||||
onApply(out);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="apg-maskbrush" role="dialog" aria-label={title}>
|
||||
<div className="apg-maskbrush__title">{title}</div>
|
||||
<div className="apg-maskbrush__stage" style={{ aspectRatio: `${cw} / ${ch}` }}>
|
||||
<img className="apg-maskbrush__img" src={src} alt="" draggable={false} />
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={cw}
|
||||
height={ch}
|
||||
className="apg-maskbrush__canvas"
|
||||
onPointerDown={onDown}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
onPointerLeave={onUp}
|
||||
/>
|
||||
</div>
|
||||
<div className="apg-maskbrush__bar">
|
||||
<label className="apg-maskbrush__brush">
|
||||
Brush
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={120}
|
||||
step={2}
|
||||
value={brush}
|
||||
onChange={(e) => setBrush(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="apg-btn apg-btn--small" onClick={clear} disabled={!dirty}>
|
||||
<Icon name="trash" size={14} /> Clear
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button type="button" className="apg-btn apg-btn--small" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary apg-btn--small"
|
||||
onClick={apply}
|
||||
disabled={!dirty}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
'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';
|
||||
import { MaskBrush } from './MaskBrush';
|
||||
import { VoiceButton } from './VoiceButton';
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
/** Ops whose result varies with the "edit strength" slider (the backend maps it per model). */
|
||||
const STRENGTH_OPS = new Set<GenerativeEditOp['type']>([
|
||||
'prompt',
|
||||
'replace-sky',
|
||||
'magic-eraser',
|
||||
'generative-fill',
|
||||
]);
|
||||
|
||||
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 [aiStrength, setAiStrength] = useState(0.5);
|
||||
const [maskMode, setMaskMode] = useState<'magic-eraser' | 'generative-fill' | null>(null);
|
||||
const [tiltBusy, setTiltBusy] = useState(false);
|
||||
const [tiltError, setTiltError] = useState<string | null>(null);
|
||||
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);
|
||||
// Attach the current "edit strength" to ops that support it.
|
||||
const opToRun = STRENGTH_OPS.has(op.type)
|
||||
? ({ ...op, strength: aiStrength } as GenerativeEditOp)
|
||||
: op;
|
||||
const blob = await provider.generativeEdit(item, img, opToRun);
|
||||
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 autoStraighten = async () => {
|
||||
if (!provider?.estimateTilt) return;
|
||||
setTiltBusy(true);
|
||||
setTiltError(null);
|
||||
try {
|
||||
const img = await loadCrossOriginImage(item.src);
|
||||
const t = await provider.estimateTilt(item, img);
|
||||
const roll = Math.max(-45, Math.min(45, Math.round(t.rollDegrees)));
|
||||
setEdits((e) => ({ ...e, straighten: roll }));
|
||||
} catch (err) {
|
||||
setTiltError(err instanceof Error ? err.message : 'Could not estimate tilt.');
|
||||
} finally {
|
||||
setTiltBusy(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…</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}
|
||||
{provider?.estimateTilt ? (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-editor__tab"
|
||||
disabled={tiltBusy}
|
||||
onClick={() => void autoStraighten()}
|
||||
>
|
||||
<Icon name="wand" size={15} />{' '}
|
||||
{tiltBusy ? 'Analyzing tilt…' : 'Auto-straighten (fix camera tilt)'}
|
||||
</button>
|
||||
) : null}
|
||||
{tiltError ? (
|
||||
<p style={{ color: '#ff6b6b', fontSize: 12, margin: 0 }}>{tiltError}</p>
|
||||
) : 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>
|
||||
{provider?.transcribeAudio ? (
|
||||
<VoiceButton
|
||||
label="Speak → add text"
|
||||
onText={(t) => {
|
||||
setAnnotations([
|
||||
...annotations,
|
||||
{
|
||||
id: nanoid(8),
|
||||
shape: 'text',
|
||||
color: annColor,
|
||||
strokeWidth: 2,
|
||||
x1: 0.08,
|
||||
y1: 0.08,
|
||||
x2: 0.55,
|
||||
y2: 0.17,
|
||||
text: t,
|
||||
},
|
||||
]);
|
||||
setAnnTool('select');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<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 run through your configured AI backend. 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 className="apg-slider-row" style={{ marginTop: 6 }}>
|
||||
<div className="apg-slider-row__head">
|
||||
<span>Edit strength</span>
|
||||
<span>{Math.round(aiStrength * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
className="apg-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={aiStrength}
|
||||
disabled={aiBusy}
|
||||
onChange={(e) => setAiStrength(Number(e.target.value))}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: '#9b9ba1',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<span>Subtle</span>
|
||||
<span>Strong</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div style={{ height: 1, background: 'rgba(255,255,255,0.1)', margin: '4px 0' }} />
|
||||
<p style={{ color: '#9b9ba1', fontSize: 12, margin: 0 }}>
|
||||
Brush & expand tools — inpaint / outpaint.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-editor__tab"
|
||||
disabled={aiBusy}
|
||||
onClick={() => setMaskMode('magic-eraser')}
|
||||
>
|
||||
<Icon name="wand" size={16} /> Magic Eraser (remove an object)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-editor__tab"
|
||||
disabled={aiBusy}
|
||||
title="Paint an area, then it fills it — type a prompt above to control what appears (optional)"
|
||||
onClick={() => setMaskMode('generative-fill')}
|
||||
>
|
||||
<Icon name="image" size={16} /> Generative Fill (paint + prompt)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-editor__tab"
|
||||
disabled={aiBusy}
|
||||
onClick={() => void runAI({ type: 'outpaint', prompt: aiPrompt.trim() || undefined })}
|
||||
>
|
||||
<Icon name="crop" size={16} /> Expand Image (Outpaint)
|
||||
</button>
|
||||
|
||||
{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>
|
||||
|
||||
{maskMode ? (
|
||||
<MaskBrush
|
||||
src={item.src}
|
||||
aspect={imgAspect}
|
||||
title={
|
||||
maskMode === 'magic-eraser'
|
||||
? 'Paint over what to remove'
|
||||
: 'Paint the area to replace (uses your prompt)'
|
||||
}
|
||||
onCancel={() => setMaskMode(null)}
|
||||
onApply={(mask) => {
|
||||
const m = maskMode;
|
||||
setMaskMode(null);
|
||||
if (m === 'generative-fill') {
|
||||
void runAI({ type: 'generative-fill', prompt: aiPrompt.trim() || 'fill naturally', mask });
|
||||
} else {
|
||||
void runAI({ type: 'magic-eraser', mask });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</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,999 @@
|
||||
'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 { blobToWavBase64, wavBase64ToBlob } from '../../lib/audioCapture';
|
||||
import { useAIProvider } from '../aiContext';
|
||||
import { VoiceButton } from './VoiceButton';
|
||||
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 [playing, setPlaying] = useState(false);
|
||||
const [selOverlay, setSelOverlay] = useState<string | null>(null);
|
||||
const provider = useAIProvider();
|
||||
const [denoiseBusy, setDenoiseBusy] = useState(false);
|
||||
const [denoiseErr, setDenoiseErr] = 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;
|
||||
};
|
||||
// The preview <video> has no native controls (they would rotate with the frame),
|
||||
// so the custom control bar drives it — keep its play/pause icon in sync here.
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
v.addEventListener('timeupdate', onTime);
|
||||
v.addEventListener('play', onPlay);
|
||||
v.addEventListener('pause', onPause);
|
||||
return () => {
|
||||
v.removeEventListener('timeupdate', onTime);
|
||||
v.removeEventListener('play', onPlay);
|
||||
v.removeEventListener('pause', onPause);
|
||||
};
|
||||
}, [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 runVideoDenoise = async () => {
|
||||
if (!provider?.denoiseAudio) return;
|
||||
setDenoiseBusy(true);
|
||||
setDenoiseErr(null);
|
||||
try {
|
||||
const resp = await fetch(item.src);
|
||||
const blob = await resp.blob();
|
||||
// Decode the video's audio track → 48 kHz mono WAV → RunPod denoise → clean WAV.
|
||||
const wav48 = await blobToWavBase64(blob, 48000);
|
||||
const cleaned = await provider.denoiseAudio(wav48);
|
||||
const url = URL.createObjectURL(wavBase64ToBlob(cleaned));
|
||||
update({ audio: { ...edits.audio, denoisedSrc: url } });
|
||||
} catch (e) {
|
||||
setDenoiseErr(
|
||||
e instanceof Error ? e.message : 'Could not clean the audio (keep clips under ~30s).',
|
||||
);
|
||||
} finally {
|
||||
setDenoiseBusy(false);
|
||||
}
|
||||
};
|
||||
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);
|
||||
// Live preview for Crop & Rotate. videoBake applies these on export; without
|
||||
// mirroring them on the preview the Rotate/Flip/Crop buttons look like they do
|
||||
// nothing. Quarter-turn rotations are scaled to fit the frame.
|
||||
const pvRot = (((edits.rotation ?? 0) % 360) + 360) % 360;
|
||||
const pvQuarter = pvRot === 90 || pvRot === 270;
|
||||
const pvAspect = (item.width || 16) / (item.height || 9);
|
||||
const pvFit = pvQuarter ? Math.min(pvAspect, 1 / pvAspect) : 1;
|
||||
const previewTransform =
|
||||
pvRot || edits.flipH || edits.flipV
|
||||
? `rotate(${pvRot}deg) scale(${(edits.flipH ? -1 : 1) * pvFit}, ${(edits.flipV ? -1 : 1) * pvFit})`
|
||||
: undefined;
|
||||
const pvCrop = edits.crop;
|
||||
const previewClip = pvCrop
|
||||
? `inset(${(pvCrop.y * 100).toFixed(3)}% ${((1 - pvCrop.x - pvCrop.width) * 100).toFixed(3)}% ${((1 - pvCrop.y - pvCrop.height) * 100).toFixed(3)}% ${(pvCrop.x * 100).toFixed(3)}%)`
|
||||
: undefined;
|
||||
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}
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
onClick={() => {
|
||||
const v = videoRef.current;
|
||||
if (v) (v.paused ? v.play() : v.pause());
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh',
|
||||
display: 'block',
|
||||
filter: filterCss || undefined,
|
||||
transform: previewTransform,
|
||||
clipPath: previewClip,
|
||||
transition: 'transform 0.15s ease',
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Custom transport — OUTSIDE the transformed <video>, so rotate/flip/crop
|
||||
only affect the frame, never the controls (the old native `controls`
|
||||
bar rotated with the video, which looked broken). */}
|
||||
<div className="apg-vedit__transport">
|
||||
<button
|
||||
type="button"
|
||||
className="apg-iconbtn"
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
onClick={() => {
|
||||
const v = videoRef.current;
|
||||
if (v) (v.paused ? v.play() : v.pause());
|
||||
}}
|
||||
>
|
||||
<Icon name={playing ? 'pause' : 'play'} size={18} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="apg-vedit__scrub"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.01}
|
||||
value={Math.min(playhead, duration || 0)}
|
||||
aria-label="Seek"
|
||||
onChange={(e) => {
|
||||
const v = videoRef.current;
|
||||
if (v) v.currentTime = Number(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="apg-vedit__time">
|
||||
{fmt(playhead)} / {fmt(duration)}
|
||||
</span>
|
||||
</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>
|
||||
<VoiceButton
|
||||
label="Speak → text"
|
||||
onText={(t) => {
|
||||
const cur = sel.text && sel.text !== 'Your text' ? sel.text : '';
|
||||
patchOverlay(sel.id, { text: cur ? `${cur} ${t}` : t });
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
{provider?.denoiseAudio ? (
|
||||
edits.audio?.denoisedSrc ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<span style={{ color: '#34c759' }}>✓ Background noise reduced (AI)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--small"
|
||||
onClick={() => update({ audio: { ...edits.audio, denoisedSrc: undefined } })}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
disabled={denoiseBusy}
|
||||
onClick={() => void runVideoDenoise()}
|
||||
>
|
||||
<Icon name="wand" size={14} />{' '}
|
||||
{denoiseBusy ? 'Cleaning audio…' : 'Reduce background noise (AI)'}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{denoiseErr ? (
|
||||
<p style={{ color: '#ff6b6b', fontSize: 12, margin: 0 }}>{denoiseErr}</p>
|
||||
) : null}
|
||||
{!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,86 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { blobToWavBase64, startRecording, wavBase64ToBlob, type Recorder } from '../../lib/audioCapture';
|
||||
import { Icon } from '../../icons';
|
||||
import { useAIProvider } from '../aiContext';
|
||||
|
||||
/**
|
||||
* Reusable dictation button: record → (optional AI denoise) → transcribe → onText.
|
||||
* Renders nothing if the AI provider can't transcribe. Used by the image-markup
|
||||
* Text tool, the video Text overlay, and the Info comment box.
|
||||
*/
|
||||
export function VoiceButton({
|
||||
onText,
|
||||
denoise = false,
|
||||
label = 'Speak',
|
||||
size = 14,
|
||||
}: {
|
||||
onText: (text: string) => void;
|
||||
denoise?: boolean;
|
||||
label?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const provider = useAIProvider();
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const recRef = useRef<Recorder | null>(null);
|
||||
|
||||
if (!provider?.transcribeAudio) return null;
|
||||
|
||||
const start = async () => {
|
||||
setStatus(null);
|
||||
try {
|
||||
recRef.current = await startRecording();
|
||||
setRecording(true);
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : 'Microphone unavailable.');
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
const rec = recRef.current;
|
||||
recRef.current = null;
|
||||
setRecording(false);
|
||||
if (!rec || !provider.transcribeAudio) return;
|
||||
try {
|
||||
const blob = await rec.stop();
|
||||
let wav: string;
|
||||
if (denoise && provider.denoiseAudio) {
|
||||
setStatus('Reducing noise…');
|
||||
const w48 = await blobToWavBase64(blob, 48000);
|
||||
const cleaned = await provider.denoiseAudio(w48);
|
||||
wav = await blobToWavBase64(wavBase64ToBlob(cleaned), 16000);
|
||||
} else {
|
||||
wav = await blobToWavBase64(blob, 16000);
|
||||
}
|
||||
setStatus('Transcribing…');
|
||||
const t = (await provider.transcribeAudio(wav)).trim();
|
||||
if (t) onText(t);
|
||||
setStatus(null);
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : 'Could not transcribe.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="apg-voice">
|
||||
<button
|
||||
type="button"
|
||||
className={`apg-btn apg-btn--small apg-voice__mic${recording ? ' apg-voice__mic--rec' : ''}`}
|
||||
onClick={recording ? stop : start}
|
||||
aria-label={recording ? 'Stop recording' : 'Dictate text'}
|
||||
title={recording ? 'Stop & transcribe' : 'Speak to type'}
|
||||
>
|
||||
<Icon name={recording ? 'check' : 'mic'} size={size} />
|
||||
{recording ? 'Stop' : label}
|
||||
</button>
|
||||
{recording || status ? (
|
||||
<span className="apg-voice__status" aria-live="polite">
|
||||
{recording ? '● Listening…' : status}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
'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.lockConfigured);
|
||||
// A server-backed lock reads/writes the host's backend, not this device.
|
||||
const serverBacked = useGallery((s) => Boolean(s.config.lockProvider));
|
||||
const [p1, setP1] = useState('');
|
||||
const [p2, setP2] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
if (p1.trim().length < 4) return setErr('Use at least 4 characters.');
|
||||
if (p1 !== p2) return setErr('Passwords do not match.');
|
||||
setBusy(true);
|
||||
const state = api.getState();
|
||||
state.clearLockError();
|
||||
await state.setLockPassword(p1.trim());
|
||||
setBusy(false);
|
||||
// The store reports an unreachable provider rather than throwing.
|
||||
if (api.getState().lockError === 'unavailable') {
|
||||
setErr("Couldn't reach the server. Try again.");
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
const state = api.getState();
|
||||
state.clearLockError();
|
||||
await state.removeLockPassword();
|
||||
setBusy(false);
|
||||
if (api.getState().lockError === 'unavailable') {
|
||||
setErr("Couldn't reach the server. Try again.");
|
||||
return;
|
||||
}
|
||||
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 }}>
|
||||
{serverBacked
|
||||
? hasPw
|
||||
? 'Set a new password, or remove the lock. It applies to your account on every device.'
|
||||
: 'Protect Recently Deleted with a password. It applies to your account on every device.'
|
||||
: 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)' }}
|
||||
disabled={busy}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
Remove Lock
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="apg-btn" onClick={closeModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
disabled={busy}
|
||||
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,246 @@
|
||||
'use client';
|
||||
|
||||
import { formatDay } from '../../lib/format';
|
||||
import { groupByTime } from '../../lib/grouping';
|
||||
import { resolveLabel } from '../../lib/smartAlbums';
|
||||
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 { openContextMenu } from '../ContextMenu';
|
||||
import { confirmAction, 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 labelAliases = useGallery((s) => s.labelAliases);
|
||||
const live = liveMedia(media);
|
||||
|
||||
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 objectEntries = [...objectLabelCounts(media, labelAliases).entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 14);
|
||||
|
||||
// Right-click an object card → permanently rename its tag (car → excavator).
|
||||
const renameTag = (label: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, [
|
||||
{
|
||||
label: 'Rename Tag',
|
||||
icon: 'tag',
|
||||
onClick: () =>
|
||||
promptAlbumName('Rename Tag', label, (name) => api.getState().renameLabel(label, name), {
|
||||
placeholder: 'Tag name',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Delete Tag',
|
||||
icon: 'trash',
|
||||
danger: true,
|
||||
onClick: () =>
|
||||
confirmAction({
|
||||
title: 'Delete Tag',
|
||||
message: `Remove the "${label}" tag? It's deleted from all photos and won't be created again.`,
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
onConfirm: () => api.getState().deleteLabel(label),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="apg-scroll">
|
||||
<div className="apg-collections">
|
||||
{/* 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)}
|
||||
onContextMenu={renameTag(label)}
|
||||
aria-label={`${count} photos containing ${label}`}
|
||||
>
|
||||
{(() => {
|
||||
// Match on the resolved label so the cover works for items still
|
||||
// stored under the original detector label.
|
||||
const cover = live.find((m) =>
|
||||
m.objectLabels.some((l) => resolveLabel(l, labelAliases) === 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}
|
||||
|
||||
{/* 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>
|
||||
|
||||
</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,161 @@
|
||||
'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 }) {
|
||||
// Host-neutral copy: the SDK is embedded in other products (see `title`), so the
|
||||
// empty state must not name a specific photo app or reference its settings.
|
||||
const hints: Array<{ icon: IconName; text: string }> = [
|
||||
{ icon: 'download', text: 'Click the + button to import.' },
|
||||
{ icon: 'duplicates', text: 'Drag photos and videos straight in.' },
|
||||
{ icon: 'camera', text: 'Capture a shot with your camera.' },
|
||||
{ icon: 'image', text: 'Everything you add is searchable.' },
|
||||
];
|
||||
const title = useGallery((s) => s.config.title);
|
||||
return (
|
||||
<div className="apg-empty" onClick={onImport} role="button" tabIndex={0}>
|
||||
<div className="apg-empty__title">Welcome to {title}</div>
|
||||
<div className="apg-empty__subtitle">To get started, 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,733 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, 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,
|
||||
searchMedia,
|
||||
} from '../../store/selectors';
|
||||
import type { MediaItem } from '../../types';
|
||||
import { MediaGrid } from '../MediaGrid';
|
||||
import { MosaicGrid } from './MosaicGrid';
|
||||
|
||||
/** Thumbnails shown in a multi-photo pin's hover strip before the "+N" chip. */
|
||||
const TIP_STRIP_MAX = 5;
|
||||
/** How long each frame of the hover mini-slider stays up. */
|
||||
const TIP_FRAME_MS = 900;
|
||||
|
||||
/** Parse a `<input type="date">` value into a local-midnight epoch, or null. */
|
||||
function parseDateInput(value: string, endOfDay: boolean): number | null {
|
||||
if (!value) return null;
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) return null;
|
||||
return endOfDay
|
||||
? new Date(y, m - 1, d, 23, 59, 59, 999).getTime()
|
||||
: new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
|
||||
}
|
||||
|
||||
/** Format a Date to a `<input type="date">` value (local `YYYY-MM-DD`). */
|
||||
function toInputDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Short human label for one `YYYY-MM-DD` value. */
|
||||
function labelDate(value: string): string {
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
if (!y || !m || !d) return '';
|
||||
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Button label for the current range ("All dates" when unset). */
|
||||
function rangeLabel(from: string, to: string): string {
|
||||
if (!from && !to) return 'All dates';
|
||||
if (from && to) return `${labelDate(from)} – ${labelDate(to)}`;
|
||||
if (from) return `From ${labelDate(from)}`;
|
||||
return `Until ${labelDate(to)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single, polished "date range" control: a button showing the current range
|
||||
* that opens a popover of quick presets + a custom From/To pair. It drives the
|
||||
* SAME from/to strings the MapView already filters by — so search/object chips,
|
||||
* the live count, and Clear all keep working unchanged.
|
||||
*/
|
||||
function DateRangeControl({
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
window.addEventListener('pointerdown', onDown, true);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', onDown, true);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const today = new Date();
|
||||
const daysAgo = (n: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(today.getDate() - n);
|
||||
return d;
|
||||
};
|
||||
const presets: Array<{ label: string; from: string; to: string }> = [
|
||||
{ label: 'All dates', from: '', to: '' },
|
||||
{ label: 'Last 7 days', from: toInputDate(daysAgo(6)), to: toInputDate(today) },
|
||||
{ label: 'Last 30 days', from: toInputDate(daysAgo(29)), to: toInputDate(today) },
|
||||
{
|
||||
label: 'This year',
|
||||
from: toInputDate(new Date(today.getFullYear(), 0, 1)),
|
||||
to: toInputDate(today),
|
||||
},
|
||||
];
|
||||
|
||||
const active = Boolean(from || to);
|
||||
|
||||
return (
|
||||
<div className="apg-daterange" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={['apg-daterange__button', active ? 'apg-daterange__button--active' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="clock" size={14} />
|
||||
<span className="apg-daterange__label">{rangeLabel(from, to)}</span>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="apg-daterange__pop" role="dialog" aria-label="Choose a date range">
|
||||
<div className="apg-daterange__presets">
|
||||
{presets.map((p) => {
|
||||
const on = p.from === from && p.to === to;
|
||||
return (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
className={['apg-daterange__preset', on ? 'apg-daterange__preset--on' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
onChange(p.from, p.to);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
{on ? <Icon name="check" size={14} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="apg-daterange__custom">
|
||||
<div className="apg-daterange__custom-label">Custom range</div>
|
||||
<div className="apg-daterange__fields">
|
||||
<label className="apg-daterange__field">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => onChange(e.target.value, to)}
|
||||
/>
|
||||
</label>
|
||||
<label className="apg-daterange__field">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => onChange(from, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// ---- sheet filters (search + date range + object chips), AND-combined ----
|
||||
const [query, setQuery] = useState('');
|
||||
const [fromDate, setFromDate] = useState('');
|
||||
const [toDate, setToDate] = useState('');
|
||||
const [objectFilter, setObjectFilter] = useState<string | null>(null);
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery('');
|
||||
setFromDate('');
|
||||
setToDate('');
|
||||
setObjectFilter(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);
|
||||
// Every hover-slider interval currently running, so teardown can kill them all
|
||||
// (a leaked interval keeps mutating detached DOM forever).
|
||||
const tipTimersRef = useRef(new Set<ReturnType<typeof setInterval>>());
|
||||
const clearTipTimers = () => {
|
||||
for (const t of tipTimersRef.current) clearInterval(t);
|
||||
tipTimersRef.current.clear();
|
||||
};
|
||||
// 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;
|
||||
// Current sheet height in px, read by the fly-to offset (the sheet covers the
|
||||
// bottom of the map, so the pin must end up above it).
|
||||
const sheetHRef = useRef(sheetH);
|
||||
sheetHRef.current = sheetH;
|
||||
|
||||
/** Zoom to the pin, keeping it clear of the sheet, then open the sheet. */
|
||||
const openClusterRef = useRef<(c: LocationCluster) => void>(() => {});
|
||||
openClusterRef.current = (c) => {
|
||||
const map = mapRef.current;
|
||||
if (map) {
|
||||
const current = map.getZoom?.() ?? 2;
|
||||
// Never zoom OUT: a single photo warrants street level, a cluster stays wide
|
||||
// enough that its members remain distinguishable.
|
||||
const target = Math.max(current, c.items.length > 1 ? 12 : 14);
|
||||
const size = map.getSize?.();
|
||||
const mapH: number = size?.y ?? 0;
|
||||
// The sheet is about to occupy the bottom `sheetH` of the map, so shift the
|
||||
// centre up by half of that — the pin then sits in the visible upper band.
|
||||
const sheetPx = mapH * 0.5; // the sheet always (re)opens at half height
|
||||
map.flyTo([c.lat, c.lng], target, { animate: true, duration: 0.6 });
|
||||
if (sheetPx > 0) {
|
||||
// panBy after the fly settles, else Leaflet cancels the in-flight animation.
|
||||
// Leaflet's panBy moves the map pane BY the offset, so content shifts the
|
||||
// opposite way: a POSITIVE y lifts the pin toward the top of the map,
|
||||
// which is the direction that gets it clear of the sheet.
|
||||
map.once('moveend', () => map.panBy([0, sheetPx / 2], { animate: true }));
|
||||
}
|
||||
}
|
||||
setCluster(c);
|
||||
setSheetH(0.5); // reset to half height each time a pin is opened
|
||||
resetFilters(); // a new pin starts with a clean filter
|
||||
};
|
||||
|
||||
// Opening one photo from a pin's hover strip.
|
||||
const openLightboxRef = useRef<(id: string) => void>(() => {});
|
||||
openLightboxRef.current = (id) => api.getState().openLightbox(id);
|
||||
|
||||
// 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) {
|
||||
clearTipTimers();
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileRef.current = null;
|
||||
markersRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapMode]);
|
||||
|
||||
// Destroy on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTipTimers();
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileRef.current = null;
|
||||
markersRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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);
|
||||
// Every marker is about to be destroyed — kill their hover timers first.
|
||||
clearTipTimers();
|
||||
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]!;
|
||||
const multi = c.items.length > 1;
|
||||
// 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: one preview for a single photo, an auto-advancing strip
|
||||
// of thumbnails for a cluster.
|
||||
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__strip"></span>' +
|
||||
'<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;
|
||||
const strip = el.querySelector('.apg-pin__strip') as HTMLElement | null;
|
||||
const cap = el.querySelector('.apg-pin__tip-cap') as HTMLElement | null;
|
||||
|
||||
// Keep the hover tooltip inside the map: on enter, measure it against the map
|
||||
// container and slide it horizontally (--tip-dx) / flip it below (--below) so it
|
||||
// never overflows and gets clipped by the gallery's rounded, overflow-hidden shell.
|
||||
const pinEl = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||
const tipEl = el.querySelector('.apg-pin__tip') as HTMLElement | null;
|
||||
const clampTip = () => {
|
||||
if (!tipEl) return;
|
||||
const mapEl = el.closest('.leaflet-container') as HTMLElement | null;
|
||||
if (!mapEl) return;
|
||||
tipEl.style.setProperty('--tip-dx', '0px');
|
||||
tipEl.classList.remove('apg-pin__tip--below');
|
||||
const m = mapEl.getBoundingClientRect();
|
||||
const t = tipEl.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
let dx = 0;
|
||||
if (t.left < m.left + pad) dx = m.left + pad - t.left;
|
||||
else if (t.right > m.right - pad) dx = m.right - pad - t.right;
|
||||
if (dx) tipEl.style.setProperty('--tip-dx', `${Math.round(dx)}px`);
|
||||
if (t.top < m.top + pad) tipEl.classList.add('apg-pin__tip--below');
|
||||
};
|
||||
pinEl?.addEventListener('mouseenter', clampTip);
|
||||
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) {
|
||||
// The badge always carries the cluster's TOTAL, and only appears when
|
||||
// there is genuinely more than one photo here.
|
||||
badge.textContent = String(c.items.length);
|
||||
badge.style.display = multi ? '' : 'none';
|
||||
}
|
||||
|
||||
if (!multi || !strip) {
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- multi-photo pin: horizontal strip + auto-advancing preview ----
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
const shown = c.items.slice(0, TIP_STRIP_MAX);
|
||||
const cells: HTMLElement[] = [];
|
||||
for (const item of shown) {
|
||||
const cell = document.createElement('button');
|
||||
cell.type = 'button';
|
||||
cell.className = 'apg-pin__strip-cell';
|
||||
cell.title = item.name;
|
||||
const thumbEl = document.createElement('img');
|
||||
thumbEl.alt = '';
|
||||
thumbEl.src = item.thumbnail ?? item.src;
|
||||
cell.appendChild(thumbEl);
|
||||
// Opening a specific photo must not also open the cluster sheet.
|
||||
cell.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
openLightboxRef.current(item.id);
|
||||
});
|
||||
strip.appendChild(cell);
|
||||
cells.push(cell);
|
||||
}
|
||||
if (c.items.length > TIP_STRIP_MAX) {
|
||||
const more = document.createElement('span');
|
||||
more.className = 'apg-pin__strip-more';
|
||||
more.textContent = `+${c.items.length - TIP_STRIP_MAX}`;
|
||||
strip.appendChild(more);
|
||||
}
|
||||
|
||||
// Auto-advance the big preview (and the highlighted cell) while hovered.
|
||||
const pin = el.querySelector('.apg-pin') as HTMLElement | null;
|
||||
if (!pin) return;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let frame = 0;
|
||||
const paint = () => {
|
||||
const item = c.items[frame % c.items.length]!;
|
||||
if (tipImg) tipImg.src = item.thumbnail ?? item.src;
|
||||
cells.forEach((cell, i) =>
|
||||
cell.classList.toggle('apg-pin__strip-cell--on', i === frame % c.items.length),
|
||||
);
|
||||
};
|
||||
const stop = () => {
|
||||
if (timer === null) return;
|
||||
clearInterval(timer);
|
||||
tipTimersRef.current.delete(timer);
|
||||
timer = null;
|
||||
frame = 0;
|
||||
if (tipImg) tipImg.src = thumb;
|
||||
cells.forEach((cell) => cell.classList.remove('apg-pin__strip-cell--on'));
|
||||
};
|
||||
const start = () => {
|
||||
if (timer !== null) return;
|
||||
frame = 0;
|
||||
paint();
|
||||
timer = setInterval(() => {
|
||||
frame += 1;
|
||||
paint();
|
||||
}, TIP_FRAME_MS);
|
||||
tipTimersRef.current.add(timer);
|
||||
};
|
||||
pin.addEventListener('mouseenter', start);
|
||||
pin.addEventListener('mouseleave', stop);
|
||||
// Leaflet destroys the element on clearLayers(); `remove` fires first.
|
||||
marker.on('remove', stop);
|
||||
});
|
||||
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]);
|
||||
|
||||
// Distinct object labels present in the open cluster, most common first.
|
||||
const clusterObjects = useMemo(() => {
|
||||
if (!cluster) return [] as Array<{ label: string; count: number }>;
|
||||
const counts = new Map<string, number>();
|
||||
for (const m of cluster.items) {
|
||||
for (const label of new Set(m.objectLabels)) {
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
}, [cluster]);
|
||||
|
||||
// Search + date range + object chip, combined with AND.
|
||||
const filtered = useMemo<MediaItem[]>(() => {
|
||||
if (!cluster) return [];
|
||||
let items = cluster.items;
|
||||
if (objectFilter) items = items.filter((m) => m.objectLabels.includes(objectFilter));
|
||||
const from = parseDateInput(fromDate, false);
|
||||
const to = parseDateInput(toDate, true);
|
||||
if (from !== null) items = items.filter((m) => m.takenAt >= from);
|
||||
if (to !== null) items = items.filter((m) => m.takenAt <= to);
|
||||
// Same matcher as the global search bar, so "car" means the same thing here.
|
||||
if (query.trim()) items = searchMedia(items, query);
|
||||
return items;
|
||||
}, [cluster, query, fromDate, toDate, objectFilter]);
|
||||
|
||||
const filterActive = Boolean(query.trim() || fromDate || toDate || objectFilter);
|
||||
|
||||
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 }} aria-live="polite">
|
||||
{filterActive
|
||||
? `${filtered.length} of ${cluster.items.length} photo${
|
||||
cluster.items.length === 1 ? '' : 's'
|
||||
}`
|
||||
: `${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>
|
||||
|
||||
{/* Search + date range + object chips — all AND-combined. */}
|
||||
<div className="apg-map__sheet-filters">
|
||||
<div className="apg-mapfilter__row">
|
||||
<div className="apg-mapfilter__search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search photos at this location"
|
||||
placeholder="Search photos, objects, text…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{filterActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="apg-mapfilter__clear"
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="apg-mapfilter__row apg-mapfilter__dates">
|
||||
<DateRangeControl
|
||||
from={fromDate}
|
||||
to={toDate}
|
||||
onChange={(f, t) => {
|
||||
setFromDate(f);
|
||||
setToDate(t);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clusterObjects.length ? (
|
||||
<div className="apg-mapfilter__chips" role="group" aria-label="Filter by object">
|
||||
{clusterObjects.map(({ label, count }) => {
|
||||
const on = objectFilter === label;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={[
|
||||
'apg-mapfilter__chip',
|
||||
on ? 'apg-mapfilter__chip--on' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={on}
|
||||
onClick={() => setObjectFilter(on ? null : label)}
|
||||
>
|
||||
{label}
|
||||
<span className="apg-mapfilter__chip-n">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="apg-map__sheet-body apg-scroll">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="apg-mapfilter__empty">
|
||||
<Icon name="search" size={26} />
|
||||
<div className="apg-mapfilter__empty-title">No matching photos</div>
|
||||
<div className="apg-mapfilter__empty-sub">
|
||||
Try a different search, date range, or object.
|
||||
</div>
|
||||
<button type="button" className="apg-btn" onClick={resetFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
groupByTime(filtered, '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">
|
||||
People and pets are grouped automatically as photos are analyzed. Add more photos and
|
||||
they will appear here.
|
||||
</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,128 @@
|
||||
'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 lockConfigured = useGallery((s) => s.lockConfigured);
|
||||
const lockUnlocked = useGallery((s) => s.lockUnlocked);
|
||||
const lockError = useGallery((s) => s.lockError);
|
||||
const [pw, setPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Locked: a password is set and the user hasn't unlocked this session.
|
||||
if (lockConfigured && !lockUnlocked) {
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
await api.getState().unlockLock(pw);
|
||||
setBusy(false);
|
||||
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);
|
||||
api.getState().clearLockError();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submit();
|
||||
}}
|
||||
/>
|
||||
{lockError ? (
|
||||
<div style={{ color: 'var(--apg-danger)', fontSize: 13 }} role="alert">
|
||||
{lockError === 'wrong-password'
|
||||
? 'Incorrect password.'
|
||||
: "Couldn't check the password. Please try again."}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="apg-btn apg-btn--primary"
|
||||
disabled={busy}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? 'Checking…' : '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={lockConfigured ? 'lock' : 'unlock'} size={13} />
|
||||
{lockConfigured ? 'Locked' : 'Lock…'}
|
||||
</button>
|
||||
</div>
|
||||
<MediaGrid items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'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)}
|
||||
{latest.author ? ` · ${latest.author}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user