import { create } from 'zustand' import type { Photo } from '../types/photo' type ViewMode = 'grid' | 'preview' interface PhotoStore { photos: Photo[] selectedPhotos: string[] activePhotoId: string | null /** The id of the photo where the current range-selection anchor lives. * Set when the user clicks (without modifiers) or arrow-navigates, * read by selectRange to figure out the start of a Shift+Click / * Shift+Arrow range. Tracked as an id (not an index) so it survives * filter changes and works correctly in tag-grouped mode where * visual indices != API indices. */ rangeStartId: string | null viewMode: ViewMode /** Flat sequence of photo ids in the order they currently appear in * the timeline grid (including duplicates from tag-grouping). Used * for both preview navigation order and range selection. Owned by * the Timeline component. */ visiblePhotoIds: string[] setPhotos: (photos: Photo[]) => void selectPhoto: (id: string) => void togglePhotoSelection: (id: string) => void /** Replace the selection with every photo between the current * rangeStartId and the supplied endId, walking visiblePhotoIds in * visual order. If there's no anchor yet, anchors at endId. */ selectRange: (endId: string) => void deselectPhoto: (id: string) => void clearSelection: () => void setActivePhoto: (id: string | null) => void setViewMode: (mode: ViewMode) => void setVisiblePhotoIds: (ids: string[]) => void /** Open preview on a specific photo. The visibleSequence (optional) * is the ordered list of photo ids the user currently sees in the * timeline; passing it from the click site avoids a race where the * passive Timeline publisher hasn't updated yet. */ openPreview: (id: string, visibleSequence?: string[]) => void closePreview: () => void } export const usePhotoStore = create((set) => ({ photos: [], selectedPhotos: [], activePhotoId: null, rangeStartId: null, viewMode: 'grid', visiblePhotoIds: [], setPhotos: (photos) => set({ photos }), selectPhoto: (id) => set({ selectedPhotos: [id], activePhotoId: id, rangeStartId: id, }), togglePhotoSelection: (id) => set((state) => { const isSelected = state.selectedPhotos.includes(id) return { selectedPhotos: isSelected ? state.selectedPhotos.filter((photoId) => photoId !== id) : [...state.selectedPhotos, id], // A toggle-add anchors the range here so a subsequent Shift+click // extends from this id. A toggle-remove leaves the anchor alone. rangeStartId: isSelected ? state.rangeStartId : id, activePhotoId: id, } }), selectRange: (endId) => set((state) => { const ids = state.visiblePhotoIds // No published sequence yet → just behave like a single-pick. if (ids.length === 0) { return { selectedPhotos: [endId], activePhotoId: endId, rangeStartId: endId, } } const anchorId = state.rangeStartId ?? state.activePhotoId ?? endId const startIdx = ids.indexOf(anchorId) const endIdx = ids.indexOf(endId) if (startIdx < 0 || endIdx < 0) { return { selectedPhotos: [endId], activePhotoId: endId, rangeStartId: endId, } } const min = Math.min(startIdx, endIdx) const max = Math.max(startIdx, endIdx) // De-dupe because the visible sequence can repeat photos in // tag-grouped mode. const seen = new Set() const next: string[] = [] for (let i = min; i <= max; i++) { const id = ids[i] if (!seen.has(id)) { seen.add(id) next.push(id) } } return { selectedPhotos: next, activePhotoId: endId, // Anchor stays put — the user can keep extending from the // original click point, matching Lightroom / Finder behavior. } }), deselectPhoto: (id) => set((state) => ({ selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id) })), clearSelection: () => set({ selectedPhotos: [], rangeStartId: null, }), setActivePhoto: (id) => set({ activePhotoId: id }), setViewMode: (mode) => set({ viewMode: mode }), // No-op when the content is identical so callers can fire from an // effect without risking a re-render loop. setVisiblePhotoIds: (visiblePhotoIds) => set((s) => { const prev = s.visiblePhotoIds if (prev.length === visiblePhotoIds.length) { let same = true for (let i = 0; i < prev.length; i++) { if (prev[i] !== visiblePhotoIds[i]) { same = false break } } if (same) return s } return { visiblePhotoIds } }), openPreview: (id, visibleSequence) => set((s) => ({ viewMode: 'preview', activePhotoId: id, // Adopt the caller-provided sequence when they pass one. Falls // back to whatever Timeline most recently published, which is // correct for paths like the global Space hotkey that don't have // a click site to compute the sequence from. visiblePhotoIds: visibleSequence && visibleSequence.length > 0 ? visibleSequence : s.visiblePhotoIds, })), closePreview: () => set({ viewMode: 'grid' }), }))