mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-30 15:25:57 -04:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f91d02da1 | ||
|
|
7885df257f | ||
|
|
94e4c3fc01 | ||
|
|
6ee54bdcb9 | ||
|
|
a31138535e | ||
|
|
8899b3dcb5 | ||
|
|
69d9b2013a | ||
|
|
ff56a96816 | ||
|
|
c5e1843b91 | ||
|
|
9a433ff42a | ||
|
|
1469b4fcff |
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
† 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>
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
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
|
||||

|
||||

|
||||
```
|
||||
|
||||
#### 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, """);
|
||||
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: 
|
||||
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
|
||||
const safeAlt = String(alt || "").replace(/"/g, """);
|
||||
const safeUrl = String(url || "");
|
||||
return `<img src="${safeUrl}" alt="${safeAlt}" />`;
|
||||
// images/videos:  - 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: 
|
||||
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||||
if (imgMatch) {
|
||||
closeListIfOpen();
|
||||
const alt = String(imgMatch[1] || "").replace(/"/g, """);
|
||||
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("- ")) {
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user