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