mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-12 21:02:14 +00:00
bulk delete function (#1034)
- in the library/gallery modal, added a checkbox on top left of each item - when checkbox is checked it enters toiggle select mode which allows multi selecting and bulk deleting objects via one delete button on the bottom right of the modal. - added trash can icon to lightbox delete button
This commit is contained in:
1 parent
98969a660e
commit
8e4ad8b3a1
3 files changed
+321
-184
No files matched your search
@@ -31,6 +31,9 @@ interface GalleryDraggableItemProps {
|
||||
onDeleted?: (id: string) => void;
|
||||
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
|
||||
maxSelections?: number;
|
||||
bulkSelected?: boolean;
|
||||
onBulkSelectToggle?: () => void;
|
||||
bulkSelectionMode?: boolean;
|
||||
}
|
||||
|
||||
export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
@@ -45,6 +48,9 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
onDeleted,
|
||||
onEditClicked,
|
||||
maxSelections,
|
||||
bulkSelected = false,
|
||||
onBulkSelectToggle,
|
||||
bulkSelectionMode = false,
|
||||
}) => {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragStarted = useRef(false);
|
||||
@@ -76,6 +82,11 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
// Disable dragging for video items, but allow clicks in select mode
|
||||
if (item.mediaClass === "video" && mode !== "select") return;
|
||||
// In bulk selection mode, skip drag — clicks toggle selection
|
||||
if (bulkSelectionMode) {
|
||||
dragStarted.current = false;
|
||||
return;
|
||||
}
|
||||
dragStarted.current = false;
|
||||
const moveListener = (moveEvent: PointerEvent) => {
|
||||
const dx = moveEvent.pageX - event.pageX;
|
||||
@@ -100,24 +111,83 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: React.PointerEvent) => {
|
||||
if (!dragStarted.current && (mode === "select" || !disableTooltipAndBadge)) {
|
||||
// In bulk selection mode, let only handleButtonClick fire onClick
|
||||
// to avoid double-toggling the selection
|
||||
if (bulkSelectionMode) return;
|
||||
if (
|
||||
!dragStarted.current &&
|
||||
(mode === "select" || !disableTooltipAndBadge)
|
||||
) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonClick = (event: React.MouseEvent) => {
|
||||
if (mode === "select" || !disableTooltipAndBadge) {
|
||||
if (mode === "select" || !disableTooltipAndBadge || bulkSelectionMode) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
// Shared button content — avoids duplicating the image/thumbnail rendering
|
||||
const showTooltip = !disableTooltipAndBadge && !bulkSelectionMode;
|
||||
|
||||
const itemButton = (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={twMerge(
|
||||
"group relative w-full",
|
||||
activeFilter === "video" ? "aspect-square" : "aspect-square"
|
||||
"w-full group relative overflow-visible rounded-md border-[3px] transition-all focus:outline-none",
|
||||
"aspect-square",
|
||||
selected || bulkSelected
|
||||
? "border-primary"
|
||||
: disableTooltipAndBadge
|
||||
? "border-transparent hover:border-primary/80"
|
||||
: "border-transparent hover:border-primary",
|
||||
mode === "select" || item.mediaClass === "video" || bulkSelectionMode
|
||||
? "cursor-pointer"
|
||||
: disableTooltipAndBadge
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={handleButtonClick}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{!item.thumbnail ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/30">
|
||||
<span className="text-white/60">Image not available</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
data-gallery-draggable-1="true"
|
||||
// NB: "loading=lazy" is necessary to prevent loading GIGABYTES of images!
|
||||
// It is a bit finnicky, too: you must include this attribute
|
||||
// BEFORE the `src` attribute, or it won't work.
|
||||
loading="lazy"
|
||||
ref={imgRef}
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
className={twMerge(
|
||||
"h-full w-full bg-black/30",
|
||||
imageFit === "contain" ? "object-contain" : "object-cover",
|
||||
)}
|
||||
draggable={false}
|
||||
onError={onImageError}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={twMerge("group relative w-full", "aspect-square")}>
|
||||
{/* dropdown menu */}
|
||||
{mode !== "select" && (
|
||||
<div
|
||||
@@ -128,111 +198,88 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
}}
|
||||
>
|
||||
<PopoverMenu
|
||||
position="bottom"
|
||||
align="end"
|
||||
mode="default"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
|
||||
}
|
||||
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
|
||||
panelClassName="min-w-28 p-1"
|
||||
closeOnUnhover
|
||||
>
|
||||
{(close) => (
|
||||
<div className="flex flex-col">
|
||||
{item.mediaClass === "image" && (
|
||||
position="bottom"
|
||||
align="end"
|
||||
mode="default"
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
|
||||
}
|
||||
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
|
||||
panelClassName="min-w-28 p-1"
|
||||
closeOnUnhover
|
||||
>
|
||||
{(close) => (
|
||||
<div className="flex flex-col">
|
||||
{item.mediaClass === "image" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (onEditClicked && (item.fullImage || item.thumbnail)) {
|
||||
await onEditClicked(
|
||||
item.fullImage || item.thumbnail!,
|
||||
item.id,
|
||||
);
|
||||
}
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} className="text-base-fg" />
|
||||
<span>Edit image</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
|
||||
onClick={async (e) => {
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onEditClicked && (item.fullImage || item.thumbnail)) {
|
||||
await onEditClicked(
|
||||
item.fullImage || item.thumbnail!,
|
||||
item.id
|
||||
);
|
||||
}
|
||||
handleDelete();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} className="text-base-fg" />
|
||||
<span>Edit image</span>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
|
||||
<span className="text-red">Delete</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
|
||||
<span className="text-red">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
)}
|
||||
</PopoverMenu>
|
||||
</div>
|
||||
)}
|
||||
{/* Media class badge on hover */}
|
||||
{/* Bulk selection checkbox — top-left */}
|
||||
{mode !== "select" && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute left-2 top-2 z-30 flex h-5 w-5 items-center justify-center rounded border-2 cursor-pointer transition-all duration-100",
|
||||
bulkSelected
|
||||
? "bg-primary border-primary"
|
||||
: "border-white/60 bg-black/40 hover:border-white",
|
||||
bulkSelectionMode
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBulkSelectToggle?.();
|
||||
}}
|
||||
>
|
||||
{bulkSelected && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-[10px] text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Media class badge on hover — bottom-left */}
|
||||
{!disableTooltipAndBadge && item.mediaClass && (
|
||||
<div className="pointer-events-none absolute left-2 top-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<div className="pointer-events-none absolute left-2 bottom-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
{item.mediaClass === "dimensional" ? "3D" : item.mediaClass}
|
||||
</div>
|
||||
)}
|
||||
{/* Tooltip on hover */}
|
||||
{disableTooltipAndBadge ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={twMerge(
|
||||
"w-full group relative overflow-visible rounded-md border-transparent border-[3px] transition-all",
|
||||
activeFilter === "video" ? "aspect-square" : "aspect-square",
|
||||
selected
|
||||
? "border-primary"
|
||||
: "border-transparent hover:border-primary/80",
|
||||
mode === "select" || item.mediaClass === "video"
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={handleButtonClick}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{!item.thumbnail ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/30">
|
||||
<span className="text-white/60">Image not available</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
data-gallery-draggable-1="true"
|
||||
// NB: "loading=lazy" is necessary to prevent loading GIGABYTES of images!
|
||||
// It is a bit finnicky, too: you must include this attribute
|
||||
// BEFORE the `src` attribute, or it won't work.
|
||||
loading="lazy"
|
||||
ref={imgRef}
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
className={twMerge(
|
||||
"h-full w-full bg-black/30",
|
||||
imageFit === "contain" ? "object-contain" : "object-cover"
|
||||
)}
|
||||
draggable={false}
|
||||
onError={onImageError}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
{/* Conditionally wrap with Tooltip — hidden when selecting or in bulk mode */}
|
||||
{showTooltip ? (
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
delay={200}
|
||||
@@ -256,55 +303,10 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={twMerge(
|
||||
"w-full group relative overflow-visible rounded-md border-[3px] transition-all focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
activeFilter === "video" ? "aspect-square" : "aspect-square",
|
||||
selected
|
||||
? "border-primary"
|
||||
: "border-transparent hover:border-primary",
|
||||
mode === "select" || item.mediaClass === "video"
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab hover:cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={handleButtonClick}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
{!item.thumbnail ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/30">
|
||||
<span className="text-white/60">Image not available</span>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
data-gallery-draggable-2="true"
|
||||
// NB: "loading=lazy" is necessary to prevent loading GIGABYTES of images!
|
||||
// It is a bit finnicky, too: you must include this attribute
|
||||
// BEFORE the `src` attribute, or it won't work.
|
||||
loading="lazy"
|
||||
ref={imgRef}
|
||||
src={item.thumbnail}
|
||||
alt={item.label}
|
||||
className={twMerge(
|
||||
"h-full w-full bg-black/30",
|
||||
imageFit === "contain" ? "object-contain" : "object-cover"
|
||||
)}
|
||||
draggable={false}
|
||||
onError={onImageError}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
|
||||
<FontAwesomeIcon icon={faCheck} className="text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{itemButton}
|
||||
</Tooltip>
|
||||
) : (
|
||||
itemButton
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,10 +43,17 @@ import {
|
||||
faExpand,
|
||||
faCompress,
|
||||
faArrowsRotate,
|
||||
faTrashCan,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { PopoverMenu } from "@storyteller/ui-popover";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import {
|
||||
showActionReminder,
|
||||
isActionReminderOpen,
|
||||
} from "@storyteller/ui-action-reminder-modal";
|
||||
import { MediaFileDelete } from "@storyteller/tauri-api";
|
||||
|
||||
export interface GalleryItem {
|
||||
id: string;
|
||||
@@ -80,22 +87,22 @@ interface GalleryModalProps {
|
||||
onDownloadClicked?: (url: string, mediaClass?: string) => Promise<void>;
|
||||
onAddToSceneClicked?: (
|
||||
url: string,
|
||||
media_id: string | undefined
|
||||
media_id: string | undefined,
|
||||
) => Promise<void>;
|
||||
isOpen?: boolean;
|
||||
forceFilter?: string;
|
||||
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
|
||||
onTurnIntoVideoClicked?: (
|
||||
url: string,
|
||||
media_id?: string
|
||||
media_id?: string,
|
||||
) => Promise<void> | void;
|
||||
onRemoveBackgroundClicked?: (
|
||||
url: string,
|
||||
media_id?: string
|
||||
media_id?: string,
|
||||
) => Promise<void> | void;
|
||||
onMake3DWorldClicked?: (
|
||||
url: string,
|
||||
media_id?: string
|
||||
media_id?: string,
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -136,7 +143,7 @@ export const GalleryModal = React.memo(
|
||||
// Default gridColumns to 5
|
||||
const defaultGridColumns = 5;
|
||||
const [sliderValue, setSliderValue] = useState(
|
||||
maxColumns - (defaultGridColumns - minColumns)
|
||||
maxColumns - (defaultGridColumns - minColumns),
|
||||
);
|
||||
const gridColumns = maxColumns - (sliderValue - minColumns);
|
||||
const [imageFit, setImageFit] = useState<"cover" | "contain">("contain");
|
||||
@@ -144,6 +151,17 @@ export const GalleryModal = React.memo(
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// Bulk selection state (view mode only)
|
||||
const [bulkSelectedIds, setBulkSelectedIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const bulkSelectionMode = bulkSelectedIds.size > 0;
|
||||
|
||||
// Clear bulk selection when filter changes
|
||||
useEffect(() => {
|
||||
setBulkSelectedIds(new Set());
|
||||
}, [activeFilter]);
|
||||
|
||||
// NB: We've got some kind of cursoring issue where subsequent pages are not requested.
|
||||
// For now, let's set the pagination limit really high.
|
||||
const pageSize = 100;
|
||||
@@ -180,11 +198,11 @@ export const GalleryModal = React.memo(
|
||||
Object.entries(grouped).sort(
|
||||
(a, b) =>
|
||||
new Date(b[1][0].createdAt).getTime() -
|
||||
new Date(a[1][0].createdAt).getTime()
|
||||
)
|
||||
new Date(a[1][0].createdAt).getTime(),
|
||||
),
|
||||
);
|
||||
},
|
||||
[formatDate]
|
||||
[formatDate],
|
||||
);
|
||||
|
||||
const handleImageError = useCallback(
|
||||
@@ -192,7 +210,7 @@ export const GalleryModal = React.memo(
|
||||
console.error(`Failed to load gallery modal image: ${url}`);
|
||||
failedImageUrls.add(url);
|
||||
},
|
||||
[failedImageUrls]
|
||||
[failedImageUrls],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -280,7 +298,7 @@ export const GalleryModal = React.memo(
|
||||
if (response.success && response.data) {
|
||||
const newItems = response.data
|
||||
.filter(
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
|
||||
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON,
|
||||
)
|
||||
.map((item: any) => ({
|
||||
id: item.token,
|
||||
@@ -289,10 +307,13 @@ export const GalleryModal = React.memo(
|
||||
item.media_class === "video"
|
||||
? item.media_links.maybe_video_previews?.animated
|
||||
: item.media_class === "dimensional"
|
||||
? item.cover_image?.maybe_cover_image_public_bucket_url
|
||||
: getThumbnailUrl(item.media_links.maybe_thumbnail_template, {
|
||||
width: THUMBNAIL_SIZES.MEDIUM,
|
||||
}),
|
||||
? item.cover_image?.maybe_cover_image_public_bucket_url
|
||||
: getThumbnailUrl(
|
||||
item.media_links.maybe_thumbnail_template,
|
||||
{
|
||||
width: THUMBNAIL_SIZES.MEDIUM,
|
||||
},
|
||||
),
|
||||
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
|
||||
fullImage: item.media_links.cdn_url,
|
||||
createdAt: item.created_at,
|
||||
@@ -338,8 +359,8 @@ export const GalleryModal = React.memo(
|
||||
mode === "view"
|
||||
? galleryModalVisibleViewMode.value
|
||||
: typeof isOpen === "boolean"
|
||||
? isOpen
|
||||
: true;
|
||||
? isOpen
|
||||
: true;
|
||||
if (modalIsOpen && username) {
|
||||
refreshGallery();
|
||||
}
|
||||
@@ -356,13 +377,15 @@ export const GalleryModal = React.memo(
|
||||
(item: GalleryItem) => {
|
||||
if (mode === "select" && onSelectItem) {
|
||||
onSelectItem(item.id);
|
||||
} else if (bulkSelectionMode) {
|
||||
toggleBulkSelect(item.id);
|
||||
} else {
|
||||
lightboxImageSignal.value = item;
|
||||
lightboxVisibleSignal.value = true;
|
||||
galleryModalLightboxMediaId.value = item.id;
|
||||
}
|
||||
},
|
||||
[mode, onSelectItem]
|
||||
[mode, onSelectItem, bulkSelectionMode],
|
||||
);
|
||||
|
||||
const handleCloseLightbox = useCallback(() => {
|
||||
@@ -384,15 +407,70 @@ export const GalleryModal = React.memo(
|
||||
|
||||
const handleItemDeleted = useCallback((id: string) => {
|
||||
setAllItems((prev) => prev.filter((it) => it.id !== id));
|
||||
setBulkSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleBulkSelect = useCallback((id: string) => {
|
||||
setBulkSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearBulkSelection = useCallback(() => {
|
||||
setBulkSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const bulkSelectedItems = useMemo(
|
||||
() => allItems.filter((item) => bulkSelectedIds.has(item.id)),
|
||||
[allItems, bulkSelectedIds],
|
||||
);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
const count = bulkSelectedIds.size;
|
||||
showActionReminder({
|
||||
reminderType: "default",
|
||||
title: `Delete ${count} item${count > 1 ? "s" : ""}?`,
|
||||
message: (
|
||||
<p className="text-sm text-white/70">
|
||||
This will permanently remove {count} item{count > 1 ? "s" : ""} from
|
||||
your library. This action cannot be undone.
|
||||
</p>
|
||||
),
|
||||
primaryActionText: "Delete",
|
||||
secondaryActionText: "Cancel",
|
||||
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
|
||||
onPrimaryAction: async () => {
|
||||
try {
|
||||
const ids = Array.from(bulkSelectedIds);
|
||||
await Promise.allSettled(ids.map((id) => MediaFileDelete(id)));
|
||||
setAllItems((prev) =>
|
||||
prev.filter((it) => !bulkSelectedIds.has(it.id)),
|
||||
);
|
||||
clearBulkSelection();
|
||||
} finally {
|
||||
isActionReminderOpen.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [bulkSelectedIds, clearBulkSelection]);
|
||||
|
||||
useSignals();
|
||||
|
||||
// Synchronize lightbox image when external media id signal is set
|
||||
useEffect(() => {
|
||||
if (galleryModalLightboxMediaId.value && allItems.length > 0) {
|
||||
const target = allItems.find(
|
||||
(it) => it.id === galleryModalLightboxMediaId.value
|
||||
(it) => it.id === galleryModalLightboxMediaId.value,
|
||||
);
|
||||
if (target) {
|
||||
lightboxImageSignal.value = target;
|
||||
@@ -425,11 +503,12 @@ export const GalleryModal = React.memo(
|
||||
? galleryModalVisibleViewMode.value &&
|
||||
galleryModalVisibleDuringDrag.value
|
||||
: typeof isOpen === "boolean"
|
||||
? isOpen
|
||||
: true
|
||||
? isOpen
|
||||
: true
|
||||
}
|
||||
onClose={() => {
|
||||
if (mode === "view") {
|
||||
clearBulkSelection();
|
||||
onClose?.() || (galleryModalVisibleViewMode.value = false);
|
||||
} else {
|
||||
onClose?.();
|
||||
@@ -438,7 +517,7 @@ export const GalleryModal = React.memo(
|
||||
className={twMerge(
|
||||
"h-[620px] max-w-4xl rounded-xl",
|
||||
mode === "view" &&
|
||||
"h-[640px] min-h-[640px] min-w-[56rem] w-[56rem] max-w-none"
|
||||
"h-[640px] min-h-[640px] min-w-[56rem] w-[56rem] max-w-none",
|
||||
)}
|
||||
childPadding={false}
|
||||
showClose={false}
|
||||
@@ -462,18 +541,18 @@ export const GalleryModal = React.memo(
|
||||
? "Select Video"
|
||||
: "Select Videos"
|
||||
: activeFilter === "3d"
|
||||
? maxSelections === 1
|
||||
? "Select 3D Object"
|
||||
: "Select 3D Objects"
|
||||
: activeFilter === "uploaded"
|
||||
? maxSelections === 1
|
||||
? "Select Upload"
|
||||
: "Select Uploads"
|
||||
: activeFilter === "all"
|
||||
? "Select Media"
|
||||
: maxSelections === 1
|
||||
? "Select Image"
|
||||
: "Select Images"
|
||||
? maxSelections === 1
|
||||
? "Select 3D Object"
|
||||
: "Select 3D Objects"
|
||||
: activeFilter === "uploaded"
|
||||
? maxSelections === 1
|
||||
? "Select Upload"
|
||||
: "Select Uploads"
|
||||
: activeFilter === "all"
|
||||
? "Select Media"
|
||||
: maxSelections === 1
|
||||
? "Select Image"
|
||||
: "Select Images"
|
||||
: "My Library"}
|
||||
</h2>
|
||||
{mode === "view" && (
|
||||
@@ -527,7 +606,7 @@ export const GalleryModal = React.memo(
|
||||
variant="action"
|
||||
onClick={() =>
|
||||
setImageFit((fit) =>
|
||||
fit === "cover" ? "contain" : "cover"
|
||||
fit === "cover" ? "contain" : "cover",
|
||||
)
|
||||
}
|
||||
className="relative z-[51] h-9 w-9 bg-ui-controls/60 hover:bg-ui-controls/90"
|
||||
@@ -587,7 +666,7 @@ export const GalleryModal = React.memo(
|
||||
// Only allow filter changes if no forceFilter was provided
|
||||
if (!forceFilterRef.current) {
|
||||
const filter = FILTERS.find(
|
||||
(f) => f.label === item.label
|
||||
(f) => f.label === item.label,
|
||||
);
|
||||
if (filter) setActiveFilter(filter.id);
|
||||
}
|
||||
@@ -604,6 +683,7 @@ export const GalleryModal = React.memo(
|
||||
<CloseButton
|
||||
onClick={() => {
|
||||
if (mode === "view") {
|
||||
clearBulkSelection();
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
} else {
|
||||
onClose?.();
|
||||
@@ -674,7 +754,9 @@ export const GalleryModal = React.memo(
|
||||
PLACEHOLDER_IMAGES.DEFAULT;
|
||||
e.currentTarget.style.opacity = "0.3";
|
||||
// Set the `data-brokenurl` property for debugging the broken images:
|
||||
(e.currentTarget as HTMLImageElement).dataset.brokenurl = item.thumbnail || "";
|
||||
(
|
||||
e.currentTarget as HTMLImageElement
|
||||
).dataset.brokenurl = item.thumbnail || "";
|
||||
handleImageError(item.thumbnail!);
|
||||
}}
|
||||
disableTooltipAndBadge={mode === "select"}
|
||||
@@ -682,12 +764,17 @@ export const GalleryModal = React.memo(
|
||||
onDeleted={handleItemDeleted}
|
||||
onEditClicked={onEditClicked}
|
||||
maxSelections={maxSelections}
|
||||
bulkSelected={bulkSelectedIds.has(item.id)}
|
||||
onBulkSelectToggle={() =>
|
||||
toggleBulkSelect(item.id)
|
||||
}
|
||||
bulkSelectionMode={bulkSelectionMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
)}
|
||||
{loading && allItems.length > 0 && (
|
||||
<div className="flex justify-center py-4">
|
||||
@@ -703,6 +790,52 @@ export const GalleryModal = React.memo(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === "view" && bulkSelectionMode && (
|
||||
<div className="flex items-center justify-between border-t border-ui-panel-border bg-ui-background p-3 py-2 rounded-b-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Thumbnail previews of selected items */}
|
||||
<div className="flex -space-x-1.5">
|
||||
{bulkSelectedItems.slice(0, 4).map((si) => (
|
||||
<div
|
||||
key={si.id}
|
||||
className="h-8 w-8 rounded overflow-hidden border-2 border-ui-panel bg-black/30 flex-shrink-0"
|
||||
>
|
||||
{si.thumbnail ? (
|
||||
<img
|
||||
src={si.thumbnail}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-black/50" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-base-fg/80">
|
||||
{bulkSelectedIds.size} selected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleBulkDelete}
|
||||
className="px-3"
|
||||
icon={faTrashCan}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<button
|
||||
onClick={clearBulkSelection}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg transition-colors"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "select" && (
|
||||
<div className="flex items-center justify-between border-t border-ui-panel-border bg-ui-panel p-4 rounded-b-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -761,7 +894,7 @@ export const GalleryModal = React.memo(
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
GalleryModal.displayName = "GalleryModal";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
faDownToLine,
|
||||
faGlobe,
|
||||
faPencil,
|
||||
faTrashCan,
|
||||
faVideo,
|
||||
faWandMagicSparkles,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
@@ -864,6 +865,7 @@ export function LightboxModal({
|
||||
|
||||
{selectedMediaToken && onDeleteClicked && (
|
||||
<Button
|
||||
icon={faTrashCan}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
onClick={async (e) => {
|
||||
|
||||
Reference in new issue
Block a user