Initial commit — Tauri image gallery with sqlite-vec

Sets up Tauri v2 + React frontend with SQLite metadata store,
sqlite-vec for CLIP embedding infrastructure, and r2d2 connection
pool replacing the single Arc<Mutex<Connection>>. Indexer now uses
rayon for parallel file metadata collection.
This commit is contained in:
2026-04-05 15:59:48 +01:00
commit c5e9c83ac9
51 changed files with 10540 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
const GAP = 8;
function RatingStars({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, index) => {
const filled = index < rating;
return (
<svg
key={index}
className={`h-3 w-3 ${filled ? "text-amber-300" : "text-white/25"}`}
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>
);
})}
</div>
);
}
function ContextMenu({
x,
y,
image,
onClose,
}: {
x: number;
y: number;
image: ImageRecord;
onClose: () => void;
}) {
const { openImage, updateImageDetails } = useGalleryStore();
return (
<div
className="fixed z-40 min-w-56 rounded-2xl border border-white/10 bg-gray-950/95 p-2 shadow-2xl backdrop-blur"
style={{ left: x, top: y }}
onClick={(event) => event.stopPropagation()}
>
<button
className="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5"
onClick={() => {
openImage(image);
onClose();
}}
>
Open Preview
</button>
<button
className="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5"
onClick={async () => {
await updateImageDetails(image.id, { favorite: !image.favorite });
onClose();
}}
>
{image.favorite ? "Remove Favorite" : "Add to Favorites"}
</button>
<div className="my-2 h-px bg-white/5" />
<div className="px-3 pb-1 pt-1 text-[11px] uppercase tracking-[0.2em] text-gray-500">Rating</div>
<div className="flex gap-1 px-2 pb-1">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className={`rounded-lg px-2 py-1 text-sm ${rating <= image.rating ? "bg-amber-400/15 text-amber-300" : "bg-white/5 text-gray-400 hover:text-white"}`}
onClick={async () => {
await updateImageDetails(image.id, { rating });
onClose();
}}
>
{rating}
</button>
);
})}
<button
className="ml-auto rounded-lg px-2 py-1 text-sm text-gray-400 hover:bg-white/5 hover:text-white"
onClick={async () => {
await updateImageDetails(image.id, { rating: 0 });
onClose();
}}
>
Clear
</button>
</div>
</div>
);
}
function ImageTile({
image,
onClick,
onContextMenu,
}: {
image: ImageRecord;
onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const src = image.thumbnail_path
? convertFileSrc(image.thumbnail_path)
: image.media_kind === "image" && image.path
? convertFileSrc(image.path)
: null;
return (
<button
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-white/[0.03] text-left"
style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={onClick}
onContextMenu={onContextMenu}
title={image.filename}
>
{image.media_kind === "video" ? (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-fuchsia-500/10 via-white/5 to-cyan-500/10">
<div className="rounded-full border border-white/10 bg-black/30 p-4 text-white shadow-lg">
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
) : src && !errored ? (
<>
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
<img
src={src}
alt={image.filename}
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
/>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-white/5 text-gray-600">
<svg className="h-9 w-9" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent opacity-80 transition-opacity group-hover:opacity-100" />
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] uppercase tracking-[0.18em] text-white/80">
{image.media_kind}
</div>
<div className="flex items-center gap-1">
{image.favorite ? (
<div className="rounded-full border border-white/10 bg-black/35 p-1 text-rose-300">
<svg className="h-3.5 w-3.5" 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>
</div>
) : null}
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="truncate text-sm font-medium text-white">{image.filename}</p>
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-white/70">
<RatingStars rating={image.rating} />
<span>{image.embedding_status === "ready" ? "indexed" : image.embedding_status}</span>
</div>
</div>
</button>
);
}
export function Gallery() {
const { images, loadMoreImages, openImage, totalImages, loadingImages, zoomPreset } = useGalleryStore();
const parentRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
const handleScroll = useCallback(() => {
const element = parentRef.current;
if (!element) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const element = parentRef.current;
if (!element) return;
element.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const close = () => setContextMenu(null);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setContextMenu(null);
}
};
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
if (images.length === 0 && !loadingImages) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-gray-600">
<svg className="h-16 w-16 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm">No media found</p>
<p className="text-xs opacity-60">Try another filter, or add a folder from the library menu.</p>
</div>
);
}
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#060816]">
<div
className="grid content-start"
style={{
padding: GAP,
gap: GAP,
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
}}
>
{images.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
{loadingImages ? (
<div className="flex justify-center py-6">
<div className="h-5 w-5 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
</div>
) : null}
{contextMenu ? (
<ContextMenu x={contextMenu.x} y={contextMenu.y} image={contextMenu.image} onClose={() => setContextMenu(null)} />
) : null}
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore } 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",
});
}
export function Lightbox() {
const { selectedImage, closeImage, images, openImage, updateImageDetails } = useGalleryStore();
const [zoom, setZoom] = useState(1);
const imageViewportRef = useRef<HTMLDivElement>(null);
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
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);
}, [selectedImage?.id]);
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>
<motion.div
key={selectedImage.id}
className="flex flex-1 flex-col"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.15 }}
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"
>
{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>
</>
)}
</div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5">
<div className="mb-5 flex items-center justify-between">
<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>
</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="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>
<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">{selectedImage.embedding_status}</p>
</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="mt-auto pt-4 text-center text-xs text-gray-600">
{currentIndex + 1} / {images.length}
</div>
</div>
</div>
</motion.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>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
type MenuKey = "library" | "view" | "filter";
function MenuButton({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={onClick}
>
{label}
</button>
);
}
function MenuPanel({ children }: { children: React.ReactNode }) {
return (
<div className="absolute left-0 top-full z-30 mt-2 min-w-56 rounded-xl border border-white/10 bg-gray-950/95 p-2 shadow-2xl backdrop-blur">
{children}
</div>
);
}
function MenuItem({
label,
hint,
active = false,
onClick,
}: {
label: string;
hint?: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-blue-500/15 text-white" : "text-gray-300 hover:bg-white/5 hover:text-white"
}`}
onClick={onClick}
>
<span>{label}</span>
{hint ? <span className="text-xs text-gray-500">{hint}</span> : null}
</button>
);
}
const ZOOM_OPTIONS: { value: ZoomPreset; label: string }[] = [
{ value: "compact", label: "Compact Grid" },
{ value: "comfortable", label: "Comfortable Grid" },
{ value: "detail", label: "Detail Grid" },
];
const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
{ value: "all", label: "All Media" },
{ value: "image", label: "Images" },
{ value: "video", label: "Videos" },
];
export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const {
addFolder,
reindexFolder,
selectedFolderId,
zoomPreset,
setZoomPreset,
mediaFilter,
setMediaFilter,
favoritesOnly,
setFavoritesOnly,
} = useGalleryStore();
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpenMenu(null);
}
};
window.addEventListener("pointerdown", handlePointerDown);
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, []);
const handleAddFolder = async () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (selected && typeof selected === "string") {
await addFolder(selected);
}
setOpenMenu(null);
};
const handleReindex = async () => {
if (selectedFolderId !== null) {
await reindexFolder(selectedFolderId);
}
setOpenMenu(null);
};
return (
<div ref={rootRef} className="relative z-20 flex items-center gap-1 border-b border-white/5 bg-gray-950/90 px-4 py-2 backdrop-blur">
<div className="relative">
<MenuButton
label="Library"
active={openMenu === "library"}
onClick={() => setOpenMenu((current) => (current === "library" ? null : "library"))}
/>
{openMenu === "library" ? (
<MenuPanel>
<MenuItem label="Add Folder" hint="Ctrl+O soon" onClick={handleAddFolder} />
<MenuItem
label="Re-index Current Folder"
hint={selectedFolderId === null ? "Select folder" : undefined}
onClick={handleReindex}
active={selectedFolderId !== null}
/>
</MenuPanel>
) : null}
</div>
<div className="relative">
<MenuButton
label="View"
active={openMenu === "view"}
onClick={() => setOpenMenu((current) => (current === "view" ? null : "view"))}
/>
{openMenu === "view" ? (
<MenuPanel>
{ZOOM_OPTIONS.map((option) => (
<MenuItem
key={option.value}
label={option.label}
active={zoomPreset === option.value}
onClick={() => {
setZoomPreset(option.value);
setOpenMenu(null);
}}
/>
))}
</MenuPanel>
) : null}
</div>
<div className="relative">
<MenuButton
label="Filter"
active={openMenu === "filter"}
onClick={() => setOpenMenu((current) => (current === "filter" ? null : "filter"))}
/>
{openMenu === "filter" ? (
<MenuPanel>
{FILTER_OPTIONS.map((option) => (
<MenuItem
key={option.value}
label={option.label}
active={mediaFilter === option.value}
onClick={() => {
setMediaFilter(option.value);
setOpenMenu(null);
}}
/>
))}
<div className="my-2 h-px bg-white/5" />
<MenuItem
label={favoritesOnly ? "Hide Favorites Only" : "Show Favorites Only"}
active={favoritesOnly}
onClick={() => {
setFavoritesOnly(!favoritesOnly);
setOpenMenu(null);
}}
/>
</MenuPanel>
) : null}
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
function FolderItem({
folder,
selected,
progress,
}: {
folder: Folder;
selected: boolean;
progress: IndexProgress | undefined;
}) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore();
const isIndexing = progress && !progress.done;
return (
<div
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
selected
? "bg-blue-600 text-white"
: "hover:bg-white/5 text-gray-300 hover:text-white"
}`}
onClick={() => selectFolder(folder.id)}
>
<svg
className="w-4 h-4 shrink-0 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"
/>
</svg>
<div className="flex-1 min-w-0">
<div className="truncate text-sm font-medium">{folder.name}</div>
{isIndexing ? (
<div className="text-xs opacity-60">
{progress.indexed}/{progress.total} indexed
</div>
) : (
<div className="text-xs opacity-50">
{folder.image_count.toLocaleString()} items
</div>
)}
{isIndexing && (
<div className="h-0.5 bg-white/20 rounded mt-1 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-300"
style={{
width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%`,
}}
/>
</div>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
className="p-1 rounded hover:bg-white/10"
title="Re-index"
onClick={(e) => {
e.stopPropagation();
reindexFolder(folder.id);
}}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
className="p-1 rounded hover:bg-red-500/20 hover:text-red-400"
title="Remove folder"
onClick={(e) => {
e.stopPropagation();
removeFolder(folder.id);
}}
>
<svg className="w-3 h-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>
</div>
</div>
);
}
export function Sidebar() {
const { folders, selectedFolderId, addFolder, indexingProgress, totalImages, selectFolder } =
useGalleryStore();
const handleAddFolder = async () => {
const selected = await open({
directory: true,
multiple: false,
title: "Select Media Folder",
});
if (selected && typeof selected === "string") {
await addFolder(selected);
}
};
return (
<aside className="w-72 shrink-0 flex flex-col bg-gray-900 border-r border-white/5">
{/* Header */}
<div className="px-4 py-4 border-b border-white/5">
<h1 className="text-white font-semibold text-base">Image Gallery</h1>
<p className="text-gray-500 text-xs mt-0.5">
{folders.reduce((s, f) => s + f.image_count, 0).toLocaleString()} total items
</p>
</div>
{/* All photos link */}
<div className="px-3 pt-3">
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${
selectedFolderId === null
? "bg-blue-600 text-white"
: "hover:bg-white/5 text-gray-300 hover:text-white"
}`}
onClick={() => selectFolder(null)}
>
<svg className="w-4 h-4 shrink-0 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm font-medium">All Media</span>
{selectedFolderId === null && (
<span className="ml-auto text-xs opacity-60">{totalImages.toLocaleString()}</span>
)}
</div>
</div>
{/* Folder list */}
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-0.5 min-h-0">
{folders.length === 0 ? (
<p className="text-gray-600 text-xs px-3 py-4 text-center">
No folders added yet
</p>
) : (
folders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]}
/>
))
)}
</div>
{/* Add folder button */}
<div className="px-3 pb-4 pt-2 border-t border-white/5">
<button
onClick={handleAddFolder}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Media Folder
</button>
</div>
</aside>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from "react";
import { tileSizeForZoom, useGalleryStore, SortOrder } from "../store";
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" },
{ value: "name_asc", label: "Name A-Z" },
{ value: "name_desc", label: "Name Z-A" },
{ value: "size_desc", label: "Largest first" },
{ value: "size_asc", label: "Smallest first" },
];
function FilterChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
active
? "border-blue-400/50 bg-blue-500/15 text-white"
: "border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white"
}`}
onClick={onClick}
>
{label}
</button>
);
}
export function Toolbar() {
const {
search,
setSearch,
sort,
setSort,
totalImages,
loadedCount,
selectedFolderId,
folders,
mediaFilter,
setMediaFilter,
favoritesOnly,
setFavoritesOnly,
zoomPreset,
setZoomPreset,
} = useGalleryStore();
const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
const title = selectedFolder ? selectedFolder.name : "All Media";
const tileSize = tileSizeForZoom(zoomPreset);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setSearch(searchValue);
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchValue, setSearch]);
return (
<div className="border-b border-white/5 bg-gray-950/60 px-5 py-4 backdrop-blur-xl shrink-0">
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h2 className="truncate text-base font-semibold text-white">{title}</h2>
<div className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] uppercase tracking-[0.16em] text-gray-400">
{favoritesOnly ? "Favorites" : mediaFilter === "all" ? "Mixed Library" : mediaFilter}
</div>
</div>
<p className="mt-1 text-xs text-gray-500">
{loadedCount < totalImages
? `Showing ${loadedCount.toLocaleString()} of ${totalImages.toLocaleString()} items`
: `${totalImages.toLocaleString()} items ready`}
</p>
</div>
<div className="relative">
<svg
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z"
/>
</svg>
<input
type="text"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search filenames, scenes, references..."
className="w-72 rounded-xl border border-white/10 bg-white/5 py-2 pl-9 pr-4 text-sm text-white placeholder:text-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
<select
value={sort}
onChange={(event) => setSort(event.target.value as SortOrder)}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value} className="bg-gray-900">
{option.label}
</option>
))}
</select>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<FilterChip label="All" active={mediaFilter === "all"} onClick={() => setMediaFilter("all")} />
<FilterChip label="Images" active={mediaFilter === "image"} onClick={() => setMediaFilter("image")} />
<FilterChip label="Videos" active={mediaFilter === "video"} onClick={() => setMediaFilter("video")} />
<FilterChip label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
<div className="ml-auto flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-400">
<span className="px-2">Tile {tileSize}px</span>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "compact" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("compact")}
>
S
</button>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "comfortable" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("comfortable")}
>
M
</button>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "detail" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("detail")}
>
L
</button>
</div>
</div>
</div>
);
}