feat: add color search and reusable tooltips

Add dominant-color palette extraction, storage, filtering, and startup backfill so the gallery and Timeline can be filtered from the toolbar color picker.

Introduce a reusable tooltip component and migrate the color filter, update indicator, and gallery filename hover affordances to it.
This commit is contained in:
2026-06-28 10:25:52 +01:00
parent e3fde46e91
commit 90dec3b212
13 changed files with 682 additions and 46 deletions
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
type Rgb = [number, number, number];
// Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
}
function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const colorBackfill = useGalleryStore((state) => state.colorBackfill);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const isActive = colorFilter !== null;
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb));
// Collapse the panel when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
return (
<div ref={ref} className="ml-1 flex items-center border-l border-white/[0.06] pl-2">
{/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
<button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200"
}`}
onClick={() => setOpen((value) => !value)}
aria-label="Filter by color"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" />
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
</svg>
{isActive ? (
<span
className="h-3 w-3 rounded-full border border-white/30"
style={{ backgroundColor: toHex(colorFilter as Rgb) }}
/>
) : null}
</button>
</Tooltip>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: "auto", opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
// Horizontal/vertical padding gives the active swatch's ring + scale
// room inside the overflow-hidden clip box (needed for the width slide).
// py-1 keeps the panel shorter than the always-present filter pills so
// opening it never changes the row height (no 1px toolbar shift).
className="flex items-center gap-1 overflow-hidden whitespace-nowrap px-1.5 py-1"
>
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
return (
<button
key={swatch.name}
title={swatch.name}
aria-label={`Filter by ${swatch.name}`}
className={`h-4 w-4 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
);
})}
{/* Custom color picker — rainbow until a custom color is chosen. */}
<label
title="Custom color"
className={`relative h-4 w-4 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
{isActive ? (
<button
className="ml-0.5 shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)}
title="Clear color filter"
>
Clear
</button>
) : null}
{colorBackfill && colorBackfill.total > 0 ? (
<span
className="ml-0.5 shrink-0 text-[10px] text-gray-600"
title="Sampling colors from existing thumbnails — color search fills in as this runs"
>
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
import { Tooltip } from "./Tooltip";
const GAP = 6;
@@ -125,6 +126,7 @@ export function ImageTile({
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<Tooltip label={image.filename} delay={500} block followCursor>
<button
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
@@ -141,7 +143,6 @@ export function ImageTile({
onClick();
}}
onContextMenu={onContextMenu}
title={image.filename}
>
{/* Selection corner — a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The
@@ -281,6 +282,7 @@ export function ImageTile({
</div>
</div>
</button>
</Tooltip>
);
}
+13 -16
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store";
import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
// SVG icons for window controls
function MinimizeIcon() {
@@ -79,22 +80,18 @@ export function TitleBar() {
up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pl-3 pr-4">
{updatePending ? (
<div
className="group relative"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
>
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
Click to update v{updateVersion}
</span>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
{/* Instant tooltip (delay 0) — this affordance should read immediately. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button
onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
>
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
</Tooltip>
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
@@ -478,6 +479,7 @@ export function Toolbar() {
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
<ColorFilter />
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
</div>
+141
View File
@@ -0,0 +1,141 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2";
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2";
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
}
const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
/**
* Lightweight custom tooltip — fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer
* (fixed-positioned, so it escapes scroll-container clipping).
*/
export function Tooltip({
label,
delay = 400,
side = "bottom",
align = "center",
block = false,
followCursor = false,
className = "",
children,
}: {
label: ReactNode;
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLSpanElement>) => {
clear();
if (followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
const move = (event: MouseEvent<HTMLSpanElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
useEffect(() => clear, []);
return (
<span
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{followCursor ? (
<motion.span
ref={tooltipRef}
role="tooltip"
// Fixed (so the scroll container's overflow doesn't clip it) and
// positioned by `place()` near the cursor with viewport-edge flipping.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: { type: "spring", stiffness: 700, damping: 45, mass: 0.35 },
top: { type: "spring", stiffness: 520, damping: 34, mass: 0.45 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
) : (
<span
role="tooltip"
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
>
{label}
</span>
)}
</span>
);
}
+20 -1
View File
@@ -354,6 +354,8 @@ interface GalleryState {
minimumRating: number;
failedEmbeddingsOnly: boolean;
failedTaggingOnly: boolean;
colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter
colorBackfill: { processed: number; total: number; done: boolean } | null;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
collectionTitle: string | null;
@@ -469,6 +471,7 @@ interface GalleryState {
setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setFailedTaggingOnly: (failedTaggingOnly: boolean) => void;
setColorFilter: (color: [number, number, number] | null) => void;
showFailedTagging: (folderId: number) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
@@ -834,6 +837,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
minimumRating: 0,
failedEmbeddingsOnly: false,
failedTaggingOnly: false,
colorFilter: null,
colorBackfill: null,
zoomPreset: "comfortable",
selectedImage: null,
collectionTitle: null,
@@ -1062,7 +1067,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get();
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, colorFilter, activeView } = get();
const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken;
// Any fresh collection load invalidates a selection that referenced the
@@ -1176,6 +1181,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly,
tagging_failed_only: failedTaggingOnly,
color: colorFilter,
sort,
offset,
limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE,
@@ -1317,6 +1323,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
setColorFilter: (colorFilter) => {
set({ colorFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
showFailedTagging: (folderId) => {
set({
selectedFolderId: folderId,
@@ -2939,6 +2950,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
});
const unlistenColorBackfill = await listen<{ processed: number; total: number; done: boolean }>(
"color-backfill-progress",
(event) => {
set({ colorBackfill: event.payload.done ? null : event.payload });
},
);
return () => {
unlistenProgress();
unlistenMediaJobs();
@@ -2949,6 +2967,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
unlistenWatcherDeleted();
unlistenFolderCounts();
unlistenFfmpegProgress();
unlistenColorBackfill();
};
},
}));