diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 465860a..6af9414 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,656 +1,24 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Reorder, useDragControls } from "framer-motion"; -import { open } from "@tauri-apps/plugin-dialog"; -import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; -import { ContextMenu, Dropdown, MenuItem, MenuSeparator } from "./menu"; -import { InlineConfirm } from "./InlineConfirm"; -import { InlineRename } from "./InlineRename"; -import { mediaSrc } from "../lib/mediaSrc"; +import { useGalleryStore } from "../store"; import { Tooltip } from "./Tooltip"; -import { CheckIcon, CloseIcon, FolderIcon, PhotoIcon, PlusIcon } from "./icons"; - -type LibrarySort = "az" | "za" | "custom"; -const LIBRARY_SORT_KEY = "phokus-library-sort"; - -function NavItem({ - label, - iconPath, - active, - onClick, -}: { - label: string; - iconPath: string; - active: boolean; - onClick: () => void; -}) { - return ( -
- - - - {label} -
- ); -} - -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<{ x: number; y: number } | null>(null); - const [renaming, setRenaming] = useState(false); - const [confirmingRemoval, setConfirmingRemoval] = useState(false); - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }; - - 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); - } - }; - - return ( - -
!renaming && selectFolder(folder.id)} - onContextMenu={handleContextMenu} - > - {customOrdering ? ( - - - - ) : null} - {isMissing ? ( - - - - - - ) : ( - - )} - -
- {renaming ? ( - renameFolder(folder.id, next)} - onClose={() => setRenaming(false)} - /> - ) : ( -
- {folder.name} -
- )} - {isIndexing ? ( - <> -
{progress.indexed}/{progress.total}
-
-
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} - /> -
- - ) : ( -
{folder.image_count.toLocaleString()}
- )} -
- - {/* Hover action buttons */} - {!renaming && ( - confirmingRemoval ? ( - { void removeFolder(folder.id); setConfirmingRemoval(false); }} - onCancel={() => setConfirmingRemoval(false)} - /> - ) : ( -
- - - - - - -
- ) - )} -
- - {isMissing && ( -
-

Folder not found

-

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

-
- - -
-
- )} - - {contextMenu && ( - setContextMenu(null)}> - void reindexFolder(folder.id)} /> - setRenaming(true)} /> - setAllWorkersPaused(folder.id, !isPausedAll)} - /> - toggleMutedFolder(folder.id)} - /> - {folder.scan_error ? void handleLocateFolder()} /> : null} - - setConfirmingRemoval(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 [confirmingRemoval, setConfirmingRemoval] = useState(false); - - const cover = mediaSrc(album.cover_thumbnail_path); - - const row = ( -
{ - if (manageMode) { - onToggleManage?.(); - } else if (!renaming) { - viewAlbum(album.id); - } - }} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) return; - if (renaming || (e.key !== "Enter" && e.key !== " ")) return; - e.preventDefault(); - if (manageMode) { - onToggleManage?.(); - } else { - viewAlbum(album.id); - } - }} - onContextMenu={(e) => { - if (manageMode) return; - e.preventDefault(); - e.stopPropagation(); - setMenu({ x: e.clientX, y: e.clientY }); - }} - > - {/* Manage-mode selection checkbox */} - {manageMode ? ( -
- -
- ) : null} - - {/* Drag handle — hover-revealed, reorders albums */} - {reorderable ? ( - - - - ) : null} - - {/* Cover thumbnail — distinguishes albums from folder rows */} -
- {cover ? ( - - ) : ( -
- -
- )} -
- -
- {renaming ? ( - renameAlbum(album.id, next)} - onClose={() => setRenaming(false)} - /> - ) : ( -
- {album.name} -
- )} -
{album.image_count.toLocaleString()}
-
- - {!renaming && confirmingRemoval ? ( - { void deleteAlbum(album.id); setConfirmingRemoval(false); }} - onCancel={() => setConfirmingRemoval(false)} - /> - ) : null} - - {menu ? ( - setMenu(null)}> - setRenaming(true)} /> - - setConfirmingRemoval(true)} /> - - ) : null} -
- ); - - if (reorderable) { - return ( - - {row} - - ); - } - return row; -} +import { PlusIcon } from "./icons"; +import { AlbumSection } from "./sidebar/AlbumSection"; +import { LibrarySection } from "./sidebar/LibrarySection"; +import { NavItem } from "./sidebar/NavItem"; 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 selectedFolderId = useGalleryStore((state) => state.selectedFolderId); 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 [createAlbumPending, setCreateAlbumPending] = 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); - const snapshotIds = albums.map((album) => album.id); - if ( - snapshotIds.length !== currentIds.length || - snapshotIds.some((id, index) => id !== currentIds[index]) - ) { - orderedAlbumsRef.current = useGalleryStore.getState().albums; - setOrderedAlbums(orderedAlbumsRef.current); - return; - } - 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; - } - if (createAlbumPending) return; - setCreateAlbumPending(true); - try { - const album = await createAlbum(name); - setNewAlbumName(""); - setCreatingAlbum(false); - useGalleryStore.getState().viewAlbum(album.id); - } finally { - setCreateAlbumPending(false); - } - }; - - 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 ( ); } diff --git a/src/components/sidebar/AlbumItem.tsx b/src/components/sidebar/AlbumItem.tsx new file mode 100644 index 0000000..231e1a8 --- /dev/null +++ b/src/components/sidebar/AlbumItem.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { Reorder, useDragControls } from "framer-motion"; +import { useGalleryStore, type Album } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { ContextMenu, MenuItem, MenuSeparator } from "../menu"; +import { InlineConfirm } from "../InlineConfirm"; +import { InlineRename } from "../InlineRename"; +import { Tooltip } from "../Tooltip"; +import { CheckIcon, PhotoIcon } from "../icons"; + +export 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 [confirmingRemoval, setConfirmingRemoval] = useState(false); + + const cover = mediaSrc(album.cover_thumbnail_path); + + const row = ( +
{ + if (manageMode) { + onToggleManage?.(); + } else if (!renaming) { + viewAlbum(album.id); + } + }} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (renaming || (e.key !== "Enter" && e.key !== " ")) return; + e.preventDefault(); + if (manageMode) { + onToggleManage?.(); + } else { + viewAlbum(album.id); + } + }} + onContextMenu={(e) => { + if (manageMode) return; + e.preventDefault(); + e.stopPropagation(); + setMenu({ x: e.clientX, y: e.clientY }); + }} + > + {/* Manage-mode selection checkbox */} + {manageMode ? ( +
+ +
+ ) : null} + + {/* Drag handle — hover-revealed, reorders albums */} + {reorderable ? ( + + + + ) : null} + + {/* Cover thumbnail — distinguishes albums from folder rows */} +
+ {cover ? ( + + ) : ( +
+ +
+ )} +
+ +
+ {renaming ? ( + renameAlbum(album.id, next)} + onClose={() => setRenaming(false)} + /> + ) : ( +
+ {album.name} +
+ )} +
{album.image_count.toLocaleString()}
+
+ + {!renaming && confirmingRemoval ? ( + { void deleteAlbum(album.id); setConfirmingRemoval(false); }} + onCancel={() => setConfirmingRemoval(false)} + /> + ) : null} + + {menu ? ( + setMenu(null)}> + setRenaming(true)} /> + + setConfirmingRemoval(true)} /> + + ) : null} +
+ ); + + if (reorderable) { + return ( + + {row} + + ); + } + return row; +} + + diff --git a/src/components/sidebar/AlbumSection.tsx b/src/components/sidebar/AlbumSection.tsx new file mode 100644 index 0000000..677271b --- /dev/null +++ b/src/components/sidebar/AlbumSection.tsx @@ -0,0 +1,221 @@ +import { useRef, useState } from "react"; +import { Reorder } from "framer-motion"; +import { useGalleryStore } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { PlusIcon } from "../icons"; +import { AlbumItem } from "./AlbumItem"; +import { useAlbumOrdering } from "./useAlbumOrdering"; + +export function AlbumSection() { + 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 [createAlbumPending, setCreateAlbumPending] = 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 { finishAlbumReorder, handleAlbumReorder, orderedAlbums, setDraggingAlbum } = useAlbumOrdering(albums, reorderAlbums); + + const startCreatingAlbum = () => { + setCreatingAlbum(true); + setNewAlbumName(""); + setTimeout(() => newAlbumInputRef.current?.focus(), 0); + }; + + const handleCreateAlbum = async () => { + const name = newAlbumName.trim(); + if (!name) { + setCreatingAlbum(false); + return; + } + if (createAlbumPending) return; + setCreateAlbumPending(true); + try { + const album = await createAlbum(name); + setNewAlbumName(""); + setCreatingAlbum(false); + useGalleryStore.getState().viewAlbum(album.id); + } finally { + setCreateAlbumPending(false); + } + }; + + 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 ( +
+
+ + {manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"} + + {manageAlbums ? ( + + ) : ( +
+ {albums.length > 0 ? ( + + + + ) : null} + + + +
+ )} +
+ + {manageAlbums ? ( +
+ {confirmingAlbumDelete ? ( +
+

+ Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in + the library — only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed. +

+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : null} + +
+ {creatingAlbum ? ( +
{ + e.preventDefault(); + void handleCreateAlbum(); + }} + > + setNewAlbumName(e.target.value)} + disabled={createAlbumPending} + onKeyDown={(e) => { + if (createAlbumPending) return; + if (e.key === "Escape") { + setCreatingAlbum(false); + setNewAlbumName(""); + } + }} + onBlur={() => void handleCreateAlbum()} + /> +
+ ) : null} + + {albums.length === 0 && !creatingAlbum ? ( +

+ Select images and “Add to album” to start curating +

+ ) : manageAlbums ? ( + albums.map((album) => ( + toggleManageSelected(album.id)} + /> + )) + ) : ( + album.id)} + onReorder={handleAlbumReorder} + className="space-y-px" + > + {orderedAlbums.map((album) => ( + setDraggingAlbum(true)} + onDragEnd={finishAlbumReorder} + /> + ))} + + )} +
+
+ ); +} diff --git a/src/components/sidebar/FolderItem.tsx b/src/components/sidebar/FolderItem.tsx new file mode 100644 index 0000000..7b30207 --- /dev/null +++ b/src/components/sidebar/FolderItem.tsx @@ -0,0 +1,227 @@ +import { useState, type MouseEvent } from "react"; +import { Reorder, useDragControls } from "framer-motion"; +import { open } from "@tauri-apps/plugin-dialog"; +import { useGalleryStore, type Folder, type IndexProgress } from "../../store"; +import { ContextMenu, MenuItem, MenuSeparator } from "../menu"; +import { InlineConfirm } from "../InlineConfirm"; +import { InlineRename } from "../InlineRename"; +import { Tooltip } from "../Tooltip"; +import { CloseIcon, FolderIcon } from "../icons"; + +export 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<{ x: number; y: number } | null>(null); + const [renaming, setRenaming] = useState(false); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }; + + 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); + } + }; + + return ( + +
!renaming && selectFolder(folder.id)} + onContextMenu={handleContextMenu} + > + {customOrdering ? ( + + + + ) : null} + {isMissing ? ( + + + + + + ) : ( + + )} + +
+ {renaming ? ( + renameFolder(folder.id, next)} + onClose={() => setRenaming(false)} + /> + ) : ( +
+ {folder.name} +
+ )} + {isIndexing ? ( + <> +
{progress.indexed}/{progress.total}
+
+
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} + /> +
+ + ) : ( +
{folder.image_count.toLocaleString()}
+ )} +
+ + {/* Hover action buttons */} + {!renaming && ( + confirmingRemoval ? ( + { void removeFolder(folder.id); setConfirmingRemoval(false); }} + onCancel={() => setConfirmingRemoval(false)} + /> + ) : ( +
+ + + + + + +
+ ) + )} +
+ + {isMissing && ( +
+

Folder not found

+

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

+
+ + +
+
+ )} + + {contextMenu && ( + setContextMenu(null)}> + void reindexFolder(folder.id)} /> + setRenaming(true)} /> + setAllWorkersPaused(folder.id, !isPausedAll)} + /> + toggleMutedFolder(folder.id)} + /> + {folder.scan_error ? void handleLocateFolder()} /> : null} + + setConfirmingRemoval(true)} /> + + )} + + ); +} + + diff --git a/src/components/sidebar/LibrarySection.tsx b/src/components/sidebar/LibrarySection.tsx new file mode 100644 index 0000000..fb3dc91 --- /dev/null +++ b/src/components/sidebar/LibrarySection.tsx @@ -0,0 +1,81 @@ +import { Reorder } from "framer-motion"; +import { useGalleryStore } from "../../store"; +import { Dropdown } from "../menu"; +import { FolderItem } from "./FolderItem"; +import { useFolderOrdering } from "./useFolderOrdering"; + +export function LibrarySection() { + const folders = useGalleryStore((state) => state.folders); + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); + const indexingProgress = useGalleryStore((state) => state.indexingProgress); + const reorderFolders = useGalleryStore((state) => state.reorderFolders); + + const { + customOrdering, + displayedFolders, + draggedFolderId, + finishReorder, + folderListRef, + handleReorder, + librarySort, + moveFolderByKeyboard, + pointerYRef, + setDraggedFolderId, + setLibrarySort, + } = useFolderOrdering(folders, reorderFolders); + + return ( + <> + {folders.length > 0 && ( +
+ Libraries + +
+ )} + + folder.id)} + onReorder={customOrdering ? handleReorder : () => {}} + layoutScroll + className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0" + > + {folders.length === 0 ? ( +

+ Add a folder to get started +

+ ) : ( + displayedFolders.map((folder) => ( + { + pointerYRef.current = pointerY; + setDraggedFolderId(folder.id); + }} + onDragEnd={finishReorder} + onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)} + /> + )) + )} +
+ + ); +} diff --git a/src/components/sidebar/NavItem.tsx b/src/components/sidebar/NavItem.tsx new file mode 100644 index 0000000..179e216 --- /dev/null +++ b/src/components/sidebar/NavItem.tsx @@ -0,0 +1,27 @@ +export function NavItem({ + label, + iconPath, + active, + onClick, +}: { + label: string; + iconPath: string; + active: boolean; + onClick: () => void; +}) { + return ( +
+ + + + {label} +
+ ); +} + + diff --git a/src/components/sidebar/types.ts b/src/components/sidebar/types.ts new file mode 100644 index 0000000..81242f3 --- /dev/null +++ b/src/components/sidebar/types.ts @@ -0,0 +1,3 @@ +export type LibrarySort = "az" | "za" | "custom"; + +export const LIBRARY_SORT_KEY = "phokus-library-sort"; diff --git a/src/components/sidebar/useAlbumOrdering.ts b/src/components/sidebar/useAlbumOrdering.ts new file mode 100644 index 0000000..535086d --- /dev/null +++ b/src/components/sidebar/useAlbumOrdering.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; +import { useGalleryStore, type Album } from "../../store"; + +export function useAlbumOrdering(albums: Album[], reorderAlbums: (ids: number[]) => Promise) { + 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); + const snapshotIds = albums.map((album) => album.id); + if ( + snapshotIds.length !== currentIds.length || + snapshotIds.some((id, index) => id !== currentIds[index]) + ) { + orderedAlbumsRef.current = useGalleryStore.getState().albums; + setOrderedAlbums(orderedAlbumsRef.current); + return; + } + if ( + nextIds.length !== currentIds.length || + nextIds.some((id, index) => id !== currentIds[index]) + ) { + void reorderAlbums(nextIds); + } + }; + + return { + finishAlbumReorder, + handleAlbumReorder, + orderedAlbums, + setDraggingAlbum, + }; +} diff --git a/src/components/sidebar/useFolderOrdering.ts b/src/components/sidebar/useFolderOrdering.ts new file mode 100644 index 0000000..c08a2e7 --- /dev/null +++ b/src/components/sidebar/useFolderOrdering.ts @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { type Folder } from "../../store"; +import { LIBRARY_SORT_KEY, type LibrarySort } from "./types"; + +export function useFolderOrdering(folders: Folder[], reorderFolders: (ids: number[]) => Promise) { + 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); + }; + + return { + customOrdering: librarySort === "custom", + displayedFolders, + draggedFolderId, + finishReorder, + folderListRef, + handleReorder, + librarySort, + moveFolderByKeyboard, + pointerYRef, + setDraggedFolderId, + setLibrarySort, + }; +}