!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)}>
+
+ )}
+
+ );
+}
+
+
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 (
+
+ );
+}
+
+
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,
+ };
+}