mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be80e57f8 | ||
|
|
5daf7d51c0 | ||
|
|
a839bd88f9 | ||
|
|
fdbe9e264b | ||
|
|
1c124c30c6 | ||
|
|
ff75ebc92f | ||
|
|
6d98604cc2 | ||
|
|
fd69376d6a | ||
|
|
5fe974b11e | ||
|
|
62c319317c | ||
|
|
4861e5f682 | ||
|
|
d159501799 | ||
|
|
969214710b |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user