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