Compare commits

...
Author SHA1 Message Date
bflatastic 202c3bb4a8 lightbox layout and x close 2025-04-08 22:39:45 +07:00
bflatastic 1090610322 lightbox fixes 2025-04-08 21:57:16 +07:00
bflatastic c6e6604d59 library modal 2025-04-08 21:41:51 +07:00
7 changed files with 830 additions and 241 deletions
+1 -1
View File
@@ -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 {
@@ -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<TabSelectorProps> = ({
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 = (
<div
className={twMerge(
"w-full",
className,
disabled ? "cursor-not-allowed opacity-60" : "",
)}
>
<Tab.Group selectedIndex={selectedIndex} onChange={handleTabChange}>
<Tab.List className="glass relative inline-flex min-w-fit overflow-x-auto rounded-lg p-0.5 py-1">
{/* Animated indicator */}
<div
className="absolute top-1 z-10 h-[calc(100%-8px)] rounded-md bg-brand-primary/30 transition-all duration-200 ease-in-out"
style={{
left: indicatorStyle.left,
width: indicatorStyle.width,
}}
/>
{tabs.map((tab, index) => (
<Tab
key={tab.id}
ref={(el) => (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}
</Tab>
))}
</Tab.List>
</Tab.Group>
</div>
);
return (
<>
{disabled && (
<Tooltip
content={
disabledMessage ?? "Cannot change tab. Generation in progress."
}
position="bottom"
>
{TabGroupElement}
</Tooltip>
)}
{!disabled && TabGroupElement}
</>
);
};
@@ -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 (
<header className="fixed left-0 top-0 z-30 w-full border-b border-ui-panel-border bg-ui-background">
<nav
className="mx-auto grid h-[64px] w-screen grid-cols-3 items-center justify-between p-3"
aria-label="Global"
>
<div className="flex gap-4">
<a href="/" className="">
<span className="sr-only">Storyteller.ai</span>
<img
className="h-[39px] w-auto pb-[3px]"
src="/resources/images/Storyteller-Logo-1.png"
alt="Logo StoryTeller.ai"
/>
</a>
{!isEditorPath(currentLocation) && (
<ButtonLink to={"/"} variant="secondary" icon={faChevronLeft}>
Back to Editor
</ButtonLink>
)}
</div>
<div className="flex items-center justify-center gap-2 font-medium">
<SceneTitleInput pageName={pageName} />
</div>
<div className="flex justify-end gap-5">
<div className="flex gap-2">
<Button variant="secondary" icon={faImages} disabled>
My Library
</Button>
<Activity />
<>
<header className="fixed left-0 top-0 z-30 w-full border-b border-ui-panel-border bg-ui-background">
<nav
className="mx-auto grid h-[64px] w-screen grid-cols-3 items-center justify-between p-3"
aria-label="Global"
>
<div className="flex gap-4">
<a href="/" className="">
<span className="sr-only">Storyteller.ai</span>
<img
className="h-[39px] w-auto pb-[3px]"
src="/resources/images/Storyteller-Logo-1.png"
alt="Logo StoryTeller.ai"
/>
</a>
{!isEditorPath(currentLocation) && (
<ButtonLink to={"/"} variant="secondary" icon={faChevronLeft}>
Back to Editor
</ButtonLink>
)}
</div>
<div className="flex justify-end gap-2">
<AuthButtons />
<div className="flex items-center justify-center gap-2 font-medium">
<SceneTitleInput pageName={pageName} />
</div>
</div>
</nav>
</header>
<div className="flex justify-end gap-5">
<div className="flex gap-2">
<Button
variant="secondary"
icon={faImages}
onClick={() => setIsLibraryModalOpen(true)}
>
My Library
</Button>
<Activity />
</div>
<div className="flex justify-end gap-2">
<AuthButtons />
</div>
</div>
</nav>
</header>
<LibraryModal
isOpen={isLibraryModalOpen}
onClose={() => setIsLibraryModalOpen(false)}
mode="view"
tabs={[
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
/>
</>
);
};
@@ -0,0 +1,367 @@
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, Fragment } from "react";
import { LibraryModalApi } from "./LibraryModalApi";
import { FilterMediaClasses } from "../../../../enums";
import { BucketConfig } from "../../../../api/BucketConfig";
import { twMerge } from "tailwind-merge";
import { createPortal } from "react-dom";
import { Transition } from "@headlessui/react";
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<GroupedItems>({});
const [loading, setLoading] = useState(false);
const [lightboxImage, setLightboxImage] = useState<LibraryItem | null>(null);
const [isLightboxVisible, setIsLightboxVisible] = useState(false);
const [failedImageUrls] = useState<Set<string>>(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);
setIsLightboxVisible(true);
}
};
const handleCloseLightbox = () => {
setIsLightboxVisible(false);
// Wait for animation to complete before removing the image
setTimeout(() => setLightboxImage(null), 300);
};
return (
<>
<TransitionDialogue
isOpen={isOpen}
onClose={onClose}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[80vh] max-h-[870px] max-w-7xl",
)}
childPadding={false}
>
<div className="flex h-full flex-col">
<div className="border-b border-white/10 p-4">
<div className="grid grid-cols-3 items-center">
<h2 className="text-xl font-semibold">
{mode === "select" ? "Select Images" : "My Library"}
</h2>
<div className="flex items-center justify-center">
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
className="w-auto"
/>
</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white"
>
<FontAwesomeIcon icon={faXmark} className="text-lg" />
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-white/60">
<LoadingSpinner className="h-12 w-12" />
</div>
</div>
) : (
<div className="space-y-6">
{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(
"grid grid-cols-5 gap-1",
mode === "view" && "grid-cols-5",
)}
>
{dateItems.map((item) => (
<button
key={item.id}
className={twMerge(
"group relative aspect-square overflow-hidden rounded-md border-[3px] transition-all",
mode === "select" &&
selectedItemIds.includes(item.id)
? "border-brand-primary"
: "border-transparent hover:border-white",
)}
onClick={() => handleItemClick(item)}
>
<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}
alt={item.label}
className="h-full w-full object-cover"
onError={() =>
handleImageError(item.thumbnail!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
{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-brand-primary">
<FontAwesomeIcon
icon={faCheck}
className="text-sm"
/>
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
{mode === "select" && (
<div className="flex items-center justify-between border-t border-white/10 bg-black/25 p-4">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-white/80">
{selectedItemIds.length}/{maxSelections} selected
</div>
{selectedItemIds.length > 0 && (
<span className="text-white/10">|</span>
)}
{selectedItemIds.length > 0 && (
<button
onClick={() => {
selectedItemIds.forEach((id) => onSelectItem?.(id));
}}
className="text-sm text-white/60 hover:text-white"
>
Deselect All
</button>
)}
</div>
<Button
onClick={() => {
const selectedItems = Object.values(groupedItems)
.flat()
.filter((item) => selectedItemIds.includes(item.id));
onUseSelected?.(selectedItems);
}}
disabled={selectedItemIds.length === 0}
>
Use selected
</Button>
</div>
)}
</div>
</TransitionDialogue>
{/* Lightbox Modal */}
{lightboxImage &&
createPortal(
<Transition appear show={isLightboxVisible} as={Fragment}>
<div className="fixed inset-0 z-[100]">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="fixed inset-0 cursor-pointer bg-black/60"
onClick={handleCloseLightbox}
/>
</Transition.Child>
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={handleCloseLightbox}
>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="relative h-[90vh] w-[80vw] rounded-xl bg-[#2C2C2C]"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleCloseLightbox}
className="absolute right-4 top-4 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white"
>
<FontAwesomeIcon icon={faXmark} className="text-lg" />
</button>
<div className="grid h-full grid-cols-3 gap-6">
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden bg-black/40">
{!lightboxImage.fullImage ? (
<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={lightboxImage.fullImage}
alt={lightboxImage.label}
className="h-full w-full object-contain"
onError={() =>
handleImageError(lightboxImage.fullImage!)
}
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
)}
</div>
<div className="py-5">Prompt</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Transition>,
document.body,
)}
</>
);
};
@@ -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,
});
}
}
@@ -0,0 +1 @@
export * from "./LibraryModal";
@@ -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<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
const [isLibraryModalOpen, setIsLibraryModalOpen] = useState(false);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<string[]>(
[],
);
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 (
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<>
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
<div className="flex w-full gap-2">
{referenceImages.map((image) => (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
key={image.id}
className="glass relative aspect-square w-20 rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
<img
src={image.url}
alt="Reference"
className="h-full w-full rounded-lg object-cover"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
<button
onClick={() => handleRemoveReference(image.id)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
>
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
</button>
</div>
))}
{uploadingImages.map(({ id, file }) => {
const previewUrl = URL.createObjectURL(file);
return (
<div
key={id}
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
>
<div className="absolute inset-0">
<img
src={previewUrl}
alt="Uploading preview"
className="h-full w-full object-cover blur-sm"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
/>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
);
})}
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
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={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
/>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Tooltip
content="Aspect ratio"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={aspectRatioList}
onSelect={handleAspectRatioSelect}
mode="toggle"
panelTitle="Aspect Ratio"
showIconsInList
triggerIcon={
<FontAwesomeIcon
icon={getCurrentAspectRatioIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content="Camera"
position="top"
className="z-50"
delay={300}
closeOnClick={true}
>
<PopoverMenu
items={cameraList}
onSelect={handleCameraSelect}
onAdd={handleAddCamera}
triggerIcon={
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
}
showAddButton
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip
content="Download frame"
position="top"
className="z-50"
closeOnClick={true}
delay={200}
>
<Button
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip>
<Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button>
<Button
className="flex items-center border-none bg-brand-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
>
{isEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="h-6 w-6 animate-spin text-white"
className="animate-spin text-lg"
/>
</div>
</div>
);
})}
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
)}
<div className="glass w-[730px] rounded-xl p-4">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileUpload}
multiple
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
onClose={() => setIsCameraSettingsOpen(false)}
cameras={cameraList}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId}
onSelectCamera={setSelectedCameraId}
onDeleteCamera={handleDeleteCamera}
/>
<div className="flex justify-center gap-2">
<PopoverMenu
mode="button"
panelTitle="Add Image"
items={[
{
label: "Upload from device",
selected: false,
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
action: "upload",
},
{
label: "Choose from library",
selected: false,
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
action: "library",
disabled: true,
},
]}
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={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
/>
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe your image..."
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => {
disableHotkeyInput(DomLevels.INPUT);
}}
onBlur={() => {
enableHotkeyInput(DomLevels.INPUT);
}}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Tooltip
content="Aspect ratio"
position="top"
className="z-50"
closeOnClick={true}
>
<PopoverMenu
items={aspectRatioList}
onSelect={handleAspectRatioSelect}
mode="toggle"
panelTitle="Aspect Ratio"
showIconsInList
triggerIcon={
<FontAwesomeIcon
icon={getCurrentAspectRatioIcon()}
className="h-4 w-4"
/>
}
/>
</Tooltip>
<Tooltip
content="Camera"
position="top"
className="z-50"
delay={300}
closeOnClick={true}
>
<PopoverMenu
items={cameraList}
onSelect={handleCameraSelect}
onAdd={handleAddCamera}
triggerIcon={
<FontAwesomeIcon icon={faCamera} className="h-4 w-4" />
}
showAddButton
showIconsInList
mode="toggle"
panelTitle="Camera"
panelActionLabel="Settings"
onPanelAction={() => setIsCameraSettingsOpen(true)}
/>
</Tooltip>
<Tooltip
content={
useSystemPrompt
? "Use system prompt: ON"
: "Use system prompt: OFF"
}
position="top"
className="z-50"
delay={200}
>
<ToggleButton
isActive={useSystemPrompt}
icon={faMessageXmark}
activeIcon={faMessageCheck}
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
/>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip
content="Download frame"
position="top"
className="z-50"
closeOnClick={true}
delay={200}
>
<Button
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faDownload}
onClick={handleDownloadFrame}
/>
</Tooltip>
<Button
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
variant="secondary"
icon={faSave}
onClick={handleSaveFrame}
>
Save frame
</Button>
<Button
className="flex items-center border-none bg-brand-primary px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
icon={!isEnqueueing ? faSparkles : undefined}
onClick={handleEnqueue}
disabled={isEnqueueing || !prompt.trim()}
>
{isEnqueueing ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-lg"
/>
) : (
"Generate"
)}
</Button>
</div>
</div>
</div>
<CameraSettingsModal
isOpen={isCameraSettingsOpen}
onClose={() => setIsCameraSettingsOpen(false)}
cameras={cameraList}
onCameraNameChange={handleCameraNameChange}
onCameraFocalLengthChange={handleCameraFocalLengthChange}
onAddCamera={handleAddCamera}
selectedCameraId={selectedCameraId}
onSelectCamera={setSelectedCameraId}
onDeleteCamera={handleDeleteCamera}
<LibraryModal
isOpen={isLibraryModalOpen}
onClose={handleLibraryClose}
mode="select"
selectedItemIds={selectedLibraryImages}
onSelectItem={handleImageSelect}
maxSelections={4}
onUseSelected={handleLibraryImages}
tabs={[
{ id: "my-media", label: "My media" },
{ id: "uploads", label: "Uploads" },
]}
activeTab={activeLibraryTab}
onTabChange={setActiveLibraryTab}
/>
</div>
</>
);
};