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

91
components/BlogFooter.tsx Normal file
View File

@@ -0,0 +1,91 @@
import Link from 'next/link'
import ThreeLogo from './ThreeLogo'
export default function BlogFooter() {
return (
<footer className="bg-white/80 dark:bg-carbon-950/80 backdrop-blur-md border-t border-carbon-200/50 dark:border-carbon-800/50 mt-24">
<div className="blog-container py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10">
<ThreeLogo className="w-full h-full" />
</div>
<div className="flex flex-col">
<span className="font-logo font-semibold text-lg leading-none text-carbon-900 dark:text-carbon-100">CHORUS</span>
<span className="text-carbon-600 dark:text-carbon-500 text-xs font-medium">PING!</span>
</div>
</div>
<p className="text-carbon-600 dark:text-carbon-400 text-sm leading-relaxed mb-6 max-w-md">
Insights and deep dives into contextual AI orchestration, agent coordination,
and the future of intelligent systems.
</p>
<div className="flex space-x-4">
<Link
href="https://www.linkedin.com/in/anthonylewisrawlins"
className="text-carbon-600 dark:text-carbon-500 hover:text-carbon-800 dark:hover:text-carbon-300 transition-colors"
>
<span className="sr-only">LinkedIn</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.338 16.338H13.67V12.16c0-.995-.017-2.277-1.387-2.277-1.39 0-1.601 1.086-1.601 2.207v4.248H8.014v-8.59h2.559v1.174h.037c.356-.675 1.227-1.387 2.526-1.387 2.703 0 3.203 1.778 3.203 4.092v4.711zM5.005 6.575a1.548 1.548 0 11-.003-3.096 1.548 1.548 0 01.003 3.096zm-1.337 9.763H6.34v-8.59H3.667v8.59zM17.668 1H2.328C1.595 1 1 1.581 1 2.298v15.403C1 18.418 1.595 19 2.328 19h15.34c.734 0 1.332-.582 1.332-1.299V2.298C19 1.581 18.402 1 17.668 1z" clipRule="evenodd"/>
</svg>
</Link>
</div>
</div>
<div>
<h3 className="text-carbon-900 dark:text-carbon-100 font-semibold mb-4">Blog</h3>
<ul className="space-y-2 text-sm">
<li>
<Link href="/" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
All Posts
</Link>
</li>
<li>
<Link href="/tags" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
Topics
</Link>
</li>
<li>
<Link href="/rss.xml" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
RSS Feed
</Link>
</li>
</ul>
</div>
<div>
<h3 className="text-carbon-900 dark:text-carbon-100 font-semibold mb-4">CHORUS</h3>
<ul className="space-y-2 text-sm">
<li>
<Link href="https://chorus.services" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
Platform
</Link>
</li>
<li>
<Link href="https://chorus.services/about" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
About
</Link>
</li>
<li>
<Link href="https://chorus.services/contact" className="text-carbon-600 dark:text-carbon-400 hover:text-carbon-800 dark:hover:text-carbon-200 transition-colors">
Contact
</Link>
</li>
</ul>
</div>
</div>
<div className="border-t border-carbon-200 dark:border-carbon-800 mt-8 pt-8 text-center">
<p className="text-carbon-600 dark:text-carbon-500 text-sm">
© {new Date().getFullYear()} CHORUS Services. Built with care by{' '}
<Link href="https://deepblack.cloud" className="text-carbon-700 dark:text-carbon-400 hover:text-carbon-900 dark:hover:text-carbon-200 transition-colors">
Deep Black Cloud
</Link>
.
</p>
</div>
</div>
</footer>
)
}

72
components/BlogHeader.tsx Normal file
View File

@@ -0,0 +1,72 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import ThreeLogo from './ThreeLogo'
import ThemeToggle from './ThemeToggle'
export default function BlogHeader() {
const [isScrolled, setIsScrolled] = useState(false)
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
return (
<header className={`sticky top-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-white/80 dark:bg-carbon-950/80 backdrop-blur-md border-b border-carbon-200/50 dark:border-carbon-800/50'
: 'bg-white/20 dark:bg-carbon-950/20 backdrop-blur-sm'
}`}>
<nav className="blog-container py-4">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center space-x-3 group">
<div className="w-14 h-14 group-hover:scale-110 transition-transform">
<ThreeLogo className="w-full h-full" />
</div>
<div className="flex flex-col">
<span className="font-logo font-semibold text-lg leading-none text-carbon-900 dark:text-carbon-100">CHORUS</span>
<span className="text-carbon-600 dark:text-carbon-500 text-xs font-medium">PING!</span>
</div>
</Link>
<div className="hidden md:flex items-center space-x-8">
<Link
href="/"
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-medium"
>
All Posts
</Link>
<Link
href="/tags"
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-medium"
>
Topics
</Link>
<Link
href="https://chorus.services"
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-carbon-100 transition-colors font-medium"
>
About CHORUS
</Link>
</div>
<div className="flex items-center space-x-4">
<ThemeToggle />
<Link
href="https://chorus.services"
className="px-4 py-2 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 text-sm"
>
Join Waitlist
</Link>
</div>
</div>
</nav>
</header>
)
}

87
components/PostCard.tsx Normal file
View File

@@ -0,0 +1,87 @@
import Link from 'next/link'
import { BlogPost } from '@/types/blog'
interface PostCardProps {
post: BlogPost
featured?: boolean
}
export default function PostCard({ post, featured = false }: PostCardProps) {
const formattedDate = new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
return (
<article className={`group ${
featured
? 'bg-carbon-50 dark:bg-carbon-900 border border-carbon-200 dark:border-carbon-800 rounded-xl p-8 hover:border-carbon-400 dark:hover:border-mulberry-700 transition-all duration-300'
: 'bg-carbon-100/50 dark:bg-carbon-900/50 border border-carbon-200/50 dark:border-carbon-800/50 rounded-lg p-6 hover:bg-carbon-50 dark:hover:bg-carbon-900 hover:border-carbon-300 dark:hover:border-carbon-700 transition-all duration-300'
}`}>
<Link href={`/posts/${post.slug}`} className="block">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<div className="blog-meta">
<time dateTime={post.date} className="text-carbon-600 dark:text-carbon-500">
{formattedDate}
</time>
<span className="text-carbon-500 dark:text-carbon-600"></span>
<span className="text-carbon-600 dark:text-carbon-500">
{post.readingTime} min read
</span>
</div>
{post.featured && (
<span className="blog-tag bg-mulberry-700 text-mulberry-200">
Featured
</span>
)}
</div>
<h2 className={`${
featured ? 'text-h3' : 'text-h4'
} font-logo text-carbon-950 dark:text-carbon-100 group-hover:text-carbon-700 dark:group-hover:text-mulberry-200 transition-colors mb-3`}>
{post.title}
</h2>
<p className="text-carbon-700 dark:text-carbon-300 leading-relaxed mb-4 line-clamp-3">
{post.description}
</p>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-br from-mulberry-400 to-ocean-500 rounded-full flex items-center justify-center">
<span className="text-carbon-950 font-semibold text-xs">
{post.author?.name?.charAt(0) || 'C'}
</span>
</div>
<div>
<p className="text-carbon-950 dark:text-carbon-200 font-medium text-sm">
{post.author?.name || 'CHORUS Team'}
</p>
{post.author?.role && (
<p className="text-carbon-600 dark:text-carbon-500 text-xs">
{post.author.role}
</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{post.tags?.slice(0, 3).map((tag) => (
<span key={tag} className="blog-tag">
{tag}
</span>
)) || []}
{(post.tags?.length || 0) > 3 && (
<span className="text-carbon-600 dark:text-carbon-500 text-xs">
+{(post.tags?.length || 0) - 3} more
</span>
)}
</div>
</div>
</Link>
</article>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import React, { useState, useEffect } from 'react'
export default function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
useEffect(() => {
// Check for saved theme preference or default to dark
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light')
setTheme(initialTheme)
// Apply theme to document
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}, [])
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
// Save to localStorage
localStorage.setItem('theme', newTheme)
// Apply to document
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
return (
<button
onClick={toggleTheme}
className="p-chorus-sm hover:bg-black/10 dark:hover:bg-white/10 transition-colors rounded-lg backdrop-blur-sm bg-white/20 dark:bg-black/20 border border-white/30 dark:border-white/20"
aria-label="Toggle theme"
>
{theme === 'dark' ? (
// Sun icon for switching to light mode
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
// Moon icon for switching to dark mode
<svg className="w-5 h-5 text-carbon-950" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
)
}

236
components/ThreeLogo.tsx Normal file
View File

@@ -0,0 +1,236 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
interface ThreeLogoProps {
className?: string;
}
export default function ThreeLogo({ className = "" }: ThreeLogoProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
// Scene / Renderer / Camera
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 3.0); // Move camera back to prevent clipping
camera.lookAt(0, 0, 0); // Ensure camera looks at exact center
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x000000, 0); // transparent background
container.appendChild(renderer.domElement);
// Resize handling with proper aspect ratio
const resize = () => {
const { clientWidth, clientHeight } = container;
// Ensure square aspect ratio for the logo
const size = Math.min(clientWidth, clientHeight);
renderer.setSize(size, size, false);
camera.aspect = 1; // Always square
camera.updateProjectionMatrix();
};
resize();
window.addEventListener('resize', resize);
// Exact lighting setup from your reference logo.html
const light = new THREE.PointLight(0xffffff, 1.4);
light.position.set(0, 4, 1);
scene.add(light);
const bottomLight = new THREE.PointLight(0x800080, 1.2, 12);
bottomLight.position.set(0, -4, 1);
scene.add(bottomLight);
const leftLight = new THREE.PointLight(0x808000, 1.45, 5);
leftLight.position.set(-5, 0, 4);
scene.add(leftLight);
scene.add(new THREE.AmbientLight(0xffffff, 0.45));
// Load environment map from your horizon gradient image
const expandGradient = (img: HTMLImageElement) => {
const canvas = document.createElement('canvas');
const w = 512, h = 256; // safe HDRI-like size
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
// Stretch the narrow gradient strip to fill the canvas
ctx.drawImage(img, 0, 0, w, h);
return new THREE.CanvasTexture(canvas);
};
const envLoader = new THREE.ImageLoader();
envLoader.load('/logos/horizon-gradient.png', (image) => {
const tex = expandGradient(image);
tex.mapping = THREE.EquirectangularReflectionMapping;
// Generate proper environment map using PMREM
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromEquirectangular(tex).texture;
scene.environment = envMap;
pmrem.dispose();
});
scene.background = null; // keep transparent
// Theme-aware material colors
const getThemeMaterial = (theme: string = 'default') => {
const colorMap = {
'default': 0x333333,
'protanopia': 0x1e40af, // Blue-800 for red-blind
'deuteranopia': 0x6b21a8, // Purple-800 for green-blind
'tritanopia': 0x991b1b, // Red-800 for blue-blind
'achromatopsia': 0x374151, // Gray-700 for color-blind
};
return new THREE.MeshPhysicalMaterial({
color: colorMap[theme as keyof typeof colorMap] || colorMap.default,
roughness: 0.24,
metalness: 1.0,
clearcoat: 0.48,
clearcoatRoughness: 0.15,
reflectivity: 1.2,
sheen: 0.35,
sheenColor: new THREE.Color(0x212121),
sheenRoughness: 0.168,
envMapIntensity: 1,
});
};
// Initialize with default material
let currentMaterial = getThemeMaterial('default');
// Load GLB with DRACO support
const loader = new GLTFLoader();
const draco = new DRACOLoader();
draco.setDecoderPath('/draco/');
loader.setDRACOLoader(draco);
let model: THREE.Object3D | null = null;
console.log('Loading your mobius-ring.glb...');
loader.load(
'/logos/mobius-ring.glb',
(gltf) => {
console.log('🎉 Your GLB loaded successfully!', gltf);
model = gltf.scene;
// Apply the theme-aware material
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
(child as THREE.Mesh).material = currentMaterial;
}
});
// Center the model exactly at origin
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center); // Move model so its center is at (0,0,0)
scene.add(model);
console.log('🎯 Your Möbius GLB model added to scene and centered at origin');
},
(progress) => {
if (progress.total > 0) {
const percent = Math.round(progress.loaded / progress.total * 100);
console.log(`GLB loading progress: ${percent}% (${progress.loaded}/${progress.total} bytes)`);
}
},
(err) => {
console.error('❌ GLB load error:', err);
// Fallback: create a torus geometry with the theme-aware material
console.log('Creating fallback torus geometry...');
const fallbackGeometry = new THREE.TorusGeometry(0.6, 0.2, 16, 100);
const fallbackMesh = new THREE.Mesh(fallbackGeometry, currentMaterial);
fallbackMesh.position.set(0, 0, 0); // Ensure fallback is also centered
scene.add(fallbackMesh);
model = fallbackMesh;
console.log('⚠️ Fallback torus geometry created (placeholder for your GLB)');
}
);
// Listen for accessibility theme changes
const handleThemeChange = (event: any) => {
const newTheme = event.detail.theme;
const newMaterial = getThemeMaterial(newTheme);
if (model) {
// Update material on all meshes
model.traverse((child: any) => {
if (child.isMesh) {
child.material.dispose(); // Clean up old material
child.material = newMaterial;
}
});
}
// Update current material reference
currentMaterial = newMaterial;
console.log(`🎨 Logo theme changed to: ${newTheme}`);
};
window.addEventListener('accessibilityThemeChanged', handleThemeChange);
let raf = 0;
const tick = () => {
raf = requestAnimationFrame(tick);
if (model) {
// Use exact rotation parameters from your reference logo.html
model.rotation.x += 0.010; // spinSpeedX from params
model.rotation.y += -0.010; // spinSpeedY from params
model.rotation.z += -0.1; // spinSpeedZ from params
}
renderer.render(scene, camera);
};
tick();
// Cleanup
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
window.removeEventListener('accessibilityThemeChanged', handleThemeChange);
renderer.dispose();
draco.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
scene.traverse((obj: any) => {
if (obj.geometry) obj.geometry.dispose?.();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m: any) => m.dispose?.());
}
});
};
}, []);
return (
<div
ref={containerRef}
className={className}
style={{
width: '100%',
height: '100%',
aspectRatio: '1 / 1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
);
}