mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
updates
This commit is contained in:
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">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
© {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">
|
||||
© {new Date().getFullYear()} ArtCraft. All rights reserved.
|
||||
© {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">
|
||||
|
||||
Reference in new issue
Block a user