mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-19 02:20:17 -05:00
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:
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"
|
||||
},
|
||||
|
||||
@@ -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() ],
|
||||
|
||||
+10
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
-88
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+14
-3
@@ -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)
|
||||
File renamed without changes.
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
-1
@@ -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: 
|
||||
let out = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
|
||||
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
|
||||
const safeAlt = String(alt || "").replace(/"/g, """);
|
||||
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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
Generated
+171
-1762
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./libs/components/viewer-3d"
|
||||
},
|
||||
{
|
||||
"path": "./libs/markdown-content"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user