mirror of
https://github.com/katanemo/plano.git
synced 2026-05-01 03:46:35 +02:00
feat: redesign archgw -> plano + website in Next.js (#613)
* feat: redesign archgw -> plano + website * feat(www): refactor landing page sections, add new diagrams and UI improvements * feat(www): sections enhanced for clarify & diagrams added * feat(www): improvements to mobile design, layout of diagrams * feat(www): clean + typecheck * feat(www): feedback loop changes * feat(www): fix type error * fix lib/utils error * feat(www): ran biome formatting * feat(www): graphic changes * feat(www): web analytics * fea(www): changes * feat(www): introduce monorepo This change brings Turborepo monorepo to independently handle the marketing website, the docs website and any other future use cases for mutli-platform support. They are using internal @katanemo package handlers for the design system and logic. * fix(www): transpiler failure * fix(www): tsconfig issue * fix(www): next.config issue * feat(docs): hold off on docs * Delete next.config.ts * feat(www): content fix * feat(www): introduce blog * feat(www): content changes * Update package-lock.json * feat: update text * Update IntroSection.tsx * feat: Turbopack issue * fix * Update IntroSection.tsx * feat: updated Research page * refactor(www): text clarity, padding adj. * format(www) * fix: add missing lib/ files to git - fixes Vercel GitHub deployment - Updated .gitignore to properly exclude Python lib/ but include Next.js lib/ directories - Added packages/ui/src/lib/utils.ts (cn utility function) - Added apps/www/src/lib/sanity.ts (Sanity client configuration) - Fixes module resolution errors in Vercel GitHub deployments (case-sensitive filesystem) * Update .gitignore * style(www): favicon + metadata * fix(www): links * fix(www): add analytics * fix(www): add * fix(www): fix links + image * fix(www): fix links + image * fix(www): fix links * fix(www): remove from tools testing.md
This commit is contained in:
parent
48bbc7cce7
commit
0c3efdbef2
119 changed files with 27142 additions and 266 deletions
326
apps/www/src/app/api/og/[slug]/route.tsx
Normal file
326
apps/www/src/app/api/og/[slug]/route.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
120
apps/www/src/app/blog/[slug]/layout.tsx
Normal file
120
apps/www/src/app/blog/[slug]/layout.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
// Get baseUrl - use NEXT_PUBLIC_APP_URL if set, otherwise construct from VERCEL_URL
|
||||
// Restrict to allowed hosts: localhost:3000, archgw-tau.vercel.app, or plano.katanemo.com
|
||||
let baseUrl = "http://localhost:3000";
|
||||
|
||||
if (process.env.NEXT_PUBLIC_APP_URL) {
|
||||
const url = process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (
|
||||
url.includes("archgw-tau.vercel.app") ||
|
||||
url.includes("plano.katanemo.com") ||
|
||||
url.includes("localhost:3000")
|
||||
) {
|
||||
baseUrl = url;
|
||||
}
|
||||
} else if (process.env.VERCEL_URL) {
|
||||
const hostname = process.env.VERCEL_URL;
|
||||
// VERCEL_URL is just the hostname, not the full URL
|
||||
if (hostname === "archgw-tau.vercel.app") {
|
||||
baseUrl = `https://${hostname}`;
|
||||
} else if (hostname === "plano.katanemo.com") {
|
||||
baseUrl = `https://${hostname}`;
|
||||
}
|
||||
}
|
||||
|
||||
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}</>;
|
||||
}
|
||||
35
apps/www/src/app/blog/[slug]/not-found.tsx
Normal file
35
apps/www/src/app/blog/[slug]/not-found.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
206
apps/www/src/app/blog/[slug]/page.tsx
Normal file
206
apps/www/src/app/blog/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
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-8 pt-8 sm:pt-12 lg:pt-1 pb-8 sm:pb-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-4 sm:pt-6 lg:pt-8 pb-4 sm: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">
|
||||
{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-6 sm:pb-8 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>
|
||||
);
|
||||
}
|
||||
119
apps/www/src/app/blog/page.tsx
Normal file
119
apps/www/src/app/blog/page.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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 pb-8 sm:pb-12 lg:pb-0">
|
||||
<FeaturedBlogCard post={featuredPostWithDate} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent Posts Section */}
|
||||
{recentPostsWithDates.length > 0 && (
|
||||
<section className="border-b border-black/10 py-8 sm:py-12 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>
|
||||
);
|
||||
}
|
||||
16
apps/www/src/app/docs/page.tsx
Normal file
16
apps/www/src/app/docs/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from "react";
|
||||
|
||||
export default function DocsPage() {
|
||||
return (
|
||||
<section className="px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-24">
|
||||
<div className="max-w-[81rem] mx-auto">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-normal leading-tight tracking-tighter text-black mb-6">
|
||||
<span className="font-sans">Documentation</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl lg:text-2xl font-sans font-[400] tracking-[-1.2px] text-black/70">
|
||||
Coming soon...
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
BIN
apps/www/src/app/favicon.ico
Normal file
BIN
apps/www/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
2
apps/www/src/app/globals.css
Normal file
2
apps/www/src/app/globals.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/* This file is kept for backwards compatibility but styles are imported from @katanemo/shared-styles */
|
||||
@import "@katanemo/shared-styles/globals.css";
|
||||
39
apps/www/src/app/layout.tsx
Normal file
39
apps/www/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import "@katanemo/shared-styles/globals.css";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { ConditionalLayout } from "@/components/ConditionalLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Plano - Delivery Infrastructure for Agentic Apps",
|
||||
description:
|
||||
"Build agents faster, and deliver them reliably to production - by offloading the critical plumbing work to Plano!",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">
|
||||
{/* Google tag (gtag.js) */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-6J5LQH3Q9G"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-6J5LQH3Q9G');
|
||||
`}
|
||||
</Script>
|
||||
<ConditionalLayout>{children}</ConditionalLayout>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
28
apps/www/src/app/page.tsx
Normal file
28
apps/www/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Hero } from "@/components/Hero";
|
||||
import { IntroSection } from "@/components/IntroSection";
|
||||
import { IdeaToAgentSection } from "@/components/IdeaToAgentSection";
|
||||
import { UseCasesSection } from "@/components/UseCasesSection";
|
||||
import { VerticalCarouselSection } from "@/components/VerticalCarouselSection";
|
||||
import { HowItWorksSection } from "@/components/HowItWorksSection";
|
||||
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
|
||||
import { LogoCloud } from "@/components/LogoCloud";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<LogoCloud />
|
||||
<IntroSection />
|
||||
<IdeaToAgentSection />
|
||||
<UseCasesSection />
|
||||
<VerticalCarouselSection />
|
||||
<HowItWorksSection />
|
||||
<UnlockPotentialSection variant="transparent" />
|
||||
|
||||
{/* Rest of the sections will be refactored next */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/www/src/app/research/page.tsx
Normal file
28
apps/www/src/app/research/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ResearchHero,
|
||||
ResearchGrid,
|
||||
ResearchTimeline,
|
||||
ResearchCTA,
|
||||
ResearchCapabilities,
|
||||
ResearchBenchmarks,
|
||||
ResearchFamily,
|
||||
} from "@/components/research";
|
||||
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
|
||||
|
||||
export default function ResearchPage() {
|
||||
return (
|
||||
<>
|
||||
<ResearchHero />
|
||||
<ResearchGrid />
|
||||
<ResearchTimeline />
|
||||
<ResearchCTA />
|
||||
<ResearchCapabilities />
|
||||
<ResearchBenchmarks />
|
||||
{/* <ResearchFamily /> */}
|
||||
<UnlockPotentialSection variant="transparent" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
apps/www/src/app/studio/[[...index]]/page.tsx
Normal file
8
apps/www/src/app/studio/[[...index]]/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { NextStudio } from "next-sanity/studio";
|
||||
import config from "../../../../sanity.config";
|
||||
|
||||
export default function StudioPage() {
|
||||
return <NextStudio config={config} />;
|
||||
}
|
||||
11
apps/www/src/app/studio/layout.tsx
Normal file
11
apps/www/src/app/studio/layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default function StudioLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 h-screen w-screen overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue