refactor(sidebar): modularize Sidebar

Break down the monolithic Sidebar component into smaller, maintainable modules to improve readability and separation of concerns.

- Extract `NavItem`, `FolderItem`, and `AlbumItem` into dedicated UI components.
- Modularize domain logic by introducing `LibrarySection` and `AlbumSection`.
- Abstract complex drag-and-drop and ordering state into custom `useFolderOrdering` and `useAlbumOrdering` hooks.
- Move shared constants and types to a dedicated `types.ts` file.
This commit is contained in:
2026-07-04 18:57:36 +01:00
parent 1074c875a3
commit 2901425f42
9 changed files with 928 additions and 846 deletions
+9 -846
View File
@@ -1,656 +1,24 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useGalleryStore } from "../store";
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 { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { CheckIcon, CloseIcon, FolderIcon, PhotoIcon, PlusIcon } from "./icons"; import { PlusIcon } from "./icons";
import { AlbumSection } from "./sidebar/AlbumSection";
type LibrarySort = "az" | "za" | "custom"; import { LibrarySection } from "./sidebar/LibrarySection";
const LIBRARY_SORT_KEY = "phokus-library-sort"; import { NavItem } from "./sidebar/NavItem";
function NavItem({
label,
iconPath,
active,
onClick,
}: {
label: string;
iconPath: string;
active: boolean;
onClick: () => void;
}) {
return (
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
active ? "bg-white/8 text-white" : "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconPath} />
</svg>
<span className={`text-[13px] font-medium ${active ? "text-white" : ""}`}>{label}</span>
</div>
);
}
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 (
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
} ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
{customOrdering ? (
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
<button
type="button"
aria-label={`Reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging
? "cursor-grabbing bg-white/10 text-gray-300"
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400"
}`}
onPointerDown={(event) => {
event.stopPropagation();
onDragStart(event.clientY);
dragControls.start(event);
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1);
}}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{isMissing ? (
<span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
) : (
<FolderIcon className="w-3.5 h-3.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRename
name={folder.name}
onRename={(next) => renameFolder(folder.id, next)}
onClose={() => setRenaming(false)}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{folder.name}
</div>
)}
{isIndexing ? (
<>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%` }}
/>
</div>
</>
) : (
<div className="text-[11px] text-gray-600 mt-0.5">{folder.image_count.toLocaleString()}</div>
)}
</div>
{/* Hover action buttons */}
{!renaming && (
confirmingRemoval ? (
<InlineConfirm
onConfirm={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }}
onCancel={() => setConfirmingRemoval(false)}
/>
) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip label="Reindex" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</Tooltip>
<Tooltip label="Remove from app" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
<CloseIcon className="w-3 h-3" strokeWidth={1.75} />
</button>
</Tooltip>
</div>
)
)}
</div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }}
>
Locate Folder
</button>
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
Remove
</button>
</div>
</div>
)}
{contextMenu && (
<ContextMenu x={contextMenu.x} y={contextMenu.y} size="sm" onClose={() => setContextMenu(null)}>
<MenuItem label="Reindex" onSelect={() => void reindexFolder(folder.id)} />
<MenuItem label="Rename" onSelect={() => setRenaming(true)} />
<MenuItem
label={isPausedAll ? "Resume background work" : "Pause background work"}
onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)}
/>
<MenuItem
label={isMuted ? "Unmute notifications" : "Mute notifications"}
onSelect={() => toggleMutedFolder(folder.id)}
/>
{folder.scan_error ? <MenuItem label="Locate Folder" onSelect={() => void handleLocateFolder()} /> : null}
<MenuSeparator />
<MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu>
)}
</Reorder.Item>
);
}
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 = (
<div
role={manageMode ? "checkbox" : "button"}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
: selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => {
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 ? (
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent"
}`}
>
<CheckIcon className="h-2.5 w-2.5" strokeWidth={3} />
</div>
) : null}
{/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? (
<Tooltip label="Drag to reorder" anchorToCursor>
<button
type="button"
aria-label={`Reorder ${album.name}`}
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
}}
onClick={(e) => e.stopPropagation()}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
<img src={cover} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<PhotoIcon className="h-3.5 w-3.5" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<InlineRename
name={album.name}
onRename={(next) => renameAlbum(album.id, next)}
onClose={() => setRenaming(false)}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{album.name}
</div>
)}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div>
</div>
{!renaming && confirmingRemoval ? (
<InlineConfirm
onConfirm={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
onCancel={() => setConfirmingRemoval(false)}
/>
) : null}
{menu ? (
<ContextMenu x={menu.x} y={menu.y} size="sm" onClose={() => setMenu(null)}>
<MenuItem label="Rename" onSelect={() => setRenaming(true)} />
<MenuSeparator />
<MenuItem label="Delete album" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu>
) : null}
</div>
);
if (reorderable) {
return (
<Reorder.Item
as="div"
value={album.id}
drag="y"
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
}
return row;
}
export function Sidebar() { export function Sidebar() {
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); 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 selectFolder = useGalleryStore((state) => state.selectFolder);
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
const setView = useGalleryStore((state) => state.setView); 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<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(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<LibrarySort>(() => {
const saved = window.localStorage.getItem(LIBRARY_SORT_KEY);
return saved === "za" || saved === "custom" ? saved : "az";
});
const [customFolders, setCustomFolders] = useState(folders);
const [draggedFolderId, setDraggedFolderId] = useState<number | null>(null);
const folderListRef = useRef<HTMLDivElement>(null);
const customFoldersRef = useRef(folders);
const pointerYRef = useRef(0);
const autoScrollFrameRef = useRef<number | null>(null);
const keyboardPersistRef = useRef<ReturnType<typeof setTimeout> | 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 ( return (
<aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60"> <aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0"> <div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span> <span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
<Tooltip label="Add Media Folder" anchorToCursor> <Tooltip label="Add Media Folder" anchorToCursor>
<button <button
onClick={handleAddFolder} onClick={() => setFolderPickerOpen(true)}
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors" className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors"
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
@@ -658,7 +26,6 @@ export function Sidebar() {
</Tooltip> </Tooltip>
</div> </div>
{/* Nav */}
<div className="px-2 pt-2 pb-1 space-y-px"> <div className="px-2 pt-2 pb-1 space-y-px">
<NavItem <NavItem
label="All Media" label="All Media"
@@ -686,212 +53,8 @@ export function Sidebar() {
/> />
</div> </div>
{/* Section label */} <LibrarySection />
{folders.length > 0 && ( <AlbumSection />
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<Dropdown
value={librarySort}
onChange={setLibrarySort}
ariaLabel="Library order"
trigger="compact"
panelClassName="min-w-0"
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
)}
{/* Folder list */}
<Reorder.Group
ref={folderListRef}
as="div"
axis="y"
values={displayedFolders.map((folder) => folder.id)}
onReorder={librarySort === "custom" ? handleReorder : () => {}}
layoutScroll
className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0"
>
{folders.length === 0 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started
</p>
) : (
displayedFolders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]}
customOrdering={librarySort === "custom"}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
/>
))
)}
</Reorder.Group>
{/* Albums — a visually distinct block, separated from Libraries by a
heavier divider and given cover-thumbnail rows so the two never
read as one list. */}
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
</span>
{manageAlbums ? (
<button
onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200"
>
Done
</button>
) : (
<div className="flex items-center gap-0.5">
{albums.length > 0 ? (
<Tooltip label="Manage albums">
<button
onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</Tooltip>
) : null}
<Tooltip label="New album">
<button
onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
)}
</div>
{/* Manage action row */}
{manageAlbums ? (
<div className="px-3 pb-1.5">
{confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400">
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.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingAlbumDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={() => void handleDeleteSelectedAlbums()}
>
Delete {manageSelectedIds.size}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)),
)
}
>
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"}
</button>
<button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
</button>
</div>
)}
</div>
) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px">
{creatingAlbum ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
}}
>
<input
ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600"
placeholder="Album name…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
}
}}
onBlur={() => void handleCreateAlbum()}
/>
</form>
) : null}
{albums.length === 0 && !creatingAlbum ? (
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : manageAlbums ? (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
) : (
<Reorder.Group
as="div"
axis="y"
values={orderedAlbums.map((album) => album.id)}
onReorder={handleAlbumReorder}
className="space-y-px"
>
{orderedAlbums.map((album) => (
<AlbumItem
key={album.id}
album={album}
reorderable
onDragStart={() => setDraggingAlbum(true)}
onDragEnd={finishAlbumReorder}
/>
))}
</Reorder.Group>
)}
</div>
</div>
</aside> </aside>
); );
} }
+176
View File
@@ -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 = (
<div
role={manageMode ? "checkbox" : "button"}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
: selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => {
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 ? (
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent"
}`}
>
<CheckIcon className="h-2.5 w-2.5" strokeWidth={3} />
</div>
) : null}
{/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? (
<Tooltip label="Drag to reorder" anchorToCursor>
<button
type="button"
aria-label={`Reorder ${album.name}`}
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
}}
onClick={(e) => e.stopPropagation()}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
<img src={cover} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<PhotoIcon className="h-3.5 w-3.5" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<InlineRename
name={album.name}
onRename={(next) => renameAlbum(album.id, next)}
onClose={() => setRenaming(false)}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{album.name}
</div>
)}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div>
</div>
{!renaming && confirmingRemoval ? (
<InlineConfirm
onConfirm={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
onCancel={() => setConfirmingRemoval(false)}
/>
) : null}
{menu ? (
<ContextMenu x={menu.x} y={menu.y} size="sm" onClose={() => setMenu(null)}>
<MenuItem label="Rename" onSelect={() => setRenaming(true)} />
<MenuSeparator />
<MenuItem label="Delete album" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu>
) : null}
</div>
);
if (reorderable) {
return (
<Reorder.Item
as="div"
value={album.id}
drag="y"
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
}
return row;
}
+221
View File
@@ -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<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(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 (
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
</span>
{manageAlbums ? (
<button
onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200"
>
Done
</button>
) : (
<div className="flex items-center gap-0.5">
{albums.length > 0 ? (
<Tooltip label="Manage albums">
<button
onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</Tooltip>
) : null}
<Tooltip label="New album">
<button
onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
>
<PlusIcon className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
)}
</div>
{manageAlbums ? (
<div className="px-3 pb-1.5">
{confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400">
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.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingAlbumDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={() => void handleDeleteSelectedAlbums()}
>
Delete {manageSelectedIds.size}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)),
)
}
>
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"}
</button>
<button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
</button>
</div>
)}
</div>
) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px">
{creatingAlbum ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
}}
>
<input
ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600"
placeholder="Album name…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
}
}}
onBlur={() => void handleCreateAlbum()}
/>
</form>
) : null}
{albums.length === 0 && !creatingAlbum ? (
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : manageAlbums ? (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
) : (
<Reorder.Group
as="div"
axis="y"
values={orderedAlbums.map((album) => album.id)}
onReorder={handleAlbumReorder}
className="space-y-px"
>
{orderedAlbums.map((album) => (
<AlbumItem
key={album.id}
album={album}
reorderable
onDragStart={() => setDraggingAlbum(true)}
onDragEnd={finishAlbumReorder}
/>
))}
</Reorder.Group>
)}
</div>
</div>
);
}
+227
View File
@@ -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 (
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
} ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
{customOrdering ? (
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
<button
type="button"
aria-label={`Reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging
? "cursor-grabbing bg-white/10 text-gray-300"
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400"
}`}
onPointerDown={(event) => {
event.stopPropagation();
onDragStart(event.clientY);
dragControls.start(event);
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1);
}}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{isMissing ? (
<span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
) : (
<FolderIcon className="w-3.5 h-3.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRename
name={folder.name}
onRename={(next) => renameFolder(folder.id, next)}
onClose={() => setRenaming(false)}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{folder.name}
</div>
)}
{isIndexing ? (
<>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
<div className="h-px bg-white/10 rounded mt-1.5 overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${progress.total > 0 ? (progress.indexed / progress.total) * 100 : 0}%` }}
/>
</div>
</>
) : (
<div className="text-[11px] text-gray-600 mt-0.5">{folder.image_count.toLocaleString()}</div>
)}
</div>
{/* Hover action buttons */}
{!renaming && (
confirmingRemoval ? (
<InlineConfirm
onConfirm={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }}
onCancel={() => setConfirmingRemoval(false)}
/>
) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip label="Reindex" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</Tooltip>
<Tooltip label="Remove from app" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
<CloseIcon className="w-3 h-3" strokeWidth={1.75} />
</button>
</Tooltip>
</div>
)
)}
</div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }}
>
Locate Folder
</button>
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
Remove
</button>
</div>
</div>
)}
{contextMenu && (
<ContextMenu x={contextMenu.x} y={contextMenu.y} size="sm" onClose={() => setContextMenu(null)}>
<MenuItem label="Reindex" onSelect={() => void reindexFolder(folder.id)} />
<MenuItem label="Rename" onSelect={() => setRenaming(true)} />
<MenuItem
label={isPausedAll ? "Resume background work" : "Pause background work"}
onSelect={() => setAllWorkersPaused(folder.id, !isPausedAll)}
/>
<MenuItem
label={isMuted ? "Unmute notifications" : "Mute notifications"}
onSelect={() => toggleMutedFolder(folder.id)}
/>
{folder.scan_error ? <MenuItem label="Locate Folder" onSelect={() => void handleLocateFolder()} /> : null}
<MenuSeparator />
<MenuItem label="Remove from app" danger onSelect={() => setConfirmingRemoval(true)} />
</ContextMenu>
)}
</Reorder.Item>
);
}
+81
View File
@@ -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 && (
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<Dropdown
value={librarySort}
onChange={setLibrarySort}
ariaLabel="Library order"
trigger="compact"
panelClassName="min-w-0"
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
)}
<Reorder.Group
ref={folderListRef}
as="div"
axis="y"
values={displayedFolders.map((folder) => 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 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started
</p>
) : (
displayedFolders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]}
customOrdering={customOrdering}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
/>
))
)}
</Reorder.Group>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function NavItem({
label,
iconPath,
active,
onClick,
}: {
label: string;
iconPath: string;
active: boolean;
onClick: () => void;
}) {
return (
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
active ? "bg-white/8 text-white" : "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={iconPath} />
</svg>
<span className={`text-[13px] font-medium ${active ? "text-white" : ""}`}>{label}</span>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export type LibrarySort = "az" | "za" | "custom";
export const LIBRARY_SORT_KEY = "phokus-library-sort";
@@ -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<void>) {
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,
};
}
+130
View File
@@ -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<void>) {
const [librarySort, setLibrarySortState] = useState<LibrarySort>(() => {
const saved = window.localStorage.getItem(LIBRARY_SORT_KEY);
return saved === "za" || saved === "custom" ? saved : "az";
});
const [customFolders, setCustomFolders] = useState(folders);
const [draggedFolderId, setDraggedFolderId] = useState<number | null>(null);
const folderListRef = useRef<HTMLDivElement>(null);
const customFoldersRef = useRef(folders);
const pointerYRef = useRef(0);
const autoScrollFrameRef = useRef<number | null>(null);
const keyboardPersistRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}