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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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(¶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
|
// Database maintenance
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user