refactor(folder-picker): modularize component
Break down the monolithic FolderPickerModal component into smaller, highly cohesive modules to improve maintainability and separate concerns. - Extract UI sub-components into dedicated files (`FolderRow`, `StagedFoldersPanel`, and `StatusLine`). - Move path manipulation, string formatting, and breadcrumb utilities into a dedicated `pathUtils.ts` file. - Abstract complex local state, API calls, and event listeners into a custom `useFolderPicker` hook. - Clean up `FolderPickerModal` to act as a lightweight presentation layer.
This commit is contained in:
@@ -1,398 +1,28 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store";
|
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { CheckIcon, ChevronRightIcon, CloseIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
|
import { FolderRow } from "./folderPicker/FolderRow";
|
||||||
function normalizePath(path: string): string {
|
import { StagedFoldersPanel } from "./folderPicker/StagedFoldersPanel";
|
||||||
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
import { StatusLine } from "./folderPicker/StatusLine";
|
||||||
}
|
import { normalizePath } from "./folderPicker/pathUtils";
|
||||||
|
import { useFolderPicker } from "./folderPicker/useFolderPicker";
|
||||||
function cleanAddressInput(path: string): string {
|
|
||||||
const trimmed = path.trim();
|
|
||||||
if (trimmed.length >= 2) {
|
|
||||||
const first = trimmed[0];
|
|
||||||
const last = trimmed[trimmed.length - 1];
|
|
||||||
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
|
||||||
return trimmed.slice(1, -1).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function friendlyDirectoryError(error: unknown): string {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (/cannot find the path|os error 3|not found|no such file/i.test(message)) {
|
|
||||||
return "Folder not found. Check the path and try again.";
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderName(path: string): string {
|
|
||||||
const trimmed = path.replace(/[\\/]+$/, "");
|
|
||||||
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
|
|
||||||
return parts.length > 0 ? parts[parts.length - 1] : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] {
|
|
||||||
if (!path) return [{ label: "This PC / Home", path: null }];
|
|
||||||
|
|
||||||
const separator = path.includes("\\") ? "\\" : "/";
|
|
||||||
const normalized = path.replace(/[\\/]+$/, "");
|
|
||||||
const windowsDrive = normalized.match(/^[A-Za-z]:/);
|
|
||||||
|
|
||||||
if (windowsDrive) {
|
|
||||||
const drive = windowsDrive[0];
|
|
||||||
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
|
|
||||||
let current = drive;
|
|
||||||
return [
|
|
||||||
{ label: "This PC", path: null },
|
|
||||||
{ label: drive, path: drive },
|
|
||||||
...rest.map((part) => {
|
|
||||||
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
|
|
||||||
return { label: part, path: current };
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = normalized.split(/[\\/]+/).filter(Boolean);
|
|
||||||
let current = separator === "/" ? "" : "";
|
|
||||||
return [
|
|
||||||
{ label: "/", path: null },
|
|
||||||
...parts.map((part) => {
|
|
||||||
current = `${current}/${part}`;
|
|
||||||
return { label: part, path: current };
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusLine({ results }: { results: FolderAddResult[] | null }) {
|
|
||||||
if (!results) return null;
|
|
||||||
const added = results.filter((result) => result.status === "added").length;
|
|
||||||
const skipped = results.filter((result) => result.status === "skipped").length;
|
|
||||||
const failed = results.filter((result) => result.status === "error").length;
|
|
||||||
return (
|
|
||||||
<p className="text-xs text-gray-500 light-theme:text-gray-600">
|
|
||||||
Added {added}, skipped {skipped}, failed {failed}.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderRow({
|
|
||||||
entry,
|
|
||||||
selected,
|
|
||||||
alreadyAdded,
|
|
||||||
onToggle,
|
|
||||||
onNavigate,
|
|
||||||
}: {
|
|
||||||
entry: DirEntry;
|
|
||||||
selected: boolean;
|
|
||||||
alreadyAdded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onNavigate: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
|
|
||||||
alreadyAdded
|
|
||||||
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
|
|
||||||
: selected
|
|
||||||
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
|
|
||||||
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
|
|
||||||
selected
|
|
||||||
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
|
|
||||||
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
|
|
||||||
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
|
|
||||||
onClick={onToggle}
|
|
||||||
disabled={alreadyAdded}
|
|
||||||
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
|
|
||||||
>
|
|
||||||
<CheckIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full min-w-0 items-center gap-2 text-left"
|
|
||||||
onClick={onNavigate}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
|
|
||||||
</svg>
|
|
||||||
<span className="truncate text-sm">{entry.name}</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{alreadyAdded ? (
|
|
||||||
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
|
|
||||||
Added
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
|
||||||
onClick={onNavigate}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StagedFoldersPanel({
|
|
||||||
stagedPaths,
|
|
||||||
onRemove,
|
|
||||||
onClear,
|
|
||||||
}: {
|
|
||||||
stagedPaths: string[];
|
|
||||||
onRemove: (path: string) => void;
|
|
||||||
onClear: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
|
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
|
|
||||||
Folders to add ({stagedPaths.length})
|
|
||||||
</p>
|
|
||||||
{stagedPaths.length > 0 ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
|
||||||
onClick={onClear}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
||||||
{stagedPaths.length === 0 ? (
|
|
||||||
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
|
|
||||||
<p className="text-sm text-gray-500">No folders selected.</p>
|
|
||||||
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
|
|
||||||
Choose folders on the left and they will collect here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{stagedPaths.map((path) => (
|
|
||||||
<Tooltip key={path} label={path} anchorToCursor block>
|
|
||||||
<div className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800">
|
|
||||||
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
|
|
||||||
</svg>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium">{folderName(path)}</p>
|
|
||||||
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
|
|
||||||
</div>
|
|
||||||
<Tooltip label="Remove from folders to add" anchorToCursor>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
|
|
||||||
onClick={() => onRemove(path)}
|
|
||||||
aria-label={`Remove ${path} from folders to add`}
|
|
||||||
>
|
|
||||||
<CloseIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderPickerModal() {
|
export function FolderPickerModal() {
|
||||||
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
|
const folderPicker = useFolderPicker();
|
||||||
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
|
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
|
||||||
const listDirectories = useGalleryStore((state) => state.listDirectories);
|
|
||||||
const addFolders = useGalleryStore((state) => state.addFolders);
|
|
||||||
|
|
||||||
const [listing, setListing] = useState<DirListing | null>(null);
|
|
||||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
|
||||||
const [addressDraft, setAddressDraft] = useState("");
|
|
||||||
const [addressEditing, setAddressEditing] = useState(false);
|
|
||||||
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [results, setResults] = useState<FolderAddResult[] | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const addressInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]);
|
|
||||||
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]);
|
|
||||||
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]);
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: listing?.entries.length ?? 0,
|
count: folderPicker.entries.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => folderPicker.scrollRef.current,
|
||||||
estimateSize: () => 48,
|
estimateSize: () => 48,
|
||||||
overscan: 8,
|
overscan: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
if (!folderPicker.folderPickerOpen) return null;
|
||||||
if (!folderPickerOpen) return;
|
|
||||||
let cancelled = false;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
void listDirectories(currentPath)
|
|
||||||
.then((nextListing) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setListing(nextListing);
|
|
||||||
setAddressDraft(nextListing.current ?? "");
|
|
||||||
setAddressEditing(false);
|
|
||||||
scrollRef.current?.scrollTo({ top: 0, left: 0 });
|
|
||||||
})
|
|
||||||
.catch((loadError) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setListing({ current: currentPath, parent: null, entries: [] });
|
|
||||||
setError(friendlyDirectoryError(loadError));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [currentPath, folderPickerOpen, listDirectories]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!folderPickerOpen) return;
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
if (addressEditing) {
|
|
||||||
setAddressDraft(listing?.current ?? "");
|
|
||||||
setAddressEditing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFolderPickerOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!addressEditing) return;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
addressInputRef.current?.focus();
|
|
||||||
addressInputRef.current?.select();
|
|
||||||
});
|
|
||||||
}, [addressEditing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (folderPickerOpen) return;
|
|
||||||
setCurrentPath(null);
|
|
||||||
setAddressDraft("");
|
|
||||||
setAddressEditing(false);
|
|
||||||
setListing(null);
|
|
||||||
setStagedPaths([]);
|
|
||||||
setError(null);
|
|
||||||
setResults(null);
|
|
||||||
setAdding(false);
|
|
||||||
}, [folderPickerOpen]);
|
|
||||||
|
|
||||||
if (!folderPickerOpen) return null;
|
|
||||||
|
|
||||||
const entries = listing?.entries ?? [];
|
|
||||||
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ""));
|
|
||||||
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : "";
|
|
||||||
const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false;
|
|
||||||
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false;
|
|
||||||
|
|
||||||
const togglePath = (path: string) => {
|
|
||||||
const normalized = normalizePath(path);
|
|
||||||
if (libraryPaths.has(normalized)) return;
|
|
||||||
setResults(null);
|
|
||||||
setStagedPaths((current) => {
|
|
||||||
const exists = current.some((staged) => normalizePath(staged) === normalized);
|
|
||||||
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const stagePath = (path: string) => {
|
|
||||||
const cleaned = cleanAddressInput(path);
|
|
||||||
if (!cleaned) {
|
|
||||||
setError("Enter a folder path first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizePath(cleaned);
|
|
||||||
if (libraryPaths.has(normalized)) {
|
|
||||||
setError("That folder is already in your library.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stagedSet.has(normalized)) {
|
|
||||||
setError("That folder is already selected.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setResults(null);
|
|
||||||
setStagedPaths((current) => [...current, cleaned]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateToAddress = () => {
|
|
||||||
const cleaned = cleanAddressInput(addressDraft);
|
|
||||||
setResults(null);
|
|
||||||
setError(null);
|
|
||||||
setCurrentPath(cleaned || null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const beginAddressEdit = () => {
|
|
||||||
setAddressDraft(listing?.current ?? "");
|
|
||||||
setAddressEditing(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStagedPath = (path: string) => {
|
|
||||||
const normalized = normalizePath(path);
|
|
||||||
setResults(null);
|
|
||||||
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStagedPaths = () => {
|
|
||||||
setResults(null);
|
|
||||||
setStagedPaths([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmAdd = async () => {
|
|
||||||
if (stagedPaths.length === 0 || adding) return;
|
|
||||||
setAdding(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const addResults = await addFolders(stagedPaths);
|
|
||||||
const failed = addResults.filter((result) => result.status === "error");
|
|
||||||
setResults(addResults);
|
|
||||||
if (failed.length > 0) {
|
|
||||||
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
|
|
||||||
setError(failed.map((failure) => failure.data).join("; "));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFolderPickerOpen(false);
|
|
||||||
} catch (addError) {
|
|
||||||
setError(addError instanceof Error ? addError.message : String(addError));
|
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
|
||||||
onClick={() => setFolderPickerOpen(false)}
|
onClick={() => folderPicker.setFolderPickerOpen(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"
|
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"
|
||||||
@@ -408,7 +38,7 @@ export function FolderPickerModal() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||||
onClick={() => setFolderPickerOpen(false)}
|
onClick={() => folderPicker.setFolderPickerOpen(false)}
|
||||||
>
|
>
|
||||||
<CloseIcon className="h-4 w-4" />
|
<CloseIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -422,36 +52,33 @@ export function FolderPickerModal() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
onClick={() => setCurrentPath(listing?.parent ?? null)}
|
onClick={() => folderPicker.setCurrentPath(folderPicker.listing?.parent ?? null)}
|
||||||
disabled={!listing?.current}
|
disabled={!folderPicker.listing?.current}
|
||||||
>
|
>
|
||||||
Up
|
Up
|
||||||
</button>
|
</button>
|
||||||
{addressEditing ? (
|
{folderPicker.addressEditing ? (
|
||||||
<form
|
<form
|
||||||
className="flex min-w-0 flex-1 items-center gap-2"
|
className="flex min-w-0 flex-1 items-center gap-2"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigateToAddress();
|
folderPicker.navigateToAddress();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="sr-only" htmlFor="folder-picker-address">Folder path</label>
|
<label className="sr-only" htmlFor="folder-picker-address">Folder path</label>
|
||||||
<input
|
<input
|
||||||
ref={addressInputRef}
|
ref={folderPicker.addressInputRef}
|
||||||
id="folder-picker-address"
|
id="folder-picker-address"
|
||||||
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 outline-none transition-colors focus:border-white/25 focus:bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800"
|
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 outline-none transition-colors focus:border-white/25 focus:bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800"
|
||||||
value={addressDraft}
|
value={folderPicker.addressDraft}
|
||||||
onChange={(event) => {
|
onChange={(event) => folderPicker.updateAddressDraft(event.target.value)}
|
||||||
setAddressDraft(event.target.value);
|
|
||||||
setResults(null);
|
|
||||||
}}
|
|
||||||
placeholder="Paste or type a folder path"
|
placeholder="Paste or type a folder path"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
disabled={loading}
|
disabled={folderPicker.loading}
|
||||||
>
|
>
|
||||||
Go
|
Go
|
||||||
</button>
|
</button>
|
||||||
@@ -461,7 +88,7 @@ export function FolderPickerModal() {
|
|||||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900"
|
className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900"
|
||||||
>
|
>
|
||||||
<nav className="flex min-w-0 items-center gap-1 overflow-hidden">
|
<nav className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||||
{breadcrumbs.map((crumb, index) => (
|
{folderPicker.breadcrumbs.map((crumb, index) => (
|
||||||
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
|
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
|
||||||
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
|
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
|
||||||
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
|
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
|
||||||
@@ -470,7 +97,7 @@ export function FolderPickerModal() {
|
|||||||
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setCurrentPath(crumb.path);
|
folderPicker.setCurrentPath(crumb.path);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
@@ -482,7 +109,7 @@ export function FolderPickerModal() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-10 flex-1 self-stretch cursor-text rounded px-1"
|
className="min-w-10 flex-1 self-stretch cursor-text rounded px-1"
|
||||||
onClick={beginAddressEdit}
|
onClick={folderPicker.beginAddressEdit}
|
||||||
aria-label="Edit folder path"
|
aria-label="Edit folder path"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,23 +117,27 @@ export function FolderPickerModal() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-2.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
|
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-2.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
onClick={() => stagePath(addressPath)}
|
onClick={() => folderPicker.stagePath(folderPicker.addressPath)}
|
||||||
disabled={!addressPath || addressAlreadyAdded || addressAlreadyStaged}
|
disabled={
|
||||||
|
!folderPicker.addressPath ||
|
||||||
|
folderPicker.addressAlreadyAdded ||
|
||||||
|
folderPicker.addressAlreadyStaged
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Select
|
Select
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{folderPicker.error ? (
|
||||||
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800">
|
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800">
|
||||||
{error}
|
{folderPicker.error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50">
|
<div ref={folderPicker.scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50">
|
||||||
{loading ? (
|
{folderPicker.loading ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
|
||||||
) : entries.length === 0 ? (
|
) : folderPicker.entries.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
|
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -514,7 +145,7 @@ export function FolderPickerModal() {
|
|||||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const entry = entries[virtualItem.index];
|
const entry = folderPicker.entries[virtualItem.index];
|
||||||
const normalized = normalizePath(entry.path);
|
const normalized = normalizePath(entry.path);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -527,10 +158,10 @@ export function FolderPickerModal() {
|
|||||||
>
|
>
|
||||||
<FolderRow
|
<FolderRow
|
||||||
entry={entry}
|
entry={entry}
|
||||||
selected={stagedSet.has(normalized)}
|
selected={folderPicker.stagedSet.has(normalized)}
|
||||||
alreadyAdded={libraryPaths.has(normalized)}
|
alreadyAdded={folderPicker.libraryPaths.has(normalized)}
|
||||||
onToggle={() => togglePath(entry.path)}
|
onToggle={() => folderPicker.togglePath(entry.path)}
|
||||||
onNavigate={() => setCurrentPath(entry.path)}
|
onNavigate={() => folderPicker.setCurrentPath(entry.path)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -541,33 +172,33 @@ export function FolderPickerModal() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<StagedFoldersPanel
|
<StagedFoldersPanel
|
||||||
stagedPaths={stagedPaths}
|
stagedPaths={folderPicker.stagedPaths}
|
||||||
onRemove={removeStagedPath}
|
onRemove={folderPicker.removeStagedPath}
|
||||||
onClear={clearStagedPaths}
|
onClear={folderPicker.clearStagedPaths}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
|
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="flex items-end justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<StatusLine results={results} />
|
<StatusLine results={folderPicker.results} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
onClick={() => setFolderPickerOpen(false)}
|
onClick={() => folderPicker.setFolderPickerOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
|
||||||
onClick={() => void confirmAdd()}
|
onClick={() => void folderPicker.confirmAdd()}
|
||||||
disabled={stagedPaths.length === 0 || adding}
|
disabled={folderPicker.stagedPaths.length === 0 || folderPicker.adding}
|
||||||
>
|
>
|
||||||
{adding ? "Adding..." : `Add ${stagedPaths.length}`}
|
{folderPicker.adding ? "Adding..." : `Add ${folderPicker.stagedPaths.length}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { DirEntry } from "../../store";
|
||||||
|
import { Tooltip } from "../Tooltip";
|
||||||
|
import { CheckIcon, ChevronRightIcon } from "../icons";
|
||||||
|
|
||||||
|
export function FolderRow({
|
||||||
|
entry,
|
||||||
|
selected,
|
||||||
|
alreadyAdded,
|
||||||
|
onToggle,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
entry: DirEntry;
|
||||||
|
selected: boolean;
|
||||||
|
alreadyAdded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
|
||||||
|
alreadyAdded
|
||||||
|
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
|
||||||
|
: selected
|
||||||
|
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
|
||||||
|
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
|
||||||
|
selected
|
||||||
|
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
|
||||||
|
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
|
||||||
|
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={alreadyAdded}
|
||||||
|
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
|
||||||
|
>
|
||||||
|
<CheckIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full min-w-0 items-center gap-2 text-left"
|
||||||
|
onClick={onNavigate}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
|
||||||
|
</svg>
|
||||||
|
<span className="truncate text-sm">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{alreadyAdded ? (
|
||||||
|
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
|
||||||
|
Added
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||||
|
onClick={onNavigate}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Tooltip } from "../Tooltip";
|
||||||
|
import { CloseIcon } from "../icons";
|
||||||
|
import { folderName } from "./pathUtils";
|
||||||
|
|
||||||
|
export function StagedFoldersPanel({
|
||||||
|
stagedPaths,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
stagedPaths: string[];
|
||||||
|
onRemove: (path: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
|
||||||
|
Folders to add ({stagedPaths.length})
|
||||||
|
</p>
|
||||||
|
{stagedPaths.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{stagedPaths.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
|
||||||
|
<p className="text-sm text-gray-500">No folders selected.</p>
|
||||||
|
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
|
||||||
|
Choose folders on the left and they will collect here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stagedPaths.map((path) => (
|
||||||
|
<Tooltip key={path} label={path} anchorToCursor block>
|
||||||
|
<div className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800">
|
||||||
|
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{folderName(path)}</p>
|
||||||
|
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
|
||||||
|
</div>
|
||||||
|
<Tooltip label="Remove from folders to add" anchorToCursor>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
|
||||||
|
onClick={() => onRemove(path)}
|
||||||
|
aria-label={`Remove ${path} from folders to add`}
|
||||||
|
>
|
||||||
|
<CloseIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { FolderAddResult } from "../../store";
|
||||||
|
|
||||||
|
export function StatusLine({ results }: { results: FolderAddResult[] | null }) {
|
||||||
|
if (!results) return null;
|
||||||
|
const added = results.filter((result) => result.status === "added").length;
|
||||||
|
const skipped = results.filter((result) => result.status === "skipped").length;
|
||||||
|
const failed = results.filter((result) => result.status === "error").length;
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-gray-500 light-theme:text-gray-600">
|
||||||
|
Added {added}, skipped {skipped}, failed {failed}.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export interface Breadcrumb {
|
||||||
|
label: string;
|
||||||
|
path: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePath(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanAddressInput(path: string): string {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (trimmed.length >= 2) {
|
||||||
|
const first = trimmed[0];
|
||||||
|
const last = trimmed[trimmed.length - 1];
|
||||||
|
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
||||||
|
return trimmed.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function friendlyDirectoryError(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (/cannot find the path|os error 3|not found|no such file/i.test(message)) {
|
||||||
|
return "Folder not found. Check the path and try again.";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderName(path: string): string {
|
||||||
|
const trimmed = path.replace(/[\\/]+$/, "");
|
||||||
|
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts[parts.length - 1] : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBreadcrumbs(path: string | null): Breadcrumb[] {
|
||||||
|
if (!path) return [{ label: "This PC / Home", path: null }];
|
||||||
|
|
||||||
|
const separator = path.includes("\\") ? "\\" : "/";
|
||||||
|
const normalized = path.replace(/[\\/]+$/, "");
|
||||||
|
const windowsDrive = normalized.match(/^[A-Za-z]:/);
|
||||||
|
|
||||||
|
if (windowsDrive) {
|
||||||
|
const drive = windowsDrive[0];
|
||||||
|
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
|
||||||
|
let current = drive;
|
||||||
|
return [
|
||||||
|
{ label: "This PC", path: null },
|
||||||
|
{ label: drive, path: drive },
|
||||||
|
...rest.map((part) => {
|
||||||
|
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
|
||||||
|
return { label: part, path: current };
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalized.split(/[\\/]+/).filter(Boolean);
|
||||||
|
let current = separator === "/" ? "" : "";
|
||||||
|
return [
|
||||||
|
{ label: "/", path: null },
|
||||||
|
...parts.map((part) => {
|
||||||
|
current = `${current}/${part}`;
|
||||||
|
return { label: part, path: current };
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { DirListing, FolderAddResult, useGalleryStore } from "../../store";
|
||||||
|
import {
|
||||||
|
buildBreadcrumbs,
|
||||||
|
cleanAddressInput,
|
||||||
|
friendlyDirectoryError,
|
||||||
|
normalizePath,
|
||||||
|
} from "./pathUtils";
|
||||||
|
|
||||||
|
export function useFolderPicker() {
|
||||||
|
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
|
||||||
|
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
|
||||||
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
|
const listDirectories = useGalleryStore((state) => state.listDirectories);
|
||||||
|
const addFolders = useGalleryStore((state) => state.addFolders);
|
||||||
|
|
||||||
|
const [listing, setListing] = useState<DirListing | null>(null);
|
||||||
|
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||||
|
const [addressDraft, setAddressDraft] = useState("");
|
||||||
|
const [addressEditing, setAddressEditing] = useState(false);
|
||||||
|
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [results, setResults] = useState<FolderAddResult[] | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const addressInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]);
|
||||||
|
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]);
|
||||||
|
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderPickerOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void listDirectories(currentPath)
|
||||||
|
.then((nextListing) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setListing(nextListing);
|
||||||
|
setAddressDraft(nextListing.current ?? "");
|
||||||
|
setAddressEditing(false);
|
||||||
|
scrollRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setListing({ current: currentPath, parent: null, entries: [] });
|
||||||
|
setError(friendlyDirectoryError(loadError));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentPath, folderPickerOpen, listDirectories]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!folderPickerOpen) return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (addressEditing) {
|
||||||
|
setAddressDraft(listing?.current ?? "");
|
||||||
|
setAddressEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolderPickerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!addressEditing) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
addressInputRef.current?.focus();
|
||||||
|
addressInputRef.current?.select();
|
||||||
|
});
|
||||||
|
}, [addressEditing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (folderPickerOpen) return;
|
||||||
|
setCurrentPath(null);
|
||||||
|
setAddressDraft("");
|
||||||
|
setAddressEditing(false);
|
||||||
|
setListing(null);
|
||||||
|
setStagedPaths([]);
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
setAdding(false);
|
||||||
|
}, [folderPickerOpen]);
|
||||||
|
|
||||||
|
const entries = listing?.entries ?? [];
|
||||||
|
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ""));
|
||||||
|
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : "";
|
||||||
|
const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false;
|
||||||
|
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false;
|
||||||
|
|
||||||
|
const togglePath = (path: string) => {
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
if (libraryPaths.has(normalized)) return;
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths((current) => {
|
||||||
|
const exists = current.some((staged) => normalizePath(staged) === normalized);
|
||||||
|
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stagePath = (path: string) => {
|
||||||
|
const cleaned = cleanAddressInput(path);
|
||||||
|
if (!cleaned) {
|
||||||
|
setError("Enter a folder path first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizePath(cleaned);
|
||||||
|
if (libraryPaths.has(normalized)) {
|
||||||
|
setError("That folder is already in your library.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stagedSet.has(normalized)) {
|
||||||
|
setError("That folder is already selected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths((current) => [...current, cleaned]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToAddress = () => {
|
||||||
|
const cleaned = cleanAddressInput(addressDraft);
|
||||||
|
setResults(null);
|
||||||
|
setError(null);
|
||||||
|
setCurrentPath(cleaned || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAddressDraft = (nextDraft: string) => {
|
||||||
|
setAddressDraft(nextDraft);
|
||||||
|
setResults(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginAddressEdit = () => {
|
||||||
|
setAddressDraft(listing?.current ?? "");
|
||||||
|
setAddressEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStagedPath = (path: string) => {
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStagedPaths = () => {
|
||||||
|
setResults(null);
|
||||||
|
setStagedPaths([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAdd = async () => {
|
||||||
|
if (stagedPaths.length === 0 || adding) return;
|
||||||
|
setAdding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const addResults = await addFolders(stagedPaths);
|
||||||
|
const failed = addResults.filter((result) => result.status === "error");
|
||||||
|
setResults(addResults);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
|
||||||
|
setError(failed.map((failure) => failure.data).join("; "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolderPickerOpen(false);
|
||||||
|
} catch (addError) {
|
||||||
|
setError(addError instanceof Error ? addError.message : String(addError));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
adding,
|
||||||
|
addressAlreadyAdded,
|
||||||
|
addressAlreadyStaged,
|
||||||
|
addressDraft,
|
||||||
|
addressEditing,
|
||||||
|
addressInputRef,
|
||||||
|
addressPath,
|
||||||
|
beginAddressEdit,
|
||||||
|
breadcrumbs,
|
||||||
|
clearStagedPaths,
|
||||||
|
confirmAdd,
|
||||||
|
entries,
|
||||||
|
error,
|
||||||
|
folderPickerOpen,
|
||||||
|
libraryPaths,
|
||||||
|
listing,
|
||||||
|
loading,
|
||||||
|
navigateToAddress,
|
||||||
|
removeStagedPath,
|
||||||
|
results,
|
||||||
|
scrollRef,
|
||||||
|
setCurrentPath,
|
||||||
|
setFolderPickerOpen,
|
||||||
|
stagePath,
|
||||||
|
stagedPaths,
|
||||||
|
stagedSet,
|
||||||
|
togglePath,
|
||||||
|
updateAddressDraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user