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.
This commit is contained in:
2026-06-30 23:25:43 +01:00
parent 1a971899d1
commit d619b01f2e
17 changed files with 421 additions and 327 deletions
+14 -8
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,7 +395,8 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
</button> </button>
)} </Tooltip>
)}
</span> </span>
); );
})} })}
@@ -455,16 +458,17 @@ 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>
{/* Expanded panel — one row per folder */} {/* Expanded panel — one row per folder */}
@@ -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,7 +527,8 @@ 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>
+15 -11
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,14 +94,15 @@ 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>
) : null} </Tooltip>
) : null}
</div> </div>
<div className="h-5 w-px bg-white/10" /> <div className="h-5 w-px bg-white/10" />
@@ -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>
); );
} }
+10 -9
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()}
> </span>
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()} </Tooltip>
</span>
) : <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>
); );
} }
+66 -62
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>
<button <Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
type="button" <button
className="flex min-w-0 flex-1 items-center gap-2 text-left" type="button"
onClick={onNavigate} className="flex w-full min-w-0 items-center gap-2 text-left"
title={entry.path} onClick={onNavigate}
> >
<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}
<button <Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
type="button" <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" type="button"
onClick={onNavigate} 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"
title={entry.has_children ? "Open folder" : "No subfolders"} onClick={onNavigate}
> >
<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,30 +167,29 @@ 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" <svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
title={path} <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 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" />
</svg>
<div className="min-w-0 flex-1">
<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>
</div>
<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"
onClick={() => onRemove(path)}
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> <div className="min-w-0 flex-1">
</div> <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>
</div>
<Tooltip label="Remove from folders to add" anchorToCursor>
<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"
onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</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>
<button <Tooltip label="Close folder picker" anchorToCursor>
type="button" <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" type="button"
onClick={() => setFolderPickerOpen(false)} 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"
title="Close folder picker" onClick={() => setFolderPickerOpen(false)}
> >
<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}
<button <Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
type="button" <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" type="button"
onClick={() => setCurrentPath(crumb.path)} 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"
title={crumb.path ?? "Roots"} onClick={() => setCurrentPath(crumb.path)}
> >
{crumb.label} {crumb.label}
</button> </button>
</Tooltip>
</span> </span>
))} ))}
</nav> </nav>
+21 -19
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,26 +35,27 @@ export function FolderScopeDropdown() {
return ( return (
<div ref={ref} className="feature-scope-dropdown relative"> <div ref={ref} className="feature-scope-dropdown relative">
<button <Tooltip label="Change folder scope" anchorToCursor>
onClick={() => setOpen((v) => !v)} <button
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 ${ onClick={() => setOpen((v) => !v)}
open 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 ${
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white" open
: "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/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"
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">
<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" />
</svg>
<span className="truncate">{currentLabel}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <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" />
</button> </svg>
<span className="truncate">{currentLabel}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</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
+63 -33
View File
@@ -75,31 +75,32 @@ export function ContextMenu({
{Array.from({ length: 5 }, (_, index) => { {Array.from({ length: 5 }, (_, index) => {
const rating = index + 1; const rating = index + 1;
return ( return (
<button <Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
key={rating} <button
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
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
fill="currentColor" viewBox="0 0 20 20"
> >
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" /> <svg
</svg> className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
</button> fill="currentColor" viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
</Tooltip>
); );
})} })}
{image.rating > 0 ? ( {image.rating > 0 ? (
<button <Tooltip label="Remove rating" followCursor>
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors" <button
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }} className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
title="Remove rating" onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
> >
<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">
> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} </svg>
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" /> </div>
</svg> </Tooltip>
</div>
)} )}
{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>
); );
} }
+66 -52
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 ? (
<button <Tooltip label="Remove rating" followCursor>
className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white" <button
onClick={() => void updateImageDetails(selectedImage.id, { rating: 0 })} className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
title="Remove rating" onClick={() => void updateImageDetails(selectedImage.id, { rating: 0 })}
> >
<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,30 +742,36 @@ 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}
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${ label={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : ""}
t.source === "ai" followCursor
? "border-sky-400/20 bg-sky-500/8 text-sky-300" disabled={t.source !== "ai" || t.confidence === null}
: "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} <span
<button className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100" t.source === "ai"
onClick={() => { ? "border-sky-400/20 bg-sky-500/8 text-sky-300"
void removeTag(t.id).then(() => : "border-white/10 bg-white/5 text-gray-300"
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"> {t.tag}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <Tooltip label="Remove tag" followCursor>
</svg> <button
</button> className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
</span> onClick={() => {
void removeTag(t.id).then(() =>
setImageTags((prev) => prev.filter((x) => x.id !== t.id)),
);
}}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</span>
</Tooltip>
))} ))}
</div> </div>
{imageTags.length > 8 && ( {imageTags.length > 8 && (
@@ -914,21 +926,22 @@ export function Lightbox() {
</div> </div>
) : null} ) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? ( {imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<button <Tooltip label="Open location in your browser" anchorToCursor>
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300" <button
title="Open location in your browser" className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
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 },
}) })
} }
> >
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)} {imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<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} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
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>
+11 -9
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>
<button <Tooltip label="Close settings" anchorToCursor className="absolute right-4 top-4 z-10">
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" <button
onClick={() => setSettingsOpen(false)} className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
title="Close settings" onClick={() => setSettingsOpen(false)}
> >
<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">
+34 -26
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,26 +275,28 @@ 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">
<button <Tooltip label="Reindex" anchorToCursor>
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors" <button
title="Reindex" className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
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>
<button </Tooltip>
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors" <Tooltip label="Remove from app" anchorToCursor>
title="Remove from app" <button
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }} className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
> onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
</svg> d="M6 18L18 6M6 6l12 12" />
</button> </svg>
</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,7 +521,8 @@ 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>
) : null} </Tooltip>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */} {/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10"> <div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
@@ -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>
+24 -22
View File
@@ -327,31 +327,33 @@ 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">
<button <Tooltip label="Remove search command" anchorToCursor>
type="button" <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" type="button"
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }} 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"
title="Remove search command" onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
> >
{commandPrefix(searchCommand)} {commandPrefix(searchCommand)}
</button> </button>
</Tooltip>
</div> </div>
) : null} ) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? ( {searchQuery.trim().length > 0 || searchCommand !== null ? (
<button <Tooltip label="Clear search" anchorToCursor className="absolute right-2 top-1/2 -translate-y-1/2">
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" <button
title="Clear search" className="rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
onClick={() => { onClick={() => {
setSearchCommand(null); setSearchCommand(null);
setSearchQuery(""); setSearchQuery("");
setTagSuggestions([]); setTagSuggestions([]);
clearSearch(); clearSearch();
}} }}
> >
<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>
+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
+41 -37
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 (
<button <Tooltip label={label} anchorToCursor>
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`} <button
onClick={(event) => { className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
event.stopPropagation(); onClick={(event) => {
onClick(); event.stopPropagation();
}} 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,30 +386,32 @@ export function VideoPlayer({ src }: { src: string }) {
</svg> </svg>
)} )}
</ControlButton> </ControlButton>
<input <Tooltip label="Volume (↑/↓)" anchorToCursor>
type="range" <input
min={0} type="range"
max={1} min={0}
step={0.02} max={1}
value={effectiveVolume} step={0.02}
className="h-1 w-20 cursor-pointer accent-white" value={effectiveVolume}
onChange={(event) => applyVolume(parseFloat(event.target.value), false)} className="h-1 w-20 cursor-pointer accent-white"
onClick={(event) => event.stopPropagation()} onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
title="Volume (↑/↓)" onClick={(event) => event.stopPropagation()}
/> />
</Tooltip>
{/* Playback speed */} {/* Playback speed */}
<div className="relative"> <div className="relative">
<button <Tooltip label="Playback speed" anchorToCursor>
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"}`} <button
onClick={(event) => { 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"}`}
event.stopPropagation(); onClick={(event) => {
setSpeedMenuOpen((value) => !value); event.stopPropagation();
}} 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" />
+10 -9
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>
<button <Tooltip label="Close" anchorToCursor>
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" <button
onClick={closeWhatsNew} className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
title="Close" onClick={closeWhatsNew}
> >
<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">
+19 -17
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) => (
<button <Tooltip key={suggestion.tag} label={`${suggestion.count.toLocaleString()} tagged`} anchorToCursor>
key={suggestion.tag} <button
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}
<button <Tooltip label="Remove from selected" followCursor>
className="text-gray-600 transition-colors hover:text-white" <button
title="Remove from selected" className="text-gray-600 transition-colors hover:text-white"
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>
+11 -9
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>
<button <Tooltip label="Close" anchorToCursor>
className="text-gray-600 transition-colors hover:text-white" <button
onClick={onClose} className="text-gray-600 transition-colors hover:text-white"
title="Close" onClick={onClose}
> >
<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>