Polish search and embedding UX
- add semantic text search with toolbar mode switching and sqlite-vec query support - improve embedding progress visibility, failure recovery, and similar-image affordances - add search clearing and keyboard controls for filename vs semantic search modes - refine background task interactions and gallery/lightbox embedding states Refs: #4
This commit is contained in:
+104
-137
@@ -2,27 +2,7 @@ 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>
|
||||
);
|
||||
}
|
||||
const GAP = 6;
|
||||
|
||||
function formatDuration(durationMs: number | null): string | null {
|
||||
if (!durationMs || durationMs <= 0) return null;
|
||||
@@ -30,27 +10,12 @@ function formatDuration(durationMs: number | null): string | null {
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function embeddingLabel(image: ImageRecord): string {
|
||||
if (image.embedding_status === "ready") {
|
||||
return image.embedding_model ? `Embeddings ready` : "Embeddings ready";
|
||||
}
|
||||
if (image.embedding_status === "failed") {
|
||||
return "Embeddings failed";
|
||||
}
|
||||
if (image.embedding_status === "processing") {
|
||||
return "Embedding...";
|
||||
}
|
||||
return "Embedding queued";
|
||||
}
|
||||
|
||||
function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
@@ -65,60 +30,57 @@ function ContextMenu({
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||
const canFindSimilar = image.embedding_status === "ready";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-gallery-context-menu
|
||||
className="fixed z-40 min-w-56 rounded-2xl border border-white/10 bg-gray-950/95 p-2 shadow-2xl backdrop-blur"
|
||||
className="fixed z-40 min-w-52 rounded-xl border border-white/10 bg-gray-950/98 p-1 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();
|
||||
}}
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
|
||||
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();
|
||||
}}
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
|
||||
onClick={async () => { await updateImageDetails(image.id, { favorite: !image.favorite }); onClose(); }}
|
||||
>
|
||||
{image.favorite ? "Remove Favorite" : "Add to Favorites"}
|
||||
</button>
|
||||
<button
|
||||
className="w-full rounded-xl px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5"
|
||||
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
canFindSimilar
|
||||
? "text-gray-200 hover:bg-white/5 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
}`}
|
||||
onClick={async () => {
|
||||
if (!canFindSimilar) return;
|
||||
await loadSimilarImages(image.id);
|
||||
onClose();
|
||||
}}
|
||||
disabled={!canFindSimilar}
|
||||
>
|
||||
Find Similar
|
||||
{canFindSimilar ? "Find Similar" : "Embeddings not ready"}
|
||||
</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 items-center gap-1 px-2 pb-1">
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600">Rating</div>
|
||||
<div className="flex items-center gap-0.5 px-2 pb-1.5">
|
||||
{Array.from({ length: 5 }, (_, index) => {
|
||||
const rating = index + 1;
|
||||
return (
|
||||
<button
|
||||
key={rating}
|
||||
className="rounded-md p-1"
|
||||
onClick={async () => {
|
||||
await updateImageDetails(image.id, { rating });
|
||||
onClose();
|
||||
}}
|
||||
className="rounded-md p-1 transition-colors hover:bg-white/5"
|
||||
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }}
|
||||
title={`Set ${rating} star rating`}
|
||||
>
|
||||
<svg
|
||||
className={`h-5 w-5 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
|
||||
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>
|
||||
@@ -127,14 +89,11 @@ function ContextMenu({
|
||||
})}
|
||||
{image.rating > 0 ? (
|
||||
<button
|
||||
className="ml-auto rounded-lg p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
|
||||
onClick={async () => {
|
||||
await updateImageDetails(image.id, { rating: 0 });
|
||||
onClose();
|
||||
}}
|
||||
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
|
||||
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
|
||||
title="Remove rating"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -156,6 +115,7 @@ function ImageTile({
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [errored, setErrored] = useState(false);
|
||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||
const canFindSimilar = image.embedding_status === "ready";
|
||||
|
||||
const src = image.thumbnail_path
|
||||
? convertFileSrc(image.thumbnail_path)
|
||||
@@ -165,94 +125,102 @@ function ImageTile({
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-white/[0.03] text-left"
|
||||
className="group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
|
||||
style={{ width: "100%", aspectRatio: "1 / 1" }}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
title={image.filename}
|
||||
>
|
||||
{/* Image / placeholder */}
|
||||
{src && !errored ? (
|
||||
<>
|
||||
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
|
||||
{!loaded && <div className="absolute inset-0 animate-pulse bg-white/[0.04]" />}
|
||||
<img
|
||||
src={src}
|
||||
alt={image.filename}
|
||||
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
|
||||
className={`h-full w-full object-cover transition-all duration-300 ${
|
||||
loaded ? "opacity-100 scale-100" : "opacity-0 scale-[1.02]"
|
||||
} group-hover:scale-[1.03]`}
|
||||
loading="lazy"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setErrored(true)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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 text-gray-300">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
|
||||
{image.media_kind === "video" ? (
|
||||
<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>
|
||||
<svg className="h-7 w-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<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 className="h-7 w-7" 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>
|
||||
)}
|
||||
|
||||
{image.media_kind === "video" ? (
|
||||
{/* Video play icon — subtle at rest, visible on hover */}
|
||||
{image.media_kind === "video" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="rounded-full border border-white/10 bg-black/35 p-3 text-white shadow-lg backdrop-blur-sm">
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<div className="rounded-full bg-black/40 p-3 text-white backdrop-blur-sm opacity-50 group-hover:opacity-90 transition-opacity duration-200">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<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.media_kind === "video" && image.duration_ms ? (
|
||||
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] font-medium text-white/80">
|
||||
{formatDuration(image.duration_ms)}
|
||||
</div>
|
||||
) : null}
|
||||
{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>
|
||||
{/* Persistent badges — only shown when meaningful */}
|
||||
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
|
||||
{image.favorite && (
|
||||
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
|
||||
<svg className="h-2.5 w-2.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>
|
||||
)}
|
||||
{image.media_kind === "video" && image.duration_ms && (
|
||||
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
|
||||
{formatDuration(image.duration_ms)}
|
||||
</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>{embeddingLabel(image)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{/* Hover overlay — slides up from bottom */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
|
||||
|
||||
{/* Hover info — appears with overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2.5 translate-y-1 group-hover:translate-y-0 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
<p className="truncate text-[12px] font-medium text-white leading-tight">{image.filename}</p>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
{image.rating > 0 ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: image.rating }, (_, i) => (
|
||||
<svg key={i} className="h-2.5 w-2.5 text-amber-300" 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>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
className="rounded-full border border-white/10 bg-black/40 px-2.5 py-1 text-[11px] text-white/85 hover:bg-black/60"
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm ${
|
||||
canFindSimilar
|
||||
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
|
||||
: "bg-white/5 text-white/30 cursor-not-allowed"
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!canFindSimilar) return;
|
||||
void loadSimilarImages(image.id);
|
||||
}}
|
||||
disabled={!canFindSimilar}
|
||||
>
|
||||
Find Similar
|
||||
Similar
|
||||
</button>
|
||||
<span className="text-[11px] text-white/50">Right-click for more</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -288,15 +256,11 @@ export function Gallery() {
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) {
|
||||
return;
|
||||
}
|
||||
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
||||
setContextMenu(null);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setContextMenu(null);
|
||||
}
|
||||
if (event.key === "Escape") setContextMenu(null);
|
||||
};
|
||||
window.addEventListener("pointerdown", close);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
@@ -308,23 +272,21 @@ export function Gallery() {
|
||||
|
||||
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 className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" 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 text-white/30 font-medium">No media found</p>
|
||||
<p className="text-xs text-white/15 mt-1">Try adjusting your filters or add a new folder</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#060816]">
|
||||
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
|
||||
<div
|
||||
className="grid content-start"
|
||||
style={{
|
||||
@@ -347,13 +309,18 @@ export function Gallery() {
|
||||
</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 className="flex justify-center py-8">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{contextMenu ? (
|
||||
<ContextMenu x={contextMenu.x} y={contextMenu.y} image={contextMenu.image} onClose={() => setContextMenu(null)} />
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
image={contextMenu.image}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user