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:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user