diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index c65ef44..e9bfc0d 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -22,6 +22,7 @@ import { THUMB_BADGE_PICK, } from '../timeline/PhotoThumbnail' import { usePhotoStore } from '../../store/photoStore' +import { useViewSettingsStore } from '../../store/viewSettingsStore' import { registerUndoable } from '../../store/undoStore' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { toast } from '../ToastContainer' @@ -85,9 +86,11 @@ export function DuplicatesView() { const groupsRef = useRef(groups) groupsRef.current = groups + const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize) + // Track the rendered column count of the duplicates grid so ↑/↓ can // skip a row instead of jumping a single cell. The grid uses - // `repeat(auto-fill, minmax(160px, 1fr))` so columns = floor(width/160). + // `repeat(auto-fill, ${thumbnailSize}px)` so columns = floor(width/size). // We measure the FIRST section's grid container — every section uses // the same auto-fill rule so any one is representative. const [columns, setColumns] = useState(4) @@ -95,12 +98,14 @@ export function DuplicatesView() { // re-mount on scroll back — track the observer so we can disconnect // cleanly every time the ref detaches (previously this leaked). const sampleObserverRef = useRef(null) + const thumbnailSizeRef = useRef(thumbnailSize) + thumbnailSizeRef.current = thumbnailSize const sampleGridRef = useCallback((el: HTMLDivElement | null) => { sampleObserverRef.current?.disconnect() sampleObserverRef.current = null if (!el) return const measure = () => { - const cols = Math.max(1, Math.floor(el.clientWidth / 160)) + const cols = Math.max(1, Math.floor(el.clientWidth / thumbnailSizeRef.current)) setColumns(cols) } measure() @@ -124,6 +129,19 @@ export function DuplicatesView() { getItemKey: (idx) => groups[idx].group_id, }) + // Resizing the thumbnails changes every section's height AND its + // column count, but the ResizeObserver only fires on width changes — + // so when the size flips, recompute columns directly and tell the + // virtualizer to re-measure rows. + useEffect(() => { + const el = scrollRef.current?.querySelector('[data-dup-grid]') + if (el) { + const cols = Math.max(1, Math.floor(el.clientWidth / thumbnailSize)) + setColumns(cols) + } + virtualizer.measure() + }, [thumbnailSize, virtualizer]) + // Window-level keyboard nav. Mirrors Timeline's handler but walks // `allMemberIds` directly — duplicate groups don't have a uniform row // grid so we approximate ↑/↓ via the measured `columns` count and @@ -273,6 +291,7 @@ export function DuplicatesView() { onSelectMember={handleSelectMember} selectedPhotos={selectedPhotos} isPending={discardMutation.isPending} + thumbnailSize={thumbnailSize} // First section feeds the column-count sample ref — // every section uses the same auto-fill rule. gridRef={vItem.index === 0 ? sampleGridRef : undefined} @@ -292,6 +311,7 @@ interface DuplicateGroupSectionProps { onSelectMember: (memberId: string) => void selectedPhotos: string[] isPending: boolean + thumbnailSize: number /** Optional callback ref attached to this section's grid container. * Used by DuplicatesView to measure the rendered column count for * ↑/↓ keyboard navigation. Only the first section gets one. */ @@ -305,6 +325,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({ onSelectMember, selectedPhotos, isPending, + thumbnailSize, gridRef, }: DuplicateGroupSectionProps) { // Auto-pick "best" copy: highest pixel count, ties broken by file_size, @@ -384,14 +405,15 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
and jump when images arrive, // which also throws off the virtualizer's row-height measure. - gridTemplateColumns: 'repeat(auto-fill, 160px)', - gridAutoRows: '160px', + gridTemplateColumns: `repeat(auto-fill, ${thumbnailSize}px)`, + gridAutoRows: `${thumbnailSize}px`, }} > {group.members.map((member) => { @@ -404,7 +426,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({ > o.value === sortBy)?.label ?? sortBy const sortValue = `${sortLabel} ${sortOrder === 'desc' ? '↓' : '↑'}` + const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize) + const setThumbnailSize = useViewSettingsStore((s) => s.setThumbnailSize) + const sizeIdx = THUMBNAIL_SIZE_PRESETS.indexOf(thumbnailSize) + const sizeValue = `${SIZE_LABELS[sizeIdx] ?? '–'} (${thumbnailSize}px)` + const anyActive = hasActiveFilters(filterState) return ( @@ -438,6 +454,64 @@ export function FilterBar({
+ {/* Size — controls the thumbnail cell size in the Timeline, + * Memories, Duplicates, and other grid views. Doesn't apply to + * the preview filmstrip (which has its own fixed track size). + * Marked isActive so the pill always reads as "in effect", + * matching Sort's treatment. */} + setThumbnailSize(DEFAULT_THUMBNAIL_SIZE) + } + > +
+
+ Thumbnail size +
+
+ {THUMBNAIL_SIZE_PRESETS.map((preset, idx) => { + const selected = preset === thumbnailSize + // Visual chip — a square that grows with the preset so + // the user can see the relative scaling without reading + // the px number. Capped at 28px so the popover stays + // narrow even at the XL preset. + const swatch = 12 + idx * 4 + return ( + + ) + })} +
+
+
+ {/* Clear-all — borderless text affordance pinned next to the pill * cluster on the right. Lives inside the pills container so it * shares the same flex group and gap and reads as "another diff --git a/frontend/src/components/memories/MemoriesView.tsx b/frontend/src/components/memories/MemoriesView.tsx index 536169d..7ffa31d 100644 --- a/frontend/src/components/memories/MemoriesView.tsx +++ b/frontend/src/components/memories/MemoriesView.tsx @@ -9,13 +9,14 @@ import { import type { Photo } from '../../types/photo' import { usePhotoStore } from '../../store/photoStore' import { useFilterStore } from '../../store/filterStore' +import { useViewSettingsStore } from '../../store/viewSettingsStore' import { PhotoThumbnail } from '../timeline/PhotoThumbnail' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useGridKeyNav } from '../../hooks/useGridKeyNav' -// Match Timeline's THUMBNAIL_SIZE / GAP so memories cells line up -// visually with what the user sees in the main grid. -const THUMBNAIL_SIZE = 160 +// GAP matches Timeline so memories cells line up visually with what the +// user sees in the main grid; the cell size is read live from the +// shared view-settings store. const GAP = 4 @@ -97,17 +98,18 @@ export function MemoriesView() { }, []) const scrollElRef = useRef(null) useEffect(() => () => roRef.current?.disconnect(), []) - // Fixed-size cells — always exactly THUMBNAIL_SIZE px, regardless of - // container width. See Timeline's column-math comment for the - // tradeoff (may leave a small gap on the right edge). + // Fixed-size cells — always exactly the user-configured thumbnail + // size, regardless of container width. See Timeline's column-math + // comment for the tradeoff (may leave a small gap on the right edge). + const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize) const { columns, cellSize } = useMemo(() => { - if (containerWidth === 0) return { columns: 4, cellSize: THUMBNAIL_SIZE } + if (containerWidth === 0) return { columns: 4, cellSize: thumbnailSize } const cols = Math.max( 1, - Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)), + Math.floor((containerWidth + GAP) / (thumbnailSize + GAP)), ) - return { columns: cols, cellSize: THUMBNAIL_SIZE } - }, [containerWidth]) + return { columns: cols, cellSize: thumbnailSize } + }, [containerWidth, thumbnailSize]) // Break each year's photos into rows of `columns` ids — matches the // shape useGridKeyNav expects. Arrow-nav crosses year boundaries diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index d33d621..b5a05f5 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -4,6 +4,7 @@ import { format } from 'date-fns' import { cn } from '@/lib/utils' import { usePhotoStore } from '../../store/photoStore' import { useFilterStore, hasActiveFilters } from '../../store/filterStore' +import { useViewSettingsStore } from '../../store/viewSettingsStore' import { PhotoThumbnail } from './PhotoThumbnail' import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' @@ -12,8 +13,9 @@ import { Button } from '@/components/ui/button' import { ImageOff } from 'lucide-react' import type { Photo } from '../../types/photo' -// Layout constants for the grid + grouped headers. -const THUMBNAIL_SIZE = 160 +// Layout constants for the grid + grouped headers. The cell size is +// user-configurable via the view-settings store; everything else is +// fixed. const GAP = 4 const PADDING = 16 const HEADER_HEIGHT = 36 @@ -180,22 +182,23 @@ export function Timeline() { // subscription, multiplying keystroke renders by the row count. const searchQuery = useFilterStore((s) => s.q) const viewMode = usePhotoStore((s) => s.viewMode) + const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize) - // Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no - // longer stretch to absorb the remaining width — we prefer a stable - // cell size across sidebar-open / sidebar-closed states over a - // perfectly-flush right edge. A small gap may remain on the right + // Number of columns that fit at the user-configured thumbnail size. + // Cells don't stretch to absorb the remaining width — we prefer a + // stable cell size across sidebar-open / sidebar-closed states over + // a perfectly-flush right edge. A small gap may remain on the right // when containerWidth isn't an integer multiple of (T+G). const { columns, cellSize } = useMemo(() => { if (containerWidth === 0) { - return { columns: 4, cellSize: THUMBNAIL_SIZE } + return { columns: 4, cellSize: thumbnailSize } } const cols = Math.max( 1, - Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)) + Math.floor((containerWidth + GAP) / (thumbnailSize + GAP)) ) - return { columns: cols, cellSize: THUMBNAIL_SIZE } - }, [containerWidth]) + return { columns: cols, cellSize: thumbnailSize } + }, [containerWidth, thumbnailSize]) // Shared photos query — both Timeline and PreviewView use the same hook so // they share one cache entry, regardless of filter state. diff --git a/frontend/src/store/viewSettingsStore.ts b/frontend/src/store/viewSettingsStore.ts new file mode 100644 index 0000000..86d280c --- /dev/null +++ b/frontend/src/store/viewSettingsStore.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand' + +/** Preset thumbnail sizes (px) for grid views — Timeline, Memories, + * Duplicates. The preview filmstrip is intentionally NOT affected; it + * keeps its own fixed 72px tracks because its job is to be a narrow + * navigation rail, not a re-sizeable grid. + * + * Values picked to roughly double the cell area between steps without + * breaking the existing 160px default — old sessions land back on + * "medium" with no visible jump. */ +export const THUMBNAIL_SIZE_PRESETS = [96, 128, 160, 208, 272] as const +export type ThumbnailSizeLevel = typeof THUMBNAIL_SIZE_PRESETS[number] + +export const DEFAULT_THUMBNAIL_SIZE: ThumbnailSizeLevel = 160 + +const STORAGE_KEY = 'muli.viewSettings.thumbnailSize' + +function loadInitialSize(): ThumbnailSizeLevel { + if (typeof window === 'undefined') return DEFAULT_THUMBNAIL_SIZE + const raw = window.localStorage.getItem(STORAGE_KEY) + const n = raw === null ? NaN : Number(raw) + return (THUMBNAIL_SIZE_PRESETS as readonly number[]).includes(n) + ? (n as ThumbnailSizeLevel) + : DEFAULT_THUMBNAIL_SIZE +} + +interface ViewSettingsStore { + thumbnailSize: ThumbnailSizeLevel + setThumbnailSize: (size: ThumbnailSizeLevel) => void +} + +export const useViewSettingsStore = create((set) => ({ + thumbnailSize: loadInitialSize(), + setThumbnailSize: (thumbnailSize) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, String(thumbnailSize)) + } + set({ thumbnailSize }) + }, +}))