Compare commits

...
Author SHA1 Message Date
bflatastic 8be80e57f8 add image plane faster than before from media token 2025-05-21 23:01:47 +07:00
bflatastic 5daf7d51c0 Update GalleryModal to include user uploads in filter for 'all' activeFilter option 2025-05-21 22:45:09 +07:00
bflatastic a839bd88f9 Implement image drop functionality in PageEditor for drag-and-drop uploads from gallery/library 2025-05-21 21:22:12 +07:00
bflatastic fdbe9e264b Enhance GalleryModal with pagination support and improved item loading logic; refactor item fetching to handle soft refresh and infinite scrolling for additional items. 2025-05-21 20:15:13 +07:00
bflatastic 1c124c30c6 Update TopBar and GalleryModal labels to be "My Library"; enhance drag-and-drop cursor styles in Gallery components. 2025-05-21 19:42:15 +07:00
bflatastic ff75ebc92f Implement image fit toggle in GalleryModal for better image display options and lightbox fix 2025-05-21 19:29:56 +07:00
bflatastic 6d98604cc2 enhance GalleryModal with SliderV2 for dynamic column adjustment and improved grid layout 2025-05-21 19:02:02 +07:00
bflatastic fd69376d6a Update maxSelections in PromptBoxVideo to limit selection to 1 item 2025-05-21 17:31:18 +07:00
bflatastic 5fe974b11e Implement filter functionality in GalleryModal
removed tabs and replace with filters, fixed drag component rendered multiple times
2025-05-21 17:27:13 +07:00
bflatastic 62c319317c prevent dragging for video items 2025-05-21 16:46:51 +07:00
bflatastic 4861e5f682 fixed gallery select mode not opening 2025-05-21 16:31:47 +07:00
bflatastic d159501799 media class support and tooltip integration 2025-05-21 16:10:27 +07:00
bflatastic 969214710b gallery drag and drop init 2025-05-21 15:36:42 +07:00
17 changed files with 866 additions and 244 deletions
@@ -122,3 +122,53 @@ const takeVideoSnapshot = async (
return blob;
};
export const uploadPlaneFromMediaToken = async ({
title,
mediaToken,
progressCallback,
}: {
title: string;
mediaToken: string;
progressCallback: (newState: UploaderState) => void;
}) => {
// Step 1: Set uploading state (simulate asset upload)
progressCallback({ status: UploaderStates.uploadingAsset });
// Step 2: Fetch the media file info (to get type, etc.)
const mediaFilesApi = new MediaFilesApi();
const mediaFileResponse = await mediaFilesApi.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (!mediaFileResponse.success || !mediaFileResponse.data) {
progressCallback({
status: UploaderStates.assetError,
errorMessage:
mediaFileResponse.errorMessage ||
"Could not fetch media file by token.",
});
return;
}
// Step 3: Optionally set the title (rename)
if (title && mediaFileResponse.data.maybe_title !== title) {
const renameResponse = await mediaFilesApi.RenameMediaFileByToken({
mediaToken,
name: title,
});
if (!renameResponse.success) {
progressCallback({
status: UploaderStates.assetError,
errorMessage:
renameResponse.errorMessage || "Could not rename media file.",
});
return;
}
}
// Step 4: (Optional) Set cover image if needed (skip if already set)
// For simplicity, we skip cover image upload here, but you could add logic to set a cover if needed.
// Step 5: Success
progressCallback({ status: UploaderStates.success });
};
@@ -10,12 +10,15 @@ import { AuthButtons } from "./AuthButtons";
import { SceneTitleInput } from "./SceneTitleInput";
import { getCurrentLocationWithoutParams } from "~/utilities";
import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity";
import { GalleryModal } from "@storyteller/ui-gallery-modal";
import {
GalleryModal,
galleryModalVisibleDuringDrag,
} from "@storyteller/ui-gallery-modal";
import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip";
import { downloadFileFromUrl } from "@storyteller/api";
import { TabSelector, TabItem } from "@storyteller/ui-tab-selector";
import { Signal } from "@preact/signals-react";
import { Signal, signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime";
import { setLogoutStates } from "~/signals/authentication/utilities";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
@@ -39,8 +42,6 @@ const appTabs: TabItem[] = [
{ id: "VIDEO", label: "Video" },
];
import { signal } from "@preact/signals-react";
export const topNavMediaId = signal<string>("");
export const topNavMediaUrl = signal<string>("");
@@ -56,8 +57,6 @@ export const TopBar = ({
useLocation().pathname,
useParams(),
);
const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false);
const [activeLibraryTab, setActiveLibraryTab] = useState("my-media");
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [url, setUrl] = useState<string>("");
@@ -110,6 +109,10 @@ export const TopBar = ({
}
};
const handleOpenGalleryModal = () => {
galleryModalVisibleDuringDrag.value = true;
};
return (
<>
<header className="fixed left-0 top-0 z-[60] w-full border-b border-white/5 bg-ui-background">
@@ -161,9 +164,9 @@ export const TopBar = ({
<Button
variant="secondary"
icon={faImages}
onClick={() => setIsLibraryModalOpen(true)}
onClick={handleOpenGalleryModal}
>
My Gallery
My Library
</Button>
<Activity />
@@ -182,16 +185,9 @@ export const TopBar = ({
/>
<GalleryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
isOpen={galleryModalVisibleDuringDrag.value}
onClose={() => (galleryModalVisibleDuringDrag.value = false)}
mode="view"
tabs={[
{ id: "my-media", label: "Generations" },
{ id: "uploads", label: "Uploads" },
{ id: "videos", label: "Videos" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
onDownloadClicked={downloadFileFromUrl}
onAddToSceneClicked={handleAddToScene}
/>
@@ -50,8 +50,18 @@ import { LoginModal } from "@storyteller/ui-login-modal";
import { DomLevels } from "./signals/hotkeys";
import { AUTH_STATUS } from "../../enums/authentication";
import { authentication } from "../../signals/authentication/authentication";
import { topNavMediaId } from "~/components/signaled/TopBar/TopBar";
import { topNavMediaUrl } from "~/components/signaled/TopBar/TopBar";
import {
topNavMediaId,
topNavMediaUrl,
} from "~/components/signaled/TopBar/TopBar";
import { setOnImageDrop } from "../../../../../../libs/components/gallery-modal/src/lib/galleryDnd";
import { uploadPlaneFromMediaToken } from "~/components/reusable/UploadModalMedia/uploadPlane";
import { addObject } from "./signals/objectGroup/addObject";
import { AssetType } from "~/enums";
import { v4 as uuidv4 } from "uuid";
import { MediaItem } from "~/pages/PageEnigma/models";
import { GalleryItem } from "../../../../../../libs/components/gallery-modal/src/lib/gallery-modal";
import { UploaderState } from "~/models";
export const PageEditor = () => {
useSignals();
@@ -68,6 +78,35 @@ export const PageEditor = () => {
};
}, []);
useEffect(() => {
setOnImageDrop(
async (item: GalleryItem, _position: { x: number; y: number }) => {
console.log("Gallery image dropped", item, _position);
try {
await uploadPlaneFromMediaToken({
title: item.label || "Image Plane",
mediaToken: item.id,
progressCallback: (state: UploaderState) => {
if (state.status) console.log("Upload status:", state.status);
},
});
console.log("Upload complete, adding object to scene");
const mediaItem: MediaItem = {
version: 1,
type: AssetType.OBJECT,
media_id: item.id || uuidv4(),
name: item.label || "Image Plane",
// Add other fields as needed
};
addObject(mediaItem);
console.log("Object added to scene:", mediaItem);
} catch (err) {
console.error("Failed to upload and add image plane:", err);
}
},
);
}, []);
const height =
dndTimelineHeight.value > -1
? pageHeight.value - dndTimelineHeight.value - 56
@@ -24,6 +24,8 @@ import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
import { useImageGenerationFailureEvent } from "./TauriEvents/useImageGenerationFailureEvent";
import { useImageGenerationSuccessEvent } from "./TauriEvents/useImageGenerationSuccessEvent";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
useSignals();
useActiveJobs();
@@ -108,6 +110,7 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
<EngineProvider sceneToken={sceneToken}>
<PageEditor />
<DragComponent />
<GalleryDragComponent />
<PrecisionSelector
showSignal={showPrecisionSelector}
coordSignal={precisionSelectorMenuCoords}
@@ -12,6 +12,8 @@ interface ListUserMediaQuery {
filter_engine_categories?: FilterEngineCategories[];
user_uploads_only?: boolean;
include_user_uploads?: boolean;
page_index?: number;
page_size?: number;
}
export class GalleryModalApi extends MediaFilesApi {
@@ -28,6 +30,8 @@ export class GalleryModalApi extends MediaFilesApi {
username: query.username,
user_uploads_only: query.user_uploads_only,
include_user_uploads: query.include_user_uploads,
page_index: query.page_index,
page_size: query.page_size,
});
}
}
@@ -1,2 +1,3 @@
export * from './lib/gallery-modal';
export * from "./lib/gallery-modal";
export * from "./lib/galleryModalSignals";
export * from "./lib/GalleryDragComponent";
@@ -0,0 +1,40 @@
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import galleryDnd from "./galleryDnd";
export const GalleryDragComponent: React.FC = () => {
const [dragState, setDragState] = useState(galleryDnd.getDragState());
useEffect(() => {
let frame: number;
function update() {
setDragState({ ...galleryDnd.getDragState() });
frame = requestAnimationFrame(update);
}
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
}, []);
if (!dragState.isDragging || !dragState.item) return null;
const { currX, currY, item } = dragState;
const size = 120;
const style: React.CSSProperties = {
left: currX + 1,
top: currY - size / 2,
};
return ReactDOM.createPortal(
<div
style={style}
className="fixed z-[1000] w-[120px] h-[120px] pointer-events-none aspect-square flex flex-col bg-black/70 rounded-xl overflow-hidden shadow-lg cursor-grabbing"
>
<img
src={item.thumbnail || item.fullImage || ""}
alt={item.label}
className="pointer-events-none select-none w-full h-full object-cover rounded-xl"
/>
</div>,
document.body
);
};
@@ -0,0 +1,198 @@
import React, { useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import galleryDnd from "./galleryDnd";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryItem } from "./gallery-modal";
type ModalMode = "select" | "view";
interface GalleryDraggableItemProps {
item: GalleryItem;
mode: ModalMode;
activeFilter: string;
selected: boolean;
onClick: () => void;
onImageError: () => void;
disableTooltipAndBadge?: boolean;
imageFit?: "cover" | "contain";
}
export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
item,
mode,
activeFilter,
selected,
onClick,
onImageError,
disableTooltipAndBadge = false,
imageFit = "cover",
}) => {
const imgRef = useRef<HTMLImageElement>(null);
const dragStarted = useRef(false);
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
// Disable dragging for video items
if (item.mediaClass === "video") return;
dragStarted.current = false;
const moveListener = (moveEvent: PointerEvent) => {
const dx = moveEvent.pageX - event.pageX;
const dy = moveEvent.pageY - event.pageY;
if (!dragStarted.current && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
dragStarted.current = true;
if (galleryDnd && !disableTooltipAndBadge) {
galleryDnd.onPointerDown(event, item);
}
window.removeEventListener("pointermove", moveListener);
}
};
window.addEventListener("pointermove", moveListener);
const upListener = () => {
window.removeEventListener("pointermove", moveListener);
window.removeEventListener("pointerup", upListener);
if (!dragStarted.current) {
onClick();
}
};
window.addEventListener("pointerup", upListener);
};
const handlePointerUp = (event: React.PointerEvent) => {
if (!dragStarted.current && !disableTooltipAndBadge) {
onClick();
}
};
const handleButtonClick = (event: React.MouseEvent) => {
if (!disableTooltipAndBadge) {
onClick();
}
};
return (
<div
className={twMerge(
"group relative w-full",
activeFilter === "video" ? "aspect-square" : "aspect-square"
)}
>
{/* Media class badge on hover */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute right-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">
{item.mediaClass}
</div>
)}
{/* Tooltip on hover */}
{disableTooltipAndBadge ? (
<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",
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
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 right-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>
) : (
<Tooltip
position="bottom"
delay={200}
className="-mt-3 bg-black/90"
content={
<div className="flex flex-col items-center text-xs whitespace-nowrap">
{item.mediaClass !== "video" && (
<span>
<span className="font-bold">Drag</span>
<span className="opacity-50"> to add</span>
</span>
)}
<span>
<span className="font-bold">Click</span>
<span className="opacity-50"> to view</span>
</span>
</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",
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
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 right-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>
</Tooltip>
)}
</div>
);
};
@@ -1,8 +1,5 @@
import { Modal } from "@storyteller/ui-modal";
import { LightboxModal } from "@storyteller/ui-lightbox-modal";
import { TabSelector } from "@storyteller/ui-tab-selector";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { CloseButton } from "@storyteller/ui-close-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
@@ -13,6 +10,25 @@ import {
UsersApi,
} from "@storyteller/api";
import { twMerge } from "tailwind-merge";
import { GalleryDraggableItem } from "./GalleryDraggableItem";
import { useSignals } from "@preact/signals-react/runtime";
import {
galleryModalVisibleDuringDrag,
galleryReopenAfterDragSignal,
} from "./galleryModalSignals";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faFilter,
faBorderAll,
faImage,
faVideo,
faCube,
faUpload,
faExpand,
faCompress,
} from "@fortawesome/pro-solid-svg-icons";
import { PopoverMenu } from "@storyteller/ui-popover";
import { SliderV2 } from "@storyteller/ui-sliderv2";
export interface GalleryItem {
id: string;
@@ -20,6 +36,7 @@ export interface GalleryItem {
thumbnail: string | null;
fullImage?: string | null;
createdAt: string;
mediaClass?: string;
}
interface GroupedItems {
@@ -36,9 +53,6 @@ interface GalleryModalProps {
onSelectItem?: (id: string) => void;
maxSelections?: number;
onUseSelected?: (selectedItems: GalleryItem[]) => void;
tabs: { id: string; label: string }[];
activeTab: string;
onTabChange: (tabId: string) => void;
onDownloadClicked?: (url: string) => Promise<void>;
onAddToSceneClicked?: (
url: string,
@@ -55,13 +69,9 @@ export const GalleryModal = React.memo(
onSelectItem,
maxSelections = 4,
onUseSelected,
tabs,
activeTab,
onTabChange,
onDownloadClicked,
onAddToSceneClicked,
}: GalleryModalProps) => {
const [groupedItems, setGroupedItems] = useState<GroupedItems>({});
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<GalleryItem | null>(
null
@@ -69,6 +79,20 @@ export const GalleryModal = React.memo(
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(new Set());
const [username, setUsername] = useState<string>("");
const [activeFilter, setActiveFilter] = useState("all");
const minColumns = 3;
const maxColumns = 12;
// Default gridColumns to 5
const defaultGridColumns = 5;
const [sliderValue, setSliderValue] = useState(
maxColumns - (defaultGridColumns - minColumns)
);
const gridColumns = maxColumns - (sliderValue - minColumns);
const [imageFit, setImageFit] = useState<"cover" | "contain">("contain");
const [allItems, setAllItems] = useState<GalleryItem[]>([]);
const [pageIndex, setPageIndex] = useState(0);
const [hasMore, setHasMore] = useState(true);
const pageSize = 25;
const imageUrl = lightboxImage?.fullImage || "";
@@ -126,67 +150,92 @@ export const GalleryModal = React.memo(
}
}, [usersApi, isOpen]);
// filter state
const FILTERS = [
{ id: "all", label: "All", icon: <FontAwesomeIcon icon={faBorderAll} /> },
{ id: "image", label: "Image", icon: <FontAwesomeIcon icon={faImage} /> },
{ id: "video", label: "Video", icon: <FontAwesomeIcon icon={faVideo} /> },
{ id: "3d", label: "3D Object", icon: <FontAwesomeIcon icon={faCube} /> },
{
id: "uploaded",
label: "Uploaded",
icon: <FontAwesomeIcon icon={faUpload} />,
},
];
// Map filter to API/media class
const getFilterMediaClass = (filter: string) => {
switch (filter) {
case "image":
return [FilterMediaClasses.IMAGE];
case "video":
return [FilterMediaClasses.VIDEO];
case "uploaded":
return [FilterMediaClasses.IMAGE, FilterMediaClasses.VIDEO];
default:
return undefined;
}
};
useEffect(() => {
const loadItems = async () => {
if (!username) return;
setLoading(true);
try {
let response = null;
if (activeTab === "videos") {
response = await api.listUserMediaFiles({
filter_media_classes: [FilterMediaClasses.VIDEO],
username: username,
include_user_uploads: true,
user_uploads_only: true,
});
} else {
response = await api.listUserMediaFiles({
filter_media_classes: [FilterMediaClasses.IMAGE],
username: username,
include_user_uploads: activeTab === "uploads",
user_uploads_only: activeTab === "uploads",
});
}
if (response.success && response.data) {
// Print the JSON response for debugging
console.log("Media files response:", response.data);
const thumbnail_size = 250;
const newItems = response.data.map((item: any) => ({
id: item.token,
label: item.maybe_title || "Image Generation",
thumbnail:
activeTab === "videos"
? item.media_links.maybe_video_previews.still
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
),
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
}));
console.log("Media files response:", newItems);
setGroupedItems(groupItemsByDate(newItems));
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
};
if (isOpen) {
loadItems();
setAllItems([]);
setPageIndex(0);
setHasMore(true);
if (username) {
loadItems(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, isOpen, username]);
}, [activeFilter, username]);
// useEffect(() => {
// console.log("LibraryModal render caused by:", {
// isOpen,
// activeTab,
// selectedItemIds,
// });
// }, [isOpen, activeTab, selectedItemIds]);
const loadItems = async (reset = false) => {
if (!username) return;
setLoading(true);
try {
let response = null;
const filterMediaClasses = getFilterMediaClass(activeFilter);
const query = {
filter_media_classes: filterMediaClasses,
username: username,
include_user_uploads:
activeFilter === "uploaded" || activeFilter === "all",
user_uploads_only: activeFilter === "uploaded",
page_index: reset ? 0 : pageIndex,
page_size: pageSize,
};
response = await api.listUserMediaFiles(query);
if (response.success && response.data) {
const thumbnail_size = 250;
const newItems = response.data.map((item: any) => ({
id: item.token,
label: item.maybe_title || "Image Generation",
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.still
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
),
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
item.media_class ||
(item.filter_media_classes
? item.filter_media_classes[0]
: "image"),
}));
setAllItems((prev) => (reset ? newItems : [...prev, ...newItems]));
// Pagination logic
const current = response.pagination?.current ?? 0;
const total = response.pagination?.total_page_count ?? 1;
setPageIndex(current + 1);
setHasMore(current + 1 < total);
}
} catch (error) {
console.error("Failed to fetch library items:", error);
}
setLoading(false);
};
const handleItemClick = useCallback(
(item: GalleryItem) => {
@@ -211,16 +260,83 @@ export const GalleryModal = React.memo(
}, [selectedItemIds, onSelectItem]);
const handleUseSelected = useCallback(() => {
const selectedItems = Object.values(groupedItems)
const selectedItems = Object.values(groupItemsByDate(allItems))
.flat()
.filter((item) => selectedItemIds.includes(item.id));
onUseSelected?.(selectedItems);
}, [groupedItems, selectedItemIds, onUseSelected]);
}, [groupItemsByDate, selectedItemIds, onUseSelected]);
useSignals();
// Compute gap class based on gridColumns
let gapClass = "gap-0.5";
if (gridColumns <= 4) gapClass = "gap-1.5";
else if (gridColumns <= 7) gapClass = "gap-1";
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (
scrollHeight - scrollTop - clientHeight < 100 &&
hasMore &&
!loading
) {
loadItems();
}
};
// Soft refresh on open: fetch first page and prepend new items
useEffect(() => {
if (isOpen && allItems.length > 0 && username) {
const fetchLatest = async () => {
const filterMediaClasses = getFilterMediaClass(activeFilter);
const response = await api.listUserMediaFiles({
filter_media_classes: filterMediaClasses,
username,
include_user_uploads: activeFilter === "uploaded",
user_uploads_only: activeFilter === "uploaded",
page_index: 0,
page_size: pageSize,
});
if (response.success && response.data) {
const thumbnail_size = 250;
const newItems = response.data.map((item: any) => ({
id: item.token,
label: item.maybe_title || "Image Generation",
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.still
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
),
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
mediaClass:
item.media_class ||
(item.filter_media_classes
? item.filter_media_classes[0]
: "image"),
}));
setAllItems((prev) => {
const existingIds = new Set(prev.map((i) => i.id));
const trulyNew = newItems.filter((i) => !existingIds.has(i.id));
return [...trulyNew, ...prev];
});
}
};
fetchLatest();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return (
<>
<Modal
isOpen={isOpen}
isOpen={
mode === "view"
? isOpen && galleryModalVisibleDuringDrag.value
: isOpen
}
onClose={onClose}
className={twMerge(
"h-[620px] max-w-4xl",
@@ -228,37 +344,109 @@ export const GalleryModal = React.memo(
)}
childPadding={false}
showClose={false}
backdropClassName="bg-transparent"
draggable={true}
allowBackgroundInteraction={true}
draggable={mode === "view"}
allowBackgroundInteraction={mode === "view" ? true : false}
closeOnOutsideClick={false}
>
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-[50] h-[60px] w-full cursor-move" />
</Modal.DragHandle>
{mode === "view" && (
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-[50] h-[60px] w-full cursor-move" />
</Modal.DragHandle>
)}
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4 py-3">
<div className="grid grid-cols-3 items-center">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "Gallery"}
</h2>
<div className="flex items-center justify-center">
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
className="w-auto relative z-[51]"
/>
<div className="grid grid-cols-2 items-center">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Library"}
</h2>
{mode === "view" && (
<div className="flex items-center relative z-[51]">
<input
type="checkbox"
id="gallery-reopen-after-drag"
checked={galleryReopenAfterDragSignal.value}
onChange={(e) =>
(galleryReopenAfterDragSignal.value =
e.target.checked)
}
className="h-4 w-4 cursor-pointer rounded-lg border-gray-300 bg-gray-700 text-primary focus:ring-primary"
/>
<label
htmlFor="gallery-reopen-after-drag"
className="ml-2 cursor-pointer select-none text-sm text-white/70"
>
Reopen after adding
</label>
</div>
)}
</div>
<div className="flex justify-end gap-1.5">
<Modal.ExpandButton />
<div className="flex justify-end gap-2 items-center">
{/* Image fit toggle button */}
<Button
variant="action"
onClick={() =>
setImageFit((fit) =>
fit === "cover" ? "contain" : "cover"
)
}
className="relative z-[51] h-9 w-9 mr-3 bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90"
>
<FontAwesomeIcon
icon={imageFit === "cover" ? faExpand : faCompress}
className="text-lg text-white"
/>
</Button>
{/* Slider */}
<div className="w-48 mr-3 relative z-[51] flex items-center gap-2">
<SliderV2
min={minColumns}
max={maxColumns}
value={sliderValue}
onChange={setSliderValue}
step={1}
variant="classic"
showTooltip={false}
className="w-full"
showProgressBar={false}
tooltipContent={`${
maxColumns - (sliderValue - minColumns)
} columns`}
/>
</div>
{/* Filter popover */}
<PopoverMenu
panelTitle="Filter"
position="bottom"
align="end"
buttonClassName="relative z-[51] mr-3"
panelClassName="min-w-36"
items={FILTERS.map((f) => ({
label: f.label,
selected: activeFilter === f.id,
icon: f.icon,
}))}
onSelect={(item) => {
const filter = FILTERS.find(
(f) => f.label === item.label
);
if (filter) setActiveFilter(filter.id);
}}
triggerIcon={<FontAwesomeIcon icon={faFilter} />}
triggerLabel={
FILTERS.find((f) => f.id === activeFilter)?.label
}
mode="toggle"
showIconsInList={true}
/>
{mode === "view" && <Modal.ExpandButton />}
<CloseButton onClick={onClose} className="relative z-[51]" />
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex-1 overflow-y-auto" onScroll={handleScroll}>
{loading && allItems.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
@@ -266,78 +454,57 @@ export const GalleryModal = React.memo(
</div>
) : (
<div className="space-y-6 p-4">
{Object.entries(groupedItems).map(([date, dateItems]) => (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge(
activeTab === "videos"
? "grid grid-cols-3 gap-2"
: "grid grid-cols-5 gap-1",
mode === "view" &&
(activeTab === "videos"
? "grid-cols-3"
: "grid-cols-5")
)}
>
{dateItems.map((item) => (
<button
key={item.id}
className={twMerge(
"group relative overflow-hidden rounded-md border-[3px] transition-all",
activeTab === "videos"
? "aspect-video"
: "aspect-square",
mode === "select" &&
selectedItemIds.includes(item.id)
? "border-primary"
: "border-transparent hover:border-white"
)}
onClick={() => handleItemClick(item)}
{Object.entries(groupItemsByDate(allItems)).map(
([date, dateItems]) => {
const filteredItems = dateItems.filter((item) => {
if (activeFilter === "3d")
return item.mediaClass === "3d";
if (activeFilter === "all")
return item.mediaClass !== "audio";
return true;
});
if (filteredItems.length === 0) return null;
return (
<div key={date}>
<h3 className="text-md mb-2 font-medium text-white/60">
{date}
</h3>
<div
className={twMerge("grid", gapClass)}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
>
<div className="relative h-full w-full">
{!item.thumbnail ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">
Image not available
</span>
</div>
) : (
<img
src={item.thumbnail || item.fullImage || ""}
alt={item.label}
className={twMerge(
"h-full w-full bg-black/30",
activeTab === "videos"
? "object-contain"
: "object-cover"
)}
onError={() =>
handleImageError(item.thumbnail!)
}
/>
)}
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute inset-0 bg-white/30" />
)}
</div>
{mode === "select" &&
selectedItemIds.includes(item.id) && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon
icon={faCheck}
className="text-sm"
/>
</div>
)}
</button>
))}
</div>
{filteredItems.map((item) => (
<GalleryDraggableItem
key={item.id}
item={item}
mode={mode}
activeFilter={activeFilter}
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={() =>
handleImageError(item.thumbnail!)
}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
/>
))}
</div>
</div>
);
}
)}
{loading && allItems.length > 0 && (
<div className="flex justify-center py-4">
<LoadingSpinner className="h-8 w-8" />
</div>
))}
)}
{!hasMore && allItems.length > 0 && (
<div className="flex justify-center py-4 text-white/40 text-xs">
No more items
</div>
)}
</div>
)}
</div>
@@ -384,7 +551,7 @@ export const GalleryModal = React.memo(
mediaId={lightboxImage?.id}
onDownloadClicked={onDownloadClicked}
onAddToSceneClicked={onAddToSceneClicked}
activeTab={activeTab}
mediaClass={lightboxImage?.mediaClass}
/>
</>
);
@@ -0,0 +1,89 @@
import { GalleryItem } from "./gallery-modal";
import {
galleryModalVisibleDuringDrag,
galleryReopenAfterDragSignal,
} from "./galleryModalSignals";
interface DragState {
item: GalleryItem | null;
isDragging: boolean;
startX: number;
startY: number;
currX: number;
currY: number;
}
const dragState: DragState = {
item: null,
isDragging: false,
startX: 0,
startY: 0,
currX: 0,
currY: 0,
};
const dragThreshold = 5;
export let onImageDrop:
| ((item: GalleryItem, position: { x: number; y: number }) => void)
| null = null;
export function setOnImageDrop(cb: typeof onImageDrop) {
onImageDrop = cb;
}
function onPointerDown(event: React.PointerEvent, item: GalleryItem) {
if (event.button !== 0) return;
dragState.item = item;
dragState.startX = event.pageX;
dragState.startY = event.pageY;
dragState.currX = event.pageX;
dragState.currY = event.pageY;
dragState.isDragging = false;
galleryModalVisibleDuringDrag.value = false;
document.body.style.cursor = "grabbing";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}
function onPointerMove(event: PointerEvent) {
if (!dragState.item) return;
const deltaX = event.pageX - dragState.startX;
const deltaY = event.pageY - dragState.startY;
if (
!dragState.isDragging &&
(Math.abs(deltaX) > dragThreshold || Math.abs(deltaY) > dragThreshold)
) {
dragState.isDragging = true;
}
dragState.currX = event.pageX;
dragState.currY = event.pageY;
}
function onPointerUp(event: PointerEvent) {
if (
dragState.item &&
dragState.isDragging &&
dragState.item.mediaClass === "image"
) {
if (onImageDrop) {
onImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
}
}
dragState.item = null;
dragState.isDragging = false;
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
document.body.style.cursor = "";
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
}
function getDragState() {
return dragState;
}
const galleryDnd = {
onPointerDown,
getDragState,
};
export default galleryDnd;
@@ -0,0 +1,4 @@
import { signal } from "@preact/signals-react";
export const galleryModalVisibleDuringDrag = signal(false);
export const galleryReopenAfterDragSignal = signal(false);
@@ -26,7 +26,7 @@ interface LightboxModalProps {
url: string,
media_id: string | undefined
) => Promise<void>;
activeTab?: string;
mediaClass?: string;
}
export function LightboxModal({
@@ -43,7 +43,7 @@ export function LightboxModal({
mediaId, // media id of the image
onDownloadClicked,
onAddToSceneClicked,
activeTab,
mediaClass,
}: LightboxModalProps) {
return createPortal(
<Transition appear show={isOpen}>
@@ -94,7 +94,7 @@ export function LightboxModal({
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
) : activeTab === "videos" ? (
) : mediaClass === "video" ? (
<video controls className="h-full w-full object-contain">
<source src={imageUrl} type="video/mp4" />
Your browser does not support the video tag.
@@ -8,7 +8,11 @@ import {
import { twMerge } from "tailwind-merge";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronUp, faCircleCheck } from "@fortawesome/pro-solid-svg-icons";
import {
faCheck,
faChevronUp,
faCircleCheck,
} from "@fortawesome/pro-solid-svg-icons";
export interface PopoverItem {
label: string;
@@ -363,14 +367,35 @@ export const PopoverMenu = ({
>
<div className="flex items-center gap-2 truncate">
{showIconsInList && item.icon}
<span className="truncate">{item.label}</span>
{mode === "toggle" ? (
<span
className={twMerge(
"truncate",
item.selected ? "text-white" : "text-white/70"
)}
>
{item.label}
</span>
) : (
<span className="truncate">{item.label}</span>
)}
</div>
{mode === "toggle" && (
<input
type="radio"
checked={item.selected}
readOnly
/>
<span
className={twMerge(
"ml-2 h-5 w-5 rounded-full border flex items-center justify-center transition-colors",
item.selected
? "border-primary bg-primary"
: "border-transparent bg-transparent"
)}
>
{item.selected && (
<FontAwesomeIcon
icon={faCheck}
className="text-white text-xs font-bold"
/>
)}
</span>
)}
</Button>
))}
@@ -86,7 +86,6 @@ export const PromptBox2D = ({
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[]
);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
@@ -630,12 +629,6 @@ export const PromptBox2D = ({
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
tabs={[
{ id: "my-media", label: "Generations" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
onDownloadClicked={downloadFileFromUrl}
/>
</>
@@ -142,7 +142,6 @@ export const PromptBox3D = ({
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[]
);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
useEffect(() => {
if (textareaRef.current) {
@@ -819,12 +818,6 @@ export const PromptBox3D = ({
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleGalleryImages}
tabs={[
{ id: "my-media", label: "Generations" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
onDownloadClicked={downloadFileFromUrl}
/>
</>
@@ -15,14 +15,9 @@ import {
faSpinnerThird,
faTimes,
faPlus,
faUpload,
faImages,
} from "@fortawesome/pro-solid-svg-icons";
import {
faRectangleVertical,
faSquare,
faRectangle,
} from "@fortawesome/pro-regular-svg-icons";
import { faRectangle } from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IsDesktopApp } from "@storyteller/tauri-utils";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
@@ -82,7 +77,6 @@ export const PromptBoxVideo = ({
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[]
);
const [activeGalleryTab, setActiveGalleryTab] = useState("my-media");
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
const [uploadingImages, setUploadingImages] = useState<
{ id: string; file: File }[]
@@ -196,7 +190,6 @@ export const PromptBoxVideo = ({
};
const handleEnqueue = async () => {
setIsEnqueueing(true);
console.log("generateResponse", generateResponse);
@@ -423,14 +416,8 @@ export const PromptBoxVideo = ({
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
maxSelections={1}
onUseSelected={handleGalleryImages}
tabs={[
{ id: "my-media", label: "Generations" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeGalleryTab}
onTabChange={setActiveGalleryTab}
onDownloadClicked={downloadFileFromUrl}
/>
</>
@@ -17,6 +17,8 @@ interface SliderProps {
innerLabel?: string;
showDecrement?: boolean;
showIncrement?: boolean;
variant?: "filled" | "classic";
showProgressBar?: boolean;
}
export const SliderV2 = ({
@@ -32,6 +34,8 @@ export const SliderV2 = ({
innerLabel,
showDecrement,
showIncrement,
variant = "filled",
showProgressBar = true,
}: SliderProps) => {
const [localValue, setLocalValue] = useState(value);
const sliderRef = useRef<HTMLDivElement>(null);
@@ -114,43 +118,72 @@ export const SliderV2 = ({
/>
)}
<div
ref={sliderRef}
className={twMerge(
"glass border-ui-border group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border",
isDragging && "!bg-ui-controls/90",
className,
)}
onMouseDown={handleMouseDown}
>
{variant === "filled" ? (
<div
ref={sliderRef}
className={twMerge(
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
isDragging && "!bg-brand-primary/50",
"glass border-ui-border group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border",
isDragging && "!bg-ui-controls/90",
className
)}
style={{ width: `${percentage}%` }}
onMouseDown={handleMouseDown}
>
{innerLabel && (
<span
<div
className={twMerge(
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
isDragging && "!bg-brand-primary/50"
)}
style={{ width: `${percentage}%` }}
>
{innerLabel && (
<span
className={twMerge(
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
isDragging && "!text-white"
)}
>
{innerLabel}
</span>
)}
<div
className={twMerge(
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
isDragging && "!text-white",
"absolute right-0 top-1/2 mr-1 h-4 w-0.5 -translate-y-1/2 rounded-full",
isDragging ? "bg-white" : "bg-white/50"
)}
>
{innerLabel}
</span>
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
</div>
</div>
) : (
<div
ref={sliderRef}
className={twMerge(
"relative h-4 w-full cursor-pointer flex items-center",
className
)}
onMouseDown={handleMouseDown}
>
<div className="absolute left-0 right-0 h-2 bg-ui-border rounded-full bg-white/15" />
{showProgressBar && (
<div
className="absolute left-0 h-2 bg-white rounded-full transition-all duration-200"
style={{ width: `${percentage}%` }}
/>
)}
<div
className={twMerge(
"absolute right-0 top-1/2 mr-1 h-4 w-0.5 -translate-y-1/2 rounded-full",
isDragging ? "bg-white" : "bg-white/50",
"absolute top-1/2 z-10 size-4 -translate-y-1/2 rounded-full border-2 border-white bg-white shadow transition-colors duration-200 hover:ring-[4px] hover:ring-white/20",
isDragging ? "ring-[4px] ring-white/20" : ""
)}
style={{ left: `calc(${percentage}% - 10px)` }}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
</div>
</div>
)}
{showIncrement && (
<Button