From 5a0f9ff59220e0b623cca140e727685a1f2bc290 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 21:17:32 +0200 Subject: [PATCH] fix: heap convert dialog supports nested subfolders + visiblePhotoIds loop guard - HeapConvertDialog: switch the target picker from sourceFolders.list (top-level source roots only) to useFolderTreeQuery, flattened depth-first into a list with depth info. Each option is indented with non-breaking spaces so nested subfolders read as a tree in the native dropdown. Backend already accepts any Folder id, so no server change needed. - photoStore.setVisiblePhotoIds: short-circuit when the new id list matches the existing one element-for-element. Avoids feedback loops if a publisher fires from an effect on a render where the contents haven't actually changed (which was triggering React error #185). - Timeline: pull setVisiblePhotoIds via a focused selector instead of the wholesale destructure so the publisher subscription doesn't re-render Timeline on unrelated photo store changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/heaps/HeapConvertDialog.tsx | 50 ++++++++++++++----- frontend/src/components/timeline/Timeline.tsx | 4 +- frontend/src/store/photoStore.ts | 18 ++++++- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/heaps/HeapConvertDialog.tsx b/frontend/src/components/heaps/HeapConvertDialog.tsx index d913412..96021fb 100644 --- a/frontend/src/components/heaps/HeapConvertDialog.tsx +++ b/frontend/src/components/heaps/HeapConvertDialog.tsx @@ -1,11 +1,37 @@ -import { useState, useEffect } from 'react' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useState, useEffect, useMemo } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { X, Folder, AlertCircle } from 'lucide-react' import clsx from 'clsx' -import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api' +import { + heaps as heapsApi, + type Heap, + type FolderTreeNode, +} from '../../services/api' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' +import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { toast } from '../ToastContainer' +interface FlatFolder { + id: string + name: string + path: string + depth: number +} + +/** Walk the folder tree depth-first into a flat list with depth info so + * the picker can render every node — including nested subfolders — as + * one indented option. */ +function flattenTree(nodes: FolderTreeNode[], depth = 0): FlatFolder[] { + const out: FlatFolder[] = [] + for (const n of nodes) { + out.push({ id: n.id, name: n.name, path: n.path, depth }) + if (n.children.length > 0) { + out.push(...flattenTree(n.children, depth + 1)) + } + } + return out +} + interface HeapConvertDialogProps { heap: Heap | null onClose: () => void @@ -23,12 +49,10 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { const [deleteHeap, setDeleteHeap] = useState(false) const [subfolderName, setSubfolderName] = useState('') - const { data: foldersData } = useQuery({ - queryKey: ['folders'], - queryFn: sourceFolders.list, - enabled: !!heap, - }) - const folders = foldersData?.folders ?? [] + // Use the recursive folder tree, not the flat source-root list, so the + // user can pick a sub-folder at any depth as the target. + const { data: tree = [] } = useFolderTreeQuery() + const folders = useMemo(() => flattenTree(tree), [tree]) // Default to the first folder when the dialog opens or folders load. useEffect(() => { @@ -77,7 +101,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { if (!heap) return null - const targetFolder = folders.find((f: any) => f.id === targetId) + const targetFolder = folders.find((f) => f.id === targetId) return (
@@ -110,9 +134,11 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { onChange={(e) => setTargetId(e.target.value)} className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none" > - {folders.map((f: any) => ( + {folders.map((f) => ( ))} diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 318d87d..bcdbba1 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -172,8 +172,10 @@ export function Timeline() { togglePhotoSelection, clearSelection, openPreview, - setVisiblePhotoIds, } = usePhotoStore() + // Pulled via a focused selector so the publisher subscription doesn't + // re-render Timeline on every unrelated photo store change. + const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds) const sortBy = useFilterStore((s) => s.sortBy) const groupBy = useFilterStore((s) => s.groupBy) diff --git a/frontend/src/store/photoStore.ts b/frontend/src/store/photoStore.ts index 5fd3e6f..f524184 100644 --- a/frontend/src/store/photoStore.ts +++ b/frontend/src/store/photoStore.ts @@ -81,7 +81,23 @@ export const usePhotoStore = create((set) => ({ setViewMode: (mode) => set({ viewMode: mode }), - setVisiblePhotoIds: (visiblePhotoIds) => set({ visiblePhotoIds }), + // 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) => set({ viewMode: 'preview', activePhotoId: id }),