Compare commits

..
Author SHA1 Message Date
CrossPr0duct 617aa75dc5 Background removal race condition fixed. 2025-06-19 13:13:30 -04:00
BFlat 10603f7c86 feature/lightbox drag (#677)
- draggable and resizable lightbox

Bug found: if lightbox is open, gallery modal cannot open the filter
dropdown until lightbox is closed.
2025-06-19 00:41:21 +07:00
5e8c524f84 Feature/login fix (#678)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2025-06-18 10:59:43 -04:00
Brandon Thomas 05f0fe1216 fal api endpoints in api gateway (#676) 2025-06-17 01:18:36 -04:00
ArEnScandCrossPr0duct 1f44f7e929 Feature/fixes (#675)
Co-authored-by: CrossPr0duct <alicextensor@gmail.com>
2025-06-16 23:58:35 -04:00
18 changed files with 2561 additions and 2435 deletions
Generated
+1642 -1961
View File
File diff suppressed because it is too large Load Diff
@@ -2,4 +2,6 @@ pub mod fal_background_removal_command;
pub mod fal_hunyuan_image_to_3d_command;
pub mod fal_kling_image_to_video_command;
pub mod get_fal_api_key_command;
pub mod set_fal_api_key_command;
pub mod set_fal_api_key_command;
// pub mod get_open_ai_api_key_command;
// pub mod set_open_ai_api_key_command;
@@ -1,38 +1,65 @@
import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals";
//import { ButtonLink } from "@storyteller/ui-button-link";
import { Button } from "@storyteller/ui-button";
import ProfileDropdown from "./ProfileDropdown";
import { LoginModal } from "@storyteller/ui-login-modal";
import { useState } from "react";
import {
disableHotkeyInput,
DomLevels,
enableHotkeyInput,
} from "~/pages/PageEnigma/signals";
export const AuthButtons = () => {
useSignals();
const [showLoginModal, setShowLoginModal] = useState(false);
const [isSignUp, setIsSignUp] = useState(false);
const { status } = authentication;
const { status, userInfo } = authentication;
console.log("Current State:");
console.log(status.value);
if (status.value === AUTH_STATUS.LOGGED_IN) {
console.log("SHOWING DROPDOWN");
return <ProfileDropdown />;
} else {
return (
<div className="flex items-center gap-3.5">
<span className="text-white/20">|</span>
<div className="flex items-center gap-2">
{/* <ButtonLink
to="/login"
variant="secondary"
reloadDocument={true}
className="h-[38px]" // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
<>
<div className="flex items-center gap-3.5">
<span className="text-white/20">|</span>
<Button
className="h-[38px]"
onClick={() => {
setIsSignUp(true);
setShowLoginModal(true);
}}
>
Login
</ButtonLink>
<ButtonLink
to="/signup"
reloadDocument={true}
className="h-[38px]" // TODO(bt,2025-04-19): Once we have in-page routing, get rid of this.
>
Sign Up
</ButtonLink> */}
Login / Sign Up
</Button>
</div>
</div>
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
isSignUp={isSignUp}
videoSrc2D="/resources/videos/artcraft-canvas-demo.mp4"
videoSrc3D="/resources/videos/artcraft-3d-demo.mp4"
onArtCraftAuthSuccess={(userInfo_value) => {
userInfo.value = userInfo_value;
status.value = AUTH_STATUS.LOGGED_IN;
setShowLoginModal(false);
}}
onOpenChange={(isOpen: boolean) => {
if (isOpen) {
disableHotkeyInput(DomLevels.DIALOGUE);
} else {
enableHotkeyInput(DomLevels.DIALOGUE);
}
}}
/>
)}
</>
);
}
};
@@ -185,13 +185,7 @@ export const PaintSurface = ({
isWithinLeftPanel(stagePoint)
) {
const lineId = `line-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
let opacity;
if (activeTool === "draw") {
opacity = store.brushOpacity
} else {
opacity = 1
}
let opacity = activeTool === "draw" ? store.brushOpacity : 1;
const newLineNode: LineNode = {
id: lineId,
@@ -200,10 +194,10 @@ export const PaintSurface = ({
stroke: activeTool === "draw" ? brushColor : fillColor || "#ffffff",
strokeWidth: brushSize / stage.scaleX(),
draggable: true,
opacity: opacity // Add opacity to the line node
opacity: opacity
};
store.selectNode(null);
store.addLineNode(newLineNode);
store.addLineNode(newLineNode, false); // Don't save state when starting line
setCurrentLineId(lineId);
setIsDrawing(true);
setLastPoint(stagePoint);
@@ -280,7 +274,6 @@ export const PaintSurface = ({
y: (point.y - stage.y()) / stage.scaleY(),
};
// Only add point if it's within the left panel bounds
if (isWithinLeftPanel(stagePoint)) {
const currentLine = store.lineNodes.find(
(line) => line.id === currentLineId,
@@ -291,7 +284,7 @@ export const PaintSurface = ({
stagePoint.x,
stagePoint.y,
];
store.updateLineNode(currentLineId, { points: updatedPoints });
store.updateLineNode(currentLineId, { points: updatedPoints }, false); // Don't save state while drawing
}
setLastPoint(stagePoint);
} else {
@@ -339,6 +332,10 @@ export const PaintSurface = ({
};
const handleStageMouseUp = () => {
if (isDrawing) {
store.saveState(); // Save state only when the stroke is complete
}
if (isSelecting && selectionRect) {
// Calculate the bounds of the selection rectangle
const left = Math.min(selectionRect.startX, selectionRect.endX);
@@ -692,7 +689,7 @@ export const PaintSurface = ({
scaleY: finalScaleY,
offsetX: finalOffsetX,
offsetY: finalOffsetY,
});
}, true);
} else {
store.updateNode(
nodeId,
@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
export interface MenuPosition {
x: number;
@@ -33,14 +34,14 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onAction, onClo
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
return (
return createPortal(
<div
ref={menuRef}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
}}
className="fixed z-50 min-w-[220px] bg-[#1A1A1A] rounded-lg shadow-xl border border-[#333333] py-1 select-none"
className="fixed z-[9999999] min-w-[220px] bg-[#1A1A1A] rounded-lg shadow-xl border border-[#333333] py-1 select-none"
>
{items.map((item, index) => (
<React.Fragment key={index}>
@@ -59,7 +60,8 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({ items, onAction, onClo
{item.divider && <div className="h-[1px] bg-[#333333] mx-2 my-1" />}
</React.Fragment>
))}
</div>
</div>,
document.body
);
};
@@ -123,7 +125,7 @@ export const ContextMenuContainer: React.FC<ContextMenuContainerProps> = ({
<div
onClick={handleClick}
onContextMenu={handleContextMenu}
className={`w-full h-full ${className}`}
className={`w-full h-full relative z-[99999] ${className}`}
>
{children}
{showMenu && (
@@ -7,6 +7,15 @@ interface DeleteHotkeysProps {
export const useDeleteHotkeys = ({ onDelete, target = document }: DeleteHotkeysProps): void => {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Check if the event target is an input field or contentEditable element
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable ||
target.getAttribute('role') === 'textbox') {
return; // Don't prevent default for input fields
}
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isDelete = event.key === 'Delete' ||
event.key === 'Backspace' ||
@@ -89,9 +89,9 @@ interface SceneState {
saveState: () => void;
// Add new actions for line nodes
addLineNode: (lineNode: LineNode) => void;
addLineNode: (lineNode: LineNode, shouldSaveState: boolean) => void;
removeLineNode: (id: string, shouldSaveState?: boolean) => void;
updateLineNode: (id: string, updates: Partial<LineNode>) => void;
updateLineNode: (id: string, updates: Partial<LineNode>, shouldSaveState: boolean) => void;
moveLineNode: (id: string, dx: number, dy: number) => void;
// Add a specific method for file uploads
@@ -133,7 +133,7 @@ interface SceneState {
sendToBack: (nodeIds: string[]) => void;
bringForward: (nodeIds: string[]) => void;
sendBackward: (nodeIds: string[]) => void;
removeBackground: (nodeIds: string[], operation: (success: boolean,base64_image:string, message: string) => Promise<{ success: boolean; file?: File }>) => Promise<void>;
removeBackground: (nodeIds: string[], operation: (success: boolean, base64_image: string, message: string) => Promise<{ success: boolean; file?: File }>) => Promise<void>;
// Add new lock action
toggleLock: (nodeIds: string[]) => void;
@@ -516,17 +516,18 @@ export const useSceneStore = create<SceneState>((set, get) => ({
},
// Add new line node actions
addLineNode: (lineNode: LineNode) => {
addLineNode: (lineNode: LineNode, shouldSaveState: boolean = true) => {
set((state) => {
const nextZ = getNextZIndex(state.nodes, state.lineNodes);
const newLineNode = {
...lineNode,
zIndex: nextZ
};
return { lineNodes: [...state.lineNodes, newLineNode] };
});
get().saveState();
if (shouldSaveState) {
get().saveState();
}
},
removeLineNode: (id: string, shouldSaveState: boolean = true) => {
@@ -547,7 +548,7 @@ export const useSceneStore = create<SceneState>((set, get) => ({
}
},
updateLineNode: (id: string, updates: Partial<LineNode>) => {
updateLineNode: (id: string, updates: Partial<LineNode>, shouldSaveState: boolean = true) => {
set((state) => {
const newLineNodes = state.lineNodes.map(node => {
if (node.id === id) {
@@ -561,7 +562,9 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
return { lineNodes: newLineNodes };
});
get().saveState();
if (shouldSaveState) {
get().saveState();
}
},
moveLineNode: (id: string, dx: number, dy: number) => {
@@ -593,7 +596,8 @@ export const useSceneStore = create<SceneState>((set, get) => ({
const img = new Image();
img.onload = () => {
const aspectRatio = img.naturalWidth / img.naturalHeight;
const finalWidth = width || Math.min(img.naturalWidth, 300);
// Increase default size to 512px width while maintaining aspect ratio
const finalWidth = width || Math.min(img.naturalWidth, 512);
const finalHeight = height || finalWidth / aspectRatio;
console.log('Image loaded with dimensions:', {
naturalWidth: img.naturalWidth,
@@ -1158,58 +1162,46 @@ export const useSceneStore = create<SceneState>((set, get) => ({
});
get().saveState();
},
removeBackground: async (nodeIds: string[], operation: (success: boolean, base64_image:string,message: string) => Promise<{ success: boolean; file?: File }>) => {
removeBackground: async (nodeIds: string[], operation: (success: boolean, base64_image: string, message: string) => Promise<{ success: boolean; file?: File }>) => {
const hasImageNodes = nodeIds.some(id => {
const node = get().nodes.find(n => n.id === id);
return node ? node.type === 'image': false;
const node = get().nodes.find(n => n.id === id);
return node ? node.type === 'image' : false;
});
let callbackSuccess = false
if (hasImageNodes) {
console.log("Selected nodes include image types.");
const firstNode = get().nodes.find(node => nodeIds.includes(node.id) && node.type === 'image');
if (firstNode) {
const imageFile = firstNode.imageFile; // Assuming the file is stored in the node
if (imageFile !== undefined) {
try {
const base64Image = await convertFileToBase64(imageFile);
console.log("Base64 Image:", base64Image);
// Additional logic to handle the base64 image can be added here
const { success, file } = await operation(true, base64Image, "Removing Background."); // Indicate success in removing background
callbackSuccess = success;
if (callbackSuccess) {
// save the state of the image.
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
if (node.id === firstNode.id) {
// Create an object URL for immediate preview if needed
console.log("Updated Node!")
const updatedImageUrl: string | undefined = file ? URL.createObjectURL(file) : node.imageUrl;
return new Node({
...node,
imageFile: file,
imageUrl: updatedImageUrl,
});
}
return node;
}),
}));
const firstNode = get().nodes.find(node => nodeIds.includes(node.id) && node.type === 'image');
if (firstNode && firstNode.imageFile) {
try {
const base64Image = await convertFileToBase64(firstNode.imageFile);
const { success, file } = await operation(true, base64Image, "Removing Background.");
if (success && file) {
// Create a new node instance
const updatedNode = new Node({
...firstNode,
imageFile: file
});
// Load the new image
await updatedNode.setImageFromFile(file);
// Update the store with the fully loaded node
set((state: SceneState) => ({
nodes: state.nodes.map((node: Node) => {
if (node.id === firstNode.id) {
return updatedNode;
}
} catch (error) {
console.error("Error converting file to base64:", error);
}
}
console.log("Image File:", imageFile);
} else {
console.warn("No image node found or first node is not an image.");
return node;
}),
}));
get().saveState();
}
} catch (error) {
console.error("Error updating image:", error);
}
} else {
console.log("No selected nodes are of image type.");
operation(false,"","Did not select a Images"); // Indicate failure to remove background
}
}
},
// Add toggleLock action
@@ -12,6 +12,7 @@ import React, {
} from "react";
import {
FilterMediaClasses,
FilterMediaType,
GalleryModalApi,
UsersApi,
} from "@storyteller/api";
@@ -218,7 +219,8 @@ export const GalleryModal = React.memo(
include_user_uploads:
activeFilter === "uploaded" ||
activeFilter === "all" ||
activeFilter === "3d",
activeFilter === "3d" ||
activeFilter === "image",
user_uploads_only: activeFilter === "uploaded",
page_index: reset ? 0 : pageIndex,
page_size: pageSize,
@@ -227,26 +229,30 @@ export const GalleryModal = React.memo(
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_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: 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"),
}));
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
)
.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_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: 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;
@@ -355,13 +361,13 @@ export const GalleryModal = React.memo(
}}
className={twMerge(
"h-[620px] max-w-4xl",
mode === "view" && "h-[640px] max-w-4xl"
mode === "view" && "h-[640px] w-[56rem]"
)}
childPadding={false}
showClose={false}
draggable={mode === "view"}
allowBackgroundInteraction={mode === "view" ? true : false}
closeOnOutsideClick={false}
closeOnOutsideClick={mode === "view" ? false : true}
>
{mode === "view" && (
<Modal.DragHandle>
@@ -526,10 +532,23 @@ export const GalleryModal = React.memo(
{Object.entries(groupItemsByDate(allItems)).map(
([date, dateItems]) => {
const filteredItems = dateItems.filter((item) => {
if (activeFilter === "3d")
if ((item as any).mediaType === "scene_json")
return false;
if (activeFilter === "3d") {
return item.mediaClass === "dimensional";
if (activeFilter === "all")
return item.mediaClass !== "audio";
}
if (activeFilter === "image") {
return item.mediaClass === "image";
}
if (activeFilter === "video") {
return item.mediaClass === "video";
}
if (activeFilter === "all") {
return (
item.mediaClass !== "audio" &&
(item as any).mediaType !== "scene_json"
);
}
return true;
});
if (filteredItems.length === 0) return null;
@@ -1,14 +1,13 @@
import { CloseButton } from "@storyteller/ui-close-button";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { Transition, TransitionChild } from "@headlessui/react";
import { createPortal } from "react-dom";
import dayjs from "dayjs";
import { faDownToLine } from "@fortawesome/pro-solid-svg-icons";
import {
FalHunyuanImageTo3d,
EnqueueImageTo3dObject,
EnqueueImageTo3dObjectModel,
} from "@storyteller/tauri-api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import React from "react";
interface LightboxModalProps {
isOpen: boolean;
@@ -46,156 +45,145 @@ export function LightboxModal({
onAddToSceneClicked,
mediaClass,
}: LightboxModalProps) {
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// NB(bt,2025-06-14): We add ?cors=1 to the image url to prevent caching "sec-fetch-mode: no-cors" from
// the <image> tag request from being cached. If we then drag it into the canvas after it's been cached,
// it won't be able to load in cors mode and will show blank in the canvas and 3D engine. This is a really
// stupid hack around this behavior.
const imageTagImageUrl = imageUrl ? imageUrl + "?cors=1" : "";
return createPortal(
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
{/* backdrop */}
<TransitionChild
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={onClose}
/>
</TransitionChild>
{/* dialog container */}
<div
className="fixed inset-0 flex items-center justify-center p-4"
onClick={onClose}
>
<TransitionChild
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()}
>
{/* close */}
<CloseButton
onClick={onClose}
className="absolute right-4 top-4 z-10"
/>
const [mediaLoaded, setMediaLoaded] = React.useState<boolean>(false);
{/* content grid */}
<div className="grid h-full grid-cols-3 gap-6">
{/* image panel */}
<div className="col-span-2 flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-black/40">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
) : 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.
</video>
) : (
<img
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
/>
)}
</div>
// Reset when imageUrl changes
React.useEffect(() => {
setMediaLoaded(false);
}, [imageUrl]);
{/* info + actions */}
<div className="flex h-full flex-col py-5 pe-5">
<div className="flex-1 space-y-4">
<div className="text-xl font-medium">
{title || "Image Generation"}
</div>
{createdAt && (
<div className="text-sm text-white/60">
Created:{" "}
{dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
</div>
)}
{additionalInfo}
</div>
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl bg-[#2C2C2C] h-[80vh] w-[60vw] max-w-screen"
draggable
allowBackgroundInteraction={true}
showClose={true}
closeOnOutsideClick={false}
resizable={true}
backdropClassName="pointer-events-none hidden"
>
{/* Invisible drag handle strip at the very top for moving */}
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" />
</Modal.DragHandle>
{/* buttons with spacing */}
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
//});
let _result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
//onClose(); // close the lightbox
//onCloseGallery(); // close the gallery
}}
>
3D
</Button>
{onAddToSceneClicked && downloadUrl && (
<Button
onClick={async (e) => {
e.stopPropagation();
await onAddToSceneClicked(downloadUrl, mediaId);
onClose(); // close the lightbox
onCloseGallery(); // close the gallery
}}
>
Add to Current Scene
</Button>
)}
{downloadUrl &&
(onDownloadClicked ? (
<Button
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
await onDownloadClicked(downloadUrl);
}}
>
Download
</Button>
) : (
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
className="no-underline"
>
<Button icon={faDownToLine}>Download</Button>
</a>
))}
</div>
) : null}
</div>
</div>
{/* content grid */}
<div className="grid h-full grid-cols-3 gap-6">
{/* image panel */}
<div className="col-span-2 relative flex h-full items-center justify-center overflow-hidden rounded-l-xl bg-[#1A1A1A]">
{!imageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-white/60">Image not available</span>
</div>
</TransitionChild>
) : mediaClass === "video" ? (
<video
controls
className="h-full w-full object-contain"
onLoadedData={() => setMediaLoaded(true)}
>
<source src={imageUrl} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : (
<img
src={imageTagImageUrl}
alt={imageAlt}
className="h-full w-full object-contain"
onError={onImageError}
onLoad={() => setMediaLoaded(true)}
/>
)}
{!mediaLoaded && imageUrl && (
<div className="absolute inset-0 bg-[#1A1A1A] flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-white" />
</div>
)}
</div>
{/* info + actions */}
<div className="flex h-full flex-col py-5 pe-5">
<div className="flex-1 space-y-4">
<div className="text-xl font-medium">
{title || "Image Generation"}
</div>
{createdAt && (
<div className="text-sm text-white/60">
Created: {dayjs(createdAt).format("MMM D, YYYY HH:mm:ss")}
</div>
)}
{additionalInfo}
</div>
{/* buttons with spacing */}
{(onAddToSceneClicked && downloadUrl) || downloadUrl ? (
<div className="mt-15 mb-15 flex justify-end gap-2">
<Button
onClick={async (e) => {
//let _result = await FalHunyuanImageTo3d({
// image_media_token: mediaId,
// //base64_image: downloadUrl,
//});
let result = await EnqueueImageTo3dObject({
image_media_token: mediaId,
model: EnqueueImageTo3dObjectModel.Hunyuan3d2,
});
//e.stopPropagation();
//await onAddToSceneClicked(downloadUrl, mediaId);
//onClose(); // close the lightbox
//onCloseGallery(); // close the gallery
}}
>
3D
</Button>
{onAddToSceneClicked && downloadUrl && (
<Button
onClick={async (e) => {
e.stopPropagation();
await onAddToSceneClicked(downloadUrl, mediaId);
onClose(); // close the lightbox
onCloseGallery(); // close the gallery
}}
>
Add to Current Scene
</Button>
)}
{downloadUrl &&
(onDownloadClicked ? (
<Button
icon={faDownToLine}
onClick={async (e) => {
e.stopPropagation();
await onDownloadClicked(downloadUrl);
}}
>
Download
</Button>
) : (
<a
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
className="no-underline"
>
<Button icon={faDownToLine}>Download</Button>
</a>
))}
</div>
) : null}
</div>
</div>
</Transition>,
document.body
</Modal>
);
}
@@ -69,6 +69,14 @@ export const ArtCraftSignUp = ({
}
};
const handleInputFocus = () => {
};
const handleInputBlur = () => {
};
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-3 text-center">
@@ -88,6 +96,8 @@ export const ArtCraftSignUp = ({
placeholder="Username"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<Input
label="Email"
@@ -98,6 +108,8 @@ export const ArtCraftSignUp = ({
required={isSignUp}
style={isSignUp ? {} : { display: "none" }}
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
</>
) : (
@@ -108,6 +120,8 @@ export const ArtCraftSignUp = ({
placeholder="Email or Username"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
@@ -119,6 +133,8 @@ export const ArtCraftSignUp = ({
placeholder="Password"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
{isSignUp && (
<Input
@@ -129,6 +145,8 @@ export const ArtCraftSignUp = ({
placeholder="Confirm Password"
required
autoComplete="off"
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
)}
<button type="submit" className="hidden" />
@@ -23,6 +23,7 @@ interface LoginModalProps {
openAiLogo?: string;
onOpenChange?: (isOpen: boolean) => void;
onArtCraftAuthSuccess?: (userInfo: any) => void;
isSignUp?: boolean;
}
export function LoginModal({
@@ -32,35 +33,21 @@ export function LoginModal({
openAiLogo,
onOpenChange,
onArtCraftAuthSuccess,
isSignUp: initialIsSignUp = true,
}: LoginModalProps) {
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isLoggedInArtCraft, setIsLoggedInArtCraft] = useState(false);
const [isSignUp, setIsSignUp] = useState(true);
const [isSignUp, setIsSignUp] = useState(initialIsSignUp);
const [errorMessage, setErrorMessage] = useState("");
const artCraftFormRef = useRef<HTMLFormElement>(null);
const [initialArtCraftLoginStatusForUI, setInitialArtCraftLoginStatusForUI] =
useState<boolean | null>(null);
const [initialSoraSessionStatusForUI, setInitialSoraSessionStatusForUI] =
useState<boolean | null>(null);
const [showDiscord, setShowDiscord] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
// Determine total steps and current step for UI
let uiTotalSteps = 4;
let uiCurrentStep = step;
if (initialSoraSessionStatusForUI) {
// If Sora session exists, remove the Sora step (step 3)
uiTotalSteps = 3;
if (step === 1) uiCurrentStep = 1;
else if (step === 2) uiCurrentStep = 2;
else if (step === 4) uiCurrentStep = 3;
} else if (initialArtCraftLoginStatusForUI) {
// If ArtCraft is logged in but not Sora, keep 4 steps
uiTotalSteps = 4;
if (step === 1) uiCurrentStep = 1;
else if (step === 3) uiCurrentStep = 2;
else if (step === 4) uiCurrentStep = 3;
}
// Now we have 3 steps: Welcome, Sign up/Login, and Success
const uiTotalSteps = 3;
const uiCurrentStep = showSuccess ? 3 : showDiscord ? 2 : step;
const initSession = async () => {
const result = await CheckSoraSession();
@@ -82,34 +69,15 @@ export function LoginModal({
// Check session on component mount
useEffect(() => {
// Run both checks in parallel
Promise.all([initSession(), checkArtCraftLogin()]).then(
([soraSessionExists, loggedIn]) => {
setInitialArtCraftLoginStatusForUI(loggedIn || false); // Set initial status for UI step counting
setInitialSoraSessionStatusForUI(soraSessionExists || false); // Set initial Sora status for UI step counting
if (soraSessionExists && loggedIn) {
setIsLoggedInArtCraft(true);
setIsOpen(false); // Close the modal if fully logged in
return;
} else if (soraSessionExists && !loggedIn) {
// Sora session exists, but not logged in to ArtCraft
setIsOpen(true);
setStep(2);
setIsLoggedInArtCraft(false);
} else if (!soraSessionExists && loggedIn) {
// No Sora session, but logged in to ArtCraft
setIsOpen(true);
setStep(3);
setIsLoggedInArtCraft(true);
} else if (!soraSessionExists) {
// No Sora session, start at step 1
setIsOpen(true);
setStep(1);
setIsLoggedInArtCraft(false); // Ensure this is also set
}
checkArtCraftLogin().then((loggedIn) => {
if (loggedIn) {
setIsLoggedInArtCraft(true);
setIsOpen(false);
} else {
setIsOpen(true);
setStep(1); // Always start at step 1
}
);
});
}, []);
useEffect(() => {
@@ -122,31 +90,7 @@ export function LoginModal({
};
const handleNext = async () => {
if (step === 3) {
setIsLoading(true);
try {
if (IsDesktopApp()) {
await invoke("open_sora_login_command");
await new Promise((resolve) => setTimeout(resolve, 3000));
const result = await CheckSoraSession();
const sessionExists = result.state === SoraSessionState.Valid;
if (sessionExists) {
setStep(4); // Always go to the final step after Sora login
}
} else {
alert("Please open the desktop app to login");
await new Promise((resolve) => setTimeout(resolve, 3000));
const sessionExists = true;
if (sessionExists) {
setStep(4); // Always go to the final step after Sora login
}
}
} catch (error) {
console.error("Login failed:", error);
} finally {
setIsLoading(false);
}
} else if (step === 2) {
if (step === 2) {
// Trigger the form submit in ArtCraftSignUp
if (artCraftFormRef.current) {
artCraftFormRef.current.requestSubmit();
@@ -174,12 +118,68 @@ export function LoginModal({
</div>
);
const handleDiscordJoin = () => {
window.open("https://discord.gg/75svZP2Vje", "_blank");
setShowDiscord(false);
setShowSuccess(true);
};
// Step content rendering
const renderStepContent = () => {
if (step === 2 && isLoggedInArtCraft) {
setStep(3);
return null;
if (showSuccess) {
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Thank you for signing in!
</h2>
<p className="text-white/70 mb-6 text-center">
You're all set to start creating amazing content.
</p>
<Button
variant="primary"
onClick={handleClose}
icon={faArrowRight}
iconFlip={true}
className="text-md"
>
Get Started
</Button>
</div>
);
}
if (showDiscord) {
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Join Our Community
</h2>
<p className="text-white/70 mb-6 text-center px-24">
Connect with other creators, share your work, and get the latest updates in our Discord community.
</p>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => {
setShowDiscord(false);
setShowSuccess(true);
}}
>
Skip for now
</Button>
<Button
variant="primary"
onClick={handleDiscordJoin}
icon={faDiscord}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]"
>
Join Discord
</Button>
</div>
</div>
);
}
switch (step) {
case 1:
return (
@@ -240,12 +240,15 @@ export function LoginModal({
try {
let signupResponse, loginResponse;
if (isSignUp) {
console.log("Sign up!")
signupResponse = await usersApi.Signup({
username,
email,
password,
passwordConfirmation,
});
console.log(signupResponse)
if (!signupResponse.success) {
setErrorMessage(
signupResponse.errorMessage ||
@@ -258,41 +261,32 @@ export function LoginModal({
usernameOrEmail: username || email,
password,
});
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
"Login after signup failed, please try again. "
);
setIsLoading(false);
return;
}
} else {
loginResponse = await usersApi.Login({
usernameOrEmail: username || email,
password,
});
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
"Login failed, please try again."
);
setIsLoading(false);
return;
}
}
if (!loginResponse.success) {
setErrorMessage(
loginResponse.errorMessage ||
"Login failed, please try again."
);
setIsLoading(false);
return;
}
setIsLoggedInArtCraft(true);
console.log("ARTCRAFT LOGIN SUCCeSS")
if (onArtCraftAuthSuccess) {
const session = await usersApi.GetSession();
const userInfo = session.data?.user;
console.log("USERINFO")
console.log(userInfo)
if (userInfo) onArtCraftAuthSuccess(userInfo);
}
// Check for Sora session and skip to step 4 if present
const soraSessionExists = await initSession();
if (soraSessionExists) {
setStep(4);
} else {
setStep(3);
}
setShowDiscord(true); // Show Discord step after successful login
} catch (e) {
console.error(e);
setErrorMessage(
@@ -308,53 +302,40 @@ export function LoginModal({
errorMessage={errorMessage}
/>
);
case 3:
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Login with your OpenAI account
</h2>
<p className="text-white/70 mb-6 text-center px-16">
We're the application layer for images and video. To provide you
with <b>100% free service</b>, we use your existing AI accounts.
To start off, you'll need to add your OpenAI Sora account. If you
have a ChatGPT subscription, you can log in with Sora.
</p>
<img
src={openAiLogo}
alt="OpenAI Logo"
className="w-72 h-72 mx-auto grow"
/>
</div>
);
case 4:
return (
<div className="flex flex-col items-center justify-center h-full">
<h2 className="text-3xl font-bold mb-2 text-center">
Thank you for signing in!
</h2>
<p className="text-white/70 mb-6 text-center px-24">
You're all set to start creating amazing content. Join our Discord
community to connect with other creators, share your work, and get
the latest updates.
</p>
<Button
variant="secondary"
onClick={() =>
window.open("https://discord.gg/75svZP2Vje", "_blank")
}
icon={faDiscord}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff] rounded-xl"
>
Join our Discord
</Button>
</div>
);
default:
return null;
}
};
const renderFooterButtons = () => {
if (showSuccess || showDiscord) {
return null; // No footer buttons needed in success or discord states
}
return (
<div className="flex items-end justify-center gap-2.5 mt-10 grow">
{step === 2 && (
<Button
variant="secondary"
onClick={handleBack}
disabled={isLoading}
>
Back
</Button>
)}
<Button
icon={faArrowRight}
iconFlip={true}
onClick={handleNext}
loading={isLoading}
disabled={isLoading}
>
{step === 1 ? "Continue" : isSignUp ? "Sign up" : "Login"}
</Button>
</div>
);
};
return createPortal(
<Transition appear show={isOpen}>
<div className="fixed inset-0 z-[100]">
@@ -382,58 +363,16 @@ export function LoginModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-4 p-8 h-full">
<span className="text-sm text-center opacity-60 font-medium">
Step {uiCurrentStep} of {uiTotalSteps}
</span>
{renderProgress()}
{!showSuccess && (
<>
<span className="text-sm text-center opacity-60 font-medium">
Step {uiCurrentStep} of {uiTotalSteps}
</span>
{renderProgress()}
</>
)}
{renderStepContent()}
<div className="flex items-end justify-center gap-2.5 mt-10 grow">
{step === 2 ? (
<Button
variant="secondary"
onClick={handleBack}
disabled={isLoading}
>
Back
</Button>
) : null}
{step === 3 && (
<Button
variant="secondary"
onClick={handleClose}
disabled={isLoading}
>
Skip for now
</Button>
)}
{step === 4 ? (
<Button
icon={faArrowRight}
iconFlip={true}
onClick={handleClose}
>
Start Creating Now
</Button>
) : (
<>
<Button
icon={step === 3 ? faRightToBracket : faArrowRight}
iconFlip={step !== 3}
onClick={handleNext}
loading={isLoading}
disabled={isLoading}
>
{step === 1
? "Continue"
: step === 2
? isSignUp
? "Sign up"
: "Login"
: "Login with OpenAI"}
</Button>
</>
)}
</div>
{renderFooterButtons()}
</div>
</div>
</TransitionChild>
@@ -4,4 +4,4 @@
* e.g.
* .container {
* }
*/
*/
+371 -17
View File
@@ -18,6 +18,49 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import { DomLevels } from "@storyteller/common";
// ---------------------------------------------------------------------------
// GLOBAL inert / aria-hidden stripper – applies once per page load
// Overrides the inert attribute set by Headless UI to allow background interaction / stacked modals
// ---------------------------------------------------------------------------
declare global {
interface Window {
__inertStripperInstalled?: boolean;
}
}
if (!window.__inertStripperInstalled) {
window.__inertStripperInstalled = true;
const strip = (el: Element) => {
if (el.hasAttribute("inert")) el.removeAttribute("inert");
// @ts-ignore – some browsers expose .inert as a property
if ((el as any).inert) (el as any).inert = false;
if (el.getAttribute("aria-hidden") === "true")
el.removeAttribute("aria-hidden");
};
// 1. Clean anything that already exists
document.querySelectorAll("[inert]").forEach(strip);
// 2. Keep document clean forever
const inertObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "attributes" && m.attributeName === "inert") {
strip(m.target as Element);
}
}
});
inertObserver.observe(document.documentElement, {
attributes: true,
subtree: true,
attributeFilter: ["inert"],
});
}
// Simple global z-index tracker for stacked modals
let modalZCounter = 70;
const DialogBackdrop = ({
className,
onClose,
@@ -120,8 +163,8 @@ export const Modal = ({
titleIcon,
onTitleIconClick,
onClose,
disableHotkeyInput = () => {},
enableHotkeyInput = () => {},
disableHotkeyInput = () => {},
className,
backdropClassName,
width,
@@ -130,6 +173,7 @@ export const Modal = ({
titleIconClassName,
showClose = true,
draggable = false,
resizable = false,
initialPosition,
closeOnOutsideClick = true,
allowBackgroundInteraction = false,
@@ -148,6 +192,7 @@ export const Modal = ({
childPadding?: boolean;
showClose?: boolean;
draggable?: boolean;
resizable?: boolean;
disableHotkeyInput?: (level: number) => void;
enableHotkeyInput?: (level: number) => void;
/**
@@ -172,11 +217,13 @@ export const Modal = ({
null
);
const [dragging, setDragging] = useState(false);
const [resizing, setResizing] = useState(false);
const dragStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const mouseStart = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const modalRef = useRef<HTMLDivElement>(null);
const positionRef = useRef<{ x: number; y: number } | null>(null);
const lastPositionRef = useRef<{ x: number; y: number } | null>(null);
const [zIndex, setZIndex] = useState<number>(() => ++modalZCounter);
// Expanded state
const [expanded, setExpanded] = useState(false);
@@ -197,7 +244,7 @@ export const Modal = ({
modalRef.current.style.top = "0px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
} else {
// Restore last non-expanded position
@@ -210,12 +257,12 @@ export const Modal = ({
modalRef.current.style.top = lastNonExpandedPosition.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded]);
}, [expanded, zIndex]);
const toggleExpanded = () => setExpanded((v) => !v);
// Reset position when modal is closed or opened
@@ -237,7 +284,7 @@ export const Modal = ({
modalRef.current.style.top = lastPositionRef.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
} else if (initialPosition) {
setPosition({ ...initialPosition });
@@ -247,11 +294,11 @@ export const Modal = ({
modalRef.current.style.top = initialPosition.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
}
}
}, [isOpen]);
}, [isOpen, zIndex]);
// Handle mouse move and up events
useEffect(() => {
@@ -284,7 +331,7 @@ export const Modal = ({
modalRef.current.style.top = positionRef.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
});
}
@@ -301,20 +348,24 @@ export const Modal = ({
window.removeEventListener("mouseup", handleMouseUp);
if (animationFrame) cancelAnimationFrame(animationFrame);
};
}, [dragging]);
}, [dragging, zIndex]);
// Center modal if not being dragged
const getModalStyle = (): React.CSSProperties => {
if (!draggable || !position) {
// If allowBackgroundInteraction, set pointerEvents: 'auto' for modal
return allowBackgroundInteraction ? { pointerEvents: "auto" } : {};
return {
...(size ? { width: size.width, height: size.height } : {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
};
}
return {
position: "fixed",
left: position.x,
top: position.y,
margin: 0,
zIndex: 70,
zIndex,
...(size ? { width: size.width, height: size.height } : {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
};
};
@@ -352,7 +403,7 @@ export const Modal = ({
modalRef.current.style.top = y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = "70";
modalRef.current.style.zIndex = String(zIndex);
}
}
};
@@ -407,12 +458,301 @@ export const Modal = ({
: children;
}
/**
* ------------------ RESIZING LOGIC --------------------
*/
const resizeDirRef = useRef<string | null>(null);
const resizeStart = useRef<{
mouseX: number;
mouseY: number;
width: number;
height: number;
x: number;
y: number;
} | null>(null);
const handleResizeStart = (e: React.MouseEvent, dir: string) => {
if (!modalRef.current) return;
e.preventDefault();
e.stopPropagation();
if (expanded) return; // don't allow resize when expanded
const rect = modalRef.current.getBoundingClientRect();
resizeDirRef.current = dir;
resizeStart.current = {
mouseX: e.clientX,
mouseY: e.clientY,
width: rect.width,
height: rect.height,
x: rect.left,
y: rect.top,
};
setResizing(true);
};
// Handle resizing mouse move / up
useEffect(() => {
if (!resizing) return;
let animationFrame: number | null = null;
const handleMouseMove = (e: MouseEvent) => {
if (!modalRef.current || !resizeStart.current || !resizeDirRef.current)
return;
const dx = e.clientX - resizeStart.current.mouseX;
const dy = e.clientY - resizeStart.current.mouseY;
let newWidth = resizeStart.current.width;
let newHeight = resizeStart.current.height;
let newX = resizeStart.current.x;
let newY = resizeStart.current.y;
const dir = resizeDirRef.current;
const minWidth = 320;
const minHeight = 240;
if (dir.includes("right")) {
newWidth = resizeStart.current.width + dx;
}
if (dir.includes("left")) {
newWidth = resizeStart.current.width - dx;
newX = resizeStart.current.x + dx;
}
if (dir.includes("bottom")) {
newHeight = resizeStart.current.height + dy;
}
if (dir.includes("top")) {
newHeight = resizeStart.current.height - dy;
newY = resizeStart.current.y + dy;
}
newWidth = Math.max(minWidth, newWidth);
newHeight = Math.max(minHeight, newHeight);
positionRef.current = { x: newX, y: newY };
sizeRef.current = { width: newWidth, height: newHeight };
if (animationFrame) cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
if (modalRef.current && positionRef.current) {
modalRef.current.style.width = newWidth + "px";
modalRef.current.style.height = newHeight + "px";
modalRef.current.style.left = positionRef.current.x + "px";
modalRef.current.style.top = positionRef.current.y + "px";
modalRef.current.style.margin = "0";
modalRef.current.style.position = "fixed";
modalRef.current.style.zIndex = String(zIndex);
}
});
};
const handleMouseUp = () => {
setResizing(false);
if (positionRef.current) setPosition({ ...positionRef.current });
if (sizeRef.current) setSize({ ...sizeRef.current });
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
if (animationFrame) cancelAnimationFrame(animationFrame);
};
}, [resizing, zIndex]);
// Render resize handles if resizable and not expanded
const renderResizeHandles = () => {
if (!resizable || expanded) return null;
const baseClass = "absolute z-[75] bg-transparent select-none";
const handleSize = 5; // px
const sideThickness = 2; // for edge handles
const handles = [
{ dir: "top", className: `top-0 left-0 w-full h-${sideThickness}` },
{ dir: "bottom", className: `bottom-0 left-0 w-full h-${sideThickness}` },
{ dir: "left", className: `left-0 top-0 h-full w-${sideThickness}` },
{ dir: "right", className: `right-0 top-0 h-full w-${sideThickness}` },
{
dir: "top-left",
className: `top-0 left-0 w-${handleSize} h-${handleSize}`,
},
{
dir: "top-right",
className: `top-0 right-0 w-${handleSize} h-${handleSize}`,
},
{
dir: "bottom-left",
className: `bottom-0 left-0 w-${handleSize} h-${handleSize}`,
},
{
dir: "bottom-right",
className: `bottom-0 right-0 w-${handleSize} h-${handleSize}`,
},
];
const cursorMap: Record<string, string> = {
top: "n-resize",
bottom: "s-resize",
left: "w-resize",
right: "e-resize",
"top-left": "nw-resize",
"top-right": "ne-resize",
"bottom-left": "sw-resize",
"bottom-right": "se-resize",
};
return handles.map((h) => (
<div
key={h.dir}
className={`${baseClass} ${h.className}`}
style={{ cursor: cursorMap[h.dir] }}
onMouseDown={(e) => handleResizeStart(e, h.dir)}
/>
));
};
// Size (width & height) state for resizable modal
const [size, setSize] = useState<{ width: number; height: number } | null>(
null
);
const sizeRef = useRef<{ width: number; height: number } | null>(null);
const lastSizeRef = useRef<{ width: number; height: number } | null>(null);
// Capture initial size on first open
useEffect(() => {
if (isOpen && !size && modalRef.current) {
const { width, height } = modalRef.current.getBoundingClientRect();
setSize({ width, height });
sizeRef.current = { width, height };
}
}, [isOpen, size]);
// Persist size across close / reopen
useEffect(() => {
if (!isOpen) {
if (sizeRef.current) lastSizeRef.current = { ...sizeRef.current };
} else {
if (lastSizeRef.current && modalRef.current) {
const { width, height } = lastSizeRef.current;
modalRef.current.style.width = width + "px";
modalRef.current.style.height = height + "px";
setSize({ width, height });
sizeRef.current = { width, height };
}
}
}, [isOpen]);
// Bring to front when user interacts with modal (mouse down anywhere inside)
useEffect(() => {
const handleBringToFront = () => {
if (modalRef.current) {
if (zIndex < modalZCounter) {
modalZCounter += 1;
setZIndex(modalZCounter);
modalRef.current.style.zIndex = String(modalZCounter);
}
}
};
const node = modalRef.current;
if (node) {
node.addEventListener("mousedown", handleBringToFront);
}
return () => {
if (node) node.removeEventListener("mousedown", handleBringToFront);
};
}, [zIndex]);
// If background interaction is allowed, ensure this modal (and its ancestors)
// never get the "inert" attribute Headless-UI uses to lock background dialogs.
useEffect(() => {
if (!allowBackgroundInteraction) return;
const node = modalRef.current as HTMLElement | null;
if (!node) return;
const stripInert = (el: HTMLElement | null) => {
if (!el) return;
if (el.hasAttribute("inert")) el.removeAttribute("inert");
// Also clear the property for browsers implementing it as mutable prop
// @ts-ignore
if ((el as any).inert) (el as any).inert = false;
if (el.getAttribute("aria-hidden") === "true") {
el.removeAttribute("aria-hidden");
}
};
// Remove inert from this modal and all its ancestors (Headless UI sets it on the modal container)
let cur: HTMLElement | null = node;
while (cur) {
stripInert(cur);
cur = cur.parentElement as HTMLElement | null;
}
// MutationObserver to keep stripping inert from THIS modal element if reapplied
const observer = new MutationObserver((mutations) => {
mutations.forEach((m) => {
if (
m.type === "attributes" &&
m.attributeName === "inert" &&
m.target instanceof HTMLElement
) {
const target = m.target as HTMLElement;
// Only touch if the target is this modal or one of its ancestors
if (target === node || node.contains(target)) {
stripInert(target);
}
}
});
});
observer.observe(node, {
attributes: true,
subtree: false,
attributeFilter: ["inert"],
});
// Additionally, Headless UI adds inert to previous portal containers that are
// siblings of the one just created. We strip inert from ANY portal container
// so long as background interaction is requested.
const stripInertFromPortals = () => {
const portals = document.querySelectorAll(
"[data-headlessui-portal][inert]"
);
portals.forEach((el) => el.removeAttribute("inert"));
};
stripInertFromPortals();
const globalObserver = new MutationObserver((mutList) => {
mutList.forEach((m) => {
if (
m.type === "attributes" &&
m.attributeName === "inert" &&
(m.target as HTMLElement).hasAttribute("inert")
) {
const target = m.target as HTMLElement;
if (target.hasAttribute("data-headlessui-portal")) {
stripInert(target as HTMLElement);
}
}
});
});
globalObserver.observe(document.body, {
attributes: true,
subtree: true,
attributeFilter: ["inert"],
});
return () => {
observer.disconnect();
globalObserver.disconnect();
};
}, [allowBackgroundInteraction]);
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-[70]"
onClose={onClose}
onClose={closeOnOutsideClick ? onClose : () => null}
static={allowBackgroundInteraction}
>
<div
@@ -424,7 +764,10 @@ export const Modal = ({
{/* Backdrop always rendered first in stacking context */}
{!allowBackgroundInteraction && (
<DialogBackdrop
className={backdropClassName}
className={twMerge(
allowBackgroundInteraction ? "pointer-events-none" : "",
backdropClassName
)}
onClose={onClose}
closeOnOutsideClick={closeOnOutsideClick}
disableHotkeyInput={disableHotkeyInput}
@@ -438,7 +781,14 @@ export const Modal = ({
/>
)}
<ModalExpandContext.Provider value={{ expanded, toggleExpanded }}>
<div className="flex min-h-full items-center justify-center p-4 text-center">
<div
className="flex min-h-full items-center justify-center p-4 text-center"
style={
allowBackgroundInteraction
? { pointerEvents: "none" }
: undefined
}
>
<TransitionChild
as="div"
enter="ease-out duration-200"
@@ -451,7 +801,9 @@ export const Modal = ({
"w-full max-w-lg transform rounded-xl relative border border-ui-panel-border bg-[#2C2C2C] text-left align-middle shadow-2xl z-[70]",
childPadding && !expanded ? "p-4" : "",
className,
dragging && !expanded ? "!transition-none" : "transition-all",
(dragging || resizing) && !expanded
? "!transition-none"
: "transition-all",
expanded &&
"w-screen h-screen max-w-screen max-h-screen rounded-none"
)}
@@ -495,9 +847,11 @@ export const Modal = ({
</DialogTitle>
)}
<div className={`h-full`.trim()}>{enhancedChildren}</div>
{/* resize handles inside panel so clicks don't count as outside */}
{renderResizeHandles()}
</DialogPanel>
{showClose && (
<div className="absolute top-0 right-0 p-2.5">
<div className="absolute top-0 right-0 m-2.5 z-[80]">
<CloseButton onClick={onClose} />
</div>
)}
@@ -5,6 +5,7 @@ import { SoraAccountButton } from "./SoraAccountButton";
import { SoraAccountBlock } from "./SoraAccountBlock";
import { ArtcraftAccountBlock } from "./ArtcraftAccountBlock";
import { FalApiKeyBlock } from "./FalApiKeyBlock";
import { OpenAIApiKeyBlock } from "./OpenAIKeyBlock";
interface AccountInfo {
username: string;
@@ -57,11 +58,9 @@ export const AccountSettingsPane = ({
*/}
<ArtcraftAccountBlock globalAccountLogoutCallback={globalAccountLogoutCallback}/>
<SoraAccountBlock />
<FalApiKeyBlock />
<OpenAIApiKeyBlock/>
{/*
<hr />
@@ -0,0 +1,71 @@
import { useState, useEffect } from "react";
import { GetOpenAIApiKey, SetOpenAIApiKey } from "@storyteller/tauri-api";
import { Input } from "@storyteller/ui-input";
export const OpenAIApiKeyBlock = () => {
const [openAIApiKey, setOpenAIApiKey] = useState<string | undefined>(
undefined
);
const [isCheckingOpenAIApiKey, setIsCheckingOpenAIApiKey] =
useState(false);
useEffect(() => {
const fetchKey = async () => {
setIsCheckingOpenAIApiKey(true);
try {
const result = await GetOpenAIApiKey();
if ("payload" in result) {
setOpenAIApiKey(result.payload.key);
}
} catch (e) {
console.error("Error fetching Fal API key", e);
setOpenAIApiKey(undefined);
} finally {
setIsCheckingOpenAIApiKey(false);
}
};
fetchKey();
}, []);
const syncOpenAIApiKey = async (key: string) => {
await SetOpenAIApiKey(key);
setOpenAIApiKey(key);
};
return (
<div className="flex justify-between items-center">
<span>Open AI API Key:</span>
<Input
value={openAIApiKey}
onChange={(e) => syncOpenAIApiKey(e.target.value)}
placeholder="Enter Open AI API key"
/>
{/*<Button
variant={
isCheckingArtcraftSession
? "secondary"
: isLoggedIn
? "destructive"
: "primary"
}
className="h-[30px]"
onClick={handleArtcraftButton}
disabled={isCheckingArtcraftSession}
>
{isCheckingArtcraftSession ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-sm"
/>
) : isLoggedIn ? (
"Log Out"
) : (
"Log In"
)}
</Button>*/}
</div>
);
};
+2
View File
@@ -6,6 +6,8 @@ export * from "./lib/fal/FalHunyuanImageTo3d.js";
export * from "./lib/fal/FalKlingImageToVideo.js";
export * from "./lib/fal/GetFalApiKey.js";
export * from "./lib/fal/SetFalApiKey.js";
export * from "./lib/openai/SetOpenAIApiKey.js";
export * from "./lib/openai/GetOpenAIApiKey.js";
export * from "./lib/settings/GetAppPreferences.js";
export * from "./lib/settings/UpdateAppPreference.js";
export * from "./lib/sora/CheckSoraSession.js";
@@ -0,0 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export interface GetOpenAIApiKeySuccessResult extends CommandResult {
payload: {
key: string;
};
}
export type GetOpenAIApiKeyResult = GetOpenAIApiKeySuccessResult | CommandResult;
export const GetOpenAIApiKey = async (): Promise<GetOpenAIApiKeyResult> => {
const result = await invoke("get_openai_api_key_command");
return result as GetOpenAIApiKeyResult;
};
@@ -0,0 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
import { CommandResult } from "../common/CommandStatus";
export const SetOpenAIApiKey = async (key: string): Promise<CommandResult> => {
const result = await invoke("set_openai_api_key_command", {
request: {
key: key,
}
});
return result as CommandResult;
};