Compare commits

..

2 Commits

Author SHA1 Message Date
LyAhn d619b01f2e refactor(ui): replace native app tooltips
github/actions/ci GitHub Actions CI finished: success
Wrap app controls with the shared Tooltip component and use cursor-positioned tooltip placement for icon buttons, chips, path labels, and media controls.

Leave native title attributes on the window chrome buttons so Minimize, Maximize, and Close keep platform-style behavior.
2026-06-30 23:25:43 +01:00
LyAhn 1a971899d1 feat(search): filter tag results by colour
Pass the active colour filter through tag searches and apply the existing palette match inside the tag query and count paths.

Update the UI Lab mock backend so colour filtering behaves the same way when testing tag search results.
2026-06-30 23:24:25 +01:00
21 changed files with 490 additions and 330 deletions
+4
View File
@@ -162,6 +162,8 @@ pub struct TagSearchParams {
pub media_kind: Option<String>, pub media_kind: Option<String>,
pub favorites_only: Option<bool>, pub favorites_only: Option<bool>,
pub rating_min: Option<i64>, pub rating_min: Option<i64>,
/// Optional `[r, g, b]` color filter applied within the tag result set.
pub color: Option<[u8; 3]>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub offset: Option<usize>, pub offset: Option<usize>,
} }
@@ -957,6 +959,7 @@ pub async fn search_images_by_tag(
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
let limit = params.limit.unwrap_or(64); let limit = params.limit.unwrap_or(64);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
let color = params.color.map(|[r, g, b]| (r, g, b));
let (images, total) = db::search_images_by_tag( let (images, total) = db::search_images_by_tag(
&conn, &conn,
&params.query, &params.query,
@@ -964,6 +967,7 @@ pub async fn search_images_by_tag(
params.media_kind.as_deref(), params.media_kind.as_deref(),
params.favorites_only.unwrap_or(false), params.favorites_only.unwrap_or(false),
params.rating_min.unwrap_or(0), params.rating_min.unwrap_or(0),
color,
limit, limit,
offset, offset,
) )
+32 -3
View File
@@ -2244,6 +2244,7 @@ pub fn search_images_by_tag(
media_kind: Option<&str>, media_kind: Option<&str>,
favorites_only: bool, favorites_only: bool,
rating_min: i64, rating_min: i64,
color: Option<(u8, u8, u8)>,
limit: usize, limit: usize,
offset: usize, offset: usize,
) -> Result<(Vec<ImageRecord>, usize)> { ) -> Result<(Vec<ImageRecord>, usize)> {
@@ -2252,6 +2253,10 @@ pub fn search_images_by_tag(
return Ok((Vec::new(), 0)); return Ok((Vec::new(), 0));
} }
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let (color_flag, qr, qg, qb) = match color {
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
None => (0, 0, 0, 0),
};
// Total count (for pagination) // Total count (for pagination)
let total: usize = conn.query_row( let total: usize = conn.query_row(
@@ -2262,13 +2267,25 @@ pub fn search_images_by_tag(
AND (?2 IS NULL OR i.media_kind = ?2) AND (?2 IS NULL OR i.media_kind = ?2)
AND (?3 = 0 OR i.favorite = 1) AND (?3 = 0 OR i.favorite = 1)
AND i.rating >= ?4 AND i.rating >= ?4
AND LOWER(TRIM(t.tag)) = ?5", AND LOWER(TRIM(t.tag)) = ?5
AND (?6 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = i.id
AND c.weight >= ?11
AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10
))",
params![ params![
folder_id, folder_id,
media_kind, media_kind,
favorites_flag, favorites_flag,
rating_min, rating_min,
normalized_query normalized_query,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
], ],
|row| row.get::<_, i64>(0), |row| row.get::<_, i64>(0),
)? as usize; )? as usize;
@@ -2282,8 +2299,14 @@ pub fn search_images_by_tag(
AND (?3 = 0 OR i.favorite = 1) AND (?3 = 0 OR i.favorite = 1)
AND i.rating >= ?4 AND i.rating >= ?4
AND LOWER(TRIM(t.tag)) = ?5 AND LOWER(TRIM(t.tag)) = ?5
AND (?6 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = i.id
AND c.weight >= ?11
AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10
))
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
LIMIT ?6 OFFSET ?7", LIMIT ?12 OFFSET ?13",
)?; )?;
let image_ids = stmt let image_ids = stmt
@@ -2294,6 +2317,12 @@ pub fn search_images_by_tag(
favorites_flag, favorites_flag,
rating_min, rating_min,
normalized_query, normalized_query,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT,
limit as i64, limit as i64,
offset as i64 offset as i64
], ],
+11 -5
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, WorkerKey } from "../store"; import { useGalleryStore, WorkerKey } from "../store";
import { Tooltip } from "./Tooltip";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = { const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail", Thumbnails: "thumbnail",
@@ -52,15 +53,16 @@ function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
<p className="truncate text-[9px] text-gray-600">{item.error}</p> <p className="truncate text-[9px] text-gray-600">{item.error}</p>
)} )}
</div> </div>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button <button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100" className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(item.path)} onClick={() => void revealItemInDir(item.path)}
> >
<svg className="h-3 w-3" 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={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
); );
} }
@@ -378,9 +380,9 @@ export function BackgroundTasks() {
{stage.detail} {stage.detail}
</span> </span>
{workerKey && ( {workerKey && (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<button <button
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity" className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }} onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }}
> >
{isPaused ? ( {isPaused ? (
@@ -393,6 +395,7 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
</button> </button>
</Tooltip>
)} )}
</span> </span>
); );
@@ -455,15 +458,16 @@ export function BackgroundTasks() {
{/* Dismiss — hidden for system tasks like duplicate scan */} {/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && ( {primary.id >= 0 && (
<Tooltip label="Dismiss" anchorToCursor>
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }} onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
)} )}
</div> </div>
@@ -508,9 +512,9 @@ export function BackgroundTasks() {
{stage.detail} {stage.detail}
</span> </span>
{workerKey && ( {workerKey && (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<button <button
className="ml-0.5 text-gray-600 hover:text-white transition-colors" className="ml-0.5 text-gray-600 hover:text-white transition-colors"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={() => toggleWorker(task.id, workerKey)} onClick={() => toggleWorker(task.id, workerKey)}
> >
{isPaused ? ( {isPaused ? (
@@ -523,6 +527,7 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
</button> </button>
</Tooltip>
)} )}
</span> </span>
); );
@@ -565,15 +570,16 @@ export function BackgroundTasks() {
)} )}
{task.id >= 0 && ( {task.id >= 0 && (
<Tooltip label="Dismiss" anchorToCursor>
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={() => dismissTask(task.id, task.snapshot)} onClick={() => dismissTask(task.id, task.snapshot)}
> >
<svg className="h-3 w-3" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
)} )}
</div> </div>
+12 -8
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { BulkTagPopover } from "./bulk/BulkTagPopover"; import { BulkTagPopover } from "./bulk/BulkTagPopover";
import { Tooltip } from "./Tooltip"
type Panel = "tag" | "rating" | "album" | "delete" | null; type Panel = "tag" | "rating" | "album" | "delete" | null;
@@ -93,13 +94,14 @@ export function BulkActionBar() {
<div className="flex items-center gap-2 px-1.5"> <div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span> <span className="text-xs font-medium text-white">{selectedCount} selected</span>
{loadedCount < totalImages || loadedCount > selectedCount ? ( {loadedCount < totalImages || loadedCount > selectedCount ? (
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}>
<button <button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300" className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={selectAllGallery} onClick={selectAllGallery}
title={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}
> >
Select all{loadedCount < totalImages ? " loaded" : ""} Select all{loadedCount < totalImages ? " loaded" : ""}
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
@@ -124,6 +126,7 @@ export function BulkActionBar() {
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1;
return ( return (
<Tooltip label={`Set ${rating} star${rating === 1 ? "" : "s"}`}>
<button <button
key={rating} key={rating}
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300" className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
@@ -131,12 +134,12 @@ export function BulkActionBar() {
await bulkSetRating(rating); await bulkSetRating(rating);
setPanel(null); setPanel(null);
}} }}
title={`Set ${rating} star${rating === 1 ? "" : "s"}`}
> >
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-4 w-4" 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" /> <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> </svg>
</button> </button>
</Tooltip>
); );
})} })}
<button <button
@@ -151,11 +154,11 @@ export function BulkActionBar() {
</div> </div>
) : null} ) : null}
</div> </div>
<Tooltip label="Mark as favorite" followCursor>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)} title="Mark as favorite"> <button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
Favorite Favorite
</button> </button>
</Tooltip>
<div className="relative"> <div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}> <button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
Add to album Add to album
@@ -219,14 +222,15 @@ export function BulkActionBar() {
<div className="h-5 w-px bg-white/10" /> <div className="h-5 w-px bg-white/10" />
<div className="relative"> <div className="relative">
<Tooltip label="Delete files from disk" followCursor>
<button <button
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`} className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
onClick={() => togglePanel("delete")} onClick={() => togglePanel("delete")}
disabled={deleting} disabled={deleting}
title="Delete files from disk"
> >
{deleting ? "Deleting…" : "Delete"} {deleting ? "Deleting…" : "Delete"}
</button> </button>
</Tooltip>
{panel === "delete" ? ( {panel === "delete" ? (
<div <div
data-bulk-popover data-bulk-popover
@@ -261,16 +265,16 @@ export function BulkActionBar() {
</div> </div>
) : null} ) : null}
</div> </div>
<Tooltip label="Clear selection" followCursor>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection} onClick={clearGallerySelection}
title="Clear selection"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
); );
} }
+8 -7
View File
@@ -59,7 +59,7 @@ export function ColorFilter() {
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2"> <div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2">
{/* Trigger — a single palette icon; shows the active color as a dot when a {/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */} filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}> <Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400} anchorToCursor>
<button <button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${ 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" open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200"
@@ -130,27 +130,28 @@ export function ColorFilter() {
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"} value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))} onChange={(event) => setColorFilter(fromHex(event.target.value))}
/> />
</label></Tooltip> </label>
</Tooltip>
</div> </div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( {isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40"> <div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40">
{colorBackfill && colorBackfill.total > 0 ? ( {colorBackfill && colorBackfill.total > 0 ? (
<span <Tooltip label="Sampling colours from existing thumbnails — colour search fills in as this runs" anchorToCursor>
className="text-[10px] text-gray-600" <span className="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()} sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span> </span>
</Tooltip>
) : <span />} ) : <span />}
{isActive ? ( {isActive ? (
<Tooltip label="Clear colour filter" anchorToCursor>
<button <button
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200" className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)} onClick={() => setColorFilter(null)}
title="Clear color filter"
> >
Clear Clear
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
) : null} ) : null}
+5 -2
View File
@@ -3,6 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { DuplicateGroup, useGalleryStore } from "../store"; import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { mediaSrc } from "../lib/mediaSrc"; import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -70,6 +71,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const isSelected = selectedIds.has(image.id); const isSelected = selectedIds.has(image.id);
const src = mediaSrc(image.thumbnail_path); const src = mediaSrc(image.thumbnail_path);
return ( return (
<Tooltip label={image.path} anchorToCursor>
<button <button
key={image.id} key={image.id}
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${ className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
@@ -79,7 +81,6 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
}`} }`}
style={{ width: 140, height: 105 }} style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)} onClick={() => toggleDuplicateSelected(image.id)}
title={image.path}
> >
{src ? ( {src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} /> <img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
@@ -99,6 +100,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p> <p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div> </div>
</button> </button>
</Tooltip>
); );
})} })}
</div> </div>
@@ -206,13 +208,14 @@ export function DuplicateFinder() {
<FolderScopeDropdown /> <FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */} {/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && ( {hasResults && selectedCount === 0 && !deleting && (
<Tooltip label={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`} anchorToCursor>
<button <button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={selectKeepFirstAllGroups} onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
> >
Select all duplicates Select all duplicates
</button> </button>
</Tooltip>
)} )}
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<> <>
+2 -1
View File
@@ -161,6 +161,7 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
}; };
return ( return (
<Tooltip label={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`} followCursor>
<motion.button <motion.button
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }} style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
@@ -189,7 +190,6 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
} }
whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }} whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)} onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
> >
{src ? ( {src ? (
<img <img
@@ -223,6 +223,7 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag
Open Open
</span> </span>
</motion.button> </motion.button>
</Tooltip>
); );
} }
+15 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
function normalizePath(path: string): string { function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
@@ -95,17 +96,18 @@ function FolderRow({
</svg> </svg>
</button> </button>
<Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left" className="flex w-full min-w-0 items-center gap-2 text-left"
onClick={onNavigate} onClick={onNavigate}
title={entry.path}
> >
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" /> <path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg> </svg>
<span className="truncate text-sm">{entry.name}</span> <span className="truncate text-sm">{entry.name}</span>
</button> </button>
</Tooltip>
{alreadyAdded ? ( {alreadyAdded ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950"> <span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
@@ -113,16 +115,17 @@ function FolderRow({
</span> </span>
) : null} ) : null}
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={onNavigate} onClick={onNavigate}
title={entry.has_children ? "Open folder" : "No subfolders"}
> >
<svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
); );
} }
@@ -164,11 +167,8 @@ function StagedFoldersPanel({
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{stagedPaths.map((path) => ( {stagedPaths.map((path) => (
<div <Tooltip key={path} label={path} anchorToCursor block>
key={path} <div className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800">
className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
title={path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" /> <path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg> </svg>
@@ -176,18 +176,20 @@ function StagedFoldersPanel({
<p className="truncate text-sm font-medium">{folderName(path)}</p> <p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p> <p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
</div> </div>
<Tooltip label="Remove from folders to add" anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700" className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
onClick={() => onRemove(path)} onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`} aria-label={`Remove ${path} from folders to add`}
title="Remove from folders to add"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
</Tooltip>
))} ))}
</div> </div>
)} )}
@@ -327,16 +329,17 @@ export function FolderPickerModal() {
<p className="text-base font-semibold text-white">Add media folders</p> <p className="text-base font-semibold text-white">Add media folders</p>
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p> <p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p>
</div> </div>
<Tooltip label="Close folder picker" anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={() => setFolderPickerOpen(false)} onClick={() => setFolderPickerOpen(false)}
title="Close folder picker"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
</header> </header>
@@ -355,14 +358,15 @@ export function FolderPickerModal() {
{breadcrumbs.map((crumb, index) => ( {breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1"> <span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null} {index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
<button <button
type="button" type="button"
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setCurrentPath(crumb.path)} onClick={() => setCurrentPath(crumb.path)}
title={crumb.path ?? "Roots"}
> >
{crumb.label} {crumb.label}
</button> </button>
</Tooltip>
</span> </span>
))} ))}
</nav> </nav>
+3 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
/** /**
* In-view folder scope picker for feature views (Timeline / Explore / * In-view folder scope picker for feature views (Timeline / Explore /
@@ -34,6 +35,7 @@ export function FolderScopeDropdown() {
return ( return (
<div ref={ref} className="feature-scope-dropdown relative"> <div ref={ref} className="feature-scope-dropdown relative">
<Tooltip label="Change folder scope" anchorToCursor>
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${ className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
@@ -41,7 +43,6 @@ export function FolderScopeDropdown() {
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white" ? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white" : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
title="Change folder scope"
> >
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
@@ -54,6 +55,7 @@ export function FolderScopeDropdown() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
</Tooltip>
{open ? ( {open ? (
<div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50"> <div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<button <button
+40 -10
View File
@@ -75,11 +75,10 @@ export function ContextMenu({
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1;
return ( return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<button <button
key={rating}
className="rounded-md p-1 transition-colors hover:bg-white/5" className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }} onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }}
title={`Set ${rating} star rating`}
> >
<svg <svg
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`} className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
@@ -88,18 +87,20 @@ export function ContextMenu({
<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" /> <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> </svg>
</button> </button>
</Tooltip>
); );
})} })}
{image.rating > 0 ? ( {image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button <button
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors" 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(); }} onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
title="Remove rating"
> >
<svg className="h-3 w-3" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -126,7 +127,6 @@ export function ImageTile({
const src = mediaSrc(image.thumbnail_path); const src = mediaSrc(image.thumbnail_path);
return ( return (
<Tooltip label={image.filename} delay={500} block followCursor>
<div <div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${ 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" : "" selected ? "ring-2 ring-inset ring-blue-400/80" : ""
@@ -221,15 +221,14 @@ export function ImageTile({
{/* Persistent badges — only shown when meaningful */} {/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none"> <div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.embedding_status === "failed" && ( {image.embedding_status === "failed" && (
<div <Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto">
className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm" <div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
title={image.embedding_error ?? "Embedding failed"}
>
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg> </svg>
</div> </div>
</Tooltip>
)} )}
{image.favorite && ( {image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm"> <div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
@@ -258,8 +257,8 @@ export function ImageTile({
<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" /> <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 */} {/* 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"> <div className="absolute bottom-0 left-0 right-0 z-20 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> <TruncatedFilename filename={image.filename} />
<div className="mt-1.5 flex items-center justify-between gap-2"> <div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? ( {image.rating > 0 ? (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
@@ -290,6 +289,37 @@ export function ImageTile({
</div> </div>
</div> </div>
</div> </div>
);
}
function TruncatedFilename({ filename }: { filename: string }) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const update = () => {
setIsTruncated(text.scrollWidth > text.clientWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(text);
return () => observer.disconnect();
}, [filename]);
const label = (
<p ref={textRef} className="truncate text-[12px] font-medium leading-tight text-white">
{filename}
</p>
);
return (
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
{label}
</Tooltip> </Tooltip>
); );
} }
+26 -12
View File
@@ -5,6 +5,7 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer"; import { VideoPlayer } from "./VideoPlayer";
import { mediaSrc } from "../lib/mediaSrc"; import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -525,15 +526,17 @@ export function Lightbox() {
<p className="text-xs text-gray-500">Details</p> <p className="text-xs text-gray-500">Details</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tooltip label= { selectedImage.favorite ? "Remove favorite" : "Add favorite" } followCursor>
<button <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"}`} 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 })} 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"> <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" /> <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> </svg>
</button> </button>
</Tooltip>
<Tooltip label= {canFindSimilar ? "Find similar images" : "Embeddings not ready"} followCursor>
<button <button
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${ className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar canFindSimilar
@@ -545,7 +548,6 @@ export function Lightbox() {
void findSimilar(selectedImage.id, selectedImage.folder_id); void findSimilar(selectedImage.id, selectedImage.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
title={canFindSimilar ? "Find similar images" : "Embeddings not ready"}
> >
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
@@ -555,6 +557,7 @@ export function Lightbox() {
</svg> </svg>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span> <span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
</button> </button>
</Tooltip>
</div> </div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}> <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"> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -566,6 +569,7 @@ export function Lightbox() {
{/* Search region button row */} {/* Search region button row */}
{canSearchRegion && ( {canSearchRegion && (
<div className="shrink-0 px-5 pb-3"> <div className="shrink-0 px-5 pb-3">
<Tooltip label={ regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content" } followCursor>
<button <button
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${ className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
regionSelectMode regionSelectMode
@@ -581,7 +585,6 @@ export function Lightbox() {
setIsDragging(false); setIsDragging(false);
}} }}
disabled={regionSearching} disabled={regionSearching}
title={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"}
> >
{regionSearching ? ( {regionSearching ? (
<span className="flex items-center justify-center gap-1.5"> <span className="flex items-center justify-center gap-1.5">
@@ -607,6 +610,7 @@ export function Lightbox() {
</span> </span>
)} )}
</button> </button>
</Tooltip>
</div> </div>
)} )}
@@ -618,11 +622,11 @@ export function Lightbox() {
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1;
return ( return (
<Tooltip label={`Set ${rating} star rating`} followCursor delay={750}>
<button <button
key={rating} key={rating}
className="rounded-md p-1" className="rounded-md p-1"
onClick={() => void updateImageDetails(selectedImage.id, { rating })} onClick={() => void updateImageDetails(selectedImage.id, { rating })}
title={`Set ${rating} star rating`}
> >
<svg <svg
className={`h-5 w-5 ${rating <= selectedImage.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`} className={`h-5 w-5 ${rating <= selectedImage.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`}
@@ -632,18 +636,20 @@ export function Lightbox() {
<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" /> <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> </svg>
</button> </button>
</Tooltip>
); );
})} })}
{selectedImage.rating > 0 ? ( {selectedImage.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button <button
className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white" 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 })} 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -717,10 +723,10 @@ export function Lightbox() {
</span> </span>
) : null} ) : null}
{selectedImage.media_kind === "image" ? ( {selectedImage.media_kind === "image" ? (
<Tooltip label={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"} followCursor>
<button <button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40" className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued} disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued}
title={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"}
onClick={() => { onClick={() => {
setTaggingQueued(true); setTaggingQueued(true);
void queueTaggingForImage(selectedImage.id).catch(() => undefined); void queueTaggingForImage(selectedImage.id).catch(() => undefined);
@@ -728,7 +734,7 @@ export function Lightbox() {
> >
{taggingQueued ? "Queued" : "AI tags"} {taggingQueued ? "Queued" : "AI tags"}
</button> </button>
) : null} </Tooltip>) : null}
</div> </div>
</div> </div>
@@ -736,16 +742,21 @@ export function Lightbox() {
<> <>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => ( {(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => (
<span <Tooltip
key={t.id} key={t.id}
label={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : ""}
followCursor
disabled={t.source !== "ai" || t.confidence === null}
>
<span
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${ className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
t.source === "ai" t.source === "ai"
? "border-sky-400/20 bg-sky-500/8 text-sky-300" ? "border-sky-400/20 bg-sky-500/8 text-sky-300"
: "border-white/10 bg-white/5 text-gray-300" : "border-white/10 bg-white/5 text-gray-300"
}`} }`}
title={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : undefined}
> >
{t.tag} {t.tag}
<Tooltip label="Remove tag" followCursor>
<button <button
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100" className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
onClick={() => { onClick={() => {
@@ -753,13 +764,14 @@ export function Lightbox() {
setImageTags((prev) => prev.filter((x) => x.id !== t.id)), setImageTags((prev) => prev.filter((x) => x.id !== t.id)),
); );
}} }}
title="Remove tag"
> >
<svg className="h-3 w-3" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</span> </span>
</Tooltip>
))} ))}
</div> </div>
{imageTags.length > 8 && ( {imageTags.length > 8 && (
@@ -914,9 +926,9 @@ export function Lightbox() {
</div> </div>
) : null} ) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? ( {imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<Tooltip label="Open location in your browser" anchorToCursor>
<button <button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300" className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
title="Open location in your browser"
onClick={() => onClick={() =>
void invoke("open_map_location", { void invoke("open_map_location", {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon }, params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
@@ -929,6 +941,7 @@ export function Lightbox() {
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -937,15 +950,16 @@ export function Lightbox() {
<div> <div>
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p> <p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button <button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300" className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(selectedImage.path)} onClick={() => void revealItemInDir(selectedImage.path)}
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p> <p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
</div> </div>
+4 -2
View File
@@ -3,6 +3,7 @@ import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbn
import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown"; import { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog"; import { getChangelogForVersion } from "../changelog";
import { Tooltip } from "./Tooltip";
type SettingsSection = "workspace" | "general"; type SettingsSection = "workspace" | "general";
@@ -394,15 +395,16 @@ export function SettingsModal() {
</div> </div>
</aside> </aside>
<Tooltip label="Close settings" anchorToCursor className="absolute right-4 top-4 z-10">
<button <button
className="absolute right-4 top-4 z-10 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => setSettingsOpen(false)} onClick={() => setSettingsOpen(false)}
title="Close settings"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
<main className="min-w-0 flex-1 overflow-y-auto"> <main className="min-w-0 flex-1 overflow-y-auto">
<div className="px-10 py-8"> <div className="px-10 py-8">
+15 -7
View File
@@ -4,6 +4,7 @@ import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown"; import { ThemedDropdown } from "./ThemedDropdown";
import { mediaSrc } from "../lib/mediaSrc"; import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
interface ContextMenuState { interface ContextMenuState {
folderId: number; folderId: number;
@@ -181,10 +182,10 @@ function FolderItem({
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
{customOrdering ? ( {customOrdering ? (
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
<button <button
type="button" type="button"
aria-label={`Reorder ${folder.name}`} aria-label={`Reorder ${folder.name}`}
title={`Drag to reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${ className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging dragging
? "cursor-grabbing bg-white/10 text-gray-300" ? "cursor-grabbing bg-white/10 text-gray-300"
@@ -208,6 +209,7 @@ function FolderItem({
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" /> <circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
{isMissing ? ( {isMissing ? (
<span className="shrink-0 text-amber-400"> <span className="shrink-0 text-amber-400">
@@ -273,9 +275,9 @@ function FolderItem({
</div> </div>
) : ( ) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip label="Reindex" anchorToCursor>
<button <button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors" className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
title="Reindex"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }} onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -283,9 +285,10 @@ function FolderItem({
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" /> 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> </svg>
</button> </button>
</Tooltip>
<Tooltip label="Remove from app" anchorToCursor>
<button <button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors" className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Remove from app"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }} onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -293,6 +296,7 @@ function FolderItem({
d="M6 18L18 6M6 6l12 12" /> d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
) )
)} )}
@@ -499,10 +503,10 @@ function AlbumItem({
{/* Drag handle — hover-revealed, reorders albums */} {/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? ( {reorderable ? (
<Tooltip label="Drag to reorder" anchorToCursor>
<button <button
type="button" type="button"
aria-label={`Reorder ${album.name}`} aria-label={`Reorder ${album.name}`}
title="Drag to reorder"
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100" className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -517,6 +521,7 @@ function AlbumItem({
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" /> <circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */} {/* Cover thumbnail — distinguishes albums from folder rows */}
@@ -833,15 +838,16 @@ export function Sidebar() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0"> <div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span> <span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
<Tooltip label="Add Media Folder" anchorToCursor>
<button <button
onClick={handleAddFolder} onClick={handleAddFolder}
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors" className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors"
title="Add Media Folder"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
{/* Nav */} {/* Nav */}
@@ -985,26 +991,28 @@ export function Sidebar() {
) : ( ) : (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{albums.length > 0 ? ( {albums.length > 0 ? (
<Tooltip label="Manage albums">
<button <button
onClick={() => setManageAlbums(true)} onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200" className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="Manage albums"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
<Tooltip label="New album">
<button <button
onClick={startCreatingAlbum} onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200" className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="New album"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
)} )}
</div> </div>
+5 -3
View File
@@ -327,20 +327,21 @@ export function Toolbar() {
/> />
{searchCommand !== null ? ( {searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2"> <div className="absolute left-8 top-1/2 -translate-y-1/2">
<Tooltip label="Remove search command" anchorToCursor>
<button <button
type="button" type="button"
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white" className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }} onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
title="Remove search command"
> >
{commandPrefix(searchCommand)} {commandPrefix(searchCommand)}
</button> </button>
</Tooltip>
</div> </div>
) : null} ) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? ( {searchQuery.trim().length > 0 || searchCommand !== null ? (
<Tooltip label="Clear search" anchorToCursor className="absolute right-2 top-1/2 -translate-y-1/2">
<button <button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors" className="rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
title="Clear search"
onClick={() => { onClick={() => {
setSearchCommand(null); setSearchCommand(null);
setSearchQuery(""); setSearchQuery("");
@@ -352,6 +353,7 @@ export function Toolbar() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
</div> </div>
+9 -1
View File
@@ -32,6 +32,7 @@ export function Tooltip({
block = false, block = false,
anchorToCursor = false, anchorToCursor = false,
followCursor = false, followCursor = false,
disabled = false,
className = "", className = "",
children, children,
}: { }: {
@@ -47,6 +48,8 @@ export function Tooltip({
anchorToCursor?: boolean; anchorToCursor?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */ /** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean; followCursor?: boolean;
/** Render the wrapper but suppress tooltip display. */
disabled?: boolean;
/** Extra classes for the wrapper (e.g. layout). */ /** Extra classes for the wrapper (e.g. layout). */
className?: string; className?: string;
children: ReactNode; children: ReactNode;
@@ -83,6 +86,7 @@ export function Tooltip({
frame.current = undefined; frame.current = undefined;
}; };
const show = (event: MouseEvent<HTMLElement>) => { const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return;
clear(); clear();
if (anchorToCursor || followCursor) place(event.clientX, event.clientY); if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) { if (delay <= 0) {
@@ -110,6 +114,10 @@ export function Tooltip({
return clear; return clear;
}, []); }, []);
useEffect(() => {
if (disabled) hide();
}, [disabled]);
const Wrapper = block ? "div" : "span"; const Wrapper = block ? "div" : "span";
const cursorTooltip = ( const cursorTooltip = (
<motion.span <motion.span
@@ -140,7 +148,7 @@ export function Tooltip({
onPointerDown={hide} onPointerDown={hide}
> >
{children} {children}
{anchorToCursor || followCursor ? ( {disabled ? null : anchorToCursor || followCursor ? (
mounted ? createPortal(cursorTooltip, document.body) : null mounted ? createPortal(cursorTooltip, document.body) : null
) : ( ) : (
<span <span
+13 -9
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2]; const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
const CONTROLS_HIDE_DELAY_MS = 2500; const CONTROLS_HIDE_DELAY_MS = 2500;
@@ -26,23 +27,24 @@ interface BufferedRange {
end: number; end: number;
} }
function ControlButton({ onClick, title, active = false, children }: { function ControlButton({ onClick, label, active = false, children }: {
onClick: () => void; onClick: () => void;
title: string; label: string;
active?: boolean; active?: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<Tooltip label={label} anchorToCursor>
<button <button
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`} className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onClick(); onClick();
}} }}
title={title}
> >
{children} {children}
</button> </button>
</Tooltip>
); );
} }
@@ -352,7 +354,7 @@ export function VideoPlayer({ src }: { src: string }) {
{/* Control row */} {/* Control row */}
<div className="mt-1 flex items-center gap-1.5"> <div className="mt-1 flex items-center gap-1.5">
<ControlButton onClick={togglePlay} title={playing ? "Pause (Space)" : "Play (Space)"}> <ControlButton onClick={togglePlay} label={playing ? "Pause (Space)" : "Play (Space)"}>
{playing ? ( {playing ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5h4v14H7zM13 5h4v14h-4z" /> <path d="M7 5h4v14H7zM13 5h4v14h-4z" />
@@ -371,7 +373,7 @@ export function VideoPlayer({ src }: { src: string }) {
<div className="flex-1" /> <div className="flex-1" />
{/* Volume */} {/* Volume */}
<ControlButton onClick={toggleMute} title={muted ? "Unmute (M)" : "Mute (M)"}> <ControlButton onClick={toggleMute} label={muted ? "Unmute (M)" : "Mute (M)"}>
{effectiveVolume === 0 ? ( {effectiveVolume === 0 ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
@@ -384,6 +386,7 @@ export function VideoPlayer({ src }: { src: string }) {
</svg> </svg>
)} )}
</ControlButton> </ControlButton>
<Tooltip label="Volume (↑/↓)" anchorToCursor>
<input <input
type="range" type="range"
min={0} min={0}
@@ -393,21 +396,22 @@ export function VideoPlayer({ src }: { src: string }) {
className="h-1 w-20 cursor-pointer accent-white" className="h-1 w-20 cursor-pointer accent-white"
onChange={(event) => applyVolume(parseFloat(event.target.value), false)} onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
title="Volume (↑/↓)"
/> />
</Tooltip>
{/* Playback speed */} {/* Playback speed */}
<div className="relative"> <div className="relative">
<Tooltip label="Playback speed" anchorToCursor>
<button <button
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`} className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setSpeedMenuOpen((value) => !value); setSpeedMenuOpen((value) => !value);
}} }}
title="Playback speed"
> >
{playbackRate}× {playbackRate}×
</button> </button>
</Tooltip>
{speedMenuOpen ? ( {speedMenuOpen ? (
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur"> <div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
{SPEED_OPTIONS.map((rate) => ( {SPEED_OPTIONS.map((rate) => (
@@ -429,14 +433,14 @@ export function VideoPlayer({ src }: { src: string }) {
</div> </div>
{/* Loop */} {/* Loop */}
<ControlButton onClick={toggleLoop} title={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}> <ControlButton onClick={toggleLoop} label={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg> </svg>
</ControlButton> </ControlButton>
{/* Fullscreen */} {/* Fullscreen */}
<ControlButton onClick={toggleFullscreen} title={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}> <ControlButton onClick={toggleFullscreen} label={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
{fullscreen ? ( {fullscreen ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
+2 -1
View File
@@ -156,15 +156,16 @@ export function WhatsNewModal() {
</h3> </h3>
{entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null} {entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null}
</div> </div>
<Tooltip label="Close" anchorToCursor>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={closeWhatsNew} onClick={closeWhatsNew}
title="Close"
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5"> <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
+5 -3
View File
@@ -1,4 +1,5 @@
import { useBulkTagEditor } from "./useBulkTagEditor"; import { useBulkTagEditor } from "./useBulkTagEditor";
import { Tooltip } from "../Tooltip";
// Presentational tag-editing fields shared by the popover and modal surfaces. // Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) { export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
@@ -35,14 +36,14 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
{suggestions.length > 0 ? ( {suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (
<Tooltip key={suggestion.tag} label={`${suggestion.count.toLocaleString()} tagged`} anchorToCursor>
<button <button
key={suggestion.tag}
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white" className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void addTag(suggestion.tag)} onClick={() => void addTag(suggestion.tag)}
title={`${suggestion.count.toLocaleString()} tagged`}
> >
{suggestion.tag} {suggestion.tag}
</button> </button>
</Tooltip>
))} ))}
</div> </div>
) : null} ) : null}
@@ -55,15 +56,16 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
className="group/chip flex items-center gap-1 rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300" className="group/chip flex items-center gap-1 rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300"
> >
{tag} {tag}
<Tooltip label="Remove from selected" followCursor>
<button <button
className="text-gray-600 transition-colors hover:text-white" className="text-gray-600 transition-colors hover:text-white"
title="Remove from selected"
onClick={() => void removeTag(tag)} onClick={() => void removeTag(tag)}
> >
<svg className="h-3 w-3" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</span> </span>
))} ))}
</div> </div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { BulkTagFields } from "./BulkTagFields"; import { BulkTagFields } from "./BulkTagFields";
import { Tooltip } from "../Tooltip";
// Inline popover surface for bulk tagging — the default editing surface. // Inline popover surface for bulk tagging — the default editing surface.
// Anchored above the bar by the parent; closes on outside click via the // Anchored above the bar by the parent; closes on outside click via the
@@ -12,15 +13,16 @@ export function BulkTagPopover({ onClose }: { onClose: () => void }) {
> >
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p> <p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p>
<Tooltip label="Close" anchorToCursor>
<button <button
className="text-gray-600 transition-colors hover:text-white" className="text-gray-600 transition-colors hover:text-white"
onClick={onClose} onClick={onClose}
title="Close"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</Tooltip>
</div> </div>
<BulkTagFields autoFocus /> <BulkTagFields autoFocus />
</div> </div>
+32
View File
@@ -9,6 +9,36 @@ let nextAlbumId = 100;
let nextTagId = 10_000; let nextTagId = 10_000;
type AnyPayload = Record<string, any> | undefined; type AnyPayload = Record<string, any> | undefined;
type Rgb = [number, number, number];
const mockPalette: Rgb[] = [
[59, 125, 216],
[31, 182, 166],
[76, 175, 80],
[242, 207, 46],
[232, 134, 46],
[226, 59, 59],
[139, 92, 246],
[236, 72, 153],
[139, 90, 43],
[26, 26, 26],
[245, 245, 245],
[154, 160, 166],
];
function isRgb(value: unknown): value is Rgb {
return Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === "number");
}
function colorDistanceSq(left: Rgb, right: Rgb): number {
return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2;
}
function matchesMockColor(image: ImageRecord, color: Rgb): boolean {
const primary = mockPalette[(image.id - 1) % mockPalette.length];
const secondary = mockPalette[(image.folder_id + image.rating) % mockPalette.length];
return colorDistanceSq(primary, color) <= 4_900 || colorDistanceSq(secondary, color) <= 4_900;
}
function params(payload: unknown): Record<string, any> { function params(payload: unknown): Record<string, any> {
if (!payload || typeof payload !== "object") return {}; if (!payload || typeof payload !== "object") return {};
@@ -28,6 +58,7 @@ function page<T>(items: T[], offset = 0, limit = 200) {
function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] { function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
const p = params(payload); const p = params(payload);
const query = String(p.search ?? p.query ?? "").trim().toLowerCase(); const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
const color = isRgb(p.color) ? p.color : null;
return input.filter((image) => { return input.filter((image) => {
if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false; if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false;
if (p.media_kind && image.media_kind !== p.media_kind) return false; if (p.media_kind && image.media_kind !== p.media_kind) return false;
@@ -36,6 +67,7 @@ function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
if (p.embedding_failed_only && image.embedding_status !== "failed") return false; if (p.embedding_failed_only && image.embedding_status !== "failed") return false;
if (p.tagging_failed_only && !image.ai_tagger_error) return false; if (p.tagging_failed_only && !image.ai_tagger_error) return false;
if (query && !image.filename.toLowerCase().includes(query)) return false; if (query && !image.filename.toLowerCase().includes(query)) return false;
if (color && !matchesMockColor(image, color)) return false;
return true; return true;
}); });
} }
+1
View File
@@ -1161,6 +1161,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
media_kind: mediaFilter === "all" ? null : mediaFilter, media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly, favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null, rating_min: minimumRating > 0 ? minimumRating : null,
color: colorFilter,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
}, },