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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 21:17:32 +02:00
parent a56062d353
commit 5a0f9ff592
3 changed files with 58 additions and 14 deletions

View File

@@ -1,11 +1,37 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react' import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx' 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 { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { toast } from '../ToastContainer' 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 { interface HeapConvertDialogProps {
heap: Heap | null heap: Heap | null
onClose: () => void onClose: () => void
@@ -23,12 +49,10 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const [deleteHeap, setDeleteHeap] = useState(false) const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('') const [subfolderName, setSubfolderName] = useState('')
const { data: foldersData } = useQuery({ // Use the recursive folder tree, not the flat source-root list, so the
queryKey: ['folders'], // user can pick a sub-folder at any depth as the target.
queryFn: sourceFolders.list, const { data: tree = [] } = useFolderTreeQuery()
enabled: !!heap, const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
})
const folders = foldersData?.folders ?? []
// Default to the first folder when the dialog opens or folders load. // Default to the first folder when the dialog opens or folders load.
useEffect(() => { useEffect(() => {
@@ -77,7 +101,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
if (!heap) return null if (!heap) return null
const targetFolder = folders.find((f: any) => f.id === targetId) const targetFolder = folders.find((f) => f.id === targetId)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -110,9 +134,11 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
onChange={(e) => setTargetId(e.target.value)} 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" 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) => (
<option key={f.id} value={f.id}> <option key={f.id} value={f.id}>
{f.name} {/* Two non-breaking spaces per depth so nested
* subfolders read as a tree in the native dropdown. */}
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</option> </option>
))} ))}
</select> </select>

View File

@@ -172,8 +172,10 @@ export function Timeline() {
togglePhotoSelection, togglePhotoSelection,
clearSelection, clearSelection,
openPreview, openPreview,
setVisiblePhotoIds,
} = usePhotoStore() } = 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 sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy) const groupBy = useFilterStore((s) => s.groupBy)

View File

@@ -81,7 +81,23 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
setViewMode: (mode) => set({ viewMode: mode }), 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 }), openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),