add blog component

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso 2024-09-20 13:41:03 -04:00
parent 81f4ff30dd
commit 9447a4bef7
Signed by: xe
SSH Key Fingerprint: SHA256:7EWsWanxCI427bJ0t3CA6LyqXnkPajReCxkUhbpJULU
18 changed files with 3774 additions and 21 deletions

View File

@ -36,4 +36,7 @@ yarn-error.log*
next-env.d.ts
# Kubernetes
/manifest/
/manifest/
# Content collections
.content-collections

3
.gitignore vendored
View File

@ -34,3 +34,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Content collections
.content-collections

View File

@ -1,6 +1,7 @@
import React from 'react';
import { User, Award, Globe } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { allAuthors } from '@/.content-collections/generated';
const TeamMember = ({ name, role, image }) => (
<div className="text-center">
@ -12,12 +13,29 @@ const TeamMember = ({ name, role, image }) => (
</div>
);
function shuffle(array) {
let currentIndex = array.length;
// While there remain elements to shuffle...
while (currentIndex != 0) {
// Pick a remaining element...
let randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [
array[randomIndex], array[currentIndex]];
}
}
const AboutPage = () => {
const teamMembers = [
{ name: "Jane Doe", role: "CEO & Founder", image: "/api/placeholder/150/150" },
{ name: "John Smith", role: "CTO", image: "/api/placeholder/150/150" },
{ name: "Emily Brown", role: "Head of AI", image: "/api/placeholder/150/150" },
];
const teamMembers = allAuthors.map(author => ({
name: author.displayName,
role: author.role,
image: author.avatarUrl,
}));
shuffle(teamMembers);
return (
<div className="bg-gray-100 min-h-screen">
@ -76,7 +94,7 @@ const AboutPage = () => {
</div>
<div className="text-center mb-12">
<h3 className="text-2xl font-bold text-gray-900">Our Leadership Team</h3>
<h3 className="text-2xl font-bold text-gray-900">Our Team</h3>
<div className="mt-10 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3">
{teamMembers.map((member, index) => (
<TeamMember key={index} {...member} />

View File

@ -0,0 +1,80 @@
import { MDXContent } from "@content-collections/mdx/react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { allPosts } from "content-collections";
import type { Post } from "content-collections";
import Image from "next/image";
import AuthorChip from "@/components/author-chip";
export interface PageParams {
slug?: string[];
}
const getPost = (slug: string[]): Post | null => {
const pageSlug = slug.join("/");
for (const post of allPosts) {
if (post._meta.path == pageSlug) {
return post;
}
}
return null;
};
export default async function Page({ params }: { params: PageParams }) {
const page = getPost(params.slug!);
if (!page) notFound();
return (
<>
<div className="bg-gray-100 min-h-screen">
<article className="">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-gray-900">{page.title}</h1>
</div>
</header>
<p className="text-muted-foreground pb-8 mt-4 text-sm max-w-[80ch] mx-auto">
<AuthorChip {...page.author!} />
{page.summary}
</p>
{page.image !== undefined && (
<Image
alt={page.imageDesc!}
src={page.imageURL!}
width={1280}
height={720}
className="mx-auto max-w-2xl"
/>
)}
<div className="mx-auto max-w-[80ch] py-6 sm:px-6 lg:px-8">
<div className="prose">
<MDXContent code={page.mdx} components={{}} />
</div>
</div>
</article>
</div>
</>
);
}
export async function generateStaticParams() {
return allPosts.map((page) => ({
slug: page._meta.path.split("/"),
}));
}
export function generateMetadata({ params }: { params: PageParams }) {
const page = getPost(params.slug!);
if (!page) notFound();
return {
title: page.title,
description: page.summary,
} satisfies Metadata;
}

37
app/blog/page.tsx Normal file
View File

@ -0,0 +1,37 @@
import { allPosts } from "content-collections";
import Image from "next/image";
export default function Posts() {
return (
<>
<div className="bg-gray-100 min-h-screen">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-gray-900">Blog</h1>
</div>
</header>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8 grid grid-cols-1 4xl:grid-cols-3">
{allPosts.map((post) => (
<div key={post._meta.path}>
<a href={`/blog/${post._meta.path}`}>
<h3 className="text-xl font-bold">{post.title}</h3>
{" -- "}
<p>{post.summary}</p>
{post.image && (
<Image
alt={post.imageDesc!}
src={post.imageURL!}
width={1280}
height={720}
className="max-w-md"
/>
)}
</a>
</div>
))}
</div>
</div>
</>
);
}

View File

@ -42,21 +42,13 @@ export default function RootLayout({
<div className="flex-shrink-0 flex items-center">
<Link
href="/"
className={`${inter.className} text-2xl font-bold text-gray-900`}
className={`${podkova.className} text-2xl font-bold text-gray-900`}
>
Techaro
</Link>
</div>
</div>
<div className="flex items-center">
<Link href="/services" passHref>
<Button
variant="ghost"
className="text-gray-600 hover:text-gray-900"
>
Services
</Button>
</Link>
<Link href="/about" passHref>
<Button
variant="ghost"
@ -65,6 +57,14 @@ export default function RootLayout({
About
</Button>
</Link>
<Link href="/blog" passHref>
<Button
variant="ghost"
className="text-gray-600 hover:text-gray-900"
>
Blog
</Button>
</Link>
<Link href="/contact" passHref>
<Button
variant="ghost"
@ -73,6 +73,14 @@ export default function RootLayout({
Contact
</Button>
</Link>
<Link href="/services" passHref>
<Button
variant="ghost"
className="text-gray-600 hover:text-gray-900"
>
Services
</Button>
</Link>
</div>
</div>
</nav>

37
app/sitemap.ts Normal file
View File

@ -0,0 +1,37 @@
import { allPosts } from "@/.content-collections/generated";
import { MetadataRoute } from "next";
export function getBaseUrl() {
return process.env.NODE_ENV === "production"
? "https://techaro.lol"
: "http://localhost:3000";
}
const basePath = getBaseUrl();
const generatedDate = new Date();
export default function sitemap(): MetadataRoute.Sitemap {
const staticPages = [
"/",
"/about",
"/blog",
"/contact",
"/services",
];
//@ts-ignore
return [
...staticPages.map((path) => ({
url: basePath + path,
lastModified: generatedDate,
changeFrequency: "daily",
priority: 1,
})),
...allPosts.map(({ date, urlPath, imageURL }) => ({
url: `${basePath}/${urlPath}`,
lastModified: new Date(date),
changeFrequency: "monthly",
priority: 0.8,
images: imageURL !== undefined ? [imageURL] : [],
}))
];
};

View File

@ -0,0 +1,55 @@
import { IconBrandBluesky, IconBrandXFilled } from "@tabler/icons-react";
import Image from "next/image";
export interface AuthorChipParams {
name: string;
displayName: string;
avatarUrl: string;
bluesky?: string;
x?: string;
}
export default function AuthorChip({
name,
displayName,
avatarUrl,
bluesky,
x,
}: AuthorChipParams) {
return (
<>
<div className="group block flex-shrink-0 mb-4">
<div className="flex items-center">
<div>
<Image
alt={`Picture of ${displayName}`}
src={avatarUrl}
width={64}
height={64}
className="inline-block h-9 w-9 rounded-full"
/>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
{displayName}
</p>
{bluesky !== undefined && (
<>
<div className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
<a href={`https://bsky.app/profile/${bluesky}`}>
<IconBrandBluesky
size={16}
fill="gray-700"
className="inline-block"
/>{" "}
@{bluesky}
</a>
</div>
</>
)}
</div>
</div>
</div>
</>
);
}

47
content-collections.ts Normal file
View File

@ -0,0 +1,47 @@
import { defineCollection, defineConfig } from "@content-collections/core";
import { compileMDX } from "@content-collections/mdx";
const authors = defineCollection({
name: "authors",
directory: "data/authors/",
include: "*.md",
schema: (z) => ({
name: z.string(),
displayName: z.string(),
role: z.string(),
avatarUrl: z.string(),
bluesky: z.string().optional(),
x: z.string().optional(),
}),
});
const posts = defineCollection({
name: "posts",
directory: "data/posts/",
include: "**/*.mdx",
schema: (z) => ({
title: z.string(),
author: z.string().default("mimi"),
date: z.string(),
summary: z.string(),
image: z.string().optional(),
imageDesc: z.string().optional(),
}),
transform: async (document, context) => {
const mdx = await compileMDX(context, document);
const author = await context
.documents(authors)
.find((a) => a.name === document.author);
return {
...document,
author,
mdx,
urlPath: `blog/${document._meta.path}`,
imageURL: document.image !== undefined ? `https://cdn.xeiaso.net/file/christine-static/${document.image}.jpg` : undefined,
};
},
});
export default defineConfig({
collections: [authors, posts],
});

7
data/authors/mimi.md Normal file
View File

@ -0,0 +1,7 @@
---
name: mimi
displayName: "Mimi Yasomi"
role: "Member of Technical Staff"
avatarUrl: /img/avatars/mimi.webp
bluesky: yasomi.xeiaso.net
---

7
data/authors/xe.md Normal file
View File

@ -0,0 +1,7 @@
---
name: xe
displayName: "Xe Iaso"
role: "Chief Executive Officer"
avatarUrl: /img/avatars/xe.jpg
bluesky: xeiaso.net
---

View File

@ -0,0 +1,11 @@
---
title: "Hello world"
date: 2024-09-20
summary: "Techaro introduces itself"
image: hero/single-grain
imageDesc: "A single wild grain plant"
---
Hello, world!
We are Techaro.

View File

@ -1,7 +1,19 @@
/** @type {import('next').NextConfig} */
import { withContentCollections } from "@content-collections/next";
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.xeiaso.net',
port: '',
pathname: '/file/christine-static/**',
},
],
},
};
export default nextConfig;
export default withContentCollections(nextConfig);

3420
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,35 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "concurrently \"content-collections watch\" \"next dev\"",
"build": "content-collections build && next build",
"start": "next start",
"lint": "next lint",
"deploy": "earthly --push +run && kubectl apply -k manifest && kubectl rollout restart -n default deployment/techaro-lol"
},
"dependencies": {
"@arcjet/next": "^1.0.0-alpha.26",
"@tabler/icons-react": "^3.17.0",
"lucide-react": "^0.399.0",
"next": "^14.2.11",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@content-collections/cli": "^0.1.4",
"@content-collections/core": "^0.7.1",
"@content-collections/mdx": "^0.1.5",
"@content-collections/next": "^0.2.2",
"@flydotio/dockerfile": "^0.5.7",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"concurrently": "^9.0.1",
"eslint": "^8",
"eslint-config-next": "^14.2.9",
"husky": "^9.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/img/avatars/xe.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -18,7 +18,8 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"content-collections": ["./.content-collections/generated"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],