Compare commits

...
Author SHA1 Message Date
Brandon Thomas c8e6ba178e github link 2025-09-29 23:09:32 -04:00
bflatastic bd93cd4f1f plan text update 2 2025-09-29 23:09:52 +07:00
bflatastic 39ad16e856 plans text change 2025-09-29 23:05:58 +07:00
bflatastic 64d589521a netlify redirects for tutorial page 2025-09-29 22:53:29 +07:00
BFlat 8c0529be0b tutorial modal for app and tutorial page for website (#859)
shared component for tutorials

app:
- tutorial modal accessible from any page on the app on the bottom right
- plays right in the modal through youtube embed
- it remembers the play progress and saves it in the zustand state so in
that current session if modal is opened again, it will continue where
the user left off

website:
- navbar updates (home and tutorials link)
- tutorial page with category buttons to filter by product or show all
- plays through youtube embed on website page
2025-09-29 16:19:59 +07:00
Brandon Thomas f198d5e90c bump version of binaries on website 2025-09-28 02:01:26 -04:00
BFlat a7f4a6eda4 dots dropdown on gallery item to edit image or delete (#858)
- each gallery item now has the three dots button on hover, clicking
shows a dropdown to edit image or delete
- reduced page size to 100 due to lag
2025-09-28 09:10:37 +07:00
Brandon Thomas 450c73a29d remove gpu loading error (#857) 2025-09-27 18:28:02 -04:00
48 changed files with 1087 additions and 678 deletions
@@ -0,0 +1,4 @@
/tutorials /index.html 200
/tutorials/ /index.html 200
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -8,9 +8,9 @@ nx build artcraft-website
echo "Change to project dir"
pushd apps/artcraft-website/
#echo "Copy netlify configs into dist"
echo "Copy netlify configs into dist"
#cp _headers dist/
#cp _redirects dist/
cp _redirects dist/
#echo "Copy netlify 404.html page into dist"
#cp "404.html" dist/
@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router-dom";
import Download from "../pages/download";
import Navbar from "../components/navbar";
import Landing2 from "../pages/landing2";
import TutorialsPage from "../pages/tutorials";
export function App() {
return (
@@ -11,6 +12,7 @@ export function App() {
<Routes>
<Route path="/" element={<Landing2 />} />
<Route path="/download" element={<Download />} />
<Route path="/tutorials" element={<TutorialsPage />} />
</Routes>
</div>
);
@@ -3,6 +3,8 @@ import {
faTiktok,
faDiscord,
faYoutube,
faGithub,
faGithubAlt,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
@@ -27,6 +29,11 @@ const navigation = {
href: SOCIAL_LINKS.TIKTOK,
icon: (props: any) => <FontAwesomeIcon icon={faTiktok} {...props} />,
},
{
name: "GitHub",
href: SOCIAL_LINKS.GITHUB,
icon: (props: any) => <FontAwesomeIcon icon={faGithubAlt} {...props} />,
},
],
};
@@ -1,10 +1,37 @@
import { Disclosure } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { DiscordButton } from "../discord-button";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
];
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const location = useLocation();
const [underlineAnim, setUnderlineAnim] = useState<
Record<string, "in" | "out" | null>
>({});
const outTimersRef = useRef<Record<string, number | undefined>>({});
const handleEnter = (href: string) => {
if (outTimersRef.current[href] !== undefined) {
window.clearTimeout(outTimersRef.current[href]);
delete outTimersRef.current[href];
}
setUnderlineAnim((prev) => ({ ...prev, [href]: "in" }));
};
const handleLeave = (href: string) => {
setUnderlineAnim((prev) => ({ ...prev, [href]: "out" }));
outTimersRef.current[href] = window.setTimeout(() => {
setUnderlineAnim((prev) => ({ ...prev, [href]: null }));
delete outTimersRef.current[href];
}, 260);
};
useEffect(() => {
const handleScroll = () => {
@@ -37,23 +64,49 @@ export default function Navbar() {
/>
</a>
</div>
{/* <div className="hidden md:ml-7 md:flex md:items-center md:space-x-3">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
aria-current={item.current ? "page" : undefined}
className={twMerge(
item.current
? "text-white/80 hover:text-white"
: "text-white/80 hover:text-white",
"rounded-md px-3 py-2 text-[15px] font-medium transition-all"
)}
>
{item.name}
</a>
))}
</div> */}
<div className="hidden md:ml-10 md:flex md:items-center md:space-x-6">
{NAV_ITEMS.map((item) => {
const isCurrent = location.pathname === item.href;
const anim = underlineAnim[item.href] ?? null;
return (
<a
key={item.name}
href={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
isCurrent
? "text-white"
: "text-white/60 hover:text-white",
"relative rounded-md text-[15px] font-semibold transition-all"
)}
onMouseEnter={() => !isCurrent && handleEnter(item.href)}
onMouseLeave={() => !isCurrent && handleLeave(item.href)}
>
<span className="relative z-10">{item.name}</span>
<span
className={twMerge(
"pointer-events-none absolute left-0 right-0 -bottom-1 h-[2px] overflow-hidden",
isCurrent ? "" : ""
)}
aria-hidden="true"
>
<span
className={twMerge(
"link-underline block h-full w-full bg-primary/90",
isCurrent
? "visible-line"
: anim === "in"
? "animate-in"
: anim === "out"
? "animate-out"
: "hidden-line"
)}
/>
</span>
</a>
);
})}
</div>
</div>
<div className="flex items-center">
<div className="hidden md:ml-4 md:flex md:shrink-0 md:items-center">
@@ -96,6 +149,31 @@ export default function Navbar() {
))}
</div>
</DisclosurePanel> */}
<style>{`
.link-underline {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
}
.visible-line {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.hidden-line {
transform: scaleX(0) translateX(0);
opacity: 0;
}
@keyframes underline-in {
0% { transform: scaleX(0) translateX(0); opacity: 0.8; }
100% { transform: scaleX(1) translateX(0); opacity: 1; }
}
@keyframes underline-out {
0% { transform: scaleX(1) translateX(0); opacity: 1; }
100% { transform: scaleX(1) translateX(100%); opacity: 0; }
}
.animate-in { animation: underline-in 220ms ease-out forwards; }
.animate-out { animation: underline-out 220ms ease-in forwards; }
`}</style>
</Disclosure>
);
}
@@ -2,4 +2,5 @@ export const SOCIAL_LINKS = {
DISCORD: "https://discord.gg/75svZP2Vje",
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
GITHUB: "https://github.com/storytold",
} as const;
@@ -1,3 +1,4 @@
export * from "./landing";
export * from "./landing2";
export * from "./download";
export * from "./tutorials";
@@ -23,7 +23,8 @@ import ModelBadgeGrid from "../../components/model-badge-grid";
//const MAC_VERSION = "2025.07.28";
//const MAC_VERSION = "2025.08.23"; // NB: The "08.22 (twenty two)" release is broken.
//const MAC_VERSION = "2025.09.23";
const MAC_VERSION = "2025.09.24";
//const MAC_VERSION = "2025.09.24"; // Works! Good release.
const MAC_VERSION = "2025.09.27";
// Current Windows version (update this when a new version is released)
// If a rollback is necessary, roll back to the last known good date.
@@ -33,7 +34,8 @@ const MAC_VERSION = "2025.09.24";
//const WINDOWS_VERSION = "2025.07.28";
//const WINDOWS_VERSION = "2025.08.22";
//const WINDOWS_VERSION = "2025.09.23";
const WINDOWS_VERSION = "2025.09.24";
//const WINDOWS_VERSION = "2025.09.24"; // Works! Good release.
const WINDOWS_VERSION = "2025.09.27";
// Link to the current production Mac build
const MAC_LINK = `https://pub-3b58c874772a4e04b9c291815224128c.r2.dev/mac/ArtCraft_0.0.1_universal_${MAC_VERSION}.dmg`;
@@ -0,0 +1 @@
export { default } from "./tutorials";
@@ -0,0 +1,139 @@
import { useMemo, useState } from "react";
import { twMerge } from "tailwind-merge";
import { defaultTutorials, TutorialItem } from "@storyteller/ui-tutorial-modal";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft, faPlay } from "@fortawesome/pro-solid-svg-icons";
const categoriesFrom = (items: TutorialItem[]): string[] => {
const set = new Set<string>();
for (const t of items) if (t.category) set.add(t.category);
return ["All", ...Array.from(set)];
};
const websiteThumb = (url: string): string => {
// Map shared modal thumbnail paths to website public path
return url.startsWith("/resources/images/")
? url.replace("/resources/images/", "/images/")
: url;
};
export const TutorialsPage = () => {
const [activeCategory, setActiveCategory] = useState<string>("All");
const [selected, setSelected] = useState<TutorialItem | null>(null);
const tutorials = defaultTutorials;
const categories = useMemo(() => categoriesFrom(tutorials), [tutorials]);
const visible = useMemo(() => {
if (activeCategory === "All") return tutorials;
return tutorials.filter((t) => t.category === activeCategory);
}, [activeCategory, tutorials]);
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-25 blur-[120px]"></div>
</div>
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
{/* Hero */}
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-4xl sm:text-6xl font-bold mb-3">Tutorials</h1>
<p className="text-white/70 text-base sm:text-lg">
Learn tips, tricks, and workflows for ArtCraft.
</p>
</div>
{/* Categories */}
{!selected && (
<div className="flex flex-wrap gap-3 justify-center mb-8">
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setActiveCategory(cat)}
className={twMerge(
"px-4 py-2 rounded-xl border",
activeCategory === cat
? "bg-primary/30 border-primary/90"
: "bg-white/5 border-white/10 hover:bg-white/10"
)}
>
{cat}
</button>
))}
</div>
)}
{/* Content */}
{!selected ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setSelected(item)}
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
>
<div className="aspect-video w-full overflow-hidden relative">
<img
src={websiteThumb(item.thumbnailUrl)}
alt={item.title}
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="inline-flex items-center gap-2 text-white/90 text-sm font-medium">
<FontAwesomeIcon icon={faPlay} />
Watch
</span>
</div>
</div>
<div className="p-3 text-sm text-white/90 flex items-center justify-between">
<span>{item.title}</span>
{item.category && (
<span className="px-2 py-0.5 text-xs rounded-full bg-white/10 border border-white/10">
{item.category}
</span>
)}
</div>
</button>
))}
</div>
) : (
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center gap-4 md:flex-row flex-col">
<Button
onClick={() => setSelected(null)}
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium border-none"
variant="action"
icon={faChevronLeft}
>
Back
</Button>
<div className="text-lg font-bold text-base-fg">
Tutorial: {selected.title}
</div>
</div>
<div className="w-full">
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
<iframe
title={selected.title}
src={selected.videoUrl.replace(
"youtu.be/",
"www.youtube.com/embed/"
)}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default TutorialsPage;
@@ -37,6 +37,9 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/tutorial-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
}
@@ -2,6 +2,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/components/button"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

@@ -19,6 +19,7 @@ import {
} from "@storyteller/ui-model-selector";
import { useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
import { getSelectedImageModel } from "@storyteller/ui-model-selector";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
@@ -451,6 +452,9 @@ const PageDraw = () => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</>
);
};
@@ -9,6 +9,7 @@ import toast from "react-hot-toast";
import { uploadImage } from "~/components/reusable/UploadModalMedia/uploadImage";
import { UploaderStates } from "~/enums";
import { MediaFilesApi } from "@storyteller/api";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
export interface BaseSelectorImage {
url: string;
@@ -175,6 +176,9 @@ export const BaseImageSelector = ({
</div>
)}
</div>
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={handleGalleryClose}
@@ -19,6 +19,7 @@ import {
} from "@storyteller/ui-model-selector";
import { ImageModel } from "@storyteller/model-list";
import { HistoryStack, ImageBundle } from "./HistoryStack";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.ImageEditor;
@@ -473,6 +474,9 @@ const PageEdit = () => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</>
);
};
@@ -77,6 +77,7 @@ import PageDraw from "../PageDraw/PageDraw";
import { useTabStore } from "../Stores/TabState";
import PageEdit from "../PageEdit/PageEdit";
import { ImageModel } from "@storyteller/model-list";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
@@ -551,6 +552,9 @@ export const PageEditor = () => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</div>
</div>
</div>
@@ -11,7 +11,6 @@ import { useSignals } from "@preact/signals-react/runtime";
import { useEffect, useState } from "react";
import * as gpu from "detect-gpu";
import { UsersApi } from "~/Classes/ApiManager";
import { TurnOnGpu } from "~/pages/PageEnigma/TurnOnGpu";
import { PrecisionSelector } from "./comps/PrecisionSelector/PrecisionSelector";
import {
precisionSelectedValue,
@@ -22,7 +21,6 @@ import {
import { InstallSounds } from "~/pages/PageEnigma/InstallSounds";
import { PageEditor } from "~/pages/PageEnigma/PageEditor";
import { GalleryDragComponent } from "@storyteller/ui-gallery-modal";
import { LoadingDots } from "@storyteller/ui-loading";
import {
PricingModal,
CreditsModal,
@@ -105,13 +103,6 @@ export const PageEnigma = ({ sceneToken }: { sceneToken?: string }) => {
InstallSounds();
});
if (validGpu === "unknown") {
return <LoadingDots />;
}
if (validGpu === "error") {
return <TurnOnGpu />;
}
const currentReminderModalProps = actionReminderProps.value;
return (
@@ -1,210 +0,0 @@
import * as CopyToClipboard from "react-copy-to-clipboard";
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy } from "@fortawesome/pro-solid-svg-icons";
export const TurnOnGpu = () => {
const [copied1, setCopied1] = useState(false);
const [copied2, setCopied2] = useState(false);
useEffect(() => {
if (copied1) {
setTimeout(() => setCopied1(false), 2000);
}
}, [copied1]);
useEffect(() => {
if (copied2) {
setTimeout(() => setCopied2(false), 2000);
}
}, [copied2]);
return (
<div className="fixed inset-0 h-screen overflow-y-auto p-16">
<div>
Hardware acceleration is <b>required</b> for StoryTeller Studio to run
properly. To check if this is enabled, please follow the steps for your
browser below.
</div>
<div className="my-4">
<b>Google Chrome: </b>
</div>
<ol>
<li>
<div>
Click the three stacked dots in upper right of the browser window.
</div>
</li>
<li>
<div>
Click the arrow next to <em>&quot;Advanced&quot;</em> and select{" "}
<em>&quot;System.&quot;</em>
</div>
</li>
<li>
<div>
Make sure the box next to{" "}
<em>&quot;Use hardware acceleration when available&quot;</em> is
toggled on.
</div>
</li>
<li>
<div>
Close and restart the browser for the change to take effect.
</div>
</li>
<ul>
<li>
<div>
You can also copy and paste this link{" "}
<CopyToClipboard.CopyToClipboard
text="chrome://settings/system"
onCopy={() => setCopied1(true)}
>
<span className="text-blue">
chrome://settings/system&nbsp;
{copied1 ? (
<span style={{ color: "red" }}>Copied.</span>
) : (
<span className="text-sm">
<FontAwesomeIcon icon={faCopy} />
</span>
)}
&nbsp;
</span>
</CopyToClipboard.CopyToClipboard>
into the address bar. This will bring you right to the system
settings.
</div>
</li>
</ul>
</ol>
<div className="my-4">
<b>Firefox:</b>
</div>
<ol>
<li>
<div>
Click on <em>&quot;Firefox&quot;</em> then{" "}
<em>&quot;Preferences.&quot;</em>
</div>
</li>
<li>
<div>
In the&nbsp;<em>&quot;General&quot;</em>&nbsp;panel, go to the&nbsp;
<em>&quot;Performance&quot; </em>section.
</div>
</li>
<li>
<div>
Uncheck the box next to&nbsp;
<em>&quot;Use recommended performance settings&quot;</em> and make
sure the box next to{" "}
<em>&quot;Use hardware acceleration when available&quot;</em> is
selected.
</div>
</li>
<li>
<div>
Close and restart the browser for the change to take effect.
</div>
</li>
<ul>
<li>
<div>
If you need help, please follow this link:{" "}
<a
href="https://support.mozilla.org/en-US/kb/performance-settings?as=u&amp;utm_source=inproduct"
target="_blank"
rel="noreferrer"
className="text-blue underline"
>
https://support.mozilla.org/en-US/kb/performance-settings?as=u&amp;utm_source=inproduct
</a>
</div>
</li>
</ul>
</ol>
<div className="my-4">
<b>Microsoft Edge:</b>
</div>
<ol>
<li>
<div>
Click the &quot;3 dots&quot; menu icon in the upper right of the
browser window
</div>
</li>
<li>
<div>
Click on&nbsp;<em>&quot;Settings&quot;</em> then{" "}
<em>&quot;System&quot;</em> on the left side.
</div>
</li>
<li>
<div>
Toggle on{" "}
<em>&quot;Use hardware acceleration when available&quot;</em> if
this is not enabled.
</div>
</li>
<li>
<div>
Close and restart the browser for the change to take effect.
</div>
</li>
<ul>
<li>
<div>
You can also copy and paste this link{" "}
<CopyToClipboard.CopyToClipboard
text="edge://settings/system"
onCopy={() => setCopied2(true)}
>
<span className="text-blue">
edge://settings/system&nbsp;
{copied2 ? (
<span style={{ color: "red" }}>Copied.</span>
) : (
<span className="text-sm">
<FontAwesomeIcon icon={faCopy} />
</span>
)}
&nbsp;
</span>
</CopyToClipboard.CopyToClipboard>
into the address bar. This will bring you right to the system
settings.
</div>
</li>
</ul>
</ol>
<div className="my-4">
<b>Safari (Mac Only)</b>
</div>
<div>
As of macOS Catalina (version 10.15), Hardware acceleration is
automatically enabled, and there is no way turn this on and off. If you
are using an older version of MacOS, please use the steps below:
</div>
<ol>
<li>
<div>
Click on the &quot;Safari&quot; then &quot;Settings&quot; &gt;{" "}
</div>
</li>
<li>
<div>
Click &quot;Advanced&quot; on the top right of the Settings window
and ensure &quot;Use hardware acceleration&quot; is enabled
</div>
</li>
</ol>
<div>
If you are still having an issue with enabling hardware acceleration, we
recommend you update your graphics card drivers, if possible. If these
updates do not help, you may have to consider upgrading your graphics
card or playing on a different device.
</div>
</div>
);
};
@@ -23,6 +23,7 @@ import {
} from "@storyteller/ui-gallery-modal";
import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.TextToImage;
@@ -186,7 +187,7 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
>
<button
onClick={() => resetBatches()}
className="bg-red/20 hover:bg-red/30 rounded-md px-3 py-1 text-xs text-white/70 transition-colors"
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30"
>
Clear session
</button>
@@ -224,6 +225,9 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</div>
</div>
</div>
@@ -18,6 +18,7 @@ import {
// import { Badge } from "@storyteller/ui-badge";
import { twMerge } from "tailwind-merge";
import { uploadImage } from "../../components/reusable/UploadModalMedia/uploadImage";
import { TutorialModalButton } from "@storyteller/ui-tutorial-modal";
const PAGE_ID: ModelPage = ModelPage.ImageToVideo;
@@ -211,6 +212,9 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
triggerLabel="Model"
/>
</div>
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<TutorialModalButton />
</div>
</div>
</div>
</div>
+3
View File
@@ -76,6 +76,9 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/tutorial-modal"
},
{
"path": "../../libs/components/model-selector"
},
@@ -1,10 +1,21 @@
import React, { useRef } from "react";
import React, { useMemo, useRef } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import {
faCheck,
faEllipsis,
faPencil,
faTrashCan,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import galleryDnd from "./galleryDnd";
import { Tooltip } from "@storyteller/ui-tooltip";
import { GalleryItem } from "./gallery-modal";
import { PopoverMenu } from "@storyteller/ui-popover";
import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { MediaFilesApi } from "@storyteller/api";
type ModalMode = "select" | "view";
@@ -17,6 +28,8 @@ interface GalleryDraggableItemProps {
onImageError: () => void;
disableTooltipAndBadge?: boolean;
imageFit?: "cover" | "contain";
onDeleted?: (id: string) => void;
onEditClicked?: (url: string, media_id?: string) => Promise<void> | void;
}
export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
@@ -28,9 +41,40 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
onImageError,
disableTooltipAndBadge = false,
imageFit = "cover",
onDeleted,
onEditClicked,
}) => {
const imgRef = useRef<HTMLImageElement>(null);
const dragStarted = useRef(false);
const mediaFilesApi = useMemo(() => new MediaFilesApi(), []);
const handleDelete = () => {
showActionReminder({
reminderType: "default",
title: "Delete this media?",
message: (
<p className="text-sm text-white/70">
This will permanently remove the media from your library. This action
cannot be undone.
</p>
),
primaryActionText: "Delete",
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
const res = await mediaFilesApi.DeleteMediaFileByToken({
mediaFileToken: item.id,
});
if (res.success) {
onDeleted?.(item.id);
}
} finally {
isActionReminderOpen.value = false;
}
},
});
};
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
// Disable dragging for video items
@@ -77,9 +121,65 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
activeFilter === "video" ? "aspect-square" : "aspect-square"
)}
>
{/* dropdown menu */}
<div
className="absolute right-2 top-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-75"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
}}
>
<PopoverMenu
position="bottom"
align="end"
mode="default"
triggerIcon={
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="min-w-28 p-1"
closeOnUnhover
>
{(close) => (
<div className="flex flex-col">
{item.mediaClass === "image" && (
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm"
onClick={async (e) => {
e.stopPropagation();
if (onEditClicked && (item.fullImage || item.thumbnail)) {
await onEditClicked(
item.fullImage || item.thumbnail!,
item.id
);
}
close();
}}
>
<FontAwesomeIcon icon={faPencil} className="text-base-fg" />
<span>Edit image</span>
</button>
)}
<button
type="button"
className="flex items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
close();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="text-red" />
<span className="text-red">Delete</span>
</button>
</div>
)}
</PopoverMenu>
</div>
{/* Media class badge on hover */}
{!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute right-2 top-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<div className="pointer-events-none absolute left-2 top-2 z-20 rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass === "dimensional" ? "3D" : item.mediaClass}
</div>
)}
@@ -127,7 +227,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
/>
)}
{selected && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<div className="absolute right-8 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon icon={faCheck} className="text-sm" />
</div>
)}
@@ -199,7 +299,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
/>
)}
{selected && (
<div className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<div className="absolute right-8 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary">
<FontAwesomeIcon icon={faCheck} className="text-sm" />
</div>
)}
@@ -120,7 +120,7 @@ export const GalleryModal = React.memo(
// NB: We've got some kind of cursoring issue where subsequent pages are not requested.
// For now, let's set the pagination limit really high.
const pageSize = 1000;
const pageSize = 100;
const imageUrl = lightboxImageSignal.value?.fullImage || "";
@@ -339,6 +339,10 @@ export const GalleryModal = React.memo(
onUseSelected?.(selectedItems);
}, [groupItemsByDate, selectedItemIds, onUseSelected]);
const handleItemDeleted = useCallback((id: string) => {
setAllItems((prev) => prev.filter((it) => it.id !== id));
}, []);
useSignals();
// Synchronize lightbox image when external media id signal is set
@@ -609,6 +613,8 @@ export const GalleryModal = React.memo(
}
disableTooltipAndBadge={mode === "select"}
imageFit={imageFit}
onDeleted={handleItemDeleted}
onEditClicked={onEditClicked}
/>
))}
</div>
@@ -5,12 +5,6 @@
{
"path": "../slider-v2"
},
{
"path": "../popover"
},
{
"path": "../../api"
},
{
"path": "../loading-spinner"
},
@@ -26,6 +20,15 @@
{
"path": "../modal"
},
{
"path": "../../api"
},
{
"path": "../action-reminder-modal"
},
{
"path": "../popover"
},
{
"path": "../tooltip"
},
@@ -46,12 +46,6 @@
{
"path": "../slider-v2/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../loading-spinner/tsconfig.lib.json"
},
@@ -67,6 +61,15 @@
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../action-reminder-modal/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../tooltip/tsconfig.lib.json"
}
@@ -50,6 +50,7 @@ interface PopoverMenuProps {
align?: "start" | "center" | "end";
panelActionLabel?: string;
onOpenChange?: (open: boolean) => void;
closeOnUnhover?: boolean;
}
export const PopoverMenu = ({
@@ -71,6 +72,7 @@ export const PopoverMenu = ({
align = "start",
panelActionLabel,
onOpenChange,
closeOnUnhover = false,
}: PopoverMenuProps) => {
const selectedItem = items.find((item) => item.selected);
@@ -87,7 +89,7 @@ export const PopoverMenu = ({
const className = twMerge(
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
"flex gap-2 items-center justify-center outline-none",
"transition-all duration-150",
"transition-all duration-100",
"bg-ui-controls/60 px-3 text-base-fg hover:bg-ui-controls/90 border border-ui-controls-border",
"active:scale-95 transform",
buttonClassName
@@ -119,7 +121,7 @@ export const PopoverMenu = ({
}, []);
const handleButtonMouseEnter = (open: boolean, openFn: () => void) => {
if (mode !== "hoverSelect") return;
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
@@ -127,7 +129,7 @@ export const PopoverMenu = ({
hoverTimeoutRef.current = null;
}
if (!open) {
if (!open && mode === "hoverSelect") {
hoverTimeoutRef.current = setTimeout(() => {
openFn();
}, 100);
@@ -135,7 +137,7 @@ export const PopoverMenu = ({
};
const handleButtonMouseLeave = (closeFn: () => void) => {
if (mode !== "hoverSelect") return;
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(false);
@@ -143,15 +145,18 @@ export const PopoverMenu = ({
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
if (!isHovering) {
closeFn();
}
}, 300);
hoverTimeoutRef.current = setTimeout(
() => {
if (!isHovering) {
closeFn();
}
},
mode === "hoverSelect" ? 300 : 150
);
};
const handlePanelMouseEnter = () => {
if (mode !== "hoverSelect") return;
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
@@ -160,16 +165,19 @@ export const PopoverMenu = ({
};
const handlePanelMouseLeave = (closeFn: () => void) => {
if (mode !== "hoverSelect") return;
if (!(mode === "hoverSelect" || closeOnUnhover)) return;
setIsHovering(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
closeFn();
}, 300);
hoverTimeoutRef.current = setTimeout(
() => {
closeFn();
},
mode === "hoverSelect" ? 300 : 150
);
};
return (
@@ -221,19 +229,19 @@ export const PopoverMenu = ({
<Transition
show={open}
enter="transition duration-100 ease-out"
enter="transition duration-75 ease-out"
enterFrom={
position === "bottom"
? "translate-y-2 opacity-0"
: "-translate-y-2 opacity-0"
? "translate-y-1 opacity-0"
: "-translate-y-1 opacity-0"
}
enterTo="translate-y-0 opacity-100"
leave="transition duration-100 ease-in"
leave="transition duration-75 ease-in"
leaveFrom="translate-y-0 opacity-100"
leaveTo={
position === "bottom"
? "translate-y-2 opacity-0"
: "-translate-y-2 opacity-0"
? "translate-y-1 opacity-0"
: "-translate-y-1 opacity-0"
}
>
<PopoverPanel
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,7 @@
# @storyteller/ui-tutorial-modal
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-tutorial-modal` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/ui-tutorial-modal",
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
@@ -0,0 +1,2 @@
export * from "./lib/tutorial-modal";
export * from "./lib/tutorials";
@@ -0,0 +1,27 @@
import { create } from "zustand";
import type { TutorialItem } from "./tutorials";
type TutorialView = "grid" | "video";
type TutorialModalStore = {
view: TutorialView;
selected: TutorialItem | null;
progressSecondsById: Record<string, number>;
setGrid: () => void;
viewTutorial: (item: TutorialItem) => void;
getProgress: (id: string) => number;
setProgress: (id: string, seconds: number) => void;
};
export const useTutorialModalStore = create<TutorialModalStore>((set, get) => ({
view: "grid",
selected: null,
progressSecondsById: {},
setGrid: () => set({ view: "grid", selected: null }),
viewTutorial: (item) => set({ view: "video", selected: item }),
getProgress: (id) => get().progressSecondsById[id] ?? 0,
setProgress: (id, seconds) =>
set((state) => ({
progressSecondsById: { ...state.progressSecondsById, [id]: seconds },
})),
}));
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import StorytellerUiTutorialModal from './tutorial-modal';
describe('StorytellerUiTutorialModal', () => {
it('should render successfully', () => {
const { baseElement } = render(<StorytellerUiTutorialModal />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,220 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { faBook, faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { defaultTutorials, TutorialItem } from "./tutorials.js";
import { useTutorialModalStore } from "./tutorial-modal-store";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export type TutorialModalButtonProps = {
items?: TutorialItem[];
panelTitle?: string;
className?: string;
onOpenChange?: (open: boolean) => void;
};
export function TutorialModalButton({
items,
panelTitle = "Tutorials",
className,
onOpenChange,
}: TutorialModalButtonProps) {
const [open, setOpen] = useState(false);
const tutorials = useMemo(() => items ?? defaultTutorials, [items]);
const view = useTutorialModalStore((s) => s.view);
const selected = useTutorialModalStore((s) => s.selected);
const setGrid = useTutorialModalStore((s) => s.setGrid);
const viewTutorial = useTutorialModalStore((s) => s.viewTutorial);
const getProgress = useTutorialModalStore((s) => s.getProgress);
const setProgress = useTutorialModalStore((s) => s.setProgress);
const handleOpen = () => {
setOpen(true);
onOpenChange?.(true);
};
const handleClose = () => {
setOpen(false);
onOpenChange?.(false);
};
// ------------------------------------------------------
// YouTube Player setup for restoring and saving progress
// ------------------------------------------------------
const playerContainerRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any | null>(null);
const progressTimerRef = useRef<number | null>(null);
const extractYouTubeId = (url: string): string | null => {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
if (host === "youtube.com" && u.pathname.startsWith("/embed/")) {
return u.pathname.split("/")[2] ?? null;
}
if (host.includes("youtube.com") && u.pathname.startsWith("/shorts/")) {
return u.pathname.split("/")[2] ?? null;
}
if (host.includes("youtube.com") && u.pathname === "/watch") {
return u.searchParams.get("v");
}
if (host === "youtu.be") {
return u.pathname.slice(1) || null;
}
} catch {}
return null;
};
const loadYouTubeIframeAPI = async (): Promise<void> => {
if (typeof window === "undefined") return;
const w = window as any;
if (w.YT && w.YT.Player) return;
await new Promise<void>((resolve) => {
const existing = document.getElementById("youtube-iframe-api");
if (existing) {
const check = () =>
w.YT && w.YT.Player ? resolve() : setTimeout(check, 50);
check();
return;
}
const tag = document.createElement("script");
tag.id = "youtube-iframe-api";
tag.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(tag);
w.onYouTubeIframeAPIReady = () => resolve();
});
};
useEffect(() => {
if (!open || view !== "video" || !selected) return;
const run = async () => {
await loadYouTubeIframeAPI();
const w = window as any;
const videoId = extractYouTubeId(selected.videoUrl);
if (!videoId) return;
if (!playerContainerRef.current) return;
// Clear any previous iframe content
playerContainerRef.current.innerHTML = "";
const startSeconds = getProgress(selected.id) || 0;
playerRef.current = new w.YT.Player(playerContainerRef.current, {
videoId,
playerVars: {
rel: 0,
modestbranding: 1,
start: Math.floor(startSeconds),
playsinline: 1,
},
events: {
onReady: () => {
// Start polling current time
progressTimerRef.current = window.setInterval(() => {
try {
const t = Math.floor(
playerRef.current?.getCurrentTime?.() ?? 0
);
if (Number.isFinite(t)) setProgress(selected.id, t);
} catch {}
}, 1000);
},
},
});
};
run();
return () => {
if (progressTimerRef.current) {
window.clearInterval(progressTimerRef.current);
progressTimerRef.current = null;
}
try {
playerRef.current?.destroy?.();
} catch {}
playerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, view, selected?.id]);
return (
<div className={className}>
<Button
aria-label="Open tutorials"
onClick={handleOpen}
variant="action"
title={view === "grid" ? "Tutorials" : selected?.title ?? "Tutorials"}
>
<span className="inline-flex items-center gap-2">
<FontAwesomeIcon icon={faBook} className="text-base-fg" />
<span className="text-base-fg">Tutorials</span>
</span>
</Button>
<Modal
isOpen={open}
onClose={handleClose}
title={view === "video" ? undefined : panelTitle}
titleIcon={faBook}
accessibleTitle={panelTitle}
className="max-w-5xl"
>
{view === "grid" && (
<>
{tutorials.length === 0 ? (
<div className="flex items-center justify-center py-12 text-white/60">
No tutorials yet.
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tutorials.map((item: TutorialItem) => (
<button
key={item.id}
type="button"
onClick={() => viewTutorial(item)}
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
>
<div className="aspect-video w-full overflow-hidden">
<img
src={item.thumbnailUrl}
alt={item.title}
className="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
</div>
<div className="p-3 text-sm text-white/90">
{item.title}
</div>
</button>
))}
</div>
)}
</>
)}
{view === "video" && selected && (
<div className="flex w-full flex-col">
<div className="mb-3 flex items-center gap-3">
<Button
onClick={setGrid}
className="w-fit text-base-fg opacity-80 hover:opacity-100 font-medium"
variant="action"
icon={faChevronLeft}
>
Back
</Button>
<div className="text-lg font-bold text-base-fg">
Tutorial: {selected.title}
</div>
</div>
<div className="w-full">
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black">
<div ref={playerContainerRef} className="h-full w-full" />
</div>
</div>
</div>
)}
</Modal>
</div>
);
}
export default TutorialModalButton;
@@ -0,0 +1,37 @@
export type TutorialItem = {
id: string;
title: string;
thumbnailUrl: string;
videoUrl: string;
category?: string;
};
export enum TutorialCategory {
Editor3D = "3D Editor",
Canvas2D = "2D Canvas",
}
export const defaultTutorials: TutorialItem[] = [
{
id: "turn-images-into-objects",
title: "Turn Images into 3D Objects",
thumbnailUrl:
"/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg",
videoUrl: "https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa",
category: TutorialCategory.Editor3D,
},
{
id: "2d-editor-basics",
title: "2D Editor Basics",
thumbnailUrl: "/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg",
videoUrl: "https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo",
category: TutorialCategory.Canvas2D,
},
{
id: "3d-editor-posing",
title: "3D Editor & Posing",
thumbnailUrl: "/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg",
videoUrl: "https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O",
category: TutorialCategory.Editor3D,
},
];
@@ -0,0 +1,19 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}
@@ -0,0 +1,53 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}
@@ -0,0 +1,34 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,49 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/tutorial-modal',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-tutorial-modal',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: ['react','react-dom','react/jsx-runtime']
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));
@@ -1,6 +1,6 @@
import { SubscriptionPlanDetails } from "./subscription-plan-details.js";
export const FREE_PLAN : SubscriptionPlanDetails = {
export const FREE_PLAN: SubscriptionPlanDetails = {
slug: "free",
name: "Free",
isPaidPlan: false,
@@ -9,11 +9,14 @@ export const FREE_PLAN : SubscriptionPlanDetails = {
features: [
{ text: "Free daily generations", included: true },
{ text: "Limited access to ArtCraft tools", included: true },
{ text: "~15 Midjourney images", included: true },
{ text: "~10 GPT-Image-1 images", included: true },
{ text: "~1 minute Kling video", included: true },
],
colorScheme: "dark" as const,
};
export const SUBSCRIPTION_PLANS : SubscriptionPlanDetails[] = [
export const SUBSCRIPTION_PLANS: SubscriptionPlanDetails[] = [
FREE_PLAN,
{
slug: "artcraft_basic",
@@ -24,10 +27,10 @@ export const SUBSCRIPTION_PLANS : SubscriptionPlanDetails[] = [
originalMonthlyPrice: 10,
originalYearlyPrice: 120,
features: [
{ text: "~1,010 Flux images", included: true },
{ text: "~36,000 real-time images", included: true },
{ text: "~180 enhanced images", included: true },
{ text: "~6 training jobs", included: true },
{ text: "~1,000 Flux images", included: true },
{ text: "~300 Midjourney images", included: true },
{ text: "~100 GPT-Image-1 images", included: true },
{ text: "~10 minutes Kling video", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "green" as const,
@@ -41,10 +44,10 @@ export const SUBSCRIPTION_PLANS : SubscriptionPlanDetails[] = [
originalMonthlyPrice: 35,
originalYearlyPrice: 420,
features: [
{ text: "~5,048 Flux images", included: true },
{ text: "~180,000 real-time images", included: true },
{ text: "~900 enhanced images", included: true },
{ text: "~30 training jobs", included: true },
{ text: "~5,000 Flux images", included: true },
{ text: "~1,000 Midjourney images", included: true },
{ text: "~300 GPT-Image-1 images", included: true },
{ text: "~30 minutes Kling video", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "purple" as const,
@@ -58,16 +61,15 @@ export const SUBSCRIPTION_PLANS : SubscriptionPlanDetails[] = [
originalMonthlyPrice: 60,
originalYearlyPrice: 720,
features: [
{ text: "~15,142 Flux images", included: true },
{ text: "~540,000 real-time images", included: true },
{ text: "~2,700 enhanced images", included: true },
{ text: "~90 training jobs", included: true },
{ text: "~15,000 Flux images", included: true },
{ text: "~3,000 Midjourney images", included: true },
{ text: "~500 GPT-Image-1 images", included: true },
{ text: "~60 minutes Kling video", included: true },
{ text: "Commercial license", included: true },
],
colorScheme: "orange" as const,
},
]
];
export const SUBSCRIPTION_PLANS_BY_SLUG : Map<string, SubscriptionPlanDetails> = new Map(
SUBSCRIPTION_PLANS.map((plan) => [plan.slug, plan])
);
export const SUBSCRIPTION_PLANS_BY_SLUG: Map<string, SubscriptionPlanDetails> =
new Map(SUBSCRIPTION_PLANS.map((plan) => [plan.slug, plan]));
+119 -380
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -167,6 +167,9 @@
},
{
"path": "./libs/state/subscription"
},
{
"path": "./libs/components/tutorial-modal"
}
]
}