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
+116
View File
@@ -0,0 +1,116 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect } from "react";
import { useGalleryStore } from "./store";
import { Sidebar } from "./components/Sidebar";
import { MenuBar } from "./components/MenuBar";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
export default function App() {
const { loadFolders, loadImages, subscribeToProgress } = useGalleryStore();
useEffect(() => {
loadFolders().then(() => loadImages(true));
let unlisten: (() => void) | undefined;
subscribeToProgress().then((fn) => {
unlisten = fn;
});
return () => {
unlisten?.();
};
}, []);
return (
<div className="flex h-screen bg-gray-950 text-white overflow-hidden select-none">
<Sidebar />
<main className="flex-1 flex flex-col min-w-0">
<MenuBar />
<Toolbar />
<Gallery />
</main>
<Lightbox />
</div>
);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+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>
);
}
+32
View File
@@ -0,0 +1,32 @@
@import "tailwindcss";
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
background: #030712;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+361
View File
@@ -0,0 +1,361 @@
import { create } from "zustand";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
import { appDataDir, join } from "@tauri-apps/api/path";
export interface Folder {
id: number;
path: string;
name: string;
image_count: number;
indexed_at: string | null;
}
export type MediaKind = "image" | "video";
export type MediaFilter = "all" | MediaKind;
export type ZoomPreset = "compact" | "comfortable" | "detail";
export interface ImageRecord {
id: number;
folder_id: number;
path: string;
filename: string;
thumbnail_path: string | null;
width: number | null;
height: number | null;
file_size: number;
created_at: string | null;
modified_at: string | null;
mime_type: string;
media_kind: MediaKind;
favorite: boolean;
rating: number;
embedding_status: string;
embedding_model: string | null;
embedding_updated_at: string | null;
embedding_error: string | null;
}
export interface IndexProgress {
folder_id: number;
total: number;
indexed: number;
current_file: string;
done: boolean;
}
export interface IndexedImagesBatch {
folder_id: number;
images: ImageRecord[];
}
export type SortOrder =
| "date_desc"
| "date_asc"
| "name_asc"
| "name_desc"
| "size_desc"
| "size_asc";
interface GalleryState {
folders: Folder[];
selectedFolderId: number | null;
images: ImageRecord[];
totalImages: number;
loadedCount: number;
loadingImages: boolean;
search: string;
sort: SortOrder;
mediaFilter: MediaFilter;
favoritesOnly: boolean;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
indexingProgress: Record<number, IndexProgress>;
cacheDir: string;
loadFolders: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
removeFolder: (folderId: number) => Promise<void>;
reindexFolder: (folderId: number) => Promise<void>;
selectFolder: (folderId: number | null) => void;
loadImages: (reset?: boolean) => Promise<void>;
loadMoreImages: () => Promise<void>;
setSearch: (search: string) => void;
setSort: (sort: SortOrder) => void;
setMediaFilter: (filter: MediaFilter) => void;
setFavoritesOnly: (favoritesOnly: boolean) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
closeImage: () => void;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
setCacheDir: (dir: string) => void;
subscribeToProgress: () => Promise<UnlistenFn>;
}
const PAGE_SIZE = 200;
function matchesSearch(image: ImageRecord, search: string): boolean {
if (!search) return true;
return image.filename.toLowerCase().includes(search.toLowerCase());
}
function matchesFilters(
image: ImageRecord,
selectedFolderId: number | null,
mediaFilter: MediaFilter,
favoritesOnly: boolean,
search: string,
): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter;
const matchesFavorite = !favoritesOnly || image.favorite;
return matchesFolder && matchesMedia && matchesFavorite && matchesSearch(image, search);
}
function compareNullableNumber(a: number | null, b: number | null): number {
return (a ?? 0) - (b ?? 0);
}
function compareNullableDate(a: string | null, b: string | null): number {
return (a ? Date.parse(a) : 0) - (b ? Date.parse(b) : 0);
}
function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number {
switch (sort) {
case "name_asc":
return a.filename.localeCompare(b.filename);
case "name_desc":
return b.filename.localeCompare(a.filename);
case "date_asc":
return compareNullableDate(a.modified_at, b.modified_at);
case "date_desc":
return compareNullableDate(b.modified_at, a.modified_at);
case "size_asc":
return compareNullableNumber(a.file_size, b.file_size);
case "size_desc":
return compareNullableNumber(b.file_size, a.file_size);
default:
return compareNullableDate(b.modified_at, a.modified_at);
}
}
function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sort: SortOrder): ImageRecord[] {
const merged = new Map<string, ImageRecord>();
for (const image of currentImages) {
merged.set(image.path, image);
}
for (const image of newImages) {
merged.set(image.path, image);
}
return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort));
}
function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
return mergeImages(images, [updatedImage], sort);
}
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
switch (zoomPreset) {
case "compact":
return 160;
case "detail":
return 280;
default:
return 220;
}
}
export const useGalleryStore = create<GalleryState>((set, get) => ({
folders: [],
selectedFolderId: null,
images: [],
totalImages: 0,
loadedCount: 0,
loadingImages: false,
search: "",
sort: "date_desc",
mediaFilter: "all",
favoritesOnly: false,
zoomPreset: "comfortable",
selectedImage: null,
indexingProgress: {},
cacheDir: "",
setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => {
const folders = await invoke<Folder[]>("get_folders");
set({ folders });
},
addFolder: async (path) => {
const { cacheDir, loadFolders } = get();
await invoke("add_folder", { path, cacheDir });
await loadFolders();
},
removeFolder: async (folderId) => {
await invoke("remove_folder", { folderId });
const { selectedFolderId, loadFolders, loadImages } = get();
await loadFolders();
if (selectedFolderId === folderId) {
set({ selectedFolderId: null });
await loadImages(true);
}
},
reindexFolder: async (folderId) => {
const { cacheDir, loadFolders } = get();
await invoke("reindex_folder", { folderId, cacheDir });
await loadFolders();
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, images: [], loadedCount: 0 });
void get().loadImages(true);
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly } = get();
set({ loadingImages: true });
try {
const offset = reset ? 0 : loadedCount;
const result = await invoke<{
images: ImageRecord[];
total: number;
offset: number;
limit: number;
}>("get_images", {
params: {
folder_id: selectedFolderId,
search: search || null,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
sort,
offset,
limit: PAGE_SIZE,
},
});
set((state) => ({
images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total,
loadedCount: reset ? result.images.length : state.loadedCount + result.images.length,
loadingImages: false,
}));
} catch (error) {
console.error("Failed to load media:", error);
set({ loadingImages: false });
}
},
loadMoreImages: async () => {
const { loadedCount, totalImages, loadingImages } = get();
if (loadingImages || loadedCount >= totalImages) return;
await get().loadImages(false);
},
setSearch: (search) => {
set({ search, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setSort: (sort) => {
set({ sort, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setMediaFilter: (mediaFilter) => {
set({ mediaFilter, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setFavoritesOnly: (favoritesOnly) => {
set({ favoritesOnly, images: [], loadedCount: 0 });
void get().loadImages(true);
},
setZoomPreset: (zoomPreset) => set({ zoomPreset }),
openImage: (image) => set({ selectedImage: image }),
closeImage: () => set({ selectedImage: null }),
updateImageDetails: async (imageId, updates) => {
const updatedImage = await invoke<ImageRecord>("update_image_details", {
params: {
image_id: imageId,
favorite: updates.favorite ?? null,
rating: updates.rating ?? null,
},
});
set((state) => ({
images: replaceImage(state.images, updatedImage, state.sort),
selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage,
}));
},
subscribeToProgress: async () => {
const unlistenProgress = await listen<IndexProgress>("index-progress", (event) => {
const progress = event.payload;
set((state) => ({
indexingProgress: {
...state.indexingProgress,
[progress.folder_id]: progress,
},
}));
if (progress.done) {
void get().loadFolders();
setTimeout(() => {
set((state) => {
const next = { ...state.indexingProgress };
delete next[progress.folder_id];
return { indexingProgress: next };
});
}, 2000);
}
});
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
const batch = event.payload;
set((state) => {
const visibleImages = batch.images.filter((image) =>
matchesFilters(
image,
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.search,
),
);
if (visibleImages.length === 0) {
return state;
}
const images = mergeImages(state.images, visibleImages, state.sort);
return {
images,
loadedCount: images.length,
totalImages: Math.max(state.totalImages, images.length),
};
});
});
return () => {
unlistenProgress();
unlistenImages();
};
},
}));
appDataDir().then(async (dir) => {
useGalleryStore.getState().setCacheDir(await join(dir, "thumbnails"));
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />