feat(timeline): add virtualised month-grouped timeline view
- New Timeline view groups all media by YYYY-MM using taken_at ?? created_at
- @tanstack/react-virtual (group-level virtualiser) keeps scroll smooth on
large libraries; estimateSize is exact so no measureElement needed
- ResizeObserver tracks container width; virtualizer.measure() is called on
cols change to prevent stale position cache after window resize
- setView("timeline") in store auto-sets sort:"taken_asc", resets images,
and triggers a fresh load
- Calendar nav item added to Sidebar between Explore and Duplicates
- ContextMenu and ImageTile exported from Gallery for reuse in Timeline
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
# Feature Plan: Watchdog + EXIF + Timeline
|
||||||
|
|
||||||
|
Branch: `feat/watchdog-exif-timeline`
|
||||||
|
Base: merged PR #8 (discovery features) into `main`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three related features being implemented together:
|
||||||
|
|
||||||
|
1. **Phase 1 — EXIF date extraction** ✅ DONE (commit `9ee5b08`)
|
||||||
|
2. **Phase 2 — Filesystem watchdog** ✅ DONE (commit `ae9e806`)
|
||||||
|
3. **Phase 3 — Timeline view** ✅ DONE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: EXIF Date Extraction ✅
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
Extracts the capture date from EXIF metadata during indexing and stores it as
|
||||||
|
`taken_at` (nullable TEXT, ISO 8601) on each image. Exposes "Taken: newest /
|
||||||
|
oldest" sort options.
|
||||||
|
|
||||||
|
### Key files changed
|
||||||
|
- `src-tauri/Cargo.toml` — added `kamadak-exif = "0.5"`
|
||||||
|
- `src-tauri/src/db.rs`:
|
||||||
|
- Added `taken_at: Option<String>` to `ImageRecord` struct (after `modified_at`)
|
||||||
|
- `ensure_column` migration + `idx_images_taken_at` index
|
||||||
|
- Updated `upsert_image`, `map_image_row` (all indices shifted +1 after taken_at), all 3 SELECT statements
|
||||||
|
- Added `taken_asc` / `taken_desc` sort cases using `COALESCE(taken_at, created_at)`
|
||||||
|
- `src-tauri/src/indexer.rs`:
|
||||||
|
- Added `extract_exif_date(path) -> Option<String>` — tries DateTimeOriginal → DateTimeDigitized → DateTime; rejects all-zero sentinel dates ("0000:00:00 00:00:00")
|
||||||
|
- `build_record` now populates `taken_at: extract_exif_date(path)`
|
||||||
|
- `src/store.ts` — added `taken_at: string | null` to `ImageRecord`, `"taken_desc" | "taken_asc"` to `SortOrder`, sort cases in `compareImages` using `a.taken_at ?? a.created_at`
|
||||||
|
- `src/components/Toolbar.tsx` — added "Taken: newest" / "Taken: oldest" to sort dropdown
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Existing images won't have `taken_at` until re-indexed (file_size or mtime must change to trigger re-extraction)
|
||||||
|
- `upsert_image` uses `taken_at = excluded.taken_at` (not COALESCE) — if file content changes, fresh EXIF is used
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Filesystem Watchdog ✅
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
Watches all registered folders using OS-native events (`ReadDirectoryChangesW`
|
||||||
|
on Windows). New, modified, and deleted files are reflected automatically
|
||||||
|
without manual reindexing. Zero CPU when idle.
|
||||||
|
|
||||||
|
### Key design
|
||||||
|
- **Adaptive blocking**: `recv()` when no events pending (truly idle), switches
|
||||||
|
to `recv_timeout(earliest_deadline)` only when debounce timers are running
|
||||||
|
- **500 ms per-path debounce** coalesces rapid OS event bursts
|
||||||
|
- **Change detection**: `build_record(path, folder_id, existing.as_ref())` skips
|
||||||
|
upsert if file_size + mtime unchanged → no thumbnail/embedding re-queues, no
|
||||||
|
metadata clobber
|
||||||
|
- `Access` events filtered out (reads don't change content)
|
||||||
|
|
||||||
|
### Key files changed
|
||||||
|
- `src-tauri/Cargo.toml` — added `notify = "6"`
|
||||||
|
- `src-tauri/src/db.rs` — added `get_indexed_entry_by_path` and `get_image_id_by_path`
|
||||||
|
- `src-tauri/src/indexer.rs` — added `WatcherHandle`, `WatcherInner`, `start_watcher`, `process_watcher_path`
|
||||||
|
- `src-tauri/src/lib.rs` — starts watcher after other workers, manages `WatcherHandle` in app state
|
||||||
|
- `src-tauri/src/commands.rs` — `add_folder`, `remove_folder`, `update_folder_path` now accept `State<'_, WatcherHandle>` and call `watcher.add_folder / remove_folder / update_folder`
|
||||||
|
- `src/store.ts` — `subscribeToProgress` listens for `"watcher-deleted"` event (`number[]` payload), removes images from state, clears `selectedImage` if deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Timeline View ✅ DONE
|
||||||
|
|
||||||
|
### What it should do
|
||||||
|
A new gallery view that groups images by date (year → month → day), virtualised
|
||||||
|
for performance, using the `taken_at` / `created_at` data from Phase 1.
|
||||||
|
|
||||||
|
### Suggested approach
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
- No new Tauri commands needed — images are already sorted by `taken_asc` /
|
||||||
|
`taken_desc` and carry `taken_at` / `created_at`
|
||||||
|
- Optionally: a `get_timeline_buckets` command that returns counts per
|
||||||
|
year/month for a navigation sidebar (nice-to-have, not essential for MVP)
|
||||||
|
|
||||||
|
**Frontend — grouping logic**
|
||||||
|
- New view type: add `"timeline"` to `ActiveView` type in `store.ts`
|
||||||
|
- Grouping function: takes `ImageRecord[]`, returns `TimelineGroup[]`:
|
||||||
|
```ts
|
||||||
|
interface TimelineGroup {
|
||||||
|
label: string; // e.g. "June 2023"
|
||||||
|
dateKey: string; // e.g. "2023-06" for keying
|
||||||
|
images: ImageRecord[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Use `taken_at ?? created_at` for the date. Group by `YYYY-MM` (month granularity works well).
|
||||||
|
|
||||||
|
**Frontend — virtualised rendering**
|
||||||
|
- Use `@tanstack/react-virtual` (already a dependency)
|
||||||
|
- Virtualise at the *row* level (each row = one group header + a row of tiles,
|
||||||
|
or a row of tiles within a group)
|
||||||
|
- Simplest pattern: flatten groups into a mixed list of `{ type: 'header', label }` and `{ type: 'image', image }` items, then use `useVirtualizer` on that flat list
|
||||||
|
- Tile size uses the existing `tileSizeForZoom` / `zoomPreset` from the store
|
||||||
|
|
||||||
|
**Frontend — navigation**
|
||||||
|
- Sidebar can list the groups (year/month) as anchor links — clicking jumps
|
||||||
|
`virtualizer.scrollToIndex(groupStartIndex)`
|
||||||
|
- Or keep it simple for MVP: just scroll, the headers are visible as you go
|
||||||
|
|
||||||
|
**Frontend — state**
|
||||||
|
- `sort` should auto-switch to `"taken_desc"` when entering timeline view
|
||||||
|
(can be a `useEffect` in the Timeline component)
|
||||||
|
- When leaving timeline view, restore the previous sort
|
||||||
|
|
||||||
|
**Suggested component structure**
|
||||||
|
```
|
||||||
|
src/components/Timeline.tsx
|
||||||
|
- TimelineView (main component, uses useVirtualizer)
|
||||||
|
- TimelineGroupHeader (date label row)
|
||||||
|
- reuses existing Gallery tile/card components
|
||||||
|
```
|
||||||
|
|
||||||
|
**Toolbar**
|
||||||
|
- Add "Timeline" to the view switcher alongside the existing gallery/explore/duplicates tabs
|
||||||
|
- Or add it to the `Sidebar` nav
|
||||||
|
|
||||||
|
### Existing hooks to reuse
|
||||||
|
- `tileSizeForZoom(zoomPreset)` for tile size
|
||||||
|
- `matchesFilters` for respecting active folder/media/favorites filters
|
||||||
|
- The existing `Gallery` grid tile component for rendering individual images
|
||||||
|
- `openImage` store action for lightbox
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General notes for a new session
|
||||||
|
|
||||||
|
- **Always run `feature-dev:code-reviewer` sweep before every commit** — see `MEMORY.md`
|
||||||
|
- **Use pnpm**, never npm
|
||||||
|
- **No `any` types** in TypeScript
|
||||||
|
- Hot reload is active during `pnpm dev:app` — don't restart for frontend changes
|
||||||
|
- CUDA `cargo check` failure is **pre-existing** (broken nvcc environment) and
|
||||||
|
unrelated to this feature work — filter it out with `grep -v candle` when checking
|
||||||
|
- The `map_image_row` in `db.rs` uses **positional column indices** — any new
|
||||||
|
column added to the SELECT must have its index maintained exactly
|
||||||
|
- `sqlite-vec` virtual table DML is unreliable inside transactions — vector
|
||||||
|
operations must happen outside `unchecked_transaction()`
|
||||||
|
- `ImageRecord` is mirrored in `db.rs` (Rust) and `store.ts` (TypeScript) —
|
||||||
|
both must stay in sync
|
||||||
+8
-1
@@ -7,6 +7,7 @@ import { Gallery } from "./components/Gallery";
|
|||||||
import { Lightbox } from "./components/Lightbox";
|
import { Lightbox } from "./components/Lightbox";
|
||||||
import { TagCloud } from "./components/TagCloud";
|
import { TagCloud } from "./components/TagCloud";
|
||||||
import { DuplicateFinder } from "./components/DuplicateFinder";
|
import { DuplicateFinder } from "./components/DuplicateFinder";
|
||||||
|
import { Timeline } from "./components/Timeline";
|
||||||
import { TitleBar } from "./components/TitleBar";
|
import { TitleBar } from "./components/TitleBar";
|
||||||
import { SettingsModal } from "./components/SettingsModal";
|
import { SettingsModal } from "./components/SettingsModal";
|
||||||
import { initializeNotifications } from "./notifications";
|
import { initializeNotifications } from "./notifications";
|
||||||
@@ -46,7 +47,13 @@ export default function App() {
|
|||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
{activeView === "explore" ? (
|
{activeView === "timeline" ? (
|
||||||
|
<>
|
||||||
|
<Toolbar />
|
||||||
|
<BackgroundTasks />
|
||||||
|
<Timeline />
|
||||||
|
</>
|
||||||
|
) : activeView === "explore" ? (
|
||||||
<>
|
<>
|
||||||
<BackgroundTasks />
|
<BackgroundTasks />
|
||||||
<TagCloud />
|
<TagCloud />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null {
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContextMenu({
|
export function ContextMenu({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
image,
|
image,
|
||||||
@@ -104,7 +104,7 @@ function ContextMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageTile({
|
export function ImageTile({
|
||||||
image,
|
image,
|
||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
|||||||
@@ -329,6 +329,23 @@ export function Sidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
|
activeView === "timeline"
|
||||||
|
? "bg-white/8 text-white"
|
||||||
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
onClick={() => setView("timeline")}
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className={`text-[13px] font-medium ${activeView === "timeline" ? "text-white" : ""}`}>
|
||||||
|
Timeline
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
||||||
activeView === "duplicates"
|
activeView === "duplicates"
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
|
||||||
|
import { ContextMenu, ImageTile } from "./Gallery";
|
||||||
|
|
||||||
|
const GAP = 6;
|
||||||
|
const HEADER_HEIGHT = 52;
|
||||||
|
|
||||||
|
interface TimelineGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
images: ImageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabel(key: string): string {
|
||||||
|
if (key === "unknown") return "Unknown Date";
|
||||||
|
const [yearStr, monthStr] = key.split("-");
|
||||||
|
const date = new Date(Number(yearStr), Number(monthStr) - 1);
|
||||||
|
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupImages(images: ImageRecord[]): TimelineGroup[] {
|
||||||
|
const map = new Map<string, ImageRecord[]>();
|
||||||
|
for (const img of images) {
|
||||||
|
const ds = img.taken_at ?? img.created_at;
|
||||||
|
const key = ds ? ds.substring(0, 7) : "unknown";
|
||||||
|
let bucket = map.get(key);
|
||||||
|
if (bucket === undefined) {
|
||||||
|
bucket = [];
|
||||||
|
map.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.push(img);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||||
|
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Timeline() {
|
||||||
|
const images = useGalleryStore((s) => s.images);
|
||||||
|
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
|
||||||
|
const openImage = useGalleryStore((s) => s.openImage);
|
||||||
|
const totalImages = useGalleryStore((s) => s.totalImages);
|
||||||
|
const loadingImages = useGalleryStore((s) => s.loadingImages);
|
||||||
|
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
|
||||||
|
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
|
||||||
|
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
image: ImageRecord;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Measure container width before first paint to avoid a single-column flash.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = parentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setContainerWidth(el.clientWidth);
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
setContainerWidth(entries[0].contentRect.width);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tileSize = tileSizeForZoom(zoomPreset);
|
||||||
|
const cols = useMemo(
|
||||||
|
() => Math.max(1, Math.floor((containerWidth + GAP) / (tileSize + GAP))),
|
||||||
|
[containerWidth, tileSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupImages(images), [images]);
|
||||||
|
|
||||||
|
// estimateSize must be exact so virtualizer positions groups correctly.
|
||||||
|
// Each group height = header + rowCount * (tileSize + GAP) where the last row's
|
||||||
|
// GAP acts as spacing between this group and the next header.
|
||||||
|
const estimateSize = useCallback(
|
||||||
|
(index: number): number => {
|
||||||
|
const group = groups[index];
|
||||||
|
if (!group) return HEADER_HEIGHT;
|
||||||
|
const rowCount = Math.ceil(group.images.length / cols);
|
||||||
|
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
|
||||||
|
},
|
||||||
|
[groups, cols, tileSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: groups.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize,
|
||||||
|
overscan: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-measure all items when cols changes so virtualizer positions stay accurate
|
||||||
|
// after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own).
|
||||||
|
useEffect(() => {
|
||||||
|
virtualizer.measure();
|
||||||
|
}, [cols, virtualizer]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = parentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollTop < 24) return;
|
||||||
|
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
|
||||||
|
if (nearBottom && !loadingImages && images.length < totalImages) {
|
||||||
|
void loadMoreImages();
|
||||||
|
}
|
||||||
|
}, [images.length, loadMoreImages, loadingImages, totalImages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = parentRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", handleScroll);
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const close = (e: PointerEvent) => {
|
||||||
|
if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setContextMenu(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", close);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", close);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"
|
||||||
|
>
|
||||||
|
{images.length === 0 && loadingImages ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
|
||||||
|
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||||
|
<p className="mt-4 text-sm text-white/40 font-medium">Loading timeline</p>
|
||||||
|
<p className="text-xs text-white/20 mt-1">Fetching results</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
|
||||||
|
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 mx-auto text-white/10 mb-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={0.75}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-white/30 font-medium">
|
||||||
|
{imageLoadError ? "Could not load timeline" : "No media found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/15 mt-1">
|
||||||
|
{imageLoadError ?? "Add a folder to see your timeline"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const group = groups[virtualItem.index];
|
||||||
|
if (!group) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: virtualItem.start,
|
||||||
|
width: "100%",
|
||||||
|
height: virtualItem.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Group header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 px-4"
|
||||||
|
style={{ height: HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-white/80 shrink-0">
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/25 shrink-0 tabular-nums">
|
||||||
|
{group.images.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image grid — paddingBottom:GAP gives the gap below the last row,
|
||||||
|
matching the row-to-row gap and making estimateSize exact. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
|
||||||
|
gap: GAP,
|
||||||
|
paddingLeft: GAP,
|
||||||
|
paddingRight: GAP,
|
||||||
|
paddingBottom: GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.images.map((image) => (
|
||||||
|
<ImageTile
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
onClick={() => openImage(image)}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setContextMenu({ x: event.clientX, y: event.clientY, image });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{images.length > 0 && loadingImages ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{contextMenu ? (
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
image={contextMenu.image}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
-1
@@ -122,7 +122,7 @@ export interface ThumbnailBatch {
|
|||||||
images: ImageRecord[];
|
images: ImageRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActiveView = "gallery" | "explore" | "duplicates";
|
export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline";
|
||||||
|
|
||||||
export interface TagCloudEntry {
|
export interface TagCloudEntry {
|
||||||
count: number;
|
count: number;
|
||||||
@@ -905,6 +905,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
closeImage: () => set({ selectedImage: null }),
|
closeImage: () => set({ selectedImage: null }),
|
||||||
|
|
||||||
setView: (activeView) => {
|
setView: (activeView) => {
|
||||||
|
if (activeView === "timeline") {
|
||||||
|
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
|
||||||
|
void get().loadImages(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeView === "duplicates") {
|
if (activeView === "duplicates") {
|
||||||
const { selectedFolderId, duplicateScanFolderId } = get();
|
const { selectedFolderId, duplicateScanFolderId } = get();
|
||||||
if (duplicateScanFolderId !== selectedFolderId) {
|
if (duplicateScanFolderId !== selectedFolderId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user