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:
+116
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user