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:
@@ -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,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
@@ -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,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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user