faq and tutorials update to use markdown, footer updates, landing vid… (#868)

…eo layout shift fix
This commit is contained in:
BFlat authored and GitHub committed 2025-10-06 14:57:33 +07:00
1 parent 026c2bf827
commit 12bf504d6f
24 files changed
+995 -133

No files matched your search

@@ -1,4 +1,8 @@
/tutorials /index.html 200
/tutorials/ /index.html 200
/faq /index.html 200
/faq/ /index.html 200
/faq/* /index.html 200
@@ -3,6 +3,9 @@ import Download from "../pages/download";
import Navbar from "../components/navbar";
import Landing2 from "../pages/landing2";
import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
import FaqIndex from "../pages/faq/index";
import FaqArticle from "../pages/faq/article";
export function App() {
return (
@@ -13,6 +16,9 @@ export function App() {
<Route path="/" element={<Landing2 />} />
<Route path="/download" element={<Download />} />
<Route path="/tutorials" element={<TutorialsPage />} />
<Route path="/tutorials/:slug" element={<TutorialsArticle />} />
<Route path="/faq" element={<FaqIndex />} />
<Route path="/faq/:slug" element={<FaqArticle />} />
</Routes>
</div>
);
@@ -7,10 +7,24 @@ import {
faGithubAlt,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
const faqFiles = import.meta.glob("../../pages/faq/content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const tutorialFiles = import.meta.glob("../../pages/tutorials/content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const navigation = {
main: [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "FAQ", href: "/faq" },
{ name: "Download", href: "/download" },
],
social: [
@@ -62,8 +76,8 @@ export default function Example() {
</a>
))}
</nav> */}
<div>
<div className="flex justify-center gap-x-10">
<div className="w-full flex flex-col items-center gap-10">
<div className="flex justify-center gap-x-10 mb-8">
{navigation.social.map((item) => (
<a
key={item.name}
@@ -79,7 +93,58 @@ export default function Example() {
</a>
))}
</div>
<p className="mt-10 text-center text-sm/6 text-gray-400">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-6 text-sm/6">
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">Pages</div>
<a href="/" className="text-gray-400 hover:text-white">
Home
</a>
</div>
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">Tutorials</div>
<a href="/tutorials" className="text-gray-400 hover:text-white">
All Tutorials
</a>
{Object.entries(tutorialFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
return (
<a
key={slug}
href={`/tutorials/${slug}`}
className="text-gray-400 hover:text-white"
>
{frontmatter.title || slug}
</a>
);
})}
</div>
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">FAQ</div>
<a href="/faq" className="text-gray-400 hover:text-white">
All FAQs
</a>
{Object.entries(faqFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
const title = (frontmatter.title || slug) as string;
const truncated =
title.length > 36 ? title.slice(0, 33) + "…" : title;
return (
<a
key={slug}
href={`/faq/${slug}`}
className="text-gray-400 hover:text-white"
>
{truncated}
</a>
);
})}
</div>
</div>
<p className="text-center text-sm/6 text-gray-400">
&copy; 2025 ArtCraft. All rights reserved.
</p>
</div>
@@ -1,37 +1,19 @@
import { Disclosure } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { DiscordButton } from "../discord-button";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "FAQ", href: "/faq" },
];
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);
};
// Underline hover handled purely via CSS
useEffect(() => {
const handleScroll = () => {
@@ -56,31 +38,29 @@ export default function Navbar() {
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
<a href="/">
<Link to="/">
<img
alt="ArtCraft"
src="/images/artcraft-logo.png"
className="h-7 w-auto"
/>
</a>
</Link>
</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
<Link
key={item.name}
href={item.href}
to={item.href}
aria-current={isCurrent ? "page" : undefined}
className={twMerge(
"nav-link",
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
@@ -93,17 +73,11 @@ export default function Navbar() {
<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"
isCurrent ? "visible-line" : ""
)}
/>
</span>
</a>
</Link>
);
})}
</div>
@@ -154,25 +128,16 @@ export default function Navbar() {
transform-origin: left center;
transform: scaleX(0) translateX(0);
opacity: 0;
transition: transform 220ms ease, opacity 220ms ease;
}
.nav-link:hover .link-underline {
transform: scaleX(1) translateX(0);
opacity: 1;
}
.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>
);
@@ -0,0 +1,41 @@
import { useEffect } from "react";
type SeoProps = {
title: string;
description?: string;
jsonLd?: object | object[];
};
const Seo = ({ title, description, jsonLd }: SeoProps) => {
useEffect(() => {
document.title = title;
if (description) {
let meta = document.querySelector(
'meta[name="description"]'
) as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "description");
document.head.appendChild(meta);
}
meta.setAttribute("content", description);
}
let script: HTMLScriptElement | null = null;
if (jsonLd) {
script = document.createElement("script");
script.type = "application/ld+json";
script.text = JSON.stringify(jsonLd);
document.head.appendChild(script);
}
return () => {
if (script && script.parentNode) script.parentNode.removeChild(script);
};
}, [title, description, jsonLd]);
return null;
};
export default Seo;
@@ -0,0 +1,115 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, markdownToHtml } from "../../utils/markdown";
import { Button } from "@storyteller/ui-button";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
const markdownFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const FaqArticle = () => {
const { slug } = useParams();
const entries = Object.entries(markdownFiles);
const markdown = (() => {
const byFilename = entries.find(([path]) =>
path.endsWith(`/content/${slug}.md`)
);
if (byFilename) return byFilename[1] as string;
for (const [_, raw] of entries) {
const { frontmatter } = parseFrontmatter(raw as string);
if (
frontmatter.slug &&
frontmatter.slug.trim().toLowerCase() === (slug || "").toLowerCase()
) {
return raw as string;
}
}
return null;
})();
const { frontmatter, body } = useMemo(
() =>
markdown ? parseFrontmatter(markdown) : { frontmatter: {}, body: "" },
[markdown]
);
if (!markdown) {
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<h1 className="text-3xl font-bold">Not found</h1>
<p className="text-white/70">We couldn't find this FAQ article.</p>
</div>
</div>
);
}
const title = `${frontmatter.title || slug} - ArtCraft`;
const description = frontmatter.abstract || "";
const thumbnail = frontmatter.thumbnail || "";
const html = markdownToHtml(body, "/faq/");
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: frontmatter.title || slug,
description: description,
articleBody: body,
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} jsonLd={jsonLd} />
<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-3xl px-4 sm:px-6 pt-24 sm:pt-32 pb-32">
<div className="mb-6">
<Button
as="link"
href="/faq"
icon={faChevronLeft}
className="rounded-lg px-4 py-2 text-sm border border-white/10 bg-white/5 hover:bg-white/10"
>
Back to FAQ
</Button>
</div>
<h1 className="text-4xl font-bold mb-4 !leading-tight">
{frontmatter.title || slug}
</h1>
{description && <p className="text-white/70 mb-8">{description}</p>}
{thumbnail && (
<div className="w-full overflow-hidden rounded-lg border border-white/10 bg-black mb-10">
<img
src={thumbnail}
alt={frontmatter.title || slug}
className="w-full h-auto object-cover"
/>
</div>
)}
<article
className="article-content max-w-none text-white/90 leading-relaxed"
dangerouslySetInnerHTML={{ __html: html }}
/>
<style>{`
.article-content h1 { font-size: 2rem; font-weight: 700; margin: 1.25rem 0; }
.article-content h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0; }
.article-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
.article-content h4 { font-size: 1.125rem; font-weight: 600; margin: 0.5rem 0; }
.article-content p { margin: 0.75rem 0; }
.article-content ul { list-style: disc; padding-left: 1.25rem; margin: 0.75rem 0; }
.article-content img { display: block; max-width: 100%; height: auto; border-radius: 0.5rem; }
`}</style>
</div>
</div>
);
};
export default FaqArticle;
@@ -0,0 +1,26 @@
---
title: 2D Canvas — Draw and Generate
seoTitle: 2D Canvas — Sketch, mask, and guide AI generation
abstract: Draw, lay out references, and inpaint precisely to get the image you intended.
isPublished: true
publishedOn: 2025-10-06
slug: 2d-canvas
---
## Draw first, generate second
The 2D Canvas is a lightweight drawing and layout tool that the model respects. Sketch shapes, block the background, and mask areas to protect or change. When you generate, the structure you drew stays intact—only the fidelity improves.
## What it’s great for
- Storyboards, thumbnails, product mockups.
- Replacing elements in a scene without moving the rest.
- Fast “idea to image” iterations.
## Tips
- Use high‑contrast sketches for clearer guidance.
- Mask small areas for better local edits (logos, eyes, hands, reflections).
- Keep prompts short; the canvas already supplies structure.
See also: [Text to Image](../text-to-image) · [Edit Image](../edit-image) · [3D Editor](../3d-editor)
@@ -0,0 +1,27 @@
---
title: 3D Editor — Pose, Compose, and Generate
seoTitle: 3D Editor — Block scenes, pose characters, and render with AI
abstract: Build shots like a game, then render them consistently across angles and takes.
isPublished: true
publishedOn: 2025-10-06
slug: 3d-editor
---
## Compose scenes like a playable set
The 3D Editor lets you place subjects, cameras, and lights in a real‑time scene. Drag‑and‑drop images to create simple 3D proxies, pose characters, and orbit the camera until the shot feels right. When you generate, the AI keeps your framing and lighting intact.
## Why 3D matters
- Consistency across multiple shots and angles.
- Natural camera motion and depth cues.
- Character posing that matches your vision.
## Workflow
1. Choose a preset or import images to build the scene.
2. Place the subject, set the camera height and focal length.
3. Add a concise prompt for style and materials.
4. Generate, compare, and tweak until it clicks.
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video) · [2D Canvas](../2d-canvas)
@@ -0,0 +1,36 @@
---
title: AI Image Generation on ArtCraft
seoTitle: AI Image Generation on ArtCraft
abstract: Explore ArtCraft's AI generation capabilities: features, workflows, and best practices.
isPublished: true
publishedOn: 2025-10-06
slug: ai-image-generation-on-artcraft
---
## What is AI image generation in ArtCraft?
ArtCraft brings modern AI image generation into a creator-first workflow that runs locally. You compose your idea on a 2D canvas or in a 3D scene, then guide the model to render the final image with consistency and control. The result is a fast loop for exploration, iteration, and polished outputs.
## Core capabilities
- 2D Canvas guidance: sketch layouts, place references, mask areas, and refine with inpainting.
- 3D Scene composition: set camera, lighting, and subject placement for consistent multi-shot renders.
- Structure-preserving generation: keep the arrangement you designed while improving detail and lighting.
- Revisions that respect intent: re-run specific regions to fix details without losing the overall look.
## A simple workflow
- Plan the shot: define focal point, subject scale, angle, and background (2D) or block out a scene (3D).
- Add guidance: references, masks, or prompts that clarify style, mood, and key details.
- Generate and iterate: compare candidates, refine regions, and lock in the final composition.
## Quality and consistency tips
- Favor clear subjects and readable silhouettes; the model follows strong structure best.
- Use concise prompts; describe style, lighting, and subject specifics with minimal filler.
- For multi-image sets, keep camera, distance, and lighting consistent between shots.
- Iterate locally on regions that need corrections (eyes, hands, logos, or textures).
## Responsible use
Only generate content you have the rights to create. Avoid infringing on other artists’ styles or producing deceptive media. When sharing, be transparent about AI involvement and follow relevant community and platform guidelines.
@@ -0,0 +1,26 @@
---
title: Edit Image — Inpaint and Replace
seoTitle: Edit Image — Remove, replace, and restyle without Photoshop
abstract: Paint masks, describe the change, and let AI update only what you selected.
isPublished: true
publishedOn: 2025-10-06
slug: edit-image
---
## Precise edits without leaving ArtCraft
Edit Image lets you remove objects, fix details, or restyle parts of an image. Paint a mask, write a brief instruction, and generate. Only the masked region changes; the rest stays locked.
## Common uses
- Clean plates: remove cables, signage, or passers‑by.
- Product swaps: change colorways, logos, or materials.
- Portrait touch‑ups: hairline fixes, reflections, and lighting tweaks.
## Tips
- Keep masks tight around the area you want to change.
- Use short, clear instructions (e.g., “replace with polished chrome”).
- For multi‑step edits, work from big to small changes.
See also: [2D Canvas](../2d-canvas) · [Text to Image](../text-to-image)
@@ -0,0 +1,31 @@
---
title: Generate AI photos and videos of yourself
seoTitle: Generate AI photos and videos of yourself in ArtCraft
abstract: Create photorealistic images of yourself by combining reference inputs with ArtCraft workflows.
isPublished: true
publishedOn: 2025-10-06
slug: generate-ai-photos-and-videos-of-yourself
---
## Create AI portraits and short videos with strong likeness
With ArtCraft you can generate images and short video clips of yourself while keeping control over pose, lighting, and style. The workflow combines good references with structure-aware generation so the results look like you—and stay consistent across shots.
## What you need
- A handful of clear photos that show your face from multiple angles, plus at least one full-body reference.
- Neutral lighting and no heavy filters. Avoid sunglasses or occlusions for the face.
## Step-by-step
1. Prepare references: choose 4–8 images that cover front, 3/4, and profile views; include one where your outfit and background match the vibe you want.
2. Set the composition: on the 2D canvas, sketch layout and subject scale, or use the 3D scene to place camera and lighting.
3. Guide the model: add a concise prompt describing mood, setting, and attire; use masks to protect areas you don’t want to change.
4. Generate and refine: compare candidates, then fix details with inpainting (eyes, hairlines, hands, accessories).
5. Make it move: for short clips, render a small set of frames from slight camera or pose variations, then export as a looping video.
## Likeness and safety tips
- Keep hair outline and eye region sharp in references; it improves identity consistency.
- Reuse the same camera distance and angle for a set; consistency beats variety for multi-shot galleries.
- Be mindful of where and how you publish. When sharing AI portraits, add context so viewers understand how they were made.
@@ -0,0 +1,27 @@
---
title: Image to Video in ArtCraft
seoTitle: Image to Video — Animate your images with AI video models
abstract: Turn a single image into a short, cinematic clip with controllable motion and style.
isPublished: true
publishedOn: 2025-10-06
slug: image-to-video
---
## From still to motion in minutes
Image to Video converts any image—your Text to Image result or a photo—into a short clip. Choose a video model, set duration and motion, then add a concise prompt to guide style and mood.
## Best practices
- Start from a clean, well‑lit image with a readable subject.
- Keep motion subtle for realism: camera drift, gentle parallax, or small pose changes.
- Use consistent prompts if you create a series of clips.
## Workflow
1. Pick a video model and duration.
2. Adjust motion options (camera, subject, or both).
3. Add a brief prompt for style and atmosphere.
4. Render, review, and export.
See also: [Text to Image](../text-to-image) · [3D Editor](../3d-editor) · [Edit Image](../edit-image)
@@ -0,0 +1,34 @@
---
title: What image and video models does ArtCraft support?
seoTitle: ArtCraft models — Image and Video generation models
abstract: A quick overview of image and video models available in ArtCraft.
isPublished: true
publishedOn: 2025-10-06
slug: models-supported
---
## Image models
- Flux Pro 1.1 Ultra
- Flux Pro 1.1
- Flux 1 Dev
- Flux 1 Schnell
- Midjourney
- GPT Image 1
- Gemini 2.5 Flash (editing)
- Flux Pro Kontext Max (editing)
- Flux Pro Inpaint (editing)
- Flux Dev Juggernaut Inpaint (editing)
## Video models
- Kling 1.6 Pro
- Kling 2.1 Pro
- Kling 2.1 Master
- Seedance 1.0 Lite
- Sora 2
- Google Veo 2
- Google Veo 3
- Google Veo 3 Fast
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video)
@@ -0,0 +1,28 @@
---
title: Text to Image in ArtCraft
seoTitle: Text to Image — Prompt and generate with top AI models
abstract: Turn ideas into images with clean prompts, model choices, and consistent structure.
isPublished: true
publishedOn: 2025-10-06
slug: text-to-image
---
## Turn prompts into polished images
Text to Image in ArtCraft lets you move quickly from idea to result. Choose a model, write a clear prompt, and render. If you need more control, sketch a loose layout on the 2D canvas or block a scene in 3D first—generation will respect your composition.
## Tips for great prompts
- Be specific about subject, action, and style (lighting, lens, materials).
- Prefer short, descriptive phrases over long paragraphs.
- Use negative cues sparingly (e.g., “no text, no watermark”).
## Choose the right model
Different models excel at different looks. For photorealism, use a high‑fidelity model. For design and branding, choose a model that handles flat color and typography well. If speed matters, start with a fast model to explore options, then switch to a higher‑quality model for final renders.
## Iterate with structure
If the result is close but not perfect, keep the composition and re‑render small regions with inpainting. This preserves the framing while fixing details like hands, logos, or textures.
See also: [2D Canvas](../2d-canvas) · [3D Editor](../3d-editor) · [Edit Image](../edit-image)
@@ -0,0 +1,74 @@
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
const faqFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const FaqIndex = () => {
const title = "Frequently Asked Questions - ArtCraft";
const description =
"Explore ArtCraft FAQs: guides on AI image generation, editing, and workflows.";
const items = Object.entries(faqFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.abstract || "",
};
});
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.title,
acceptedAnswer: {
"@type": "Answer",
text: item.description,
},
})),
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} jsonLd={jsonLd} />
<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-4xl px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<div className="text-center mb-10">
<h1 className="text-4xl sm:text-6xl font-bold mb-3">
Frequently Asked Questions
</h1>
<p className="text-white/70 text-base sm:text-lg">
Deep-dive guides and answers about ArtCraft.
</p>
</div>
<div className="flex flex-col gap-4">
{items.map((item) => (
<Link
key={item.slug}
to={`/faq/${item.slug}`}
className="block rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 p-5 transition-all"
>
<h2 className="text-xl font-semibold mb-2">{item.title}</h2>
<p className="text-white/70 text-sm">{item.description}</p>
</Link>
))}
</div>
</div>
</div>
);
};
export default FaqIndex;
@@ -2,3 +2,6 @@ export * from "./landing";
export * from "./landing2";
export * from "./download";
export * from "./tutorials";
export { default as TutorialsIndex } from "./tutorials/md-index";
export { default as TutorialsArticle } from "./tutorials/article";
export * from "./faq";
@@ -102,7 +102,7 @@ const Landing = () => {
</motion.div>
<div className="relative z-10 px-2 sm:px-4 md:px-6 py-8 w-full flex flex-col items-center justify-center">
<div className="max-w-full md:max-w-[1200px] mx-auto text-center flex flex-col items-center">
<div className="w-full max-w-[1200px] mx-auto text-center flex flex-col items-center">
{/* Main Heading */}
<motion.h1
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-bold leading-tight mb-4 sm:mb-6 drop-shadow-[0_4px_32px_rgba(80,80,255,0.25)]"
@@ -169,13 +169,13 @@ const Landing = () => {
{/* Glassy Video Mockup */}
<motion.div
className="relative mx-auto mt-4 w-full max-w-full"
className="relative mx-auto mt-4 w-full max-w-[1200px]"
initial="hidden"
animate="visible"
variants={fadeUpVariants}
>
<div
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-xl shadow-2xl p-2 sm:p-4 aspect-[16/9] w-full max-w-full md:max-w-[1200px] mx-auto transition-transform duration-300"
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-xl shadow-2xl p-2 sm:p-4 aspect-[16/9] w-full mx-auto transition-transform duration-300"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
@@ -184,7 +184,7 @@ const Landing = () => {
autoPlay
loop
playsInline
className="w-full h-full object-cover rounded-2xl"
className="w-full h-full object-cover rounded-2xl bg-white"
src="https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4"
>
<source
@@ -0,0 +1,137 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, markdownToHtml } from "../../utils/markdown";
import { Button } from "@storyteller/ui-button";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
const tutorialFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const TutorialsArticle = () => {
const { slug } = useParams();
const entries = Object.entries(tutorialFiles);
const markdown = (() => {
// 1) filename match
const byFilename = entries.find(([path]) =>
path.endsWith(`/content/${slug}.md`)
);
if (byFilename) return byFilename[1] as string;
// 2) frontmatter slug match
const wanted = (slug || "").toLowerCase();
for (const [_, raw] of entries) {
const { frontmatter } = parseFrontmatter(raw as string);
const fmSlug = (frontmatter.slug || "").trim().toLowerCase();
if (fmSlug && fmSlug === wanted) return raw as string;
const aliasesRaw = (frontmatter.aliases || "").toString();
if (aliasesRaw) {
const candidateList = aliasesRaw
.split(/[\s,]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (candidateList.includes(wanted)) return raw as string;
}
}
return null;
})();
const { frontmatter, body } = useMemo(
() =>
markdown ? parseFrontmatter(markdown) : { frontmatter: {}, body: "" },
[markdown]
);
if (!markdown) {
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<div className="relative z-10 mx-auto w-full max-w-[1200px] px-4 sm:px-8 pt-28 sm:pt-36 pb-12">
<h1 className="text-3xl font-bold">Not found</h1>
<p className="text-white/70">We couldn't find this tutorial.</p>
</div>
</div>
);
}
const title = `${frontmatter.title || slug} - ArtCraft`;
const description = frontmatter.abstract || "";
const toEmbed = (url: string): string => {
if (!url) return url;
if (url.includes("youtu.be/"))
return url.replace("youtu.be/", "www.youtube.com/embed/");
if (url.includes("watch?v=")) return url.replace("watch?v=", "embed/");
return url;
};
const frontmatterVideo =
(frontmatter.videoUrl as string) ||
(frontmatter.youtubeId
? `https://www.youtube.com/embed/${frontmatter.youtubeId}`
: "");
const videoUrl = toEmbed(frontmatterVideo);
const html = markdownToHtml(body);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: frontmatter.title || slug,
description: description,
articleBody: body,
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} jsonLd={jsonLd} />
<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-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-32">
<div className="mb-6">
<Button
as="link"
href="/tutorials"
icon={faChevronLeft}
className="rounded-lg px-4 py-2 text-sm border border-white/10 bg-white/5 hover:bg-white/10"
>
Back to Tutorials
</Button>
</div>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 !leading-tight">
{frontmatter.title || slug}
</h1>
{description && <p className="text-white/70 mb-8">{description}</p>}
{videoUrl && (
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black mb-10">
<iframe
title={frontmatter.title || slug}
src={videoUrl}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)}
<article
className="article-content max-w-none text-white/90 leading-relaxed"
dangerouslySetInnerHTML={{ __html: html }}
/>
<style>{`
.article-content h1 { font-size: 2rem; font-weight: 700; margin: 1.25rem 0; }
.article-content h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0; }
.article-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
.article-content h4 { font-size: 1.125rem; font-weight: 600; margin: 0.5rem 0; }
.article-content p { margin: 0.75rem 0; }
.article-content ul { list-style: disc; padding-left: 1.25rem; margin: 0.75rem 0; }
.article-content img { display: block; max-width: 100%; height: auto; border-radius: 0.5rem; }
`}</style>
</div>
</div>
);
};
export default TutorialsArticle;
@@ -0,0 +1,11 @@
---
title: 2D Editor Basics
seoTitle: 2D Editor Basics in ArtCraft
abstract: Learn the canvas tools, arrange elements, and generate from layouts.
isPublished: true
publishedOn: 2025-10-06
thumbnail: /images/tutorial-thumbnails/2D_Editor_Basics.jpg
slug: 2d-editor-basics
category: 2D
videoUrl: https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo
---
@@ -0,0 +1,12 @@
---
title: 3D Editor Basics
seoTitle: 3D Editor Basics in ArtCraft
abstract: Position camera and objects, compose shots, maintain character consistency.
isPublished: true
publishedOn: 2025-10-06
thumbnail: /images/tutorial-thumbnails/3D_Editor_Basics.jpg
slug: 3d-editor-basics
aliases: 3d-editor-basics
category: 3D
videoUrl: https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O
---
@@ -0,0 +1,12 @@
---
title: Turn Images into 3D Objects
seoTitle: Turn Images into 3D Objects in ArtCraft
abstract: Import images, convert them to scene elements, compose and render.
isPublished: true
publishedOn: 2025-10-06
thumbnail: /images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg
slug: turn-images-into-objects
aliases: turn-images-into-3d-objects
category: 3D
videoUrl: https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa
---
@@ -0,0 +1,56 @@
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
const tutorialFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const TutorialsIndex = () => {
const title = "Tutorials - ArtCraft";
const description =
"Learn tips, tricks, and workflows for ArtCraft with step-by-step guides.";
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
<Seo title={title} description={description} />
<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">
<div className="text-center mb-10">
<h1 className="text-4xl sm:text-6xl font-bold mb-3">Tutorials</h1>
<p className="text-white/70 text-base sm:text-lg">
Step-by-step guides to master ArtCraft.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(tutorialFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
const title = frontmatter.title || slug;
const description = frontmatter.abstract || "";
return (
<Link
key={slug}
to={`/tutorials/${slug}`}
className="block rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 p-5"
>
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<p className="text-white/70 text-sm">{description}</p>
</Link>
);
})}
</div>
</div>
</div>
);
};
export default TutorialsIndex;
@@ -1,14 +1,16 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
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";
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
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)];
type MdTutorial = {
slug: string;
title: string;
abstract: string;
category?: string;
thumbnail?: string;
};
const websiteThumb = (url: string): string => {
@@ -20,15 +22,41 @@ const websiteThumb = (url: string): string => {
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 mdFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
const items = useMemo<MdTutorial[]>(() => {
const entries = Object.entries(mdFiles);
const results: MdTutorial[] = [];
for (const [path, raw] of entries) {
const { frontmatter } = parseFrontmatter(raw as string);
const filenameSlug = pathToFilename(path);
const slug = (frontmatter.slug || filenameSlug).trim().toLowerCase();
results.push({
slug,
title: frontmatter.title || filenameSlug,
abstract: frontmatter.abstract || "",
category: frontmatter.category || undefined,
thumbnail: frontmatter.thumbnail || undefined,
});
}
return results;
}, [mdFiles]);
const categories = useMemo(() => {
const set = new Set<string>();
for (const t of items) if (t.category) set.add(t.category);
return ["All", ...Array.from(set)];
}, [items]);
const visible = useMemo(() => {
if (activeCategory === "All") return tutorials;
return tutorials.filter((t) => t.category === activeCategory);
}, [activeCategory, tutorials]);
if (activeCategory === "All") return items;
return items.filter((t) => t.category === activeCategory);
}, [activeCategory, items]);
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden bg-dots">
@@ -46,46 +74,48 @@ export const TutorialsPage = () => {
</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>
)}
<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)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((item) => {
const href = `/tutorials/${item.slug}`;
return (
<Link
key={item.slug}
to={href}
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)}
src={
item.thumbnail
? websiteThumb(item.thumbnail)
: "/images/tutorial-thumbnails/2D_Editor_Basics.jpg"
}
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
Watch video
</span>
</div>
</div>
@@ -97,40 +127,10 @@ export const TutorialsPage = () => {
</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>
)}
</Link>
);
})}
</div>
</div>
</div>
);
@@ -0,0 +1,126 @@
export const markdownToHtml = (
markdown: string,
basePath: string = ""
): string => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const htmlParts: string[] = [];
let inList = false;
const closeListIfOpen = () => {
if (inList) {
htmlParts.push("</ul>");
inList = false;
}
};
const isAbsolute = (url: string): boolean => /^(?:[a-z]+:)?\/\//i.test(url);
const normalizeLink = (url: string): string => {
if (!basePath) return url;
if (!url) return url;
if (url.startsWith("#")) return url;
if (url.startsWith("/")) return url;
if (isAbsolute(url)) return url;
if (url.startsWith("../")) return basePath + url.replace(/^\.\.\//, "");
if (url.startsWith("./")) return basePath + url.replace(/^\.\//, "");
return basePath + url;
};
const renderInline = (text: string): string => {
// images: ![alt](url)
let out = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const safeAlt = String(alt || "").replace(/"/g, "&quot;");
const safeUrl = String(url || "");
return `<img src="${safeUrl}" alt="${safeAlt}" />`;
});
// links: [text](url)
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
const safeLabel = String(label || "").replace(/"/g, "&quot;");
const safeUrl = normalizeLink(String(url || ""));
return `<a href="${safeUrl}" class="underline hover:opacity-90">${safeLabel}</a>`;
});
return out;
};
for (const raw of lines) {
const line = raw.trimEnd();
const ltrim = line.replace(/^\s+/, "");
// ATX headings: support #..###### with optional space after hashes
const heading = ltrim.match(/^(#{1,6})\s*(.*)$/);
if (heading) {
closeListIfOpen();
const level = Math.min(heading[1].length, 6);
const text = renderInline(heading[2] || "");
htmlParts.push(`<h${level}>${text}</h${level}>`);
continue;
}
// Standalone image line
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
closeListIfOpen();
const alt = String(imgMatch[1] || "").replace(/"/g, "&quot;");
const url = normalizeLink(String(imgMatch[2] || ""));
htmlParts.push(`<p><img src="${url}" alt="${alt}" /></p>`);
continue;
}
if (line.startsWith("- ")) {
if (!inList) {
inList = true;
htmlParts.push("<ul>");
}
htmlParts.push(`<li>${renderInline(line.slice(2))}</li>`);
continue;
}
if (line.trim() === "") {
closeListIfOpen();
htmlParts.push("");
continue;
}
closeListIfOpen();
htmlParts.push(`<p>${renderInline(line)}</p>`);
}
closeListIfOpen();
return htmlParts.join("\n");
};
export type FrontmatterResult = {
frontmatter: Record<string, string>;
body: string;
};
export const parseFrontmatter = (raw: string): FrontmatterResult => {
const text = raw.replace(/\r\n/g, "\n");
if (!text.startsWith("---\n")) {
return { frontmatter: {}, body: text };
}
const end = text.indexOf("\n---\n", 4);
if (end === -1) return { frontmatter: {}, body: text };
const header = text.slice(4, end);
const body = text.slice(end + 5);
const fm: Record<string, string> = {};
for (const line of header.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line
.slice(idx + 1)
.trim()
.replace(/^"|^'|"$|'$/g, "");
if (key) fm[key] = value;
}
return { frontmatter: fm, body };
};
export const slugify = (s: string): string =>
s
.toLowerCase()
.replace(/[_\s]+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "");
export const pathToFilename = (path: string): string => {
const parts = path.split("/");
const filename = parts[parts.length - 1] || "";
return filename.replace(/\.md$/, "");
};