mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Webapp: send library media to the create-image / create-video promptbox (#1807)
- Adds a **Send to prompt** action to the library's multi-select bar
with two
targets: **Create image** and **Create video**
- Selected images are attached as reference images on the target page
without
touching the user's typed prompt or settings; selection is cleared and
the
app navigates to the create page
- Selected videos can be sent to the video prompt as reference videos;
models
without video-reference support (only Seedance has it) get an
explanatory
toast instead of a silent drop
- Media travels via `pendingRefImages` / `pendingRefVideos` store slots
(mirroring the existing `pendingRecreate` pattern) and is merged on the
receiving page once its model list is loaded, so the **real per-model
reference caps** apply - fixes silent no-ops when refs were already
attached
- Merging dedupes by media token, clamps to the model cap, and toasts
partial
adds ("Added 3 of 5 …"), full decks, and unsupported models
- Keyframe-only video models take the first sent image as the start
frame;
reference-capable models switch into reference mode so the deck is
visible
- DRY: shared `galleryItemToRefImage` mapper (reused by the create-image
library picker) and `buildVideoRefsToAdd` (reused by the video-ref
picker,
duration probing + total-duration cap)
- Bar UX: option list adapts to the selection (video-only hides Create
image),
icon-only button on phones to avoid overflowing the bar
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import type { NavigateFunction } from "react-router-dom";
|
||||
import type { GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import { toast } from "../components/toast/toast";
|
||||
import type { RefImage, RefVideo } from "../components/prompt-box/types";
|
||||
import { useCreateImageStore } from "../pages/create-image/create-image-store";
|
||||
import { useCreateVideoStore } from "../pages/create-video/create-video-store";
|
||||
|
||||
export type PromptDestination = "image" | "video";
|
||||
|
||||
const DESTINATION_ROUTES: Record<PromptDestination, string> = {
|
||||
image: "/create-image",
|
||||
video: "/create-video",
|
||||
};
|
||||
|
||||
export function isImagePromptable(item: GalleryItem): boolean {
|
||||
return item.mediaClass === "image" && !!(item.thumbnail || item.fullImage);
|
||||
}
|
||||
|
||||
// Videos can only be prompted from on the video page (as reference videos,
|
||||
// model permitting).
|
||||
export function isVideoPromptable(item: GalleryItem): boolean {
|
||||
return item.mediaClass === "video" && !!item.fullImage;
|
||||
}
|
||||
|
||||
// Parks library media for a create page's reference deck and navigates
|
||||
// there. The reference caps are per-model and only known on the receiving
|
||||
// page, so the media travels via the store's pending slots and the page
|
||||
// merges it (dedupe + real caps) once its model list is loaded.
|
||||
export function sendToPrompt(
|
||||
items: GalleryItem[],
|
||||
destination: PromptDestination,
|
||||
navigate: NavigateFunction,
|
||||
): void {
|
||||
const images = items.filter(isImagePromptable);
|
||||
const videos = destination === "video" ? items.filter(isVideoPromptable) : [];
|
||||
if (images.length === 0 && videos.length === 0) {
|
||||
toast.error("Select at least one image to use as a reference");
|
||||
return;
|
||||
}
|
||||
|
||||
if (destination === "image") {
|
||||
useCreateImageStore
|
||||
.getState()
|
||||
.setPendingRefImages(images.map(galleryItemToRefImage));
|
||||
if (items.some(isVideoPromptable)) {
|
||||
toast.success(
|
||||
`Sent ${images.length} ${images.length === 1 ? "image" : "images"} — videos can't be image references`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const store = useCreateVideoStore.getState();
|
||||
if (images.length > 0) {
|
||||
store.setPendingRefImages(images.map(galleryItemToRefImage));
|
||||
}
|
||||
if (videos.length > 0) {
|
||||
store.setPendingRefVideos(videos.map(galleryItemToRefVideo));
|
||||
}
|
||||
}
|
||||
navigate(DESTINATION_ROUTES[destination]);
|
||||
}
|
||||
|
||||
export function galleryItemToRefImage(item: GalleryItem): RefImage {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url: item.thumbnail || item.fullImage || "",
|
||||
fullUrl: item.fullImage || undefined,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
function galleryItemToRefVideo(item: GalleryItem): RefVideo {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
url: item.fullImage || "",
|
||||
file: new File([], "library-video"),
|
||||
mediaToken: item.id,
|
||||
// 0 = unknown; the video page probes the file before adding it.
|
||||
duration: item.durationMillis ? item.durationMillis / 1000 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Merge (used by the receiving create pages) ─────────────────────────────
|
||||
|
||||
export interface MergeRefImagesResult {
|
||||
next: RefImage[];
|
||||
added: number;
|
||||
// Incoming refs whose media token was already attached.
|
||||
duplicates: number;
|
||||
// Fresh refs that didn't fit under the model's cap.
|
||||
overflow: number;
|
||||
}
|
||||
|
||||
// Merges incoming reference images into an existing deck: skips refs whose
|
||||
// media token is already attached, then clamps to the model's cap.
|
||||
export function mergeRefImages(
|
||||
existing: RefImage[],
|
||||
incoming: RefImage[],
|
||||
maxRefs: number,
|
||||
): MergeRefImagesResult {
|
||||
const existingTokens = new Set(
|
||||
existing.map((ref) => ref.mediaToken).filter(Boolean),
|
||||
);
|
||||
const fresh = incoming.filter(
|
||||
(ref) => !ref.mediaToken || !existingTokens.has(ref.mediaToken),
|
||||
);
|
||||
const slots = Math.max(0, maxRefs - existing.length);
|
||||
const added = fresh.slice(0, slots);
|
||||
return {
|
||||
next: [...existing, ...added],
|
||||
added: added.length,
|
||||
duplicates: incoming.length - fresh.length,
|
||||
overflow: fresh.length - added.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Feedback for a merge. Silent when everything landed or the only skips were
|
||||
// images already attached (either way, everything selected is now in the
|
||||
// deck); otherwise say what was left out and why.
|
||||
export function toastMergeRefImagesOutcome(
|
||||
result: MergeRefImagesResult,
|
||||
maxRefs: number,
|
||||
): void {
|
||||
if (result.overflow === 0) return;
|
||||
if (result.added > 0) {
|
||||
const sent = result.added + result.overflow;
|
||||
toast.success(
|
||||
`Added ${result.added} of ${sent} images — this model takes up to ${maxRefs} references`,
|
||||
);
|
||||
} else if (maxRefs === 0) {
|
||||
toast.error("The selected model doesn't take image references");
|
||||
} else {
|
||||
toast.error(
|
||||
`Couldn't add more images — this model's reference limit (${maxRefs}) is reached`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,15 @@ type CreateImageState = {
|
||||
ui: ImageUiState;
|
||||
referenceImages: RefImage[];
|
||||
pendingRecreate: RecreatePayload | null;
|
||||
// Reference images sent from another page (library "Send to prompt").
|
||||
// Consumed by the create-image page, which applies the real per-model cap.
|
||||
pendingRefImages: RefImage[] | null;
|
||||
setUi: (patch: Partial<ImageUiState>) => void;
|
||||
setReferenceImages: (images: RefImage[]) => void;
|
||||
setPendingRecreate: (payload: RecreatePayload | null) => void;
|
||||
consumePendingRecreate: () => RecreatePayload | null;
|
||||
setPendingRefImages: (refs: RefImage[] | null) => void;
|
||||
consumePendingRefImages: () => RefImage[] | null;
|
||||
startBatch: (
|
||||
prompt: string,
|
||||
requestedCount: number,
|
||||
@@ -68,6 +73,7 @@ export const useCreateImageStore = create<CreateImageState>()(
|
||||
ui: { ...DEFAULT_UI },
|
||||
referenceImages: [],
|
||||
pendingRecreate: null,
|
||||
pendingRefImages: null,
|
||||
|
||||
setUi: (patch) =>
|
||||
set((s) => ({ ui: { ...s.ui, ...patch } })),
|
||||
@@ -82,6 +88,14 @@ export const useCreateImageStore = create<CreateImageState>()(
|
||||
return payload;
|
||||
},
|
||||
|
||||
setPendingRefImages: (refs) => set({ pendingRefImages: refs }),
|
||||
|
||||
consumePendingRefImages: () => {
|
||||
const refs = get().pendingRefImages;
|
||||
if (refs) set({ pendingRefImages: null });
|
||||
return refs;
|
||||
},
|
||||
|
||||
startBatch: (prompt, requestedCount, modelLabel) => {
|
||||
const id = crypto.randomUUID();
|
||||
const batch: ImageBatch = {
|
||||
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
qualityFromLabel,
|
||||
} from "./components/QualityPicker";
|
||||
import { useImageCostEstimate } from "../../lib/cost-estimate-api";
|
||||
import {
|
||||
galleryItemToRefImage,
|
||||
mergeRefImages,
|
||||
toastMergeRefImagesOutcome,
|
||||
} from "../../lib/send-to-prompt";
|
||||
import {
|
||||
resolveModelOption,
|
||||
resolveModelCount,
|
||||
@@ -315,6 +320,25 @@ export default function CreateImage() {
|
||||
if (payload.modelId) setRecreateModelIdToVerify(payload.modelId);
|
||||
}, [pendingRecreate, setUi]);
|
||||
|
||||
// Consume reference images sent from the library ("Send to prompt").
|
||||
// Waits for the model list so the merge applies the real per-model cap —
|
||||
// the sender doesn't know which model is selected here.
|
||||
const pendingRefImages = useCreateImageStore((s) => s.pendingRefImages);
|
||||
useEffect(() => {
|
||||
if (!pendingRefImages || apiModels.length === 0) return;
|
||||
const incoming = useCreateImageStore.getState().consumePendingRefImages();
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
const result = mergeRefImages(referenceImages, incoming, maxImageRefs);
|
||||
if (result.added > 0) setReferenceImages(result.next);
|
||||
toastMergeRefImagesOutcome(result, maxImageRefs);
|
||||
}, [
|
||||
pendingRefImages,
|
||||
apiModels,
|
||||
referenceImages,
|
||||
maxImageRefs,
|
||||
setReferenceImages,
|
||||
]);
|
||||
|
||||
// Resume polling for pending batches
|
||||
useEffect(() => {
|
||||
const cleanups = pollingCleanupsRef.current;
|
||||
@@ -365,13 +389,7 @@ export default function CreateImage() {
|
||||
const availableSlots = Math.max(0, maxImageRefs - referenceImages.length);
|
||||
const newImages: RefImage[] = items
|
||||
.slice(0, availableSlots)
|
||||
.map((item) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: item.thumbnail || item.fullImage || "",
|
||||
fullUrl: item.fullImage || undefined,
|
||||
file: new File([], "library-image"),
|
||||
mediaToken: item.id,
|
||||
}));
|
||||
.map(galleryItemToRefImage);
|
||||
setReferenceImages([...referenceImages, ...newImages]);
|
||||
setIsImagePickerOpen(false);
|
||||
},
|
||||
|
||||
@@ -51,10 +51,18 @@ type CreateVideoState = {
|
||||
ui: VideoUiState;
|
||||
refs: VideoRefsState;
|
||||
pendingRecreate: RecreatePayload | null;
|
||||
// Reference media sent from another page (library "Send to prompt").
|
||||
// Consumed by the create-video page, which applies the real per-model caps.
|
||||
pendingRefImages: RefImage[] | null;
|
||||
pendingRefVideos: RefVideo[] | null;
|
||||
setUi: (patch: Partial<VideoUiState>) => void;
|
||||
setRefs: (patch: Partial<VideoRefsState>) => void;
|
||||
setPendingRecreate: (payload: RecreatePayload | null) => void;
|
||||
consumePendingRecreate: () => RecreatePayload | null;
|
||||
setPendingRefImages: (refs: RefImage[] | null) => void;
|
||||
consumePendingRefImages: () => RefImage[] | null;
|
||||
setPendingRefVideos: (refs: RefVideo[] | null) => void;
|
||||
consumePendingRefVideos: () => RefVideo[] | null;
|
||||
startBatch: (prompt: string, modelLabel: string, batchCount?: number) => string;
|
||||
setBatchJobToken: (batchId: string, jobToken: string) => void;
|
||||
completeBatch: (batchId: string, video: GeneratedVideo) => void;
|
||||
@@ -90,6 +98,8 @@ export const useCreateVideoStore = create<CreateVideoState>()(
|
||||
ui: { ...DEFAULT_UI },
|
||||
refs: { ...DEFAULT_REFS },
|
||||
pendingRecreate: null,
|
||||
pendingRefImages: null,
|
||||
pendingRefVideos: null,
|
||||
|
||||
setUi: (patch) =>
|
||||
set((s) => ({ ui: { ...s.ui, ...patch } })),
|
||||
@@ -105,6 +115,22 @@ export const useCreateVideoStore = create<CreateVideoState>()(
|
||||
return payload;
|
||||
},
|
||||
|
||||
setPendingRefImages: (refs) => set({ pendingRefImages: refs }),
|
||||
|
||||
consumePendingRefImages: () => {
|
||||
const refs = get().pendingRefImages;
|
||||
if (refs) set({ pendingRefImages: null });
|
||||
return refs;
|
||||
},
|
||||
|
||||
setPendingRefVideos: (refs) => set({ pendingRefVideos: refs }),
|
||||
|
||||
consumePendingRefVideos: () => {
|
||||
const refs = get().pendingRefVideos;
|
||||
if (refs) set({ pendingRefVideos: null });
|
||||
return refs;
|
||||
},
|
||||
|
||||
startBatch: (prompt, modelLabel, batchCount) => {
|
||||
const id = crypto.randomUUID();
|
||||
const batch: VideoBatch = {
|
||||
|
||||
@@ -55,6 +55,10 @@ import {
|
||||
} from "../create-image/components/AspectRatioIcon";
|
||||
import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker";
|
||||
import { useVideoCostEstimate } from "../../lib/cost-estimate-api";
|
||||
import {
|
||||
mergeRefImages,
|
||||
toastMergeRefImagesOutcome,
|
||||
} from "../../lib/send-to-prompt";
|
||||
import {
|
||||
resolveModelOption,
|
||||
resolveModelCount,
|
||||
@@ -203,6 +207,41 @@ function buildSizePopoverItems(
|
||||
}));
|
||||
}
|
||||
|
||||
// Caps candidate reference videos against the deck's slot and total-duration
|
||||
// limits, probing each file's duration when it isn't known yet. Rejections
|
||||
// toast per video. Shared by the library video picker and the "Send to
|
||||
// prompt" consume flow.
|
||||
async function buildVideoRefsToAdd(
|
||||
candidates: RefVideo[],
|
||||
existing: RefVideo[],
|
||||
maxRefs: number,
|
||||
maxTotalDuration: number,
|
||||
): Promise<RefVideo[]> {
|
||||
const slots = Math.max(0, maxRefs - existing.length);
|
||||
const picked = candidates.slice(0, slots);
|
||||
const added: RefVideo[] = [];
|
||||
let total = existing.reduce((sum, v) => sum + v.duration, 0);
|
||||
for (const candidate of picked) {
|
||||
const duration =
|
||||
candidate.duration > 0
|
||||
? candidate.duration
|
||||
: await getVideoDurationFromUrl(candidate.url);
|
||||
if (duration <= 0) {
|
||||
toast.error("Could not read video file");
|
||||
continue;
|
||||
}
|
||||
if (total + duration > maxTotalDuration) {
|
||||
toast.error(
|
||||
`Video too long — max ${maxTotalDuration}s total (${maxTotalDuration - total}s remaining)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
total += duration;
|
||||
added.push({ ...candidate, duration });
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// Effective max duration for the active input mode. Some models (e.g. Grok)
|
||||
// cap duration lower when image references are used — that's reference mode here.
|
||||
function maxDurationForMode(
|
||||
@@ -747,6 +786,79 @@ export default function CreateVideo() {
|
||||
});
|
||||
}, [pendingRecreate, setUi]);
|
||||
|
||||
// Consume reference images sent from the library ("Send to prompt").
|
||||
// Waits for the model list so the merge applies the real per-model cap —
|
||||
// the sender doesn't know which model is selected here. Reference-capable
|
||||
// models take the images as references (switching into reference mode so
|
||||
// they're visible); keyframe-only models take the first as the start frame.
|
||||
const pendingRefImages = useCreateVideoStore((s) => s.pendingRefImages);
|
||||
useEffect(() => {
|
||||
if (!pendingRefImages || apiModels.length === 0) return;
|
||||
const incoming = useCreateVideoStore.getState().consumePendingRefImages();
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
const supportsRefs = !!selectedModel?.image_references_supported;
|
||||
const supportsKeyframe =
|
||||
!!selectedModel?.starting_keyframe_supported ||
|
||||
!!selectedModel?.starting_keyframe_required;
|
||||
const maxImages = supportsRefs
|
||||
? (selectedModel?.image_references_max ?? 3)
|
||||
: supportsKeyframe
|
||||
? 1
|
||||
: 0;
|
||||
const result = mergeRefImages(referenceImages, incoming, maxImages);
|
||||
if (result.added > 0) {
|
||||
setReferenceImages(result.next);
|
||||
if (supportsRefs) setUi({ inputMode: "reference" });
|
||||
}
|
||||
toastMergeRefImagesOutcome(result, maxImages);
|
||||
}, [
|
||||
pendingRefImages,
|
||||
apiModels,
|
||||
selectedModel,
|
||||
referenceImages,
|
||||
setReferenceImages,
|
||||
setUi,
|
||||
]);
|
||||
|
||||
// Consume reference videos sent from the library ("Send to prompt").
|
||||
// Only some models (e.g. Seedance) take video references — anything else
|
||||
// gets a toast instead of a silently hidden deck.
|
||||
const pendingRefVideos = useCreateVideoStore((s) => s.pendingRefVideos);
|
||||
useEffect(() => {
|
||||
if (!pendingRefVideos || apiModels.length === 0) return;
|
||||
const incoming = useCreateVideoStore.getState().consumePendingRefVideos();
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
if (!supportsVideoRefs) {
|
||||
toast.error(
|
||||
"The selected model doesn't support video references — pick one that does (e.g. Seedance)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const existingTokens = new Set(referenceVideos.map((v) => v.mediaToken));
|
||||
const fresh = incoming.filter((v) => !existingTokens.has(v.mediaToken));
|
||||
void (async () => {
|
||||
const added = await buildVideoRefsToAdd(
|
||||
fresh,
|
||||
referenceVideos,
|
||||
maxVideoRefs,
|
||||
maxVideoRefDuration,
|
||||
);
|
||||
if (added.length > 0) {
|
||||
setReferenceVideos([...referenceVideos, ...added]);
|
||||
setUi({ inputMode: "reference" });
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
pendingRefVideos,
|
||||
apiModels,
|
||||
supportsVideoRefs,
|
||||
referenceVideos,
|
||||
maxVideoRefs,
|
||||
maxVideoRefDuration,
|
||||
setReferenceVideos,
|
||||
setUi,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups = pollingCleanupsRef.current;
|
||||
const pendingBatches = useCreateVideoStore
|
||||
@@ -930,34 +1042,21 @@ export default function CreateVideo() {
|
||||
const handleLibraryVideoSelect = useCallback(
|
||||
async (items: GalleryItem[]) => {
|
||||
setIsVideoRefPickerOpen(false);
|
||||
const availableSlots = Math.max(0, maxVideoRefs - referenceVideos.length);
|
||||
const picked = items.slice(0, availableSlots);
|
||||
|
||||
const added: RefVideo[] = [];
|
||||
let total = referenceVideos.reduce((sum, v) => sum + v.duration, 0);
|
||||
for (const item of picked) {
|
||||
const url = item.fullImage;
|
||||
if (!url) continue;
|
||||
const duration = await getVideoDurationFromUrl(url);
|
||||
if (duration <= 0) {
|
||||
toast.error("Could not read video file");
|
||||
continue;
|
||||
}
|
||||
if (total + duration > maxVideoRefDuration) {
|
||||
toast.error(
|
||||
`Video too long — max ${maxVideoRefDuration}s total (${maxVideoRefDuration - total}s remaining)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
total += duration;
|
||||
added.push({
|
||||
const candidates: RefVideo[] = items
|
||||
.filter((item) => !!item.fullImage)
|
||||
.map((item) => ({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url,
|
||||
url: item.fullImage!,
|
||||
file: new File([], "library-video"),
|
||||
mediaToken: item.id,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
duration: 0,
|
||||
}));
|
||||
const added = await buildVideoRefsToAdd(
|
||||
candidates,
|
||||
referenceVideos,
|
||||
maxVideoRefs,
|
||||
maxVideoRefDuration,
|
||||
);
|
||||
if (added.length > 0) {
|
||||
setReferenceVideos([...referenceVideos, ...added]);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ import {
|
||||
import { Lightbox } from "../../components/lightbox/lightbox";
|
||||
import { toast } from "../../components/toast/toast";
|
||||
import { downloadItemsAsZip } from "../../lib/download-media-zip";
|
||||
import {
|
||||
isImagePromptable,
|
||||
isVideoPromptable,
|
||||
sendToPrompt,
|
||||
type PromptDestination,
|
||||
} from "../../lib/send-to-prompt";
|
||||
import {
|
||||
useLibraryFoldersStore,
|
||||
useLibrarySelectionStore,
|
||||
@@ -2061,6 +2067,15 @@ const LibraryTile = memo(function LibraryTile({
|
||||
// Owns its own subscription to the selection store (and the folder popover
|
||||
// state), so selection changes re-render only this small bar, never the page.
|
||||
|
||||
const SEND_TO_TARGETS: {
|
||||
destination: PromptDestination;
|
||||
label: string;
|
||||
icon: typeof faImage;
|
||||
}[] = [
|
||||
{ destination: "image", label: "Create image", icon: faImage },
|
||||
{ destination: "video", label: "Create video", icon: faVideo },
|
||||
];
|
||||
|
||||
interface BulkSelectionBarProps {
|
||||
allItems: GalleryItem[];
|
||||
folders: UiFolder[];
|
||||
@@ -2081,7 +2096,9 @@ function BulkSelectionBar({
|
||||
const ids = useLibrarySelectionStore((s) => s.ids);
|
||||
const folderMediaItems = useLibraryFoldersStore((s) => s.folderMediaItems);
|
||||
const tagMediaItems = useLibraryTagsStore((s) => s.tagMediaItems);
|
||||
const navigate = useNavigate();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [sendToOpen, setSendToOpen] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
@@ -2120,6 +2137,8 @@ function BulkSelectionBar({
|
||||
|
||||
if (ids.size === 0) return null;
|
||||
const clear = () => useLibrarySelectionStore.getState().clear();
|
||||
const hasPromptImages = selectedItems.some(isImagePromptable);
|
||||
const hasPromptVideos = selectedItems.some(isVideoPromptable);
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
const downloadable = selectedItems.filter(
|
||||
@@ -2178,6 +2197,56 @@ function BulkSelectionBar({
|
||||
{ids.size} selected
|
||||
</span>
|
||||
|
||||
{/* Send to prompt (only when the selection contains images or videos).
|
||||
Images can go to either prompt; videos only to the video prompt. */}
|
||||
{(hasPromptImages || hasPromptVideos) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
title="Send to prompt"
|
||||
onClick={() => setSendToOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={hasPromptImages ? faImage : faVideo}
|
||||
className="text-xs"
|
||||
/>
|
||||
{/* Icon-only on phones — a fourth labeled button overflows the bar. */}
|
||||
<span className="hidden sm:inline">Send to prompt</span>
|
||||
</button>
|
||||
{sendToOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[59]"
|
||||
onClick={() => setSendToOpen(false)}
|
||||
/>
|
||||
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
|
||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
|
||||
Use as reference in
|
||||
</div>
|
||||
{SEND_TO_TARGETS.filter(
|
||||
(target) => target.destination === "video" || hasPromptImages,
|
||||
).map((target) => (
|
||||
<button
|
||||
key={target.destination}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSendToOpen(false);
|
||||
sendToPrompt(selectedItems, target.destination, navigate);
|
||||
clear();
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
|
||||
>
|
||||
<FontAwesomeIcon icon={target.icon} className="w-4 text-xs" />
|
||||
<span>{target.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to folder */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user