mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
feat(www): introduce blog
This commit is contained in:
parent
8b0f2b94d8
commit
98fb8cb2fd
25 changed files with 16178 additions and 26 deletions
88
apps/www/src/components/BlogCard.tsx
Normal file
88
apps/www/src/components/BlogCard.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { urlFor } from "@/lib/sanity";
|
||||
|
||||
interface BlogCardProps {
|
||||
post: {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
formattedDate?: string;
|
||||
author?: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
image?: any;
|
||||
};
|
||||
};
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function BlogCard({ post, index = 0 }: BlogCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: index * 0.05,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<Link href={`/blog/${post.slug.current}`} className="group block h-full">
|
||||
<motion.article
|
||||
className="h-full min-h-[320px] bg-linear-to-b from-primary/20 to-primary/1 border border-primary/20 rounded-md p-6 sm:p-8 flex flex-col"
|
||||
whileHover={{
|
||||
borderColor: "rgba(119, 128, 217, 0.5)",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
{post.formattedDate && (
|
||||
<div className="text-base font-medium tracking-[-0.9px] text-black mb-6">
|
||||
{post.formattedDate}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xl sm:text-2xl font-normal leading-tight tracking-tighter text-black group-hover:text-[var(--secondary)] transition-colors flex-grow">
|
||||
<span className="font-sans font-medium tracking-[-1.5px]">
|
||||
{post.title}
|
||||
</span>
|
||||
</h3>
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-3 mt-auto pt-6">
|
||||
{post.author.image ? (
|
||||
<div className="relative w-10 h-10 rounded overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={urlFor(post.author.image).width(80).url()}
|
||||
alt={post.author.name || "Author"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-[var(--secondary)]/20 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
{post.author.name && (
|
||||
<div className="text-base font-mono font-semibold tracking-wider text-primary uppercase">
|
||||
{post.author.name}
|
||||
</div>
|
||||
)}
|
||||
{post.author.title && (
|
||||
<div className="text-xs font-mono font-normal tracking-wider text-[#28327D] uppercase">
|
||||
{post.author.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.article>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
26
apps/www/src/components/BlogHeader.tsx
Normal file
26
apps/www/src/components/BlogHeader.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function BlogHeader() {
|
||||
return (
|
||||
<motion.section
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-20 lg:py-16">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-normal leading-tight tracking-tighter text-black mb-4">
|
||||
<span className="font-sans">What's new with Plano</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl lg:text-2xl font-sans font-normal tracking-[-1.2px] text-black max-w-3xl">
|
||||
Building the future of infrastructure and tools for AI developers.
|
||||
</p>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
12
apps/www/src/components/BlogSectionHeader.tsx
Normal file
12
apps/www/src/components/BlogSectionHeader.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
export function BlogSectionHeader() {
|
||||
return (
|
||||
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-normal leading-tight tracking-tighter text-black mb-12">
|
||||
<span className="font-sans">
|
||||
The latest and greatest from our blog.
|
||||
</span>
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
118
apps/www/src/components/FeaturedBlogCard.tsx
Normal file
118
apps/www/src/components/FeaturedBlogCard.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { urlFor } from "@/lib/sanity";
|
||||
|
||||
interface FeaturedBlogCardProps {
|
||||
post: {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
summary?: string;
|
||||
formattedDate?: string;
|
||||
mainImage?: any;
|
||||
mainImageUrl?: string;
|
||||
author?: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
image?: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function FeaturedBlogCard({ post }: FeaturedBlogCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<Link href={`/blog/${post.slug.current}`} className="group block">
|
||||
<motion.div
|
||||
className="bg-linear-to-b from-primary/20 to-primary/1 border border-primary/20 rounded-md p-8 sm:p-10 lg:p-12"
|
||||
whileHover={{
|
||||
borderColor: "rgba(119, 128, 217, 0.5)",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<div className="grid lg:grid-cols-2 gap-8 lg:gap-12 items-center">
|
||||
{/* Content */}
|
||||
<div className="order-1 text-left">
|
||||
{post.formattedDate && (
|
||||
<div className="text-base font-medium tracking-[-0.9px] text-black mb-4">
|
||||
{post.formattedDate}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-4xl font-medium tracking-[-1.5px] text-black mb-4 group-hover:text-[var(--secondary)] transition-colors text-left">
|
||||
<span className="font-sans">{post.title}</span>
|
||||
</h2>
|
||||
{post.summary && (
|
||||
<p className="text-base sm:text-base font-mono font-normal tracking-[-0.9px] text-black/70 mb-6 text-left">
|
||||
{post.summary}
|
||||
</p>
|
||||
)}
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-3">
|
||||
{post.author.image ? (
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={urlFor(post.author.image).width(80).url()}
|
||||
alt={post.author.name || "Author"}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-[var(--secondary)]/20 shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
{post.author.name && (
|
||||
<div className="text-lg font-mono font-semibold tracking-wider text-primary uppercase">
|
||||
{post.author.name}
|
||||
</div>
|
||||
)}
|
||||
{post.author.title && (
|
||||
<div className="text-sm font-mono font-normal tracking-wider text-[#28327D] uppercase">
|
||||
{post.author.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="relative aspect-[18/9] w-full overflow-hidden rounded-lg bg-black/5 order-2">
|
||||
{post.mainImage ? (
|
||||
<Image
|
||||
src={urlFor(post.mainImage).width(800).url()}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : post.mainImageUrl ? (
|
||||
<Image
|
||||
src={post.mainImageUrl}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-[var(--secondary)]/20 to-black/10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
124
apps/www/src/components/PortableText.tsx
Normal file
124
apps/www/src/components/PortableText.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { PortableText as SanityPortableText } from "@portabletext/react";
|
||||
import Image from "next/image";
|
||||
import { urlFor } from "@/lib/sanity";
|
||||
import type { PortableTextBlock } from "@portabletext/types";
|
||||
|
||||
interface PortableTextProps {
|
||||
content: PortableTextBlock[];
|
||||
}
|
||||
|
||||
const components = {
|
||||
types: {
|
||||
image: ({ value }: any) => {
|
||||
if (!value?.asset) return null;
|
||||
|
||||
const imageUrl = urlFor(value);
|
||||
const asset = value.asset;
|
||||
|
||||
// Get natural dimensions if available from metadata
|
||||
const dimensions = asset.metadata?.dimensions;
|
||||
const width = dimensions?.width || 1000;
|
||||
const height = dimensions?.height || 562;
|
||||
const aspectRatio = dimensions ? height / width : 0.5625; // Default to 16:9 if no dimensions
|
||||
|
||||
return (
|
||||
<div className="my-6 lg:my-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="relative w-full overflow-hidden rounded-lg bg-black/5">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ paddingBottom: `${aspectRatio * 100}%` }}
|
||||
>
|
||||
<Image
|
||||
src={imageUrl.width(Math.min(width, 1000)).url()}
|
||||
alt={value.alt || "Blog image"}
|
||||
fill
|
||||
className="object-contain"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 768px, 1000px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{value.alt && (
|
||||
<p className="mt-2 text-sm text-black/60 text-center">{value.alt}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
block: {
|
||||
h1: (props: any) => (
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-normal leading-tight tracking-tighter text-black mt-8 mb-4 first:mt-0">
|
||||
<span className="font-sans">{props.children}</span>
|
||||
</h1>
|
||||
),
|
||||
h2: (props: any) => (
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-normal leading-tight tracking-tighter text-black mt-8 mb-4 first:mt-0">
|
||||
<span className="font-sans">{props.children}</span>
|
||||
</h2>
|
||||
),
|
||||
h3: (props: any) => (
|
||||
<h3 className="text-2xl sm:text-3xl lg:text-4xl font-normal leading-tight tracking-tighter text-black mt-6 mb-3 first:mt-0">
|
||||
<span className="font-sans">{props.children}</span>
|
||||
</h3>
|
||||
),
|
||||
h4: (props: any) => (
|
||||
<h4 className="text-xl sm:text-2xl lg:text-3xl font-normal leading-tight tracking-tighter text-black mt-6 mb-3 first:mt-0">
|
||||
<span className="font-sans">{props.children}</span>
|
||||
</h4>
|
||||
),
|
||||
normal: (props: any) => (
|
||||
<p className="text-base sm:text-lg font-sans font-[400] tracking-[-0.5px] text-black/80 mb-4 leading-relaxed">
|
||||
{props.children}
|
||||
</p>
|
||||
),
|
||||
blockquote: (props: any) => (
|
||||
<blockquote className="border-l-4 border-[var(--secondary)] pl-6 py-2 my-6 italic text-black/70">
|
||||
{props.children}
|
||||
</blockquote>
|
||||
),
|
||||
},
|
||||
list: {
|
||||
bullet: (props: any) => (
|
||||
<ul className="list-disc list-inside mb-4 space-y-2 text-base sm:text-lg font-sans font-[400] tracking-[-0.5px] text-black/80">
|
||||
{props.children}
|
||||
</ul>
|
||||
),
|
||||
number: (props: any) => (
|
||||
<ol className="list-decimal list-inside mb-4 space-y-2 text-base sm:text-lg font-sans font-[400] tracking-[-0.5px] text-black/80">
|
||||
{props.children}
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
listItem: {
|
||||
bullet: (props: any) => (
|
||||
<li className="ml-4">{props.children}</li>
|
||||
),
|
||||
number: (props: any) => (
|
||||
<li className="ml-4">{props.children}</li>
|
||||
),
|
||||
},
|
||||
marks: {
|
||||
strong: ({ children }: { children: React.ReactNode }) => (
|
||||
<strong className="font-semibold text-black">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children: React.ReactNode }) => (
|
||||
<em className="italic">{children}</em>
|
||||
),
|
||||
link: (props: any) => (
|
||||
<a
|
||||
href={props.value?.href || "#"}
|
||||
target={props.value?.href?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={props.value?.href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="text-[var(--secondary)] hover:underline font-medium"
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export function PortableText({ content }: PortableTextProps) {
|
||||
return <SanityPortableText value={content} components={components} />;
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue