import { useEffect, useMemo, useRef, useState } from "react"; import { Reorder, useDragControls } from "framer-motion"; import { open } from "@tauri-apps/plugin-dialog"; import { convertFileSrc } from "@tauri-apps/api/core"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; import { ThemedDropdown } from "./ThemedDropdown"; interface ContextMenuState { folderId: number; x: number; y: number; } type LibrarySort = "az" | "za" | "custom"; const LIBRARY_SORT_KEY = "phokus-library-sort"; function FolderContextMenu({ menu, folder, isMuted, isPausedAll, onClose, onRename, onReindex, onLocate, onToggleMute, onTogglePauseAll, onRemove, }: { menu: ContextMenuState; folder: Folder; isMuted: boolean; isPausedAll: boolean; onClose: () => void; onRename: () => void; onReindex: () => void; onLocate: () => void; onToggleMute: () => void; onTogglePauseAll: () => void; onRemove: () => void; }) { const ref = useRef(null); useEffect(() => { const handleDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", handleDown); document.addEventListener("keydown", handleKey); return () => { document.removeEventListener("mousedown", handleDown); document.removeEventListener("keydown", handleKey); }; }, [onClose]); const item = (label: string, onClick: () => void, danger = false) => ( ); return (
{item("Reindex", onReindex)} {item("Rename", onRename)} {item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)} {item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)} {folder.scan_error && item("Locate Folder", onLocate)}
{item("Remove from app", onRemove, true)}
); } function FolderItem({ folder, selected, progress, customOrdering, dragging, onDragStart, onDragEnd, onKeyboardMove, }: { folder: Folder; selected: boolean; progress: IndexProgress | undefined; customOrdering: boolean; dragging: boolean; onDragStart: (pointerY: number) => void; onDragEnd: () => void; onKeyboardMove: (direction: -1 | 1) => void; }) { const dragControls = useDragControls(); const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused); const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]); const isMuted = mutedFolderIds.includes(folder.id); // "Fully paused" only when every worker for this folder is paused. const isPausedAll = !!folderWorkers && folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging; const isIndexing = progress && !progress.done; const isMissing = !!folder.scan_error && !isIndexing; const [contextMenu, setContextMenu] = useState(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(folder.name); const [confirmingRemoval, setConfirmingRemoval] = useState(false); const renameInputRef = useRef(null); useEffect(() => { if (renaming) { setRenameValue(folder.name); setTimeout(() => renameInputRef.current?.select(), 0); } }, [renaming, folder.name]); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); // Keep menu inside viewport const x = Math.min(e.clientX, window.innerWidth - 180); const y = Math.min(e.clientY, window.innerHeight - 160); setContextMenu({ folderId: folder.id, x, y }); }; const handleLocateFolder = async () => { const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` }); if (picked && typeof picked === "string") { await updateFolderPath(folder.id, picked); } }; const commitRename = async () => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== folder.name) { await renameFolder(folder.id, trimmed); } setRenaming(false); }; const handleRenameKey = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); void commitRename(); } if (e.key === "Escape") { setRenaming(false); } }; return (
!renaming && selectFolder(folder.id)} onContextMenu={handleContextMenu} > {customOrdering ? ( ) : null} {isMissing ? ( ) : ( )}
{renaming ? ( setRenameValue(e.target.value)} onKeyDown={handleRenameKey} onBlur={() => void commitRename()} onClick={(e) => e.stopPropagation()} /> ) : (
{folder.name}
)} {isIndexing ? ( <>
{progress.indexed}/{progress.total}
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} />
) : (
{folder.image_count.toLocaleString()}
)}
{/* Hover action buttons */} {!renaming && ( confirmingRemoval ? (
e.stopPropagation()}>
) : (
) )}
{isMissing && (

Folder not found

This folder may have been moved or renamed. Locate it to resume, or remove it from the app.

)} {contextMenu && contextMenu.folderId === folder.id && ( setContextMenu(null)} onRename={() => setRenaming(true)} onReindex={() => void reindexFolder(folder.id)} onLocate={() => void handleLocateFolder()} onToggleMute={() => toggleMutedFolder(folder.id)} onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)} onRemove={() => setConfirmingRemoval(true)} /> )} ); } function AlbumContextMenu({ x, y, onClose, onRename, onDelete, }: { x: number; y: number; onClose: () => void; onRename: () => void; onDelete: () => void; }) { const ref = useRef(null); useEffect(() => { const handleDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("mousedown", handleDown); document.addEventListener("keydown", handleKey); return () => { document.removeEventListener("mousedown", handleDown); document.removeEventListener("keydown", handleKey); }; }, [onClose]); const item = (label: string, onClick: () => void, danger = false) => ( ); return (
{item("Rename", onRename)}
{item("Delete album", onDelete, true)}
); } function AlbumItem({ album, manageMode = false, selectedForManage = false, onToggleManage, reorderable = false, onDragStart, onDragEnd, }: { album: Album; manageMode?: boolean; selectedForManage?: boolean; onToggleManage?: () => void; reorderable?: boolean; onDragStart?: () => void; onDragEnd?: () => void; }) { const dragControls = useDragControls(); const viewAlbum = useGalleryStore((state) => state.viewAlbum); const renameAlbum = useGalleryStore((state) => state.renameAlbum); const deleteAlbum = useGalleryStore((state) => state.deleteAlbum); const activeView = useGalleryStore((state) => state.activeView); const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id; const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(album.name); const [confirmingRemoval, setConfirmingRemoval] = useState(false); const renameInputRef = useRef(null); useEffect(() => { if (renaming) { setRenameValue(album.name); setTimeout(() => renameInputRef.current?.select(), 0); } }, [renaming, album.name]); const commitRename = async () => { const trimmed = renameValue.trim(); if (trimmed && trimmed !== album.name) { await renameAlbum(album.id, trimmed); } setRenaming(false); }; const cover = album.cover_thumbnail_path ? convertFileSrc(album.cover_thumbnail_path) : null; const row = (
{ if (manageMode) { onToggleManage?.(); } else if (!renaming) { viewAlbum(album.id); } }} onContextMenu={(e) => { if (manageMode) return; e.preventDefault(); e.stopPropagation(); setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) }); }} > {/* Manage-mode selection checkbox */} {manageMode ? (
) : null} {/* Drag handle — hover-revealed, reorders albums */} {reorderable ? ( ) : null} {/* Cover thumbnail — distinguishes albums from folder rows */}
{cover ? ( ) : (
)}
{renaming ? ( setRenameValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void commitRename(); } if (e.key === "Escape") setRenaming(false); }} onBlur={() => void commitRename()} onClick={(e) => e.stopPropagation()} /> ) : (
{album.name}
)}
{album.image_count.toLocaleString()}
{!renaming && confirmingRemoval ? (
e.stopPropagation()}>
) : null} {menu ? ( setMenu(null)} onRename={() => setRenaming(true)} onDelete={() => setConfirmingRemoval(true)} /> ) : null}
); if (reorderable) { return ( {row} ); } return row; } export function Sidebar() { const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); const indexingProgress = useGalleryStore((state) => state.indexingProgress); const selectFolder = useGalleryStore((state) => state.selectFolder); const activeView = useGalleryStore((state) => state.activeView); const setView = useGalleryStore((state) => state.setView); const reorderFolders = useGalleryStore((state) => state.reorderFolders); const albums = useGalleryStore((state) => state.albums); const createAlbum = useGalleryStore((state) => state.createAlbum); const deleteAlbums = useGalleryStore((state) => state.deleteAlbums); const reorderAlbums = useGalleryStore((state) => state.reorderAlbums); const [creatingAlbum, setCreatingAlbum] = useState(false); const [newAlbumName, setNewAlbumName] = useState(""); const newAlbumInputRef = useRef(null); const [manageAlbums, setManageAlbums] = useState(false); const [manageSelectedIds, setManageSelectedIds] = useState>(new Set()); const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false); const [orderedAlbums, setOrderedAlbums] = useState(albums); const orderedAlbumsRef = useRef(albums); const [draggingAlbum, setDraggingAlbum] = useState(false); // Keep the local drag order in sync with the store except mid-drag, so a // background album refresh doesn't yank the row out from under the pointer. useEffect(() => { if (draggingAlbum) return; setOrderedAlbums(albums); orderedAlbumsRef.current = albums; }, [albums, draggingAlbum]); const handleAlbumReorder = (ids: number[]) => { const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album])); const next = ids .map((id) => byId.get(id)) .filter((album): album is Album => album !== undefined); orderedAlbumsRef.current = next; setOrderedAlbums(next); }; const finishAlbumReorder = () => { setDraggingAlbum(false); const nextIds = orderedAlbumsRef.current.map((album) => album.id); // Read live store order (not the render-time closure) in case albums changed. const currentIds = useGalleryStore.getState().albums.map((album) => album.id); if ( nextIds.length !== currentIds.length || nextIds.some((id, index) => id !== currentIds[index]) ) { void reorderAlbums(nextIds); } }; const [librarySort, setLibrarySortState] = useState(() => { const saved = window.localStorage.getItem(LIBRARY_SORT_KEY); return saved === "za" || saved === "custom" ? saved : "az"; }); const [customFolders, setCustomFolders] = useState(folders); const [draggedFolderId, setDraggedFolderId] = useState(null); const folderListRef = useRef(null); const customFoldersRef = useRef(folders); const pointerYRef = useRef(0); const autoScrollFrameRef = useRef(null); const keyboardPersistRef = useRef | null>(null); useEffect( () => () => { if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); }, [], ); useEffect(() => { if (draggedFolderId !== null) return; setCustomFolders(folders); customFoldersRef.current = folders; }, [folders, draggedFolderId]); useEffect(() => { if (draggedFolderId === null) return; const handlePointerMove = (event: PointerEvent) => { pointerYRef.current = event.clientY; }; const autoScroll = () => { const list = folderListRef.current; if (list) { const rect = list.getBoundingClientRect(); const edgeSize = Math.min(64, rect.height * 0.2); const topDistance = pointerYRef.current - rect.top; const bottomDistance = rect.bottom - pointerYRef.current; let velocity = 0; if (topDistance < edgeSize) { velocity = -Math.pow((edgeSize - Math.max(0, topDistance)) / edgeSize, 1.6) * 14; } else if (bottomDistance < edgeSize) { velocity = Math.pow((edgeSize - Math.max(0, bottomDistance)) / edgeSize, 1.6) * 14; } if (velocity !== 0) list.scrollTop += velocity; } autoScrollFrameRef.current = requestAnimationFrame(autoScroll); }; window.addEventListener("pointermove", handlePointerMove, { passive: true }); autoScrollFrameRef.current = requestAnimationFrame(autoScroll); return () => { window.removeEventListener("pointermove", handlePointerMove); if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current); autoScrollFrameRef.current = null; }; }, [draggedFolderId]); const displayedFolders = useMemo(() => { if (librarySort === "custom") return customFolders; return [...folders].sort((a, b) => { const result = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); return librarySort === "az" ? result : -result; }); }, [customFolders, folders, librarySort]); const setLibrarySort = (sort: LibrarySort) => { window.localStorage.setItem(LIBRARY_SORT_KEY, sort); setLibrarySortState(sort); }; const handleReorder = (orderedIds: number[]) => { const byId = new Map(customFoldersRef.current.map((folder) => [folder.id, folder])); const next = orderedIds .map((id) => byId.get(id)) .filter((folder): folder is Folder => folder !== undefined); customFoldersRef.current = next; setCustomFolders(next); }; const finishReorder = () => { const nextIds = customFoldersRef.current.map((folder) => folder.id); setDraggedFolderId(null); const currentIds = folders.map((folder) => folder.id); if (nextIds.some((id, index) => id !== currentIds[index])) { void reorderFolders(nextIds); } }; const moveFolderByKeyboard = (folderId: number, direction: -1 | 1) => { const current = customFoldersRef.current; const currentIndex = current.findIndex((folder) => folder.id === folderId); const nextIndex = currentIndex + direction; if (currentIndex < 0 || nextIndex < 0 || nextIndex >= current.length) return; const next = [...current]; [next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]]; customFoldersRef.current = next; setCustomFolders(next); // Debounce the DB write so a held arrow key doesn't fire one per keystroke; // the local order updates immediately, only the persist waits to settle. if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); keyboardPersistRef.current = setTimeout(() => { keyboardPersistRef.current = null; void reorderFolders(customFoldersRef.current.map((folder) => folder.id)); }, 400); }; const handleAddFolder = () => { setFolderPickerOpen(true); }; const startCreatingAlbum = () => { setCreatingAlbum(true); setNewAlbumName(""); setTimeout(() => newAlbumInputRef.current?.focus(), 0); }; const handleCreateAlbum = async () => { const name = newAlbumName.trim(); if (!name) { setCreatingAlbum(false); return; } const album = await createAlbum(name); setNewAlbumName(""); setCreatingAlbum(false); useGalleryStore.getState().viewAlbum(album.id); }; const exitManageAlbums = () => { setManageAlbums(false); setManageSelectedIds(new Set()); setConfirmingAlbumDelete(false); }; const toggleManageSelected = (albumId: number) => { setManageSelectedIds((prev) => { const next = new Set(prev); if (next.has(albumId)) next.delete(albumId); else next.add(albumId); return next; }); setConfirmingAlbumDelete(false); }; const handleDeleteSelectedAlbums = async () => { const ids = Array.from(manageSelectedIds); if (ids.length === 0) return; await deleteAlbums(ids); exitManageAlbums(); }; return ( ); }