This commit is contained in:
bflatastic authored and Brandon Thomas committed 2026-01-27 05:24:31 -05:00
1 parent a3b9cd71ad
commit 4883185ad8
11 files changed
+567 -315

No files matched your search

@@ -8,7 +8,8 @@ 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";
import NewsIndex from "../pages/news/news-index";
import NewsPost from "../pages/news/news-post";
import Pricing from "../pages/pricing";
import Login from "../pages/login";
import Signup from "../pages/signup";
@@ -169,7 +169,10 @@ export default function Navbar() {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10">
<MenuItems
modal={false}
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-[#1C1C20] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none border border-white/10"
>
<div className="px-4 py-3 border-b border-white/10">
<p className="text-sm text-white font-semibold truncate">
{user.display_name || user.username}
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { DOWNLOAD_LINKS } from "../../config/downloads";
import { isMobile } from "react-device-detect";
import Seo from "../../components/seo";
const Download = () => {
const systemRequirements = [
@@ -29,6 +30,10 @@ const Download = () => {
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
<Seo
title="Download ArtCraft - Windows and macOS"
description="Download ArtCraft for Windows and macOS. Start creating AI artwork today."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
<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-30 blur-[120px]"></div>
@@ -4,65 +4,78 @@ import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link } from "react-router-dom";
import Seo from "../../components/seo";
const ForgotPassword = () => {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Reset Password - ArtCraft"
description="Reset your ArtCraft password."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">Enter your email to receive reset instructions</p>
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Reset Password</h1>
<p className="text-white/60 text-sm">
Enter your email to receive reset instructions
</p>
</div>
{!submitted ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={() => setSubmitted(true)}
>
Send Reset Link
</Button>
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
</div>
<h3 className="text-xl font-bold mb-2">Check your email</h3>
<p className="text-white/60 text-sm mb-8">
We've sent a password reset link to <br/><span className="text-white font-medium">{email}</span>
</p>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
onClick={() => setSubmitted(false)}
>
Try another email
</Button>
</div>
)}
{!submitted ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="mt-8 text-center text-sm">
<Link to="/login" className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2">
<FontAwesomeIcon icon={faArrowLeft} /> Back to Log in
</Link>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={() => setSubmitted(true)}
>
Send Reset Link
</Button>
</div>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<FontAwesomeIcon icon={faEnvelope} className="text-2xl" />
</div>
<h3 className="text-xl font-bold mb-2">Check your email</h3>
<p className="text-white/60 text-sm mb-8">
We've sent a password reset link to <br />
<span className="text-white font-medium">{email}</span>
</p>
<Button
className="w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-12"
onClick={() => setSubmitted(false)}
>
Try another email
</Button>
</div>
)}
<div className="mt-8 text-center text-sm">
<Link
to="/login"
className="text-white/40 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<FontAwesomeIcon icon={faArrowLeft} /> Back to Log in
</Link>
</div>
</div>
</main>
</div>
@@ -5,125 +5,147 @@ import { Button } from "@storyteller/ui-button";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import Seo from "../../components/seo";
const Login = () => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setError(null);
setIsLoading(true);
setError(null);
setIsLoading(true);
const api = new UsersApi();
const response = await api.Login({
usernameOrEmail: email,
password: password
});
const api = new UsersApi();
const response = await api.Login({
usernameOrEmail: email,
password: password,
});
setIsLoading(false);
setIsLoading(false);
if (response.success) {
window.dispatchEvent(new Event('auth-change'));
navigate("/");
} else {
setError(response.errorMessage || "Invalid credentials");
}
if (response.success) {
window.dispatchEvent(new Event("auth-change"));
navigate("/");
} else {
setError(response.errorMessage || "Invalid credentials");
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Login - ArtCraft"
description="Login to your ArtCraft account."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
</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>
<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>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">or</span>
<div className="relative flex items-center justify-center py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10"></div>
</div>
<div className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">Email or Username</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide">Password</label>
<Link to="/forgot-password" className="text-xs text-primary hover:text-primary-400 transition-colors">Forgot password?</Link>
</div>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? <FontAwesomeIcon icon={faSpinner} spin /> : "Log in"}
</Button>
</div>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Don't have an account?{" "}
<Link to="/signup" className="text-primary hover:text-primary-400 font-semibold transition-colors">
Sign up
</Link>
<span className="relative bg-[#1C1C20] px-4 text-xs text-white/40 uppercase tracking-widest">
or
</span>
</div>
<div className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
Email or Username
</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide">
Password
</label>
<Link
to="/forgot-password"
className="text-xs text-primary hover:text-primary-400 transition-colors"
>
Forgot password?
</Link>
</div>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
className="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12 mt-2"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinner} spin />
) : (
"Log in"
)}
</Button>
</div>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Don't have an account?{" "}
<Link
to="/signup"
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Sign up
</Link>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
@@ -30,14 +30,15 @@ import {
getProviderDisplayName,
getProviderIconByName,
} from "@storyteller/model-list";
import Seo from "../../components/seo";
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'];
const VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
const COPY_FEEDBACK_DURATION = 1500;
const SHARE_URL_BASE = 'https://getartcraft.com/media/';
const SHARE_URL_BASE = "https://getartcraft.com/media/";
const isVideoUrl = (url: string): boolean => {
const urlLower = url.toLowerCase();
return VIDEO_EXTENSIONS.some(ext => urlLower.includes(ext));
return VIDEO_EXTENSIONS.some((ext) => urlLower.includes(ext));
};
interface ContextImage {
@@ -78,7 +79,7 @@ const EMPTY_PROMPT_DATA: PromptData = {
const createPromptData = (
data: any,
hasToken: boolean,
loading: boolean = false
loading: boolean = false,
): PromptData => ({
text: data?.maybe_positive_prompt || null,
loading,
@@ -114,7 +115,13 @@ const useCopyFeedback = () => {
return { copied, triggerCopy };
};
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
const InfoRow = ({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) => (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
@@ -155,7 +162,7 @@ export default function MediaPage() {
if (mediaResponse.success && mediaResponse.data) {
const file = mediaResponse.data;
const url = file.media_links?.cdn_url || null;
setMedia((prev) => {
const isSameUrl = prev.url === url;
return {
@@ -171,8 +178,8 @@ export default function MediaPage() {
});
if (file.maybe_prompt_token) {
setPromptData(prev => ({ ...prev, hasToken: true, loading: true }));
setPromptData((prev) => ({ ...prev, hasToken: true, loading: true }));
try {
const promptsApi = new PromptsApi();
const promptResponse = await promptsApi.GetPromptsByToken({
@@ -182,17 +189,31 @@ export default function MediaPage() {
const data = promptResponse.success ? promptResponse.data : null;
setPromptData(createPromptData(data, true, false));
} catch {
setPromptData(prev => ({ ...prev, loading: false }));
setPromptData((prev) => ({ ...prev, loading: false }));
}
} else {
setPromptData(EMPTY_PROMPT_DATA);
}
} else {
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
setMedia({
url: null,
token: null,
createdAt: null,
isVideo: false,
isLoaded: false,
creator: null,
});
toast.error("Media not found");
}
} catch {
setMedia({ url: null, token: null, createdAt: null, isVideo: false, isLoaded: false, creator: null });
setMedia({
url: null,
token: null,
createdAt: null,
isVideo: false,
isLoaded: false,
creator: null,
});
toast.error("Failed to load media");
} finally {
setMediaRecordLoading(false);
@@ -205,8 +226,6 @@ export default function MediaPage() {
}
}, [mediaIdParam, loadMedia]);
const handleCopyPrompt = useCallback(async () => {
if (!promptData.text) return;
try {
@@ -231,10 +250,12 @@ export default function MediaPage() {
const shareButtonIcon = shareCopy.copied ? faCheck : faLink;
return (
<div className="relative min-h-screen w-full p-4 pt-16 bg-dots flex items-start lg:items-center justify-center">
<Seo
title="Shared Media - ArtCraft"
description="View shared media from ArtCraft."
/>
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
{/* Media Preview Area */}
@@ -254,14 +275,14 @@ export default function MediaPage() {
loop
muted
playsInline
onError={() => console.error('Video failed to load')}
onError={() => console.error("Video failed to load")}
onLoadedData={(e) => {
const el = e.currentTarget;
setMedia(prev => ({
...prev,
setMedia((prev) => ({
...prev,
isLoaded: true,
width: el.videoWidth,
height: el.videoHeight
height: el.videoHeight,
}));
}}
/>
@@ -273,18 +294,22 @@ export default function MediaPage() {
className="h-full w-full object-contain transition-opacity duration-300"
style={{ opacity: media.isLoaded ? 1 : 0 }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = PLACEHOLDER_IMAGES.DEFAULT;
(e.currentTarget as HTMLImageElement).style.opacity = "0.3";
(e.currentTarget as HTMLImageElement).dataset.brokenurl = media.url || "";
setMedia(prev => ({ ...prev, isLoaded: true }));
(e.currentTarget as HTMLImageElement).src =
PLACEHOLDER_IMAGES.DEFAULT;
(e.currentTarget as HTMLImageElement).style.opacity =
"0.3";
(
e.currentTarget as HTMLImageElement
).dataset.brokenurl = media.url || "";
setMedia((prev) => ({ ...prev, isLoaded: true }));
}}
onLoad={(e) => {
const el = e.currentTarget;
setMedia(prev => ({
...prev,
setMedia((prev) => ({
...prev,
isLoaded: true,
width: el.naturalWidth,
height: el.naturalHeight
height: el.naturalHeight,
}));
}}
/>
@@ -327,7 +352,10 @@ export default function MediaPage() {
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="aspect-square w-full bg-white/10 rounded-lg" />
<div
key={i}
className="aspect-square w-full bg-white/10 rounded-lg"
/>
))}
</div>
</div>
@@ -341,8 +369,12 @@ export default function MediaPage() {
size={36}
username={media.creator.username}
email_hash={media.creator.email_gravatar_hash}
avatarIndex={media.creator.core_info.default_avatar.image_index}
backgroundIndex={media.creator.core_info.default_avatar.color_index}
avatarIndex={
media.creator.core_info.default_avatar.image_index
}
backgroundIndex={
media.creator.core_info.default_avatar.color_index
}
className="rounded-xl border-white/10"
/>
) : (
@@ -351,8 +383,12 @@ export default function MediaPage() {
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-white text-sm font-semibold leading-none">{media.creator.display_name}</span>
<span className="text-white/60 text-xs font-medium">Author</span>
<span className="text-white text-sm font-semibold leading-none">
{media.creator.display_name}
</span>
<span className="text-white/60 text-xs font-medium">
Author
</span>
</div>
</div>
)}
@@ -381,11 +417,13 @@ export default function MediaPage() {
onClick={handleCopyPrompt}
className="flex items-center gap-1.5 text-xs text-white/60 hover:text-white transition-colors"
>
<FontAwesomeIcon icon={promptCopy.copied ? faCheck : faCopy} />
<FontAwesomeIcon
icon={promptCopy.copied ? faCheck : faCopy}
/>
<span>{promptCopy.copied ? "Copied" : "Copy"}</span>
</button>
</div>
<div className="relative text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
{promptData.loading ? (
<div className="flex items-center gap-2">
@@ -404,52 +442,59 @@ export default function MediaPage() {
</div>
</div>
{promptData.contextImages && promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
{promptData.contextImages &&
promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map(
(contextImage, index) => {
const { thumbnail } =
getContextImageThumbnail(contextImage, {
size: THUMBNAIL_SIZES.SMALL,
});
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
>
<img
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
);
},
)}
</div>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL }
);
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors"
>
<img
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
</div>
);
})}
</div>
</div>
)}
)}
{(promptData.provider || promptData.modelType || media.createdAt) && (
{(promptData.provider ||
promptData.modelType ||
media.createdAt) && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faCircleInfo} />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
value={
<>
{getModelCreatorIcon(promptData.modelType)}
<span>{getModelDisplayName(promptData.modelType)}</span>
<span>
{getModelDisplayName(
promptData.modelType,
)}
</span>
</>
}
/>
@@ -460,8 +505,15 @@ export default function MediaPage() {
label="Provider"
value={
<>
{getProviderIconByName(promptData.provider, "h-4 w-4 invert")}
<span>{getProviderDisplayName(promptData.provider)}</span>
{getProviderIconByName(
promptData.provider,
"h-4 w-4 invert",
)}
<span>
{getProviderDisplayName(
promptData.provider,
)}
</span>
</>
}
/>
@@ -473,11 +525,13 @@ export default function MediaPage() {
value={`${media.width} × ${media.height}`}
/>
)}
{media.createdAt && (
<InfoRow
label="Created"
value={dayjs(media.createdAt).format("MMMM D, YYYY")}
value={dayjs(media.createdAt).format(
"MMMM D, YYYY",
)}
/>
)}
</div>
@@ -504,11 +558,15 @@ export default function MediaPage() {
"w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border",
media.url
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed pointer-events-none",
)}
href={media.url ? addCorsParam(media.url) || media.url : undefined}
href={
media.url ? addCorsParam(media.url) || media.url : undefined
}
download={
media.url ? `artcraft-${media.token || (media.isVideo ? "video" : "image")}` : undefined
media.url
? `artcraft-${media.token || (media.isVideo ? "video" : "image")}`
: undefined
}
aria-disabled={!media.url}
target="_blank"
@@ -0,0 +1,16 @@
import { NewsIndex as LibNewsIndex } from "@storyteller/markdown-content";
import Seo from "../../components/seo";
const NewsIndex = ({ basePath }: { basePath: string }) => {
return (
<>
<Seo
title="News & Updates - ArtCraft"
description="Latest updates, features, and announcements from the ArtCraft team."
/>
<LibNewsIndex basePath={basePath} />
</>
);
};
export default NewsIndex;
@@ -0,0 +1,25 @@
import {
NewsPost as LibNewsPost,
getNewsPostBySlug,
} from "@storyteller/markdown-content";
import Seo from "../../components/seo";
import { useParams } from "react-router-dom";
const NewsPost = ({ basePath }: { basePath: string }) => {
const { slug } = useParams();
const post = slug ? getNewsPostBySlug(slug) : null;
const title = post
? `${post.title} - ArtCraft`
: "Article Not Found - ArtCraft";
const desc = post ? post.description : "";
return (
<>
<Seo title={title} description={desc} />
<LibNewsPost basePath={basePath} />
</>
);
};
export default NewsPost;
@@ -2,107 +2,198 @@ import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import Footer from "../../components/footer";
import {
SUBSCRIPTION_PLANS,
SubscriptionPlanDetails,
} from "@storyteller/subscription";
import { twMerge } from "tailwind-merge";
import Seo from "../../components/seo";
import { useState } from "react";
import { TabSelector } from "@storyteller/ui-tab-selector";
const billingTabs = [
{ id: "yearly", label: "Yearly" },
{ id: "monthly", label: "Monthly" },
];
const Pricing = () => {
const [billingType, setBillingType] = useState("yearly");
const isYearly = billingType === "yearly";
const getColorSchemeClasses = (
colorScheme: SubscriptionPlanDetails["colorScheme"],
isCurrent: boolean,
) => {
const baseClasses =
"relative rounded-3xl p-8 border flex flex-col transition-all duration-300 backdrop-blur-md";
switch (colorScheme) {
case "dark":
return twMerge(
baseClasses,
"bg-[#1C1C20] border-white/10 hover:border-white/20",
);
case "green":
// Basic
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#002D23]/80 via-[#006B54]/50 to-[#00D28B]/10 border-[#00a873]/50",
"hover:border-[#00a873] hover:shadow-[0_0_30px_rgba(0,210,139,0.2)]",
);
case "purple":
// Pro
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#2D004D]/80 via-[#6400A8]/50 to-[#C03FFF]/10 border-[#9D4CFF]/50",
"hover:border-[#9D4CFF] hover:shadow-[0_0_30px_rgba(192,63,255,0.2)]",
);
case "orange":
// Max
return twMerge(
baseClasses,
"bg-gradient-to-b from-[#332100]/80 via-[#B35C00]/50 to-[#FFB347]/10 border-[#FF8C00]/50",
"hover:border-[#FF8C00] hover:shadow-[0_0_30px_rgba(255,179,71,0.2)]",
);
default:
return twMerge(
baseClasses,
"bg-white/5 border-white/10 hover:border-white/20",
);
}
};
const getButtonText = (planSlug: string) => {
// Since this is the website, all buttons should probably lead to download or signup/login
// If we want to mirror the app, we can't really do "Switch Plan" because we don't know the logged in user's state easily here (unless we fetch it).
// For now, let's keep it simple: "Get Started" or "Start Free Trial"
if (planSlug === "free") return "Get Started";
return "Select Plan";
};
const getButtonHref = (planSlug: string) => {
if (planSlug === "free") return "/download";
return "/signup";
};
const formatPrice = (plan: SubscriptionPlanDetails) => {
if (plan.monthlyPrice === 0) {
return { current: 0, original: null };
}
if (isYearly) {
// Annual price divided by 12
const val = Math.round(plan.yearlyPrice / 12);
// Check if there's an original price to show savings
const original = plan.originalYearlyPrice
? Math.round(plan.originalYearlyPrice / 12)
: null;
return { current: val, original };
} else {
// Monthly price
const val = plan.originalMonthlyPrice || plan.monthlyPrice;
return { current: val, original: null };
}
};
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
<Seo
title="Pricing - ArtCraft"
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-50" />
<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>
<main className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-3xl mx-auto mb-16 sm:mb-24">
<div className="text-center max-w-3xl mx-auto mb-10">
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-6">
Simple, Transparent Pricing
</h1>
<p className="text-xl text-white/70 leading-relaxed">
<p className="text-xl text-white/70 leading-relaxed mb-8">
Start for free and scale as you grow. No hidden fees.
</p>
{/* Billing Toggle */}
<div className="flex items-center justify-center gap-4 mb-8 relative w-fit mx-auto">
<TabSelector
tabs={billingTabs}
activeTab={billingType}
onTabChange={setBillingType}
className="w-fit border border-white/20 rounded-lg bg-white/5"
tabClassName="w-24 text-md"
indicatorClassName="bg-primary/30 border border-primary"
selectedTabClassName="text-white"
/>
<span className="bg-primary text-white px-3 py-0.5 rounded-full text-sm font-medium -top-3 -right-10 md:-left-6 md:right-auto absolute pointer-events-none transform md:-rotate-12 rotate-12">
-20%
</span>
</div>
</div>
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Free Tier */}
<div className="bg-white/5 backdrop-blur-md rounded-3xl p-8 border border-white/10 flex flex-col hover:border-white/20 transition-all duration-300">
<h3 className="text-2xl font-bold mb-2">Hobbyist</h3>
<div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-bold">$0</span>
<span className="text-white/60">/month</span>
</div>
<p className="text-white/60 mb-8 h-12">
Perfect for getting started and learning the basics of ArtCraft.
</p>
<Button
className="w-full justify-center bg-white/10 hover:bg-white/20 border-transparent mb-8"
as="link"
href="/download"
>
Get Started
</Button>
<ul className="space-y-4 flex-1">
<Feature text="Unlimited local generations" />
<Feature text="Acccess to basic models" />
<Feature text="Community support" />
<Feature text="Non-commercial use" />
</ul>
</div>
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{SUBSCRIPTION_PLANS.map((plan) => {
const isPopular = plan.slug === "artcraft_pro";
const { current: price, original: originalPrice } =
formatPrice(plan);
{/* Pro Tier (Highlighted) */}
<div className="bg-[#1C1C20] backdrop-blur-md rounded-3xl p-8 border-2 border-primary shadow-2xl shadow-primary/20 flex flex-col relative transform md:-translate-y-4">
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg">
MOST POPULAR
</div>
<h3 className="text-2xl font-bold mb-2 text-primary">Pro</h3>
<div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-bold">$20</span>
<span className="text-white/60">/month</span>
</div>
<p className="text-white/60 mb-8 h-12">
For professional artists and creators who need more power.
</p>
<Button
className="w-full justify-center bg-primary hover:bg-primary-600 border-transparent mb-8"
as="link"
href="/signup"
>
Start Free Trial
</Button>
<ul className="space-y-4 flex-1">
<Feature text="Everything in Hobbyist" highlighted />
<Feature text="Cloud sync & backup" highlighted />
<Feature text="Priority rendering queue" highlighted />
<Feature text="Commercial license" highlighted />
<Feature text="Access to latest models" highlighted />
<Feature text="4K resolution support" highlighted />
</ul>
</div>
return (
<div
key={plan.slug}
className={
getColorSchemeClasses(plan.colorScheme, false) +
(isPopular ? " transform md:-translate-y-4 shadow-2xl" : "")
}
>
{isPopular && (
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary px-4 py-1 rounded-full text-sm font-bold shadow-lg whitespace-nowrap">
MOST POPULAR
</div>
)}
{/* Studio Tier */}
<div className="bg-white/5 backdrop-blur-md rounded-3xl p-8 border border-white/10 flex flex-col hover:border-white/20 transition-all duration-300">
<h3 className="text-2xl font-bold mb-2">Studio</h3>
<div className="mb-6 flex items-baseline gap-1">
<span className="text-4xl font-bold">$100</span>
<span className="text-white/60">/month</span>
</div>
<p className="text-white/60 mb-8 h-12">
For teams and studios requiring collaboration tools.
</p>
<Button
className="w-full justify-center bg-white/10 hover:bg-white/20 border-transparent mb-8"
as="link"
href="/contact"
>
Contact Sales
</Button>
<ul className="space-y-4 flex-1">
<Feature text="Everything in Pro" />
<Feature text="Team collaboration" />
<Feature text="Dedicated support manager" />
<Feature text="Custom model fine-tuning" />
<Feature text="SSO & Advanced Security" />
</ul>
</div>
<h3
className={`text-2xl font-bold mb-2 ${isPopular ? "text-primary" : "text-white"}`}
>
{plan.name}
</h3>
<div className="mb-1 flex items-baseline gap-2">
{originalPrice !== null && (
<span className="text-white/40 line-through text-xl decoration-white/40">
${originalPrice}
</span>
)}
<span className="text-4xl font-bold">${price}</span>
<span className="text-white/60">/month</span>
</div>
<div className="text-xs text-white/40 mb-6 uppercase tracking-wider font-semibold min-h-[1rem]">
{plan.monthlyPrice === 0
? "Free forever"
: isYearly
? "Billed yearly"
: "Billed monthly"}
</div>
<Button
className={`w-full justify-center border-transparent mb-8 ${isPopular ? "bg-primary hover:bg-primary-600" : "bg-white/10 hover:bg-white/20"}`}
as="link"
href={getButtonHref(plan.slug)}
>
{getButtonText(plan.slug)}
</Button>
<ul className="space-y-4 flex-1">
{plan.features.map((feature, idx) => (
<Feature
key={idx}
text={feature.text}
highlighted={isPopular}
/>
))}
</ul>
</div>
);
})}
</div>
</main>
@@ -111,12 +202,22 @@ const Pricing = () => {
);
};
const Feature = ({ text, highlighted = false }: { text: string; highlighted?: boolean }) => (
const Feature = ({
text,
highlighted = false,
}: {
text: string;
highlighted?: boolean;
}) => (
<li className="flex items-start gap-3">
<div className={`mt-1 w-5 h-5 rounded-full flex items-center justify-center ${highlighted ? "bg-primary/20 text-primary" : "bg-white/10 text-white/50"}`}>
<FontAwesomeIcon icon={faCheck} className="text-xs" />
<div
className={`mt-1 w-5 h-5 rounded-full flex items-center justify-center shrink-0 ${highlighted ? "bg-primary/20 text-primary" : "bg-white/10 text-white/50"}`}
>
<FontAwesomeIcon icon={faCheck} className="text-xs" />
</div>
<span className={`text-sm ${highlighted ? "text-white" : "text-white/70"}`}>{text}</span>
<span className={`text-sm ${highlighted ? "text-white" : "text-white/70"}`}>
{text}
</span>
</li>
);
@@ -1,37 +1,44 @@
import { Link, useNavigate } from "react-router-dom";
import { SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
const Signup = () => {
const navigate = useNavigate();
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots flex flex-col">
<Seo
title="Sign Up - ArtCraft"
description="Create your ArtCraft account."
/>
<div className="dotted-pattern absolute inset-0 z-[0] opacity-30" />
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-3xl p-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
<p className="text-white/60 text-sm">Join thousands of creators</p>
</div>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2">Create an Account</h1>
<p className="text-white/60 text-sm">Join thousands of creators</p>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Already have an account?{" "}
<Link to="/login" className="text-primary hover:text-primary-400 font-semibold transition-colors">
Log in
</Link>
</div>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
<div className="mt-8 text-center text-sm text-white/60">
Already have an account?{" "}
<Link
to="/login"
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Log in
</Link>
</div>
</div>
</main>
<div className="relative z-10 py-6 text-center text-white/20 text-xs">
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
&copy; {new Date().getFullYear()} ArtCraft. All rights reserved.
</div>
</div>
);
@@ -2,19 +2,20 @@ import {
faCheckCircle,
faDownload,
faDesktop,
faRocket,
} from "@fortawesome/pro-solid-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 Seo from "../../components/seo";
const Welcome = () => {
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
return (
<div className="relative min-h-[calc(100vh-1px)] pt-20 md:pt-0 overflow-hidden bg-[#101014] text-white flex flex-col items-center justify-center p-4">
<Seo title="Welcome - ArtCraft" description="Welcome to ArtCraft." />
<main className="relative z-10 w-full max-w-2xl">
<div className="bg-[#28282C] rounded-[32px] overflow-hidden shadow-2xl border border-white/5">
<div className="p-8 pt-10 text-center flex flex-col items-center">