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
|
||||
|
||||
# Kubernetes
|
||||
/manifest/
|
||||
/manifest/
|
||||
|
||||
# Content collections
|
||||
.content-collections
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,3 +34,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Content collections
|
||||
.content-collections
|
@ -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} />
|
||||
|
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">
|
||||
<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
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} */
|
||||
|
||||
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
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"content-collections": ["./.content-collections/generated"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user