chore: post-review hardening + changelog link tooltip

Security / robustness / a11y polish on top of the color-search + tooltips work:

- URL opening: route through validated backend commands (open_map_location
  with lat/lon bounds-checking, open_changelog_url with a fixed URL) instead
  of the frontend opener:allow-open-url capability, which is now removed.
- EXIF GPS parsing: validate coordinate ranges and require the correct
  N/S/E/W hemisphere ref byte, then clamp.
- Guard double-submit on album create / add-to-album (Lightbox, BulkActionBar,
  Sidebar) and discard stale autocomplete responses in the bulk tag editor.
- Gallery tile: stop nesting <button>s — non-interactive tile div with an
  overlay button for open/toggle; checkbox and Similar promoted with z-index
  + focus rings.
- Accessibility: keyboard handlers, focus-visible rings, and aria on album
  rows, tag-manage actions, and tile controls; Tooltip uses a block <div>
  wrapper in block mode and aria-hidden when hidden.
- Show the destination URL in a tooltip on the "Full changelog" link so the
  user can see where it goes before clicking.
- Toolbar "All" filter also clears the color filter; color-filtered views no
  longer get unfiltered newly-indexed images injected.
- Sidebar reorder: bail if the album set changed mid-drag.
- Tooling: add cargo fmt scripts; alphabetize package.json scripts.
This commit is contained in:
2026-06-28 14:17:58 +01:00
parent 90dec3b212
commit bb0038e0a1
14 changed files with 211 additions and 78 deletions
+6 -4
View File
@@ -6,16 +6,18 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:app": "tauri build", "build:app": "tauri build",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"build:vite": "tsc && vite build", "build:vite": "tsc && vite build",
"build:web": "cd website && tsc && vite build", "build:web": "cd website && tsc && vite build",
"changelog:add": "node tools/changelog-add.mjs",
"clean:app": "cd src-tauri && cargo clean", "clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev", "dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features", "dev:app:cpu": "tauri dev -- --no-default-features",
"dev:web": "cd website && pnpm dev",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"changelog:add": "node tools/changelog-add.mjs",
"dev:vite": "vite", "dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt",
"format:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
-1
View File
@@ -6,7 +6,6 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"opener:allow-open-url",
"dialog:default", "dialog:default",
"dialog:allow-open", "dialog:allow-open",
"fs:default", "fs:default",
+66 -13
View File
@@ -1702,25 +1702,37 @@ pub struct ImageExif {
} }
fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option<f64> { fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option<f64> {
let (max_abs, positive_ref, negative_ref) = match (coord, reference) {
(exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) => (90.0, b'N', b'S'),
(exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) => (180.0, b'E', b'W'),
_ => return None,
};
let field = exif.get_field(coord, exif::In::PRIMARY)?; let field = exif.get_field(coord, exif::In::PRIMARY)?;
if let exif::Value::Rational(ref parts) = field.value { if let exif::Value::Rational(ref parts) = field.value {
if parts.len() >= 3 { if parts.len() >= 3 {
let degrees = parts[0].to_f64() + parts[1].to_f64() / 60.0 + parts[2].to_f64() / 3600.0; let degrees = parts[0].to_f64() + parts[1].to_f64() / 60.0 + parts[2].to_f64() / 3600.0;
if !degrees.is_finite() || degrees < 0.0 || degrees > max_abs {
return None;
}
// Read the hemisphere straight from the ref tag's ASCII bytes // Read the hemisphere straight from the ref tag's ASCII bytes
// ("N"/"S"/"E"/"W") rather than its formatted display string. // ("N"/"S"/"E"/"W") rather than its formatted display string.
let negative = exif let ref_byte =
.get_field(reference, exif::In::PRIMARY) exif.get_field(reference, exif::In::PRIMARY)
.map(|f| match &f.value { .and_then(|f| match &f.value {
exif::Value::Ascii(values) => values exif::Value::Ascii(values) => values.iter().flatten().next().copied(),
.iter() _ => None,
.flatten() })?;
.next() if ref_byte != positive_ref && ref_byte != negative_ref {
.map(|&byte| byte == b'S' || byte == b'W') return None;
.unwrap_or(false), }
_ => false, let signed = if ref_byte == negative_ref {
}) -degrees
.unwrap_or(false); } else {
return Some(if negative { -degrees } else { degrees }); degrees
};
return signed
.is_finite()
.then_some(signed.clamp(-max_abs, max_abs));
} }
} }
None None
@@ -2500,6 +2512,47 @@ pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[derive(Deserialize)]
pub struct OpenMapLocationParams {
pub lat: f64,
pub lon: f64,
}
#[tauri::command]
pub async fn open_map_location(
app: AppHandle,
params: OpenMapLocationParams,
) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
if !params.lat.is_finite()
|| !params.lon.is_finite()
|| !(-90.0..=90.0).contains(&params.lat)
|| !(-180.0..=180.0).contains(&params.lon)
{
return Err("Invalid map coordinates".to_string());
}
let url = format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=15/{lat:.6}/{lon:.6}",
lat = params.lat,
lon = params.lon,
);
app.opener()
.open_url(url, None::<&str>)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn open_changelog_url(app: AppHandle) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(
"https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md",
None::<&str>,
)
.map_err(|e| e.to_string())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Database maintenance // Database maintenance
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2
View File
@@ -216,6 +216,8 @@ pub fn run() {
commands::get_tagging_queue_folder_ids, commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids, commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder, commands::open_app_data_folder,
commands::open_map_location,
commands::open_changelog_url,
commands::get_database_info, commands::get_database_info,
commands::vacuum_database, commands::vacuum_database,
commands::rebuild_semantic_index, commands::rebuild_semantic_index,
+9 -2
View File
@@ -23,6 +23,7 @@ export function BulkActionBar() {
const [panel, setPanel] = useState<Panel>(null); const [panel, setPanel] = useState<Panel>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [newAlbumName, setNewAlbumName] = useState(""); const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null);
@@ -67,11 +68,16 @@ export function BulkActionBar() {
const handleCreateAlbum = async () => { const handleCreateAlbum = async () => {
const name = newAlbumName.trim(); const name = newAlbumName.trim();
if (!name) return; if (!name || creatingAlbum) return;
setCreatingAlbum(true);
try {
const album = await createAlbum(name); const album = await createAlbum(name);
await addToAlbum(album.id, ids); await addToAlbum(album.id, ids);
setNewAlbumName(""); setNewAlbumName("");
setPanel(null); setPanel(null);
} finally {
setCreatingAlbum(false);
}
}; };
const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors"; const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors";
@@ -187,11 +193,12 @@ export function BulkActionBar() {
placeholder="New album…" placeholder="New album…"
value={newAlbumName} value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)} onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creatingAlbum}
/> />
<button <button
type="submit" type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50" className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={!newAlbumName.trim()} disabled={creatingAlbum || !newAlbumName.trim()}
> >
Add Add
</button> </button>
+19 -11
View File
@@ -113,7 +113,7 @@ export function ImageTile({
}: { }: {
image: ImageRecord; image: ImageRecord;
onClick: () => void; onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLButtonElement>) => void; onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
}) { }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
@@ -127,27 +127,35 @@ export function ImageTile({
return ( return (
<Tooltip label={image.filename} delay={500} block followCursor> <Tooltip label={image.filename} delay={500} block followCursor>
<button <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" : ""
}`} }`}
style={{ width: "100%", aspectRatio: "1 / 1" }} style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={() => { onContextMenu={onContextMenu}
// In selection mode a plain click toggles; otherwise it opens. >
{/* Full-tile click target — opens, or toggles selection while selecting.
A real button (over the non-interactive tile div) keeps it keyboard-
accessible without nesting buttons. */}
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation();
if (selectionActive) toggleGallerySelected(image.id); if (selectionActive) toggleGallerySelected(image.id);
else onClick(); else onClick();
}} }}
onDoubleClick={(event) => { onDoubleClick={(event) => {
// Double-click always opens, even in selection mode.
event.stopPropagation(); event.stopPropagation();
onClick(); onClick();
}} }}
onContextMenu={onContextMenu} />
>
{/* Selection corner — a top-left zone that reveals the checkbox only when {/* Selection corner — a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The hovered (not the whole tile) and toggles selection on click. The
checkbox stays visible once the item is selected. */} checkbox stays visible once the item is selected. */}
<div <button
type="button"
role="checkbox" role="checkbox"
aria-checked={selected} aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"} aria-label={selected ? "Deselect" : "Select"}
@@ -168,7 +176,7 @@ export function ImageTile({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
</div> </button>
{/* Image / placeholder */} {/* Image / placeholder */}
{src && !errored ? ( {src && !errored ? (
<> <>
@@ -265,7 +273,7 @@ export function ImageTile({
<span /> <span />
)} )}
<button <button
className={`rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm ${ className={`relative z-20 rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white" ? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/5 text-white/30 cursor-not-allowed" : "bg-white/5 text-white/30 cursor-not-allowed"
@@ -281,7 +289,7 @@ export function ImageTile({
</button> </button>
</div> </div>
</div> </div>
</button> </div>
</Tooltip> </Tooltip>
); );
} }
+23 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useCallback, useRef, useState } from "react"; import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { revealItemInDir, openUrl } from "@tauri-apps/plugin-opener"; 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";
@@ -177,6 +177,7 @@ export function Lightbox() {
const [albumMenuOpen, setAlbumMenuOpen] = useState(false); const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null); const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState(""); const [newAlbumName, setNewAlbumName] = useState("");
const [albumAdding, setAlbumAdding] = useState(false);
const [regionSelectMode, setRegionSelectMode] = useState(false); const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null); const [dragRect, setDragRect] = useState<DragRect | null>(null);
@@ -821,9 +822,14 @@ export function Lightbox() {
key={album.id} key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white" className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => { onClick={() => {
void addToAlbum(album.id, [selectedImage.id]); if (albumAdding) return;
setAlbumAddedTo(album.id); setAlbumAdding(true);
void addToAlbum(album.id, [selectedImage.id])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}} }}
disabled={albumAdding}
> >
<span className="truncate">{album.name}</span> <span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? ( {albumAddedTo === album.id ? (
@@ -840,12 +846,16 @@ export function Lightbox() {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const name = newAlbumName.trim(); const name = newAlbumName.trim();
if (!name) return; if (!name || albumAdding) return;
void createAlbum(name).then((album) => { setAlbumAdding(true);
void addToAlbum(album.id, [selectedImage.id]); void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [selectedImage.id]);
setAlbumAddedTo(album.id); setAlbumAddedTo(album.id);
});
setNewAlbumName(""); setNewAlbumName("");
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}} }}
> >
<input <input
@@ -853,11 +863,12 @@ export function Lightbox() {
placeholder="New album…" placeholder="New album…"
value={newAlbumName} value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)} onChange={(e) => setNewAlbumName(e.target.value)}
disabled={albumAdding}
/> />
<button <button
type="submit" type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50" className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={!newAlbumName.trim()} disabled={albumAdding || !newAlbumName.trim()}
> >
Add Add
</button> </button>
@@ -897,9 +908,9 @@ export function Lightbox() {
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300" className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
title="Open location in your browser" title="Open location in your browser"
onClick={() => onClick={() =>
void openUrl( void invoke("open_map_location", {
`https://www.openstreetmap.org/?mlat=${imageExif.gps_lat}&mlon=${imageExif.gps_lon}#map=15/${imageExif.gps_lat}/${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)}
+34 -1
View File
@@ -376,7 +376,8 @@ function AlbumContextMenu({
? "text-red-400 hover:bg-red-500/15 hover:text-red-300" ? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-300 hover:bg-white/8 hover:text-white" : "text-gray-300 hover:bg-white/8 hover:text-white"
}`} }`}
onClick={() => { onClick={(event) => {
event.stopPropagation();
onClick(); onClick();
onClose(); onClose();
}} }}
@@ -448,6 +449,10 @@ function AlbumItem({
const row = ( const row = (
<div <div
role={manageMode ? "checkbox" : "button"}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${ className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50" ? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
@@ -462,6 +467,16 @@ function AlbumItem({
viewAlbum(album.id); viewAlbum(album.id);
} }
}} }}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (renaming || (e.key !== "Enter" && e.key !== " ")) return;
e.preventDefault();
if (manageMode) {
onToggleManage?.();
} else {
viewAlbum(album.id);
}
}}
onContextMenu={(e) => { onContextMenu={(e) => {
if (manageMode) return; if (manageMode) return;
e.preventDefault(); e.preventDefault();
@@ -603,6 +618,7 @@ export function Sidebar() {
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums); const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums); const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
const [creatingAlbum, setCreatingAlbum] = useState(false); const [creatingAlbum, setCreatingAlbum] = useState(false);
const [createAlbumPending, setCreateAlbumPending] = useState(false);
const [newAlbumName, setNewAlbumName] = useState(""); const [newAlbumName, setNewAlbumName] = useState("");
const newAlbumInputRef = useRef<HTMLInputElement>(null); const newAlbumInputRef = useRef<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false); const [manageAlbums, setManageAlbums] = useState(false);
@@ -634,6 +650,15 @@ export function Sidebar() {
const nextIds = orderedAlbumsRef.current.map((album) => album.id); const nextIds = orderedAlbumsRef.current.map((album) => album.id);
// Read live store order (not the render-time closure) in case albums changed. // Read live store order (not the render-time closure) in case albums changed.
const currentIds = useGalleryStore.getState().albums.map((album) => album.id); const currentIds = useGalleryStore.getState().albums.map((album) => album.id);
const snapshotIds = albums.map((album) => album.id);
if (
snapshotIds.length !== currentIds.length ||
snapshotIds.some((id, index) => id !== currentIds[index])
) {
orderedAlbumsRef.current = useGalleryStore.getState().albums;
setOrderedAlbums(orderedAlbumsRef.current);
return;
}
if ( if (
nextIds.length !== currentIds.length || nextIds.length !== currentIds.length ||
nextIds.some((id, index) => id !== currentIds[index]) nextIds.some((id, index) => id !== currentIds[index])
@@ -768,10 +793,16 @@ export function Sidebar() {
setCreatingAlbum(false); setCreatingAlbum(false);
return; return;
} }
if (createAlbumPending) return;
setCreateAlbumPending(true);
try {
const album = await createAlbum(name); const album = await createAlbum(name);
setNewAlbumName(""); setNewAlbumName("");
setCreatingAlbum(false); setCreatingAlbum(false);
useGalleryStore.getState().viewAlbum(album.id); useGalleryStore.getState().viewAlbum(album.id);
} finally {
setCreateAlbumPending(false);
}
}; };
const exitManageAlbums = () => { const exitManageAlbums = () => {
@@ -1041,7 +1072,9 @@ export function Sidebar() {
placeholder="Album name…" placeholder="Album name…"
value={newAlbumName} value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)} onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => { onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") { if (e.key === "Escape") {
setCreatingAlbum(false); setCreatingAlbum(false);
setNewAlbumName(""); setNewAlbumName("");
+5 -5
View File
@@ -327,9 +327,9 @@ function TagManageRow({
setBusy(true); setBusy(true);
try { try {
await onRename(entry.tag, next); await onRename(entry.tag, next);
setEditing(false);
} finally { } finally {
setBusy(false); setBusy(false);
setEditing(false);
} }
}; };
@@ -382,7 +382,7 @@ function TagManageRow({
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<button <button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50" className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); } finally { setBusy(false); setConfirming(false); } }} onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy} disabled={busy}
> >
Delete Delete
@@ -396,16 +396,16 @@ function TagManageRow({
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white" className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
title="Rename or merge into another tag" title="Rename or merge into another tag"
> >
Rename Rename
</button> </button>
<button <button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300" className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
> >
Delete Delete
+3 -1
View File
@@ -161,6 +161,8 @@ export function Toolbar() {
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly); const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly); const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const similarScope = useGalleryStore((state) => state.similarScope); const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId); const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
@@ -452,7 +454,7 @@ export function Toolbar() {
{/* Filter row */} {/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5"> <div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
+8 -4
View File
@@ -77,7 +77,7 @@ export function Tooltip({
if (frame.current !== undefined) cancelAnimationFrame(frame.current); if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined; frame.current = undefined;
}; };
const show = (event: MouseEvent<HTMLSpanElement>) => { const show = (event: MouseEvent<HTMLElement>) => {
clear(); clear();
if (followCursor) place(event.clientX, event.clientY); if (followCursor) place(event.clientX, event.clientY);
if (delay <= 0) { if (delay <= 0) {
@@ -90,7 +90,7 @@ export function Tooltip({
clear(); clear();
setVisible(false); setVisible(false);
}; };
const move = (event: MouseEvent<HTMLSpanElement>) => { const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return; if (!followCursor) return;
const { clientX, clientY } = event; const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current); if (frame.current !== undefined) cancelAnimationFrame(frame.current);
@@ -102,8 +102,10 @@ export function Tooltip({
useEffect(() => clear, []); useEffect(() => clear, []);
const Wrapper = block ? "div" : "span";
return ( return (
<span <Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`} className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show} onMouseEnter={show}
onMouseMove={followCursor ? move : undefined} onMouseMove={followCursor ? move : undefined}
@@ -115,6 +117,7 @@ export function Tooltip({
<motion.span <motion.span
ref={tooltipRef} ref={tooltipRef}
role="tooltip" role="tooltip"
aria-hidden={!visible}
// Fixed (so the scroll container's overflow doesn't clip it) and // Fixed (so the scroll container's overflow doesn't clip it) and
// positioned by `place()` near the cursor with viewport-edge flipping. // positioned by `place()` near the cursor with viewport-edge flipping.
className={`fixed ${BASE_CLASSES}`} className={`fixed ${BASE_CLASSES}`}
@@ -131,11 +134,12 @@ export function Tooltip({
) : ( ) : (
<span <span
role="tooltip" role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`} className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
> >
{label} {label}
</span> </span>
)} )}
</span> </Wrapper>
); );
} }
+7 -2
View File
@@ -1,9 +1,12 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { openUrl } from "@tauri-apps/plugin-opener"; import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog"; import { getChangelogForVersion, type ChangelogSection } from "../changelog";
import { Tooltip } from "./Tooltip";
// Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md"; const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
// Per-section accent. These all use the standard colour scale, which the // Per-section accent. These all use the standard colour scale, which the
@@ -175,13 +178,15 @@ export function WhatsNewModal() {
</div> </div>
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4"> <div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
<Tooltip label={CHANGELOG_URL} side="top" align="start">
<button <button
type="button" type="button"
onClick={() => void openUrl(CHANGELOG_URL)} onClick={() => void invoke("open_changelog_url")}
className="text-xs text-gray-500 transition-colors hover:text-gray-300" className="text-xs text-gray-500 transition-colors hover:text-gray-300"
> >
Full changelog Full changelog
</button> </button>
</Tooltip>
<button <button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25" className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={closeWhatsNew} onClick={closeWhatsNew}
+6
View File
@@ -15,25 +15,31 @@ export function useBulkTagEditor() {
const [appliedTags, setAppliedTags] = useState<string[]>([]); const [appliedTags, setAppliedTags] = useState<string[]>([]);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const requestRef = useRef(0);
useEffect(() => { useEffect(() => {
const query = input.trim(); const query = input.trim();
if (!query) { if (!query) {
requestRef.current += 1;
setSuggestions([]); setSuggestions([]);
return; return;
} }
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
const requestId = ++requestRef.current;
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
try { try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", { const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query, folder_id: selectedFolderId ?? null, limit: 8 }, params: { query, folder_id: selectedFolderId ?? null, limit: 8 },
}); });
if (requestId !== requestRef.current) return;
setSuggestions(results); setSuggestions(results);
} catch { } catch {
if (requestId !== requestRef.current) return;
setSuggestions([]); setSuggestions([]);
} }
}, 120); }, 120);
return () => { return () => {
requestRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
}; };
}, [input, selectedFolderId]); }, [input, selectedFolderId]);
+2 -1
View File
@@ -2822,7 +2822,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if ( if (
isDerivedCollectionTitle(state.collectionTitle) || isDerivedCollectionTitle(state.collectionTitle) ||
state.activeView === "explore" || state.activeView === "explore" ||
state.activeView === "album" state.activeView === "album" ||
state.colorFilter !== null
) { ) {
return state; return state;
} }