mirror of
https://github.com/katanemo/plano.git
synced 2026-07-02 15:51:02 +02:00
feat(www): introduce blog
This commit is contained in:
parent
8b0f2b94d8
commit
98fb8cb2fd
25 changed files with 16178 additions and 26 deletions
3
apps/www/.gitignore
vendored
3
apps/www/.gitignore
vendored
|
|
@ -39,3 +39,6 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# sanity
|
||||||
|
.sanity/
|
||||||
|
|
@ -11,6 +11,16 @@ const nextConfig: NextConfig = {
|
||||||
// Ensure workspace packages are handled correctly
|
// Ensure workspace packages are handled correctly
|
||||||
externalDir: true,
|
externalDir: true,
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "cdn.sanity.io",
|
||||||
|
port: "",
|
||||||
|
pathname: "/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,37 @@
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"clean": "rm -rf .next"
|
"clean": "rm -rf .next",
|
||||||
|
"sanity": "sanity dev",
|
||||||
|
"sanity:build": "sanity build",
|
||||||
|
"sanity:deploy": "sanity deploy",
|
||||||
|
"migrate:blogs": "tsx scripts/migrate-blogs.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@katanemo/shared-styles": "*",
|
"@katanemo/shared-styles": "*",
|
||||||
"@katanemo/ui": "*",
|
"@katanemo/ui": "*",
|
||||||
|
"@portabletext/react": "^5.0.0",
|
||||||
|
"@portabletext/types": "^3.0.0",
|
||||||
|
"@sanity/client": "^7.13.0",
|
||||||
|
"@sanity/image-url": "^1.2.0",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@vercel/analytics": "^1.5.0",
|
||||||
|
"csv-parse": "^6.1.0",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
|
"next-sanity": "^11.6.9",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"sanity": "^4.18.0",
|
||||||
|
"styled-components": "^6.1.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.2.0",
|
||||||
"@katanemo/tailwind-config": "*",
|
"@katanemo/tailwind-config": "*",
|
||||||
"@katanemo/tsconfig": "*",
|
"@katanemo/tsconfig": "*",
|
||||||
"@biomejs/biome": "2.2.0",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
|
||||||
BIN
apps/www/public/Logomark.png
Normal file
BIN
apps/www/public/Logomark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/www/public/fonts/IBMPlexSans-VariableFont_wdth,wght.otf
Normal file
BIN
apps/www/public/fonts/IBMPlexSans-VariableFont_wdth,wght.otf
Normal file
Binary file not shown.
BIN
apps/www/public/fonts/JetBrainsMono-Medium.otf
Normal file
BIN
apps/www/public/fonts/JetBrainsMono-Medium.otf
Normal file
Binary file not shown.
BIN
apps/www/public/fonts/JetBrainsMono-Regular.otf
Normal file
BIN
apps/www/public/fonts/JetBrainsMono-Regular.otf
Normal file
Binary file not shown.
BIN
apps/www/public/fonts/jetbrains-mono-bold.otf
Normal file
BIN
apps/www/public/fonts/jetbrains-mono-bold.otf
Normal file
Binary file not shown.
17
apps/www/sanity.config.ts
Normal file
17
apps/www/sanity.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineConfig } from "sanity";
|
||||||
|
import { structureTool } from "sanity/structure";
|
||||||
|
import { schemaTypes } from "./schemaTypes";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
name: "default",
|
||||||
|
title: "Plano",
|
||||||
|
|
||||||
|
projectId: "71ny25bn",
|
||||||
|
dataset: "production",
|
||||||
|
|
||||||
|
plugins: [structureTool()],
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
types: schemaTypes,
|
||||||
|
},
|
||||||
|
});
|
||||||
214
apps/www/schemaTypes/blogType.ts
Normal file
214
apps/www/schemaTypes/blogType.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { defineField, defineType } from "sanity";
|
||||||
|
|
||||||
|
export const blogType = defineType({
|
||||||
|
name: "blog",
|
||||||
|
title: "Blog",
|
||||||
|
type: "document",
|
||||||
|
fields: [
|
||||||
|
defineField({
|
||||||
|
name: "title",
|
||||||
|
title: "Title",
|
||||||
|
type: "string",
|
||||||
|
validation: (rule) => rule.required(),
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "slug",
|
||||||
|
title: "Slug",
|
||||||
|
type: "slug",
|
||||||
|
options: {
|
||||||
|
source: "title",
|
||||||
|
maxLength: 96,
|
||||||
|
},
|
||||||
|
validation: (rule) => rule.required(),
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "summary",
|
||||||
|
title: "Post Summary",
|
||||||
|
type: "text",
|
||||||
|
description: "A brief summary of the blog post",
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "body",
|
||||||
|
title: "Post Body",
|
||||||
|
type: "array",
|
||||||
|
of: [
|
||||||
|
{
|
||||||
|
type: "block",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "alt",
|
||||||
|
type: "string",
|
||||||
|
title: "Alternative text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "bodyHtml",
|
||||||
|
title: "Post Body (HTML)",
|
||||||
|
type: "text",
|
||||||
|
description: "Raw HTML content from migration (for reference)",
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "mainImage",
|
||||||
|
title: "Main Image",
|
||||||
|
type: "image",
|
||||||
|
options: {
|
||||||
|
hotspot: true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "alt",
|
||||||
|
type: "string",
|
||||||
|
title: "Alternative text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "mainImageUrl",
|
||||||
|
title: "Main Image URL",
|
||||||
|
type: "url",
|
||||||
|
description: "Fallback URL if image not uploaded to Sanity",
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "thumbnailImage",
|
||||||
|
title: "Thumbnail Image",
|
||||||
|
type: "image",
|
||||||
|
options: {
|
||||||
|
hotspot: true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "alt",
|
||||||
|
type: "string",
|
||||||
|
title: "Alternative text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "thumbnailImageUrl",
|
||||||
|
title: "Thumbnail Image URL",
|
||||||
|
type: "url",
|
||||||
|
description: "Fallback URL if image not uploaded to Sanity",
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "featured",
|
||||||
|
title: "Featured?",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "published",
|
||||||
|
title: "Published",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "draft",
|
||||||
|
title: "Draft",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "archived",
|
||||||
|
title: "Archived",
|
||||||
|
type: "boolean",
|
||||||
|
initialValue: false,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "publishedAt",
|
||||||
|
title: "Published On",
|
||||||
|
type: "datetime",
|
||||||
|
validation: (rule) => rule.required(),
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "createdAt",
|
||||||
|
title: "Created On",
|
||||||
|
type: "datetime",
|
||||||
|
initialValue: () => new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "updatedAt",
|
||||||
|
title: "Updated On",
|
||||||
|
type: "datetime",
|
||||||
|
initialValue: () => new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "author",
|
||||||
|
title: "Author",
|
||||||
|
type: "object",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
title: "Author Name",
|
||||||
|
type: "string",
|
||||||
|
validation: (rule) => rule.required(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
title: "Author Title",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image",
|
||||||
|
title: "Author Image",
|
||||||
|
type: "image",
|
||||||
|
options: {
|
||||||
|
hotspot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "imageUrl",
|
||||||
|
title: "Author Image URL",
|
||||||
|
type: "url",
|
||||||
|
description: "Fallback URL if image not uploaded to Sanity",
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "color",
|
||||||
|
title: "Color",
|
||||||
|
type: "string",
|
||||||
|
description: "Optional color theme for the blog post",
|
||||||
|
}),
|
||||||
|
// Legacy fields from CSV migration (can be hidden in UI)
|
||||||
|
defineField({
|
||||||
|
name: "collectionId",
|
||||||
|
title: "Collection ID",
|
||||||
|
type: "string",
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "localeId",
|
||||||
|
title: "Locale ID",
|
||||||
|
type: "string",
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
defineField({
|
||||||
|
name: "itemId",
|
||||||
|
title: "Item ID",
|
||||||
|
type: "string",
|
||||||
|
hidden: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
preview: {
|
||||||
|
select: {
|
||||||
|
title: "title",
|
||||||
|
author: "author.name",
|
||||||
|
media: "mainImage",
|
||||||
|
publishedAt: "publishedAt",
|
||||||
|
},
|
||||||
|
prepare(selection) {
|
||||||
|
const { author } = selection;
|
||||||
|
return { ...selection, subtitle: author && `by ${author}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
3
apps/www/schemaTypes/index.ts
Normal file
3
apps/www/schemaTypes/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { blogType } from "./blogType";
|
||||||
|
|
||||||
|
export const schemaTypes = [blogType];
|
||||||
319
apps/www/src/app/api/og/[slug]/route.tsx
Normal file
319
apps/www/src/app/api/og/[slug]/route.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { client, urlFor } from "@/lib/sanity";
|
||||||
|
|
||||||
|
export const runtime = "edge";
|
||||||
|
|
||||||
|
// Font loading function that uses the request origin
|
||||||
|
function loadFont(fileName: string, baseUrl: string) {
|
||||||
|
return fetch(new URL(`/fonts/${fileName}`, baseUrl)).then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch font ${fileName}: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.arrayBuffer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBlogPost(slug: string) {
|
||||||
|
const query = `*[_type == "blog" && slug.current == $slug && published == true][0] {
|
||||||
|
_id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
publishedAt,
|
||||||
|
mainImage,
|
||||||
|
author {
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
image
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const post = await client.fetch(query, { slug });
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleDateString("en-US", { month: "long" });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
const getOrdinal = (n: number) => {
|
||||||
|
const s = ["th", "st", "nd", "rd"];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${month} ${getOrdinal(day)}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Get base URL for font loading - use request origin in production
|
||||||
|
const fontBaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
(process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}`
|
||||||
|
: request.nextUrl.origin);
|
||||||
|
|
||||||
|
// Load fonts with error handling
|
||||||
|
let fontData;
|
||||||
|
try {
|
||||||
|
const [ibmPlexSans, jetbrainsMonoRegular, jetbrainsMonoMedium, jetbrainsMonoBold] =
|
||||||
|
await Promise.all([
|
||||||
|
loadFont("IBMPlexSans-VariableFont_wdth,wght.otf", fontBaseUrl),
|
||||||
|
loadFont("JetBrainsMono-Regular.otf", fontBaseUrl),
|
||||||
|
loadFont("JetBrainsMono-Medium.otf", fontBaseUrl),
|
||||||
|
loadFont("jetbrains-mono-bold.otf", fontBaseUrl),
|
||||||
|
]).catch((error: Error) => {
|
||||||
|
console.error("Error loading fonts:", error);
|
||||||
|
throw new Error(`Failed to load fonts: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
fontData = {
|
||||||
|
ibmPlexSans,
|
||||||
|
jetbrainsMonoRegular,
|
||||||
|
jetbrainsMonoMedium,
|
||||||
|
jetbrainsMonoBold,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Font loading error:", errorMessage);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to load required fonts",
|
||||||
|
details: errorMessage,
|
||||||
|
baseUrl: fontBaseUrl,
|
||||||
|
}),
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await getBlogPost(slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return new Response(JSON.stringify({ error: "Post not found" }), { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get author image URL if available
|
||||||
|
let authorImageUrl: string | null = null;
|
||||||
|
if (post.author?.image) {
|
||||||
|
authorImageUrl = urlFor(post.author.image).width(120).url();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use logo PNG
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ||
|
||||||
|
(process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}`
|
||||||
|
: request.nextUrl.origin);
|
||||||
|
const logoUrl = `${baseUrl}/Logomark.png`;
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(to top right, #ffffff, #dcdfff)",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
padding: "60px 80px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo - Top Left - SVG as data URL */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "60px",
|
||||||
|
left: "80px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Plano"
|
||||||
|
width="120"
|
||||||
|
height="48"
|
||||||
|
style={{
|
||||||
|
objectFit: "contain",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content - Left-Aligned, aligned with logo */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "center",
|
||||||
|
flex: 1,
|
||||||
|
width: "85%",
|
||||||
|
marginTop: "150px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title - Left Aligned */}
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "64px",
|
||||||
|
lineHeight: "1.1",
|
||||||
|
color: "#000000",
|
||||||
|
marginBottom: "24px",
|
||||||
|
letterSpacing: "-0.08em",
|
||||||
|
fontFamily: "IBM Plex Sans Bold",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Date - Below Title, Left Aligned */}
|
||||||
|
{/* {post.publishedAt && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "20px",
|
||||||
|
color: "#000000",
|
||||||
|
marginBottom: "40px",
|
||||||
|
letterSpacing: "-1.8px",
|
||||||
|
fontFamily: "IBM Plex Sans Regular",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDate(post.publishedAt)}
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Author Section - Below Date, Left Aligned */}
|
||||||
|
{post.author?.name && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "16px",
|
||||||
|
marginTop: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authorImageUrl && (
|
||||||
|
<img
|
||||||
|
src={authorImageUrl}
|
||||||
|
alt={post.author.name}
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
style={{
|
||||||
|
borderRadius: "4px",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "20px",
|
||||||
|
color: "#7780d9",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.09em",
|
||||||
|
fontFamily: "JetBrains Mono Bold",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.author.name}
|
||||||
|
</div>
|
||||||
|
{post.author.title && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#28327D",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.10em",
|
||||||
|
fontFamily: "JetBrains Mono Medium",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.author.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: "IBM Plex Sans Regular",
|
||||||
|
data: fontData.ibmPlexSans,
|
||||||
|
style: "normal",
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IBM Plex Sans Medium",
|
||||||
|
data: fontData.ibmPlexSans,
|
||||||
|
style: "normal",
|
||||||
|
weight: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IBM Plex Sans Bold",
|
||||||
|
data: fontData.ibmPlexSans,
|
||||||
|
style: "normal",
|
||||||
|
weight: 700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JetBrains Mono Regular",
|
||||||
|
data: fontData.jetbrainsMonoRegular,
|
||||||
|
style: "normal",
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JetBrains Mono Medium",
|
||||||
|
data: fontData.jetbrainsMonoMedium,
|
||||||
|
style: "normal",
|
||||||
|
weight: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "JetBrains Mono Bold",
|
||||||
|
data: fontData.jetbrainsMonoBold,
|
||||||
|
style: "normal",
|
||||||
|
weight: 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error("Error generating image response:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to generate image",
|
||||||
|
details: errorMessage,
|
||||||
|
}),
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
104
apps/www/src/app/blog/[slug]/layout.tsx
Normal file
104
apps/www/src/app/blog/[slug]/layout.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import { client } from "@/lib/sanity";
|
||||||
|
|
||||||
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
|
interface BlogPost {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
slug: { current: string };
|
||||||
|
summary?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
image?: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBlogPost(slug: string): Promise<BlogPost | null> {
|
||||||
|
const query = `*[_type == "blog" && slug.current == $slug && published == true][0] {
|
||||||
|
_id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
publishedAt,
|
||||||
|
author
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const post = await client.fetch(query, { slug });
|
||||||
|
return post || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Params;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const post = await getBlogPost(resolvedParams.slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return {
|
||||||
|
title: "Post Not Found - Plano",
|
||||||
|
description: "The requested blog post could not be found.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
(process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}`
|
||||||
|
: "http://localhost:3000");
|
||||||
|
|
||||||
|
const ogImageUrl = `${baseUrl}/api/og/${resolvedParams.slug}`;
|
||||||
|
|
||||||
|
const metadata: Metadata = {
|
||||||
|
title: `${post.title} - Plano Blog`,
|
||||||
|
description: post.summary || "Read more on Plano Blog",
|
||||||
|
openGraph: {
|
||||||
|
title: post.title,
|
||||||
|
description: post.summary || "Read more on Plano Blog",
|
||||||
|
type: "article",
|
||||||
|
publishedTime: post.publishedAt,
|
||||||
|
authors: post.author?.name ? [post.author.name] : undefined,
|
||||||
|
url: `${baseUrl}/blog/${resolvedParams.slug}`,
|
||||||
|
siteName: "Plano",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: ogImageUrl,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: post.title,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locale: "en_US",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: post.title,
|
||||||
|
description: post.summary || "Read more on Plano Blog",
|
||||||
|
images: [ogImageUrl],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating metadata:", error);
|
||||||
|
return {
|
||||||
|
title: "Blog Post - Plano",
|
||||||
|
description: "Read this post on Plano Blog",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Layout({ children, params }: LayoutProps) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
36
apps/www/src/app/blog/[slug]/not-found.tsx
Normal file
36
apps/www/src/app/blog/[slug]/not-found.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||||
|
<div className="max-w-md mx-auto px-4 text-center">
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-normal leading-tight tracking-tighter text-black mb-4">
|
||||||
|
<span className="font-sans">Post Not Found</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg font-sans font-[400] tracking-[-0.5px] text-black/70 mb-8">
|
||||||
|
The blog post you're looking for doesn't exist or has been removed.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="inline-flex items-center gap-2 text-base font-medium text-black hover:text-[var(--secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Back to Blog
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
207
apps/www/src/app/blog/[slug]/page.tsx
Normal file
207
apps/www/src/app/blog/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { client, urlFor } from "@/lib/sanity";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PortableText } from "@/components/PortableText";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
|
||||||
|
|
||||||
|
interface BlogPost {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
slug: { current: string };
|
||||||
|
summary?: string;
|
||||||
|
body?: any[];
|
||||||
|
bodyHtml?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
mainImage?: any;
|
||||||
|
mainImageUrl?: string;
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
image?: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBlogPost(slug: string): Promise<BlogPost | null> {
|
||||||
|
const query = `*[_type == "blog" && slug.current == $slug && published == true][0] {
|
||||||
|
_id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
body[]{
|
||||||
|
...,
|
||||||
|
asset->{
|
||||||
|
_id,
|
||||||
|
url,
|
||||||
|
metadata {
|
||||||
|
dimensions {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
aspectRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bodyHtml,
|
||||||
|
publishedAt,
|
||||||
|
mainImage,
|
||||||
|
mainImageUrl,
|
||||||
|
author
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const post = await client.fetch(query, { slug });
|
||||||
|
return post || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllBlogSlugs(): Promise<string[]> {
|
||||||
|
const query = `*[_type == "blog" && published == true] {
|
||||||
|
"slug": slug.current
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const posts = await client.fetch(query);
|
||||||
|
return posts.map((post: { slug: string }) => post.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const slugs = await getAllBlogSlugs();
|
||||||
|
return slugs.map((slug) => ({ slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPostPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await getBlogPost(slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="min-h-screen">
|
||||||
|
{/* Featured Image - First */}
|
||||||
|
{(post.mainImage || post.mainImageUrl) && (
|
||||||
|
<div className="">
|
||||||
|
<div className="max-w-[89rem] mx-auto px-4 sm:px-6 lg:px- py-8 sm:py-12">
|
||||||
|
<div className="relative aspect-[21/8] w-full overflow-hidden rounded-lg">
|
||||||
|
{post.mainImage ? (
|
||||||
|
<Image
|
||||||
|
src={urlFor(post.mainImage).width(1600).url()}
|
||||||
|
alt={post.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={post.mainImageUrl!}
|
||||||
|
alt={post.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className="max-w-[58rem] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Back to Blog Button */}
|
||||||
|
<div className="pt-8 sm:pt-12 lg:pt-16 pb-6">
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-black/60 hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Back to Blog
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author and Date */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 sm:gap-6 pb-4 pt-5">
|
||||||
|
{post.author?.name && (
|
||||||
|
<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}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded bg-[var(--secondary)]/20 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{post.publishedAt && (
|
||||||
|
<time
|
||||||
|
dateTime={post.publishedAt}
|
||||||
|
className="text-base font-medium tracking-[-0.9px] text-black sm:ml-auto"
|
||||||
|
>
|
||||||
|
{new Date(post.publishedAt).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="pb-10 sm:-ml-1.5">
|
||||||
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-medium leading-tight tracking-tighter text-black">
|
||||||
|
<span className="font-sans">{post.title}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="pb-12 sm:pb-16 lg:pb-20 ">
|
||||||
|
{post.body && post.body.length > 0 ? (
|
||||||
|
<div className="prose prose-lg max-w-none">
|
||||||
|
<PortableText content={post.body} />
|
||||||
|
</div>
|
||||||
|
) : post.bodyHtml ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-lg max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-base sm:text-lg font-sans font-normal tracking-[-0.5px] text-black/80">
|
||||||
|
Content coming soon...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UnlockPotentialSection variant="transparent" />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
120
apps/www/src/app/blog/page.tsx
Normal file
120
apps/www/src/app/blog/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { client } from "@/lib/sanity";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
|
||||||
|
import { BlogHeader } from "@/components/BlogHeader";
|
||||||
|
import { FeaturedBlogCard } from "@/components/FeaturedBlogCard";
|
||||||
|
import { BlogCard } from "@/components/BlogCard";
|
||||||
|
import { BlogSectionHeader } from "@/components/BlogSectionHeader";
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Blog - Plano",
|
||||||
|
description: "Latest insights, updates, and stories from Plano",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BlogPost {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
slug: { current: string };
|
||||||
|
summary?: string;
|
||||||
|
publishedAt?: string;
|
||||||
|
mainImage?: any;
|
||||||
|
mainImageUrl?: string;
|
||||||
|
thumbnailImage?: any;
|
||||||
|
thumbnailImageUrl?: string;
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
image?: any;
|
||||||
|
};
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleDateString("en-US", { month: "long" });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
// Add ordinal suffix
|
||||||
|
const getOrdinal = (n: number) => {
|
||||||
|
const s = ["th", "st", "nd", "rd"];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${month} ${getOrdinal(day)}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBlogPosts(): Promise<BlogPost[]> {
|
||||||
|
const query = `*[_type == "blog" && published == true] | order(publishedAt desc) {
|
||||||
|
_id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
summary,
|
||||||
|
publishedAt,
|
||||||
|
mainImage,
|
||||||
|
mainImageUrl,
|
||||||
|
thumbnailImage,
|
||||||
|
thumbnailImageUrl,
|
||||||
|
author,
|
||||||
|
featured
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return await client.fetch(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogPage() {
|
||||||
|
const posts = await getBlogPosts();
|
||||||
|
const featuredPost = posts.find((post) => post.featured) || posts[0];
|
||||||
|
const recentPosts = posts
|
||||||
|
.filter((post) => post._id !== featuredPost?._id)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
// Format dates in server component
|
||||||
|
const featuredPostWithDate = featuredPost
|
||||||
|
? {
|
||||||
|
...featuredPost,
|
||||||
|
formattedDate: featuredPost.publishedAt
|
||||||
|
? formatDate(featuredPost.publishedAt)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const recentPostsWithDates = recentPosts.map((post) => ({
|
||||||
|
...post,
|
||||||
|
formattedDate: post.publishedAt ? formatDate(post.publishedAt) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Header Section */}
|
||||||
|
<BlogHeader />
|
||||||
|
|
||||||
|
{/* Featured Post */}
|
||||||
|
{featuredPostWithDate && (
|
||||||
|
<section className="">
|
||||||
|
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-0">
|
||||||
|
<FeaturedBlogCard post={featuredPostWithDate} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Posts Section */}
|
||||||
|
{recentPostsWithDates.length > 0 && (
|
||||||
|
<section className="border-b border-black/10 py-12 sm:py-16 lg:py-24">
|
||||||
|
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<BlogSectionHeader />
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
||||||
|
{recentPostsWithDates.map((post, index) => (
|
||||||
|
<BlogCard key={post._id} post={post} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Call to Action Section */}
|
||||||
|
<UnlockPotentialSection variant="transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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} />;
|
||||||
|
}
|
||||||
|
|
||||||
14768
package-lock.json
generated
14768
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,11 @@
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"globals.css"
|
"globals.css"
|
||||||
]
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "echo 'Skipping build'",
|
||||||
|
"lint": "biome check",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "echo 'Skipping build'",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { X, Menu } from "lucide-react";
|
import { X, Menu } from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/start", label: "start locally" },
|
{ href: "https://docs.plano.katanemo.com/getting-started/installation", label: "start locally" },
|
||||||
{ href: "/docs", label: "docs" },
|
{ href: "https://docs.plano.katanemo.com", label: "docs" },
|
||||||
{ href: "/model-research", label: "models research" },
|
{ href: "/model-research", label: "models research" },
|
||||||
{ href: "/blog", label: "blog" },
|
{ href: "/blog", label: "blog" },
|
||||||
{ href: "/why", label: "why plano?" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue