mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 17:08:13 +00:00
create new scene from template/load scene ui (#230)
This commit is contained in:
1 parent
b39793a1a6
commit
0a186908fb
11 files changed
+378
-38
No files matched your search
@@ -9,7 +9,6 @@ import { Button, ButtonPropsI } from "./Button";
|
||||
import { TransitionDialogue } from "~/components/TransitionDialogue";
|
||||
|
||||
type UnionedButtonProps = { label?: string } & ButtonPropsI;
|
||||
type ModalPropsType = ButtonDropdownProps["options"][number]["dialogProps"];
|
||||
|
||||
interface ButtonDropdownProps {
|
||||
label: string;
|
||||
@@ -27,6 +26,7 @@ interface ButtonDropdownProps {
|
||||
confirmButtonProps?: UnionedButtonProps;
|
||||
closeButtonProps?: UnionedButtonProps;
|
||||
showClose?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
@@ -37,20 +37,31 @@ export const ButtonDropdown = ({
|
||||
icon,
|
||||
}: ButtonDropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [modalProps, setModalProps] = useState<ModalPropsType | null>(null);
|
||||
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const closeModal = () => setIsOpen(false);
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
options[selectedOptionIndex!].dialogProps?.onClose?.();
|
||||
};
|
||||
|
||||
const handleOptionClick = (option) => {
|
||||
const handleOptionClick = (index: number) => {
|
||||
const option = options[index];
|
||||
if (option.onClick) {
|
||||
option.onClick();
|
||||
}
|
||||
if (option.dialogProps) {
|
||||
setModalProps(option.dialogProps);
|
||||
setSelectedOptionIndex(index);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDialogProps =
|
||||
selectedOptionIndex !== null
|
||||
? options[selectedOptionIndex].dialogProps
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Menu as="div" className="inline-block text-left">
|
||||
@@ -84,7 +95,7 @@ export const ButtonDropdown = ({
|
||||
className={`duration-50 bg-brand-secondary font-medium text-white transition-all ${
|
||||
active ? "bg-ui-controls-button/60" : ""
|
||||
} ${option.disabled ? "pointer-events-none opacity-40" : ""} group flex w-full items-center py-1.5 pl-7 pr-4 text-sm`.trim()}
|
||||
onClick={() => handleOptionClick(option)}>
|
||||
onClick={() => handleOptionClick(index)}>
|
||||
<div className="flex w-full">
|
||||
<div className="grow text-start">{option.label}</div>
|
||||
<div className="ml-10 font-normal text-white/75">
|
||||
@@ -101,34 +112,35 @@ export const ButtonDropdown = ({
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
||||
{modalProps && (
|
||||
{currentDialogProps && (
|
||||
<TransitionDialogue
|
||||
title={modalProps.title}
|
||||
title={currentDialogProps.title}
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className={modalProps.className}>
|
||||
{modalProps.content}
|
||||
className={currentDialogProps.className}>
|
||||
{currentDialogProps.content}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{modalProps.showClose !== false && modalProps.closeButtonProps && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
{...modalProps.closeButtonProps}
|
||||
onClick={closeModal}>
|
||||
{modalProps.closeButtonProps.label}
|
||||
</Button>
|
||||
)}
|
||||
{currentDialogProps.showClose !== false &&
|
||||
currentDialogProps.closeButtonProps && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
{...currentDialogProps.closeButtonProps}
|
||||
onClick={closeModal}>
|
||||
{currentDialogProps.closeButtonProps.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{modalProps.confirmButtonProps && (
|
||||
{currentDialogProps.confirmButtonProps && (
|
||||
<Button
|
||||
{...modalProps.confirmButtonProps}
|
||||
{...currentDialogProps.confirmButtonProps}
|
||||
onClick={(e) => {
|
||||
if (modalProps.confirmButtonProps?.onClick) {
|
||||
modalProps.confirmButtonProps?.onClick(e);
|
||||
if (currentDialogProps.confirmButtonProps?.onClick) {
|
||||
currentDialogProps.confirmButtonProps?.onClick(e);
|
||||
}
|
||||
closeModal();
|
||||
}}>
|
||||
{modalProps.confirmButtonProps.label || "Confirm"}
|
||||
{currentDialogProps.confirmButtonProps.label || "Confirm"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, Fragment, ReactNode } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Fragment, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
disableHotkeyInput,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ToasterContext } from "~/contexts/ToasterContext";
|
||||
import { TestFeaturesButtons } from "./TestFeaturesButtons";
|
||||
import { Help } from "./Help";
|
||||
import { faFile } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { LoadScene } from "./LoadScene";
|
||||
import { NewSceneFromTemplate } from "./NewSceneFromTemplate";
|
||||
|
||||
export const ControlsTopButtons = () => {
|
||||
const editorEngine = useContext(EngineContext);
|
||||
@@ -38,6 +40,11 @@ export const ControlsTopButtons = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSceneSelection = (token: string) => {
|
||||
setSceneToken(token);
|
||||
console.log(`Selected Scene Token is ${token}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pl-3 pt-3">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -53,9 +60,11 @@ export const ControlsTopButtons = () => {
|
||||
content: (
|
||||
<Input
|
||||
label="Please enter a name for your new scene"
|
||||
value={sceneName}
|
||||
onChange={(e) => {
|
||||
setSceneToken(e.target.value);
|
||||
setSceneName(e.target.value);
|
||||
}}
|
||||
autoComplete="false"
|
||||
/>
|
||||
),
|
||||
confirmButtonProps: {
|
||||
@@ -67,6 +76,27 @@ export const ControlsTopButtons = () => {
|
||||
label: "Cancel",
|
||||
},
|
||||
showClose: true,
|
||||
onClose: () => setSceneName(""),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "New scene from template...",
|
||||
description: "Ctrl+Shift+N",
|
||||
dialogProps: {
|
||||
title: "Create a New Scene from Template",
|
||||
content: (
|
||||
<NewSceneFromTemplate onSceneSelect={handleSceneSelection} />
|
||||
),
|
||||
confirmButtonProps: {
|
||||
label: "Create",
|
||||
disabled: sceneToken === "",
|
||||
onClick: handleButtonLoadScene,
|
||||
},
|
||||
closeButtonProps: {
|
||||
label: "Cancel",
|
||||
},
|
||||
showClose: true,
|
||||
className: "max-w-7xl w-auto",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -74,14 +104,7 @@ export const ControlsTopButtons = () => {
|
||||
description: "Ctrl+O",
|
||||
dialogProps: {
|
||||
title: "Load a Scene",
|
||||
content: (
|
||||
<Input
|
||||
label="Please provide the token of the scene you want to load"
|
||||
onChange={(e) => {
|
||||
setSceneToken(e.target.value);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
content: <LoadScene onSceneSelect={handleSceneSelection} />,
|
||||
confirmButtonProps: {
|
||||
label: "Load",
|
||||
disabled: sceneToken === "",
|
||||
@@ -91,10 +114,11 @@ export const ControlsTopButtons = () => {
|
||||
label: "Cancel",
|
||||
},
|
||||
showClose: true,
|
||||
className: "max-w-7xl w-auto",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
disabled: true, // save scene should be disabled if there are no changes
|
||||
label: "Save scene",
|
||||
description: "Ctrl+S",
|
||||
dialogProps: {
|
||||
@@ -119,7 +143,6 @@ export const ControlsTopButtons = () => {
|
||||
},
|
||||
divider: true,
|
||||
},
|
||||
|
||||
{
|
||||
label: "Save scene as copy",
|
||||
description: "Ctrl+Shift+S",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScenePicker, SceneTypes } from "../ScenePicker";
|
||||
import { Label } from "~/components";
|
||||
|
||||
interface LoadSceneProps {
|
||||
onSceneSelect: (token: string) => void;
|
||||
}
|
||||
|
||||
export const LoadScene = ({ onSceneSelect }: LoadSceneProps) => {
|
||||
// Dummy Data - replace with API data
|
||||
const dummyScenes: SceneTypes[] = [
|
||||
{
|
||||
token: "111",
|
||||
name: "Dragon",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "222",
|
||||
name: "Dancing Girl",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "333",
|
||||
name: "Dragon",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "444",
|
||||
name: "Home Office",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "555",
|
||||
name: "Dragon",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "666",
|
||||
name: "Dancing Girl",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "777",
|
||||
name: "Dragon",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "888",
|
||||
name: "Dancing Girl",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "999",
|
||||
name: "Dragon",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "000",
|
||||
name: "Home Office",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "0001",
|
||||
name: "Home Office",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "0002",
|
||||
name: "Home Office",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "0003",
|
||||
name: "Home Office",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "0004",
|
||||
name: "Home Office",
|
||||
updated_at: "Apr 03, 2024 14:24:48",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
];
|
||||
|
||||
const handleSceneSelect = (selectedScene: SceneTypes) => {
|
||||
onSceneSelect(selectedScene.token);
|
||||
};
|
||||
const [bottomGradientOpacity, setBottomGradientOpacity] = useState(1);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = () => {
|
||||
const element = scrollContainerRef.current;
|
||||
if (element) {
|
||||
const atBottom =
|
||||
element.scrollHeight - element.scrollTop === element.clientHeight;
|
||||
const hasOverflow = element.scrollHeight > element.clientHeight;
|
||||
|
||||
setBottomGradientOpacity(hasOverflow && !atBottom ? 1 : 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollContainerRef.current;
|
||||
if (element) {
|
||||
handleScroll();
|
||||
element.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label>My Scenes</Label>
|
||||
<div className="relative flex max-h-[500px] flex-col">
|
||||
<div
|
||||
className="overflow-y-auto overflow-x-hidden"
|
||||
ref={scrollContainerRef}>
|
||||
<ScenePicker
|
||||
scenes={dummyScenes}
|
||||
onSceneSelect={handleSceneSelect}
|
||||
showDate={true}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-10 bg-gradient-to-t from-ui-panel to-transparent transition-opacity duration-200"
|
||||
style={{ opacity: bottomGradientOpacity }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { LoadScene } from "./LoadScene";
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ScenePicker, SceneTypes } from "../ScenePicker";
|
||||
|
||||
interface NewSceneFromTemplateProps {
|
||||
onSceneSelect: (token: string) => void;
|
||||
}
|
||||
|
||||
export const NewSceneFromTemplate = ({
|
||||
onSceneSelect,
|
||||
}: NewSceneFromTemplateProps) => {
|
||||
// Dummy Data - replace with API data
|
||||
const dummyScenes: SceneTypes[] = [
|
||||
{
|
||||
token: "m_9pf7a7v0138zx58f4x6ejsehjcvfq6",
|
||||
name: "Dragon",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
{
|
||||
token: "m_nmzvdqr6kr8eqpmxqdzkqj0yknrjwv",
|
||||
name: "Dancing Girl",
|
||||
thumbnail: "/resources/placeholders/scene_placeholder.png",
|
||||
},
|
||||
];
|
||||
|
||||
const handleSceneSelect = (selectedScene: SceneTypes) => {
|
||||
onSceneSelect(selectedScene.token);
|
||||
};
|
||||
const [bottomGradientOpacity, setBottomGradientOpacity] = useState(1);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = () => {
|
||||
const element = scrollContainerRef.current;
|
||||
if (element) {
|
||||
const atBottom =
|
||||
element.scrollHeight - element.scrollTop === element.clientHeight;
|
||||
const hasOverflow = element.scrollHeight > element.clientHeight;
|
||||
|
||||
setBottomGradientOpacity(hasOverflow && !atBottom ? 1 : 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollContainerRef.current;
|
||||
if (element) {
|
||||
handleScroll();
|
||||
element.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative flex max-h-[500px] flex-col">
|
||||
<div
|
||||
className="overflow-y-auto overflow-x-hidden"
|
||||
ref={scrollContainerRef}>
|
||||
<ScenePicker scenes={dummyScenes} onSceneSelect={handleSceneSelect} />
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-10 bg-gradient-to-t from-ui-panel to-transparent transition-opacity duration-200"
|
||||
style={{ opacity: bottomGradientOpacity }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { NewSceneFromTemplate } from "./NewSceneFromTemplate";
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type SceneTypes = {
|
||||
token: string;
|
||||
name: string;
|
||||
updated_at?: string;
|
||||
thumbnail: string;
|
||||
};
|
||||
|
||||
interface ScenePickerProps {
|
||||
scenes: SceneTypes[];
|
||||
onSceneSelect: (selectedScene: SceneTypes) => void;
|
||||
showDate?: boolean;
|
||||
}
|
||||
|
||||
export const ScenePicker: React.FC<ScenePickerProps> = ({
|
||||
scenes,
|
||||
onSceneSelect,
|
||||
showDate,
|
||||
}) => {
|
||||
const [selectedSceneId, setSelectedSceneId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scenes.length > 0 && !selectedSceneId) {
|
||||
setSelectedSceneId(scenes[0].token);
|
||||
onSceneSelect(scenes[0]); // Trigger the callback with the first scene
|
||||
}
|
||||
}, [scenes, onSceneSelect, selectedSceneId]);
|
||||
|
||||
const handleSelected = (scene: SceneTypes) => {
|
||||
setSelectedSceneId(scene.token);
|
||||
onSceneSelect(scene);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{scenes.map((scene) => (
|
||||
<button
|
||||
key={scene.token}
|
||||
className={twMerge(
|
||||
"relative w-[260px] cursor-pointer overflow-hidden rounded-lg border-2 transition-colors ease-in-out ",
|
||||
selectedSceneId === scene.token
|
||||
? "border-brand-primary"
|
||||
: "border-[#4B4B5C] hover:border-ui-controls-button",
|
||||
)}
|
||||
onClick={() => handleSelected(scene)}>
|
||||
<img
|
||||
className="aspect-video object-cover"
|
||||
src={scene.thumbnail}
|
||||
alt={scene.name}
|
||||
/>
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-gradient-to-t from-ui-panel to-transparent" />
|
||||
<div className="absolute bottom-[8px] left-[10px] text-start text-sm drop-shadow-md">
|
||||
<div className="flex flex-col">
|
||||
<span className="w-60 truncate text-sm font-medium">
|
||||
{scene.name}
|
||||
</span>
|
||||
{showDate && (
|
||||
<span className="text-xs opacity-70">{scene.updated_at}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
className={`absolute right-1.5 top-1.5 h-[22px] w-[22px] transition-opacity duration-200 ease-in-out ${
|
||||
selectedSceneId === scene.token ? "opacity-100" : "opacity-0"
|
||||
}`}>
|
||||
<path
|
||||
opacity="1"
|
||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
|
||||
fill="#FC6B68"
|
||||
/>
|
||||
<path
|
||||
d="M369 175c-9.4 9.4-9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ScenePicker } from "./ScenePicker";
|
||||
export type { SceneTypes } from "./ScenePicker";
|
||||
@@ -27,7 +27,9 @@ export const ItemPicker = ({
|
||||
<button
|
||||
className={twMerge(
|
||||
"relative cursor-pointer overflow-hidden rounded-lg border-2 transition-colors ease-in-out",
|
||||
selected ? "border-brand-primary" : "border-ui-border",
|
||||
selected
|
||||
? "border-brand-primary"
|
||||
: "border-[#4B4B5C] hover:border-ui-controls-button",
|
||||
)}
|
||||
style={{
|
||||
minWidth: (width as number) + 4,
|
||||
@@ -47,7 +49,7 @@ export const ItemPicker = ({
|
||||
maxHeight: height,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-gradient-to-t from-gray-700" />
|
||||
<div className="absolute left-0 top-0 h-full w-full bg-gradient-to-t from-ui-panel" />
|
||||
<H4 className="absolute bottom-[1px] left-[6px] text-start text-[13px] drop-shadow-md">
|
||||
{label}
|
||||
</H4>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Reference in new issue
Block a user