a12e81d8bd
EXIF info panel: - New on-demand get_image_exif command (kamadak-exif) returning camera/lens/ aperture/shutter/ISO/focal-length and decimal GPS; read from the file when the lightbox opens (no DB schema change, works on already-indexed images). - Lightbox shows a Camera panel; GPS opens the location in the browser via OpenStreetMap (adds opener:allow-open-url capability). Tag management: - Backend rename_tag (rename, or merge when the target exists) and delete_tag (library-wide), both clearing the tag-cloud cache; store actions invalidate tag caches, refresh Explore, and re-point/refresh an active tag-search. - Explore -> Tag Cloud gains a Manage mode: a flat list with per-tag rename/ merge/delete. Albums: - Drag-to-reorder in the sidebar via framer-motion Reorder with a hover handle; order persists through the existing reorder_albums command. Reads live store order on drag end and robustly derives GPS hemisphere from raw EXIF ref bytes (review follow-ups). CHANGELOG updated.
1093 lines
42 KiB
TypeScript
1093 lines
42 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Reorder, useDragControls } from "framer-motion";
|
|
import { open } from "@tauri-apps/plugin-dialog";
|
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
|
|
import { ThemedDropdown } from "./ThemedDropdown";
|
|
|
|
interface ContextMenuState {
|
|
folderId: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
type LibrarySort = "az" | "za" | "custom";
|
|
const LIBRARY_SORT_KEY = "phokus-library-sort";
|
|
|
|
function FolderContextMenu({
|
|
menu,
|
|
folder,
|
|
isMuted,
|
|
isPausedAll,
|
|
onClose,
|
|
onRename,
|
|
onReindex,
|
|
onLocate,
|
|
onToggleMute,
|
|
onTogglePauseAll,
|
|
onRemove,
|
|
}: {
|
|
menu: ContextMenuState;
|
|
folder: Folder;
|
|
isMuted: boolean;
|
|
isPausedAll: boolean;
|
|
onClose: () => void;
|
|
onRename: () => void;
|
|
onReindex: () => void;
|
|
onLocate: () => void;
|
|
onToggleMute: () => void;
|
|
onTogglePauseAll: () => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleDown = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
};
|
|
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
document.addEventListener("mousedown", handleDown);
|
|
document.addEventListener("keydown", handleKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleDown);
|
|
document.removeEventListener("keydown", handleKey);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const item = (label: string, onClick: () => void, danger = false) => (
|
|
<button
|
|
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
|
|
danger
|
|
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
|
|
: "text-gray-300 hover:bg-white/8 hover:text-white"
|
|
}`}
|
|
onClick={() => { onClick(); onClose(); }}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
|
|
style={{ left: menu.x, top: menu.y }}
|
|
>
|
|
{item("Reindex", onReindex)}
|
|
{item("Rename", onRename)}
|
|
{item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)}
|
|
{item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)}
|
|
{folder.scan_error && item("Locate Folder", onLocate)}
|
|
<div className="my-1 border-t border-white/[0.06]" />
|
|
{item("Remove from app", onRemove, true)}
|
|
</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<ContextMenuState | null>(null);
|
|
const [renaming, setRenaming] = useState(false);
|
|
const [renameValue, setRenameValue] = useState(folder.name);
|
|
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (renaming) {
|
|
setRenameValue(folder.name);
|
|
setTimeout(() => renameInputRef.current?.select(), 0);
|
|
}
|
|
}, [renaming, folder.name]);
|
|
|
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Keep menu inside viewport
|
|
const x = Math.min(e.clientX, window.innerWidth - 180);
|
|
const y = Math.min(e.clientY, window.innerHeight - 160);
|
|
setContextMenu({ folderId: folder.id, x, y });
|
|
};
|
|
|
|
const handleLocateFolder = async () => {
|
|
const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
|
|
if (picked && typeof picked === "string") {
|
|
await updateFolderPath(folder.id, picked);
|
|
}
|
|
};
|
|
|
|
const commitRename = async () => {
|
|
const trimmed = renameValue.trim();
|
|
if (trimmed && trimmed !== folder.name) {
|
|
await renameFolder(folder.id, trimmed);
|
|
}
|
|
setRenaming(false);
|
|
};
|
|
|
|
const handleRenameKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
|
|
if (e.key === "Escape") { setRenaming(false); }
|
|
};
|
|
|
|
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 ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`Reorder ${folder.name}`}
|
|
title={`Drag to 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>
|
|
) : 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>
|
|
) : (
|
|
<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="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
|
</svg>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{renaming ? (
|
|
<input
|
|
ref={renameInputRef}
|
|
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onKeyDown={handleRenameKey}
|
|
onBlur={() => void commitRename()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<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 ? (
|
|
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
|
|
onClick={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }}
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
|
|
onClick={() => setConfirmingRemoval(false)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
|
|
title="Reindex"
|
|
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>
|
|
<button
|
|
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
|
title="Remove from app"
|
|
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
|
|
d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</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.folderId === folder.id && (
|
|
<FolderContextMenu
|
|
menu={contextMenu}
|
|
folder={folder}
|
|
isMuted={isMuted}
|
|
isPausedAll={isPausedAll}
|
|
onClose={() => setContextMenu(null)}
|
|
onRename={() => setRenaming(true)}
|
|
onReindex={() => void reindexFolder(folder.id)}
|
|
onLocate={() => void handleLocateFolder()}
|
|
onToggleMute={() => toggleMutedFolder(folder.id)}
|
|
onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)}
|
|
onRemove={() => setConfirmingRemoval(true)}
|
|
/>
|
|
)}
|
|
</Reorder.Item>
|
|
);
|
|
}
|
|
|
|
function AlbumContextMenu({
|
|
x,
|
|
y,
|
|
onClose,
|
|
onRename,
|
|
onDelete,
|
|
}: {
|
|
x: number;
|
|
y: number;
|
|
onClose: () => void;
|
|
onRename: () => void;
|
|
onDelete: () => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
const handleDown = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
};
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("mousedown", handleDown);
|
|
document.addEventListener("keydown", handleKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleDown);
|
|
document.removeEventListener("keydown", handleKey);
|
|
};
|
|
}, [onClose]);
|
|
|
|
const item = (label: string, onClick: () => void, danger = false) => (
|
|
<button
|
|
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
|
|
danger
|
|
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
|
|
: "text-gray-300 hover:bg-white/8 hover:text-white"
|
|
}`}
|
|
onClick={() => {
|
|
onClick();
|
|
onClose();
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
|
|
style={{ left: x, top: y }}
|
|
>
|
|
{item("Rename", onRename)}
|
|
<div className="my-1 border-t border-white/[0.06]" />
|
|
{item("Delete album", onDelete, true)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AlbumItem({
|
|
album,
|
|
manageMode = false,
|
|
selectedForManage = false,
|
|
onToggleManage,
|
|
reorderable = false,
|
|
onDragStart,
|
|
onDragEnd,
|
|
}: {
|
|
album: Album;
|
|
manageMode?: boolean;
|
|
selectedForManage?: boolean;
|
|
onToggleManage?: () => void;
|
|
reorderable?: boolean;
|
|
onDragStart?: () => void;
|
|
onDragEnd?: () => void;
|
|
}) {
|
|
const dragControls = useDragControls();
|
|
const viewAlbum = useGalleryStore((state) => state.viewAlbum);
|
|
const renameAlbum = useGalleryStore((state) => state.renameAlbum);
|
|
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum);
|
|
const activeView = useGalleryStore((state) => state.activeView);
|
|
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
|
|
const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id;
|
|
|
|
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
|
|
const [renaming, setRenaming] = useState(false);
|
|
const [renameValue, setRenameValue] = useState(album.name);
|
|
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (renaming) {
|
|
setRenameValue(album.name);
|
|
setTimeout(() => renameInputRef.current?.select(), 0);
|
|
}
|
|
}, [renaming, album.name]);
|
|
|
|
const commitRename = async () => {
|
|
const trimmed = renameValue.trim();
|
|
if (trimmed && trimmed !== album.name) {
|
|
await renameAlbum(album.id, trimmed);
|
|
}
|
|
setRenaming(false);
|
|
};
|
|
|
|
const cover = album.cover_thumbnail_path ? convertFileSrc(album.cover_thumbnail_path) : null;
|
|
|
|
const row = (
|
|
<div
|
|
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);
|
|
}
|
|
}}
|
|
onContextMenu={(e) => {
|
|
if (manageMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) });
|
|
}}
|
|
>
|
|
{/* Manage-mode selection checkbox */}
|
|
{manageMode ? (
|
|
<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"
|
|
}`}
|
|
>
|
|
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Drag handle — hover-revealed, reorders albums */}
|
|
{reorderable ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`Reorder ${album.name}`}
|
|
title="Drag to reorder"
|
|
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>
|
|
) : 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">
|
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
{renaming ? (
|
|
<input
|
|
ref={renameInputRef}
|
|
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
|
|
if (e.key === "Escape") setRenaming(false);
|
|
}}
|
|
onBlur={() => void commitRename()}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<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 ? (
|
|
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
|
|
onClick={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
|
|
>
|
|
Confirm
|
|
</button>
|
|
<button
|
|
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
|
|
onClick={() => setConfirmingRemoval(false)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{menu ? (
|
|
<AlbumContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
onClose={() => setMenu(null)}
|
|
onRename={() => setRenaming(true)}
|
|
onDelete={() => setConfirmingRemoval(true)}
|
|
/>
|
|
) : 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() {
|
|
const folders = useGalleryStore((state) => state.folders);
|
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
|
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
|
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
|
const selectFolder = useGalleryStore((state) => state.selectFolder);
|
|
const activeView = useGalleryStore((state) => state.activeView);
|
|
const setView = useGalleryStore((state) => state.setView);
|
|
const reorderFolders = useGalleryStore((state) => state.reorderFolders);
|
|
const albums = useGalleryStore((state) => state.albums);
|
|
const createAlbum = useGalleryStore((state) => state.createAlbum);
|
|
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
|
|
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
|
|
const [creatingAlbum, setCreatingAlbum] = useState(false);
|
|
const [newAlbumName, setNewAlbumName] = useState("");
|
|
const newAlbumInputRef = useRef<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);
|
|
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;
|
|
}
|
|
const album = await createAlbum(name);
|
|
setNewAlbumName("");
|
|
setCreatingAlbum(false);
|
|
useGalleryStore.getState().viewAlbum(album.id);
|
|
};
|
|
|
|
const exitManageAlbums = () => {
|
|
setManageAlbums(false);
|
|
setManageSelectedIds(new Set());
|
|
setConfirmingAlbumDelete(false);
|
|
};
|
|
|
|
const toggleManageSelected = (albumId: number) => {
|
|
setManageSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(albumId)) next.delete(albumId);
|
|
else next.add(albumId);
|
|
return next;
|
|
});
|
|
setConfirmingAlbumDelete(false);
|
|
};
|
|
|
|
const handleDeleteSelectedAlbums = async () => {
|
|
const ids = Array.from(manageSelectedIds);
|
|
if (ids.length === 0) return;
|
|
await deleteAlbums(ids);
|
|
exitManageAlbums();
|
|
};
|
|
|
|
return (
|
|
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]">
|
|
{/* Header */}
|
|
<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>
|
|
<button
|
|
onClick={handleAddFolder}
|
|
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors"
|
|
title="Add Media Folder"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nav */}
|
|
<div className="px-2 pt-2 pb-1 space-y-px">
|
|
<div
|
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
|
activeView === "gallery" && selectedFolderId === null
|
|
? "bg-white/8 text-white"
|
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
|
}`}
|
|
onClick={() => selectFolder(null)}
|
|
>
|
|
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
<span className={`text-[13px] font-medium ${activeView === "gallery" && selectedFolderId === null ? "text-white" : ""}`}>
|
|
All Media
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
|
activeView === "explore"
|
|
? "bg-white/8 text-white"
|
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
|
}`}
|
|
onClick={() => setView("explore")}
|
|
>
|
|
<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="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
|
</svg>
|
|
<span className={`text-[13px] font-medium ${activeView === "explore" ? "text-white" : ""}`}>
|
|
Explore
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
|
activeView === "timeline"
|
|
? "bg-white/8 text-white"
|
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
|
}`}
|
|
onClick={() => setView("timeline")}
|
|
>
|
|
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
<span className={`text-[13px] font-medium ${activeView === "timeline" ? "text-white" : ""}`}>
|
|
Timeline
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
|
|
activeView === "duplicates"
|
|
? "bg-white/8 text-white"
|
|
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
|
|
}`}
|
|
onClick={() => setView("duplicates")}
|
|
>
|
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
</svg>
|
|
<span className={`text-[13px] font-medium ${activeView === "duplicates" ? "text-white" : ""}`}>
|
|
Duplicates
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section label */}
|
|
{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>
|
|
<ThemedDropdown
|
|
value={librarySort}
|
|
onChange={(value) => setLibrarySort(value as LibrarySort)}
|
|
ariaLabel="Library order"
|
|
compact
|
|
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 ? (
|
|
<button
|
|
onClick={() => setManageAlbums(true)}
|
|
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
|
|
title="Manage albums"
|
|
>
|
|
<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>
|
|
) : null}
|
|
<button
|
|
onClick={startCreatingAlbum}
|
|
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
|
|
title="New album"
|
|
>
|
|
<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="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
</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)}
|
|
onKeyDown={(e) => {
|
|
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>
|
|
);
|
|
}
|