Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ jobs:
verify:
name: Lint & build
runs-on: ubuntu-latest
env:
NEXT_PUBLIC_SITE_URL: https://www.nmandiveyi.com
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9.15.9
version: 10.14.0

- uses: actions/setup-node@v4
with:
Expand All @@ -34,8 +36,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck
- name: ESLint
run: pnpm run lint

- name: TypeScript
run: pnpm run typecheck

- name: Build
run: pnpm run build
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ node_modules/
# build
dist/
build/
.next/
out/

# dev caches
.tanstack/
Expand Down
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,45 @@
# Ngonidzashe Mandiveyi — Portfolio

Personal portfolio site built with React 19, Vite, Tailwind CSS v4, TanStack Router, TanStack Query, Zustand, and shadcn/ui.
Personal portfolio site built with Next.js 16, React 19, Tailwind CSS v4, and shadcn/ui.

## Stack

- **React 19** + TypeScript
- **Vite** — dev server and production build
- **Next.js 16** (App Router) + TypeScript
- **Tailwind CSS v4** — styling and design tokens
- **shadcn/ui** — UI primitives (Button)
- **TanStack Router** — type-safe routing
- **TanStack Query** — ready for external API fetching
- **Zustand** — client state (mobile navigation)
- **React Hook Form + Zod** — ready for future forms

## Getting started

```bash
pnpm install
export NEXT_PUBLIC_SITE_URL=http://localhost:3001
pnpm dev
```

Open [http://localhost:5173](http://localhost:5173).
Open [http://localhost:3001](http://localhost:3001).

## Scripts

| Command | Description |
|---------|-------------|
| `pnpm dev` | Start development server |
| `pnpm build` | Type-check and build for production |
| `pnpm preview` | Preview production build locally |
| `pnpm lint` | Run TypeScript checks |
| `pnpm dev` | Start development server on port 3001 |
| `pnpm build` | Production build |
| `pnpm start` | Run production server on port 3001 |
| `pnpm lint` | ESLint |
| `pnpm typecheck` | TypeScript check |

## Project structure

```
app/ # Next.js App Router (layout, pages, sitemap)
src/
components/ # Layout, sections, pages, UI
content/ # Article body components
data/ # Typed site content
routes/ # TanStack Router file routes
stores/ # Zustand stores
styles/ # Theme and fonts
styles/ # Theme tokens
infra/ # Terraform (DigitalOcean App Platform + Cloudflare)
```

## Adding content
Expand All @@ -50,4 +49,8 @@ src/

## Deployment

Build output goes to `dist/`. Configure your static host with SPA fallback so client-side routes (e.g. `/article/...`) resolve correctly.
- **App:** DigitalOcean App Platform runs `pnpm run build` then `pnpm start` (port 3001) on push to `main`
- **Infra:** `terraform apply` from `infra/environments/prod` (local state)
- **CI:** GitHub Actions runs lint, typecheck, and build on PRs to `main`

Set `NEXT_PUBLIC_SITE_URL` to your canonical origin (e.g. `https://www.nmandiveyi.com`) in App Platform and locally for correct metadata/sitemap URLs.
62 changes: 62 additions & 0 deletions app/article/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'

import { ArticlePage } from '@/components/pages/ArticlePage'
import { JsonLd } from '@/components/shared/JsonLd'
import { articles, getArticleBySlug, isArticleSlug } from '@/data/articles'
import { articleJsonLd, canonicalUrl, pageTitle, seo } from '@/lib/seo'

type PageProps = {
params: Promise<{ slug: string }>
}

export function generateStaticParams() {
return articles.map((article) => ({ slug: article.slug }))
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
if (!isArticleSlug(slug)) {
return { title: 'Not found' }
}

const article = getArticleBySlug(slug)!
const title = pageTitle(article.title)

return {
title: article.title,
description: article.excerpt,
alternates: {
canonical: `/article/${article.slug}`,
},
openGraph: {
type: 'article',
locale: seo.locale,
siteName: seo.siteName,
title,
description: article.excerpt,
url: canonicalUrl(`/article/${article.slug}`),
},
twitter: {
card: 'summary',
title,
description: article.excerpt,
},
}
}

export default async function Page({ params }: PageProps) {
const { slug } = await params
if (!isArticleSlug(slug)) {
notFound()
}

const article = getArticleBySlug(slug)!

return (
<>
<JsonLd data={articleJsonLd(article)} />
<ArticlePage slug={slug} />
</>
)
}
2 changes: 1 addition & 1 deletion src/index.css → app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300&family=Syne:wght@400;500;600;700;800&display=swap');

@import 'tailwindcss';
@import './styles/theme.css';
@import '../src/styles/theme.css';
50 changes: 50 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Metadata, Viewport } from 'next'

import './globals.css'

import { AppShell } from '@/components/layout/AppShell'
import { JsonLd } from '@/components/shared/JsonLd'
import { getSiteUrl, personJsonLd, seo, webSiteJsonLd } from '@/lib/seo'

export const metadata: Metadata = {
metadataBase: getSiteUrl(),
title: {
default: seo.defaultTitle,
template: `%s | ${seo.siteName}`,
},
description: seo.defaultDescription,
authors: [{ name: seo.siteName }],
openGraph: {
type: 'website',
locale: seo.locale,
siteName: seo.siteName,
title: seo.defaultTitle,
description: seo.defaultDescription,
},
twitter: {
card: 'summary',
title: seo.defaultTitle,
description: seo.defaultDescription,
},
alternates: {
canonical: '/',
},
}

export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#0d0d0b',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-CA">
<body>
<JsonLd data={personJsonLd()} />
<JsonLd data={webSiteJsonLd()} />
<AppShell>{children}</AppShell>
</body>
</html>
)
}
6 changes: 3 additions & 3 deletions src/components/pages/NotFoundPage.tsx → app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Link } from '@tanstack/react-router'
import Link from 'next/link'

export function NotFoundPage() {
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<p className="mb-4 font-mono text-[11px] tracking-[0.2em] text-[var(--accent2)] uppercase">
404
</p>
<h1 className="mb-6 font-serif text-[42px] text-[var(--text)]">Page not found</h1>
<Link
to="/"
href="/"
className="font-mono text-[11px] tracking-[0.12em] text-[var(--muted)] uppercase no-underline transition-colors hover:text-[var(--accent)]"
>
← Back to site
Expand Down
5 changes: 5 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { HomePage } from '@/components/pages/HomePage'

export default function Page() {
return <HomePage />
}
23 changes: 23 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { MetadataRoute } from 'next'

import { articles } from '@/data/articles'
import { getSiteUrl } from '@/lib/seo'

export default function sitemap(): MetadataRoute.Sitemap {
const base = getSiteUrl().origin

return [
{
url: base,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 1,
},
...articles.map((article) => ({
url: `${base}/article/${article.slug}`,
lastModified: new Date('2024-02-01'),
changeFrequency: 'yearly' as const,
priority: 0.8,
})),
]
}
4 changes: 2 additions & 2 deletions components.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
Expand Down
26 changes: 26 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
/** @type {import("eslint").Linter.Config[]} */
const nextCoreWebVitals = require('eslint-config-next/core-web-vitals')

const eslintConfig = [
{
ignores: [
'**/.next/**',
'**/node_modules/**',
'**/out/**',
'next-env.d.ts',
'pnpm-lock.yaml',
'dist/**',
],
},
...nextCoreWebVitals,
{
rules: {
'react-hooks/set-state-in-effect': 'off',
},
},
]

export default eslintConfig
43 changes: 0 additions & 43 deletions index.html

This file was deleted.

Loading
Loading