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.
This commit is contained in:
2026-06-27 23:50:44 +01:00
parent 6bef90b7fb
commit a12e81d8bd
9 changed files with 574 additions and 5 deletions
+66 -2
View File
@@ -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<ImageTag[]>([]);
const [imageExif, setImageExif] = useState<ImageExif | null>(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}
</div>
{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)) ? (
<div>
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">Camera</p>
<div className="space-y-1.5">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")}
</p>
) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
{imageExif.iso ? <span>ISO {imageExif.iso}</span> : null}
{imageExif.focal_length ? <span>{imageExif.focal_length}</span> : null}
</div>
) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<button
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}`,
)
}
>
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
) : null}
</div>
</div>
) : null}
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
+103 -3
View File
@@ -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 = (
<div
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
@@ -475,6 +482,28 @@ function AlbumItem({
</div>
) : null}
{/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${album.name}`}
title="Drag to reorder"
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
}}
onClick={(e) => e.stopPropagation()}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
@@ -539,6 +568,25 @@ function AlbumItem({
) : null}
</div>
);
if (reorderable) {
return (
<Reorder.Item
as="div"
value={album.id}
drag="y"
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
}
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<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(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<LibrarySort>(() => {
const saved = window.localStorage.getItem(LIBRARY_SORT_KEY);
return saved === "za" || saved === "custom" ? saved : "az";
@@ -974,16 +1056,34 @@ export function Sidebar() {
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : (
) : manageAlbums ? (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode={manageAlbums}
manageMode
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
) : (
<Reorder.Group
as="div"
axis="y"
values={orderedAlbums.map((album) => album.id)}
onReorder={handleAlbumReorder}
className="space-y-px"
>
{orderedAlbums.map((album) => (
<AlbumItem
key={album.id}
album={album}
reorderable
onDragStart={() => setDraggingAlbum(true)}
onDragEnd={finishAlbumReorder}
/>
))}
</Reorder.Group>
)}
</div>
</div>
+179
View File
@@ -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<void>;
onDelete: (tag: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.tag);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/[0.04]">
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/40"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setEditing(false);
}}
disabled={busy}
/>
) : (
<button
className="truncate text-left text-sm text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)}
title="Search this tag"
>
{entry.tag}
</button>
)}
</div>
<span className="shrink-0 text-xs tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
{editing ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()}
disabled={busy || !value.trim()}
title="Rename (merges into the target if it already exists)"
>
Save
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : confirming ? (
<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); } }}
disabled={busy}
>
Delete
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover: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"
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"
onClick={() => setConfirming(true)}
>
Delete
</button>
</div>
)}
</div>
);
}
function TagManageList({
entries,
onSearch,
onRename,
onDelete,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
return (
<div className="mx-auto w-full max-w-2xl overflow-y-auto px-6 py-6">
<p className="mb-3 px-3 text-[11px] leading-relaxed text-white/30">
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.
</p>
<div className="divide-y divide-white/[0.05]">
{entries.map((entry) => (
<TagManageRow
key={entry.tag}
entry={entry}
onSearch={onSearch}
onRename={onRename}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{exploreMode === "tags" && hasEntries ? (
<button
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags
? "border-white/15 bg-white/10 text-white"
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
}`}
onClick={() => setManageTags((v) => !v)}
>
{manageTags ? "Done" : "Manage"}
</button>
) : null}
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
@@ -380,6 +552,13 @@ export function TagCloud() {
</div>
) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : manageTags ? (
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
/>
) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8">
+57
View File
@@ -189,6 +189,19 @@ export interface Album {
updated_at: string;
}
export interface ImageExif {
make: string | null;
model: string | null;
lens: string | null;
iso: string | null;
f_number: string | null;
exposure_time: string | null;
focal_length: string | null;
datetime_original: string | null;
gps_lat: number | null;
gps_lon: number | null;
}
export interface TagCloudEntry {
count: number;
representative_image_id: number;
@@ -551,6 +564,9 @@ interface GalleryState {
getImageTags: (imageId: number) => Promise<ImageTag[]>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>;
getImageExif: (imageId: number) => Promise<ImageExif>;
renameTag: (from: string, to: string) => Promise<void>;
deleteTag: (tag: string) => Promise<number>;
// Gallery multi-select (Feature A)
toggleGallerySelected: (imageId: number) => void;
@@ -2210,6 +2226,47 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ exploreTagsFolderId: undefined });
},
getImageExif: async (imageId) => {
return invoke<ImageExif>("get_image_exif", { params: { image_id: imageId } });
},
renameTag: async (from, to) => {
await invoke("rename_tag", { params: { from, to } });
// Tag content changed — invalidate the explore-tags and tag-cloud caches.
set({
exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined,
tagCloudEntries: [],
});
const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === from) {
// An active tag-search points at the old name — repoint it so the gallery
// refreshes instead of showing stale results for a tag that no longer exists.
get().setSearch(`/t ${to}`);
} else if (get().activeView === "explore") {
await get().loadExploreTags();
}
},
deleteTag: async (tag) => {
const removed = await invoke<number>("delete_tag", { params: { tag } });
set({
exploreTagsFolderId: undefined,
exploreTagEntries: [],
tagCloudFolderId: undefined,
tagCloudEntries: [],
});
const parsed = parseSearchValue(get().search);
if (parsed.mode === "tag" && parsed.query === tag) {
// The searched tag is gone — reload so the now-empty result is reflected.
void get().loadImages(true);
} else if (get().activeView === "explore") {
await get().loadExploreTags();
}
return removed;
},
// ── Gallery multi-select (Feature A) ──────────────────────────────────────
toggleGallerySelected: (imageId) => {