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
46 changed files with 1087 additions and 459 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>
@@ -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"
}
]
}