diff --git a/package.json b/package.json index b8b789f..e87a8af 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 47a7de2..93b55f7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,7 +6,6 @@ "permissions": [ "core:default", "opener:default", - "opener:allow-open-url", "dialog:default", "dialog:allow-open", "fs:default", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c724283..b591786 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1702,25 +1702,37 @@ pub struct ImageExif { } fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option { + 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 // --------------------------------------------------------------------------- diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe0cd94..76966ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 2f32637..3404f24 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -23,6 +23,7 @@ export function BulkActionBar() { const [panel, setPanel] = useState(null); const [deleting, setDeleting] = useState(false); + const [creatingAlbum, setCreatingAlbum] = useState(false); const [newAlbumName, setNewAlbumName] = useState(""); const barRef = useRef(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} /> diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 5612157..82facb2 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -113,7 +113,7 @@ export function ImageTile({ }: { image: ImageRecord; onClick: () => void; - onContextMenu: (event: React.MouseEvent) => void; + onContextMenu: (event: React.MouseEvent) => void; }) { const [loaded, setLoaded] = useState(false); const [errored, setErrored] = useState(false); @@ -127,27 +127,35 @@ export function ImageTile({ return ( - {/* Image / placeholder */} {src && !errored ? ( <> @@ -265,7 +273,7 @@ export function ImageTile({ )} + ); } diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 45c7e46..f4e08b3 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -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(null); const [newAlbumName, setNewAlbumName] = useState(""); + const [albumAdding, setAlbumAdding] = useState(false); const [regionSelectMode, setRegionSelectMode] = useState(false); const [isDragging, setIsDragging] = useState(false); const [dragRect, setDragRect] = useState(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} > {album.name} {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)); }} > setNewAlbumName(e.target.value)} + disabled={albumAdding} /> @@ -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)} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b4165f9..3c67282 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 = (
{ + 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(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(""); diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index f6a473e..e31b88a 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -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({
) : ( -
+
+ + +