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
+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) => {