68a9df5ab3
github/actions/ci GitHub Actions CI finished: failure
Lay the lightbox info-panel metadata out in a two-column grid: paired fields (Dimensions/Duration, Video codec/Audio codec, Type/File size) sit side by side while Rating, Modified, and Embedding span the full width. More compact panel with less scrolling. Tags and EXIF sections are unchanged.
978 lines
48 KiB
TypeScript
978 lines
48 KiB
TypeScript
import { useEffect, useCallback, useRef, useState } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
|
||
import { VideoPlayer } from "./VideoPlayer";
|
||
import { mediaSrc } from "../lib/mediaSrc";
|
||
|
||
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`;
|
||
}
|
||
|
||
function formatDate(iso: string | null): string {
|
||
if (!iso) return "Unknown";
|
||
return new Date(iso).toLocaleDateString(undefined, {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
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")}`;
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
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" };
|
||
}
|
||
}
|
||
|
||
interface DragRect {
|
||
startX: number;
|
||
startY: number;
|
||
endX: number;
|
||
endY: number;
|
||
}
|
||
|
||
/** Compute a CSS-pixel rect (relative to the viewport container) from a DragRect. */
|
||
function normaliseRect(r: DragRect): { left: number; top: number; width: number; height: number } {
|
||
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),
|
||
};
|
||
}
|
||
|
||
/** Convert a CSS-pixel drag rect (relative to viewport container) to normalised 0–1 crop coords
|
||
* relative to the actual rendered <img> element bounds. */
|
||
function rectToNormalisedCrop(
|
||
rect: DragRect,
|
||
imgEl: HTMLImageElement,
|
||
): { x: number; y: number; w: number; h: number } | null {
|
||
const imgBounds = imgEl.getBoundingClientRect();
|
||
if (imgBounds.width === 0 || imgBounds.height === 0) return null;
|
||
|
||
// rect coords are already in viewport space (client coords)
|
||
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);
|
||
|
||
// Clamp to image bounds
|
||
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;
|
||
|
||
// Normalize by the CSS transform scale — getBoundingClientRect already returns
|
||
// the scaled (on-screen) size, so we normalize directly against that.
|
||
return {
|
||
x: (clampedX - imgBounds.left) / imgBounds.width,
|
||
y: (clampedY - imgBounds.top) / imgBounds.height,
|
||
w: croppedW / imgBounds.width,
|
||
h: croppedH / imgBounds.height,
|
||
};
|
||
}
|
||
|
||
/** Minimum selection size as a fraction of the viewport container dimension. */
|
||
const MIN_SELECTION_FRACTION = 0.02;
|
||
|
||
const MIN_ZOOM = 0.5;
|
||
const MAX_ZOOM = 4;
|
||
|
||
interface ViewTransform {
|
||
zoom: number;
|
||
panX: number;
|
||
panY: number;
|
||
}
|
||
|
||
const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
|
||
|
||
/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the
|
||
* viewport centre — stays under the cursor across a zoom change. */
|
||
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 Lightbox() {
|
||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||
const closeImage = useGalleryStore((state) => state.closeImage);
|
||
const images = useGalleryStore((state) => state.images);
|
||
const openImage = useGalleryStore((state) => state.openImage);
|
||
const findSimilar = useGalleryStore((state) => state.findSimilar);
|
||
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
|
||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||
const getImageTags = useGalleryStore((state) => state.getImageTags);
|
||
const addUserTag = useGalleryStore((state) => state.addUserTag);
|
||
const removeTag = useGalleryStore((state) => state.removeTag);
|
||
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
|
||
const albums = useGalleryStore((state) => state.albums);
|
||
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
|
||
const createAlbum = useGalleryStore((state) => state.createAlbum);
|
||
const getImageExif = useGalleryStore((state) => state.getImageExif);
|
||
|
||
// Tracks the image id that is currently displayed, used to discard async
|
||
// tag mutations that resolve after the user has navigated to another image.
|
||
const currentImageIdRef = useRef<number | null>(null);
|
||
currentImageIdRef.current = selectedImage?.id ?? null;
|
||
|
||
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
|
||
const zoom = view.zoom;
|
||
const [isPanning, setIsPanning] = useState(false);
|
||
const lastPanPointRef = useRef({ x: 0, y: 0 });
|
||
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);
|
||
|
||
// Region selection state
|
||
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
|
||
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
|
||
const [newAlbumName, setNewAlbumName] = useState("");
|
||
const [albumAdding, setAlbumAdding] = useState(false);
|
||
const [regionSelectMode, setRegionSelectMode] = useState(false);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [dragRect, setDragRect] = useState<DragRect | null>(null);
|
||
const [regionSearching, setRegionSearching] = useState(false);
|
||
|
||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||
const imgRef = useRef<HTMLImageElement>(null);
|
||
|
||
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
||
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
||
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
|
||
|
||
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]);
|
||
|
||
const exitRegionMode = useCallback(() => {
|
||
setRegionSelectMode(false);
|
||
setIsDragging(false);
|
||
setDragRect(null);
|
||
}, []);
|
||
|
||
// Keep the pan within bounds: when the scaled image overflows the viewport
|
||
// the image edge may not be dragged past the viewport edge; when it fits,
|
||
// the pan snaps back to centre on that axis.
|
||
const clampPan = useCallback((v: ViewTransform): ViewTransform => {
|
||
const img = imgRef.current;
|
||
const vp = imageViewportRef.current;
|
||
if (!img || !vp) return v;
|
||
const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2);
|
||
const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2);
|
||
return {
|
||
...v,
|
||
panX: Math.min(maxX, Math.max(-maxX, v.panX)),
|
||
panY: Math.min(maxY, Math.max(-maxY, v.panY)),
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setView(IDENTITY_VIEW);
|
||
setImageTags([]);
|
||
setImageExif(null);
|
||
setTagInput("");
|
||
setTagsExpanded(false);
|
||
setTaggingQueued(false);
|
||
exitRegionMode();
|
||
setRegionSearching(false);
|
||
}, [selectedImage?.id, exitRegionMode]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedImage) return;
|
||
// Capture the ID so a stale response for image A cannot overwrite B's tags
|
||
// when the user navigates before the request resolves.
|
||
let cancelled = false;
|
||
void getImageTags(selectedImage.id)
|
||
.then((tags) => { if (!cancelled) setImageTags(tags); })
|
||
.catch(() => { if (!cancelled) setImageTags([]); });
|
||
return () => { cancelled = true; };
|
||
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
|
||
|
||
// EXIF is read on demand from the file (not stored), so it works on every
|
||
// already-indexed image without a reindex. Only meaningful for images.
|
||
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; };
|
||
}, [selectedImage?.id, selectedImage?.media_kind, getImageExif]);
|
||
|
||
// Reset the queued state once the worker finishes so the button is usable again
|
||
useEffect(() => {
|
||
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
|
||
}, [selectedImage?.ai_tagged_at]);
|
||
|
||
useEffect(() => {
|
||
const viewport = imageViewportRef.current;
|
||
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
|
||
|
||
const handleWheel = (event: WheelEvent) => {
|
||
if (regionSelectMode) return; // don't zoom during selection
|
||
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((v) => {
|
||
const delta = event.deltaY < 0 ? 0.15 : -0.15;
|
||
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta));
|
||
return clampPan(zoomViewAt(v, next, anchorX, anchorY));
|
||
});
|
||
};
|
||
|
||
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
||
return () => viewport.removeEventListener("wheel", handleWheel);
|
||
}, [selectedImage, regionSelectMode, clampPan]);
|
||
|
||
useEffect(() => {
|
||
const handler = (event: KeyboardEvent) => {
|
||
if (!selectedImage) return;
|
||
if (event.key === "Escape") {
|
||
if (regionSelectMode) {
|
||
exitRegionMode();
|
||
} else {
|
||
closeImage();
|
||
}
|
||
}
|
||
if (regionSelectMode) return; // block nav keys during selection
|
||
// Shift+arrows are reserved for video seeking (handled by VideoPlayer)
|
||
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
|
||
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
|
||
if (event.key === "+" || event.key === "=")
|
||
setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0)));
|
||
if (event.key === "-")
|
||
setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0)));
|
||
};
|
||
|
||
window.addEventListener("keydown", handler);
|
||
return () => window.removeEventListener("keydown", handler);
|
||
}, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode, clampPan]);
|
||
|
||
// ── Region selection / pan pointer handlers ─────────────────────────────────
|
||
const handleRegionPointerDown = useCallback(
|
||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (!regionSelectMode) {
|
||
// Drag-to-pan when the image is zoomed in
|
||
if (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, zoom, selectedImage?.media_kind],
|
||
);
|
||
|
||
const handleRegionPointerMove = 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((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy }));
|
||
return;
|
||
}
|
||
if (!isDragging) return;
|
||
setDragRect((prev) =>
|
||
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
|
||
);
|
||
},
|
||
[isDragging, isPanning, clampPan],
|
||
);
|
||
|
||
const handleRegionPointerUp = 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);
|
||
|
||
// Ignore tiny accidental clicks
|
||
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));
|
||
},
|
||
[isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode],
|
||
);
|
||
|
||
// Build the CSS rect for the selection overlay (viewport-relative)
|
||
const selectionOverlay =
|
||
isDragging && dragRect ? normaliseRect(dragRect) : null;
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{selectedImage ? (
|
||
<motion.div
|
||
key="lightbox"
|
||
className="media-dark-surface fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.15 }}
|
||
onClick={regionSelectMode ? undefined : closeImage}
|
||
>
|
||
<button
|
||
className="absolute left-4 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"
|
||
disabled={currentIndex <= 0 || regionSelectMode}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
goPrev();
|
||
}}
|
||
>
|
||
<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>
|
||
|
||
<div className="flex flex-1 flex-col" onClick={(event) => event.stopPropagation()}>
|
||
<div className="flex flex-1 overflow-hidden">
|
||
<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={handleRegionPointerDown}
|
||
onPointerMove={handleRegionPointerMove}
|
||
onPointerUp={handleRegionPointerUp}
|
||
>
|
||
{/* Region selection mode hint */}
|
||
{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>
|
||
)}
|
||
|
||
{/* Selection rectangle overlay */}
|
||
{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",
|
||
// Slightly dim the image while in region select mode
|
||
...(regionSelectMode ? { opacity: 0.85 } : {}),
|
||
}}
|
||
/>
|
||
{!regionSelectMode && (
|
||
<div className="pointer-events-none absolute right-6 top-6 opacity-0 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 px-2 py-1 backdrop-blur">
|
||
<button
|
||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))}
|
||
>
|
||
-
|
||
</button>
|
||
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
|
||
<button
|
||
className="px-2 text-sm text-gray-300 hover:text-white"
|
||
onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
<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">
|
||
<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 })}
|
||
title={selectedImage.favorite ? "Remove favorite" : "Add 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>
|
||
<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}
|
||
title={canFindSimilar ? "Find similar images" : "Embeddings not ready"}
|
||
>
|
||
<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>
|
||
</div>
|
||
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search region button row */}
|
||
{canSearchRegion && (
|
||
<div className="shrink-0 px-5 pb-3">
|
||
<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;
|
||
setRegionSelectMode((prev) => !prev);
|
||
setDragRect(null);
|
||
setIsDragging(false);
|
||
}}
|
||
disabled={regionSearching}
|
||
title={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"}
|
||
>
|
||
{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">
|
||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
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>
|
||
</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 (
|
||
<button
|
||
key={rating}
|
||
className="rounded-md p-1"
|
||
onClick={() => void updateImageDetails(selectedImage.id, { rating })}
|
||
title={`Set ${rating} star rating`}
|
||
>
|
||
<svg
|
||
className={`h-5 w-5 ${rating <= selectedImage.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`}
|
||
fill="currentColor"
|
||
viewBox="0 0 20 20"
|
||
>
|
||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
|
||
</svg>
|
||
</button>
|
||
);
|
||
})}
|
||
{selectedImage.rating > 0 ? (
|
||
<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 })}
|
||
title="Remove rating"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
) : 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">
|
||
{selectedImage.ai_rating ? (
|
||
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${ratingPill(selectedImage.ai_rating).className}`}>
|
||
{ratingPill(selectedImage.ai_rating).label}
|
||
</span>
|
||
) : null}
|
||
{selectedImage.media_kind === "image" ? (
|
||
<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={!(taggerModelStatus?.ready ?? false) || taggingQueued}
|
||
title={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"}
|
||
onClick={() => {
|
||
setTaggingQueued(true);
|
||
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
|
||
}}
|
||
>
|
||
{taggingQueued ? "Queued" : "AI tags"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{imageTags.length > 0 ? (
|
||
<>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => (
|
||
<span
|
||
key={t.id}
|
||
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
|
||
t.source === "ai"
|
||
? "border-sky-400/20 bg-sky-500/8 text-sky-300"
|
||
: "border-white/10 bg-white/5 text-gray-300"
|
||
}`}
|
||
title={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : undefined}
|
||
>
|
||
{t.tag}
|
||
<button
|
||
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||
onClick={() => {
|
||
void removeTag(t.id).then(() =>
|
||
setImageTags((prev) => prev.filter((x) => x.id !== t.id)),
|
||
);
|
||
}}
|
||
title="Remove tag"
|
||
>
|
||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
{imageTags.length > 8 && (
|
||
<button
|
||
className="mt-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||
onClick={() => setTagsExpanded((v) => !v)}
|
||
>
|
||
{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={(e) => {
|
||
e.preventDefault();
|
||
const raw = tagInput.trim();
|
||
if (!raw || tagAdding) return;
|
||
setTagAdding(true);
|
||
const taggedImageId = selectedImage.id;
|
||
void addUserTag(taggedImageId, raw)
|
||
.then((newTag) => {
|
||
// Discard if the user navigated away before the request resolved.
|
||
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={(e) => setTagInput(e.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>
|
||
|
||
<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((v) => !v); 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, [selectedImage.id])
|
||
.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={(e) => {
|
||
e.preventDefault();
|
||
const name = newAlbumName.trim();
|
||
if (!name || albumAdding) return;
|
||
setAlbumAdding(true);
|
||
void createAlbum(name)
|
||
.then(async (album) => {
|
||
await addToAlbum(album.id, [selectedImage.id]);
|
||
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={(e) => setNewAlbumName(e.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>
|
||
|
||
{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)) ? (
|
||
<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 ? (
|
||
<button
|
||
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
|
||
title="Open location in your browser"
|
||
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>
|
||
) : 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>
|
||
<button
|
||
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
|
||
title="Reveal in Explorer"
|
||
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>
|
||
</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} / {images.length}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="absolute right-72 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 lg:right-80"
|
||
disabled={currentIndex >= images.length - 1 || regionSelectMode}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
goNext();
|
||
}}
|
||
>
|
||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
</button>
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
);
|
||
}
|