feat(grid): user-configurable thumbnail size for grid views
Adds a "Size" pill in the FilterBar with 5 presets (XS/S/M/L/XL, 96–272px, default M=160) that drives the cell size in the Timeline, Memories, and Duplicates grids. Preference persists in localStorage. Preview filmstrip is intentionally untouched — it's a fixed-track nav rail, not a grid. Centralised in a new viewSettingsStore so every grid reads from the same source. Duplicates' virtualizer is poked on size change so row heights and the keyboard nav's column count stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
|||||||
THUMB_BADGE_PICK,
|
THUMB_BADGE_PICK,
|
||||||
} from '../timeline/PhotoThumbnail'
|
} from '../timeline/PhotoThumbnail'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||||
import { registerUndoable } from '../../store/undoStore'
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
@@ -85,9 +86,11 @@ export function DuplicatesView() {
|
|||||||
const groupsRef = useRef(groups)
|
const groupsRef = useRef(groups)
|
||||||
groupsRef.current = groups
|
groupsRef.current = groups
|
||||||
|
|
||||||
|
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
|
||||||
|
|
||||||
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||||
// skip a row instead of jumping a single cell. The grid uses
|
// 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
|
// We measure the FIRST section's grid container — every section uses
|
||||||
// the same auto-fill rule so any one is representative.
|
// the same auto-fill rule so any one is representative.
|
||||||
const [columns, setColumns] = useState(4)
|
const [columns, setColumns] = useState(4)
|
||||||
@@ -95,12 +98,14 @@ export function DuplicatesView() {
|
|||||||
// re-mount on scroll back — track the observer so we can disconnect
|
// re-mount on scroll back — track the observer so we can disconnect
|
||||||
// cleanly every time the ref detaches (previously this leaked).
|
// cleanly every time the ref detaches (previously this leaked).
|
||||||
const sampleObserverRef = useRef<ResizeObserver | null>(null)
|
const sampleObserverRef = useRef<ResizeObserver | null>(null)
|
||||||
|
const thumbnailSizeRef = useRef(thumbnailSize)
|
||||||
|
thumbnailSizeRef.current = thumbnailSize
|
||||||
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
sampleObserverRef.current?.disconnect()
|
sampleObserverRef.current?.disconnect()
|
||||||
sampleObserverRef.current = null
|
sampleObserverRef.current = null
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => {
|
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)
|
setColumns(cols)
|
||||||
}
|
}
|
||||||
measure()
|
measure()
|
||||||
@@ -124,6 +129,19 @@ export function DuplicatesView() {
|
|||||||
getItemKey: (idx) => groups[idx].group_id,
|
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<HTMLDivElement>('[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
|
// Window-level keyboard nav. Mirrors Timeline's handler but walks
|
||||||
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
// `allMemberIds` directly — duplicate groups don't have a uniform row
|
||||||
// grid so we approximate ↑/↓ via the measured `columns` count and
|
// grid so we approximate ↑/↓ via the measured `columns` count and
|
||||||
@@ -273,6 +291,7 @@ export function DuplicatesView() {
|
|||||||
onSelectMember={handleSelectMember}
|
onSelectMember={handleSelectMember}
|
||||||
selectedPhotos={selectedPhotos}
|
selectedPhotos={selectedPhotos}
|
||||||
isPending={discardMutation.isPending}
|
isPending={discardMutation.isPending}
|
||||||
|
thumbnailSize={thumbnailSize}
|
||||||
// First section feeds the column-count sample ref —
|
// First section feeds the column-count sample ref —
|
||||||
// every section uses the same auto-fill rule.
|
// every section uses the same auto-fill rule.
|
||||||
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
|
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
|
||||||
@@ -292,6 +311,7 @@ interface DuplicateGroupSectionProps {
|
|||||||
onSelectMember: (memberId: string) => void
|
onSelectMember: (memberId: string) => void
|
||||||
selectedPhotos: string[]
|
selectedPhotos: string[]
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
|
thumbnailSize: number
|
||||||
/** Optional callback ref attached to this section's grid container.
|
/** Optional callback ref attached to this section's grid container.
|
||||||
* Used by DuplicatesView to measure the rendered column count for
|
* Used by DuplicatesView to measure the rendered column count for
|
||||||
* ↑/↓ keyboard navigation. Only the first section gets one. */
|
* ↑/↓ keyboard navigation. Only the first section gets one. */
|
||||||
@@ -305,6 +325,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
|||||||
onSelectMember,
|
onSelectMember,
|
||||||
selectedPhotos,
|
selectedPhotos,
|
||||||
isPending,
|
isPending,
|
||||||
|
thumbnailSize,
|
||||||
gridRef,
|
gridRef,
|
||||||
}: DuplicateGroupSectionProps) {
|
}: DuplicateGroupSectionProps) {
|
||||||
// Auto-pick "best" copy: highest pixel count, ties broken by file_size,
|
// Auto-pick "best" copy: highest pixel count, ties broken by file_size,
|
||||||
@@ -384,14 +405,15 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={gridRef}
|
ref={gridRef}
|
||||||
|
data-dup-grid
|
||||||
className="grid gap-1 p-2"
|
className="grid gap-1 p-2"
|
||||||
style={{
|
style={{
|
||||||
// Fix both axes so each cell reserves a 160×160 box before
|
// Fix both axes so each cell reserves a size×size box before
|
||||||
// its thumbnail finishes loading — otherwise rows collapse to
|
// its thumbnail finishes loading — otherwise rows collapse to
|
||||||
// the height of an empty <img> and jump when images arrive,
|
// the height of an empty <img> and jump when images arrive,
|
||||||
// which also throws off the virtualizer's row-height measure.
|
// which also throws off the virtualizer's row-height measure.
|
||||||
gridTemplateColumns: 'repeat(auto-fill, 160px)',
|
gridTemplateColumns: `repeat(auto-fill, ${thumbnailSize}px)`,
|
||||||
gridAutoRows: '160px',
|
gridAutoRows: `${thumbnailSize}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.members.map((member) => {
|
{group.members.map((member) => {
|
||||||
@@ -404,7 +426,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
|||||||
>
|
>
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
photo={photoByMemberId.get(member.id)!}
|
photo={photoByMemberId.get(member.id)!}
|
||||||
size={160}
|
size={thumbnailSize}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedSet.has(member.id)}
|
isSelected={selectedSet.has(member.id)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
type MediaType,
|
type MediaType,
|
||||||
type SortField,
|
type SortField,
|
||||||
} from '../../store/filterStore'
|
} from '../../store/filterStore'
|
||||||
|
import {
|
||||||
|
useViewSettingsStore,
|
||||||
|
THUMBNAIL_SIZE_PRESETS,
|
||||||
|
DEFAULT_THUMBNAIL_SIZE,
|
||||||
|
type ThumbnailSizeLevel,
|
||||||
|
} from '../../store/viewSettingsStore'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
import { FilterPill } from './FilterPill'
|
import { FilterPill } from './FilterPill'
|
||||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
@@ -48,6 +54,11 @@ const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
|||||||
{ value: 'rating', label: 'Rating' },
|
{ value: 'rating', label: 'Rating' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Short labels for each thumbnail preset in THUMBNAIL_SIZE_PRESETS order.
|
||||||
|
// Used by the Size pill's value summary AND the buttons inside its
|
||||||
|
// popover, so the two stay in sync.
|
||||||
|
const SIZE_LABELS = ['XS', 'S', 'M', 'L', 'XL'] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact, always-visible filter toolbar built out of FilterPill primitives.
|
* Compact, always-visible filter toolbar built out of FilterPill primitives.
|
||||||
* Each pill represents a filter category, opens a popover with the
|
* Each pill represents a filter category, opens a popover with the
|
||||||
@@ -155,6 +166,11 @@ export function FilterBar({
|
|||||||
const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? sortBy
|
const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? sortBy
|
||||||
const sortValue = `${sortLabel} ${sortOrder === 'desc' ? '↓' : '↑'}`
|
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)
|
const anyActive = hasActiveFilters(filterState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -438,6 +454,64 @@ export function FilterBar({
|
|||||||
</div>
|
</div>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<FilterPill
|
||||||
|
label="Size"
|
||||||
|
value={sizeValue}
|
||||||
|
isActive
|
||||||
|
onClear={
|
||||||
|
thumbnailSize === DEFAULT_THUMBNAIL_SIZE
|
||||||
|
? undefined
|
||||||
|
: () => setThumbnailSize(DEFAULT_THUMBNAIL_SIZE)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2 p-1">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
|
Thumbnail size
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setThumbnailSize(preset as ThumbnailSizeLevel)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-10 flex-col items-center justify-center gap-0.5 rounded border text-[10px] transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-primary/60 bg-primary/15 text-primary'
|
||||||
|
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||||
|
)}
|
||||||
|
title={`${SIZE_LABELS[idx]} — ${preset}px`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-sm',
|
||||||
|
selected ? 'bg-primary' : 'bg-text-muted/50'
|
||||||
|
)}
|
||||||
|
style={{ width: swatch, height: swatch }}
|
||||||
|
/>
|
||||||
|
<span className="leading-none">{SIZE_LABELS[idx]}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilterPill>
|
||||||
|
|
||||||
{/* Clear-all — borderless text affordance pinned next to the pill
|
{/* Clear-all — borderless text affordance pinned next to the pill
|
||||||
* cluster on the right. Lives inside the pills container so it
|
* cluster on the right. Lives inside the pills container so it
|
||||||
* shares the same flex group and gap and reads as "another
|
* shares the same flex group and gap and reads as "another
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import {
|
|||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
|
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||||
|
|
||||||
// Match Timeline's THUMBNAIL_SIZE / GAP so memories cells line up
|
// GAP matches Timeline so memories cells line up visually with what the
|
||||||
// visually with what the user sees in the main grid.
|
// user sees in the main grid; the cell size is read live from the
|
||||||
const THUMBNAIL_SIZE = 160
|
// shared view-settings store.
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
|
|
||||||
|
|
||||||
@@ -97,17 +98,18 @@ export function MemoriesView() {
|
|||||||
}, [])
|
}, [])
|
||||||
const scrollElRef = useRef<HTMLDivElement | null>(null)
|
const scrollElRef = useRef<HTMLDivElement | null>(null)
|
||||||
useEffect(() => () => roRef.current?.disconnect(), [])
|
useEffect(() => () => roRef.current?.disconnect(), [])
|
||||||
// Fixed-size cells — always exactly THUMBNAIL_SIZE px, regardless of
|
// Fixed-size cells — always exactly the user-configured thumbnail
|
||||||
// container width. See Timeline's column-math comment for the
|
// size, regardless of container width. See Timeline's column-math
|
||||||
// tradeoff (may leave a small gap on the right edge).
|
// comment for the tradeoff (may leave a small gap on the right edge).
|
||||||
|
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
|
||||||
const { columns, cellSize } = useMemo(() => {
|
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(
|
const cols = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)),
|
Math.floor((containerWidth + GAP) / (thumbnailSize + GAP)),
|
||||||
)
|
)
|
||||||
return { columns: cols, cellSize: THUMBNAIL_SIZE }
|
return { columns: cols, cellSize: thumbnailSize }
|
||||||
}, [containerWidth])
|
}, [containerWidth, thumbnailSize])
|
||||||
|
|
||||||
// Break each year's photos into rows of `columns` ids — matches the
|
// Break each year's photos into rows of `columns` ids — matches the
|
||||||
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
|
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { format } from 'date-fns'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||||
|
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
@@ -12,8 +13,9 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { ImageOff } from 'lucide-react'
|
import { ImageOff } from 'lucide-react'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers.
|
// Layout constants for the grid + grouped headers. The cell size is
|
||||||
const THUMBNAIL_SIZE = 160
|
// user-configurable via the view-settings store; everything else is
|
||||||
|
// fixed.
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
const PADDING = 16
|
const PADDING = 16
|
||||||
const HEADER_HEIGHT = 36
|
const HEADER_HEIGHT = 36
|
||||||
@@ -180,22 +182,23 @@ export function Timeline() {
|
|||||||
// subscription, multiplying keystroke renders by the row count.
|
// subscription, multiplying keystroke renders by the row count.
|
||||||
const searchQuery = useFilterStore((s) => s.q)
|
const searchQuery = useFilterStore((s) => s.q)
|
||||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
|
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
|
||||||
|
|
||||||
// Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no
|
// Number of columns that fit at the user-configured thumbnail size.
|
||||||
// longer stretch to absorb the remaining width — we prefer a stable
|
// Cells don't stretch to absorb the remaining width — we prefer a
|
||||||
// cell size across sidebar-open / sidebar-closed states over a
|
// stable cell size across sidebar-open / sidebar-closed states over
|
||||||
// perfectly-flush right edge. A small gap may remain on the right
|
// a perfectly-flush right edge. A small gap may remain on the right
|
||||||
// when containerWidth isn't an integer multiple of (T+G).
|
// when containerWidth isn't an integer multiple of (T+G).
|
||||||
const { columns, cellSize } = useMemo(() => {
|
const { columns, cellSize } = useMemo(() => {
|
||||||
if (containerWidth === 0) {
|
if (containerWidth === 0) {
|
||||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
return { columns: 4, cellSize: thumbnailSize }
|
||||||
}
|
}
|
||||||
const cols = Math.max(
|
const cols = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP))
|
Math.floor((containerWidth + GAP) / (thumbnailSize + GAP))
|
||||||
)
|
)
|
||||||
return { columns: cols, cellSize: THUMBNAIL_SIZE }
|
return { columns: cols, cellSize: thumbnailSize }
|
||||||
}, [containerWidth])
|
}, [containerWidth, thumbnailSize])
|
||||||
|
|
||||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||||
// they share one cache entry, regardless of filter state.
|
// they share one cache entry, regardless of filter state.
|
||||||
|
|||||||
40
frontend/src/store/viewSettingsStore.ts
Normal file
40
frontend/src/store/viewSettingsStore.ts
Normal file
@@ -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<ViewSettingsStore>((set) => ({
|
||||||
|
thumbnailSize: loadInitialSize(),
|
||||||
|
setThumbnailSize: (thumbnailSize) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, String(thumbnailSize))
|
||||||
|
}
|
||||||
|
set({ thumbnailSize })
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user