Compare commits

..
Author SHA1 Message Date
Brandon Thomas 6f91d02da1 Remove Google Auth (for now) - this will be difficult for the app 2026-01-28 14:48:04 -05:00
bflatastic 7885df257f website: news blog md file image and gif support with sizing, also to work in-app 2026-01-29 00:34:34 +07:00
bflatastic 94e4c3fc01 website: news blog md file video support 2026-01-29 00:20:41 +07:00
BFlat 6ee54bdcb9 Feature/google login (#1018)
google login button update
2026-01-27 23:32:04 +07:00
bflatastic a31138535e website: added discord cta on cancel and success pages, pricing footnote cleanup 2026-01-27 21:42:17 +07:00
Brandon Thomas 8899b3dcb5 routes 2026-01-27 06:16:42 -05:00
Brandon Thomas 69d9b2013a fix various pricing page language 2026-01-27 06:13:54 -05:00
Brandon Thomas ff56a96816 fix routes 2026-01-27 05:53:27 -05:00
Brandon Thomas c5e1843b91 fix routes 2026-01-27 05:48:53 -05:00
Brandon Thomas 9a433ff42a missing routes 2026-01-27 05:39:51 -05:00
BFlatandBrandon Thomas 1469b4fcff Feature/flow update (#1007)
- added login / sign up and pricing flow
- download page in nav and signup and pricing links/buttons

currently missing features:
- forget password function
- google social login

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-01-27 05:34:34 -05:00
19 changed files with 619 additions and 120 deletions
+4
View File
@@ -45,3 +45,7 @@ vite.config.*.timestamp*
vitest.config.*.timestamp*
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
# Environment files
.env.local
.env*.local
@@ -0,0 +1,4 @@
# Google OAuth Configuration
# Get the Client ID from the Google Cloud Console:
# https://console.cloud.google.com/apis/credentials
VITE_GOOGLE_CLIENT_ID=YOUR_CLIENT_ID.apps.googleusercontent.com
+28 -1
View File
@@ -1,13 +1,32 @@
/checkout_cancel /index.html 200
/checkout_cancel/ /index.html 200
/checkout_cancel/* /index.html 200
/checkout_success /index.html 200
/checkout_success/ /index.html 200
/checkout_success/* /index.html 200
/checkout* /index.html 200
/checkout/* /index.html 200
/download /index.html 200
/download/ /index.html 200
/tutorials /index.html 200
/tutorials/ /index.html 200
/login /index.html 200
/login/ /index.html 200
/login/* /index.html 200
/media /index.html 200
/media/ /index.html 200
/media/* /index.html 200
/pricing /index.html 200
/pricing/ /index.html 200
/pricing/* /index.html 200
/faq /index.html 200
/faq/ /index.html 200
/faq/* /index.html 200
@@ -23,4 +42,12 @@
# Redirects for growth measurement
/reddit /?utm_source=reddit 301
/news.json /index.html 200
/news.json /index.html 200
/signup /index.html 200
/signup/ /index.html 200
/signup/* /index.html 200
/welcome /index.html 200
/welcome/ /index.html 200
/welcome/* /index.html 200
@@ -0,0 +1,105 @@
import { GoogleLogin, CredentialResponse } from "@react-oauth/google";
import { UsersApi } from "@storyteller/api";
import { faSpinner } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
interface GoogleLoginButtonProps {
onSuccess: (isNewUser: boolean) => void;
onError: (error: string) => void;
mode?: "login" | "signup";
className?: string;
}
export function GoogleLoginButton({
onSuccess,
onError,
mode = "login",
className = "",
}: GoogleLoginButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleGoogleSuccess = async (
credentialResponse: CredentialResponse,
) => {
if (!credentialResponse.credential) {
onError("No credential received from Google");
return;
}
setIsLoading(true);
try {
const api = new UsersApi();
const result = await api.GoogleSSO({
credential: credentialResponse.credential,
});
if (result.success) {
window.dispatchEvent(new Event("auth-change"));
onSuccess(result.data?.usernameNotYetCustomized ?? false);
} else {
onError(result.errorMessage || "Authentication failed");
}
} catch (err) {
onError("Network error");
} finally {
setIsLoading(false);
}
};
return (
<div className={`relative w-full h-12 ${className}`}>
{/* Custom visual button - shown behind */}
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-lg font-medium border border-gray-200 hover:bg-gray-50 transition-colors">
{isLoading ? (
<FontAwesomeIcon
icon={faSpinner}
spin
className="text-lg text-gray-600"
/>
) : (
<>
<GoogleIcon />
<span>Continue with Google</span>
</>
)}
</div>
{/* Google's actual button - invisible overlay that receives clicks */}
<div
className="absolute inset-0 w-full h-full overflow-hidden cursor-pointer flex items-center justify-center"
style={{ opacity: 0.01, zIndex: 10, transform: "scaleY(1.2)" }}
>
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={() => onError("Google login failed")}
size="large"
width={400}
text="continue_with"
/>
</div>
</div>
);
}
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"
fill="#4285F4"
/>
<path
d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z"
fill="#34A853"
/>
<path
d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"
fill="#FBBC05"
/>
<path
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"
fill="#EA4335"
/>
</svg>
);
}
@@ -1 +1,2 @@
export * from "./signup-form";
export * from "./GoogleLoginButton";
@@ -1,12 +1,12 @@
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { UsersApi } from "@storyteller/api";
import { GoogleLoginButton } from "./GoogleLoginButton";
interface SignupFormProps {
onSuccess: () => void;
onSuccess: (isNewUser?: boolean) => void;
signupSource: string;
className?: string;
showGoogleButton?: boolean;
@@ -69,17 +69,23 @@ export const SignupForm = ({
}
};
//const handleGoogleSuccess = (isNewUser: boolean) => {
// onSuccess(isNewUser);
//};
//const handleGoogleError = (errorMessage: string) => {
// setError(errorMessage);
//};
return (
<div className={`space-y-4 ${className}`}>
{showGoogleButton && (
{/* {showGoogleButton && (
<>
<Button
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
onClick={() => {}}
>
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
Continue with Google
</Button>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
@@ -91,6 +97,7 @@ export const SignupForm = ({
</div>
</>
)}
*/}
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-sm text-center animate-in fade-in slide-in-from-top-2">
@@ -256,7 +256,7 @@ const PricingTable = ({
{showHeader && (
<div className={`text-center mx-auto ${compact ? "mb-6" : "mb-10"}`}>
<h2
className={`font-bold mb-4 ${compact ? "text-2xl md:text-3xl" : "text-3xl sm:text-4xl md:text-5xl"}`}
className={`font-bold mb-4 ${compact ? "text-2xl md:text-3xl" : "text-4xl sm:text-4xl md:text-6xl"}`}
>
{title}
</h2>
+17 -12
View File
@@ -1,11 +1,14 @@
import { StrictMode } from 'react';
import { BrowserRouter } from 'react-router-dom';
import * as ReactDOM from 'react-dom/client';
import App from './app/app';
import { StorytellerApiHostStore } from '@storyteller/api';
import { StrictMode } from "react";
import { BrowserRouter } from "react-router-dom";
import * as ReactDOM from "react-dom/client";
import { GoogleOAuthProvider } from "@react-oauth/google";
import App from "./app/app";
import { StorytellerApiHostStore } from "@storyteller/api";
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
document.getElementById("root") as HTMLElement,
);
// In development, route API through the Vite dev server origin to avoid CORS
@@ -14,14 +17,16 @@ if (import.meta.env.DEV) {
const origin = window.location.origin;
StorytellerApiHostStore.getInstance().setApiSchemeAndHost(origin);
} catch (e) {
console.warn('Failed to set dev API host override', e);
console.warn("Failed to set dev API host override", e);
}
}
root.render(
<StrictMode>
<BrowserRouter>
<App/>
</BrowserRouter>
</StrictMode>
);
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<BrowserRouter>
<App />
</BrowserRouter>
</GoogleOAuthProvider>
</StrictMode>,
);
@@ -1,7 +1,9 @@
import { faXmarkCircle, faArrowLeft } from "@fortawesome/pro-solid-svg-icons";
import { faXmarkCircle } from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import { SOCIAL_LINKS } from "../../config/links";
import Seo from "../../components/seo";
const CheckoutCancel = () => {
@@ -60,34 +62,42 @@ const CheckoutCancel = () => {
>
View Plans Again
</Button>
</div>
{/* Discord CTA */}
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-white/70 text-sm">
Need help deciding?
</span>
</div>
<Button
as="link"
href="/"
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
className="bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
>
<FontAwesomeIcon icon={faArrowLeft} />
Back to Home
<FontAwesomeIcon icon={faDiscord} />
Join Discord
</Button>
</div>
</div>
{/* Footer Links */}
<div className="text-center mt-8 flex justify-center gap-6">
<div className="text-center mt-8 flex justify-center gap-4">
<Link
to="/"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
Back to Home
</Link>
<span className="text-white/20">•</span>
<Link
to="/faq"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
FAQ
</Link>
<span className="text-white/20">•</span>
<a
href="https://discord.gg/artcraft"
target="_blank"
rel="noopener noreferrer"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
Get Help
</a>
</div>
</div>
</main>
@@ -3,13 +3,14 @@ import {
faDownload,
faDesktop,
faRocket,
faArrowLeft,
} from "@fortawesome/pro-solid-svg-icons";
import { faDiscord } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
import { isMobile, isMacOs } from "react-device-detect";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import { SOCIAL_LINKS } from "../../config/links";
import Seo from "../../components/seo";
const CheckoutSuccess = () => {
@@ -91,14 +92,6 @@ const CheckoutSuccess = () => {
<FontAwesomeIcon icon={faDownload} className="mr-2" />
Download ArtCraft
</Button>
<Button
as="link"
href="/"
className="bg-white/10 hover:bg-white/20 px-8 py-3 text-sm font-bold rounded-xl justify-center border-transparent"
>
<FontAwesomeIcon icon={faArrowLeft} />
Back to Home
</Button>
</div>
) : (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
@@ -129,15 +122,40 @@ const CheckoutSuccess = () => {
</a>
</div>
)}
{/* Discord CTA */}
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-white/70 text-sm">
Join our community
</span>
</div>
<Button
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
className="bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
>
<FontAwesomeIcon icon={faDiscord} />
Join Discord
</Button>
</div>
</div>
{/* Footer Link */}
<div className="text-center mt-8">
{/* Footer Links */}
<div className="text-center mt-8 flex justify-center gap-4">
<Link
to="/"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
Back to Home
</Link>
<span className="text-white/20">•</span>
<Link
to="/pricing"
className="text-white/40 hover:text-white text-sm font-medium transition-colors"
>
View Plan Details
View Plans
</Link>
</div>
</div>
@@ -1,4 +1,3 @@
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faEye, faEyeSlash, faSpinner } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
@@ -6,6 +5,7 @@ import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import Seo from "../../components/seo";
import { GoogleLoginButton } from "../../components/auth";
const Login = () => {
const navigate = useNavigate();
@@ -36,6 +36,19 @@ const Login = () => {
}
};
//const handleGoogleSuccess = (isNewUser: boolean) => {
// if (isNewUser) {
// // Optionally redirect to set username for new users
// navigate("/welcome");
// } else {
// navigate("/");
// }
//};
//const handleGoogleError = (errorMessage: string) => {
// setError(errorMessage);
//};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
@@ -52,13 +65,11 @@ const Login = () => {
</div>
<div className="space-y-4">
<Button
className="w-full bg-white text-black hover:bg-gray-100 border-none justify-center gap-3 font-medium h-12"
onClick={() => {}}
>
<FontAwesomeIcon icon={faGoogle} className="text-lg" />
Continue with Google
</Button>
{/*<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
@@ -68,6 +79,7 @@ const Login = () => {
or
</span>
</div>
*/}
<div className="space-y-4">
{error && (
@@ -16,14 +16,19 @@ const Pricing = () => {
<main className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<PricingTable
title="Invest in ArtCraft"
subtitle="Support open-source development. Your subscription makes ArtCraft better and gets you tons of generations."
title="Invest in Yourself"
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
/>
</main>
<p className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
Note: Pricing table is wrong, but we'll fix this shortly.
</p>
{/* Footnotes */}
<div className="relative z-10 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 text-center">
<p className="text-md text-white/80 leading-relaxed">
&dagger; ArtCraft can be used without paying for a subscription. You
can bring your own compute and third party subscriptions. We hope
you'll subscribe, though, as that helps accelerate our development.
</p>
</div>
<Footer />
</div>
+38
View File
@@ -211,6 +211,44 @@ export class UsersApi extends ApiManager {
};
}
}
public async GoogleSSO({
credential,
}: {
credential: string;
}): Promise<ApiResponse<{ usernameNotYetCustomized?: boolean }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/accounts/google_sso`;
const body = {
google_credential: credential,
};
try {
const response = await this.authFetch<
{ google_credential: string },
{
success: boolean;
username_not_yet_customized?: boolean;
error_message?: string;
}
>(endpoint, {
method: "POST",
body: body,
});
return {
success: response.success,
data: response.success
? { usernameNotYetCustomized: response.username_not_yet_customized }
: undefined,
errorMessage: response.error_message,
};
} catch (err: any) {
return {
success: false,
errorMessage: err.message,
};
}
}
}
(window as any).UsersApi = new UsersApi();
@@ -9,7 +9,7 @@ import {
import {
faDiscord,
faGithub,
faYoutube
faYoutube,
} from "@fortawesome/free-brands-svg-icons";
import { OpenUrl } from "@storyteller/tauri-api";
import { Modal } from "@storyteller/ui-modal";
@@ -38,7 +38,9 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
// Attempt to fetch fresh news
const fetchNews = async () => {
try {
const baseUrl = import.meta.env.DEV ? "http://localhost:4200" : "https://getartcraft.com";
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();
@@ -54,42 +56,61 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
fetchNews();
}, []);
const selectedPost = useMemo(() =>
selectedSlug ? items.find(i => i.slug === selectedSlug) : null
, [selectedSlug, items]);
const selectedPost = useMemo(
() => (selectedSlug ? items.find((i) => i.slug === selectedSlug) : null),
[selectedSlug, items],
);
const html = useMemo(() =>
selectedPost ? markdownToHtml(selectedPost.body) : ""
, [selectedPost]);
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
<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" />
)}
{selectedPost.date && (
<p className="text-xs text-white/50 mb-4">{selectedPost.date}</p>
)}
<div dangerouslySetInnerHTML={{ __html: html }} className="text-white/80 space-y-4" />
{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>{`
<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; }
.article-content img { display: block; width: 100%; max-width: 100%; height: auto; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); margin: 1rem 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; }
/* Video embed styles */
.article-content .video-embed { position: relative; width: 100%; margin: 1.5rem 0; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.1); background: #000; }
.article-content .youtube-embed { padding-bottom: 56.25%; /* 16:9 aspect ratio */ height: 0; }
.article-content .youtube-embed iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.article-content .video-embed video { display: block; width: 100%; height: auto; max-height: 50vh; }
`}</style>
</div>
);
@@ -97,28 +118,39 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
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}
{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>}
<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>
<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>}
{items.length === 0 && (
<div className="text-center text-white/40 py-10">No news yet.</div>
)}
</div>
);
};
export function HelpMenuButton({
items,
panelTitle = "Tutorials",
@@ -136,13 +168,13 @@ export function HelpMenuButton({
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);
@@ -282,7 +314,7 @@ export function HelpMenuButton({
progressTimerRef.current = window.setInterval(() => {
try {
const t = Math.floor(
playerRef.current?.getCurrentTime?.() ?? 0
playerRef.current?.getCurrentTime?.() ?? 0,
);
if (Number.isFinite(t)) setProgress(selected.id, t);
} catch {}
@@ -307,7 +339,12 @@ 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);
const modalTitle =
view === "video"
? undefined
: view === "news"
? "News & Updates"
: panelTitle;
return (
<div className={className}>
@@ -332,9 +369,7 @@ export function HelpMenuButton({
accessibleTitle={modalTitle}
className={view === "news" ? "max-w-3xl" : "max-w-5xl"}
>
{view === "news" && (
<NewsView onBack={() => {}} />
)}
{view === "news" && <NewsView onBack={() => {}} />}
{view === "grid" && (
<>
+67 -2
View File
@@ -19,8 +19,8 @@ thumbnail: https://optional-image-url.com/image.png
---
```
| Field | Required | Description |
|-------------|----------|-------------------------------------------------------|
| 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 |
@@ -71,6 +71,7 @@ node scripts/generate-news-json.mjs
```
This will output the compiled JSON to:
```
frontend/apps/artcraft-website/public/news.json
```
@@ -89,3 +90,67 @@ frontend/apps/artcraft-website/public/news.json
- **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)
## Embedding Videos
### YouTube Videos
Embed YouTube videos using the `@youtube()` syntax. You can use either a video ID or a full URL:
```markdown
@youtube(dQw4w9WgXcQ)
@youtube(https://www.youtube.com/watch?v=dQw4w9WgXcQ)
@youtube(https://youtu.be/dQw4w9WgXcQ)
```
All of the above will embed the same YouTube video. Supported URL formats:
- `youtube.com/watch?v=VIDEO_ID`
- `youtu.be/VIDEO_ID`
- `youtube.com/embed/VIDEO_ID`
- `youtube.com/shorts/VIDEO_ID`
### Regular Videos
Embed regular video files (mp4, webm, etc.) using the `@video()` syntax:
```markdown
@video(https://example.com/my-video.mp4)
@video(/videos/demo.webm)
```
Alternatively, you can use standard image syntax with a video file extension. The parser will automatically detect video files and render them as videos:
```markdown
![Demo video](https://example.com/my-video.mp4)
```
Supported video formats: `.mp4`, `.webm`, `.ogg`, `.mov`, `.m4v`, `.avi`
### Images and GIFs
Embed images or GIFs with controllable sizes. You can use standard markdown syntax with a pipe `|` or a dedicated tag:
#### Standard Markdown with Size
```markdown
![My Image](https://example.com/image.png|300)
![Animated GIF](https://example.com/animation.gif|400x300)
```
#### Dedicated Tags
```markdown
@image(https://example.com/image.png, 300)
@gif(https://example.com/animation.gif, 400x300)
```
#### Size Formats:
- **Width only**: `300` (defaults to pixels) or `50%`
- **Width and Height**: `400x300`
If the width is less than 100%, the image will be automatically centered. By default (if no size is specified), images and GIFs will take up **100% of the container width**. All images have a maximum width of 100% to ensure they stay within the blog container.
@@ -6,8 +6,8 @@ import { faChevronLeft } from "@fortawesome/pro-solid-svg-icons";
import { useMemo } from "react";
interface NewsPostProps {
slug?: string;
basePath: string; // e.g. "/news"
slug?: string;
basePath: string; // e.g. "/news"
}
export const NewsPost = ({ slug: propSlug, basePath }: NewsPostProps) => {
@@ -15,9 +15,7 @@ export const NewsPost = ({ slug: propSlug, basePath }: NewsPostProps) => {
const slug = propSlug || paramSlug;
const post = slug ? getNewsPostBySlug(slug) : null;
const html = useMemo(() =>
post ? markdownToHtml(post.body) : ""
, [post]);
const html = useMemo(() => (post ? markdownToHtml(post.body) : ""), [post]);
if (!post) {
return (
@@ -25,7 +23,12 @@ export const NewsPost = ({ slug: propSlug, basePath }: NewsPostProps) => {
<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>
<Link
to={basePath}
className="text-blue-400 mt-4 inline-block hover:underline"
>
Return to News
</Link>
</div>
</div>
);
@@ -54,8 +57,12 @@ export const NewsPost = ({ slug: propSlug, basePath }: NewsPostProps) => {
<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.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">
@@ -79,13 +86,19 @@ export const NewsPost = ({ slug: propSlug, basePath }: NewsPostProps) => {
.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 img { display: block; width: 100%; 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; }
/* Video embed styles */
.article-content .video-embed { position: relative; width: 100%; margin: 2rem 0; border-radius: 0.75rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.1); background: #000; }
.article-content .youtube-embed { padding-bottom: 56.25%; /* 16:9 aspect ratio */ height: 0; }
.article-content .youtube-embed iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.article-content .video-embed video { display: block; width: 100%; height: auto; max-height: 70vh; }
`}</style>
</div>
</div>
@@ -1,6 +1,6 @@
export const markdownToHtml = (
markdown: string,
basePath: string = ""
basePath: string = "",
): string => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const htmlParts: string[] = [];
@@ -25,17 +25,115 @@ export const markdownToHtml = (
return basePath + url;
};
// Check if a URL points to a video file
const isVideoUrl = (url: string): boolean => {
const videoExtensions = [".mp4", ".webm", ".ogg", ".mov", ".m4v", ".avi"];
const lowerUrl = url.toLowerCase().split("?")[0]; // Remove query params for extension check
return videoExtensions.some((ext) => lowerUrl.endsWith(ext));
};
// Extract YouTube video ID from various URL formats
const extractYouTubeId = (input: string): string | null => {
// If it's already just a video ID (11 characters, alphanumeric with - and _)
if (/^[a-zA-Z0-9_-]{11}$/.test(input.trim())) {
return input.trim();
}
// YouTube URL patterns
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = input.match(pattern);
if (match) return match[1];
}
return null;
};
// Render a YouTube embed
const renderYouTubeEmbed = (videoId: string): string => {
return `<div class="video-embed youtube-embed"><iframe src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div>`;
};
// Render a regular video embed
const renderVideoEmbed = (url: string): string => {
const safeUrl = normalizeLink(url);
return `<div class="video-embed"><video controls><source src="${safeUrl}" type="video/${getVideoMimeType(url)}">Your browser does not support the video tag.</video></div>`;
};
// Get video MIME type from URL
const getVideoMimeType = (url: string): string => {
const lowerUrl = url.toLowerCase().split("?")[0];
if (lowerUrl.endsWith(".webm")) return "webm";
if (lowerUrl.endsWith(".ogg")) return "ogg";
if (lowerUrl.endsWith(".mov") || lowerUrl.endsWith(".m4v")) return "mp4";
return "mp4"; // Default to mp4
};
// Parse size string like "300" or "400x300"
const parseSize = (sizeStr: string): { width?: string; height?: string } => {
if (!sizeStr) return {};
const parts = sizeStr.toLowerCase().split("x");
if (parts.length === 2) {
return { width: parts[0], height: parts[1] };
}
return { width: parts[0] };
};
// Render an image with optional size
const renderImage = (
url: string,
alt: string = "",
sizeStr: string = "",
): string => {
const safeUrl = normalizeLink(url);
const safeAlt = alt.replace(/"/g, "&quot;");
const { width, height } = parseSize(sizeStr);
let style =
"width: 100%; max-width: 100%; border-radius: 0.75rem; border: 1px solid rgba(255,255,255,0.1); margin: 2rem 0; display: block;";
let attrs = "";
if (width) {
const w =
width.endsWith("%") || width.endsWith("px") ? width : `${width}px`;
style += ` width: ${w};`;
attrs += ` width="${width.replace("px", "")}"`;
}
if (height) {
const h =
height.endsWith("%") || height.endsWith("px") ? height : `${height}px`;
style += ` height: ${h};`;
attrs += ` height="${height.replace("px", "")}"`;
}
// Centering if not full width
if (width && width !== "100%") {
style += " margin-left: auto; margin-right: auto;";
}
return `<img src="${safeUrl}" alt="${safeAlt}" style="${style}"${attrs} />`;
};
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)
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
const safeAlt = String(alt || "").replace(/"/g, "&quot;");
const safeUrl = String(url || "");
return `<img src="${safeUrl}" alt="${safeAlt}" />`;
// images/videos: ![alt](url|size) - detect video files and render as video
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, urlAndSize) => {
const [url, size] = urlAndSize.split("|");
const safeUrl = String(url || "").trim();
if (isVideoUrl(safeUrl)) {
return renderVideoEmbed(safeUrl);
}
return renderImage(safeUrl, alt, size);
});
// links: [text](url)
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
@@ -49,6 +147,40 @@ export const markdownToHtml = (
for (const raw of lines) {
const line = raw.trimEnd();
const ltrim = line.replace(/^\s+/, "");
// YouTube embed: @youtube(VIDEO_ID or URL)
const youtubeMatch = ltrim.match(/^@youtube\(([^)]+)\)\s*$/);
if (youtubeMatch) {
closeListIfOpen();
const videoId = extractYouTubeId(youtubeMatch[1]);
if (videoId) {
htmlParts.push(renderYouTubeEmbed(videoId));
} else {
htmlParts.push(`<p>Invalid YouTube video: ${youtubeMatch[1]}</p>`);
}
continue;
}
// Video embed: @video(url)
const videoMatch = ltrim.match(/^@video\(([^)]+)\)\s*$/);
if (videoMatch) {
closeListIfOpen();
htmlParts.push(renderVideoEmbed(videoMatch[1]));
continue;
}
// Image/GIF embed: @image(url, size) or @gif(url, size)
const imageMatch = ltrim.match(
/^@(image|gif)\(([^,)]+)(?:,\s*([^)]+))?\)\s*$/,
);
if (imageMatch) {
closeListIfOpen();
const url = imageMatch[2].trim();
const size = imageMatch[3]?.trim() || "";
htmlParts.push(renderImage(url, "", size));
continue;
}
// ATX headings: support #..###### with optional space after hashes
const heading = ltrim.match(/^(#{1,6})\s*(.*)$/);
if (heading) {
@@ -58,13 +190,20 @@ export const markdownToHtml = (
htmlParts.push(`<h${level}>${text}</h${level}>`);
continue;
}
// Standalone image line
// Standalone image/video line: ![alt](url|size)
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
if (imgMatch) {
closeListIfOpen();
const alt = String(imgMatch[1] || "").replace(/"/g, "&quot;");
const url = normalizeLink(String(imgMatch[2] || ""));
htmlParts.push(`<p><img src="${url}" alt="${alt}" /></p>`);
const [url, size] = imgMatch[2].split("|");
const safeUrl = normalizeLink(String(url || "").trim());
// Check if it's a video file
if (isVideoUrl(safeUrl)) {
htmlParts.push(renderVideoEmbed(safeUrl));
} else {
htmlParts.push(renderImage(safeUrl, alt, size));
}
continue;
}
if (line.startsWith("- ")) {
+10
View File
@@ -24,6 +24,7 @@
"@headlessui/react": "^2.2.2",
"@preact/signals-react": "^3.0.1",
"@radix-ui/react-dialog": "^1.1.14",
"@react-oauth/google": "^0.13.4",
"@remix-run/serve": "^2.15.0",
"@sparkjsdev/spark": "^0.1.10",
"dayjs": "^1.11.10",
@@ -7022,6 +7023,15 @@
"integrity": "sha512-84feABbmeWo1kg81726UOlMKAhcQyFXYz2SjRKYkS78QmfhVDhJ2o/ps1VjhFfBz0i/scDwT1XNv9GwmRIghkg==",
"peer": true
},
"node_modules/@react-oauth/google": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz",
"integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@react-spring/animated": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz",
+1
View File
@@ -16,6 +16,7 @@
"@headlessui/react": "^2.2.2",
"@preact/signals-react": "^3.0.1",
"@radix-ui/react-dialog": "^1.1.14",
"@react-oauth/google": "^0.13.4",
"@remix-run/serve": "^2.15.0",
"@sparkjsdev/spark": "^0.1.10",
"dayjs": "^1.11.10",