diff --git a/frontend/apps/editor2d/src/GalleryRootComponent/LibraryModal.tsx b/frontend/apps/editor2d/src/GalleryRootComponent/LibraryModal.tsx new file mode 100644 index 0000000000..70077f7a84 --- /dev/null +++ b/frontend/apps/editor2d/src/GalleryRootComponent/LibraryModal.tsx @@ -0,0 +1,333 @@ +import { faCheck } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { CloseButton } from "@headlessui/react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { twMerge } from "tailwind-merge"; +import { Button, LoadingSpinner, TransitionDialogue } from "~/components/reusable"; +import { TabSelector } from "~/components/ui"; + +export function GetCdnOrigin(): string { + return 'https://cdn-2.fakeyou.com'; +} +export enum FilterMediaClasses { + AUDIO = "audio", + IMAGE = "image", + VIDEO = "video", +} + +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 = React.memo( + ({ + 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 [isLightboxVisible, setIsLightboxVisible] = useState(false); + const [failedImageUrls] = useState>(new Set()); + const cdnOrigin = GetCdnOrigin(); + const imageUrl = lightboxImage?.fullImage || ""; + const api = useMemo(() => new LibraryModalApi(), []); + + const formatDate = useCallback((date: string) => { + const d = new Date(date); + return d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + }, []); + + const groupItemsByDate = useCallback( + (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(), + ), + ); + }, + [formatDate], + ); + + const handleImageError = useCallback( + (url: string) => { + failedImageUrls.add(url); + }, + [failedImageUrls], + ); + + const getImageUrl = useCallback( + (path: string | undefined | null) => { + if (!path) return null; + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = `${cdnOrigin}${normalizedPath}`; + return failedImageUrls.has(url) ? null : url; + }, + [cdnOrigin, failedImageUrls], + ); + + 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 || "Image Generation", + thumbnail: getImageUrl(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]); + + // useEffect(() => { + // console.log("LibraryModal render caused by:", { + // isOpen, + // activeTab, + // selectedItemIds, + // }); + // }, [isOpen, activeTab, selectedItemIds]); + + const handleItemClick = useCallback( + (item: LibraryItem) => { + if (mode === "select" && onSelectItem) { + onSelectItem(item.id); + } else { + setLightboxImage(item); + setIsLightboxVisible(true); + } + }, + [mode, onSelectItem], + ); + + const handleCloseLightbox = useCallback(() => { + setIsLightboxVisible(false); + // Wait for animation to complete before removing the image + setTimeout(() => setLightboxImage(null), 300); + }, []); + + const handleDeselectAll = useCallback(() => { + selectedItemIds.forEach((id) => onSelectItem?.(id)); + }, [selectedItemIds, onSelectItem]); + + const handleUseSelected = useCallback(() => { + const selectedItems = Object.values(groupedItems) + .flat() + .filter((item) => selectedItemIds.includes(item.id)); + onUseSelected?.(selectedItems); + }, [groupedItems, selectedItemIds, onUseSelected]); + + return ( + <> + +
+
+
+

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

+
+ +
+
+ +
+
+
+ +
+ {loading ? ( +
+
+ +
+
+ ) : ( +
+ {Object.entries(groupedItems).map(([date, dateItems]) => ( +
+

+ {date} +

+
+ {dateItems.map((item) => ( + + ))} +
+
+ ))} +
+ )} +
+ + {mode === "select" && ( +
+
+
+ {selectedItemIds.length}/{maxSelections} selected +
+ {selectedItemIds.length > 0 && ( + | + )} + {selectedItemIds.length > 0 && ( + + )} +
+ +
+ )} +
+
+ + imageUrl && handleImageError(imageUrl)} + title={lightboxImage?.label} + createdAt={lightboxImage?.createdAt} + downloadUrl={imageUrl} + /> + + ); + }, +); + +LibraryModal.displayName = "LibraryModal"; diff --git a/frontend/apps/editor2d/src/GalleryRootComponent/LibraryModalApi.ts b/frontend/apps/editor2d/src/GalleryRootComponent/LibraryModalApi.ts new file mode 100644 index 0000000000..6033d99b8c --- /dev/null +++ b/frontend/apps/editor2d/src/GalleryRootComponent/LibraryModalApi.ts @@ -0,0 +1,26 @@ +import { FilterEngineCategories, FilterMediaClasses, FilterMediaType } from "~/Classes/ApiManager/enums/QueryFilters"; +import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi"; + +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, + }); + } +}