refactor(lightbox): split Lightbox into smaller hooks and components

Deconstruct the Lightbox.tsx god component into feature-specific hooks (slideshow, region selection, navigation, media details) and separate UI components (viewport, slideshow view, details panel). Extracted shared types and formatting logic into utility files.
This commit is contained in:
2026-07-04 16:33:54 +01:00
parent 52ac2543ec
commit fe312e7678
13 changed files with 1843 additions and 1347 deletions
+154 -1347
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
import { useState } from "react";
import { Album } from "../../store";
interface LightboxAlbumMenuProps {
imageId: number;
albums: Album[];
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
createAlbum: (name: string) => Promise<Album>;
}
export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }: LightboxAlbumMenuProps) {
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState("");
const [albumAdding, setAlbumAdding] = useState(false);
return (
<div className="relative">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Albums</p>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => { setAlbumMenuOpen((open) => !open); setAlbumAddedTo(null); }}
>
Add to album
</button>
</div>
{albumMenuOpen ? (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => {
if (albumAdding) return;
setAlbumAdding(true);
void addToAlbum(album.id, [imageId])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
disabled={albumAdding}
>
<span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? (
<span className="shrink-0 text-[10px] text-emerald-400">Added</span>
) : (
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
)}
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(event) => {
event.preventDefault();
const name = newAlbumName.trim();
if (!name || albumAdding) return;
setAlbumAdding(true);
void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [imageId]);
setAlbumAddedTo(album.id);
setNewAlbumName("");
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={albumAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={albumAdding || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,437 @@
import { MutableRefObject } from "react";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { Album, ImageExif, ImageRecord, ImageTag } from "../../store";
import { Tooltip } from "../Tooltip";
import { CloseIcon, StarIcon } from "../icons";
import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from "./format";
import { LightboxAlbumMenu } from "./LightboxAlbumMenu";
interface LightboxDetailsPanelProps {
selectedImage: ImageRecord;
currentIndex: number;
imageCount: number;
imageTags: ImageTag[];
setImageTags: React.Dispatch<React.SetStateAction<ImageTag[]>>;
imageExif: ImageExif | null;
tagInput: string;
setTagInput: React.Dispatch<React.SetStateAction<string>>;
tagAdding: boolean;
setTagAdding: React.Dispatch<React.SetStateAction<boolean>>;
tagsExpanded: boolean;
setTagsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
taggingQueued: boolean;
setTaggingQueued: React.Dispatch<React.SetStateAction<boolean>>;
currentImageIdRef: MutableRefObject<number | null>;
albums: Album[];
canFindSimilar: boolean;
canSearchRegion: boolean;
regionSelectMode: boolean;
regionSearching: boolean;
taggerReady: boolean;
taggerButtonTooltip: string;
closeImage: () => void;
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>;
queueTaggingForImage: (imageId: number) => Promise<number>;
addToAlbum: (albumId: number, imageIds: number[]) => Promise<number>;
createAlbum: (name: string) => Promise<Album>;
onToggleRegionMode: () => void;
}
export function LightboxDetailsPanel({
selectedImage,
currentIndex,
imageCount,
imageTags,
setImageTags,
imageExif,
tagInput,
setTagInput,
tagAdding,
setTagAdding,
tagsExpanded,
setTagsExpanded,
taggingQueued,
setTaggingQueued,
currentImageIdRef,
albums,
canFindSimilar,
canSearchRegion,
regionSelectMode,
regionSearching,
taggerReady,
taggerButtonTooltip,
closeImage,
findSimilar,
updateImageDetails,
addUserTag,
removeTag,
queueTaggingForImage,
addToAlbum,
createAlbum,
onToggleRegionMode,
}: LightboxDetailsPanelProps) {
const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null;
const hasCameraInfo =
imageExif &&
(imageExif.make ||
imageExif.model ||
imageExif.lens ||
imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null));
return (
<div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
<p className="text-xs text-gray-500">Details</p>
</div>
<div className="flex items-center gap-2">
<Tooltip label={selectedImage.favorite ? "Remove favorite" : "Add favorite"} followCursor>
<button
className={`rounded-full border p-2 ${selectedImage.favorite ? "border-rose-400/40 bg-rose-500/10 text-rose-300" : "border-white/10 bg-white/5 text-gray-400 hover:text-white"}`}
onClick={() => void updateImageDetails(selectedImage.id, { favorite: !selectedImage.favorite })}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</button>
</Tooltip>
<Tooltip label={canFindSimilar ? "Find similar images" : "Embeddings not ready"} followCursor>
<button
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar
? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed"
}`}
onClick={() => {
if (!canFindSimilar) return;
void findSimilar(selectedImage.id, selectedImage.folder_id);
}}
disabled={!canFindSimilar}
>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z" />
</svg>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
</button>
</Tooltip>
</div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
<CloseIcon className="h-4 w-4" />
</button>
</div>
{canSearchRegion && (
<div className="shrink-0 px-5 pb-3">
<Tooltip label={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"} followCursor>
<button
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
regionSelectMode
? "border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20"
: regionSearching
? "border-white/5 bg-white/[0.03] text-gray-500 cursor-not-allowed"
: "border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white"
}`}
onClick={() => {
if (regionSearching) return;
onToggleRegionMode();
}}
disabled={regionSearching}
>
{regionSearching ? (
<span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Searching region
</span>
) : regionSelectMode ? (
<span className="flex items-center justify-center gap-1.5">
<CloseIcon className="h-3 w-3" />
Cancel selection
</span>
) : (
<span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
Search within image
</span>
)}
</button>
</Tooltip>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor delay={750}>
<button
className="rounded-md p-1"
onClick={() => void updateImageDetails(selectedImage.id, { rating })}
>
<StarIcon className={`h-5 w-5 ${rating <= selectedImage.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`} />
</button>
</Tooltip>
);
})}
{selectedImage.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
onClick={() => void updateImageDetails(selectedImage.id, { rating: 0 })}
>
<CloseIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
) : null}
</div>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Dimensions</p>
<p className="text-white">
{selectedImage.width && selectedImage.height
? `${selectedImage.width} x ${selectedImage.height}px`
: "Pending / unavailable"}
</p>
</div>
{selectedImage.media_kind === "video" ? (
<>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Duration</p>
<p className="text-white">{formatDuration(selectedImage.duration_ms)}</p>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Video codec</p>
<p className="text-white">{selectedImage.video_codec ?? "Pending / unavailable"}</p>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Audio codec</p>
<p className="text-white">{selectedImage.audio_codec ?? "None / unavailable"}</p>
</div>
{selectedImage.metadata_error ? (
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p>
<p className="text-amber-300">{selectedImage.metadata_error}</p>
</div>
) : null}
</>
) : null}
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p>
<p className="text-white">{selectedImage.mime_type}</p>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">File size</p>
<p className="text-white">{formatBytes(selectedImage.file_size)}</p>
</div>
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Modified</p>
<p className="text-white">{formatDate(selectedImage.modified_at)}</p>
</div>
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Embedding</p>
<p className="text-white">{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}</p>
{selectedImage.embedding_error ? (
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
) : null}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Tags</p>
<div className="flex items-center gap-1.5">
{aiRating ? (
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${aiRating.className}`}>
{aiRating.label}
</span>
) : null}
{selectedImage.media_kind === "image" ? (
<Tooltip label={taggerButtonTooltip} followCursor>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!taggerReady || taggingQueued}
onClick={() => {
setTaggingQueued(true);
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
}}
>
{taggingQueued ? "Queued" : "AI tags"}
</button>
</Tooltip>
) : null}
</div>
</div>
{imageTags.length > 0 ? (
<>
<div className="flex flex-wrap gap-1.5">
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => (
<Tooltip
key={tag.id}
label={tag.source === "ai" && tag.confidence !== null ? `AI confidence: ${(tag.confidence * 100).toFixed(0)}%` : ""}
followCursor
disabled={tag.source !== "ai" || tag.confidence === null}
>
<span
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
tag.source === "ai"
? "border-sky-400/20 bg-sky-500/8 text-sky-300"
: "border-white/10 bg-white/5 text-gray-300"
}`}
>
{tag.tag}
<Tooltip label="Remove tag" followCursor>
<button
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
onClick={() => {
void removeTag(tag.id).then(() =>
setImageTags((prev) => prev.filter((item) => item.id !== tag.id)),
);
}}
>
<CloseIcon className="h-3 w-3" />
</button>
</Tooltip>
</span>
</Tooltip>
))}
</div>
{imageTags.length > 8 && (
<button
className="mt-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
onClick={() => setTagsExpanded((expanded) => !expanded)}
>
{tagsExpanded ? "Show less" : `+${imageTags.length - 8} more`}
</button>
)}
</>
) : (
<p className="text-xs text-gray-600">No tags yet</p>
)}
<form
className="mt-2 flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
const raw = tagInput.trim();
if (!raw || tagAdding) return;
setTagAdding(true);
const taggedImageId = selectedImage.id;
void addUserTag(taggedImageId, raw)
.then((newTag) => {
if (currentImageIdRef.current !== taggedImageId) return;
setImageTags((prev) => [...prev, newTag]);
setTagInput("");
})
.catch(() => undefined)
.finally(() => setTagAdding(false));
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="Add tag…"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
disabled={tagAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={tagAdding || !tagInput.trim()}
>
Add
</button>
</form>
</div>
<LightboxAlbumMenu
imageId={selectedImage.id}
albums={albums}
addToAlbum={addToAlbum}
createAlbum={createAlbum}
/>
{hasCameraInfo ? (
<div>
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">Camera</p>
<div className="space-y-1.5">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")}
</p>
) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
{imageExif.iso ? <span>ISO {imageExif.iso}</span> : null}
{imageExif.focal_length ? <span>{imageExif.focal_length}</span> : null}
</div>
) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<Tooltip label="Open location in your browser" anchorToCursor>
<button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
onClick={() =>
void invoke("open_map_location", {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
})
}
>
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</Tooltip>
) : null}
</div>
</div>
) : null}
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(selectedImage.path)}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</Tooltip>
</div>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
</div>
</div>
<div className="shrink-0 mt-auto px-5 pb-4 pt-2 text-center text-xs text-gray-600">
{currentIndex + 1} / {imageCount}
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { ChevronRightIcon } from "../icons";
interface LightboxNavButtonProps {
direction: "previous" | "next";
disabled: boolean;
onClick: () => void;
}
export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) {
const isNext = direction === "next";
return (
<button
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 ${
isNext ? "right-72 lg:right-80" : "left-4"
}`}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
aria-label={isNext ? "Next image" : "Previous image"}
>
{isNext ? (
<ChevronRightIcon className="h-5 w-5" />
) : (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
)}
</button>
);
}
@@ -0,0 +1,164 @@
import { AnimatePresence, motion } from "framer-motion";
import { RefObject } from "react";
import { ImageRecord } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { VideoPlayer } from "../VideoPlayer";
import { Tooltip } from "../Tooltip";
import { SelectionOverlay, ViewTransform } from "./types";
import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from "./viewTransform";
interface LightboxViewportProps {
selectedImage: ImageRecord;
imageViewportRef: RefObject<HTMLDivElement | null>;
imgRef: RefObject<HTMLImageElement | null>;
view: ViewTransform;
zoom: number;
regionSelectMode: boolean;
isPanning: boolean;
selectionOverlay: SelectionOverlay | null;
canStartSlideshow: boolean;
onStartSlideshow: () => void;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
setView: React.Dispatch<React.SetStateAction<ViewTransform>>;
clampPan: (view: ViewTransform) => ViewTransform;
}
export function LightboxViewport({
selectedImage,
imageViewportRef,
imgRef,
view,
zoom,
regionSelectMode,
isPanning,
selectionOverlay,
canStartSlideshow,
onStartSlideshow,
onPointerDown,
onPointerMove,
onPointerUp,
setView,
clampPan,
}: LightboxViewportProps) {
return (
<div
ref={imageViewportRef}
className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode
? "cursor-crosshair select-none"
: isPanning
? "cursor-grabbing select-none"
: zoom > 1 && selectedImage.media_kind === "image"
? "cursor-grab"
: ""
}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{regionSelectMode && (
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center">
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur">
<svg className="h-3.5 w-3.5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
Draw a region to search <kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd> to cancel
</div>
</div>
)}
{selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && (
<div
className="pointer-events-none fixed z-30 rounded border-2 border-violet-400 bg-violet-400/15 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
style={{
left: selectionOverlay.left,
top: selectionOverlay.top,
width: selectionOverlay.width,
height: selectionOverlay.height,
}}
/>
)}
<AnimatePresence mode="wait">
<motion.div
key={selectedImage.id}
className={
selectedImage.media_kind === "video"
? "absolute inset-0"
: "flex items-center justify-center"
}
initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === "video" ? (
<VideoPlayer src={mediaSrc(selectedImage.path) ?? ""} />
) : (
<img
ref={imgRef}
src={mediaSrc(selectedImage.path) ?? ""}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
style={{
maxHeight: "calc(100vh - 10rem)",
transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transformOrigin: "center center",
...(regionSelectMode ? { opacity: 0.85 } : {}),
}}
/>
)}
</motion.div>
</AnimatePresence>
{!regionSelectMode && (
<div className="pointer-events-none absolute right-6 top-6 opacity-75 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 p-1 shadow-2xl shadow-black/25 backdrop-blur">
<Tooltip label={canStartSlideshow ? "Start slideshow" : "No images available for slideshow"} followCursor>
<button
aria-label="Start slideshow"
className={`rounded-full p-2 transition-colors ${
canStartSlideshow
? "text-gray-300 hover:bg-white/10 hover:text-white"
: "cursor-not-allowed text-gray-600"
}`}
disabled={!canStartSlideshow}
onClick={(event) => {
event.stopPropagation();
onStartSlideshow();
}}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 5.5v13l10-6.5-10-6.5z" />
</svg>
</button>
</Tooltip>
{selectedImage.media_kind === "image" ? (
<>
<div className="mx-0.5 h-5 w-px bg-white/10" />
<button
aria-label="Zoom out"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setView((currentView) => clampPan(zoomViewAt(currentView, Math.max(MIN_ZOOM, currentView.zoom - 0.25), 0, 0)))}
>
-
</button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button
aria-label="Zoom in"
className="rounded-full px-2 py-1 text-sm text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setView((currentView) => clampPan(zoomViewAt(currentView, Math.min(MAX_ZOOM, currentView.zoom + 0.25), 0, 0)))}
>
+
</button>
</>
) : null}
</div>
</div>
)}
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { AnimatePresence, motion } from "framer-motion";
import { ImageRecord } from "../../store";
import { mediaSrc } from "../../lib/mediaSrc";
import { Tooltip } from "../Tooltip";
import { ChevronRightIcon, CloseIcon } from "../icons";
import { SlideshowMotion } from "./useSlideshow";
interface SlideshowViewProps {
selectedImage: ImageRecord;
imageCount: number;
position: number;
controlsShown: boolean;
paused: boolean;
loadingMore: boolean;
motionConfig: SlideshowMotion;
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
onShowControls: () => void;
onExit: () => void;
onGo: (direction: -1 | 1) => void;
onTogglePaused: () => void;
}
export function SlideshowView({
selectedImage,
imageCount,
position,
controlsShown,
paused,
loadingMore,
motionConfig,
onPointerMove,
onShowControls,
onExit,
onGo,
onTogglePaused,
}: SlideshowViewProps) {
return (
<div
className={`relative flex h-full w-full items-center justify-center overflow-hidden bg-black ${
controlsShown ? "" : "cursor-none"
}`}
onClick={(event) => {
event.stopPropagation();
onShowControls();
}}
onPointerMove={onPointerMove}
>
<AnimatePresence initial={false}>
{selectedImage.media_kind === "image" ? (
<motion.div
key={selectedImage.id}
className="absolute inset-0 flex items-center justify-center"
initial={motionConfig.imageInitial}
animate={motionConfig.imageAnimate}
exit={motionConfig.imageExit}
transition={motionConfig.imageTransition}
>
<motion.img
src={mediaSrc(selectedImage.path) ?? ""}
alt={selectedImage.filename}
className="max-h-full max-w-full object-contain"
draggable={false}
initial={motionConfig.contentInitial}
animate={motionConfig.contentAnimate}
transition={motionConfig.contentTransition}
/>
</motion.div>
) : (
<motion.div
key="slideshow-waiting"
className="text-sm text-gray-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
Finding next image
</motion.div>
)}
</AnimatePresence>
<motion.div
className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between p-5"
initial={false}
animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : -6 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<div className="max-w-[60vw] whitespace-nowrap rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md">
<span className="font-medium text-white">{position}</span>
<span className="mx-1 text-gray-600">/</span>
<span>{imageCount}</span>
<span className="mx-2 text-gray-700"></span>
<span className="inline-block max-w-[42vw] truncate align-bottom text-gray-400">{selectedImage.filename}</span>
</div>
<Tooltip label="Exit slideshow" followCursor>
<button
aria-label="Exit slideshow"
className="pointer-events-auto rounded-full border border-white/10 bg-black/45 p-2 text-gray-300 shadow-2xl shadow-black/30 backdrop-blur-md transition-colors hover:bg-white/10 hover:text-white"
onClick={(event) => {
event.stopPropagation();
onExit();
}}
>
<CloseIcon className="h-4 w-4" strokeWidth={1.8} />
</button>
</Tooltip>
</motion.div>
<motion.div
className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-center p-6"
initial={false}
animate={{ opacity: controlsShown ? 1 : 0, y: controlsShown ? 0 : 8 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/50 p-1.5 shadow-2xl shadow-black/30 backdrop-blur-md">
<Tooltip label="Previous image" followCursor>
<button
aria-label="Previous image"
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={imageCount <= 1}
onClick={(event) => {
event.stopPropagation();
onGo(-1);
}}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 19l-7-7 7-7" />
</svg>
</button>
</Tooltip>
<Tooltip label={paused ? "Resume slideshow" : "Pause slideshow"} followCursor>
<button
aria-label={paused ? "Resume slideshow" : "Pause slideshow"}
className="rounded-full border border-white/10 bg-white/10 p-2.5 text-white transition-colors hover:bg-white/15"
onClick={(event) => {
event.stopPropagation();
onTogglePaused();
onShowControls();
}}
>
{paused ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72a1 1 0 001.52.86l10.55-6.86a1 1 0 000-1.72L9.52 4.28A1 1 0 008 5.14z" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5a1 1 0 00-1 1v12a1 1 0 001 1h2a1 1 0 001-1V6a1 1 0 00-1-1H7zM15 5a1 1 0 00-1 1v12a1 1 0 001 1h2a1 1 0 001-1V6a1 1 0 00-1-1h-2z" />
</svg>
)}
</button>
</Tooltip>
<Tooltip label="Next image" followCursor>
<button
aria-label="Next image"
className="rounded-full p-2 text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={imageCount <= 1}
onClick={(event) => {
event.stopPropagation();
onGo(1);
}}
>
<ChevronRightIcon className="h-5 w-5" strokeWidth={1.8} />
</button>
</Tooltip>
</div>
</motion.div>
{loadingMore ? (
<div className="pointer-events-none absolute bottom-6 right-6 rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-xs text-gray-500 backdrop-blur-md">
Loading more
</div>
) : null}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { AiRating } from "../../store";
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDate(iso: string | null): string {
if (!iso) return "Unknown";
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function formatDuration(durationMs: number | null): string {
if (!durationMs || durationMs <= 0) return "Pending / unavailable";
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function embeddingLabel(status: string, model: string | null): string {
if (status === "ready") {
return model ? `Ready (${model})` : "Ready";
}
if (status === "failed") {
return "Failed";
}
if (status === "processing") {
return "Processing";
}
return "Queued";
}
export function ratingPill(rating: AiRating): { label: string; className: string } {
switch (rating) {
case "general":
return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" };
case "sensitive":
return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" };
case "questionable":
return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" };
case "explicit":
return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" };
}
}
+26
View File
@@ -0,0 +1,26 @@
export interface DragRect {
startX: number;
startY: number;
endX: number;
endY: number;
}
export interface ViewTransform {
zoom: number;
panX: number;
panY: number;
}
export interface NormalizedCrop {
x: number;
y: number;
w: number;
h: number;
}
export interface SelectionOverlay {
left: number;
top: number;
width: number;
height: number;
}
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from "react";
import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from "../../store";
interface UseLightboxMediaDetailsParams {
selectedImage: ImageRecord | null;
taggerModelStatus: TaggerModelStatus | null;
getImageTags: (imageId: number) => Promise<ImageTag[]>;
getImageExif: (imageId: number) => Promise<ImageExif>;
loadTaggerModelStatus: () => Promise<void>;
onSelectedImageReset: () => void;
}
export function useLightboxMediaDetails({
selectedImage,
taggerModelStatus,
getImageTags,
getImageExif,
loadTaggerModelStatus,
onSelectedImageReset,
}: UseLightboxMediaDetailsParams) {
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [imageExif, setImageExif] = useState<ImageExif | null>(null);
const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false);
const [taggingQueued, setTaggingQueued] = useState(false);
const currentImageIdRef = useRef<number | null>(null);
currentImageIdRef.current = selectedImage?.id ?? null;
useEffect(() => {
setImageTags([]);
setImageExif(null);
setTagInput("");
setTagsExpanded(false);
setTaggingQueued(false);
onSelectedImageReset();
}, [onSelectedImageReset, selectedImage?.id]);
useEffect(() => {
if (!selectedImage) return;
let cancelled = false;
void getImageTags(selectedImage.id)
.then((tags) => { if (!cancelled) setImageTags(tags); })
.catch(() => { if (!cancelled) setImageTags([]); });
return () => { cancelled = true; };
}, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id]);
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== "image") {
setImageExif(null);
return;
}
let cancelled = false;
void getImageExif(selectedImage.id)
.then((exif) => { if (!cancelled) setImageExif(exif); })
.catch(() => { if (!cancelled) setImageExif(null); });
return () => { cancelled = true; };
}, [getImageExif, selectedImage?.id, selectedImage?.media_kind]);
useEffect(() => {
if (selectedImage?.media_kind !== "image" || taggerModelStatus !== null) return;
void loadTaggerModelStatus();
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus]);
useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
}, [selectedImage?.ai_tagged_at]);
return {
currentImageIdRef,
imageTags,
setImageTags,
imageExif,
tagInput,
setTagInput,
tagAdding,
setTagAdding,
tagsExpanded,
setTagsExpanded,
taggingQueued,
setTaggingQueued,
};
}
@@ -0,0 +1,109 @@
import { useCallback, useEffect } from "react";
import { ImageRecord } from "../../store";
import { ViewTransform } from "./types";
import { zoomViewAt } from "./viewTransform";
interface UseLightboxNavigationParams {
selectedImage: ImageRecord | null;
images: ImageRecord[];
currentIndex: number;
slideshowActive: boolean;
regionSelectMode: boolean;
closeImage: () => void;
exitRegionMode: () => void;
exitSlideshow: () => void;
goSlideshow: (direction: -1 | 1) => void;
showSlideshowControls: () => void;
setSlideshowPaused: React.Dispatch<React.SetStateAction<boolean>>;
openImage: (image: ImageRecord) => void;
setView: React.Dispatch<React.SetStateAction<ViewTransform>>;
clampPan: (view: ViewTransform) => ViewTransform;
}
export function useLightboxNavigation({
selectedImage,
images,
currentIndex,
slideshowActive,
regionSelectMode,
closeImage,
exitRegionMode,
exitSlideshow,
goSlideshow,
showSlideshowControls,
setSlideshowPaused,
openImage,
setView,
clampPan,
}: UseLightboxNavigationParams) {
const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1]);
}, [currentIndex, images, openImage]);
const goNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
}, [currentIndex, images, openImage]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (!selectedImage) return;
if (slideshowActive) {
if (event.key === "Escape") {
event.preventDefault();
exitSlideshow();
return;
}
if (event.key === " ") {
event.preventDefault();
setSlideshowPaused((paused) => !paused);
showSlideshowControls();
return;
}
if (event.key === "ArrowLeft" && !event.shiftKey) {
event.preventDefault();
goSlideshow(-1);
return;
}
if (event.key === "ArrowRight" && !event.shiftKey) {
event.preventDefault();
goSlideshow(1);
return;
}
return;
}
if (event.key === "Escape") {
if (regionSelectMode) {
exitRegionMode();
} else {
closeImage();
}
}
if (regionSelectMode) return;
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
if (event.key === "+" || event.key === "=")
setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0)));
if (event.key === "-")
setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0)));
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [
clampPan,
closeImage,
exitRegionMode,
exitSlideshow,
goNext,
goPrev,
goSlideshow,
regionSelectMode,
selectedImage,
setSlideshowPaused,
setView,
showSlideshowControls,
slideshowActive,
]);
return { goPrev, goNext };
}
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react";
import { ImageRecord } from "../../store";
import { DragRect, ViewTransform } from "./types";
import {
clampPanToViewport,
MIN_SELECTION_FRACTION,
normaliseRect,
rectToNormalisedCrop,
zoomViewAt,
} from "./viewTransform";
interface UseRegionSelectionParams {
selectedImage: ImageRecord | null;
slideshowActive: boolean;
imageViewportRef: RefObject<HTMLDivElement | null>;
imgRef: RefObject<HTMLImageElement | null>;
view: ViewTransform;
setView: Dispatch<SetStateAction<ViewTransform>>;
findSimilarByRegion: (
imageId: number,
crop: { x: number; y: number; w: number; h: number },
sourceFolderId: number | null,
) => Promise<void>;
}
export function useRegionSelection({
selectedImage,
slideshowActive,
imageViewportRef,
imgRef,
view,
setView,
findSimilarByRegion,
}: UseRegionSelectionParams) {
const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null);
const [regionSearching, setRegionSearching] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const clampPan = useCallback(
(nextView: ViewTransform): ViewTransform =>
clampPanToViewport(nextView, imgRef.current, imageViewportRef.current),
[imageViewportRef, imgRef],
);
const exitRegionMode = useCallback(() => {
setRegionSelectMode(false);
setIsDragging(false);
setDragRect(null);
}, []);
useEffect(() => {
const viewport = imageViewportRef.current;
if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return;
const handleWheel = (event: WheelEvent) => {
if (regionSelectMode) return;
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
event.preventDefault();
const bounds = viewport.getBoundingClientRect();
const anchorX = event.clientX - (bounds.left + bounds.width / 2);
const anchorY = event.clientY - (bounds.top + bounds.height / 2);
setView((currentView) => {
const delta = event.deltaY < 0 ? 0.15 : -0.15;
const next = Math.min(4, Math.max(0.5, currentView.zoom + delta));
return clampPan(zoomViewAt(currentView, next, anchorX, anchorY));
});
};
viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel);
}, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive]);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!regionSelectMode) {
if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setIsPanning(true);
}
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
setDragRect({
startX: event.clientX,
startY: event.clientY,
endX: event.clientX,
endY: event.clientY,
});
},
[regionSelectMode, selectedImage?.media_kind, view.zoom],
);
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
const dx = event.clientX - lastPanPointRef.current.x;
const dy = event.clientY - lastPanPointRef.current.y;
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setView((currentView) => clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy }));
return;
}
if (!isDragging) return;
setDragRect((prev) =>
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
);
},
[clampPan, isDragging, isPanning, setView],
);
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId);
setIsPanning(false);
return;
}
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false);
return;
}
event.currentTarget.releasePointerCapture(event.pointerId);
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY };
const crop = rectToNormalisedCrop(finalRect, imgRef.current);
setIsDragging(false);
setDragRect(null);
const containerBounds = imageViewportRef.current?.getBoundingClientRect();
const containerSize = containerBounds
? Math.min(containerBounds.width, containerBounds.height)
: 500;
const selW = Math.abs(finalRect.endX - finalRect.startX);
const selH = Math.abs(finalRect.endY - finalRect.startY);
if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) {
exitRegionMode();
return;
}
exitRegionMode();
setRegionSearching(true);
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
},
[dragRect, exitRegionMode, findSimilarByRegion, imageViewportRef, imgRef, isDragging, isPanning, selectedImage],
);
return {
regionSelectMode,
setRegionSelectMode,
isPanning,
regionSearching,
setRegionSearching,
exitRegionMode,
clampPan,
selectionOverlay: isDragging && dragRect ? normaliseRect(dragRect) : null,
handlePointerDown,
handlePointerMove,
handlePointerUp,
};
}
+276
View File
@@ -0,0 +1,276 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react";
import type { Transition } from "framer-motion";
import { ImageRecord, SlideshowOrder, SlideshowTransition } from "../../store";
import { IDENTITY_VIEW } from "./viewTransform";
import { ViewTransform } from "./types";
const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const SLIDESHOW_CONTROLS_IDLE_MS = 5000;
const SLIDESHOW_LOAD_MORE_THRESHOLD = 3;
export interface SlideshowMotion {
imageInitial: { opacity: number; filter: string };
imageAnimate: { opacity: number; filter: string };
imageExit: { opacity: number; filter: string };
imageTransition: Transition;
contentInitial: { scale: number; x?: number; y?: number };
contentAnimate: { scale: number; x?: number; y?: number };
contentTransition: Transition;
}
interface UseSlideshowParams {
rootRef: RefObject<HTMLDivElement | null>;
selectedImage: ImageRecord | null;
images: ImageRecord[];
currentIndex: number;
loadedCount: number;
totalImages: number;
intervalSeconds: number;
order: SlideshowOrder;
transition: SlideshowTransition;
openImage: (image: ImageRecord) => void;
loadMoreImages: () => Promise<void>;
exitRegionMode: () => void;
setView: Dispatch<SetStateAction<ViewTransform>>;
}
export function useSlideshow({
rootRef,
selectedImage,
images,
currentIndex,
loadedCount,
totalImages,
intervalSeconds,
order,
transition,
openImage,
loadMoreImages,
exitRegionMode,
setView,
}: UseSlideshowParams) {
const [active, setActive] = useState(false);
const [paused, setPaused] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [motionStep, setMotionStep] = useState(0);
const controlsIdleTimeoutRef = useRef<number | null>(null);
const pointerRef = useRef<{ x: number; y: number } | null>(null);
const randomSeenRef = useRef<Set<number>>(new Set());
const slideshowImages = useMemo(
() => images.filter((image) => image.media_kind === "image"),
[images],
);
const slideshowIndex = selectedImage
? slideshowImages.findIndex((image) => image.id === selectedImage.id)
: -1;
const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1);
const canStart = slideshowImages.length > 0;
const directions = [
{ x: -1, y: 0.45 },
{ x: 1, y: -0.45 },
{ x: -0.7, y: -0.55 },
{ x: 0.7, y: 0.55 },
];
const direction = directions[motionStep % directions.length];
const motionConfig: SlideshowMotion = {
imageInitial: { opacity: 0, filter: "blur(8px)" },
imageAnimate: { opacity: 1, filter: "blur(0px)" },
imageExit: { opacity: 0, filter: "blur(4px)" },
imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE },
contentInitial:
transition === "gentle-motion"
? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y }
: { scale: 1.012 },
contentAnimate:
transition === "gentle-motion"
? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y }
: { scale: 1 },
contentTransition:
transition === "gentle-motion"
? { duration: intervalSeconds + 0.75, ease: "linear" }
: { duration: 0.72, ease: SLIDESHOW_EASE },
};
const showControls = useCallback(() => {
if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current);
controlsIdleTimeoutRef.current = null;
}
setControlsVisible(true);
if (active) {
controlsIdleTimeoutRef.current = window.setTimeout(() => {
setControlsVisible(false);
controlsIdleTimeoutRef.current = null;
}, SLIDESHOW_CONTROLS_IDLE_MS);
}
}, [active]);
const exit = useCallback(() => {
setActive(false);
setPaused(false);
setControlsVisible(true);
if (document.fullscreenElement === rootRef.current) {
void document.exitFullscreen().catch(() => undefined);
}
}, [rootRef]);
const go = useCallback(
(direction: -1 | 1, revealControls = true) => {
if (slideshowImages.length === 0) return;
if (slideshowImages.length === 1) {
openImage(slideshowImages[0]);
return;
}
const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0;
let nextIndex =
(currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length;
if (order === "random") {
const currentId = slideshowImages[currentSlideshowIndex]?.id;
let candidates = slideshowImages.filter(
(image) => image.id !== currentId && !randomSeenRef.current.has(image.id),
);
if (candidates.length === 0) {
randomSeenRef.current = new Set(currentId == null ? [] : [currentId]);
candidates = slideshowImages.filter((image) => image.id !== currentId);
}
const nextImage = candidates[Math.floor(Math.random() * candidates.length)];
nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id);
randomSeenRef.current.add(nextImage.id);
}
setMotionStep((step) => step + 1);
openImage(slideshowImages[nextIndex]);
if (revealControls) {
showControls();
}
},
[openImage, order, showControls, slideshowImages, slideshowIndex],
);
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const previous = pointerRef.current;
const next = { x: event.clientX, y: event.clientY };
pointerRef.current = next;
if (!previous) {
if (!controlsVisible) {
showControls();
}
return;
}
if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) {
showControls();
}
},
[controlsVisible, showControls],
);
const start = useCallback(() => {
if (!selectedImage || slideshowImages.length === 0) return;
const nextImage =
selectedImage.media_kind === "image"
? selectedImage
: images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ??
slideshowImages[0];
if (nextImage.id !== selectedImage.id) {
openImage(nextImage);
}
randomSeenRef.current = new Set([nextImage.id]);
setMotionStep(0);
exitRegionMode();
setView(IDENTITY_VIEW);
setPaused(false);
setControlsVisible(true);
pointerRef.current = null;
setActive(true);
void rootRef.current?.requestFullscreen().catch(() => undefined);
}, [currentIndex, exitRegionMode, images, openImage, rootRef, selectedImage, setView, slideshowImages]);
useEffect(() => {
if (selectedImage) return;
setActive(false);
setPaused(false);
setControlsVisible(true);
}, [selectedImage]);
useEffect(() => {
const handleFullscreenChange = () => {
if (!active) return;
if (document.fullscreenElement !== rootRef.current) {
setActive(false);
setPaused(false);
setControlsVisible(true);
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, [active, rootRef]);
useEffect(() => {
showControls();
return () => {
if (controlsIdleTimeoutRef.current !== null) {
window.clearTimeout(controlsIdleTimeoutRef.current);
controlsIdleTimeoutRef.current = null;
}
};
}, [active, paused, showControls]);
useEffect(() => {
if (!active || paused || slideshowImages.length <= 1) return;
const timeout = window.setTimeout(() => {
go(1, false);
}, intervalSeconds * 1000);
return () => window.clearTimeout(timeout);
}, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length]);
useEffect(() => {
if (
!active ||
loadingMore ||
loadedCount >= totalImages ||
slideshowImages.length === 0 ||
slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD
) {
return;
}
setLoadingMore(true);
void loadMoreImages().finally(() => setLoadingMore(false));
}, [
active,
loadedCount,
loadMoreImages,
loadingMore,
slideshowImages.length,
slideshowIndex,
totalImages,
]);
return {
active,
paused,
setPaused,
controlsShown: controlsVisible,
loadingMore,
images: slideshowImages,
position,
canStart,
motionConfig,
start,
exit,
go,
showControls,
handlePointerMove,
};
}
+67
View File
@@ -0,0 +1,67 @@
import { DragRect, NormalizedCrop, SelectionOverlay, ViewTransform } from "./types";
export const MIN_SELECTION_FRACTION = 0.02;
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 4;
export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
export function normaliseRect(r: DragRect): SelectionOverlay {
return {
left: Math.min(r.startX, r.endX),
top: Math.min(r.startY, r.endY),
width: Math.abs(r.endX - r.startX),
height: Math.abs(r.endY - r.startY),
};
}
export function rectToNormalisedCrop(rect: DragRect, imgEl: HTMLImageElement): NormalizedCrop | null {
const imgBounds = imgEl.getBoundingClientRect();
if (imgBounds.width === 0 || imgBounds.height === 0) return null;
const rawX = Math.min(rect.startX, rect.endX);
const rawY = Math.min(rect.startY, rect.endY);
const rawW = Math.abs(rect.endX - rect.startX);
const rawH = Math.abs(rect.endY - rect.startY);
const clampedX = Math.max(rawX, imgBounds.left);
const clampedY = Math.max(rawY, imgBounds.top);
const clampedRight = Math.min(rawX + rawW, imgBounds.right);
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom);
const croppedW = clampedRight - clampedX;
const croppedH = clampedBottom - clampedY;
if (croppedW <= 0 || croppedH <= 0) return null;
return {
x: (clampedX - imgBounds.left) / imgBounds.width,
y: (clampedY - imgBounds.top) / imgBounds.height,
w: croppedW / imgBounds.width,
h: croppedH / imgBounds.height,
};
}
export function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom };
const ratio = newZoom / view.zoom;
return {
zoom: newZoom,
panX: anchorX - (anchorX - view.panX) * ratio,
panY: anchorY - (anchorY - view.panY) * ratio,
};
}
export function clampPanToViewport(
view: ViewTransform,
img: HTMLImageElement | null,
viewport: HTMLDivElement | null,
): ViewTransform {
if (!img || !viewport) return view;
const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2);
const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2);
return {
...view,
panX: Math.min(maxX, Math.max(-maxX, view.panX)),
panY: Math.min(maxY, Math.max(-maxY, view.panY)),
};
}