refactor(bulk-action-bar): modularize component
Break down the monolithic BulkActionBar component into smaller, highly cohesive modules to improve maintainability and separation of concerns. - Extract distinct UI regions and popovers into dedicated components (`BulkSelectionSummary`, `BulkAlbumPopover`, `BulkDeleteConfirm`, `BulkRatingPopover`, and `BulkTagPopover`). - Abstract shared panel states and configurations into a dedicated `types.ts` file within the `bulk` directory. - Simplify the main `BulkActionBar` component to act as a clean presentation orchestrator.
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore } from "../store";
|
||||||
import { AlbumPicker } from "./AlbumPicker";
|
import { BulkAlbumPopover } from "./bulk/BulkAlbumPopover";
|
||||||
|
import { BulkDeleteConfirm } from "./bulk/BulkDeleteConfirm";
|
||||||
|
import { BulkRatingPopover } from "./bulk/BulkRatingPopover";
|
||||||
|
import { BulkSelectionSummary } from "./bulk/BulkSelectionSummary";
|
||||||
import { BulkTagPopover } from "./bulk/BulkTagPopover";
|
import { BulkTagPopover } from "./bulk/BulkTagPopover";
|
||||||
|
import { type BulkPanel } from "./bulk/types";
|
||||||
import { useDismissable } from "./menu";
|
import { useDismissable } from "./menu";
|
||||||
import { Tooltip } from "./Tooltip"
|
import { Tooltip } from "./Tooltip";
|
||||||
import { CloseIcon, StarIcon, WarningIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
|
|
||||||
type Panel = "tag" | "rating" | "album" | "delete" | null;
|
|
||||||
|
|
||||||
export function BulkActionBar() {
|
export function BulkActionBar() {
|
||||||
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
|
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
|
||||||
@@ -23,7 +25,7 @@ export function BulkActionBar() {
|
|||||||
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
|
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
|
||||||
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
|
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
|
||||||
|
|
||||||
const [panel, setPanel] = useState<Panel>(null);
|
const [panel, setPanel] = useState<BulkPanel>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export function BulkActionBar() {
|
|||||||
|
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds);
|
||||||
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
|
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
|
||||||
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next));
|
const togglePanel = (next: Exclude<BulkPanel, null>) => setPanel((current) => (current === next ? null : next));
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -66,19 +68,12 @@ export function BulkActionBar() {
|
|||||||
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
|
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 px-1.5">
|
<BulkSelectionSummary
|
||||||
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
|
loadedCount={loadedCount}
|
||||||
{loadedCount < totalImages || loadedCount > selectedCount ? (
|
selectedCount={selectedCount}
|
||||||
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}>
|
totalImages={totalImages}
|
||||||
<button
|
onSelectAll={selectAllGallery}
|
||||||
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
|
/>
|
||||||
onClick={selectAllGallery}
|
|
||||||
>
|
|
||||||
Select all{loadedCount < totalImages ? " loaded" : ""}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-5 w-px bg-white/10" />
|
<div className="h-5 w-px bg-white/10" />
|
||||||
|
|
||||||
@@ -93,39 +88,7 @@ export function BulkActionBar() {
|
|||||||
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
|
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
|
||||||
Rating
|
Rating
|
||||||
</button>
|
</button>
|
||||||
{panel === "rating" ? (
|
{panel === "rating" ? <BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} /> : null}
|
||||||
<div
|
|
||||||
data-bulk-popover
|
|
||||||
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 5 }, (_, index) => {
|
|
||||||
const rating = index + 1;
|
|
||||||
return (
|
|
||||||
<Tooltip label={`Set ${rating} star${rating === 1 ? "" : "s"}`}>
|
|
||||||
<button
|
|
||||||
key={rating}
|
|
||||||
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
|
|
||||||
onClick={async () => {
|
|
||||||
await bulkSetRating(rating);
|
|
||||||
setPanel(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StarIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
|
|
||||||
onClick={async () => {
|
|
||||||
await bulkSetRating(0);
|
|
||||||
setPanel(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<Tooltip label="Mark as favorite" followCursor>
|
<Tooltip label="Mark as favorite" followCursor>
|
||||||
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
|
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
|
||||||
@@ -136,14 +99,7 @@ export function BulkActionBar() {
|
|||||||
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
|
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
|
||||||
Add to album
|
Add to album
|
||||||
</button>
|
</button>
|
||||||
{panel === "album" ? (
|
{panel === "album" ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null}
|
||||||
<div
|
|
||||||
data-bulk-popover
|
|
||||||
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
|
|
||||||
>
|
|
||||||
<AlbumPicker onPick={handlePickAlbum} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inAlbumView ? (
|
{inAlbumView ? (
|
||||||
@@ -159,52 +115,34 @@ export function BulkActionBar() {
|
|||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Tooltip label="Delete files from disk" followCursor>
|
<Tooltip label="Delete files from disk" followCursor>
|
||||||
<button
|
<button
|
||||||
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
|
className={
|
||||||
onClick={() => togglePanel("delete")}
|
panel === "delete"
|
||||||
disabled={deleting}
|
? `${btn} bg-red-500/15 text-red-300`
|
||||||
>
|
: `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`
|
||||||
{deleting ? "Deleting…" : "Delete"}
|
}
|
||||||
</button>
|
onClick={() => togglePanel("delete")}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{panel === "delete" ? (
|
{panel === "delete" ? (
|
||||||
<div
|
<BulkDeleteConfirm
|
||||||
data-bulk-popover
|
deleting={deleting}
|
||||||
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
|
selectedCount={selectedCount}
|
||||||
>
|
onCancel={() => setPanel(null)}
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-red-300">
|
onDelete={handleDelete}
|
||||||
<WarningIcon className="h-3.5 w-3.5 shrink-0" />
|
/>
|
||||||
<p className="text-xs font-semibold">Delete from disk</p>
|
|
||||||
</div>
|
|
||||||
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
|
|
||||||
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
|
|
||||||
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-1.5">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={() => setPanel(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
|
|
||||||
onClick={() => void handleDelete()}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip label="Clear selection" followCursor>
|
<Tooltip label="Clear selection" followCursor>
|
||||||
<button
|
<button
|
||||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
onClick={clearGallerySelection}
|
onClick={clearGallerySelection}
|
||||||
>
|
>
|
||||||
<CloseIcon className="h-4 w-4" />
|
<CloseIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AlbumPicker } from "../AlbumPicker";
|
||||||
|
|
||||||
|
interface BulkAlbumPopoverProps {
|
||||||
|
onPick: (albumId: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkAlbumPopover({ onPick }: BulkAlbumPopoverProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-bulk-popover
|
||||||
|
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
<AlbumPicker onPick={(albumId) => void onPick(albumId)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { WarningIcon } from "../icons";
|
||||||
|
|
||||||
|
interface BulkDeleteConfirmProps {
|
||||||
|
deleting: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkDeleteConfirm({ deleting, selectedCount, onCancel, onDelete }: BulkDeleteConfirmProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-bulk-popover
|
||||||
|
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-red-300">
|
||||||
|
<WarningIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<p className="text-xs font-semibold">Delete from disk</p>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
|
||||||
|
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. This removes
|
||||||
|
the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
|
||||||
|
onClick={() => void onDelete()}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Tooltip } from "../Tooltip";
|
||||||
|
import { StarIcon } from "../icons";
|
||||||
|
|
||||||
|
interface BulkRatingPopoverProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSetRating: (rating: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkRatingPopover({ onClose, onSetRating }: BulkRatingPopoverProps) {
|
||||||
|
const setRating = async (rating: number) => {
|
||||||
|
await onSetRating(rating);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-bulk-popover
|
||||||
|
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 5 }, (_, index) => {
|
||||||
|
const rating = index + 1;
|
||||||
|
return (
|
||||||
|
<Tooltip key={rating} label={`Set ${rating} star${rating === 1 ? "" : "s"}`}>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
|
||||||
|
onClick={() => void setRating(rating)}
|
||||||
|
>
|
||||||
|
<StarIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
|
||||||
|
onClick={() => void setRating(0)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Tooltip } from "../Tooltip";
|
||||||
|
|
||||||
|
interface BulkSelectionSummaryProps {
|
||||||
|
loadedCount: number;
|
||||||
|
selectedCount: number;
|
||||||
|
totalImages: number;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkSelectionSummary({
|
||||||
|
loadedCount,
|
||||||
|
selectedCount,
|
||||||
|
totalImages,
|
||||||
|
onSelectAll,
|
||||||
|
}: BulkSelectionSummaryProps) {
|
||||||
|
const showSelectAll = loadedCount < totalImages || loadedCount > selectedCount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-1.5">
|
||||||
|
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
|
||||||
|
{showSelectAll ? (
|
||||||
|
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}>
|
||||||
|
<button className="text-[11px] text-gray-500 transition-colors hover:text-gray-300" onClick={onSelectAll}>
|
||||||
|
Select all{loadedCount < totalImages ? " loaded" : ""}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type BulkPanel = "tag" | "rating" | "album" | "delete" | null;
|
||||||
Reference in New Issue
Block a user