add blog component
Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
parent
81f4ff30dd
commit
9447a4bef7
@ -36,4 +36,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Kubernetes
|
# Kubernetes
|
||||||
/manifest/
|
/manifest/
|
||||||
|
|
||||||
|
# Content collections
|
||||||
|
.content-collections
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,3 +34,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Content collections
|
||||||
|
.content-collections
|
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { User, Award, Globe } from 'lucide-react';
|
import { User, Award, Globe } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { allAuthors } from '@/.content-collections/generated';
|
||||||
|
|
||||||
const TeamMember = ({ name, role, image }) => (
|
const TeamMember = ({ name, role, image }) => (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -12,12 +13,29 @@ const TeamMember = ({ name, role, image }) => (
|
|||||||
</div>
|
</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 AboutPage = () => {
|
||||||
const teamMembers = [
|
const teamMembers = allAuthors.map(author => ({
|
||||||
{ name: "Jane Doe", role: "CEO & Founder", image: "/api/placeholder/150/150" },
|
name: author.displayName,
|
||||||
{ name: "John Smith", role: "CTO", image: "/api/placeholder/150/150" },
|
role: author.role,
|
||||||
{ name: "Emily Brown", role: "Head of AI", image: "/api/placeholder/150/150" },
|
image: author.avatarUrl,
|
||||||
];
|
}));
|
||||||
|
shuffle(teamMembers);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-100 min-h-screen">
|
<div className="bg-gray-100 min-h-screen">
|
||||||
@ -76,7 +94,7 @@ const AboutPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
<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">
|
<div className="mt-10 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{teamMembers.map((member, index) => (
|
{teamMembers.map((member, index) => (
|
||||||
<TeamMember key={index} {...member} />
|
<TeamMember key={index} {...member} />
|
||||||
|
80
app/blog/[...slug]/page.tsx
Normal file
80
app/blog/[...slug]/page.tsx
Normal 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
37
app/blog/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -42,21 +42,13 @@ export default function RootLayout({
|
|||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className={`${inter.className} text-2xl font-bold text-gray-900`}
|
className={`${podkova.className} text-2xl font-bold text-gray-900`}
|
||||||
>
|
>
|
||||||
Techaro
|
Techaro
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<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>
|
<Link href="/about" passHref>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -65,6 +57,14 @@ export default function RootLayout({
|
|||||||
About
|
About
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/blog" passHref>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Blog
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link href="/contact" passHref>
|
<Link href="/contact" passHref>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -73,6 +73,14 @@ export default function RootLayout({
|
|||||||
Contact
|
Contact
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/services" passHref>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Services
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
37
app/sitemap.ts
Normal file
37
app/sitemap.ts
Normal 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] : [],
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
};
|
55
components/author-chip.tsx
Normal file
55
components/author-chip.tsx
Normal 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
47
content-collections.ts
Normal 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
7
data/authors/mimi.md
Normal 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
7
data/authors/xe.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: xe
|
||||||
|
displayName: "Xe Iaso"
|
||||||
|
role: "Chief Executive Officer"
|
||||||
|
avatarUrl: /img/avatars/xe.jpg
|
||||||
|
bluesky: xeiaso.net
|
||||||
|
---
|
11
data/posts/2024/hello-world.mdx
Normal file
11
data/posts/2024/hello-world.mdx
Normal 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.
|
@ -1,7 +1,19 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
|
import { withContentCollections } from "@content-collections/next";
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
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
3420
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -3,28 +3,35 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "concurrently \"content-collections watch\" \"next dev\"",
|
||||||
"build": "next build",
|
"build": "content-collections build && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"deploy": "earthly --push +run && kubectl apply -k manifest && kubectl rollout restart -n default deployment/techaro-lol"
|
"deploy": "earthly --push +run && kubectl apply -k manifest && kubectl rollout restart -n default deployment/techaro-lol"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@arcjet/next": "^1.0.0-alpha.26",
|
"@arcjet/next": "^1.0.0-alpha.26",
|
||||||
|
"@tabler/icons-react": "^3.17.0",
|
||||||
"lucide-react": "^0.399.0",
|
"lucide-react": "^0.399.0",
|
||||||
"next": "^14.2.11",
|
"next": "^14.2.11",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@flydotio/dockerfile": "^0.5.7",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "^14.2.9",
|
"eslint-config-next": "^14.2.9",
|
||||||
|
"husky": "^9.1.6",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/img/avatars/mimi.webp
Normal file
BIN
public/img/avatars/mimi.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
BIN
public/img/avatars/xe.jpg
Normal file
BIN
public/img/avatars/xe.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
@ -18,7 +18,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"],
|
||||||
|
"content-collections": ["./.content-collections/generated"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user