Initial commit: CHORUS PING! blog

- Next.js 14 blog application with theme support
- Docker containerization with volume bindings
- Traefik integration with Let's Encrypt SSL
- MDX support for blog posts
- Theme toggle with localStorage persistence
- Scheduled posts directory structure
- Brand guidelines compliance with CHORUS colors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
anthonyrawlins
2025-08-27 14:46:26 +10:00
commit 6e13451dc4
63 changed files with 12242 additions and 0 deletions

116
app/globals.css Normal file
View File

@@ -0,0 +1,116 @@
@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:ital,wght@0,100..900;1,100..900&family=Exo:ital,wght@0,100..900;1,100..900&family=Inconsolata:wdth,wght@50..200,200..900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-inter: 'Inter Tight', ui-sans-serif, system-ui;
--font-exo: 'Exo', 'Inter Tight', ui-sans-serif, system-ui;
--font-mono: 'Inconsolata', ui-monospace, monospace;
}
html {
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
scroll-behavior: smooth;
}
body {
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* CHORUS Blog Typography */
.prose {
@apply max-w-none text-carbon-950 dark:text-carbon-100;
}
.prose h1 {
@apply text-h2 font-sans font-thin text-carbon-950 dark:text-mulberry-100 mb-8;
}
.prose h2 {
@apply text-h3 font-logo text-carbon-950 dark:text-mulberry-200 mb-6 mt-12;
}
.prose h3 {
@apply text-h4 font-sans text-carbon-950 dark:text-mulberry-300 mb-4 mt-8;
}
.prose h4 {
@apply text-h5 font-sans text-carbon-800 dark:text-mulberry-400 mb-3 mt-6;
}
.prose h5 {
@apply text-h6 font-sans text-carbon-700 dark:text-mulberry-500 mb-3 mt-6;
}
.prose h6 {
@apply text-h7 font-sans text-carbon-600 dark:text-mulberry-600 mb-2 mt-4;
}
.prose p {
@apply text-base leading-relaxed mb-6 text-carbon-800 dark:text-carbon-200;
}
.prose a {
@apply text-ocean-600 dark:text-ocean-400 hover:text-ocean-800 dark:hover:text-ocean-300 transition-colors underline decoration-ocean-500/30 hover:decoration-ocean-600/50 dark:hover:decoration-ocean-400/50;
}
.prose strong {
@apply text-carbon-950 dark:text-carbon-100 font-semibold;
}
.prose code {
@apply bg-carbon-200 dark:bg-carbon-800 text-eucalyptus-700 dark:text-eucalyptus-400 px-2 py-1 rounded-sm font-mono text-sm;
}
.prose pre {
@apply bg-carbon-100 dark:bg-carbon-900 border border-carbon-300 dark:border-carbon-700 rounded-lg overflow-x-auto p-4 mb-6;
}
.prose pre code {
@apply bg-transparent text-carbon-950 dark:text-carbon-200 p-0;
}
.prose blockquote {
@apply border-l-4 border-carbon-400 dark:border-mulberry-600 pl-6 italic text-carbon-600 dark:text-carbon-300 my-6;
}
.prose ul {
@apply list-disc pl-6 mb-6 space-y-2;
}
.prose ol {
@apply list-decimal pl-6 mb-6 space-y-2;
}
.prose li {
@apply text-carbon-800 dark:text-carbon-200;
}
/* Blog-specific utilities */
.blog-container {
@apply max-w-4xl mx-auto px-4 sm:px-6 lg:px-8;
}
.blog-meta {
@apply text-sm text-carbon-600 dark:text-carbon-500 flex items-center space-x-4;
}
.blog-tag {
@apply inline-block bg-carbon-200 dark:bg-mulberry-800 text-carbon-700 dark:text-mulberry-200 px-3 py-1 rounded-full text-xs font-medium;
}
/* Reading progress indicator */
.reading-progress {
@apply fixed top-0 left-0 w-full h-1 bg-ocean-600 dark:bg-mulberry-600 transform origin-left scale-x-0 transition-transform z-50;
}
/* Utilities */
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}

113
app/layout.tsx Normal file
View File

@@ -0,0 +1,113 @@
import type { Metadata } from 'next'
import { Inter, Exo } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
const exo = Exo({
subsets: ['latin'],
variable: '--font-exo',
display: 'swap',
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
})
export const metadata: Metadata = {
title: 'CHORUS PING! - Insights on Contextual AI',
description: 'Deep dives into contextual AI orchestration, agent coordination, and the future of intelligent systems.',
keywords: ['contextual AI', 'agent orchestration', 'enterprise AI', 'AI insights', 'technology blog'],
authors: [{ name: 'Anthony Lewis Rawlins', url: 'https://deepblack.cloud' }],
creator: 'Deep Black Cloud',
publisher: 'CHORUS Services',
metadataBase: new URL('https://blog.chorus.services'),
alternates: {
canonical: 'https://blog.chorus.services',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://blog.chorus.services',
siteName: 'CHORUS PING!',
title: 'CHORUS PING! - Insights on Contextual AI',
description: 'Deep dives into contextual AI orchestration, agent coordination, and the future of intelligent systems.',
images: [
{
url: '/logos/logo-ring-only.png',
width: 256,
height: 256,
alt: 'CHORUS Services Logo',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'CHORUS PING! - Insights on Contextual AI',
description: 'Deep dives into contextual AI orchestration, agent coordination, and the future of intelligent systems.',
images: ['/logos/chorus-landscape-on-blue.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
{/* Google tag (gtag.js) */}
<script async src="https://www.googletagmanager.com/gtag/js?id=G-WTFF8JL9SF"></script>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-WTFF8JL9SF');
`,
}}
/>
{/* Theme initialization script */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var shouldBeDark = theme === 'dark' || (!theme && prefersDark);
if (shouldBeDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (e) {
// If localStorage is not available, default to dark
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
</head>
<body className={`${inter.variable} ${exo.variable} font-sans bg-white dark:bg-carbon-950 text-carbon-950 dark:text-carbon-100 transition-colors duration-300`}>
{children}
</body>
</html>
)
}

68
app/page.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { getSortedPostsData, getFeaturedPosts } from '@/lib/blog'
import BlogHeader from '@/components/BlogHeader'
import BlogFooter from '@/components/BlogFooter'
import PostCard from '@/components/PostCard'
export default function HomePage() {
const allPosts = getSortedPostsData()
const featuredPosts = getFeaturedPosts()
const recentPosts = allPosts.slice(0, 6)
return (
<main className="min-h-screen">
<BlogHeader />
{/* Hero Section */}
<section className="blog-container py-16">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-h1 font-logo text-carbon-950 dark:text-mulberry-100 mb-6">
Informed Agentic AI
</h1>
<p className="text-xl text-carbon-700 dark:text-carbon-300 leading-relaxed">
Deep dives into AI orchestration, agent coordination, and the future of
intelligent systems from the team building CHORUS.
</p>
</div>
</section>
{/* Featured Posts */}
{featuredPosts.length > 0 && (
<section className="blog-container py-8">
<h2 className="text-h3 font-logo text-carbon-950 dark:text-carbon-100 mb-8">Featured Posts</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{featuredPosts.map((post) => (
<PostCard key={post.slug} post={post} featured={true} />
))}
</div>
</section>
)}
{/* Recent Posts */}
<section className="blog-container py-12">
<h2 className="text-h3 font-logo text-carbon-950 dark:text-carbon-100 mb-8">Recent Posts</h2>
{recentPosts.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{recentPosts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 bg-carbon-200 dark:bg-carbon-800 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-carbon-600 dark:text-carbon-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h3 className="text-h5 font-semibold text-carbon-950 dark:text-carbon-200 mb-2">Coming Soon</h3>
<p className="text-carbon-600 dark:text-carbon-400 max-w-md mx-auto">
We're preparing some excellent content about contextual AI and agent orchestration.
Check back soon for our first posts!
</p>
</div>
)}
</section>
<BlogFooter />
</main>
)
}

174
app/posts/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,174 @@
import { notFound } from 'next/navigation'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getPostData, getAllPostSlugs } from '@/lib/blog'
import BlogHeader from '@/components/BlogHeader'
import BlogFooter from '@/components/BlogFooter'
import Link from 'next/link'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeSlug from 'rehype-slug'
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
interface PostPageProps {
params: Promise<{
slug: string
}>
}
export async function generateStaticParams() {
const slugs = getAllPostSlugs()
return slugs.map(({ params }) => ({ slug: params.slug }))
}
export async function generateMetadata({ params }: PostPageProps) {
const { slug } = await params
const post = getPostData(slug)
if (!post) {
return {
title: 'Post Not Found - CHORUS PING!'
}
}
return {
title: `${post.title} - CHORUS PING!`,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
authors: [post.author?.name || 'CHORUS Team'],
tags: post.tags,
},
}
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params
const post = getPostData(slug)
if (!post) {
notFound()
}
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
const mdxOptions = {
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeHighlight,
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap' }]
],
}
}
return (
<main className="min-h-screen">
<BlogHeader />
<article className="blog-container py-12">
{/* Back link */}
<div className="mb-8">
<Link
href="/"
className="inline-flex items-center text-carbon-600 dark:text-carbon-400 hover:text-carbon-950 dark:hover:text-carbon-200 transition-colors text-sm group"
>
<svg
className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform"
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 all posts
</Link>
</div>
{/* Article header */}
<header className="mb-12 max-w-4xl">
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
))}
</div>
<h1 className="text-h1 font-logo text-carbon-950 dark:text-mulberry-100 mb-6">
{post.title}
</h1>
<p className="text-xl text-carbon-700 dark:text-carbon-300 leading-relaxed mb-8">
{post.description}
</p>
<div className="flex items-center justify-between border-b border-carbon-300 dark:border-carbon-800 pb-8">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-to-br from-mulberry-400 to-ocean-500 rounded-full flex items-center justify-center">
<span className="text-carbon-950 font-semibold">
{post.author.name.charAt(0)}
</span>
</div>
<div>
<p className="text-carbon-950 dark:text-carbon-200 font-medium">
{post.author.name}
</p>
{post.author.role && (
<p className="text-carbon-600 dark:text-carbon-500 text-sm">
{post.author.role}
</p>
)}
</div>
</div>
<div className="blog-meta text-right">
<time dateTime={post.date} className="block">
{formattedDate}
</time>
<span className="text-carbon-600 dark:text-carbon-600">
{post.readingTime} min read
</span>
</div>
</div>
</header>
{/* Article content */}
<div className="prose prose-lg max-w-none">
<MDXRemote source={post.content} options={mdxOptions} />
</div>
{/* Article footer */}
<footer className="mt-16 pt-8 border-t border-carbon-300 dark:border-carbon-800">
<div className="text-center">
<h3 className="text-h5 font-logo text-carbon-950 dark:text-carbon-100 mb-4">
Join the CHORUS Community
</h3>
<p className="text-carbon-600 dark:text-carbon-400 mb-6 max-w-2xl mx-auto">
Stay updated with the latest insights on contextual AI and agent orchestration.
Join our waitlist to get early access to the CHORUS platform.
</p>
<Link
href="https://chorus.services"
className="inline-flex items-center px-6 py-3 bg-carbon-900 dark:bg-mulberry-700 hover:bg-carbon-800 dark:hover:bg-mulberry-600 text-white dark:text-mulberry-100 rounded-lg font-medium transition-colors"
>
Join Waitlist
<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</footer>
</article>
<BlogFooter />
</main>
)
}