490 lines
24 KiB
TypeScript
490 lines
24 KiB
TypeScript
import { useEffect, useCallback, useRef, useState } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
|
|
|
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" };
|
|
}
|
|
}
|
|
|
|
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 loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
|
const similarScope = useGalleryStore((state) => state.similarScope);
|
|
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 [zoom, setZoom] = useState(1);
|
|
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
|
const [tagInput, setTagInput] = useState("");
|
|
const [tagAdding, setTagAdding] = useState(false);
|
|
const [tagsExpanded, setTagsExpanded] = useState(false);
|
|
const [taggingQueued, setTaggingQueued] = useState(false);
|
|
const imageViewportRef = useRef<HTMLDivElement>(null);
|
|
|
|
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
|
const canFindSimilar = selectedImage?.embedding_status === "ready";
|
|
|
|
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(() => {
|
|
setZoom(1);
|
|
setImageTags([]);
|
|
setTagInput("");
|
|
setTagsExpanded(false);
|
|
setTaggingQueued(false);
|
|
}, [selectedImage?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedImage) return;
|
|
void getImageTags(selectedImage.id)
|
|
.then(setImageTags)
|
|
.catch(() => setImageTags([]));
|
|
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
|
|
|
|
// 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 (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
|
|
event.preventDefault();
|
|
setZoom((value) => {
|
|
const delta = event.deltaY < 0 ? 0.15 : -0.15;
|
|
return Math.min(4, Math.max(0.5, value + delta));
|
|
});
|
|
};
|
|
|
|
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
|
return () => viewport.removeEventListener("wheel", handleWheel);
|
|
}, [selectedImage]);
|
|
|
|
useEffect(() => {
|
|
const handler = (event: KeyboardEvent) => {
|
|
if (!selectedImage) return;
|
|
if (event.key === "Escape") closeImage();
|
|
if (event.key === "ArrowLeft") goPrev();
|
|
if (event.key === "ArrowRight") goNext();
|
|
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25));
|
|
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25));
|
|
};
|
|
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [selectedImage, closeImage, goPrev, goNext]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{selectedImage ? (
|
|
<motion.div
|
|
key="lightbox"
|
|
className="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={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}
|
|
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-auto p-10"
|
|
>
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={selectedImage.id}
|
|
className="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" ? (
|
|
<video
|
|
src={convertFileSrc(selectedImage.path)}
|
|
controls
|
|
className="max-h-full max-w-full rounded-2xl shadow-2xl"
|
|
style={{ maxHeight: "calc(100vh - 10rem)" }}
|
|
/>
|
|
) : (
|
|
<>
|
|
<img
|
|
src={convertFileSrc(selectedImage.path)}
|
|
alt={selectedImage.filename}
|
|
className="max-w-full rounded-2xl shadow-2xl"
|
|
style={{
|
|
maxHeight: "calc(100vh - 10rem)",
|
|
transform: `scale(${zoom})`,
|
|
transformOrigin: "center center",
|
|
}}
|
|
/>
|
|
<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={() => setZoom((value) => Math.max(0.5, value - 0.25))}
|
|
>
|
|
-
|
|
</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={() => setZoom((value) => Math.min(4, value + 0.25))}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
|
|
<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={`rounded-full border px-3 py-1.5 text-xs ${
|
|
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 loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
|
|
}}
|
|
disabled={!canFindSimilar}
|
|
>
|
|
{canFindSimilar ? "Similar" : "Embeddings not ready"}
|
|
</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>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
|
|
<div>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<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 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);
|
|
void addUserTag(selectedImage.id, raw)
|
|
.then((newTag) => {
|
|
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>
|
|
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
|
|
<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-80 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 >= images.length - 1}
|
|
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>
|
|
);
|
|
}
|