new homepage

This commit is contained in:
Jordi Enric
2025-07-27 18:35:59 +02:00
parent dd9893d51b
commit 4b4c452a20
9 changed files with 492 additions and 150 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 925 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

@@ -0,0 +1,24 @@
import { motion } from "framer-motion";
export function BrowserWrapper({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-2xl border border-slate-300 bg-gradient-to-b from-slate-100 to-slate-200 p-2">
<div className="flex items-center gap-2 px-2 pb-1">
{new Array(3).fill(0).map((v, idx) => (
<span
key={`dot-${idx}`}
className="block size-3 rounded-full border border-slate-300"
></span>
))}
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="min-h-[600px] overflow-hidden rounded-xl border border-slate-300"
>
{children}
</motion.div>
</div>
);
}
@@ -0,0 +1,79 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { useEffect, useRef, useState } from "react";
import { BrowserWrapper } from "./browser-wrapper";
import { TsApiExample } from "./ts-api-example";
import { HttpApiExample } from "./http-api-example";
import { LaptopMinimal, PencilLine } from "lucide-react";
import { TbBrandTypescript } from "react-icons/tb";
import { TbHttpGet } from "react-icons/tb";
export const HeroImages = () => {
const FEATURES = [
{
id: "editor",
title: "Editor",
icon: <PencilLine className="mr-2 size-5 opacity-80" />,
content: (
<BrowserWrapper>
<img
src="/static/hero/editor.png"
alt="Editor"
className="h-full w-full rounded-lg border-blue-300 object-cover shadow-xl"
/>
</BrowserWrapper>
),
},
{
id: "dashboard",
title: "Dashboard",
icon: <LaptopMinimal className="mr-2 size-5 opacity-80" />,
content: (
<BrowserWrapper>
<img
src="/static/hero/dashboard.png"
alt="Dashboard"
className="h-full w-full rounded-lg border-orange-300 object-cover shadow-xl"
/>
</BrowserWrapper>
),
},
{
id: "tsapi",
title: "TypeScript",
content: <TsApiExample />,
icon: <TbBrandTypescript className="mr-2 size-5 opacity-80" />,
},
{
id: "httpapi",
title: "HTTP",
content: <HttpApiExample />,
icon: <TbHttpGet className="mr-2 size-5 opacity-80" />,
},
];
const [currentIndex, setCurrentIndex] = useState(0);
const currentFeature = FEATURES[currentIndex];
return (
<Tabs value={currentFeature?.id} className="w-full">
<TabsList className="mb-4 w-full space-x-4">
{FEATURES.map((f, index) => (
<TabsTrigger
key={`${f.id}-trigger`}
value={f.id}
onClick={() => setCurrentIndex(index)}
className="relative flex cursor-pointer items-center overflow-hidden rounded-full border px-4 py-2 text-lg font-semibold opacity-70 data-[state=active]:border data-[state=active]:border-b data-[state=active]:border-slate-800 data-[state=active]:bg-slate-900 data-[state=active]:text-slate-100 data-[state=active]:opacity-100"
>
{f.icon}
{f.title}
</TabsTrigger>
))}
</TabsList>
{FEATURES.map((f) => (
<TabsContent key={`${f.id}-content`} value={f.id}>
{f.content}
</TabsContent>
))}
</Tabs>
);
};
@@ -0,0 +1,147 @@
import { motion, AnimatePresence } from "framer-motion";
import { useState, useEffect } from "react";
import { BrowserWrapper } from "./browser-wrapper";
import { CheckCircle2, Loader2 } from "lucide-react";
import { SyntaxHighlight } from "../syntax-highlight";
import { cn } from "@/lib/utils";
export const HttpApiExample = () => {
const posts = [
{
title: "Blogging Tips for 2025",
published_at: "2025-07-31",
},
{
title: "Grow your business with SEO",
published_at: "2025-08-15",
},
{
title: "The Future of Web Design",
published_at: "2025-09-01",
},
] as const;
const STEPS = [
{
id: "fetch-posts",
comment: "fetch posts to build blog homepage",
code: `const response = await fetch('https://api.zenblog.com/blogs/YOUR_BLOG_ID/posts');
const { data: posts } = await response.json();`,
browser: (
<div>
<h1 className="py-8 text-center text-xl font-medium">
My Startup Blog
</h1>
<div className="grid grid-cols-2 gap-4">
{posts.map((post) => (
<div key={post.title}>
<div className="h-24 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"></div>
<h3 className="my-4 text-xl font-medium">{post.title}</h3>
</div>
))}
</div>
</div>
),
},
{
id: "fetch-post",
comment: "fetch a single post with its content",
code: `const response = await fetch('https://api.zenblog.com/blogs/YOUR_BLOG_ID/posts/post-slug');
const post = await response.json();`,
browser: (
<div>
<div className="mb-4 h-24 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"></div>
<h1 className="text-2xl font-medium">{posts[0].title}</h1>
<p>Published on: {posts[0].published_at}</p>
<hr />
<p className="mt-4 font-mono">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
ullamcorper, nisl at venenatis facilibus, erat felis aliquet enim,
nec luctus ligula leo et quam.
</p>
</div>
),
},
{
id: "fetch-filtered",
comment: "fetch posts filtered by category or tags",
code: `const response = await fetch('https://api.zenblog.com/blogs/YOUR_BLOG_ID/posts?category=news&limit=10');
const { data: posts } = await response.json();`,
browser: (
<div>
<h1 className="py-8 text-center text-xl font-medium">
News Category
</h1>
<div className="space-y-4">
{posts.slice(0, 2).map((post) => (
<div key={post.title} className="flex gap-4">
<div className="h-16 w-16 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"></div>
<div>
<h3 className="text-lg font-medium">{post.title}</h3>
<p className="text-sm text-gray-600">{post.published_at}</p>
</div>
</div>
))}
</div>
</div>
),
},
];
const [activeStep, setActiveStep] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const handleStepClick = (index: number) => {
if (index === activeStep) return;
setActiveStep(index);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 500);
};
return (
<div className="flex min-h-[600px] gap-px rounded-3xl bg-slate-800 p-2">
<div className="space-y-px pr-4 font-mono text-sm">
{STEPS.map((step, index) => (
<div
key={step.id}
className={cn(
`cursor-pointer rounded-2xl p-4 opacity-50 transition-opacity duration-300 hover:bg-slate-700/50`,
{ "bg-slate-700/50 opacity-100": index === activeStep }
)}
onClick={() => handleStepClick(index)}
>
<div className="mb-3 text-slate-400">{`// ` + step.comment}</div>
<pre className="whitespace-pre-wrap text-white">
<SyntaxHighlight
code={step.code}
language="javascript"
></SyntaxHighlight>
</pre>
</div>
))}
</div>
<div className="w-[500px]">
<BrowserWrapper>
<motion.div
key={isLoading ? "loading" : activeStep}
className="p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{isLoading ? (
<div className="flex h-60 w-full items-center justify-center">
<Loader2 className="animate-spin" />
</div>
) : (
STEPS[activeStep]?.browser
)}
</motion.div>
</BrowserWrapper>
</div>
</div>
);
};
@@ -0,0 +1,146 @@
import { motion, AnimatePresence } from "framer-motion";
import { useState, useEffect } from "react";
import { BrowserWrapper } from "./browser-wrapper";
import { CheckCircle2, Loader2 } from "lucide-react";
import { SyntaxHighlight } from "../syntax-highlight";
import { cn } from "@/lib/utils";
export const TsApiExample = () => {
const [activeStep, setActiveStep] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const posts = [
{
title: "Blogging Tips for 2025",
published_at: "2025-07-31",
},
{
title: "Grow your business with SEO",
published_at: "2025-08-15",
},
{
title: "The Future of Web Design",
published_at: "2025-09-01",
},
] as const;
const STEPS = [
{
id: "init",
comment: "1. initialize the zenblog client",
code: `import { createZenblogClient } from "zenblog";
const zenblog = createZenblogClient({ blogId: BLOG_ID });`,
browser: (
<div>
<div className="flex h-60 w-full flex-col items-center justify-center gap-4 text-center">
<CheckCircle2 className="text-emerald-500" />
<span className="font-mono text-sm">
Zenblog client initialized successfully!
</span>
<button
onClick={() => {
setActiveStep(1);
}}
className="rounded-full bg-black px-3 py-1.5 font-medium text-white"
>
Next step
</button>
</div>
</div>
),
},
{
id: "getposts",
comment: "2. fetch posts to build blog homepage",
code: `const posts = await zenblog.posts.list();`,
browser: (
<div>
<h1 className="py-8 text-center text-xl font-medium">
My Startup Blog
</h1>
<div className="grid grid-cols-2 gap-4">
{posts.map((post) => (
<div key={post.title}>
<div className="h-24 rounded-lg bg-gradient-to-br from-orange-500 to-orange-600"></div>
<h3 className="my-4 text-xl font-medium">{post.title}</h3>
</div>
))}
</div>
</div>
),
},
{
id: "getpost",
comment: "3. fetch a single post with its content",
code: `const post = await zenblog.posts.get({ slug: params.slug });`,
browser: (
<div>
<div className="mb-4 h-24 rounded-lg bg-gradient-to-br from-orange-500 to-orange-600"></div>
<h1 className="text-2xl font-medium">{posts[0].title}</h1>
<p>Published on: {posts[0].published_at}</p>
<hr />
<p className="mt-4 font-mono">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
ullamcorper, nisl at venenatis facilisis, erat felis aliquet enim,
nec luctus ligula leo et quam.
</p>
</div>
),
},
];
const handleStepClick = (index: number) => {
if (index === activeStep) return;
setActiveStep(index);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 500);
};
return (
<div className="flex min-h-[600px] gap-px rounded-3xl bg-slate-800 p-2">
<div className="w-full space-y-px pr-4 font-mono text-sm">
{STEPS.map((step, index) => (
<div
key={step.id}
className={cn(
`cursor-pointer rounded-2xl p-4 opacity-50 transition-opacity duration-300 hover:bg-slate-700/50`,
{ "bg-slate-700/50 opacity-100": index === activeStep }
)}
onClick={() => handleStepClick(index)}
>
<div className="mb-3 text-slate-400">{`// ` + step.comment}</div>
<pre className="whitespace-pre-wrap text-white">
<SyntaxHighlight
code={step.code}
language="tsx"
></SyntaxHighlight>
</pre>
</div>
))}
</div>
<div className="w-[500px]">
<BrowserWrapper>
<motion.div
key={isLoading ? "loading" : activeStep}
className="p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{isLoading ? (
<div className="flex h-60 w-full items-center justify-center">
<Loader2 className="animate-spin" />
</div>
) : (
STEPS[activeStep]?.browser
)}
</motion.div>
</BrowserWrapper>
</div>
</div>
);
};
@@ -0,0 +1,26 @@
import { Prism as ReactSyntaxHighlighter } from "react-syntax-highlighter";
import { dracula } from "react-syntax-highlighter/dist/esm/styles/prism";
export const SyntaxHighlight = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: string;
showLineNumbers?: boolean;
}) => {
return (
<ReactSyntaxHighlighter
language={language}
style={dracula}
showLineNumbers={showLineNumbers}
customStyle={{
backgroundColor: "transparent",
padding: 0,
}}
>
{code}
</ReactSyntaxHighlighter>
);
};
+69 -149
View File
@@ -5,8 +5,10 @@ import {
FaCheckCircle,
FaCode,
FaCopy,
FaHandPeace,
FaImage,
FaNetworkWired,
FaPencilAlt,
FaPencilRuler,
FaPenFancy,
FaRocket,
@@ -32,6 +34,8 @@ import { motion } from "framer-motion";
import { toast } from "sonner";
import { Marquee } from "@/components/magicui/marquee";
import { cn } from "@/lib/utils";
import { ArrowRight } from "lucide-react";
import { HeroImages } from "@/components/Homepage/hero-images";
const h1Font = Lora({
subsets: ["latin"],
@@ -40,53 +44,40 @@ const h1Font = Lora({
const FEATURES = [
{
icon: <FaRocket />,
title: "Get started in minutes",
Icon: (props: any) => <FaRocket {...props} />,
title: "Speed & Simplicity",
description:
"No need to configure models, create a blog, write a post, publish.",
},
// {
// icon: <FaPencilAlt />,
// title: "More than blogs",
// description:
// "Manage your website content easily. Blog, job postings, changelog, docs, help center, etc.",
// },
{
icon: <FaNetworkWired />,
title: "Easy to integrate",
description:
"Use our HTTP API to fetch your content and display it on your website however you want. Works with any stack.",
"No need to configure models, create a blog, write a post, publish. Get up and running in minutes.",
},
{
icon: <FaCode />,
title: "Type safety",
description:
"Use our typesafe API client to fetch your content. No more parsing issues.",
Icon: (props: any) => <FaCode {...props} />,
title: "DeveloperFirst",
description: "Typesafe SDK, REST API, framework-friendly",
},
{
icon: <FaSmile />,
Icon: (props: any) => <FaImage {...props} />,
title: "Image & Video Hosting",
description:
"We host your media for you. No need to upload them to another service. Forget about S3 or CDNs.",
},
{
Icon: (props: any) => <FaPencilAlt {...props} />,
title: "WriterFriendly",
description:
"Inspired by tools like Notion, we made an editor that is both powerful and simple to use.",
},
{
Icon: (props: any) => <FaHandPeace {...props} />,
title: "Fully featured",
description:
"You can manage tags, categories, authors, multiple blogs, images and videos without configuring anything!",
},
{
Icon: (props: any) => <FaSmile {...props} />,
title: "It's not Wordpress",
description:
"Forget about wordpress headaches. Just focus on writing great content.",
},
{
icon: <FaImage />,
title: "Image hosting",
description:
"We host your images for you. No need to upload them to another service.",
},
{
icon: <FaPencilRuler />,
title: "Simple editor",
description:
"Inspired by tools like Notion, we made an editor that is both powerful and simple to use.",
},
// {
// icon: <FaPalette />,
// title: "Themes!",
// description:
// "We offer free themes for the most popular frameworks. Or you can build your own.",
// },
];
const Home = () => {
@@ -140,34 +131,30 @@ const Home = () => {
<div className={`${h1Font.variable}`}>
<div className="flex flex-col">
<Navigation />
<motion.main
className="mt-12 px-6 pb-24"
initial={{ opacity: 0, y: 10, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.5 }}
>
<div className="mx-auto mt-20 max-w-5xl px-6 text-center md:mt-12">
<main className="mt-12 px-6 pb-24">
<div className="mx-auto mt-8 max-w-5xl px-6 text-center">
<h1
className={`mt-4 text-balance text-4xl font-semibold tracking-tight text-slate-800 md:text-5xl`}
className={`mt-4 text-balance text-4xl font-semibold tracking-tight text-slate-800 md:text-6xl`}
>
Notion-like editor with <br />a simple headless API
Launch your blog in minutes
</h1>
<div className="mt-4 text-slate-500">
<p className="text-balance text-lg">
A simple blogging CMS for devs and marketers
<p className="mx-auto max-w-xl text-balance text-lg font-medium">
A headless CMS with Notion-style editor, hosted image and
video uploads, and a type-safe API.
</p>
<Link className="mt-6 inline-flex" href="/sign-up">
<Button
size="default"
className="h-10 rounded-xl font-medium"
className="h-12 rounded-xl text-lg font-medium"
>
Start blogging for free
Start blogging for free <ArrowRight className="ml-0.5" />
</Button>
</Link>
<div>
<code
className="mt-4 inline-block cursor-copy rounded-lg px-3 py-1.5 text-sm text-zinc-600 transition-all hover:bg-zinc-100"
className="mt-4 inline-block cursor-copy rounded-lg px-3 py-1.5 text-sm text-slate-600 transition-all hover:bg-slate-100"
onClick={() => {
navigator.clipboard.writeText("npm i zenblog");
toast.success("Copied to clipboard");
@@ -178,97 +165,30 @@ const Home = () => {
</div>
</div>
</div>
<div className="mx-auto mt-24 max-w-6xl rounded-xl shadow-xl">
<Image
className="aspect-square w-full rounded-lg border border-zinc-200 object-cover shadow-sm transition-all md:aspect-video"
src="/static/zenblog-ui.webp"
loading="lazy"
blurDataURL="/static/zenblog-ui.webp"
placeholder="blur"
width={1200}
height={600}
alt="The zenblog editor UI"
/>
<div className="mx-auto mt-12 flex max-w-7xl items-center justify-center">
<HeroImages />
</div>
<div className="relative mx-auto mt-12 max-w-5xl">
<Marquee pauseOnHover className="[--duration:30s]">
{tweets.map((tweet) => (
<TweetItem key={tweet.username} {...tweet} />
))}
</Marquee>
<div className="from-background pointer-events-none absolute inset-y-0 left-0 w-1/4 bg-gradient-to-r"></div>
<div className="from-background pointer-events-none absolute inset-y-0 right-0 w-1/4 bg-gradient-to-l"></div>
</div>
<div className="mx-auto mt-24 max-w-5xl rounded-xl bg-slate-950 p-2 font-mono text-white">
<div className="mb-3 p-2 px-4">
<FaCode className="text-2xl text-emerald-400" />
<h2 className="mt-2 text-xl font-medium">Developer friendly</h2>
<p className="mt-2 text-balance font-sans text-sm text-slate-300">
Zenblog is very easy to integrate into any stack by using our
HTTP API or our typesafe, typescript API client.
</p>
<Link
className="mt-2 flex items-center gap-2 text-emerald-400 underline"
href="/docs"
>
Go to docs
</Link>
</div>
<CodeBlockComponent
filename="blog.tsx"
language="typescript"
highlightedLines={[]}
>
{`import { createZenblogClient } from "zenblog";
const client = createZenblogClient({ blogId: "your-blog-id" });
const posts = await client.posts.list();
const post = await client.posts.get({ slug: "hello-world" });`}
</CodeBlockComponent>
</div>
<div className="mx-auto mt-24 max-w-5xl rounded-xl border bg-white/80 p-2 backdrop-blur-lg">
<div className="p-2 px-4">
<FaPenFancy className="text-2xl text-orange-400" />
<h2 className="mt-2 text-2xl font-medium">
<span className="font-serif italic">Wonderful,</span> simple,
writing experience
</h2>
<p className="mt-2 text-balance font-sans text-sm text-slate-500">
Our editor is designed to be both powerful and simple to use.
You can focus on writing without worrying about the rest.
</p>
<Link
className="mt-2 flex items-center gap-2 text-orange-600 underline"
href="/sign-up"
>
Try it for free
</Link>
</div>
<div className="mt-4">
<Image
className="w-full rounded-lg border shadow-sm"
src="/static/editor-screenshot.webp"
width={1200}
height={400}
alt="The zenblog editor UI"
/>
</div>
<div className="mx-auto mt-12 flex max-w-6xl flex-wrap justify-center gap-6">
{tweets.map((tweet) => (
<TweetItem key={tweet.username} {...tweet} />
))}
</div>
<div className="mx-auto mt-24 max-w-4xl">
<h2 className="text-2xl font-medium">Features</h2>
<div className="mt-8 grid grid-cols-1 gap-8 sm:grid-cols-2">
<h2 className="text-center text-2xl font-medium">Why Zenblog?</h2>
<div className="mx-auto mt-8 divide-y rounded-xl border bg-slate-100/40">
{FEATURES.map((feature) => (
<div key={feature.title} className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-slate-400">
{feature.icon && feature.icon}
<div
key={feature.title}
className="border-1 group flex flex-col gap-2 overflow-hidden p-6"
>
<div className="">
<feature.Icon className="size-6 text-slate-400/70 transition-all group-hover:scale-105 group-hover:text-orange-500" />
</div>
<h3 className="text-lg font-medium">{feature.title}</h3>
<p className="max-w-xs text-sm text-zinc-500">
<h3 className="text-xl font-medium">{feature.title}</h3>
<p className="text-xl text-slate-500">
{feature.description}
</p>
</div>
@@ -297,7 +217,10 @@ const post = await client.posts.get({ slug: "hello-world" });`}
// image: "/static/zenblog-astro-template.webp",
// },
].map((fw) => (
<article key={fw.id} className="grid md:grid-cols-2">
<article
key={fw.id}
className="grid rounded-xl border border-slate-200 bg-slate-100/50 p-2 md:grid-cols-2"
>
<div>
<Image
className="w-full rounded-lg border shadow-sm"
@@ -309,10 +232,10 @@ const post = await client.posts.get({ slug: "hello-world" });`}
</div>
<div className="p-4 text-left">
<h3 className="text-lg font-medium">{fw.name}</h3>
<p className="text-balance text-sm text-zinc-500">
<p className="text-balance text-lg text-slate-400">
{fw.desc}
</p>
<div className="mt-4 flex flex-wrap gap-4 text-orange-600">
<div className="mt-4 grid gap-4 text-orange-600">
<Link
href={fw.link}
target="_blank"
@@ -323,7 +246,7 @@ const post = await client.posts.get({ slug: "hello-world" });`}
<Link
href={fw.demo}
target="_blank"
className="mt-4 flex items-center gap-2 underline"
className="mt-1 flex items-center gap-2 underline"
>
Try the demo
</Link>
@@ -387,7 +310,7 @@ const post = await client.posts.get({ slug: "hello-world" });`}
</Accordion>
</div>
</section>
</motion.main>
</main>
</div>
<Footer />
</div>
@@ -428,7 +351,7 @@ function PricingItem({
return (
<div className="flex w-full max-w-lg flex-1 flex-col rounded-lg border px-4 py-3 pb-8 text-left shadow-sm">
<h2 className="text-lg font-medium">{title}</h2>
<p className="text-sm text-zinc-500">{description}</p>
<p className="text-sm text-slate-500">{description}</p>
<ul className="mt-6 space-y-3 text-left">
{formattedFeatures.map((feature, idx) => (
<li
@@ -445,9 +368,9 @@ function PricingItem({
<div>
<p className="mt-8 font-mono text-2xl font-medium">
${price}
<span className="text-sm text-zinc-500">/month</span>
<span className="text-sm text-slate-500">/month</span>
</p>
<p className="text-xs font-medium text-zinc-500">
<p className="text-xs font-medium text-slate-500">
${String(+price * 12)} Billed annually
</p>
</div>
@@ -517,10 +440,7 @@ const TweetItem = ({
return (
<div
className={cn(
"relative flex w-96 flex-col gap-2 rounded-xl p-4 hover:bg-slate-100/70",
{
"min-h-32": !isThread,
}
"relative flex w-80 flex-col gap-2 rounded-xl border bg-slate-100/50 p-4"
)}
>
<div className="flex items-center gap-2">
@@ -536,7 +456,7 @@ const TweetItem = ({
/>
<div className={cn("flex flex-col")}>
<p className="text-sm font-medium">{name}</p>
<p className="text-xs font-medium text-zinc-500">{username}</p>
<p className="text-xs font-medium text-slate-500">{username}</p>
</div>
</div>
<div className="flex flex-col">
+1 -1
View File
@@ -18,7 +18,7 @@ module.exports = {
border: {
"1": "1px",
"2": "2px",
DEFAULT: "0.3px solid var(--border-color)",
DEFAULT: "1px solid var(--border-color)",
},
container: {
center: true,