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>
);
}