Feature/markdown content refactor (#980)

- refactored all markdown content as a shared library for use across
both the ArtCraft website and the app (FAQ, tutorials, news)
- added a news page to the website
- added a news & updates option to the app's bottom right help context
menu

Instructions to add and build News Posts for both the site and app is in
``NEWS_POSTS.md`` but in short:
- To add a new news post, create an .md file in
``libs/markdown-content/src/lib/content/news/`` folder
- Restart the artcraft-website dev server after adding new posts (or in
/frontend directory, run ``node scripts/generate-news-json.mjs``
manually) - this will update the public ``news.json`` file on the
website.
- This is just a temporary workaround - this way if the ArtCraft app
itself hasn't updated, it'll still be able to load the latest public
``news.json`` file from the website to show within the app.
This commit is contained in:
BFlat authored and GitHub committed 2026-01-19 10:39:11 +07:00
1 parent 32a2f19ccb
commit 2a08051ec4
54 files changed
+1239 -2111

No files matched your search

@@ -343,10 +343,10 @@
"markdownDescription": "Denies the fetch_send command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"description": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`",
"type": "string",
"const": "default",
"markdownDescription": "This permission set configures what kind of\nfetch operations are available from the http plugin.\n\nThis enables all fetch operations but does not\nallow explicitly any origins to be fetched. This needs to\nbe manually configured before usage.\n\n#### Granted Permissions\n\nAll fetch operations are enabled.\n\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
"markdownDescription": "This permission set configures what kind of\r\nfetch operations are available from the http plugin.\r\n\r\nThis enables all fetch operations but does not\r\nallow explicitly any origins to be fetched. This needs to\r\nbe manually configured before usage.\r\n\r\n#### Granted Permissions\r\n\r\nAll fetch operations are enabled.\r\n\r\n\n#### This default permission set includes:\n\n- `allow-fetch`\n- `allow-fetch-cancel`\n- `allow-fetch-read-body`\n- `allow-fetch-send`"
}
]
}
@@ -12,5 +12,11 @@
/faq/ /index.html 200
/faq/* /index.html 200
/news /index.html 200
/news/ /index.html 200
/news/* /index.html 200
# Redirects for growth measurement
/reddit /?utm_source=reddit 301
/news.json /index.html 200
@@ -0,0 +1,9 @@
[
{
"slug": "welcome",
"title": "Welcome to the ArtCraft Blog",
"description": "We are excited to launch our new blog where we will share updates, tutorials, and behind-the-scenes content.",
"date": "2026-01-16",
"body": "\n# Hello World!\n\nWelcome to the official **ArtCraft Blog**. This is where we'll be posting about:\n\n- **New Features**: Be the first to know about the latest tools and capabilities added to ArtCraft.\n- **Tutorials**: Learn how to get the most out of our AI generation and editing suite.\n- **Community Showcases**: Highlighting amazing work created by our users.\n- **Development Updates**: Insights into what we are building next.\n\n## Stay Tuned\n\nWe have a lot of exciting things in the pipeline. Make sure to check back regularly!\n\nHappy Crafting!\nThe ArtCraft Team\n"
}
]
@@ -7,6 +7,7 @@ import TutorialsPage from "../pages/tutorials";
import TutorialsArticle from "../pages/tutorials/article";
import FaqIndex from "../pages/faq/index";
import FaqArticle from "../pages/faq/article";
import { NewsIndex, NewsPost } from "@storyteller/markdown-content";
export function App() {
return (
@@ -22,6 +23,8 @@ export function App() {
<Route path="/tutorials/:slug" element={<TutorialsArticle />} />
<Route path="/faq" element={<FaqIndex />} />
<Route path="/faq/:slug" element={<FaqArticle />} />
<Route path="/news" element={<NewsIndex basePath="/news" />} />
<Route path="/news/:slug" element={<NewsPost basePath="/news" />} />
</Routes>
</div>
);
@@ -6,19 +6,10 @@ import {
faGithubAlt,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
import { getFaqItems, getTutorialItems } from "@storyteller/markdown-content";
import { Link } from "react-router-dom";
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,
});
// Imports removed.
const navigation = {
main: [
@@ -100,23 +91,24 @@ export default function Example() {
<Link to="/" className="text-gray-400 hover:text-white">
Home
</Link>
<Link to="/news" className="text-gray-400 hover:text-white">
News
</Link>
</div>
<div className="flex flex-col items-center sm:items-start gap-2">
<div className="text-gray-200 mb-1">Tutorials</div>
<Link to="/tutorials" className="text-gray-400 hover:text-white">
All Tutorials
</Link>
{Object.entries(tutorialFiles).map(([path, raw]) => {
const { frontmatter } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
if (frontmatter.isPublished === "false") return null;
{getTutorialItems().map((item) => {
if (!item.isPublished) return null;
return (
<Link
key={slug}
to={`/tutorials/${slug}`}
key={item.slug}
to={`/tutorials/${item.slug}`}
className="text-gray-400 hover:text-white"
>
{frontmatter.title || slug}
{item.title}
</Link>
);
})}
@@ -126,17 +118,14 @@ export default function Example() {
<Link to="/faq" className="text-gray-400 hover:text-white">
All FAQs
</Link>
{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;
{getFaqItems().map((item) => {
if (!item.isPublished) return null;
const truncated =
title.length > 36 ? title.slice(0, 33) + "…" : title;
item.title.length > 36 ? item.title.slice(0, 33) + "…" : item.title;
return (
<Link
key={slug}
to={`/faq/${slug}`}
key={item.slug}
to={`/faq/${item.slug}`}
className="text-gray-400 hover:text-white"
>
{truncated}
@@ -7,6 +7,7 @@ import { DiscordButton } from "../discord-button";
const NAV_ITEMS = [
{ name: "Home", href: "/" },
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
];
@@ -1,42 +1,19 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, markdownToHtml } from "../../utils/markdown";
import { getFaqItemBySlug, markdownToHtml } from "@storyteller/markdown-content";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
const markdownFiles = import.meta.glob("./content/*.md", {
query: "?raw",
import: "default",
eager: true,
});
import { useMemo } from "react";
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]
);
const item = slug ? getFaqItemBySlug(slug) : null;
if (!markdown) {
const html = useMemo(() =>
item ? markdownToHtml(item.body, "/faq/") : ""
, [item]);
if (!item) {
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">
@@ -47,16 +24,16 @@ const FaqArticle = () => {
);
}
const title = `${frontmatter.title || slug} - ArtCraft`;
const description = frontmatter.abstract || "";
const thumbnail = frontmatter.thumbnail || "";
const html = markdownToHtml(body, "/faq/");
const title = `${item.title} - ArtCraft`;
const description = item.description || "";
const thumbnail = item.thumbnail || "";
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: frontmatter.title || slug,
headline: item.title,
description: description,
articleBody: body,
articleBody: item.body,
};
return (
@@ -79,7 +56,7 @@ const FaqArticle = () => {
</div>
<h1 className="text-4xl font-bold mb-4 !leading-tight">
{frontmatter.title || slug}
{item.title || slug}
</h1>
{description && <p className="text-white/70 mb-8">{description}</p>}
@@ -87,7 +64,7 @@ const FaqArticle = () => {
<div className="w-full overflow-hidden rounded-lg border border-white/10 bg-black mb-10">
<img
src={thumbnail}
alt={frontmatter.title || slug}
alt={item.title || slug}
className="w-full h-auto object-cover"
/>
</div>
@@ -1,27 +1,15 @@
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,
});
import { getFaqItems } from "@storyteller/markdown-content";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
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 items = getFaqItems();
const jsonLd = {
"@context": "https://schema.org",
@@ -59,9 +47,12 @@ const FaqIndex = () => {
<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"
className="group 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>
<h2 className="text-xl font-semibold mb-2 group-hover:text-blue-400 transition-colors flex items-center gap-2">
{item.title}
<FontAwesomeIcon icon={faArrowRight} className="opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300" />
</h2>
<p className="text-white/70 text-sm">{item.description}</p>
</Link>
))}
@@ -2,6 +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 TutorialsIndex } from "./tutorials/tutorials";
export { default as TutorialsArticle } from "./tutorials/article";
export * from "./faq";
@@ -1,50 +1,20 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import Seo from "../../components/seo";
import { parseFrontmatter, markdownToHtml } from "../../utils/markdown";
import { getTutorialItemBySlug, markdownToHtml } from "@storyteller/markdown-content";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
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]
);
const item = slug ? getTutorialItemBySlug(slug) : null;
if (!markdown) {
const html = useMemo(() =>
item ? markdownToHtml(item.body) : ""
, [item]);
if (!item) {
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">
@@ -55,8 +25,8 @@ const TutorialsArticle = () => {
);
}
const title = `${frontmatter.title || slug} - ArtCraft`;
const description = frontmatter.abstract || "";
const title = `${item.title} - ArtCraft`;
const description = item.abstract || "";
const toEmbed = (url: string): string => {
if (!url) return url;
if (url.includes("youtu.be/"))
@@ -65,18 +35,17 @@ const TutorialsArticle = () => {
return url;
};
const frontmatterVideo =
(frontmatter.videoUrl as string) ||
(frontmatter.youtubeId
? `https://www.youtube.com/embed/${frontmatter.youtubeId}`
(item.videoUrl as string) ||
(item.youtubeId
? `https://www.youtube.com/embed/${item.youtubeId}`
: "");
const videoUrl = toEmbed(frontmatterVideo);
const html = markdownToHtml(body);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: frontmatter.title || slug,
headline: item.title,
description: description,
articleBody: body,
articleBody: item.body,
};
return (
@@ -99,14 +68,14 @@ const TutorialsArticle = () => {
</div>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 !leading-tight">
{frontmatter.title || slug}
{item.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}
title={item.title || slug}
src={videoUrl}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@@ -1,56 +0,0 @@
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;
@@ -4,15 +4,7 @@ import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay } from "@fortawesome/pro-solid-svg-icons";
import { parseFrontmatter, pathToFilename } from "../../utils/markdown";
type MdTutorial = {
slug: string;
title: string;
abstract: string;
category?: string;
thumbnail?: string;
};
import { getTutorialItems, TutorialItem } from "@storyteller/markdown-content";
const websiteThumb = (url: string): string => {
// Map shared modal thumbnail paths to website public path
@@ -24,29 +16,9 @@ const websiteThumb = (url: string): string => {
export const TutorialsPage = () => {
const [activeCategory, setActiveCategory] = useState<string>("All");
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 items = useMemo<TutorialItem[]>(() => {
return getTutorialItems();
}, []);
const categories = useMemo(() => {
const set = new Set<string>();
@@ -57,6 +57,9 @@
},
{
"path": "../../libs/components/button/tsconfig.lib.json"
},
{
"path": "../../libs/markdown-content/tsconfig.lib.json"
}
]
}
@@ -23,6 +23,9 @@
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/markdown-content"
},
{
"path": "./tsconfig.app.json"
},
+18 -1
View File
@@ -1,6 +1,23 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { execSync } from 'child_process';
import path from 'path';
// Custom plugin to generate news.json on dev server start
function generateNewsPlugin() {
return {
name: 'generate-news',
buildStart() {
try {
const scriptPath = path.resolve(__dirname, '../../scripts/generate-news-json.mjs');
execSync(`node "${scriptPath}"`, { stdio: 'inherit' });
} catch (e) {
console.warn('Failed to generate news.json:', e);
}
},
};
}
export default defineConfig(() => ({
root: __dirname,
@@ -21,7 +38,7 @@ export default defineConfig(() => ({
port: 4300,
host: 'localhost',
},
plugins: [react()],
plugins: [generateNewsPlugin(), react()],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
@@ -4,7 +4,7 @@ import { useSignals, useSignalEffect } from "@preact/signals-react/runtime";
import {
faCheckSquare,
faFile,
faQuestion,
faKeyboard,
faSquare,
} from "@fortawesome/pro-solid-svg-icons";
import { EngineContext } from "~/pages/PageEnigma/contexts/EngineContext";
@@ -29,7 +29,7 @@ export const ControlsTopButtons = () => {
const params = useParams();
const location = useLocation();
const navigate = useNavigate();
const [helpIsShowing, setHelpIsShowing] = useState(false);
const [shortcutsIsShowing, setShortcutsIsShowing] = useState(false);
const editorEngine = useContext(EngineContext);
const [sceneTitleInput, setSceneTitleInput] = useState<string>("");
@@ -288,24 +288,26 @@ export const ControlsTopButtons = () => {
</Button>
<Button
icon={faQuestion}
icon={faKeyboard}
variant="secondary"
className="shadow-xl"
onClick={() => setHelpIsShowing(true)}
onClick={() => setShortcutsIsShowing(true)}
>
Help
Shortcuts
</Button>
</div>
<TestFeaturesButtons debug={false} />
<Modal
isOpen={helpIsShowing}
onClose={() => setHelpIsShowing(false)}
title="Help"
isOpen={shortcutsIsShowing}
onClose={() => setShortcutsIsShowing(false)}
title="Shortcuts"
className="h-[500px] max-w-4xl"
>
<Help />
</Modal>
</div>
);
};
@@ -53,96 +53,91 @@ export const Mouse = (props: {
export const Plus = () => <div className="text-xl font-medium">+</div>;
const ShortcutsView = () => (
<div className="grid select-none grid-cols-2 gap-12 ps-4 mt-6">
<div className="flex flex-col gap-8">
<ShortcutsGroup label="Navigation">
<Shortcut label="Orbit View">
<KeyGroup>
<Mouse button="left" label="(Hold)" />
</KeyGroup>
</Shortcut>
<Shortcut label="Forward Backward">
<KeyGroup>
<Key button="W" />
<Key button="S" />
</KeyGroup>
</Shortcut>
<Shortcut label="Left Right">
<KeyGroup>
<Key button="A" />
<Key button="D" />
</KeyGroup>
</Shortcut>
<Shortcut label="Up Down">
<KeyGroup>
<Key button="E" />
<Key button="Q" />
</KeyGroup>
</Shortcut>
<Shortcut label="Speed Boost">
<KeyGroup>
<Key button="Shift" />
</KeyGroup>
</Shortcut>
</ShortcutsGroup>
<ShortcutsGroup label="Interaction">
<Shortcut label="Select Object">
<Mouse button="left" label="(Click)" />
</Shortcut>
<Shortcut label="Delete Selection">
<Key button="Del" />
</Shortcut>
</ShortcutsGroup>
</div>
<div className="flex flex-col gap-8">
<ShortcutsGroup label="Shortcuts">
<Shortcut label="Transform">
<Key button="T" />
</Shortcut>
<Shortcut label="Rotate">
<Key button="R" />
</Shortcut>
<Shortcut label="Scale">
<Key button="G" />
</Shortcut>
<Shortcut label="Focus">
<Key button="F" />
</Shortcut>
<Shortcut label="Copy">
<Key button="Ctrl" />
<Plus></Plus>
<Key button="C" />
</Shortcut>
<Shortcut label="Paste">
<Key button="Ctrl" />
<Plus></Plus>
<Key button="Shift" />
<Plus></Plus>
<Key button="V" />
</Shortcut>
<Shortcut label="Open 3D Asset Modal">
<Key button="B" />
</Shortcut>
</ShortcutsGroup>
</div>
</div>
);
export const Help = () => {
return (
<div className="mt-8 grid select-none grid-cols-2 gap-12 ps-4">
<div className="flex flex-col gap-8">
<ShortcutsGroup label="Navigation">
{/* <Shortcut label="Forward">
<Key button="w" label="Forward" />
</Shortcut>
<Shortcut label="Rotate View">
<Mouse button="left" label="(Drag)" />
</Shortcut>
<Shortcut label="Zoom">
<Mouse button="middle" label="(Scroll)" />
</Shortcut> */}
<Shortcut label="Orbit View">
<KeyGroup>
<Mouse button="left" label="(Hold)" />
</KeyGroup>
</Shortcut>
<Shortcut label="Forward Backward">
<KeyGroup>
<Key button="W" />
<Key button="S" />
</KeyGroup>
</Shortcut>
<Shortcut label="Left Right">
<KeyGroup>
<Key button="A" />
<Key button="D" />
</KeyGroup>
</Shortcut>
<Shortcut label="Up Down">
<KeyGroup>
<Key button="E" />
<Key button="Q" />
</KeyGroup>
</Shortcut>
<Shortcut label="Speed Boost">
<KeyGroup>
<Key button="Shift" />
</KeyGroup>
</Shortcut>
</ShortcutsGroup>
<ShortcutsGroup label="Interaction">
<Shortcut label="Select Object">
<Mouse button="left" label="(Click)" />
</Shortcut>
{/* <Shortcut label="Clear Selection">
<Key button="Esc" />
</Shortcut> */}
{/* <Shortcut label="Focus Selection">
<Key button="F" />
</Shortcut> */}
<Shortcut label="Delete Selection">
<Key button="Del" />
</Shortcut>
</ShortcutsGroup>
</div>
<div className="flex flex-col gap-8">
<ShortcutsGroup label="Shortcuts">
<Shortcut label="Transform">
<Key button="T" />
</Shortcut>
<Shortcut label="Rotate">
<Key button="R" />
</Shortcut>
<Shortcut label="Scale">
<Key button="G" />
</Shortcut>
<Shortcut label="Focus">
<Key button="F" />
</Shortcut>
<Shortcut label="Copy">
<Key button="Ctrl" />
<Plus></Plus>
<Key button="C" />
</Shortcut>
<Shortcut label="Paste">
<Key button="Ctrl" />
<Plus></Plus>
<Key button="Shift" />
<Plus></Plus>
<Key button="V" />
</Shortcut>
<Shortcut label="Open 3D Asset Modal">
<Key button="B" />
</Shortcut>
</ShortcutsGroup>
<div className="flex flex-col h-full">
<div className="flex-1">
<ShortcutsView />
</div>
</div>
);
+3 -3
View File
@@ -109,9 +109,6 @@
{
"path": "../../libs/components/close-button"
},
{
"path": "../../libs/model-list"
},
{
"path": "../../libs/components/model-selector"
},
@@ -139,6 +136,9 @@
{
"path": "../../libs/components/list-dropdown"
},
{
"path": "../../libs/model-list"
},
{
"path": "../../libs/components/gallery-modal"
},
@@ -1,27 +1,38 @@
import { create } from "zustand";
import type { TutorialItem } from "./tutorials";
import { TutorialItem } from "./tutorials";
type TutorialView = "grid" | "video";
export type ViewType = "grid" | "video" | "news";
type TutorialModalStore = {
view: TutorialView;
interface TutorialModalState {
isOpen: boolean;
view: ViewType;
selected: TutorialItem | null;
progressSecondsById: Record<string, number>;
progress: Record<string, number>; // itemId -> seconds
setOpen: (isOpen: boolean) => void;
setGrid: () => void;
setNews: () => void;
viewTutorial: (item: TutorialItem) => void;
getProgress: (id: string) => number;
setProgress: (id: string, seconds: number) => void;
};
getProgress: (id: string) => number;
}
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 },
})),
}));
export const useTutorialModalStore = create<TutorialModalState>()(
(set, get) => ({
isOpen: false,
view: "grid",
selected: null,
progress: {},
setOpen: (isOpen) => set({ isOpen }),
setGrid: () => set({ view: "grid", selected: null }),
setNews: () => set({ view: "news", selected: null }),
viewTutorial: (item) => set({ view: "video", selected: item }),
setProgress: (id, seconds) =>
set((state) => ({
progress: { ...state.progress, [id]: seconds },
})),
getProgress: (id) => get().progress[id] || 0,
})
);
@@ -3,6 +3,8 @@ import {
faBook,
faChevronLeft,
faCircleQuestion,
faNewspaper,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import {
faDiscord,
@@ -16,6 +18,7 @@ import { useTutorialModalStore } from "./help-menu-store";
import { Button } from "@storyteller/ui-button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content";
export type HelpMenuButtonProps = {
items?: TutorialItem[];
@@ -24,6 +27,98 @@ export type HelpMenuButtonProps = {
onOpenChange?: (open: boolean) => void;
};
// Internal News Component
const NewsView = ({ onBack }: { onBack: () => void }) => {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const bundledItems = useMemo(() => getNewsPosts(), []);
const [items, setItems] = useState(bundledItems);
useEffect(() => {
// Attempt to fetch fresh news
const fetchNews = async () => {
try {
const baseUrl = import.meta.env.DEV ? "http://localhost:4200" : "https://getartcraft.com";
const res = await fetch(`${baseUrl}/news.json`);
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) {
setItems(data);
}
}
} catch (e) {
// Fallback or ignore
console.warn("Failed to fetch news feed, using bundled content.", e);
}
};
fetchNews();
}, []);
const selectedPost = useMemo(() =>
selectedSlug ? items.find(i => i.slug === selectedSlug) : null
, [selectedSlug, items]);
const html = useMemo(() =>
selectedPost ? markdownToHtml(selectedPost.body) : ""
, [selectedPost]);
if (selectedPost) {
return (
<div className="flex flex-col h-[500px] overflow-y-auto pr-2 custom-scrollbar">
<button
onClick={() => setSelectedSlug(null)}
className="self-start mb-4 text-sm flex items-center gap-2 text-white/60 hover:text-white transition-colors"
>
<FontAwesomeIcon icon={faChevronLeft} /> Back to News
</button>
<div className="article-content">
<h1 className="text-2xl font-bold mb-2">{selectedPost.title}</h1>
{selectedPost.date && <p className="text-xs text-white/50 mb-4">{selectedPost.date}</p>}
{selectedPost.thumbnail && (
<img src={selectedPost.thumbnail} alt={selectedPost.title} className="w-full h-48 object-cover rounded-lg mb-4 border border-white/10" />
)}
<div dangerouslySetInnerHTML={{ __html: html }} className="text-white/80 space-y-4" />
</div>
<style>{`
.article-content h1 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.article-content h2 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; color: #93c5fd; }
.article-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.75rem 0 0.5rem; }
.article-content p { margin-bottom: 0.75rem; line-height: 1.6; }
.article-content ul { list-style: disc; padding-left: 1.25rem; margin-bottom: 0.75rem; }
.article-content li { margin-bottom: 0.25rem; }
.article-content img { border-radius: 0.5rem; max-width: 100%; height: auto; }
`}</style>
</div>
);
}
return (
<div className="flex flex-col gap-4 mt-2 h-[500px] overflow-y-auto pr-2 custom-scrollbar">
{items.map(item => (
<div
key={item.slug}
onClick={() => setSelectedSlug(item.slug)}
className="bg-white/5 hover:bg-white/10 border border-white/10 p-4 rounded-lg cursor-pointer transition-all group"
>
<div className="flex justify-between items-start">
<h3 className="text-lg font-semibold group-hover:text-blue-300 transition-colors flex items-center gap-2">
{item.title}
<FontAwesomeIcon icon={faArrowRight} size="xs" className="opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-blue-400" />
</h3>
{item.date && <span className="text-xs text-white/40 font-mono whitespace-nowrap ml-2">{item.date}</span>}
</div>
<p className="text-sm text-white/60 mt-1 line-clamp-2">{item.description}</p>
</div>
))}
{items.length === 0 && <div className="text-center text-white/40 py-10">No news yet.</div>}
</div>
);
};
export function HelpMenuButton({
items,
panelTitle = "Tutorials",
@@ -35,11 +130,21 @@ export function HelpMenuButton({
const view = useTutorialModalStore((s) => s.view);
const selected = useTutorialModalStore((s) => s.selected);
const setGrid = useTutorialModalStore((s) => s.setGrid);
const setNews = useTutorialModalStore((s) => s.setNews);
const viewTutorial = useTutorialModalStore((s) => s.viewTutorial);
const getProgress = useTutorialModalStore((s) => s.getProgress);
const setProgress = useTutorialModalStore((s) => s.setProgress);
// Sync open state if managed externally? No, local state + optional callback.
const handleOpenTutorials = () => {
setGrid();
setOpen(true);
onOpenChange?.(true);
};
const handleOpenNews = () => {
setNews();
setOpen(true);
onOpenChange?.(true);
};
@@ -57,6 +162,12 @@ export function HelpMenuButton({
icon: <FontAwesomeIcon icon={faBook} className="text-base" />,
action: "tutorials",
},
{
label: "News & Updates",
selected: false,
icon: <FontAwesomeIcon icon={faNewspaper} className="text-base" />,
action: "news",
},
{
label: "Discord",
selected: false,
@@ -82,6 +193,9 @@ export function HelpMenuButton({
case "tutorials":
handleOpenTutorials();
break;
case "news":
handleOpenNews();
break;
case "discord":
OpenUrl("https://discord.com/invite/75svZP2Vje");
break;
@@ -193,6 +307,8 @@ export function HelpMenuButton({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, view, selected?.id]);
const modalTitle = view === "video" ? undefined : (view === "news" ? "News & Updates" : panelTitle);
return (
<div className={className}>
<PopoverMenu
@@ -211,11 +327,15 @@ export function HelpMenuButton({
<Modal
isOpen={open}
onClose={handleClose}
title={view === "video" ? undefined : panelTitle}
titleIcon={faBook}
accessibleTitle={panelTitle}
className="max-w-5xl"
title={modalTitle}
titleIcon={view === "news" ? faNewspaper : faBook}
accessibleTitle={modalTitle}
className={view === "news" ? "max-w-3xl" : "max-w-5xl"}
>
{view === "news" && (
<NewsView onBack={() => {}} />
)}
{view === "grid" && (
<>
{tutorials.length === 0 ? (
@@ -14,6 +14,9 @@
{
"path": "../../tauri-api"
},
{
"path": "../../markdown-content"
},
{
"path": "./tsconfig.lib.json"
},
@@ -54,6 +54,9 @@
},
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../../markdown-content/tsconfig.lib.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
@@ -0,0 +1,91 @@
# News Posts
This directory contains markdown files for news/blog posts that appear in the ArtCraft app and website.
## Creating a New Post
1. Create a new `.md` file in `src/lib/content/news/` with a URL-friendly filename (e.g., `my-new-feature.md`)
2. Add the required frontmatter at the top of the file
3. Write your content in markdown below the frontmatter
### Frontmatter Template
```yaml
---
title: Your Post Title
abstract: A brief description of the post (shown in the news list)
date: YYYY-MM-DD
thumbnail: https://optional-image-url.com/image.png
---
```
| Field | Required | Description |
|-------------|----------|-------------------------------------------------------|
| `title` | Yes | The title displayed in the news list and post header |
| `abstract` | Yes | Short description shown in the news list preview |
| `date` | Yes | Publication date in `YYYY-MM-DD` format |
| `thumbnail` | No | URL to an optional header image for the post |
### Example Post
```markdown
---
title: Welcome to the ArtCraft Blog
abstract: We are excited to launch our new blog where we will share updates and tutorials.
date: 2026-01-16
thumbnail: https://example.com/blog-header.png
---
# Hello World!
Welcome to the official **ArtCraft Blog**. This is where we'll be posting about:
- **New Features**: Be the first to know about the latest tools.
- **Tutorials**: Learn how to get the most out of ArtCraft.
- **Community Showcases**: Highlighting amazing work created by our users.
## Stay Tuned
We have a lot of exciting things in the pipeline!
```
## Building the News Feed
The news posts are compiled into a `news.json` file that is served to the app.
### Automatic Build (Dev Server)
When you run the dev server, news posts are automatically compiled:
```bash
npx nx dev artcraft-website
```
### Manual Build
To manually regenerate `news.json`:
```bash
cd frontend
node scripts/generate-news-json.mjs
```
This will output the compiled JSON to:
```
frontend/apps/artcraft-website/public/news.json
```
## How It Works
1. The `generate-news-json.mjs` script reads all `.md` files from the news content directory
2. It parses the frontmatter and body content
3. It generates a sorted JSON array (newest first by date)
4. The JSON is saved to the website's `public/` folder
5. The app fetches this JSON to display the news feed
## Tips
- **Filename = Slug**: The filename (without `.md`) becomes the URL slug for the post
- **Sorting**: Posts are automatically sorted by date, newest first
- **Markdown Support**: Full markdown is supported including headers, lists, bold, italic, images, and code blocks
- **Hot Reload**: After adding/editing a post, restart the dev server to see changes (or run the build script manually)
+7
View File
@@ -0,0 +1,7 @@
# @storyteller/markdown-content
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/markdown-content` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,17 @@
{
"name": "@storyteller/markdown-content",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
@@ -0,0 +1,5 @@
export * from './lib/markdown-content';
export * from './lib/pages/NewsIndex';
export * from './lib/pages/NewsPost';
export * from './lib/data/content';
export * from './lib/utils/markdown';
@@ -9,26 +9,37 @@ slug: models-supported
## Image models
- Grok
- Midjourney
- Flux Pro 1.1 Ultra
- Flux Pro 1.1
- Flux 1 Dev
- Flux 1 Schnell
- Midjourney
- GPT Image 1
- Gemini 2.5 Flash (editing)
- Nano Banana
- Nano Banana Pro
- GPT Image 1 (GPT-4o)
- GPT Image 1.5
- Seedream 4
- Seedream 4.5
- Flux Pro Kontext Max (editing)
- Flux Pro Inpaint (editing)
- Flux Dev Juggernaut Inpaint (editing)
## Video models
- Grok Video
- Kling 1.6 Pro
- Kling 2.1 Pro
- Kling 2.1 Master
- Kling 2.5 Turbo Pro
- Kling 2.6 Pro
- Seedance 1.0 Lite
- Sora 2
- Sora 2 Pro
- Google Veo 2
- Google Veo 3
- Google Veo 3 Fast
- Google Veo 3.1
- Google Veo 3.1 Fast
See also: [Text to Image](../text-to-image) · [Image to Video](../image-to-video)
@@ -0,0 +1,21 @@
---
title: Welcome to the ArtCraft Blog
abstract: We are excited to launch our new blog where we will share updates, tutorials, and behind-the-scenes content.
date: 2026-01-16
---
# Hello World!
Welcome to the official **ArtCraft Blog**. This is where we'll be posting about:
- **New Features**: Be the first to know about the latest tools and capabilities added to ArtCraft.
- **Tutorials**: Learn how to get the most out of our AI generation and editing suite.
- **Community Showcases**: Highlighting amazing work created by our users.
- **Development Updates**: Insights into what we are building next.
## Stay Tuned
We have a lot of exciting things in the pipeline. Make sure to check back regularly!
Happy Crafting!
The ArtCraft Team
@@ -0,0 +1,125 @@
import { parseFrontmatter, pathToFilename } from "../utils/markdown";
// NEWS
const newsFiles = import.meta.glob("../content/news/*.md", {
query: "?raw",
import: "default",
eager: true,
});
export interface NewsPostData {
slug: string;
title: string;
description: string;
date: string;
body: string;
thumbnail?: string;
}
export const getNewsPosts = (): NewsPostData[] => {
return Object.entries(newsFiles)
.map(([path, raw]) => {
const { frontmatter, body } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.abstract || "",
date: frontmatter.date || "",
thumbnail: frontmatter.thumbnail,
body,
};
})
.sort((a, b) => {
if (a.date && b.date)
return new Date(b.date).getTime() - new Date(a.date).getTime();
return 0; // consistent output
});
};
export const getNewsPostBySlug = (slug: string): NewsPostData | null => {
const posts = getNewsPosts();
return posts.find((p) => p.slug === slug) || null;
};
// FAQ
const faqFiles = import.meta.glob("../content/faq/*.md", {
query: "?raw",
import: "default",
eager: true,
});
export interface FaqItem {
slug: string;
title: string;
description: string;
body: string;
isPublished: boolean;
thumbnail?: string;
}
export const getFaqItems = (): FaqItem[] => {
return Object.entries(faqFiles).map(([path, raw]) => {
const { frontmatter, body } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.abstract || "",
body,
isPublished: frontmatter.isPublished !== "false",
thumbnail: frontmatter.thumbnail,
};
});
};
export const getFaqItemBySlug = (slug: string): FaqItem | null => {
const items = getFaqItems();
return items.find(i => i.slug === slug) || null;
};
// TUTORIALS
const tutorialFiles = import.meta.glob("../content/tutorials/*.md", {
query: "?raw",
import: "default",
eager: true,
});
export interface TutorialItem {
slug: string;
title: string;
abstract: string;
category?: string;
thumbnail?: string;
videoUrl?: string;
youtubeId?: string;
aliases?: string[];
body: string;
isPublished: boolean;
}
export const getTutorialItems = (): TutorialItem[] => {
return Object.entries(tutorialFiles).map(([path, raw]) => {
const { frontmatter, body } = parseFrontmatter(raw as string);
const slug = pathToFilename(path);
return {
slug: (frontmatter.slug || slug).trim().toLowerCase(),
title: frontmatter.title || slug,
abstract: frontmatter.abstract || "",
category: frontmatter.category,
thumbnail: frontmatter.thumbnail,
videoUrl: frontmatter.videoUrl,
youtubeId: frontmatter.youtubeId,
aliases: frontmatter.aliases ? frontmatter.aliases.split(/[\s,]+/).map(s => s.trim().toLowerCase()).filter(Boolean) : [],
body,
isPublished: frontmatter.isPublished !== "false",
};
});
};
export const getTutorialItemBySlug = (slug: string): TutorialItem | null => {
const items = getTutorialItems();
const wanted = slug.toLowerCase();
return items.find(t => t.slug === wanted || t.aliases?.includes(wanted)) || null;
};
@@ -0,0 +1,7 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/
@@ -0,0 +1,12 @@
import { render } from '@testing-library/react';
import StorytellerMarkdownContent from './markdown-content';
describe('StorytellerMarkdownContent', () => {
it('should render successfully', () => {
const { baseElement } = render(<StorytellerMarkdownContent />);
expect(baseElement).toBeTruthy();
});
});
@@ -0,0 +1,15 @@
import styles from './markdown-content.module.css';
export function StorytellerMarkdownContent() {
return (
<div className={styles['container']}>
<h1>Welcome to StorytellerMarkdownContent!</h1>
</div>
);
}
export default StorytellerMarkdownContent;
@@ -0,0 +1,64 @@
import { Link } from "react-router-dom";
import { getNewsPosts } from "../data/content";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/pro-solid-svg-icons";
interface NewsIndexProps {
basePath: string; // e.g. "/news" or "/blog"
}
export const NewsIndex = ({ basePath }: NewsIndexProps) => {
const items = getNewsPosts();
const title = "News & Updates";
const description =
"Latest updates, features, and announcements from the ArtCraft team.";
// Note: SEO/Head handling should be done by the consumer app if needed (e.g. Helmet)
// But we can check if we want to include it here or let the parent handle it.
// For now, we return the UI structure.
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-20 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">
{title}
</h1>
<p className="text-white/70 text-base sm:text-lg">
{description}
</p>
</div>
<div className="flex flex-col gap-6">
{items.map((item) => (
<Link
key={item.slug}
to={`${basePath}/${item.slug}`}
className="group block rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 p-6 transition-all"
>
<div className="flex flex-col sm:flex-row sm:items-baseline justify-between gap-2 mb-2">
<h2 className="text-2xl font-semibold group-hover:text-blue-400 transition-colors flex items-center gap-3">
{item.title}
<FontAwesomeIcon icon={faArrowRight} className="text-xl opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300" />
</h2>
{item.date && (
<span className="text-white/50 text-sm font-mono shrink-0">{item.date}</span>
)}
</div>
<p className="text-white/70 text-base leading-relaxed">{item.description}</p>
</Link>
))}
{items.length === 0 && (
<div className="text-center py-10 opacity-50">
No updates yet. Check back soon!
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,93 @@
import { Link, useParams } from "react-router-dom";
import { getNewsPostBySlug } from "../data/content";
import { markdownToHtml } from "../utils/markdown";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
interface NewsPostProps {
slug?: string;
basePath: string; // e.g. "/news"
}
export const NewsPost = ({ slug: propSlug, basePath }: NewsPostProps) => {
const { slug: paramSlug } = useParams();
const slug = propSlug || paramSlug;
const post = slug ? getNewsPostBySlug(slug) : null;
const html = useMemo(() =>
post ? markdownToHtml(post.body) : ""
, [post]);
if (!post) {
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 article.</p>
<Link to={basePath} className="text-blue-400 mt-4 inline-block hover:underline">Return to News</Link>
</div>
</div>
);
}
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-20 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-8 flex items-center justify-between">
<Link
to={basePath}
className="rounded-lg px-4 py-2 text-sm border border-white/10 bg-white/5 hover:bg-white/10 inline-flex items-center gap-2 transition-colors"
>
<FontAwesomeIcon icon={faChevronLeft} />
Back to News
</Link>
{post.date && (
<span className="text-white/50 font-mono text-sm">{post.date}</span>
)}
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-6 !leading-tight tracking-tight">
{post.title}
</h1>
{post.description && <p className="text-xl text-white/70 mb-8 leading-relaxed font-light border-l-4 border-primary pl-4">{post.description}</p>}
{post.thumbnail && (
<div className="w-full overflow-hidden rounded-xl border border-white/10 bg-black mb-12 shadow-2xl">
<img
src={post.thumbnail}
alt={post.title}
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: 2rem 0 1rem; color: #fff; }
.article-content h2 { font-size: 1.75rem; font-weight: 700; margin: 2rem 0 1rem; color: #bae6fd; }
.article-content h3 { font-size: 1.5rem; font-weight: 600; margin: 1.5rem 0 0.75rem; color: #bae6fd; }
.article-content h4 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; }
.article-content p { margin: 1rem 0; line-height: 1.75; font-size: 1.05rem; }
.article-content ul { list-style: disc; padding-left: 1.5rem; margin: 1rem 0; }
.article-content li { margin-bottom: 0.5rem; }
.article-content img { display: block; max-width: 100%; height: auto; border-radius: 0.75rem; border: 1px solid rgba(255,255,255,0.1); margin: 2rem 0; }
.article-content a { color: #3b82f6; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
.article-content a:hover { border-bottom-color: #3b82f6; }
.article-content blockquote { border-left: 4px solid #3b82f6; padding-left: 1rem; font-style: italic; color: rgba(255,255,255,0.7); margin: 1.5rem 0; }
.article-content code { background: rgba(255,255,255,0.1); padding: 0.2em 0.4em; border-radius: 0.25em; font-family: monospace; font-size: 0.9em; }
.article-content pre { background: rgba(0,0,0,0.3); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1.5rem 0; border: 1px solid rgba(255,255,255,0.1); }
.article-content pre code { background: transparent; padding: 0; color: inherit; }
`}</style>
</div>
</div>
);
};
@@ -26,8 +26,13 @@ export const markdownToHtml = (
};
const renderInline = (text: string): string => {
// bold: **text**
let out = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// italic: *text*
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
// images: ![alt](url)
let out = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const safeAlt = String(alt || "").replace(/"/g, "&quot;");
const safeUrl = String(url || "");
return `<img src="${safeUrl}" alt="${safeAlt}" />`;
@@ -0,0 +1,13 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
@@ -0,0 +1,45 @@
{
"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"
]
}
@@ -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/markdown-content',
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/markdown-content',
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,
}
},
}));
+171 -1762
View File
File diff suppressed because it is too large. Load diff
+71
View File
@@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const NEWS_DIR = path.resolve(__dirname, '../libs/markdown-content/src/lib/content/news');
const OUTPUT_FILE = path.resolve(__dirname, '../apps/artcraft-website/public/news.json');
const parseFrontmatter = (raw) => {
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 = {};
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 };
};
const generateNewsJson = () => {
if (!fs.existsSync(NEWS_DIR)) {
console.error(`News directory not found: ${NEWS_DIR}`);
process.exit(1);
}
const files = fs.readdirSync(NEWS_DIR).filter(f => f.endsWith('.md'));
const posts = files.map(filename => {
const filePath = path.join(NEWS_DIR, filename);
const raw = fs.readFileSync(filePath, 'utf-8');
const { frontmatter, body } = parseFrontmatter(raw);
const slug = filename.replace(/\.md$/, "");
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.abstract || "",
date: frontmatter.date || "",
thumbnail: frontmatter.thumbnail,
body,
};
}).sort((a, b) => {
if (a.date && b.date)
return new Date(b.date).getTime() - new Date(a.date).getTime();
return 0;
});
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(posts, null, 2));
console.log(`Generated news.json at ${OUTPUT_FILE} with ${posts.length} posts.`);
};
generateNewsJson();
+2 -1
View File
@@ -28,7 +28,8 @@
// Uncomment if 2D ever gets used
// "apps/editor2d/app/src/*",
"apps/artcraft/app/src/*"
]
],
"@storyteller/markdown-content": ["libs/markdown-content/src/index.ts"]
}
}
}
+3
View File
@@ -176,6 +176,9 @@
},
{
"path": "./libs/components/viewer-3d"
},
{
"path": "./libs/markdown-content"
}
]
}