diff --git a/frontend/editor3d/app/api/BucketConfig.ts b/frontend/editor3d/app/api/BucketConfig.ts index 4c9df68507..be946fd718 100644 --- a/frontend/editor3d/app/api/BucketConfig.ts +++ b/frontend/editor3d/app/api/BucketConfig.ts @@ -22,7 +22,7 @@ class BucketConfig { //const media_api_base_url = environmentVariables.values.GOOGLE_API; //return `${media_api_base_url}/${bucket}${path}`; const media_api_base_url = GetCdnOrigin(); - return `${media_api_base_url}/${path}`; + return `${media_api_base_url}${path}`; } //private getBucket(): string { diff --git a/frontend/editor3d/app/components/reusable/TabSelector.tsx b/frontend/editor3d/app/components/reusable/TabSelector.tsx new file mode 100644 index 0000000000..de3305d2c3 --- /dev/null +++ b/frontend/editor3d/app/components/reusable/TabSelector.tsx @@ -0,0 +1,109 @@ +import React, { useRef, useEffect, useState } from "react"; +import { Tab } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; +import { Tooltip } from "~/components"; + +export interface TabItem { + id: string; + label: string; +} + +interface TabSelectorProps { + tabs: TabItem[]; + activeTab: string; + onTabChange: (tabId: string) => void; + className?: string; + disabled?: boolean; + disabledMessage?: string; +} + +export const TabSelector: React.FC = ({ + tabs, + activeTab, + onTabChange, + className, + disabled, + disabledMessage, +}) => { + // Find the index of the active tab + const selectedIndex = tabs.findIndex((tab) => tab.id === activeTab); + const tabsRef = useRef<(HTMLElement | null)[]>([]); + const [indicatorStyle, setIndicatorStyle] = useState({ + left: 0, + width: 0, + }); + + // Handle tab change + const handleTabChange = (index: number) => { + onTabChange(tabs[index].id); + }; + + // Update the indicator position when the selected tab changes + useEffect(() => { + if (selectedIndex >= 0 && tabsRef.current[selectedIndex]) { + const currentTab = tabsRef.current[selectedIndex]; + if (currentTab) { + setIndicatorStyle({ + left: currentTab.offsetLeft, + width: currentTab.offsetWidth, + }); + } + } + }, [selectedIndex, tabs]); + + const TabGroupElement = ( +
+ + + {/* Animated indicator */} +
+ + {tabs.map((tab, index) => ( + (tabsRef.current[index] = el)} + disabled={disabled} + className={({ selected }) => + twMerge( + "relative z-20 mx-0.5 min-w-max rounded-md border-2 border-transparent px-4 py-0.5 text-center text-sm font-semibold transition-all duration-200 ease-in-out", + selected ? "text-white" : "text-gray-300 hover:text-white", + disabled ? "cursor-not-allowed opacity-60" : "", + ) + } + > + {tab.label} + + ))} + + +
+ ); + + return ( + <> + {disabled && ( + + {TabGroupElement} + + )} + {!disabled && TabGroupElement} + + ); +}; diff --git a/frontend/editor3d/app/components/signaled/TopBar/TopBar.tsx b/frontend/editor3d/app/components/signaled/TopBar/TopBar.tsx index 016984f03a..050870048e 100644 --- a/frontend/editor3d/app/components/signaled/TopBar/TopBar.tsx +++ b/frontend/editor3d/app/components/signaled/TopBar/TopBar.tsx @@ -5,6 +5,8 @@ import { AuthButtons } from "./AuthButtons"; import { SceneTitleInput } from "./SceneTitleInput"; import { getCurrentLocationWithoutParams } from "~/utilities"; import { Activity } from "~/pages/PageEnigma/comps/GenerateModals/Activity"; +import { LibraryModal } from "~/pages/PageEnigma/comps/LibraryModal/LibraryModal"; +import { useState } from "react"; function isEditorPath(path: string) { if (path === "/") return true; @@ -20,45 +22,65 @@ export const TopBar = ({ pageName }: Props) => { useLocation().pathname, useParams(), ); + const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false); + const [activeLibraryTab, setActiveLibraryTab] = useState("my-media"); return ( -
- +
+ + setIsLibraryModalOpen(false)} + mode="view" + tabs={[ + { id: "my-media", label: "My media" }, + { id: "uploads", label: "Uploads" }, + ]} + activeTab={activeLibraryTab} + onTabChange={setActiveLibraryTab} + /> + ); }; diff --git a/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/LibraryModal.tsx b/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/LibraryModal.tsx new file mode 100644 index 0000000000..7a379438fa --- /dev/null +++ b/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/LibraryModal.tsx @@ -0,0 +1,319 @@ +import { TransitionDialogue } from "~/components/reusable/TransitionDialogue"; +import { faCheck, faXmark } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Button, LoadingSpinner } from "~/components"; +import { TabSelector } from "~/components/reusable/TabSelector"; +import { useState, useEffect } from "react"; +import { LibraryModalApi } from "./LibraryModalApi"; +import { FilterMediaClasses } from "../../../../enums"; +import { BucketConfig } from "../../../../api/BucketConfig"; +import { twMerge } from "tailwind-merge"; + +export interface LibraryItem { + id: string; + label: string; + thumbnail: string | null; + fullImage?: string | null; + createdAt: string; +} + +interface GroupedItems { + [date: string]: LibraryItem[]; +} + +type ModalMode = "select" | "view"; + +interface LibraryModalProps { + isOpen: boolean; + onClose: () => void; + mode: ModalMode; + selectedItemIds?: string[]; + onSelectItem?: (id: string) => void; + maxSelections?: number; + onUseSelected?: (selectedItems: LibraryItem[]) => void; + tabs: { id: string; label: string }[]; + activeTab: string; + onTabChange: (tabId: string) => void; +} + +export const LibraryModal = ({ + isOpen, + onClose, + mode = "view", + selectedItemIds = [], + onSelectItem, + maxSelections = 4, + onUseSelected, + tabs, + activeTab, + onTabChange, +}: LibraryModalProps) => { + const [groupedItems, setGroupedItems] = useState({}); + const [loading, setLoading] = useState(false); + const [lightboxImage, setLightboxImage] = useState(null); + const [failedImageUrls] = useState>(new Set()); + const bucketConfig = new BucketConfig(); + const api = new LibraryModalApi(); + + const formatDate = (date: string) => { + const d = new Date(date); + return d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + }; + + const groupItemsByDate = (items: LibraryItem[]) => { + const grouped = items.reduce((acc: GroupedItems, item) => { + const dateKey = formatDate(item.createdAt); + if (!acc[dateKey]) { + acc[dateKey] = []; + } + acc[dateKey].push(item); + return acc; + }, {}); + + // Sort dates in descending order + return Object.fromEntries( + Object.entries(grouped).sort( + (a, b) => + new Date(b[1][0].createdAt).getTime() - + new Date(a[1][0].createdAt).getTime(), + ), + ); + }; + + const handleImageError = (url: string) => { + failedImageUrls.add(url); + }; + + const getImageUrl = (path: string | undefined | null) => { + if (!path) return null; + const url = bucketConfig.getGcsUrl(path); + return failedImageUrls.has(url) ? null : url; + }; + + useEffect(() => { + const loadItems = async () => { + setLoading(true); + try { + const response = await api.listUserMediaFiles({ + filter_media_classes: [FilterMediaClasses.IMAGE], + }); + if (response.success && response.data) { + const newItems = response.data.map((item) => ({ + id: item.token, + label: item.maybe_title || "Untitled", + thumbnail: getImageUrl( + item.cover_image.maybe_cover_image_public_bucket_path || + item.public_bucket_path, + ), + fullImage: getImageUrl(item.public_bucket_path), + createdAt: item.created_at, + })); + setGroupedItems(groupItemsByDate(newItems)); + } + } catch (error) { + console.error("Failed to fetch library items:", error); + } + setLoading(false); + }; + + if (isOpen) { + loadItems(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, isOpen]); + + const handleItemClick = (item: LibraryItem) => { + if (mode === "select" && onSelectItem) { + onSelectItem(item.id); + } else { + setLightboxImage(item); + } + }; + + return ( + <> + +
+
+
+

+ {mode === "select" ? "Select Images" : "My Library"} +

+
+ +
+
+ {mode === "select" && ( + + Select up to {maxSelections} images + + )} +
+
+
+ +
+ {loading ? ( +
+
+ +
+
+ ) : ( +
+ {Object.entries(groupedItems).map(([date, dateItems]) => ( +
+

+ {date} +

+
+ {dateItems.map((item) => ( + + ))} +
+
+ ))} +
+ )} +
+ + {mode === "select" && ( +
+
+
+ {selectedItemIds.length}/{maxSelections} selected +
+ {selectedItemIds.length > 0 && ( + | + )} + {selectedItemIds.length > 0 && ( + + )} +
+ +
+ )} +
+
+ + {/* Lightbox Modal */} + setLightboxImage(null)} + className="max-h-[90vh] max-w-[90vw]" + childPadding={false} + > + {lightboxImage && ( +
+ + {!lightboxImage.fullImage ? ( +
+ Image not available +
+ ) : ( + {lightboxImage.label} handleImageError(lightboxImage.fullImage!)} + crossOrigin="anonymous" + referrerPolicy="no-referrer" + /> + )} +
+ )} +
+ + ); +}; diff --git a/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/LibraryModalApi.ts b/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/LibraryModalApi.ts new file mode 100644 index 0000000000..da367b90e0 --- /dev/null +++ b/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/LibraryModalApi.ts @@ -0,0 +1,30 @@ +import { MediaFilesApi } from "../../../../Classes/ApiManager/MediaFilesApi"; +import { + FilterMediaClasses, + FilterMediaType, + FilterEngineCategories, +} from "../../../../enums"; + +interface ListUserMediaQuery { + include_user_uploads?: boolean; + filter_media_classes?: FilterMediaClasses[]; + filter_media_type?: FilterMediaType[]; + filter_engine_categories?: FilterEngineCategories[]; + page?: number; + limit?: number; +} + +export class LibraryModalApi extends MediaFilesApi { + constructor() { + super(); + } + + public async listUserMediaFiles(query: ListUserMediaQuery) { + return await this.ListUserMediaFiles({ + ...query, + filter_media_classes: query.filter_media_classes, + filter_media_type: query.filter_media_type, + filter_engine_categories: query.filter_engine_categories, + }); + } +} diff --git a/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/index.tsx b/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/index.tsx new file mode 100644 index 0000000000..a775fcbc29 --- /dev/null +++ b/frontend/editor3d/app/pages/PageEnigma/comps/LibraryModal/index.tsx @@ -0,0 +1 @@ +export * from "./LibraryModal"; diff --git a/frontend/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx b/frontend/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx index fb57e61982..9be7517172 100644 --- a/frontend/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx +++ b/frontend/editor3d/app/pages/PageEnigma/comps/PromptBox/PromptBox.tsx @@ -38,6 +38,9 @@ import { import { QueueNames } from "~/pages/PageEnigma/Queue/QueueNames"; import Queue from "~/pages/PageEnigma/Queue/Queue"; import { toEngineActions } from "~/pages/PageEnigma/Queue/toEngineActions"; +import { LibraryModal } from "../LibraryModal/LibraryModal"; +import { BucketConfig } from "../../../../api/BucketConfig"; +import type { LibraryItem } from "../LibraryModal/LibraryModal"; interface ReferenceImage { id: string; @@ -107,6 +110,11 @@ export const PromptBox = () => { const fileInputRef = useRef(null); const textareaRef = useRef(null); const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false); + const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false); + const [selectedLibraryImages, setSelectedLibraryImages] = useState( + [], + ); + const [activeLibraryTab, setActiveLibraryTab] = useState("my-media"); useEffect(() => { if (textareaRef.current) { @@ -267,7 +275,39 @@ export const PromptBox = () => { }; const handleLibrarySelect = () => { - console.log("Opening library selector"); + setIsLibraryModalOpen(true); + }; + + const handleLibraryClose = () => { + setIsLibraryModalOpen(false); + setSelectedLibraryImages([]); + }; + + const handleImageSelect = (id: string) => { + setSelectedLibraryImages((prev) => { + if (prev.includes(id)) { + return prev.filter((imageId) => imageId !== id); + } + if (prev.length >= 4) { + return prev; + } + return [...prev, id]; + }); + }; + + const handleLibraryImages = (selectedItems: LibraryItem[]) => { + selectedItems.forEach((item) => { + if (!item.fullImage) return; + const referenceImage: ReferenceImage = { + id: Math.random().toString(36).substring(7), + url: item.fullImage, + file: new File([], "library-image"), + mediaToken: item.id, + }; + setReferenceImages((prev) => [...prev, referenceImage]); + }); + setIsLibraryModalOpen(false); + setSelectedLibraryImages([]); }; const handleAction = (action: string) => { @@ -402,218 +442,238 @@ export const PromptBox = () => { }; return ( -
- {(referenceImages.length > 0 || uploadingImages.length > 0) && ( -
- {referenceImages.map((image) => ( -
- Reference - -
- ))} - {uploadingImages.map(({ id, file }) => { - const previewUrl = URL.createObjectURL(file); - return ( + <> +
+ {(referenceImages.length > 0 || uploadingImages.length > 0) && ( +
+ {referenceImages.map((image) => (
-
- Uploading preview + Reference + +
+ ))} + {uploadingImages.map(({ id, file }) => { + const previewUrl = URL.createObjectURL(file); + return ( +
+
+ Uploading preview +
+
+ +
-
+ ); + })} +
+ )} +
+ +
+ , + action: "upload", + }, + { + label: "Choose from library", + selected: false, + icon: , + action: "library", + }, + ]} + onPanelAction={handleAction} + showIconsInList + buttonClassName="backdrop-blur-none bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none" + triggerIcon={ + + } + /> + +