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:
+6
-4
@@ -6,16 +6,18 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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:web": "cd website && tsc && vite build",
|
||||
"changelog:add": "node tools/changelog-add.mjs",
|
||||
"clean:app": "cd src-tauri && cargo clean",
|
||||
"dev:app": "tauri dev",
|
||||
"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:web": "cd website && pnpm dev",
|
||||
"format:app": "cd src-tauri && cargo fmt",
|
||||
"format:check": "cd src-tauri && cargo fmt --check",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"opener:allow-open-url",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"fs:default",
|
||||
|
||||
+66
-13
@@ -1702,25 +1702,37 @@ pub struct ImageExif {
|
||||
}
|
||||
|
||||
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)?;
|
||||
if let exif::Value::Rational(ref parts) = field.value {
|
||||
if parts.len() >= 3 {
|
||||
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
|
||||
// ("N"/"S"/"E"/"W") rather than its formatted display string.
|
||||
let negative = exif
|
||||
.get_field(reference, exif::In::PRIMARY)
|
||||
.map(|f| match &f.value {
|
||||
exif::Value::Ascii(values) => values
|
||||
.iter()
|
||||
.flatten()
|
||||
.next()
|
||||
.map(|&byte| byte == b'S' || byte == b'W')
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
return Some(if negative { -degrees } else { degrees });
|
||||
let ref_byte =
|
||||
exif.get_field(reference, exif::In::PRIMARY)
|
||||
.and_then(|f| match &f.value {
|
||||
exif::Value::Ascii(values) => values.iter().flatten().next().copied(),
|
||||
_ => None,
|
||||
})?;
|
||||
if ref_byte != positive_ref && ref_byte != negative_ref {
|
||||
return None;
|
||||
}
|
||||
let signed = if ref_byte == negative_ref {
|
||||
-degrees
|
||||
} else {
|
||||
degrees
|
||||
};
|
||||
return signed
|
||||
.is_finite()
|
||||
.then_some(signed.clamp(-max_abs, max_abs));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -2500,6 +2512,47 @@ pub async fn open_app_data_folder(app: AppHandle) -> Result<(), 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(¶ms.lat)
|
||||
|| !(-180.0..=180.0).contains(¶ms.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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -216,6 +216,8 @@ pub fn run() {
|
||||
commands::get_tagging_queue_folder_ids,
|
||||
commands::set_tagging_queue_folder_ids,
|
||||
commands::open_app_data_folder,
|
||||
commands::open_map_location,
|
||||
commands::open_changelog_url,
|
||||
commands::get_database_info,
|
||||
commands::vacuum_database,
|
||||
commands::rebuild_semantic_index,
|
||||
|
||||
@@ -23,6 +23,7 @@ export function BulkActionBar() {
|
||||
|
||||
const [panel, setPanel] = useState<Panel>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [creatingAlbum, setCreatingAlbum] = useState(false);
|
||||
const [newAlbumName, setNewAlbumName] = useState("");
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -67,11 +68,16 @@ export function BulkActionBar() {
|
||||
|
||||
const handleCreateAlbum = async () => {
|
||||
const name = newAlbumName.trim();
|
||||
if (!name) return;
|
||||
const album = await createAlbum(name);
|
||||
await addToAlbum(album.id, ids);
|
||||
setNewAlbumName("");
|
||||
setPanel(null);
|
||||
if (!name || creatingAlbum) return;
|
||||
setCreatingAlbum(true);
|
||||
try {
|
||||
const album = await createAlbum(name);
|
||||
await addToAlbum(album.id, ids);
|
||||
setNewAlbumName("");
|
||||
setPanel(null);
|
||||
} finally {
|
||||
setCreatingAlbum(false);
|
||||
}
|
||||
};
|
||||
|
||||
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…"
|
||||
value={newAlbumName}
|
||||
onChange={(event) => setNewAlbumName(event.target.value)}
|
||||
disabled={creatingAlbum}
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
disabled={!newAlbumName.trim()}
|
||||
disabled={creatingAlbum || !newAlbumName.trim()}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
|
||||
+24
-16
@@ -113,7 +113,7 @@ export function ImageTile({
|
||||
}: {
|
||||
image: ImageRecord;
|
||||
onClick: () => void;
|
||||
onContextMenu: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [errored, setErrored] = useState(false);
|
||||
@@ -127,27 +127,35 @@ export function ImageTile({
|
||||
|
||||
return (
|
||||
<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 ${
|
||||
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
|
||||
}`}
|
||||
style={{ width: "100%", aspectRatio: "1 / 1" }}
|
||||
onClick={() => {
|
||||
// In selection mode a plain click toggles; otherwise it opens.
|
||||
if (selectionActive) toggleGallerySelected(image.id);
|
||||
else onClick();
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
// Double-click always opens, even in selection mode.
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{/* 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);
|
||||
else onClick();
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
/>
|
||||
{/* Selection corner — a top-left zone that reveals the checkbox only when
|
||||
hovered (not the whole tile) and toggles selection on click. The
|
||||
checkbox stays visible once the item is selected. */}
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
aria-label={selected ? "Deselect" : "Select"}
|
||||
@@ -168,7 +176,7 @@ export function ImageTile({
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/* Image / placeholder */}
|
||||
{src && !errored ? (
|
||||
<>
|
||||
@@ -265,7 +273,7 @@ export function ImageTile({
|
||||
<span />
|
||||
)}
|
||||
<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
|
||||
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
|
||||
: "bg-white/5 text-white/30 cursor-not-allowed"
|
||||
@@ -281,7 +289,7 @@ export function ImageTile({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
+25
-14
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { revealItemInDir, openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
|
||||
@@ -177,6 +177,7 @@ export function Lightbox() {
|
||||
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
|
||||
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
|
||||
const [newAlbumName, setNewAlbumName] = useState("");
|
||||
const [albumAdding, setAlbumAdding] = useState(false);
|
||||
const [regionSelectMode, setRegionSelectMode] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragRect, setDragRect] = useState<DragRect | null>(null);
|
||||
@@ -821,9 +822,14 @@ export function Lightbox() {
|
||||
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"
|
||||
onClick={() => {
|
||||
void addToAlbum(album.id, [selectedImage.id]);
|
||||
setAlbumAddedTo(album.id);
|
||||
if (albumAdding) return;
|
||||
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>
|
||||
{albumAddedTo === album.id ? (
|
||||
@@ -840,12 +846,16 @@ export function Lightbox() {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const name = newAlbumName.trim();
|
||||
if (!name) return;
|
||||
void createAlbum(name).then((album) => {
|
||||
void addToAlbum(album.id, [selectedImage.id]);
|
||||
setAlbumAddedTo(album.id);
|
||||
});
|
||||
setNewAlbumName("");
|
||||
if (!name || albumAdding) return;
|
||||
setAlbumAdding(true);
|
||||
void createAlbum(name)
|
||||
.then(async (album) => {
|
||||
await addToAlbum(album.id, [selectedImage.id]);
|
||||
setAlbumAddedTo(album.id);
|
||||
setNewAlbumName("");
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setAlbumAdding(false));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -853,11 +863,12 @@ export function Lightbox() {
|
||||
placeholder="New album…"
|
||||
value={newAlbumName}
|
||||
onChange={(e) => setNewAlbumName(e.target.value)}
|
||||
disabled={albumAdding}
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
disabled={!newAlbumName.trim()}
|
||||
disabled={albumAdding || !newAlbumName.trim()}
|
||||
>
|
||||
Add
|
||||
</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"
|
||||
title="Open location in your browser"
|
||||
onClick={() =>
|
||||
void openUrl(
|
||||
`https://www.openstreetmap.org/?mlat=${imageExif.gps_lat}&mlon=${imageExif.gps_lon}#map=15/${imageExif.gps_lat}/${imageExif.gps_lon}`,
|
||||
)
|
||||
void invoke("open_map_location", {
|
||||
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
|
||||
})
|
||||
}
|
||||
>
|
||||
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
|
||||
|
||||
@@ -376,7 +376,8 @@ function AlbumContextMenu({
|
||||
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
|
||||
: "text-gray-300 hover:bg-white/8 hover:text-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
onClose();
|
||||
}}
|
||||
@@ -448,6 +449,10 @@ function AlbumItem({
|
||||
|
||||
const row = (
|
||||
<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 ${
|
||||
selectedForManage
|
||||
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
|
||||
@@ -462,6 +467,16 @@ function AlbumItem({
|
||||
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) => {
|
||||
if (manageMode) return;
|
||||
e.preventDefault();
|
||||
@@ -603,6 +618,7 @@ export function Sidebar() {
|
||||
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
|
||||
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
|
||||
const [creatingAlbum, setCreatingAlbum] = useState(false);
|
||||
const [createAlbumPending, setCreateAlbumPending] = useState(false);
|
||||
const [newAlbumName, setNewAlbumName] = useState("");
|
||||
const newAlbumInputRef = useRef<HTMLInputElement>(null);
|
||||
const [manageAlbums, setManageAlbums] = useState(false);
|
||||
@@ -634,6 +650,15 @@ export function Sidebar() {
|
||||
const nextIds = orderedAlbumsRef.current.map((album) => album.id);
|
||||
// Read live store order (not the render-time closure) in case albums changed.
|
||||
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 (
|
||||
nextIds.length !== currentIds.length ||
|
||||
nextIds.some((id, index) => id !== currentIds[index])
|
||||
@@ -768,10 +793,16 @@ export function Sidebar() {
|
||||
setCreatingAlbum(false);
|
||||
return;
|
||||
}
|
||||
const album = await createAlbum(name);
|
||||
setNewAlbumName("");
|
||||
setCreatingAlbum(false);
|
||||
useGalleryStore.getState().viewAlbum(album.id);
|
||||
if (createAlbumPending) return;
|
||||
setCreateAlbumPending(true);
|
||||
try {
|
||||
const album = await createAlbum(name);
|
||||
setNewAlbumName("");
|
||||
setCreatingAlbum(false);
|
||||
useGalleryStore.getState().viewAlbum(album.id);
|
||||
} finally {
|
||||
setCreateAlbumPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exitManageAlbums = () => {
|
||||
@@ -1041,7 +1072,9 @@ export function Sidebar() {
|
||||
placeholder="Album name…"
|
||||
value={newAlbumName}
|
||||
onChange={(e) => setNewAlbumName(e.target.value)}
|
||||
disabled={createAlbumPending}
|
||||
onKeyDown={(e) => {
|
||||
if (createAlbumPending) return;
|
||||
if (e.key === "Escape") {
|
||||
setCreatingAlbum(false);
|
||||
setNewAlbumName("");
|
||||
|
||||
@@ -327,9 +327,9 @@ function TagManageRow({
|
||||
setBusy(true);
|
||||
try {
|
||||
await onRename(entry.tag, next);
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -382,7 +382,7 @@ function TagManageRow({
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<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"
|
||||
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}
|
||||
>
|
||||
Delete
|
||||
@@ -396,16 +396,16 @@ function TagManageRow({
|
||||
</button>
|
||||
</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
|
||||
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)}
|
||||
title="Rename or merge into another tag"
|
||||
>
|
||||
Rename
|
||||
</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)}
|
||||
>
|
||||
Delete
|
||||
|
||||
@@ -161,6 +161,8 @@ export function Toolbar() {
|
||||
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
|
||||
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
|
||||
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 setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
|
||||
@@ -452,7 +454,7 @@ export function Toolbar() {
|
||||
|
||||
{/* Filter row */}
|
||||
<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="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); }} />
|
||||
|
||||
@@ -77,7 +77,7 @@ export function Tooltip({
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
|
||||
frame.current = undefined;
|
||||
};
|
||||
const show = (event: MouseEvent<HTMLSpanElement>) => {
|
||||
const show = (event: MouseEvent<HTMLElement>) => {
|
||||
clear();
|
||||
if (followCursor) place(event.clientX, event.clientY);
|
||||
if (delay <= 0) {
|
||||
@@ -90,7 +90,7 @@ export function Tooltip({
|
||||
clear();
|
||||
setVisible(false);
|
||||
};
|
||||
const move = (event: MouseEvent<HTMLSpanElement>) => {
|
||||
const move = (event: MouseEvent<HTMLElement>) => {
|
||||
if (!followCursor) return;
|
||||
const { clientX, clientY } = event;
|
||||
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
|
||||
@@ -102,8 +102,10 @@ export function Tooltip({
|
||||
|
||||
useEffect(() => clear, []);
|
||||
|
||||
const Wrapper = block ? "div" : "span";
|
||||
|
||||
return (
|
||||
<span
|
||||
<Wrapper
|
||||
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
|
||||
onMouseEnter={show}
|
||||
onMouseMove={followCursor ? move : undefined}
|
||||
@@ -115,6 +117,7 @@ export function Tooltip({
|
||||
<motion.span
|
||||
ref={tooltipRef}
|
||||
role="tooltip"
|
||||
aria-hidden={!visible}
|
||||
// Fixed (so the scroll container's overflow doesn't clip it) and
|
||||
// positioned by `place()` near the cursor with viewport-edge flipping.
|
||||
className={`fixed ${BASE_CLASSES}`}
|
||||
@@ -131,11 +134,12 @@ export function Tooltip({
|
||||
) : (
|
||||
<span
|
||||
role="tooltip"
|
||||
aria-hidden={!visible}
|
||||
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
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 { 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";
|
||||
|
||||
// Per-section accent. These all use the standard colour scale, which the
|
||||
@@ -175,13 +178,15 @@ export function WhatsNewModal() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openUrl(CHANGELOG_URL)}
|
||||
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
|
||||
>
|
||||
Full changelog ↗
|
||||
</button>
|
||||
<Tooltip label={CHANGELOG_URL} side="top" align="start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void invoke("open_changelog_url")}
|
||||
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
|
||||
>
|
||||
Full changelog ↗
|
||||
</button>
|
||||
</Tooltip>
|
||||
<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"
|
||||
onClick={closeWhatsNew}
|
||||
|
||||
@@ -15,25 +15,31 @@ export function useBulkTagEditor() {
|
||||
const [appliedTags, setAppliedTags] = useState<string[]>([]);
|
||||
const [pending, setPending] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const requestRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const query = input.trim();
|
||||
if (!query) {
|
||||
requestRef.current += 1;
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
const requestId = ++requestRef.current;
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
|
||||
params: { query, folder_id: selectedFolderId ?? null, limit: 8 },
|
||||
});
|
||||
if (requestId !== requestRef.current) return;
|
||||
setSuggestions(results);
|
||||
} catch {
|
||||
if (requestId !== requestRef.current) return;
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, 120);
|
||||
return () => {
|
||||
requestRef.current += 1;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [input, selectedFolderId]);
|
||||
|
||||
+2
-1
@@ -2822,7 +2822,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
if (
|
||||
isDerivedCollectionTitle(state.collectionTitle) ||
|
||||
state.activeView === "explore" ||
|
||||
state.activeView === "album"
|
||||
state.activeView === "album" ||
|
||||
state.colorFilter !== null
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user