From a12e81d8bd81ec29af2e98c6c8c53d239aa4fe8c Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 27 Jun 2026 23:50:44 +0100 Subject: [PATCH] feat: lightbox EXIF panel, tag management, reorderable albums EXIF info panel: - New on-demand get_image_exif command (kamadak-exif) returning camera/lens/ aperture/shutter/ISO/focal-length and decimal GPS; read from the file when the lightbox opens (no DB schema change, works on already-indexed images). - Lightbox shows a Camera panel; GPS opens the location in the browser via OpenStreetMap (adds opener:allow-open-url capability). Tag management: - Backend rename_tag (rename, or merge when the target exists) and delete_tag (library-wide), both clearing the tag-cloud cache; store actions invalidate tag caches, refresh Explore, and re-point/refresh an active tag-search. - Explore -> Tag Cloud gains a Manage mode: a flat list with per-tag rename/ merge/delete. Albums: - Drag-to-reorder in the sidebar via framer-motion Reorder with a hover handle; order persists through the existing reorder_albums command. Reads live store order on drag end and robustly derives GPS hemisphere from raw EXIF ref bytes (review follow-ups). CHANGELOG updated. --- CHANGELOG.md | 11 ++ src-tauri/capabilities/default.json | 1 + src-tauri/src/commands.rs | 125 +++++++++++++++++++ src-tauri/src/db.rs | 29 +++++ src-tauri/src/lib.rs | 3 + src/components/Lightbox.tsx | 68 ++++++++++- src/components/Sidebar.tsx | 106 +++++++++++++++- src/components/TagCloud.tsx | 179 ++++++++++++++++++++++++++++ src/store.ts | 57 +++++++++ 9 files changed, 574 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed16e4..56b81b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) views too. - **Build badge in Settings** — the version line in Settings → Updates now shows whether the running build is the CPU or CUDA (GPU-accelerated) variant. +- **Camera info in the lightbox** — the image info panel now shows EXIF details + (camera, lens, aperture, shutter, ISO, focal length) and, when a photo is + geotagged, its GPS coordinates as a link that opens the location in your + browser. Read on demand from the file, so it works on already-indexed images + without re-indexing. +- **Tag management** — Explore → Tag Cloud gains a Manage mode with a flat tag + list where you can rename a tag, merge it into another (rename it to an + existing tag's name), or delete it from every image. Changes apply across the + whole library. +- **Reorderable albums** — drag albums in the sidebar (hover the row for the + drag handle) to set their order, which persists across sessions. - **What's New** — after updating, Phokus now greets you with a "What's new" toast that opens an in-app release-notes screen for the new version, with the changes grouped into collapsible Added / Changed / Fixed sections. It's diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 93b55f7..47a7de2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "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 ca05086..9539579 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1660,6 +1660,96 @@ pub fn get_build_variant() -> String { } } +/// Camera/EXIF metadata for the lightbox info panel. Read on demand from the +/// file (not stored in the DB) so it works on every already-indexed image +/// without a reindex. All fields are optional — absent tags are simply omitted. +#[derive(Serialize, Default)] +pub struct ImageExif { + pub make: Option, + pub model: Option, + pub lens: Option, + pub iso: Option, + pub f_number: Option, + pub exposure_time: Option, + pub focal_length: Option, + pub datetime_original: Option, + pub gps_lat: Option, + pub gps_lon: Option, +} + +fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option { + 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; + // 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 }); + } + } + None +} + +fn extract_image_exif(path: &Path) -> ImageExif { + let mut out = ImageExif::default(); + let Ok(file) = std::fs::File::open(path) else { + return out; + }; + let mut reader = std::io::BufReader::new(file); + let Ok(exif) = exif::Reader::new().read_from_container(&mut reader) else { + return out; + }; + + let text = |tag: exif::Tag| -> Option { + let value = exif + .get_field(tag, exif::In::PRIMARY)? + .display_value() + .with_unit(&exif) + .to_string(); + let trimmed = value.trim().trim_matches('"').trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }; + + out.make = text(exif::Tag::Make); + out.model = text(exif::Tag::Model); + out.lens = text(exif::Tag::LensModel); + out.iso = text(exif::Tag::PhotographicSensitivity); + out.f_number = text(exif::Tag::FNumber); + out.exposure_time = text(exif::Tag::ExposureTime); + out.focal_length = text(exif::Tag::FocalLength); + out.datetime_original = text(exif::Tag::DateTimeOriginal); + out.gps_lat = gps_coord(&exif, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef); + out.gps_lon = gps_coord(&exif, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef); + out +} + +#[derive(Deserialize)] +pub struct GetImageExifParams { + pub image_id: i64, +} + +#[tauri::command] +pub async fn get_image_exif( + db: State<'_, DbState>, + params: GetImageExifParams, +) -> Result { + let conn = db.get().map_err(|e| e.to_string())?; + let record = db::get_image_by_id(&conn, params.image_id).map_err(|e| e.to_string())?; + Ok(extract_image_exif(Path::new(&record.path))) +} + // ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ── fn dot(a: &[f32], b: &[f32]) -> f32 { @@ -2074,6 +2164,41 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string()) } +#[derive(Deserialize)] +pub struct RenameTagParams { + pub from: String, + pub to: String, +} + +#[derive(Deserialize)] +pub struct DeleteTagParams { + pub tag: String, +} + +#[tauri::command] +pub async fn rename_tag(db: State<'_, DbState>, params: RenameTagParams) -> Result<(), String> { + let from = params.from.trim(); + let to = params.to.trim(); + if from.is_empty() || to.is_empty() { + return Err("Tag names cannot be empty".to_string()); + } + if from == to { + return Ok(()); + } + let conn = db.get().map_err(|e| e.to_string())?; + db::rename_tag(&conn, from, to).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_tag(db: State<'_, DbState>, params: DeleteTagParams) -> Result { + let tag = params.tag.trim(); + if tag.is_empty() { + return Err("Tag name cannot be empty".to_string()); + } + let conn = db.get().map_err(|e| e.to_string())?; + db::delete_tag(&conn, tag).map_err(|e| e.to_string()) +} + // --------------------------------------------------------------------------- // Albums // --------------------------------------------------------------------------- diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f6282f0..acaab7b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2624,6 +2624,35 @@ pub fn remove_tag(conn: &Connection, tag_id: i64) -> Result<()> { Ok(()) } +/// Rename a tag across the whole library, or merge it into an existing tag when +/// `to` already exists. Images that already carry `to` keep a single instance +/// (the colliding source row is dropped). +pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> { + let tx = conn.unchecked_transaction()?; + // Move rows where the target tag isn't already on that image; UNIQUE + // collisions are skipped (OR IGNORE)… + tx.execute( + "UPDATE OR IGNORE image_tags SET tag = ?2 WHERE tag = ?1", + params![from, to], + )?; + // …then drop the now-duplicate leftovers still under the old name. + tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?; + // The tag-cloud cache keys on image-id hashes, not tag text, so a rename + // wouldn't invalidate it automatically — clear it. + tx.execute("DELETE FROM tag_cloud_cache", [])?; + tx.commit()?; + Ok(()) +} + +/// Delete a tag from every image in the library. Returns the number of rows removed. +pub fn delete_tag(conn: &Connection, name: &str) -> Result { + let tx = conn.unchecked_transaction()?; + let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64; + tx.execute("DELETE FROM tag_cloud_cache", [])?; + tx.commit()?; + Ok(removed) +} + pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result { let inserted = conn.execute( "INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eceb248..592202e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -185,6 +185,9 @@ pub fn run() { commands::get_image_tags, commands::add_user_tag, commands::remove_tag, + commands::rename_tag, + commands::delete_tag, + commands::get_image_exif, commands::list_albums, commands::create_album, commands::rename_album, diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index d8232b6..dffa8c5 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,8 +1,8 @@ import { useEffect, useCallback, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { useGalleryStore, ImageTag, AiRating } from "../store"; +import { revealItemInDir, openUrl } from "@tauri-apps/plugin-opener"; +import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; import { VideoPlayer } from "./VideoPlayer"; function formatBytes(bytes: number): string { @@ -156,6 +156,7 @@ export function Lightbox() { const albums = useGalleryStore((state) => state.albums); const addToAlbum = useGalleryStore((state) => state.addToAlbum); const createAlbum = useGalleryStore((state) => state.createAlbum); + const getImageExif = useGalleryStore((state) => state.getImageExif); // Tracks the image id that is currently displayed, used to discard async // tag mutations that resolve after the user has navigated to another image. @@ -167,6 +168,7 @@ export function Lightbox() { const [isPanning, setIsPanning] = useState(false); const lastPanPointRef = useRef({ x: 0, y: 0 }); const [imageTags, setImageTags] = useState([]); + const [imageExif, setImageExif] = useState(null); const [tagInput, setTagInput] = useState(""); const [tagAdding, setTagAdding] = useState(false); const [tagsExpanded, setTagsExpanded] = useState(false); @@ -221,6 +223,7 @@ export function Lightbox() { useEffect(() => { setView(IDENTITY_VIEW); setImageTags([]); + setImageExif(null); setTagInput(""); setTagsExpanded(false); setTaggingQueued(false); @@ -239,6 +242,20 @@ export function Lightbox() { return () => { cancelled = true; }; }, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]); + // EXIF is read on demand from the file (not stored), so it works on every + // already-indexed image without a reindex. Only meaningful for images. + useEffect(() => { + if (!selectedImage || selectedImage.media_kind !== "image") { + setImageExif(null); + return; + } + let cancelled = false; + void getImageExif(selectedImage.id) + .then((exif) => { if (!cancelled) setImageExif(exif); }) + .catch(() => { if (!cancelled) setImageExif(null); }); + return () => { cancelled = true; }; + }, [selectedImage?.id, selectedImage?.media_kind, getImageExif]); + // Reset the queued state once the worker finishes so the button is usable again useEffect(() => { if (selectedImage?.ai_tagged_at) setTaggingQueued(false); @@ -852,6 +869,53 @@ export function Lightbox() { ) : null} + {imageExif && + (imageExif.make || + imageExif.model || + imageExif.lens || + imageExif.f_number || + imageExif.exposure_time || + imageExif.iso || + imageExif.focal_length || + (imageExif.gps_lat != null && imageExif.gps_lon != null)) ? ( +
+

Camera

+
+ {imageExif.make || imageExif.model ? ( +

+ {[imageExif.make, imageExif.model].filter(Boolean).join(" ")} +

+ ) : null} + {imageExif.lens ?

{imageExif.lens}

: null} + {imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( +
+ {imageExif.f_number ? {imageExif.f_number} : null} + {imageExif.exposure_time ? {imageExif.exposure_time} : null} + {imageExif.iso ? ISO {imageExif.iso} : null} + {imageExif.focal_length ? {imageExif.focal_length} : null} +
+ ) : null} + {imageExif.gps_lat != null && imageExif.gps_lon != null ? ( + + ) : null} +
+
+ ) : null} +

Path

diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d57a8fd..b4165f9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -403,12 +403,19 @@ function AlbumItem({ manageMode = false, selectedForManage = false, onToggleManage, + reorderable = false, + onDragStart, + onDragEnd, }: { album: Album; manageMode?: boolean; selectedForManage?: boolean; onToggleManage?: () => void; + reorderable?: boolean; + onDragStart?: () => void; + onDragEnd?: () => void; }) { + const dragControls = useDragControls(); const viewAlbum = useGalleryStore((state) => state.viewAlbum); const renameAlbum = useGalleryStore((state) => state.renameAlbum); const deleteAlbum = useGalleryStore((state) => state.deleteAlbum); @@ -439,7 +446,7 @@ function AlbumItem({ const cover = album.cover_thumbnail_path ? convertFileSrc(album.cover_thumbnail_path) : null; - return ( + const row = (
) : null} + {/* Drag handle — hover-revealed, reorders albums */} + {reorderable ? ( + + ) : null} + {/* Cover thumbnail — distinguishes albums from folder rows */}
{cover ? ( @@ -539,6 +568,25 @@ function AlbumItem({ ) : null}
); + + if (reorderable) { + return ( + + {row} + + ); + } + return row; } export function Sidebar() { @@ -553,12 +601,46 @@ export function Sidebar() { const albums = useGalleryStore((state) => state.albums); const createAlbum = useGalleryStore((state) => state.createAlbum); const deleteAlbums = useGalleryStore((state) => state.deleteAlbums); + const reorderAlbums = useGalleryStore((state) => state.reorderAlbums); const [creatingAlbum, setCreatingAlbum] = useState(false); const [newAlbumName, setNewAlbumName] = useState(""); const newAlbumInputRef = useRef(null); const [manageAlbums, setManageAlbums] = useState(false); const [manageSelectedIds, setManageSelectedIds] = useState>(new Set()); const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false); + const [orderedAlbums, setOrderedAlbums] = useState(albums); + const orderedAlbumsRef = useRef(albums); + const [draggingAlbum, setDraggingAlbum] = useState(false); + + // Keep the local drag order in sync with the store except mid-drag, so a + // background album refresh doesn't yank the row out from under the pointer. + useEffect(() => { + if (draggingAlbum) return; + setOrderedAlbums(albums); + orderedAlbumsRef.current = albums; + }, [albums, draggingAlbum]); + + const handleAlbumReorder = (ids: number[]) => { + const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album])); + const next = ids + .map((id) => byId.get(id)) + .filter((album): album is Album => album !== undefined); + orderedAlbumsRef.current = next; + setOrderedAlbums(next); + }; + + const finishAlbumReorder = () => { + setDraggingAlbum(false); + 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); + if ( + nextIds.length !== currentIds.length || + nextIds.some((id, index) => id !== currentIds[index]) + ) { + void reorderAlbums(nextIds); + } + }; const [librarySort, setLibrarySortState] = useState(() => { const saved = window.localStorage.getItem(LIBRARY_SORT_KEY); return saved === "za" || saved === "custom" ? saved : "az"; @@ -974,16 +1056,34 @@ export function Sidebar() {

Select images and “Add to album” to start curating

- ) : ( + ) : manageAlbums ? ( albums.map((album) => ( toggleManageSelected(album.id)} /> )) + ) : ( + album.id)} + onReorder={handleAlbumReorder} + className="space-y-px" + > + {orderedAlbums.map((album) => ( + setDraggingAlbum(true)} + onDragEnd={finishAlbumReorder} + /> + ))} + )}
diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index 97ae8b3..f6a473e 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -292,6 +292,162 @@ function ClusterCloud({ ); } +// A flat, manageable row for a single tag — rename (which doubles as merge when +// the new name already exists) and delete across the whole library. +function TagManageRow({ + entry, + onSearch, + onRename, + onDelete, +}: { + entry: ExploreTagEntry; + onSearch: (tag: string) => void; + onRename: (from: string, to: string) => Promise; + onDelete: (tag: string) => Promise; +}) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(entry.tag); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) { + setValue(entry.tag); + setTimeout(() => inputRef.current?.select(), 0); + } + }, [editing, entry.tag]); + + const commitRename = async () => { + const next = value.trim(); + if (!next || next === entry.tag) { + setEditing(false); + return; + } + setBusy(true); + try { + await onRename(entry.tag, next); + } finally { + setBusy(false); + setEditing(false); + } + }; + + return ( +
+
+ {editing ? ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); void commitRename(); } + if (e.key === "Escape") setEditing(false); + }} + disabled={busy} + /> + ) : ( + + )} +
+ {entry.count.toLocaleString()} + + {editing ? ( +
+ + +
+ ) : confirming ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ ); +} + +function TagManageList({ + entries, + onSearch, + onRename, + onDelete, +}: { + entries: ExploreTagEntry[]; + onSearch: (tag: string) => void; + onRename: (from: string, to: string) => Promise; + onDelete: (tag: string) => Promise; +}) { + return ( +
+

+ Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete + removes a tag from every image. These changes apply across your whole library. +

+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} + export function TagCloud() { const exploreMode = useGalleryStore((state) => state.exploreMode); const setExploreMode = useGalleryStore((state) => state.setExploreMode); @@ -303,7 +459,11 @@ export function TagCloud() { const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const searchForTag = useGalleryStore((state) => state.searchForTag); + const renameTag = useGalleryStore((state) => state.renameTag); + const deleteTag = useGalleryStore((state) => state.deleteTag); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); + const [manageTags, setManageTags] = useState(false); + const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; useEffect(() => { if (exploreMode === "visual") void loadTagCloud(); @@ -342,6 +502,18 @@ export function TagCloud() {

+ {exploreMode === "tags" && hasEntries ? ( + + ) : null}