chore(template): publish Content.Next
Some checks are pending
CI / quality (push) Waiting to run

This commit is contained in:
w4c-templates 2026-10-11 23:29:57 +03:00
parent d60ed9ca39
commit 3caefb8833
100 changed files with 13888 additions and 2 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
node_modules
.next
.git
.env
.env.local
*.db
docker
.github
.w4c
tests
README.md
coverage

15
.env.example Normal file
View file

@ -0,0 +1,15 @@
# --- Core -------------------------------------------------------------------
# SQLite in development; point at Postgres in production (see README).
DATABASE_URL="file:./dev.db"
# Public origin used for absolute URLs (sitemap.xml, rss.xml, OG, checkout redirects).
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# --- Payments (optional) ----------------------------------------------------
# Leave empty to use the built-in mock provider (no keys needed).
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
# --- Email (optional) -------------------------------------------------------
# Leave empty to use the built-in log provider (no keys needed).
RESEND_API_KEY=""
EMAIL_FROM="Acme <noreply@example.com>"

7
.eslintrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off",
"react/no-unescaped-entities": "off"
}
}

33
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
env:
DATABASE_URL: "file:./ci.db"
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run db:setup
- run: npm test
- run: npm run build
- name: e2e smoke
run: |
npm start &
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:3000/api/health >/dev/null; then break; fi
sleep 1
done
npm run test:e2e

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
node_modules
.next
out
dist
coverage
.env
.env.local
*.db
*.db-journal
.vercel
next-env.d.ts

BIN
.w4c/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

43
.w4c/qa.json Normal file
View file

@ -0,0 +1,43 @@
{
"install": "npm ci",
"setup": "npm run db:setup",
"lint": "npm run lint",
"typecheck": "npm run typecheck",
"unit": "npm run test",
"build": "npm run build",
"start": "npm run start",
"e2e": "node tests/e2e/smoke.mjs",
"port": 3000,
"health": "/api/health",
"env": {
"DATABASE_URL": "file:./dev.db",
"NEXT_PUBLIC_SITE_URL": "http://localhost:3000"
},
"routes": [
{ "path": "/", "expect": [200] },
{ "path": "/blog", "expect": [200] },
{ "path": "/blog/launch-checklist", "expect": [200] },
{ "path": "/docs", "expect": [200] },
{ "path": "/docs/installation", "expect": [200] },
{ "path": "/tags", "expect": [200] },
{ "path": "/tags/engineering", "expect": [200] },
{ "path": "/authors/ada-lovelace", "expect": [200] },
{ "path": "/search?q=launch", "expect": [200] },
{ "path": "/rss.xml", "expect": [200] },
{ "path": "/pricing", "expect": [200] },
{ "path": "/login", "expect": [200] },
{ "path": "/register", "expect": [200] },
{ "path": "/legal/privacy", "expect": [200] },
{ "path": "/dashboard", "expect": [307, 302] },
{ "path": "/admin", "expect": [307, 302] },
{ "path": "/admin/posts", "expect": [307, 302] },
{ "path": "/admin/docs", "expect": [307, 302] },
{ "path": "/api/health", "expect": [200] },
{ "path": "/api/auth/me", "expect": [401] },
{ "path": "/api/admin/posts", "expect": [401] },
{ "path": "/api/admin/docs", "expect": [401] },
{ "path": "/sitemap.xml", "expect": [200] },
{ "path": "/robots.txt", "expect": [200] }
],
"timeout": { "install": 900, "build": 900, "boot": 120 }
}

29
.w4c/template.json Normal file
View file

@ -0,0 +1,29 @@
{
"schema": 2,
"system": "v2",
"id": "content-next",
"case": "content",
"stack": "next",
"title": "Content / Blog & Docs — Next.js",
"description": "Ready-to-run content site: markdown posts and docs, taxonomy, search, RSS/sitemap and a role-gated authoring admin.",
"tags": ["content", "blog", "docs", "cms", "markdown", "seo"],
"preview": ".w4c/preview.png",
"demoUrl": "https://content-next.demo.wiz4chat.com",
"agentId": "w4c-start2",
"defaultName": "my-blog",
"featured": true,
"env": [
{ "key": "DATABASE_URL", "required": false, "note": "SQLite by default; Postgres in production" },
{ "key": "NEXT_PUBLIC_SITE_URL", "required": false, "note": "Public origin for sitemap, RSS and OG" },
{ "key": "RESEND_API_KEY", "required": false, "note": "log provider is used when absent" }
],
"modules": [
{ "id": "resend-email", "skill": "integration-resend-email", "default": true },
{ "id": "analytics", "skill": "integration-analytics" }
],
"seededAccounts": [
{ "role": "admin", "email": "admin@example.com", "password": "password" },
{ "role": "editor", "email": "editor@example.com", "password": "password" },
{ "role": "user", "email": "user@example.com", "password": "password" }
]
}

View file

@ -1,3 +1,80 @@
# templates2.Content.Next
# Content / Blog & Docs — Next.js (templates2)
Ready-to-run content site: markdown posts and docs, taxonomy, search, RSS/sitemap and a role-gated authoring admin.
A **ready-to-run** content site: markdown-ish posts and docs with frontmatter-style fields
(title, slug, date, tags, author, draft), a table of contents, code highlighting, taxonomy,
server-side search, RSS/sitemap, JSON-LD and a **role-gated authoring admin** — all working on
the first run.
> Built by forking [`Base.Next`](../Base.Next); it inherits auth, roles, Prisma, mock payments
> and email, the admin shell, SEO, i18n, CI and Docker.
## Quickstart
```bash
npm ci
cp .env.example .env # SQLite + mock providers by default — no keys needed
npm run db:setup # prisma db push + seed
npm run dev # http://localhost:3000
```
Demo accounts (seeded), password `password`:
| Role | Email | Can do |
|---|---|---|
| admin | `admin@example.com` | everything, incl. users & audit |
| editor | `editor@example.com` | author posts and docs |
| user | `user@example.com` | read-only |
## What works out of the box
- **Content** — posts and docs stored in Prisma (authored in the admin, not hard-coded), rendered by
an in-repo markdown renderer with a table of contents, fenced-code highlighting + language labels,
lists, quotes, links and inline formatting.
- **Public site** — paginated `/blog`, post pages with byline + related posts, `/docs` grouped by
section with a sidebar and prev/next, `/tags`, `/authors/[slug]` and a server-side `/search`.
- **Authoring admin** — `/admin/posts` and `/admin/docs`: create/edit/preview/publish/**draft**,
featured posts, tag multi-select, with live preview; server API under `/api/admin/posts` and
`/api/admin/docs` enforces the role and writes an audit entry.
- **Feeds/SEO** — `sitemap.xml` (posts, docs, tags, authors), `rss.xml`, per-post metadata and
JSON-LD `Article`.
- **Core** — auth + roles, mock payments/email, admin shell, `/api/health`, both themes,
responsive to 360 px, WCAG AA contrast.
## Markdown support
Extending the base's minimal renderer (no `mdx`/`remark`/`gray-matter` dependency): `#`–`###`
headings, paragraphs, ordered/unordered lists, blockquotes, `---`, `**bold**`, `*italic*`,
`` `inline code` ``, `[links](/path)`, and fenced code blocks with a language label and lightweight
syntax highlighting.
## Data model
`prisma/schema.prisma`: `Author`, `Tag`, `PostTag`, `Post`, `Doc` (plus the base `User`,
`Session`, `Subscription`, `AuditLog`). SQLite in development; point `DATABASE_URL` at Postgres
for production.
## Scripts
| Script | What it does |
|---|---|
| `npm run dev` | Dev server with HMR |
| `npm run build` / `npm start` | Production build / serve |
| `npm run lint` / `npm run typecheck` | ESLint / `tsc --noEmit` |
| `npm test` | Unit tests (Vitest) |
| `npm run test:e2e` | HTTP end-to-end smoke (server must be running) |
| `npm run db:setup` | `prisma db push` + seed |
## Deployment
```bash
docker compose -f docker/docker-compose.yml up --build
```
## Quality
```bash
node ../templates2/qa.mjs Content.Next # from the repo root: node templates2/qa.mjs Content.Next
```
The harness isolates a clean copy, then runs install → verify → test → build → boot → routes →
e2e → screenshot. See [`../CONTRACT.md`](../CONTRACT.md) for the Definition of Done.

View file

@ -0,0 +1,59 @@
import Link from 'next/link';
import { Badge, Card } from '@/components/ui';
import { requireUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { getPlan } from '@/lib/plans';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await requireUser();
const sub = await db.subscription.findUnique({ where: { userId: user.id } });
const plan = getPlan(sub?.plan);
const usedCalls = await db.auditLog.count({ where: { actorId: user.id } });
const usage = [
{ label: 'Seats', used: 1, limit: plan.limits.seats },
{ label: 'API calls', used: usedCalls, limit: plan.limits.apiCalls },
];
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t('dashboard.title')}</h1>
<p className="muted mt-1">{t('dashboard.welcome', { name: user.name || user.email })}</p>
</div>
<Badge tone={plan.id === 'pro' ? 'brand' : 'neutral'}>{plan.name}</Badge>
</header>
<Card title={t('dashboard.plan')} actions={<Link href="/pricing" className="btn-secondary">{t('pricing.choose')}</Link>}>
<p className="muted text-sm">
{plan.name} — ${plan.price} / month
</p>
</Card>
<Card title={t('dashboard.usage')}>
<ul className="space-y-4">
{usage.map((u) => {
const pct = Math.min(100, Math.round((u.used / u.limit) * 100));
return (
<li key={u.label}>
<div className="mb-1 flex justify-between text-sm">
<span>{u.label}</span>
<span className="muted">
{u.used} / {u.limit}
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full" style={{ background: 'var(--border)' }}>
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: 'var(--color-brand-600)' }} />
</div>
</li>
);
})}
</ul>
</Card>
</div>
);
}

12
app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,12 @@
import { SiteHeader } from '@/components/SiteHeader';
import { SiteFooter } from '@/components/SiteFooter';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="page flex-1 py-10">{children}</main>
<SiteFooter />
</div>
);
}

13
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,13 @@
import Link from 'next/link';
import { t } from '@/lib/i18n';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-5 py-12">
<Link href="/" className="mb-8 text-xl font-bold">
{t('brand.name')}
</Link>
<div className="w-full max-w-sm">{children}</div>
</div>
);
}

15
app/(auth)/login/page.tsx Normal file
View file

@ -0,0 +1,15 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { AuthForm } from '@/components/AuthForm';
import { getCurrentUser } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export default async function LoginPage() {
if (await getCurrentUser()) redirect('/dashboard');
return (
<Suspense fallback={<p className="muted text-center">Loading…</p>}>
<AuthForm mode="login" />
</Suspense>
);
}

View file

@ -0,0 +1,15 @@
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { AuthForm } from '@/components/AuthForm';
import { getCurrentUser } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export default async function RegisterPage() {
if (await getCurrentUser()) redirect('/dashboard');
return (
<Suspense fallback={<p className="muted text-center">Loading…</p>}>
<AuthForm mode="register" />
</Suspense>
);
}

View file

@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getAuthorWithPosts } from '@/lib/server/content';
import { PostCard } from '@/components/content/PostCard';
import { EmptyState } from '@/components/ui';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const data = await getAuthorWithPosts(slug);
if (!data) return { title: 'Author not found' };
return { title: `${data.author.name}`, description: data.author.bio ?? `Posts by ${data.author.name}.` };
}
export default async function AuthorPage({ params }: Params) {
const { slug } = await params;
const data = await getAuthorWithPosts(slug);
if (!data) notFound();
return (
<div className="page py-16">
<div className="flex items-center gap-4">
<span
aria-hidden="true"
className="flex h-14 w-14 items-center justify-center rounded-full text-lg font-semibold text-white"
style={{ background: data.author.avatarColor }}
>
{data.author.name.slice(0, 1)}
</span>
<div>
<h1 className="text-3xl font-bold">{t('author.postsBy', { name: data.author.name })}</h1>
{data.author.bio && <p className="muted mt-1 max-w-2xl text-sm">{data.author.bio}</p>}
</div>
</div>
{data.posts.length === 0 ? (
<EmptyState>{t('common.empty')}</EmptyState>
) : (
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,99 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPublishedPost, listRelatedPosts } from '@/lib/server/content';
import { Markdown } from '@/components/content/Markdown';
import { Toc } from '@/components/content/Toc';
import { PostCard } from '@/components/content/PostCard';
import { extractHeadings } from '@/lib/content';
import { formatDate } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const post = await getPublishedPost(slug);
if (!post) return { title: 'Post not found' };
return {
title: post.title,
description: post.excerpt,
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
publishedTime: post.publishedAt?.toISOString(),
modifiedTime: post.updatedAt.toISOString(),
authors: [post.author.name],
tags: post.tags.map(({ tag }) => tag.name),
},
};
}
export default async function PostPage({ params }: Params) {
const { slug } = await params;
const post = await getPublishedPost(slug);
if (!post) notFound();
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
const headings = extractHeadings(post.body);
const related = await listRelatedPosts(post);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt?.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: { '@type': 'Person', name: post.author.name, url: `${base}/authors/${post.author.slug}` },
keywords: post.tags.map(({ tag }) => tag.name).join(', '),
mainEntityOfPage: `${base}/blog/${post.slug}`,
url: `${base}/blog/${post.slug}`,
};
return (
<article className="page py-16">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<div className="mx-auto grid max-w-5xl gap-10 lg:grid-cols-[minmax(0,1fr)_16rem]">
<div className="min-w-0">
<div className="flex flex-wrap gap-2">
{post.tags.map(({ tag }) => (
<Link key={tag.slug} href={`/tags/${tag.slug}`} className="badge bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100">
{tag.name}
</Link>
))}
</div>
<h1 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">{post.title}</h1>
<p className="muted mt-3 text-sm">
{formatDate(post.publishedAt as Date)} · {post.readingMinutes} min read ·{' '}
<Link href={`/authors/${post.author.slug}`} className="font-medium hover:underline">
{post.author.name}
</Link>
</p>
<div className="mt-8">
<Markdown body={post.body} />
</div>
</div>
<aside className="lg:pt-24">
<div className="lg:sticky lg:top-24">
<Toc headings={headings} />
</div>
</aside>
</div>
{related.length > 0 && (
<section className="mx-auto mt-16 max-w-5xl">
<h2 className="mb-6 text-xl font-semibold">{t('blog.related')}</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{related.map((item) => (
<PostCard key={item.id} post={item} />
))}
</div>
</section>
)}
</article>
);
}

View file

@ -0,0 +1,49 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { listPublishedPosts } from '@/lib/server/content';
import { PostCard } from '@/components/content/PostCard';
import { PagePagination } from '@/components/content/PagePagination';
import { EmptyState } from '@/components/ui';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: t('blog.title'), description: t('blog.subtitle') };
export default async function BlogPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const { items, pages, page: current } = await listPublishedPosts({ page });
return (
<div className="page py-16">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">{t('blog.title')}</h1>
<p className="muted mt-3">{t('blog.subtitle')}</p>
</div>
<div className="flex gap-2">
<Link href="/tags" className="btn-secondary">
{t('nav.tags')}
</Link>
<a href="/rss.xml" className="btn-secondary">
{t('blog.rss')}
</a>
</div>
</header>
{items.length === 0 ? (
<EmptyState>{t('blog.empty')}</EmptyState>
) : (
<>
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<PagePagination page={current} pages={pages} basePath="/blog" />
</>
)}
</div>
);
}

View file

@ -0,0 +1,93 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { docNeighbors, getPublishedDoc, groupDocsBySection, listPublishedDocs } from '@/lib/server/content';
import { Markdown } from '@/components/content/Markdown';
import { Toc } from '@/components/content/Toc';
import { extractHeadings } from '@/lib/content';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const doc = await getPublishedDoc(slug);
if (!doc) return { title: 'Doc not found' };
return { title: `${doc.title} · ${t('docs.title')}`, description: `${doc.title} — ${doc.section}` };
}
export default async function DocPage({ params }: Params) {
const { slug } = await params;
const doc = await getPublishedDoc(slug);
if (!doc) notFound();
const allDocs = await listPublishedDocs();
const groups = groupDocsBySection(allDocs);
const { prev, next } = docNeighbors(doc, allDocs);
const headings = extractHeadings(doc.body);
return (
<div className="page py-16">
<div className="grid gap-10 lg:grid-cols-[15rem_minmax(0,1fr)_14rem]">
<aside aria-label="Documentation">
<Link href="/docs" className="text-sm font-semibold">
{t('docs.title')}
</Link>
<nav className="mt-4 space-y-4 text-sm">
{groups.map((group) => (
<div key={group.section}>
<p className="mb-1 font-medium">{group.section}</p>
<ul className="space-y-1 border-l pl-3" style={{ borderColor: 'var(--border)' }}>
{group.items.map((item) => (
<li key={item.id}>
<Link
href={`/docs/${item.slug}`}
className={item.slug === doc.slug ? 'font-medium' : 'muted hover:opacity-80'}
aria-current={item.slug === doc.slug ? 'page' : undefined}
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
<article className="min-w-0">
<p className="muted text-xs uppercase tracking-wide">{doc.section}</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight">{doc.title}</h1>
<div className="mt-8">
<Markdown body={doc.body} />
</div>
<nav className="mt-12 flex items-center justify-between gap-4 text-sm" aria-label="Doc navigation">
{prev ? (
<Link href={`/docs/${prev.slug}`} className="btn-secondary">
← {prev.title}
</Link>
) : (
<span />
)}
{next ? (
<Link href={`/docs/${next.slug}`} className="btn-secondary">
{next.title} →
</Link>
) : (
<span />
)}
</nav>
</article>
<aside className="hidden lg:block">
<div className="sticky top-24">
<Toc headings={headings} />
</div>
</aside>
</div>
</div>
);
}

View file

@ -0,0 +1,42 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { groupDocsBySection, listPublishedDocs } from '@/lib/server/content';
import { EmptyState } from '@/components/ui';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: t('docs.title'), description: t('docs.subtitle') };
export default async function DocsPage() {
const docs = await listPublishedDocs();
const groups = groupDocsBySection(docs);
return (
<div className="page py-16">
<h1 className="text-3xl font-bold">{t('docs.title')}</h1>
<p className="muted mt-3">{t('docs.subtitle')}</p>
{groups.length === 0 ? (
<EmptyState>{t('docs.empty')}</EmptyState>
) : (
<div className="mt-10 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{groups.map((group) => (
<section key={group.section}>
<h2 className="text-lg font-semibold">{group.section}</h2>
<ul className="mt-3 space-y-2 text-sm">
{group.items.map((doc) => (
<li key={doc.id}>
<Link href={`/docs/${doc.slug}`} className="muted hover:opacity-80">
{doc.title}
</Link>
</li>
))}
</ul>
</section>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,12 @@
import { SiteHeader } from '@/components/SiteHeader';
import { SiteFooter } from '@/components/SiteFooter';
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
</div>
);
}

View file

@ -0,0 +1,10 @@
export default function PrivacyPage() {
return (
<article className="page prose max-w-3xl py-16">
<h1 className="text-3xl font-bold">Privacy Policy</h1>
<p className="muted mt-4">
This is a placeholder legal page. Replace it with your own policy before going live.
</p>
</article>
);
}

View file

@ -0,0 +1,10 @@
export default function TermsPage() {
return (
<article className="page prose max-w-3xl py-16">
<h1 className="text-3xl font-bold">Terms of Service</h1>
<p className="muted mt-4">
This is a placeholder legal page. Replace it with your own terms before going live.
</p>
</article>
);
}

82
app/(marketing)/page.tsx Normal file
View file

@ -0,0 +1,82 @@
import Link from 'next/link';
import { latestAndFeatured } from '@/lib/server/content';
import { PostCard } from '@/components/content/PostCard';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function HomePage() {
const { featured, latest } = await latestAndFeatured(3);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: t('brand.name'),
url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
};
const highlights = featured.length > 0 ? featured : latest;
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<section className="page py-20 text-center">
<h1 className="mx-auto max-w-3xl text-4xl font-extrabold tracking-tight sm:text-5xl">{t('home.contentTitle')}</h1>
<p className="muted mx-auto mt-5 max-w-2xl text-lg">{t('home.contentSubtitle')}</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link href="/blog" className="btn-primary">
{t('home.readBlog')}
</Link>
<Link href="/docs" className="btn-secondary">
{t('nav.docs')}
</Link>
<Link href="/search" className="btn-secondary">
{t('nav.search')}
</Link>
</div>
</section>
{highlights.length > 0 && (
<section className="page pb-16">
<div className="mb-8 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">{featured.length > 0 ? t('home.featured') : t('home.latest')}</h2>
<Link href="/blog" className="muted text-sm hover:opacity-80">
{t('blog.title')} →
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{highlights.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
)}
{featured.length > 0 && latest.length > 0 && (
<section className="page pb-24">
<div className="mb-8 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">{t('home.latest')}</h2>
<Link href="/blog" className="muted text-sm hover:opacity-80">
{t('blog.title')} →
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{latest.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
)}
<section className="page pb-24">
<div className="card flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h2 className="text-xl font-semibold">{t('home.docsTitle')}</h2>
<p className="muted mt-1 text-sm">{t('home.docsSubtitle')}</p>
</div>
<Link href="/docs" className="btn-primary">
{t('nav.docs')}
</Link>
</div>
</section>
</>
);
}

View file

@ -0,0 +1,74 @@
import Link from 'next/link';
import { Badge } from '@/components/ui';
import { UpgradeButton } from '@/components/UpgradeButton';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { PLANS, PLAN_IDS, getPlan } from '@/lib/plans';
import { formatCurrency } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function PricingPage({
searchParams,
}: {
searchParams: Promise<{ billing?: string }>;
}) {
const { billing } = await searchParams;
const user = await getCurrentUser();
const sub = user ? await db.subscription.findUnique({ where: { userId: user.id } }) : null;
const currentPlan = getPlan(sub?.plan).id;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: t('brand.name'),
applicationCategory: 'BusinessApplication',
offers: PLAN_IDS.map((id) => ({ '@type': 'Offer', name: PLANS[id].name, price: PLANS[id].price, priceCurrency: 'USD' })),
};
return (
<div className="page py-16">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<h1 className="text-center text-3xl font-bold">{t('pricing.title')}</h1>
<p className="muted mt-3 text-center">{t('pricing.subtitle')}</p>
{billing === 'success' && (
<p className="mx-auto mt-6 max-w-md rounded-lg border border-emerald-500 bg-emerald-50 px-4 py-3 text-center text-sm text-emerald-800">
{t('pricing.upgraded')}
</p>
)}
<div className="mx-auto mt-10 grid max-w-4xl gap-6 sm:grid-cols-2">
{PLAN_IDS.map((id) => {
const plan = PLANS[id];
const isCurrent = user && currentPlan === id;
return (
<div key={id} className="card flex flex-col">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{plan.name}</h2>
{isCurrent && <Badge tone="brand">Current</Badge>}
</div>
<p className="mt-4 text-3xl font-bold">
{formatCurrency(plan.price)}
<span className="muted text-base font-normal"> {t('pricing.perMonth')}</span>
</p>
<ul className="muted mt-5 flex-1 space-y-2 text-sm">
{plan.features.map((f) => (
<li key={f}>• {f}</li>
))}
</ul>
{isCurrent ? (
<p className="mt-6 text-center text-sm font-medium text-emerald-600">{t('pricing.current')}</p>
) : user ? (
<UpgradeButton plan={id} label={t('pricing.choose')} />
) : (
<Link href={`/login?next=/pricing`} className="btn-primary mt-6 w-full">
{t('pricing.choose')}
</Link>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import type { Metadata } from 'next';
import { searchPosts } from '@/lib/server/content';
import { PostCard } from '@/components/content/PostCard';
import { EmptyState } from '@/components/ui';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: t('search.title') };
export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q } = await searchParams;
const query = (q ?? '').trim();
const results = query ? await searchPosts(query) : [];
return (
<div className="page py-16">
<h1 className="text-3xl font-bold">{t('search.title')}</h1>
<form action="/search" method="get" className="mt-6 flex max-w-xl gap-2">
<label className="sr-only" htmlFor="q">
{t('search.title')}
</label>
<input id="q" name="q" type="search" defaultValue={query} className="input" placeholder={t('search.placeholder')} />
<button type="submit" className="btn-primary">
{t('search.submit')}
</button>
</form>
{!query ? (
<p className="muted mt-8 text-sm">{t('search.hint')}</p>
) : results.length === 0 ? (
<EmptyState>{t('search.empty', { query })}</EmptyState>
) : (
<>
<p className="muted mt-8 text-sm">{t('search.resultsFor', { count: results.length, query })}</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{results.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getTagWithPosts } from '@/lib/server/content';
import { PostCard } from '@/components/content/PostCard';
import { EmptyState } from '@/components/ui';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
type Params = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const data = await getTagWithPosts(slug);
if (!data) return { title: 'Tag not found' };
return { title: `${data.tag.name} · ${t('tags.title')}`, description: `Posts tagged ${data.tag.name}.` };
}
export default async function TagPage({ params }: Params) {
const { slug } = await params;
const data = await getTagWithPosts(slug);
if (!data) notFound();
return (
<div className="page py-16">
<p className="muted text-sm uppercase tracking-wide">{t('tags.title')}</p>
<h1 className="mt-2 text-3xl font-bold">{data.tag.name}</h1>
<p className="muted mt-3">{t('tags.count', { count: data.posts.length })}</p>
{data.posts.length === 0 ? (
<EmptyState>{t('common.empty')}</EmptyState>
) : (
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { listTags } from '@/lib/server/content';
import { EmptyState } from '@/components/ui';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = { title: t('tags.title') };
export default async function TagsPage() {
const tags = await listTags();
return (
<div className="page py-16">
<h1 className="text-3xl font-bold">{t('tags.title')}</h1>
<p className="muted mt-3">{t('tags.subtitle')}</p>
{tags.length === 0 ? (
<EmptyState>{t('common.empty')}</EmptyState>
) : (
<ul className="mt-10 flex flex-wrap gap-3">
{tags.map((tag) => (
<li key={tag.id}>
<Link href={`/tags/${tag.slug}`} className="badge bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100">
{tag.name} · {t('tags.count', { count: tag.count })}
</Link>
</li>
))}
</ul>
)}
</div>
);
}

39
app/admin/audit/page.tsx Normal file
View file

@ -0,0 +1,39 @@
import { EmptyState } from '@/components/ui';
import { requireAdmin } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { formatDate } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminAuditPage() {
await requireAdmin();
const entries = await db.auditLog.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
include: { actor: true },
});
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('admin.audit')}</h1>
{entries.length === 0 ? (
<EmptyState />
) : (
<ul className="divide-y rounded-xl border" style={{ borderColor: 'var(--border)' }}>
{entries.map((e) => (
<li key={e.id} className="flex items-center justify-between gap-4 px-4 py-3 text-sm">
<span>
<span className="font-medium">{e.action}</span>
{e.target ? <span className="muted"> → {e.target}</span> : null}
</span>
<span className="muted shrink-0 text-xs">
{e.actor?.email ?? 'system'} · {formatDate(e.createdAt)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

12
app/admin/docs/page.tsx Normal file
View file

@ -0,0 +1,12 @@
import { DocsManager } from '@/components/admin/DocsManager';
import { requireContentStaff } from '@/lib/server/auth';
import { listDocsAdmin } from '@/lib/server/content';
export const dynamic = 'force-dynamic';
export default async function AdminDocsPage() {
await requireContentStaff();
const docs = await listDocsAdmin();
const sectionOptions = [...new Set(docs.map((doc) => doc.section))].sort();
return <DocsManager sectionOptions={sectionOptions.length ? sectionOptions : ['Getting started', 'Guides', 'Reference']} />;
}

31
app/admin/layout.tsx Normal file
View file

@ -0,0 +1,31 @@
import Link from 'next/link';
import { AdminNav } from '@/components/AdminNav';
import { requireContentStaff } from '@/lib/server/auth';
import { roleLabel } from '@/lib/rbac';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const staff = await requireContentStaff();
return (
<div className="flex min-h-screen flex-col">
<header className="border-b" style={{ borderColor: 'var(--border)' }}>
<div className="page flex h-16 items-center justify-between">
<Link href="/" className="text-lg font-bold">
{t('brand.name')} <span className="muted font-normal">/ {t('admin.title')}</span>
</Link>
<span className="muted text-sm">
{staff.email} · {roleLabel(staff.role)}
</span>
</div>
</header>
<div className="page flex flex-1 flex-col gap-6 py-8 md:flex-row">
<aside className="md:w-52 md:shrink-0">
<AdminNav role={staff.role} />
</aside>
<main className="min-w-0 flex-1">{children}</main>
</div>
</div>
);
}

53
app/admin/page.tsx Normal file
View file

@ -0,0 +1,53 @@
import Link from 'next/link';
import { Card } from '@/components/ui';
import { requireContentStaff } from '@/lib/server/auth';
import { contentOverview } from '@/lib/server/content';
import { formatDate } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminOverviewPage() {
await requireContentStaff();
const data = await contentOverview();
const stats = [
{ label: t('admin.publishedPosts'), value: data.published, href: '/admin/posts?status=published' },
{ label: t('admin.drafts'), value: data.drafts, href: '/admin/posts?status=draft' },
{ label: t('admin.docsTotal'), value: data.docs, href: '/admin/docs' },
{ label: t('admin.tagsTotal'), value: data.tags, href: '/tags' },
];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('admin.overview')}</h1>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Link key={s.label} href={s.href} className="card block">
<p className="muted text-sm">{s.label}</p>
<p className="mt-2 text-3xl font-bold">{s.value}</p>
</Link>
))}
</div>
<Card title={t('admin.recentActivity')} actions={<Link href="/admin/audit" className="btn-secondary">{t('admin.audit')}</Link>}>
{data.recent.length === 0 ? (
<p className="muted text-sm">{t('common.empty')}</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--border)' }}>
{data.recent.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-4 py-2 text-sm">
<span>
<span className="font-medium">{entry.action}</span>
{entry.target ? <span className="muted"> → {entry.target}</span> : null}
</span>
<span className="muted shrink-0 text-xs">
{entry.actor?.email ?? 'system'} · {formatDate(entry.createdAt)}
</span>
</li>
))}
</ul>
)}
</Card>
</div>
);
}

16
app/admin/posts/page.tsx Normal file
View file

@ -0,0 +1,16 @@
import { PostsManager } from '@/components/admin/PostsManager';
import { requireContentStaff } from '@/lib/server/auth';
import { listAuthors, listTags } from '@/lib/server/content';
export const dynamic = 'force-dynamic';
export default async function AdminPostsPage() {
await requireContentStaff();
const [authors, tags] = await Promise.all([listAuthors(), listTags()]);
return (
<PostsManager
authors={authors.map((a) => ({ id: a.id, name: a.name }))}
tagOptions={tags.map((tag) => tag.name)}
/>
);
}

51
app/admin/users/page.tsx Normal file
View file

@ -0,0 +1,51 @@
import { Badge, EmptyState } from '@/components/ui';
import { UserActions } from '@/components/admin/UserActions';
import { requireAdmin } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { formatDate } from '@/lib/utils';
import { t } from '@/lib/i18n';
export const dynamic = 'force-dynamic';
export default async function AdminUsersPage() {
await requireAdmin();
const users = await db.user.findMany({ orderBy: { createdAt: 'asc' }, include: { subscription: true } });
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('admin.users')}</h1>
{users.length === 0 ? (
<EmptyState />
) : (
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
<table className="w-full text-sm">
<thead className="muted text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Role</th>
<th className="px-4 py-3">Plan</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">{u.email}</td>
<td className="px-4 py-3">
<Badge tone={u.role === 'admin' ? 'brand' : 'neutral'}>{u.role}</Badge>
</td>
<td className="px-4 py-3">{u.subscription?.plan ?? 'free'}</td>
<td className="px-4 py-3">{formatDate(u.createdAt)}</td>
<td className="px-4 py-3">
<UserActions id={u.id} role={u.role} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { apiWrite } from '@/lib/server/guard';
import { deleteDoc, docInputSchema, getDocAdmin, updateDoc } from '@/lib/server/content';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Ctx) {
const g = await apiWrite();
if (g.error) return g.error;
const { id } = await params;
const doc = await getDocAdmin(id);
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ doc });
}
export async function PATCH(request: Request, { params }: Ctx) {
const g = await apiWrite();
if (g.error) return g.error;
const { id } = await params;
const parsed = docInputSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid doc', issues: parsed.error.flatten() }, { status: 400 });
const result = await updateDoc(id, parsed.data, g.user);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.error === 'Not found' ? 404 : 400 });
return NextResponse.json({ doc: result.doc });
}
export async function DELETE(_request: Request, { params }: Ctx) {
const g = await apiWrite();
if (g.error) return g.error;
const { id } = await params;
const result = await deleteDoc(id, g.user);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.error === 'Not found' ? 404 : 403 });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { apiWrite } from '@/lib/server/guard';
import { createDoc, docInputSchema, listDocsAdmin } from '@/lib/server/content';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const g = await apiWrite();
if (g.error) return g.error;
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || undefined;
const docs = await listDocsAdmin(status);
return NextResponse.json({ docs });
}
export async function POST(request: Request) {
const g = await apiWrite();
if (g.error) return g.error;
const parsed = docInputSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid doc', issues: parsed.error.flatten() }, { status: 400 });
const result = await createDoc(parsed.data, g.user);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
return NextResponse.json({ doc: result.doc }, { status: 201 });
}

View file

@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { apiWrite } from '@/lib/server/guard';
import { deletePost, getPostAdmin, postInputSchema, updatePost } from '@/lib/server/content';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Ctx) {
const g = await apiWrite();
if (g.error) return g.error;
const { id } = await params;
const post = await getPostAdmin(id);
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 });
return NextResponse.json({ post });
}
export async function PATCH(request: Request, { params }: Ctx) {
const g = await apiWrite();
if (g.error) return g.error;
const { id } = await params;
const parsed = postInputSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid post', issues: parsed.error.flatten() }, { status: 400 });
const result = await updatePost(id, parsed.data, g.user);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.error === 'Not found' ? 404 : 400 });
return NextResponse.json({ post: result.post });
}
export async function DELETE(_request: Request, { params }: Ctx) {
const g = await apiWrite();
if (g.error) return g.error;
const { id } = await params;
const result = await deletePost(id, g.user);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.error === 'Not found' ? 404 : 403 });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { apiWrite } from '@/lib/server/guard';
import { createPost, listPostsAdmin, postInputSchema } from '@/lib/server/content';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const g = await apiWrite();
if (g.error) return g.error;
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || undefined;
const posts = await listPostsAdmin(status);
return NextResponse.json({ posts });
}
export async function POST(request: Request) {
const g = await apiWrite();
if (g.error) return g.error;
const parsed = postInputSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid post', issues: parsed.error.flatten() }, { status: 400 });
const result = await createPost(parsed.data, g.user);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
return NextResponse.json({ post: result.post }, { status: 201 });
}

View file

@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
const patchSchema = z.object({ role: z.enum(['user', 'admin']) });
async function requireAdminApi() {
const user = await getCurrentUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (user.role !== 'admin') return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
return { user };
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const { id } = await params;
const parsed = patchSchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid role' }, { status: 400 });
if (id === auth.user!.id && parsed.data.role !== 'admin') {
return NextResponse.json({ error: 'You cannot remove your own admin role.' }, { status: 400 });
}
const updated = await db.user.update({ where: { id }, data: { role: parsed.data.role } });
await recordAudit({ actorId: auth.user!.id, action: 'user.role', target: updated.email, meta: { role: parsed.data.role } });
return NextResponse.json({ user: { id: updated.id, email: updated.email, role: updated.role } });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = await requireAdminApi();
if (auth.error) return auth.error;
const { id } = await params;
if (id === auth.user!.id) return NextResponse.json({ error: 'You cannot delete your own account.' }, { status: 400 });
const deleted = await db.user.delete({ where: { id } });
await recordAudit({ actorId: auth.user!.id, action: 'user.delete', target: deleted.email });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
if (user.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const users = await db.user.findMany({
orderBy: { createdAt: 'asc' },
select: { id: true, email: true, name: true, role: true, createdAt: true, subscription: { select: { plan: true, status: true } } },
});
return NextResponse.json({ users });
}

View file

@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/server/db';
import { createSession, verifyPassword } from '@/lib/server/auth';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
export async function POST(request: Request) {
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
const email = parsed.data.email.toLowerCase();
const user = await db.user.findUnique({ where: { email } });
// Constant-ish response: never reveal whether the email exists.
if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 });
}
await db.subscription.upsert({
where: { userId: user.id },
update: {},
create: { userId: user.id, plan: 'free', status: 'active', provider: 'mock' },
});
await createSession(user.id);
await recordAudit({ actorId: user.id, action: 'user.login', target: user.email });
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } });
}

View file

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export async function POST() {
await destroySession();
// 303 so a plain HTML form POST turns into a GET of the home page.
return NextResponse.redirect(new URL('/', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), 303);
}

10
app/api/auth/me/route.ts Normal file
View file

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ user: null }, { status: 401 });
return NextResponse.json({ user });
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db } from '@/lib/server/db';
import { createSession, hashPassword } from '@/lib/server/auth';
import { recordAudit } from '@/lib/server/audit';
import { sendEmail } from '@/lib/server/email';
export const dynamic = 'force-dynamic';
const schema = z.object({
email: z.string().email().max(200),
password: z.string().min(8).max(200),
name: z.string().max(120).optional(),
});
export async function POST(request: Request) {
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email or password (min 8 characters).' }, { status: 400 });
}
const email = parsed.data.email.toLowerCase();
const existing = await db.user.findUnique({ where: { email } });
if (existing) return NextResponse.json({ error: 'An account with this email already exists.' }, { status: 409 });
const user = await db.user.create({
data: {
email,
name: parsed.data.name || null,
role: 'user',
passwordHash: await hashPassword(parsed.data.password),
subscription: { create: { plan: 'free', status: 'active', provider: 'mock' } },
},
});
await recordAudit({ actorId: user.id, action: 'user.register', target: user.email });
await sendEmail({
to: user.email,
subject: 'Welcome',
html: '<p>Your account is ready. Sign in to get started.</p>',
}).catch((e) => console.error('[email] register', e));
await createSession(user.id);
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } }, { status: 201 });
}

View file

@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { getCurrentUser } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { getPayments } from '@/lib/server/payments';
import { recordAudit } from '@/lib/server/audit';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic';
const schema = z.object({ plan: z.enum(['free', 'pro']) });
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
const provider = getPayments();
const siteUrl = env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, '');
const result = await provider.createCheckout({
userId: user.id,
email: user.email,
plan: parsed.data.plan,
successUrl: `${siteUrl}/dashboard`,
cancelUrl: `${siteUrl}/pricing`,
});
// The mock provider has no external redirect: activate the subscription right away so the
// full "plan → checkout → return → active" flow works with zero configuration.
if (provider.name === 'mock') {
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.subscription.upsert({
where: { userId: user.id },
update: { plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
create: { userId: user.id, plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
});
}
await recordAudit({ actorId: user.id, action: 'billing.checkout', target: parsed.data.plan, meta: { provider: provider.name } });
return NextResponse.json({ id: result.id, url: result.url, provider: provider.name });
}

View file

@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/server/db';
import { getPayments } from '@/lib/server/payments';
import { recordAudit } from '@/lib/server/audit';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get('stripe-signature');
const provider = getPayments();
let event;
try {
event = await provider.parseWebhook(raw, signature);
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : 'Bad webhook' }, { status: 400 });
}
if (event.type === 'checkout.completed' && event.userId) {
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.subscription.upsert({
where: { userId: event.userId },
update: { plan: event.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
create: { userId: event.userId, plan: event.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
});
await recordAudit({ actorId: event.userId, action: 'billing.activated', target: event.plan ?? 'pro' });
} else if (event.type === 'subscription.canceled' && event.userId) {
await db.subscription.update({ where: { userId: event.userId }, data: { status: 'canceled' } }).catch(() => undefined);
await recordAudit({ actorId: event.userId, action: 'billing.canceled', target: event.userId });
}
// Idempotent: re-delivered events simply re-apply the same upsert.
return NextResponse.json({ received: true });
}

7
app/api/health/route.ts Normal file
View file

@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export function GET() {
return NextResponse.json({ status: 'ok', service: 'templates2-base-next', time: new Date().toISOString() });
}

13
app/error.tsx Normal file
View file

@ -0,0 +1,13 @@
'use client';
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-5 text-center">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="muted mt-2 text-sm">{error.message || 'An unexpected error occurred.'}</p>
<button type="button" className="btn-primary mt-8" onClick={reset}>
Try again
</button>
</div>
);
}

153
app/globals.css Normal file
View file

@ -0,0 +1,153 @@
@import "tailwindcss";
/* Theme is toggled by a `.dark` class on <html> (see ThemeProvider). */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-200: #c7d2fe;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
:root {
--surface: #ffffff;
--surface-muted: #f8fafc;
--border: #e2e8f0;
--text: #0f172a;
--text-muted: #52607a;
color-scheme: light;
}
.dark {
--surface: #0b1120;
--surface-muted: #111827;
--border: #1f2937;
--text: #e5e7eb;
--text-muted: #94a3b8;
color-scheme: dark;
}
* {
border-color: var(--border);
}
html,
body {
background: var(--surface);
color: var(--text);
}
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
@layer components {
.page {
@apply mx-auto w-full max-w-6xl px-5;
}
.surface {
background: var(--surface);
}
.muted {
color: var(--text-muted);
}
.card {
@apply rounded-2xl border p-6;
background: var(--surface-muted);
border-color: var(--border);
}
/* All button variants share the base rule (Tailwind v4 cannot @apply a custom class). */
.btn,
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-danger {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition;
}
.btn:disabled,
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled {
@apply cursor-not-allowed opacity-60;
}
.btn-primary {
@apply text-white;
background: var(--color-brand-600);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-brand-700);
}
.btn-secondary {
@apply border;
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--surface-muted);
}
.btn-ghost {
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface-muted);
color: var(--text);
}
.btn-danger {
@apply bg-red-600 text-white;
}
.btn-danger:hover:not(:disabled) {
@apply bg-red-700;
}
.label {
@apply mb-1 block text-sm font-medium;
}
.input {
@apply w-full rounded-lg border px-3 py-2 text-sm outline-none;
background: var(--surface);
color: var(--text);
border-color: var(--border);
}
.input:focus {
border-color: var(--color-brand-500);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-500) 25%, transparent);
}
.badge {
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
}
}
/* Visible keyboard focus everywhere (WCAG 2.4.7). */
:focus-visible {
outline: 2px solid var(--color-brand-500);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

31
app/layout.tsx Normal file
View file

@ -0,0 +1,31 @@
import type { Metadata } from 'next';
import './globals.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { default: 'Acme — ready-to-run app', template: '%s · Acme' },
description: 'A production-ready base app: auth, roles, payments, admin, SEO — working on day one.',
openGraph: {
type: 'website',
siteName: 'Acme',
url: '/',
title: 'Acme — ready-to-run app',
description: 'A production-ready base app: auth, roles, payments, admin, SEO.',
},
robots: { index: true, follow: true },
};
const themeScript = `try{var t=localStorage.getItem('theme');var d=t? t==='dark' : matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}

10
app/loading.tsx Normal file
View file

@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex items-center gap-3">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden />
<span className="muted text-sm">Loading…</span>
</div>
</div>
);
}

14
app/not-found.tsx Normal file
View file

@ -0,0 +1,14 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-5 text-center">
<p className="text-6xl font-bold">404</p>
<h1 className="mt-4 text-xl font-semibold">Page not found</h1>
<p className="muted mt-2">The page you are looking for does not exist.</p>
<Link href="/" className="btn-primary mt-8">
Back home
</Link>
</div>
);
}

9
app/robots.ts Normal file
View file

@ -0,0 +1,9 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin', '/dashboard', '/api'] },
sitemap: `${base}/sitemap.xml`,
};
}

43
app/rss.xml/route.ts Normal file
View file

@ -0,0 +1,43 @@
import { db } from '@/lib/server/db';
export const dynamic = 'force-dynamic';
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export async function GET() {
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
let posts: { slug: string; title: string; excerpt: string; publishedAt: Date | null }[] = [];
try {
posts = await db.post.findMany({
where: { status: 'published' },
select: { slug: true, title: true, excerpt: true, publishedAt: true },
orderBy: { publishedAt: 'desc' },
take: 50,
});
} catch {
posts = [];
}
const items = posts
.map(
(p) => ` <item>
<title>${escapeXml(p.title)}</title>
<link>${base}/blog/${p.slug}</link>
<guid>${base}/blog/${p.slug}</guid>
<pubDate>${(p.publishedAt ?? new Date()).toUTCString()}</pubDate>
<description>${escapeXml(p.excerpt)}</description>
</item>`,
)
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Acme Blog</title>
<link>${base}/blog</link>
<description>Notes, guides and product updates.</description>
${items}
</channel></rss>`;
return new Response(xml, { headers: { 'content-type': 'application/rss+xml; charset=utf-8' } });
}

39
app/sitemap.ts Normal file
View file

@ -0,0 +1,39 @@
import type { MetadataRoute } from 'next';
import { db } from '@/lib/server/db';
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
const now = new Date();
const staticPages: MetadataRoute.Sitemap = [
{ url: `${base}/`, lastModified: now, changeFrequency: 'weekly', priority: 1 },
{ url: `${base}/blog`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${base}/docs`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${base}/tags`, lastModified: now, changeFrequency: 'weekly', priority: 0.5 },
{ url: `${base}/search`, lastModified: now, changeFrequency: 'monthly', priority: 0.4 },
{ url: `${base}/pricing`, lastModified: now, changeFrequency: 'monthly', priority: 0.6 },
{ url: `${base}/legal/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/legal/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
];
let dynamicPages: MetadataRoute.Sitemap = [];
try {
const [posts, docs, tags, authors] = await Promise.all([
db.post.findMany({ where: { status: 'published' }, select: { slug: true, updatedAt: true } }),
db.doc.findMany({ where: { status: 'published' }, select: { slug: true, updatedAt: true } }),
db.tag.findMany({ select: { slug: true } }),
db.author.findMany({ select: { slug: true } }),
]);
dynamicPages = [
...posts.map((p) => ({ url: `${base}/blog/${p.slug}`, lastModified: p.updatedAt, changeFrequency: 'monthly' as const, priority: 0.7 })),
...docs.map((d) => ({ url: `${base}/docs/${d.slug}`, lastModified: d.updatedAt, changeFrequency: 'monthly' as const, priority: 0.6 })),
...tags.map((t) => ({ url: `${base}/tags/${t.slug}`, lastModified: now, changeFrequency: 'weekly' as const, priority: 0.4 })),
...authors.map((a) => ({ url: `${base}/authors/${a.slug}`, lastModified: now, changeFrequency: 'weekly' as const, priority: 0.4 })),
];
} catch {
dynamicPages = [];
}
return [...staticPages, ...dynamicPages];
}

29
components/AdminNav.tsx Normal file
View file

@ -0,0 +1,29 @@
import Link from 'next/link';
import { t } from '@/lib/i18n';
type Item = { href: string; label: string; adminOnly?: boolean };
const ITEMS: Item[] = [
{ href: '/admin', label: t('admin.overview'), adminOnly: true },
{ href: '/admin/posts', label: t('admin.posts') },
{ href: '/admin/docs', label: t('admin.docs') },
{ href: '/admin/audit', label: t('admin.audit'), adminOnly: true },
{ href: '/admin/users', label: t('admin.users'), adminOnly: true },
];
export function AdminNav({ role }: { role: string }) {
const items = ITEMS.filter((item) => !item.adminOnly || role === 'admin');
return (
<nav className="flex flex-wrap gap-1 md:flex-col" aria-label="Admin">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="rounded-lg px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10"
>
{item.label}
</Link>
))}
</nav>
);
}

79
components/AuthForm.tsx Normal file
View file

@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Field } from '@/components/ui';
import { t } from '@/lib/i18n';
export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
const router = useRouter();
const params = useSearchParams();
const next = params.get('next') || '/dashboard';
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setBusy(true);
setError(null);
const form = new FormData(e.currentTarget);
const body = Object.fromEntries(form.entries());
try {
const res = await fetch(`/api/auth/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `Request failed (${res.status})`);
}
router.push(next);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Request failed');
setBusy(false);
}
}
const isRegister = mode === 'register';
return (
<div className="card">
<h1 className="text-xl font-semibold">{isRegister ? t('auth.registerTitle') : t('auth.loginTitle')}</h1>
<form className="mt-6 space-y-4" onSubmit={onSubmit} noValidate>
{isRegister && (
<Field label={t('auth.name')} htmlFor="name">
<input id="name" name="name" className="input" autoComplete="name" />
</Field>
)}
<Field label={t('auth.email')} htmlFor="email">
<input id="email" name="email" type="email" required className="input" autoComplete="email" />
</Field>
<Field label={t('auth.password')} htmlFor="password">
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="input"
autoComplete={isRegister ? 'new-password' : 'current-password'}
/>
</Field>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? '…' : isRegister ? t('auth.submitRegister') : t('auth.submitLogin')}
</button>
</form>
<p className="muted mt-5 text-center text-sm">
{isRegister ? t('auth.hasAccount') : t('auth.noAccount')}{' '}
<Link href={isRegister ? '/login' : '/register'} className="font-medium text-brand-600 hover:underline">
{isRegister ? t('auth.submitLogin') : t('auth.submitRegister')}
</Link>
</p>
{!isRegister && <p className="muted mt-3 text-center text-xs">{t('auth.demoHint')}</p>}
</div>
);
}

25
components/SiteFooter.tsx Normal file
View file

@ -0,0 +1,25 @@
import Link from 'next/link';
import { t } from '@/lib/i18n';
export function SiteFooter() {
return (
<footer className="border-t" style={{ borderColor: 'var(--border)' }}>
<div className="page flex flex-col items-center justify-between gap-4 py-8 text-sm sm:flex-row">
<p className="muted">
© {new Date().getFullYear()} {t('brand.name')}
</p>
<nav className="flex gap-5" aria-label="Footer">
<Link href="/pricing" className="muted hover:opacity-80">
{t('nav.pricing')}
</Link>
<Link href="/legal/privacy" className="muted hover:opacity-80">
Privacy
</Link>
<Link href="/legal/terms" className="muted hover:opacity-80">
Terms
</Link>
</nav>
</div>
</footer>
);
}

56
components/SiteHeader.tsx Normal file
View file

@ -0,0 +1,56 @@
import Link from 'next/link';
import { getCurrentUser } from '@/lib/server/auth';
import { canAuthor } from '@/lib/rbac';
import { t } from '@/lib/i18n';
import { ThemeToggle } from './ThemeToggle';
export async function SiteHeader() {
const user = await getCurrentUser();
return (
<header className="border-b" style={{ borderColor: 'var(--border)' }}>
<div className="page flex h-16 items-center justify-between gap-4">
<Link href="/" className="text-lg font-bold tracking-tight">
{t('brand.name')}
</Link>
<nav className="hidden items-center gap-6 text-sm md:flex" aria-label="Main">
<Link href="/blog" className="muted hover:opacity-80">
{t('nav.blog')}
</Link>
<Link href="/docs" className="muted hover:opacity-80">
{t('nav.docs')}
</Link>
<Link href="/tags" className="muted hover:opacity-80">
{t('nav.tags')}
</Link>
<Link href="/search" className="muted hover:opacity-80">
{t('nav.search')}
</Link>
{user && (
<Link href="/dashboard" className="muted hover:opacity-80">
{t('nav.dashboard')}
</Link>
)}
{canAuthor(user?.role) && (
<Link href="/admin" className="muted hover:opacity-80">
{t('nav.admin')}
</Link>
)}
</nav>
<div className="flex items-center gap-2">
<ThemeToggle />
{user ? (
<form action="/api/auth/logout" method="post">
<button type="submit" className="btn-secondary">
{t('nav.logout')}
</button>
</form>
) : (
<Link href="/login" className="btn-primary">
{t('nav.login')}
</Link>
)}
</div>
</div>
</header>
);
}

View file

@ -0,0 +1,41 @@
'use client';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
function apply(theme: Theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
try {
localStorage.setItem('theme', theme);
} catch {
/* ignore */
}
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
const current = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
setTheme(current);
}, []);
function toggle() {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
apply(next);
}
return (
<button
type="button"
className="btn-secondary"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
<span aria-hidden>{theme === 'dark' ? '☾' : '☀'}</span>
<span className="hidden sm:inline">{theme === 'dark' ? 'Dark' : 'Light'}</span>
</button>
);
}

View file

@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function UpgradeButton({ plan, label, disabled }: { plan: string; label: string; disabled?: boolean }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function upgrade() {
setBusy(true);
setError(null);
try {
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
});
if (res.status === 401) {
router.push('/login?next=/pricing');
return;
}
if (!res.ok) throw new Error(`Checkout failed (${res.status})`);
const data = (await res.json()) as { url: string };
router.push(data.url.replace(/^https?:\/\/[^/]+/, ''));
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : 'Checkout failed');
} finally {
setBusy(false);
}
}
return (
<div className="mt-6">
<button type="button" className="btn-primary w-full" onClick={upgrade} disabled={busy || disabled}>
{busy ? 'Working…' : label}
</button>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,317 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@/components/ui';
import { Markdown } from '@/components/content/Markdown';
import { t } from '@/lib/i18n';
import { formatDate, slugify } from '@/lib/utils';
type Doc = {
id: string;
slug: string;
title: string;
section: string;
order: number;
body: string;
status: string;
updatedAt: string;
};
type FormState = {
id: string | null;
title: string;
slug: string;
slugEdited: boolean;
section: string;
order: number;
body: string;
status: 'draft' | 'published';
};
const EMPTY = (section: string): FormState => ({
id: null,
title: '',
slug: '',
slugEdited: false,
section,
order: 1,
body: '',
status: 'published',
});
export function DocsManager({ sectionOptions }: { sectionOptions: string[] }) {
const [items, setItems] = useState<Doc[]>([]);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/docs${status ? `?status=${status}` : ''}`);
if (!res.ok) throw new Error('request failed');
const data = (await res.json()) as { docs: Doc[] };
setItems(data.docs);
} catch {
setError(t('common.error'));
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setFormError(null);
setShowPreview(false);
setForm(EMPTY(sectionOptions[0] ?? 'Getting started'));
}
function openEdit(doc: Doc) {
setFormError(null);
setShowPreview(false);
setForm({
id: doc.id,
title: doc.title,
slug: doc.slug,
slugEdited: true,
section: doc.section,
order: doc.order,
body: doc.body,
status: doc.status === 'draft' ? 'draft' : 'published',
});
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
}
async function save() {
if (!form) return;
setBusy(true);
setFormError(null);
const res = await fetch(form.id ? `/api/admin/docs/${form.id}` : '/api/admin/docs', {
method: form.id ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: form.title,
slug: form.slug,
section: form.section,
order: Number(form.order) || 1,
body: form.body,
status: form.status,
}),
});
setBusy(false);
if (!res.ok) {
setFormError(((await res.json().catch(() => ({}))) as { error?: string }).error || t('common.error'));
return;
}
setForm(null);
await load();
}
async function toggleStatus(doc: Doc) {
setBusy(true);
await fetch(`/api/admin/docs/${doc.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: doc.title,
slug: doc.slug,
section: doc.section,
order: doc.order,
body: doc.body,
status: doc.status === 'published' ? 'draft' : 'published',
}),
});
setBusy(false);
await load();
}
async function remove(doc: Doc) {
if (!window.confirm(`${t('admin.delete')} “${doc.title}”? This cannot be undone.`)) return;
setBusy(true);
await fetch(`/api/admin/docs/${doc.id}`, { method: 'DELETE' });
setBusy(false);
await load();
}
const suggestedSlug = form ? (form.slugEdited ? form.slug : slugify(form.title)) : '';
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t('admin.docs')}</h1>
<p className="muted mt-1 text-sm">{items.length} pages</p>
</div>
<button type="button" className="btn-primary" onClick={openCreate}>
{t('admin.newDoc')}
</button>
</header>
<div className="card flex flex-wrap items-end gap-3">
<label className="text-sm">
{t('admin.status')}
<select className="input mt-1" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">{t('admin.all')}</option>
<option value="draft">{t('admin.draft')}</option>
<option value="published">{t('admin.published')}</option>
</select>
</label>
</div>
{form && (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{form.id ? t('admin.editDoc') : t('admin.newDoc')}</h2>
<button type="button" className="btn-ghost" onClick={() => setShowPreview((v) => !v)}>
{showPreview ? t('admin.editDoc') : t('admin.livePreview')}
</button>
</div>
{showPreview ? (
<div className="rounded-xl border p-4" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-xl font-bold">{form.title || t('admin.untitled')}</h3>
<p className="muted mt-1 text-xs">{t('admin.previewHint')}</p>
<Markdown body={form.body} />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm sm:col-span-2">
{t('admin.title')}
<input
className="input mt-1"
required
value={form.title}
onChange={(e) => {
const title = e.target.value;
setForm((prev) => (prev ? { ...prev, title, slug: prev.slugEdited ? prev.slug : slugify(title) } : prev));
}}
/>
</label>
<label className="text-sm">
{t('admin.slug')}
<input
className="input mt-1"
value={suggestedSlug}
onChange={(e) => setForm((prev) => (prev ? { ...prev, slug: e.target.value, slugEdited: true } : prev))}
/>
</label>
<label className="text-sm">
{t('admin.section')}
<input
className="input mt-1"
list="section-options"
value={form.section}
onChange={(e) => setField('section', e.target.value)}
/>
<datalist id="section-options">
{sectionOptions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</label>
<label className="text-sm">
{t('admin.order')}
<input
className="input mt-1"
type="number"
min={0}
value={form.order}
onChange={(e) => setField('order', Number(e.target.value))}
/>
</label>
<label className="text-sm">
{t('admin.status')}
<select
className="input mt-1"
value={form.status}
onChange={(e) => setField('status', e.target.value as FormState['status'])}
>
<option value="draft">{t('admin.draft')}</option>
<option value="published">{t('admin.published')}</option>
</select>
</label>
<label className="text-sm sm:col-span-2">
{t('admin.body')}
<textarea
className="input mt-1 min-h-64 font-mono"
value={form.body}
onChange={(e) => setField('body', e.target.value)}
/>
</label>
</div>
)}
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className="flex gap-2">
<button type="button" className="btn-primary" disabled={busy} onClick={save}>
{t('admin.save')}
</button>
<button type="button" className="btn-secondary" onClick={() => setForm(null)}>
{t('admin.cancel')}
</button>
</div>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{loading ? (
<p className="muted text-sm">{t('common.loading')}</p>
) : items.length === 0 ? (
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
) : (
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
<table className="w-full text-sm">
<thead className="muted text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">{t('admin.title')}</th>
<th className="px-4 py-3">{t('admin.section')}</th>
<th className="px-4 py-3">{t('admin.order')}</th>
<th className="px-4 py-3">{t('admin.status')}</th>
<th className="px-4 py-3">{t('admin.updated')}</th>
<th className="px-4 py-3 text-right">{t('admin.actions')}</th>
</tr>
</thead>
<tbody>
{items.map((doc) => (
<tr key={doc.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">{doc.title}</td>
<td className="px-4 py-3">{doc.section}</td>
<td className="px-4 py-3">{doc.order}</td>
<td className="px-4 py-3">
<Badge tone={doc.status === 'published' ? 'success' : 'warning'}>
{doc.status === 'published' ? t('admin.published') : t('admin.draft')}
</Badge>
</td>
<td className="px-4 py-3">{formatDate(doc.updatedAt)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button type="button" className="btn-secondary" onClick={() => openEdit(doc)}>
{t('admin.edit')}
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={() => toggleStatus(doc)}>
{doc.status === 'published' ? t('admin.unpublish') : t('admin.publish')}
</button>
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(doc)}>
{t('admin.delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,351 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@/components/ui';
import { Markdown } from '@/components/content/Markdown';
import { t } from '@/lib/i18n';
import { formatDate, slugify } from '@/lib/utils';
type PostTag = { tag: { id: string; name: string; slug: string } };
type Post = {
id: string;
slug: string;
title: string;
excerpt: string;
body: string;
status: string;
featured: boolean;
readingMinutes: number;
publishedAt: string | null;
updatedAt: string;
author: { id: string; name: string };
tags: PostTag[];
};
type AuthorOption = { id: string; name: string };
type FormState = {
id: string | null;
title: string;
slug: string;
slugEdited: boolean;
excerpt: string;
body: string;
status: 'draft' | 'published';
featured: boolean;
authorId: string;
tags: string;
};
const EMPTY = (authorId: string): FormState => ({
id: null,
title: '',
slug: '',
slugEdited: false,
excerpt: '',
body: '',
status: 'draft',
featured: false,
authorId,
tags: '',
});
export function PostsManager({ authors, tagOptions }: { authors: AuthorOption[]; tagOptions: string[] }) {
const [items, setItems] = useState<Post[]>([]);
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/posts${status ? `?status=${status}` : ''}`);
if (!res.ok) throw new Error('request failed');
const data = (await res.json()) as { posts: Post[] };
setItems(data.posts);
} catch {
setError(t('common.error'));
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setFormError(null);
setShowPreview(false);
setForm(EMPTY(authors[0]?.id ?? ''));
}
function openEdit(post: Post) {
setFormError(null);
setShowPreview(false);
setForm({
id: post.id,
title: post.title,
slug: post.slug,
slugEdited: true,
excerpt: post.excerpt,
body: post.body,
status: post.status === 'published' ? 'published' : 'draft',
featured: post.featured,
authorId: post.author.id,
tags: post.tags.map(({ tag }) => tag.name).join(', '),
});
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
}
async function save() {
if (!form) return;
setBusy(true);
setFormError(null);
const payload = {
title: form.title,
slug: form.slug,
excerpt: form.excerpt,
body: form.body,
status: form.status,
featured: form.featured,
authorId: form.authorId,
tags: form.tags
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
const res = await fetch(form.id ? `/api/admin/posts/${form.id}` : '/api/admin/posts', {
method: form.id ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) {
setFormError(((await res.json().catch(() => ({}))) as { error?: string }).error || t('common.error'));
return;
}
setForm(null);
await load();
}
async function toggleStatus(post: Post) {
setBusy(true);
const next = post.status === 'published' ? 'draft' : 'published';
await fetch(`/api/admin/posts/${post.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
body: post.body,
status: next,
featured: post.featured,
authorId: post.author.id,
tags: post.tags.map(({ tag }) => tag.name),
}),
});
setBusy(false);
await load();
}
async function remove(post: Post) {
if (!window.confirm(`${t('admin.delete')} “${post.title}”? This cannot be undone.`)) return;
setBusy(true);
await fetch(`/api/admin/posts/${post.id}`, { method: 'DELETE' });
setBusy(false);
await load();
}
const previewBody = form?.body ?? '';
const suggestedSlug = form ? (form.slugEdited ? form.slug : slugify(form.title)) : '';
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">{t('admin.posts')}</h1>
<p className="muted mt-1 text-sm">{items.length} posts</p>
</div>
<button type="button" className="btn-primary" onClick={openCreate} disabled={authors.length === 0}>
{t('admin.newPost')}
</button>
</header>
<div className="card flex flex-wrap items-end gap-3">
<label className="text-sm">
{t('admin.status')}
<select className="input mt-1" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">{t('admin.all')}</option>
<option value="draft">{t('admin.draft')}</option>
<option value="published">{t('admin.published')}</option>
</select>
</label>
</div>
{form && (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{form.id ? t('admin.editPost') : t('admin.newPost')}</h2>
<button type="button" className="btn-ghost" onClick={() => setShowPreview((v) => !v)}>
{showPreview ? t('admin.editPost') : t('admin.livePreview')}
</button>
</div>
{showPreview ? (
<div className="rounded-xl border p-4" style={{ borderColor: 'var(--border)' }}>
<h3 className="text-xl font-bold">{form.title || t('admin.untitled')}</h3>
<p className="muted mt-1 text-xs">{t('admin.previewHint')}</p>
<Markdown body={previewBody} />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm sm:col-span-2">
{t('admin.title')}
<input
className="input mt-1"
required
value={form.title}
onChange={(e) => {
const title = e.target.value;
setForm((prev) => (prev ? { ...prev, title, slug: prev.slugEdited ? prev.slug : slugify(title) } : prev));
}}
/>
</label>
<label className="text-sm">
{t('admin.slug')}
<input
className="input mt-1"
value={suggestedSlug}
onChange={(e) => setForm((prev) => (prev ? { ...prev, slug: e.target.value, slugEdited: true } : prev))}
/>
</label>
<label className="text-sm">
{t('admin.author')}
<select className="input mt-1" value={form.authorId} onChange={(e) => setField('authorId', e.target.value)}>
{authors.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<label className="text-sm sm:col-span-2">
{t('admin.excerpt')}
<input className="input mt-1" value={form.excerpt} onChange={(e) => setField('excerpt', e.target.value)} />
</label>
<label className="text-sm sm:col-span-2">
{t('admin.tags')}
<input
className="input mt-1"
list="tag-options"
value={form.tags}
onChange={(e) => setField('tags', e.target.value)}
placeholder={tagOptions.join(', ')}
/>
<datalist id="tag-options">
{tagOptions.map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</label>
<label className="text-sm">
{t('admin.status')}
<select
className="input mt-1"
value={form.status}
onChange={(e) => setField('status', e.target.value as FormState['status'])}
>
<option value="draft">{t('admin.draft')}</option>
<option value="published">{t('admin.published')}</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.featured} onChange={(e) => setField('featured', e.target.checked)} />
{t('admin.featured')}
</label>
<label className="text-sm sm:col-span-2">
{t('admin.body')}
<textarea
className="input mt-1 min-h-64 font-mono"
value={form.body}
onChange={(e) => setField('body', e.target.value)}
/>
</label>
</div>
)}
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className="flex gap-2">
<button type="button" className="btn-primary" disabled={busy} onClick={save}>
{t('admin.save')}
</button>
<button type="button" className="btn-secondary" onClick={() => setForm(null)}>
{t('admin.cancel')}
</button>
</div>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
{loading ? (
<p className="muted text-sm">{t('common.loading')}</p>
) : items.length === 0 ? (
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
) : (
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
<table className="w-full text-sm">
<thead className="muted text-left text-xs uppercase">
<tr>
<th className="px-4 py-3">{t('admin.title')}</th>
<th className="px-4 py-3">{t('admin.author')}</th>
<th className="px-4 py-3">{t('admin.tags')}</th>
<th className="px-4 py-3">{t('admin.status')}</th>
<th className="px-4 py-3">{t('admin.updated')}</th>
<th className="px-4 py-3 text-right">{t('admin.actions')}</th>
</tr>
</thead>
<tbody>
{items.map((post) => (
<tr key={post.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
<td className="px-4 py-3 font-medium">
{post.title}
{post.featured && <Badge tone="brand">★</Badge>}
</td>
<td className="px-4 py-3">{post.author.name}</td>
<td className="px-4 py-3">{post.tags.map(({ tag }) => tag.name).join(', ') || t('admin.none')}</td>
<td className="px-4 py-3">
<Badge tone={post.status === 'published' ? 'success' : 'warning'}>
{post.status === 'published' ? t('admin.published') : t('admin.draft')}
</Badge>
</td>
<td className="px-4 py-3">{formatDate(post.updatedAt)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button type="button" className="btn-secondary" onClick={() => openEdit(post)}>
{t('admin.edit')}
</button>
<button type="button" className="btn-secondary" disabled={busy} onClick={() => toggleStatus(post)}>
{post.status === 'published' ? t('admin.unpublish') : t('admin.publish')}
</button>
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(post)}>
{t('admin.delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function UserActions({ id, role }: { id: string; role: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function update(nextRole: string) {
setBusy(true);
await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: nextRole }),
});
setBusy(false);
router.refresh();
}
async function remove() {
if (!window.confirm('Delete this user? This cannot be undone.')) return;
setBusy(true);
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
setBusy(false);
router.refresh();
}
return (
<div className="flex justify-end gap-2">
<button
type="button"
className="btn-secondary"
disabled={busy}
onClick={() => update(role === 'admin' ? 'user' : 'admin')}
>
{role === 'admin' ? 'Make user' : 'Make admin'}
</button>
<button type="button" className="btn-danger" disabled={busy} onClick={remove}>
Delete
</button>
</div>
);
}

View file

@ -0,0 +1,270 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { extractHeadings } from '@/lib/content';
/**
* A tiny in-repo markdown renderer ("MDX-lite") — no markdown/MDX dependency.
* Supports headings (h1–h3), paragraphs, ordered/unordered lists, blockquotes,
* horizontal rules, inline code/bold/italic/links and fenced code blocks with a
* language label and lightweight syntax highlighting.
*/
type Block =
| { type: 'heading'; level: number; text: string }
| { type: 'code'; lang: string; code: string }
| { type: 'list'; ordered: boolean; items: string[] }
| { type: 'quote'; text: string }
| { type: 'hr' }
| { type: 'paragraph'; text: string };
function splitBlocks(body: string): Block[] {
const lines = body.replace(/\r\n/g, '\n').split('\n');
const blocks: Block[] = [];
const para: string[] = [];
const flush = () => {
if (para.length) {
blocks.push({ type: 'paragraph', text: para.join(' ').trim() });
para.length = 0;
}
};
let i = 0;
while (i < lines.length) {
const raw = lines[i];
const trimmed = raw.trim();
if (/^```/.test(trimmed)) {
flush();
const lang = trimmed.slice(3).trim();
const buf: string[] = [];
i += 1;
while (i < lines.length && !/^```/.test(lines[i].trim())) {
buf.push(lines[i]);
i += 1;
}
i += 1;
blocks.push({ type: 'code', lang, code: buf.join('\n') });
continue;
}
const heading = /^(#{1,3})\s+(.+)$/.exec(trimmed);
if (heading) {
flush();
blocks.push({ type: 'heading', level: heading[1].length, text: heading[2].trim() });
i += 1;
continue;
}
if (/^(---|\*\*\*|___)\s*$/.test(trimmed)) {
flush();
blocks.push({ type: 'hr' });
i += 1;
continue;
}
if (/^>\s?/.test(trimmed)) {
flush();
const buf: string[] = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
buf.push(lines[i].trim().replace(/^>\s?/, ''));
i += 1;
}
blocks.push({ type: 'quote', text: buf.join(' ') });
continue;
}
const unordered = /^[-*+]\s+/.test(trimmed);
const ordered = /^\d+[.)]\s+/.test(trimmed);
if (unordered || ordered) {
flush();
const items: string[] = [];
while (i < lines.length) {
const t = lines[i].trim();
const um = /^[-*+]\s+(.*)$/.exec(t);
const om = /^\d+[.)]\s+(.*)$/.exec(t);
const match = ordered ? om : um;
if (!match) break;
items.push(match[1]);
i += 1;
}
blocks.push({ type: 'list', ordered, items });
continue;
}
if (trimmed === '') {
flush();
i += 1;
continue;
}
para.push(trimmed);
i += 1;
}
flush();
return blocks;
}
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g;
function renderInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
let last = 0;
let k = 0;
let m: RegExpExecArray | null;
INLINE_RE.lastIndex = 0;
while ((m = INLINE_RE.exec(text)) !== null) {
if (m.index > last) nodes.push(text.slice(last, m.index));
const token = m[0];
const key = `${keyPrefix}-${k++}`;
if (token.startsWith('`')) {
nodes.push(
<code key={key} className="rounded bg-black/5 px-1.5 py-0.5 font-mono text-[0.85em] dark:bg-white/10">
{token.slice(1, -1)}
</code>,
);
} else if (token.startsWith('**')) {
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith('[')) {
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
if (link && link[2].startsWith('/')) {
nodes.push(
<Link key={key} href={link[2]} className="text-brand-600 underline underline-offset-2 dark:text-brand-400">
{link[1]}
</Link>,
);
} else if (link) {
nodes.push(
<a key={key} href={link[2]} className="text-brand-600 underline underline-offset-2 dark:text-brand-400" rel="noreferrer">
{link[1]}
</a>,
);
} else {
nodes.push(token);
}
} else {
nodes.push(<em key={key}>{token.slice(1, -1)}</em>);
}
last = m.index + token.length;
}
if (last < text.length) nodes.push(text.slice(last));
return nodes;
}
const KEYWORDS = new Set([
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
'class', 'extends', 'implements', 'new', 'this', 'super', 'import', 'from', 'export', 'default', 'await', 'async',
'type', 'interface', 'enum', 'public', 'private', 'protected', 'readonly', 'static', 'void', 'null', 'undefined',
'true', 'false', 'try', 'catch', 'finally', 'throw', 'in', 'of', 'as', 'yield', 'def', 'elif', 'None', 'True', 'False',
'self', 'lambda', 'pass', 'raise', 'with', 'end', 'and', 'or', 'not', 'nil', 'func', 'package', 'struct', 'go', 'defer',
'SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'TABLE', 'JOIN', 'GROUP', 'ORDER', 'BY',
]);
const HASH_LANGS = new Set(['bash', 'sh', 'shell', 'zsh', 'yaml', 'yml', 'python', 'py', 'ruby', 'rb', 'toml', 'ini', 'conf', 'dockerfile', 'make']);
const CODE_HASH = /(#.*|\/\/.*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][A-Za-z0-9_$]*\b)/g;
const CODE_SLASH = /(\/\/.*|\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][A-Za-z0-9_$]*\b)/g;
const TONE = {
comment: 'text-slate-500 italic',
string: 'text-emerald-600 dark:text-emerald-400',
number: 'text-amber-600 dark:text-amber-400',
keyword: 'text-violet-600 dark:text-violet-400',
};
function highlight(code: string, lang: string, keyPrefix: string): ReactNode[] {
const re = HASH_LANGS.has(lang.toLowerCase()) ? CODE_HASH : CODE_SLASH;
const nodes: ReactNode[] = [];
let last = 0;
let k = 0;
let m: RegExpExecArray | null;
re.lastIndex = 0;
while ((m = re.exec(code)) !== null) {
if (m.index > last) nodes.push(code.slice(last, m.index));
const key = `${keyPrefix}-${k++}`;
if (m[1]) nodes.push(<span key={key} className={TONE.comment}>{m[1]}</span>);
else if (m[2]) nodes.push(<span key={key} className={TONE.string}>{m[2]}</span>);
else if (m[3]) nodes.push(<span key={key} className={TONE.number}>{m[3]}</span>);
else if (KEYWORDS.has(m[4])) nodes.push(<span key={key} className={TONE.keyword}>{m[4]}</span>);
else nodes.push(m[4]);
last = m.index + m[0].length;
}
if (last < code.length) nodes.push(code.slice(last));
return nodes;
}
export function Markdown({ body }: { body: string }) {
const headings = extractHeadings(body);
const blocks = splitBlocks(body);
let headingCursor = 0;
return (
<div className="content-prose">
{blocks.map((block, i) => {
const key = `b${i}`;
if (block.type === 'code') {
return (
<figure key={key} className="my-6 overflow-hidden rounded-xl border" style={{ borderColor: 'var(--border)' }}>
{block.lang ? (
<figcaption className="muted border-b px-4 py-2 font-mono text-xs uppercase" style={{ borderColor: 'var(--border)', background: 'var(--surface-muted)' }}>
{block.lang}
</figcaption>
) : null}
<pre className="overflow-x-auto px-4 py-3 text-sm leading-relaxed" style={{ background: 'var(--surface-muted)' }}>
<code className="font-mono">{highlight(block.code, block.lang, key)}</code>
</pre>
</figure>
);
}
if (block.type === 'heading') {
if (block.level === 1) {
return (
<h1 key={key} className="mt-10 text-2xl font-bold tracking-tight">
{block.text}
</h1>
);
}
const anchor = headings[headingCursor++]?.id;
return block.level === 2 ? (
<h2 key={key} id={anchor} className="mt-10 scroll-mt-24 text-xl font-semibold tracking-tight">
{block.text}
</h2>
) : (
<h3 key={key} id={anchor} className="mt-8 scroll-mt-24 text-lg font-semibold">
{block.text}
</h3>
);
}
if (block.type === 'list') {
const cls = 'mt-4 list-outside space-y-1 pl-6';
return block.ordered ? (
<ol key={key} className={`${cls} list-decimal`}>
{block.items.map((item, j) => (
<li key={`${key}-${j}`}>{renderInline(item, `${key}-${j}`)}</li>
))}
</ol>
) : (
<ul key={key} className={`${cls} list-disc`}>
{block.items.map((item, j) => (
<li key={`${key}-${j}`}>{renderInline(item, `${key}-${j}`)}</li>
))}
</ul>
);
}
if (block.type === 'quote') {
return (
<blockquote key={key} className="muted my-6 border-l-4 pl-4 italic" style={{ borderColor: 'var(--color-brand-400)' }}>
{renderInline(block.text, key)}
</blockquote>
);
}
if (block.type === 'hr') {
return <hr key={key} className="my-8 border-t" style={{ borderColor: 'var(--border)' }} />;
}
return (
<p key={key} className="muted mt-4 leading-relaxed">
{renderInline(block.text, key)}
</p>
);
})}
</div>
);
}

View file

@ -0,0 +1,29 @@
import Link from 'next/link';
/** Pagination controls for a list route that reads `?page=`. */
export function PagePagination({ page, pages, basePath }: { page: number; pages: number; basePath: string }) {
if (pages <= 1) return null;
const prev = page > 1 ? `${basePath}?page=${page - 1}` : null;
const next = page < pages ? `${basePath}?page=${page + 1}` : null;
return (
<nav className="mt-10 flex items-center justify-between text-sm" aria-label="Pagination">
{prev ? (
<Link href={prev} className="btn-secondary">
Previous
</Link>
) : (
<span />
)}
<span className="muted">
Page {page} of {pages}
</span>
{next ? (
<Link href={next} className="btn-secondary">
Next
</Link>
) : (
<span />
)}
</nav>
);
}

View file

@ -0,0 +1,44 @@
import Link from 'next/link';
import { formatDate } from '@/lib/utils';
export type PostCardPost = {
slug: string;
title: string;
excerpt: string;
publishedAt: Date | null;
readingMinutes: number;
tags: { tag: { name: string; slug: string } }[];
author: { name: string; slug: string };
};
export function PostCard({ post }: { post: PostCardPost }) {
return (
<article className="card flex flex-col">
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{post.tags.map(({ tag }) => (
<Link
key={tag.slug}
href={`/tags/${tag.slug}`}
className="badge bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100"
>
{tag.name}
</Link>
))}
</div>
)}
<h2 className="mt-3 text-lg font-semibold">
<Link href={`/blog/${post.slug}`} className="hover:underline">
{post.title}
</Link>
</h2>
<p className="muted mt-2 flex-1 text-sm">{post.excerpt}</p>
<p className="muted mt-4 text-xs">
{post.publishedAt ? formatDate(post.publishedAt) : 'Draft'} · {post.readingMinutes} min read ·{' '}
<Link href={`/authors/${post.author.slug}`} className="hover:underline">
{post.author.name}
</Link>
</p>
</article>
);
}

View file

@ -0,0 +1,19 @@
import type { Heading } from '@/lib/content';
export function Toc({ headings }: { headings: Heading[] }) {
if (headings.length === 0) return null;
return (
<nav aria-label="Table of contents" className="text-sm">
<p className="mb-2 font-semibold">On this page</p>
<ul className="space-y-1">
{headings.map((heading) => (
<li key={heading.id} className={heading.level === 3 ? 'pl-4' : ''}>
<a href={`#${heading.id}`} className="muted hover:opacity-80">
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}

68
components/ui/index.tsx Normal file
View file

@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
export function Card({ title, children, actions }: { title?: ReactNode; children: ReactNode; actions?: ReactNode }) {
return (
<section className="card">
{(title || actions) && (
<header className="mb-4 flex items-center justify-between gap-4">
{title ? <h2 className="text-lg font-semibold">{title}</h2> : <span />}
{actions}
</header>
)}
{children}
</section>
);
}
const badgeTones: Record<string, string> = {
neutral: 'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100',
success: 'bg-emerald-600 text-white',
warning: 'bg-amber-300 text-black',
danger: 'bg-red-600 text-white',
brand: 'bg-brand-600 text-white',
};
export function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: keyof typeof badgeTones }) {
return <span className={`badge ${badgeTones[tone] ?? badgeTones.neutral}`}>{children}</span>;
}
const alertTones: Record<string, string> = {
info: 'border-brand-400 bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100',
success: 'border-emerald-500 bg-emerald-50 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100',
warning: 'border-amber-400 bg-amber-50 text-black',
error: 'border-red-500 bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-100',
};
export function Alert({ children, tone = 'info' }: { children: ReactNode; tone?: keyof typeof alertTones }) {
return (
<div role="status" className={`rounded-lg border px-4 py-3 text-sm ${alertTones[tone] ?? alertTones.info}`}>
{children}
</div>
);
}
export function Field({
label,
htmlFor,
hint,
children,
}: {
label: string;
htmlFor: string;
hint?: string;
children: ReactNode;
}) {
return (
<div>
<label className="label" htmlFor={htmlFor}>
{label}
</label>
{children}
{hint && <p className="muted mt-1 text-xs">{hint}</p>}
</div>
);
}
export function EmptyState({ children }: { children?: ReactNode }) {
return <p className="muted py-6 text-center text-sm">{children ?? 'Nothing here yet.'}</p>;
}

23
docker/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Build stage
FROM node:22-alpine AS build
WORKDIR /app
RUN apk add --no-cache libc6-compat openssl
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npx prisma generate && npm run build
# Runtime stage
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl && addgroup -S app && adduser -S app -G app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
COPY --from=build /app/prisma ./prisma
USER app
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]

15
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: "file:./prod.db"
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3

61
lib/content.ts Normal file
View file

@ -0,0 +1,61 @@
import { slugify } from './utils';
/**
* Pure content helpers — no DB, no `server-only`, so they are directly unit-testable.
* The same heading algorithm backs both the table of contents and the rendered body,
* which keeps TOC anchors in sync with the headings they point at.
*/
export type Heading = { id: string; text: string; level: number };
const HEADING_RE = /^(#{2,3})\s+(.+?)\s*#*\s*$/;
/** Words-per-minute used for the reading-time estimate. */
const WPM = 200;
export function readingMinutes(body: string): number {
const words = body.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / WPM));
}
export function headingId(text: string): string {
return slugify(text) || 'section';
}
/** Extract h2/h3 headings for a table of contents; ids are de-duplicated in document order. */
export function extractHeadings(body: string): Heading[] {
const out: Heading[] = [];
const seen = new Map<string, number>();
for (const raw of body.split('\n')) {
const m = HEADING_RE.exec(raw.trim());
if (!m) continue;
const level = m[1].length;
const text = m[2].trim();
const base = headingId(text);
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
out.push({ id: n === 0 ? base : `${base}-${n}`, text, level });
}
return out;
}
export function isPublished(status: unknown): boolean {
return status === 'published';
}
/** Keep only published items (a draft must never leak onto a public list). */
export function filterPublished<T extends { status: string }>(items: T[]): T[] {
return items.filter((item) => isPublished(item.status));
}
/** Derive a plain-text excerpt from a markdown body when none is supplied. */
export function excerptFromBody(body: string, max = 180): string {
const text = body
.replace(/```[\s\S]*?```/g, ' ')
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[#>*_`]/g, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
}

20
lib/env.ts Normal file
View file

@ -0,0 +1,20 @@
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().default('file:./dev.db'),
NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
STRIPE_SECRET_KEY: z.string().optional().default(''),
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
RESEND_API_KEY: z.string().optional().default(''),
EMAIL_FROM: z.string().default('Acme <noreply@example.com>'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment configuration');
}
export const env = parsed.data;
export const isProd = process.env.NODE_ENV === 'production';

19
lib/i18n.ts Normal file
View file

@ -0,0 +1,19 @@
import en from '@/messages/en.json';
export const DEFAULT_LOCALE = 'en';
export const messages = { en } as const;
export type Locale = keyof typeof messages;
type Vars = Record<string, string | number>;
/** Tiny dictionary lookup: t('nav.pricing') with optional {name} interpolation. */
export function t(key: string, vars?: Vars, locale: Locale = DEFAULT_LOCALE): string {
const dict = messages[locale] as Record<string, unknown>;
const value = key.split('.').reduce<unknown>((acc, part) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part];
return undefined;
}, dict);
if (typeof value !== 'string') return key;
if (!vars) return value;
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`));
}

10
lib/password.ts Normal file
View file

@ -0,0 +1,10 @@
import bcrypt from 'bcryptjs';
/** Pure password helpers (no server-only import) so they are unit-testable. */
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 10);
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

41
lib/plans.ts Normal file
View file

@ -0,0 +1,41 @@
export type PlanId = 'free' | 'pro';
export type Plan = {
id: PlanId;
name: string;
price: number;
/** Monthly included usage per metric. */
limits: { seats: number; apiCalls: number };
features: string[];
};
export const PLANS: Record<PlanId, Plan> = {
free: {
id: 'free',
name: 'Free',
price: 0,
limits: { seats: 2, apiCalls: 1_000 },
features: ['Up to 2 members', '1,000 API calls / month', 'Community support'],
},
pro: {
id: 'pro',
name: 'Pro',
price: 29,
limits: { seats: 25, apiCalls: 100_000 },
features: ['Up to 25 members', '100,000 API calls / month', 'Priority support'],
},
};
export const PLAN_IDS: PlanId[] = ['free', 'pro'];
export function isPlanId(value: unknown): value is PlanId {
return value === 'free' || value === 'pro';
}
export function getPlan(value: unknown): Plan {
return isPlanId(value) ? PLANS[value] : PLANS.free;
}
export function withinLimit(plan: unknown, metric: keyof Plan['limits'], used: number): boolean {
return used < getPlan(plan).limits[metric];
}

20
lib/rbac.ts Normal file
View file

@ -0,0 +1,20 @@
export type ContentRole = 'admin' | 'editor' | 'user';
export const CONTENT_ROLES: ContentRole[] = ['admin', 'editor', 'user'];
export function isContentRole(value: unknown): value is ContentRole {
return value === 'admin' || value === 'editor' || value === 'user';
}
/** Admins and editors may author content; plain users are read-only. */
export function canAuthor(role: unknown): boolean {
return role === 'admin' || role === 'editor';
}
/** Only admins manage users, roles and the audit log. */
export function isAdmin(role: unknown): boolean {
return role === 'admin';
}
export function roleLabel(role: unknown): string {
return isContentRole(role) ? role : 'user';
}

15
lib/server/audit.ts Normal file
View file

@ -0,0 +1,15 @@
import 'server-only';
import { db } from './db';
export type AuditInput = { actorId?: string | null; action: string; target?: string; meta?: unknown };
export async function recordAudit({ actorId, action, target, meta }: AuditInput): Promise<void> {
await db.auditLog.create({
data: {
actorId: actorId ?? null,
action,
target: target ?? null,
meta: meta === undefined ? null : JSON.stringify(meta),
},
});
}

75
lib/server/auth.ts Normal file
View file

@ -0,0 +1,75 @@
import 'server-only';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { randomBytes } from 'node:crypto';
import { db } from './db';
import { canAuthor } from '@/lib/rbac';
export { hashPassword, verifyPassword } from '@/lib/password';
export const SESSION_COOKIE = 'w4c_session';
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
export type SessionUser = {
id: string;
email: string;
name: string | null;
role: string;
};
/** Create a session row and set the httpOnly cookie (works in Route Handlers and Server Actions). */
export async function createSession(userId: string): Promise<string> {
const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + TTL_MS);
await db.session.create({ data: { token, userId, expiresAt } });
const store = await cookies();
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
expires: expiresAt,
});
return token;
}
export async function destroySession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) await db.session.deleteMany({ where: { token } });
store.delete(SESSION_COOKIE);
}
export async function getCurrentUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await db.session.findUnique({ where: { token }, include: { user: true } });
if (!session || session.expiresAt < new Date()) return null;
const { id, email, name, role } = session.user;
return { id, email, name, role };
}
/** Page guard: redirect to /login when unauthenticated. */
export async function requireUser(): Promise<SessionUser> {
const user = await getCurrentUser();
if (!user) redirect('/login');
return user;
}
/** Page guard: require the admin role. */
export async function requireAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (user.role !== 'admin') redirect('/dashboard');
return user;
}
/**
* Page guard for the content admin shell: admins and editors may enter and author.
* Plain users are redirected to their dashboard.
*/
export async function requireContentStaff(): Promise<SessionUser> {
const user = await requireUser();
if (!canAuthor(user.role)) redirect('/dashboard');
return user;
}

349
lib/server/content.ts Normal file
View file

@ -0,0 +1,349 @@
import 'server-only';
import { z } from 'zod';
import type { Prisma } from '@prisma/client';
import { db } from './db';
import { recordAudit } from './audit';
import { canAuthor } from '@/lib/rbac';
import { excerptFromBody, isPublished, readingMinutes } from '@/lib/content';
import { slugify } from '@/lib/utils';
export const POST_PAGE_SIZE = 6;
const postInclude = { author: true, tags: { include: { tag: true } } } satisfies Prisma.PostInclude;
const postOrder = [{ publishedAt: 'desc' as const }, { createdAt: 'desc' as const }];
export type PostWithRelations = Prisma.PostGetPayload<{ include: typeof postInclude }>;
export type DocRow = { id: string; slug: string; title: string; section: string; order: number; body: string; status: string; updatedAt: Date };
export type TagWithCount = { id: string; name: string; slug: string; count: number };
type Actor = { id: string; role: string };
// --------------------------------------------------------------------------- public reads
export async function listPublishedPosts({ page = 1, pageSize = POST_PAGE_SIZE }: { page?: number; pageSize?: number } = {}) {
const where = { status: 'published' };
const safePage = Math.max(1, Math.floor(page) || 1);
const [items, total] = await Promise.all([
db.post.findMany({
where,
orderBy: postOrder,
skip: (safePage - 1) * pageSize,
take: pageSize,
include: postInclude,
}),
db.post.count({ where }),
]);
return { items, total, page: safePage, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) };
}
export function getPublishedPost(slug: string) {
return db.post.findFirst({ where: { slug, status: 'published' }, include: postInclude });
}
/** Related posts: shared tag or same author, ranked by how many tags they share, then recency. */
export async function listRelatedPosts(post: PostWithRelations, take = 3) {
const tagIds = post.tags.map((pt) => pt.tagId);
const rows = await db.post.findMany({
where: {
status: 'published',
id: { not: post.id },
OR: [{ authorId: post.authorId }, ...(tagIds.length ? [{ tags: { some: { tagId: { in: tagIds } } } }] : [])],
},
include: postInclude,
orderBy: postOrder,
take: take * 3,
});
return rows
.map((row) => {
const shared = row.tags.filter((pt) => tagIds.includes(pt.tagId)).length;
return { row, score: shared * 10 + (row.authorId === post.authorId ? 3 : 0) };
})
.sort((a, b) => b.score - a.score)
.slice(0, take)
.map((x) => x.row);
}
export function listPublishedDocs() {
return db.doc.findMany({ where: { status: 'published' }, orderBy: [{ section: 'asc' }, { order: 'asc' }, { title: 'asc' }] });
}
export function getPublishedDoc(slug: string) {
return db.doc.findFirst({ where: { slug, status: 'published' } });
}
/** Group docs by section, preserving the ordered sections list. */
export function groupDocsBySection(docs: DocRow[]) {
const sections = new Map<string, DocRow[]>();
for (const doc of docs) {
const list = sections.get(doc.section) ?? [];
list.push(doc);
sections.set(doc.section, list);
}
return [...sections.entries()].map(([section, items]) => ({ section, items }));
}
/** Previous/next doc within the same section (ordered by `order`). */
export function docNeighbors(doc: DocRow, docs: DocRow[]) {
const inSection = docs.filter((d) => d.section === doc.section);
const index = inSection.findIndex((d) => d.id === doc.id);
return {
prev: index > 0 ? inSection[index - 1] : null,
next: index >= 0 && index < inSection.length - 1 ? inSection[index + 1] : null,
};
}
/** Server-side, case-insensitive search over title, excerpt and body. */
export async function searchPosts(query: string): Promise<PostWithRelations[]> {
const needle = query.trim().toLowerCase();
if (!needle) return [];
const posts = await db.post.findMany({ where: { status: 'published' }, include: postInclude, orderBy: postOrder });
return posts.filter((post) =>
[post.title, post.excerpt, post.body, post.author.name, ...post.tags.map((pt) => pt.tag.name)]
.join('\n')
.toLowerCase()
.includes(needle),
);
}
export async function listTags(): Promise<TagWithCount[]> {
const tags = await db.tag.findMany({ orderBy: { name: 'asc' } });
return Promise.all(
tags.map(async (tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
count: await db.postTag.count({ where: { tagId: tag.id, post: { status: 'published' } } }),
})),
);
}
export async function getTagWithPosts(slug: string) {
const tag = await db.tag.findUnique({ where: { slug } });
if (!tag) return null;
const posts = await db.post.findMany({
where: { status: 'published', tags: { some: { tagId: tag.id } } },
include: postInclude,
orderBy: postOrder,
});
return { tag, posts };
}
export async function getAuthorWithPosts(slug: string) {
const author = await db.author.findUnique({ where: { slug } });
if (!author) return null;
const posts = await db.post.findMany({ where: { status: 'published', authorId: author.id }, include: postInclude, orderBy: postOrder });
return { author, posts };
}
export function listAuthors() {
return db.author.findMany({ orderBy: { name: 'asc' } });
}
export async function latestAndFeatured(limit = 3) {
const [featured, latest] = await Promise.all([
db.post.findMany({ where: { status: 'published', featured: true }, include: postInclude, orderBy: postOrder, take: limit }),
db.post.findMany({ where: { status: 'published' }, include: postInclude, orderBy: postOrder, take: limit }),
]);
return { featured, latest };
}
export async function contentOverview() {
const [published, drafts, docs, authors, tags, recent] = await Promise.all([
db.post.count({ where: { status: 'published' } }),
db.post.count({ where: { status: 'draft' } }),
db.doc.count(),
db.author.count(),
db.tag.count(),
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 6, include: { actor: true } }),
]);
return { published, drafts, docs, authors, tags, recent };
}
// --------------------------------------------------------------------------- admin reads
export function listPostsAdmin(status?: string) {
return db.post.findMany({
where: status ? { status } : {},
include: postInclude,
orderBy: { updatedAt: 'desc' },
});
}
export function getPostAdmin(id: string) {
return db.post.findUnique({ where: { id }, include: postInclude });
}
export function listDocsAdmin(status?: string) {
return db.doc.findMany({
where: status ? { status } : {},
orderBy: [{ section: 'asc' }, { order: 'asc' }, { updatedAt: 'desc' }],
});
}
export function getDocAdmin(id: string) {
return db.doc.findUnique({ where: { id } });
}
// --------------------------------------------------------------------------- validation
const slugField = z
.string()
.trim()
.max(200)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase letters, numbers and hyphens.')
.optional()
.or(z.literal(''));
export const postInputSchema = z.object({
title: z.string().trim().min(1).max(200),
slug: slugField,
excerpt: z.string().trim().max(600).optional().or(z.literal('')),
body: z.string().min(1),
status: z.enum(['draft', 'published']),
featured: z.boolean().optional(),
authorId: z.string().min(1),
tags: z.array(z.string().trim().min(1).max(40)).max(12).optional(),
});
export const docInputSchema = z.object({
title: z.string().trim().min(1).max(200),
slug: slugField,
section: z.string().trim().min(1).max(80),
order: z.number().int().min(0).max(9999).optional(),
body: z.string().min(1),
status: z.enum(['draft', 'published']),
});
export type PostInput = z.infer<typeof postInputSchema>;
export type DocInput = z.infer<typeof docInputSchema>;
// --------------------------------------------------------------------------- helpers
async function uniqueSlug(base: string, excludeId?: string): Promise<string> {
const root = slugify(base) || 'entry';
let candidate = root;
let n = 2;
// eslint-disable-next-line no-await-in-loop
while (await db.post.findFirst({ where: { slug: candidate, ...(excludeId ? { id: { not: excludeId } } : {}) } })) {
candidate = `${root}-${n++}`;
}
return candidate;
}
async function uniqueDocSlug(base: string, excludeId?: string): Promise<string> {
const root = slugify(base) || 'doc';
let candidate = root;
let n = 2;
// eslint-disable-next-line no-await-in-loop
while (await db.doc.findFirst({ where: { slug: candidate, ...(excludeId ? { id: { not: excludeId } } : {}) } })) {
candidate = `${root}-${n++}`;
}
return candidate;
}
async function syncTags(postId: string, names: string[]): Promise<void> {
const unique = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
await db.postTag.deleteMany({ where: { postId } });
for (const name of unique) {
const slug = slugify(name);
if (!slug) continue;
// eslint-disable-next-line no-await-in-loop
const tag = await db.tag.upsert({ where: { slug }, update: {}, create: { name, slug } });
// eslint-disable-next-line no-await-in-loop
await db.postTag.create({ data: { postId, tagId: tag.id } });
}
}
// --------------------------------------------------------------------------- mutations
export async function createPost(input: PostInput, actor: Actor) {
if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' };
const author = await db.author.findUnique({ where: { id: input.authorId } });
if (!author) return { ok: false as const, error: 'Unknown author.' };
const published = isPublished(input.status);
const slug = input.slug ? await uniqueSlug(input.slug) : await uniqueSlug(input.title);
const post = await db.post.create({
data: {
slug,
title: input.title,
excerpt: input.excerpt?.trim() || excerptFromBody(input.body),
body: input.body,
status: input.status,
publishedAt: published ? new Date() : null,
readingMinutes: readingMinutes(input.body),
featured: input.featured ?? false,
authorId: author.id,
},
});
await syncTags(post.id, input.tags ?? []);
await recordAudit({ actorId: actor.id, action: 'post.create', target: post.slug, meta: { status: post.status } });
return { ok: true as const, post };
}
export async function updatePost(id: string, input: PostInput, actor: Actor) {
if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' };
const existing = await db.post.findUnique({ where: { id } });
if (!existing) return { ok: false as const, error: 'Not found' };
const author = await db.author.findUnique({ where: { id: input.authorId } });
if (!author) return { ok: false as const, error: 'Unknown author.' };
const published = isPublished(input.status);
const slug = input.slug && input.slug !== existing.slug ? await uniqueSlug(input.slug, id) : existing.slug;
const post = await db.post.update({
where: { id },
data: {
slug,
title: input.title,
excerpt: input.excerpt?.trim() || excerptFromBody(input.body),
body: input.body,
status: input.status,
publishedAt: published ? existing.publishedAt ?? new Date() : null,
readingMinutes: readingMinutes(input.body),
featured: input.featured ?? false,
authorId: author.id,
},
});
await syncTags(post.id, input.tags ?? []);
await recordAudit({ actorId: actor.id, action: 'post.update', target: post.slug, meta: { status: post.status } });
return { ok: true as const, post };
}
export async function deletePost(id: string, actor: Actor) {
if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' };
const post = await db.post.delete({ where: { id } }).catch(() => null);
if (!post) return { ok: false as const, error: 'Not found' };
await recordAudit({ actorId: actor.id, action: 'post.delete', target: post.slug });
return { ok: true as const };
}
export async function createDoc(input: DocInput, actor: Actor) {
if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' };
const slug = input.slug ? await uniqueDocSlug(input.slug) : await uniqueDocSlug(input.title);
const doc = await db.doc.create({
data: { slug, title: input.title, section: input.section, order: input.order ?? 1, body: input.body, status: input.status },
});
await recordAudit({ actorId: actor.id, action: 'doc.create', target: doc.slug, meta: { status: doc.status } });
return { ok: true as const, doc };
}
export async function updateDoc(id: string, input: DocInput, actor: Actor) {
if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' };
const existing = await db.doc.findUnique({ where: { id } });
if (!existing) return { ok: false as const, error: 'Not found' };
const slug = input.slug && input.slug !== existing.slug ? await uniqueDocSlug(input.slug, id) : existing.slug;
const doc = await db.doc.update({
where: { id },
data: { slug, title: input.title, section: input.section, order: input.order ?? existing.order, body: input.body, status: input.status },
});
await recordAudit({ actorId: actor.id, action: 'doc.update', target: doc.slug, meta: { status: doc.status } });
return { ok: true as const, doc };
}
export async function deleteDoc(id: string, actor: Actor) {
if (!canAuthor(actor.role)) return { ok: false as const, error: 'Forbidden' };
const doc = await db.doc.delete({ where: { id } }).catch(() => null);
if (!doc) return { ok: false as const, error: 'Not found' };
await recordAudit({ actorId: actor.id, action: 'doc.delete', target: doc.slug });
return { ok: true as const };
}

8
lib/server/db.ts Normal file
View file

@ -0,0 +1,8 @@
import { PrismaClient } from '@prisma/client';
// A single PrismaClient per process (avoids exhausting connections in dev HMR).
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

44
lib/server/email.ts Normal file
View file

@ -0,0 +1,44 @@
import 'server-only';
import { env } from '@/lib/env';
export type EmailMessage = { to: string; subject: string; html: string };
export type EmailResult = { id: string; provider: string };
export interface EmailProvider {
name: string;
send(message: EmailMessage): Promise<EmailResult>;
}
/** Default provider: no keys needed — logs the message and returns a synthetic id. */
export class LogProvider implements EmailProvider {
name = 'log';
async send(message: EmailMessage): Promise<EmailResult> {
console.log(`[email:log] to=${message.to} subject="${message.subject}"`);
return { id: `log_${Date.now().toString(36)}`, provider: this.name };
}
}
/** Real provider, activated by RESEND_API_KEY. */
export class ResendProvider implements EmailProvider {
name = 'resend';
constructor(private apiKey: string, private from: string) {}
async send(message: EmailMessage): Promise<EmailResult> {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: this.from, to: message.to, subject: message.subject, html: message.html }),
});
if (!res.ok) throw new Error(`Resend error ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { id: string };
return { id: data.id, provider: this.name };
}
}
export function getEmail(): EmailProvider {
if (env.RESEND_API_KEY) return new ResendProvider(env.RESEND_API_KEY, env.EMAIL_FROM);
return new LogProvider();
}
export function sendEmail(message: EmailMessage): Promise<EmailResult> {
return getEmail().send(message);
}

24
lib/server/guard.ts Normal file
View file

@ -0,0 +1,24 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { getCurrentUser, type SessionUser } from './auth';
import { canAuthor } from '@/lib/rbac';
export type Guarded = { user: SessionUser; error?: undefined } | { user?: undefined; error: NextResponse };
const forbidden = () => NextResponse.json({ error: 'Forbidden' }, { status: 403 });
/** API guard: a signed-in author (admin | editor) may read the content admin API. */
export async function apiStaff(): Promise<Guarded> {
const user = await getCurrentUser();
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
if (!canAuthor(user.role)) return { error: forbidden() };
return { user };
}
/** API guard for mutations (create/update/publish/delete) — admin | editor. */
export async function apiWrite(): Promise<Guarded> {
const g = await apiStaff();
if (g.error) return g;
if (!canAuthor(g.user.role)) return { error: forbidden() };
return g;
}

86
lib/server/payments.ts Normal file
View file

@ -0,0 +1,86 @@
import 'server-only';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '@/lib/env';
export type CheckoutInput = { userId: string; email: string; plan: string; successUrl: string; cancelUrl: string };
export type CheckoutResult = { id: string; url: string };
export type WebhookEvent = { type: 'checkout.completed' | 'subscription.canceled' | 'ignored'; userId?: string; plan?: string };
export interface PaymentProvider {
name: string;
createCheckout(input: CheckoutInput): Promise<CheckoutResult>;
parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent>;
}
/**
* Zero-config provider. It never redirects off-site: the checkout route marks the
* subscription active directly, so "plan → checkout → return → active" works with no keys.
*/
export class MockProvider implements PaymentProvider {
name = 'mock';
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
const id = `mock_${input.userId.slice(0, 8)}_${input.plan}_${Date.now().toString(36)}`;
return { id, url: `${input.successUrl}?billing=success&provider=mock&plan=${encodeURIComponent(input.plan)}` };
}
async parseWebhook(rawBody: string): Promise<WebhookEvent> {
try {
const data = JSON.parse(rawBody) as { type?: string; userId?: string; plan?: string };
if (!data.type) return { type: 'ignored' };
return { type: data.type as WebhookEvent['type'], userId: data.userId, plan: data.plan };
} catch {
return { type: 'ignored' };
}
}
}
/** Stripe provider over the REST API (no SDK dependency), activated by STRIPE_SECRET_KEY. */
export class StripeProvider implements PaymentProvider {
name = 'stripe';
constructor(private secretKey: string, private webhookSecret: string) {}
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
const body = new URLSearchParams();
body.set('mode', 'subscription');
body.set('success_url', input.successUrl);
body.set('cancel_url', input.cancelUrl);
body.set('client_reference_id', input.userId);
body.set('customer_email', input.email);
body.set('line_items[0][price]', input.plan);
body.set('line_items[0][quantity]', '1');
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!res.ok) throw new Error(`Stripe error ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { id: string; url: string };
return { id: data.id, url: data.url };
}
async parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent> {
if (!this.webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
if (!verifyStripeSignature(rawBody, signature, this.webhookSecret)) return { type: 'ignored' };
const event = JSON.parse(rawBody) as { type?: string; data?: { object?: { client_reference_id?: string } } };
if (event.type === 'checkout.session.completed') {
return { type: 'checkout.completed', userId: event.data?.object?.client_reference_id };
}
if (event.type === 'customer.subscription.deleted') return { type: 'subscription.canceled' };
return { type: 'ignored' };
}
}
// Minimal Stripe signature check (t=timestamp,v1=signature) to make webhooks idempotent + trusted.
function verifyStripeSignature(payload: string, header: string | null, secret: string): boolean {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
if (!parts.t || !parts.v1) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${payload}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
export function getPayments(): PaymentProvider {
if (env.STRIPE_SECRET_KEY) return new StripeProvider(env.STRIPE_SECRET_KEY, env.STRIPE_WEBHOOK_SECRET);
return new MockProvider();
}

21
lib/utils.ts Normal file
View file

@ -0,0 +1,21 @@
/** Join conditional class names. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function formatDate(value: Date | string): string {
const d = typeof value === 'string' ? new Date(value) : value;
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
}
export function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

148
messages/en.json Normal file
View file

@ -0,0 +1,148 @@
{
"brand": { "name": "Acme" },
"nav": {
"home": "Home",
"blog": "Blog",
"docs": "Docs",
"tags": "Tags",
"search": "Search",
"pricing": "Pricing",
"dashboard": "Dashboard",
"admin": "Admin",
"login": "Sign in",
"register": "Create account",
"logout": "Sign out"
},
"home": {
"title": "Ship your product, not your boilerplate",
"subtitle": "A ready-to-run base app: authentication, roles, payments, admin and SEO — working on day one.",
"cta": "Get started",
"secondaryCta": "View pricing",
"featuresTitle": "Everything included",
"contentTitle": "A blog and docs site that is ready to publish",
"contentSubtitle": "Posts, docs, taxonomy, search, RSS and a role-gated authoring admin — seeded and working on day one.",
"readBlog": "Read the blog",
"featured": "Featured",
"latest": "Latest posts",
"docsTitle": "Documentation",
"docsSubtitle": "Guides and reference, organised into sections.",
"features": {
"auth": { "title": "Auth & roles", "body": "Email + password, httpOnly sessions, role guards on every route." },
"billing": { "title": "Payments", "body": "Checkout works with a mock provider and no keys; Stripe activates from env." },
"admin": { "title": "Admin panel", "body": "A role-gated shell with a dashboard and CRUD primitives." },
"seo": { "title": "SEO & a11y", "body": "Metadata, sitemap, robots and AA contrast out of the box." }
}
},
"blog": {
"title": "Blog",
"subtitle": "Notes, guides and product updates.",
"rss": "RSS",
"empty": "No posts published yet.",
"related": "Related posts"
},
"docs": {
"title": "Docs",
"subtitle": "Guides and reference documentation, organised into sections.",
"empty": "No documentation published yet."
},
"search": {
"title": "Search",
"placeholder": "Search titles and body text…",
"submit": "Search",
"hint": "Type a query to search across titles and body text.",
"empty": "No results for “{query}”.",
"resultsFor": "{count} result(s) for “{query}”"
},
"tags": {
"title": "Tags",
"subtitle": "Browse posts by topic.",
"count": "{count} posts"
},
"author": {
"postsBy": "Posts by {name}"
},
"pricing": {
"title": "Simple pricing",
"subtitle": "Start free. Upgrade when you grow.",
"perMonth": "/ month",
"choose": "Choose plan",
"current": "Current plan",
"upgraded": "Your plan is now active."
},
"auth": {
"loginTitle": "Sign in",
"registerTitle": "Create your account",
"email": "Email",
"password": "Password",
"name": "Name",
"submitLogin": "Sign in",
"submitRegister": "Create account",
"noAccount": "No account yet?",
"hasAccount": "Already have an account?",
"demoHint": "Demo: admin@example.com / editor@example.com / user@example.com — password \"password\"."
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome, {name}",
"plan": "Your plan",
"usage": "Usage"
},
"admin": {
"title": "Content",
"overview": "Overview",
"users": "Users",
"audit": "Audit log",
"posts": "Posts",
"docs": "Docs",
"totalUsers": "Total users",
"activeSubs": "Active subscriptions",
"publishedPosts": "Published posts",
"drafts": "Drafts",
"docsTotal": "Docs",
"tagsTotal": "Tags",
"recentActivity": "Recent activity",
"makeAdmin": "Make admin",
"makeUser": "Make user",
"delete": "Delete",
"newPost": "New post",
"editPost": "Edit post",
"newDoc": "New page",
"editDoc": "Edit page",
"title": "Title",
"slug": "Slug",
"author": "Author",
"excerpt": "Excerpt",
"body": "Body (markdown)",
"tags": "Tags",
"featured": "Featured",
"status": "Status",
"section": "Section",
"order": "Order",
"all": "All",
"draft": "Draft",
"published": "Published",
"publish": "Publish",
"unpublish": "Unpublish",
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"actions": "Actions",
"updated": "Updated",
"created": "Created",
"livePreview": "Preview",
"previewHint": "Live preview is rendered with the in-repo markdown renderer.",
"untitled": "Untitled",
"none": "—",
"empty": "Nothing here yet. Create your first entry.",
"readOnly": "You have read-only access. Ask an admin for the editor role to author content."
},
"common": {
"loading": "Loading…",
"empty": "Nothing here yet.",
"error": "Something went wrong.",
"retry": "Try again",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
}
}

28
middleware.ts Normal file
View file

@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from 'next/server';
// Keep in sync with lib/server/auth.ts (middleware runs on the edge runtime and must not
// import the server-only auth module).
const SESSION_COOKIE = 'w4c_session';
const PROTECTED = ['/dashboard', '/admin'];
/**
* Coarse gate: unauthenticated visitors are redirected to /login before the page renders,
* so the response is a real 307 (not a streamed client redirect). Role checks stay in the
* page/layout guards, which remain authoritative.
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtected = PROTECTED.some((p) => pathname === p || pathname.startsWith(`${p}/`));
if (!isProtected) return NextResponse.next();
if (request.cookies.get(SESSION_COOKIE)) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = '/login';
url.search = '';
url.searchParams.set('next', pathname);
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
};

26
next.config.ts Normal file
View file

@ -0,0 +1,26 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
output: 'standalone',
outputFileTracingRoot: root,
poweredByHeader: false,
reactStrictMode: true,
async headers() {
return [
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
],
},
];
},
};
export default nextConfig;

8771
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

46
package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "templates2-base-next",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "node tests/e2e/smoke.mjs",
"db:push": "prisma db push --skip-generate",
"db:seed": "prisma db seed",
"db:setup": "prisma db push --skip-generate && prisma db seed",
"postinstall": "prisma generate"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.3.0",
"bcryptjs": "^2.4.3",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^8.57.1",
"eslint-config-next": "^15.2.0",
"postcss": "^8.4.49",
"prisma": "^6.3.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^2.1.8"
}
}

5
postcss.config.mjs Normal file
View file

@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

119
prisma/schema.prisma Normal file
View file

@ -0,0 +1,119 @@
// Templates2 Base — core data model shared by every case.
// SQLite in development (file:./dev.db); switch the datasource url to Postgres in production.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
role String @default("user") // user | admin
createdAt DateTime @default(now())
sessions Session[]
subscription Subscription?
auditLogs AuditLog[]
}
model Session {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
plan String @default("free") // free | pro
status String @default("active") // active | canceled | past_due
provider String @default("mock") // mock | stripe
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AuditLog {
id String @id @default(cuid())
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
action String
target String?
meta String?
createdAt DateTime @default(now())
@@index([actorId])
}
// --- Content domain ---------------------------------------------------------
model Author {
id String @id @default(cuid())
name String
slug String @unique
bio String?
avatarColor String @default("#4f46e5")
createdAt DateTime @default(now())
posts Post[]
}
model Tag {
id String @id @default(cuid())
name String
slug String @unique
posts PostTag[]
}
model PostTag {
postId String
tagId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([postId, tagId])
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
excerpt String
body String
status String @default("draft") // draft | published
publishedAt DateTime?
readingMinutes Int @default(1)
featured Boolean @default(false)
authorId String
author Author @relation(fields: [authorId], references: [id], onDelete: Cascade)
tags PostTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, publishedAt])
@@index([authorId])
}
model Doc {
id String @id @default(cuid())
slug String @unique
title String
section String
order Int @default(1)
body String
status String @default("published") // draft | published
updatedAt DateTime @updatedAt
@@index([section, order])
}

373
prisma/seed.ts Normal file
View file

@ -0,0 +1,373 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const db = new PrismaClient();
const fence = (lang: string, ...lines: string[]) => ['```' + lang, ...lines, '```'];
const readingMinutes = (body: string) => Math.max(1, Math.round(body.trim().split(/\s+/).filter(Boolean).length / 200));
const AUTHORS = [
{
slug: 'ada-lovelace',
name: 'Ada Lovelace',
bio: 'Engineer and writer. Ada covers the front end, performance and the launch process.',
avatarColor: '#4f46e5',
},
{
slug: 'grace-hopper',
name: 'Grace Hopper',
bio: 'Compiler enthusiast turned product writer. Grace writes about developer experience and tooling.',
avatarColor: '#0d9488',
},
];
const TAGS = ['Engineering', 'Product', 'Design'];
type SeedPost = {
slug: string;
title: string;
excerpt: string;
author: string;
tags: string[];
status: 'draft' | 'published';
featured?: boolean;
daysAgo: number;
body: string;
};
const POSTS: SeedPost[] = [
{
slug: 'launch-checklist',
title: 'The content launch checklist',
excerpt: 'Everything to verify before you point a domain at a brand-new content site.',
author: 'ada-lovelace',
tags: ['Product', 'Engineering'],
status: 'published',
featured: true,
daysAgo: 2,
body: [
'A launch is not a deploy — it is a sequence of checks that keep a bad first impression off the record.',
'',
'## Before you ship',
'',
'Confirm metadata, sitemap, robots and a working RSS feed. Verify the 404 page, the empty states and the search results page.',
'',
'- [x] titles and descriptions on every route',
'- [x] sitemap includes posts and docs',
'- [x] RSS validates',
'',
'## After you ship',
'',
'Submit the sitemap and watch the error logs for the first hour. Then publish your second post — cadence beats polish.',
'',
...fence('bash', 'curl -s https://example.com/sitemap.xml | head -n 5', 'curl -s https://example.com/rss.xml | head -n 5'),
].join('\n'),
},
{
slug: 'structured-content-with-prisma',
title: 'Structured content with Prisma',
excerpt: 'Store posts, tags and authors relationally so the site stays queryable as it grows.',
author: 'grace-hopper',
tags: ['Engineering'],
status: 'published',
daysAgo: 5,
body: [
'Hard-coded arrays get you to a demo. A relational model gets you to a product.',
'',
'## The shape',
'',
'A post belongs to an author and has many tags through a join table. That single decision unlocks author pages, tag pages and related posts.',
'',
...fence(
'prisma',
'model Post {',
' id String @id @default(cuid())',
' slug String @unique',
' title String',
' status String @default("draft")',
' authorId String',
' tags PostTag[]',
'}',
),
'',
'## Querying published content',
'',
'Filtering by `status` at the query layer means a draft can never leak onto a public list.',
'',
...fence('ts', 'const posts = await db.post.findMany({', " where: { status: 'published' },", " orderBy: { publishedAt: 'desc' },", '});'),
].join('\n'),
},
{
slug: 'a-design-token-system',
title: 'A design-token system that survives dark mode',
excerpt: 'One set of CSS variables powers both themes — contrast stays predictable.',
author: 'ada-lovelace',
tags: ['Design', 'Engineering'],
status: 'published',
daysAgo: 8,
body: [
'Tokens are the contract between design and code. Define them once and let the theme swap the values.',
'',
'## Light and dark',
'',
'Keep colour decisions in two places only: the `:root` block and the `.dark` block.',
'',
...fence('css', ':root {', ' --surface: #ffffff;', ' --text: #0f172a;', '}', '.dark {', ' --surface: #0b1120;', ' --text: #e5e7eb;', '}'),
'',
'## Contrast',
'',
'Check the muted text against each surface. AA is the floor, not the ceiling.',
].join('\n'),
},
{
slug: 'seo-for-content-sites',
title: 'SEO for content sites',
excerpt: 'Metadata, JSON-LD, a sitemap and an RSS feed — the four things search engines actually read.',
author: 'grace-hopper',
tags: ['Engineering', 'Product'],
status: 'published',
daysAgo: 12,
body: [
'Search engines reward pages that describe themselves. Start with a title, a description and canonical URLs.',
'',
'## Structured data',
'',
'Add JSON-LD for your articles. It is cheap and it shows up in results.',
'',
...fence(
'json',
'{',
' "@context": "https://schema.org",',
' "@type": "Article",',
' "headline": "SEO for content sites"',
'}',
),
'',
'## Feeds',
'',
'A sitemap and an RSS feed are the fastest way to get discovered.',
].join('\n'),
},
{
slug: 'writing-docs-people-read',
title: 'Writing docs people actually read',
excerpt: 'Short sections, runnable examples and a table of contents that earns its place.',
author: 'ada-lovelace',
tags: ['Product'],
status: 'published',
daysAgo: 16,
body: [
'Good documentation answers one question per section and ends with the next step.',
'',
'## Lead with the task',
'',
'Open with what the reader wants to do, not with architecture.',
'',
'## Show, do not tell',
'',
'Every code sample should be runnable. If it is not, say so.',
'',
...fence('bash', 'npm ci', 'cp .env.example .env', 'npm run db:setup', 'npm run dev'),
].join('\n'),
},
{
slug: 'reading-time-and-toc',
title: 'Reading time and a table of contents, from markdown',
excerpt: 'Two small utilities turn a raw markdown body into a navigable article.',
author: 'grace-hopper',
tags: ['Engineering'],
status: 'published',
daysAgo: 20,
body: [
'A table of contents is just the headings, in order, with stable anchors. Reading time is a word count divided by a comfortable pace.',
'',
'## Extracting headings',
'',
...fence('ts', 'const headings = body', ' .split("\\n")', ' .filter((line) => line.startsWith("## "));'),
'',
'## Estimating reading time',
'',
...fence('ts', 'const minutes = Math.max(1, Math.round(words / 200));'),
'',
'## Why it matters',
'',
'Both signals help a reader decide whether to commit — and they are cheap to compute server-side.',
].join('\n'),
},
{
slug: 'search-that-scales',
title: 'Search that scales past a hundred posts',
excerpt: 'Start with a server-side scan over titles and bodies; move to an index when it hurts.',
author: 'ada-lovelace',
tags: ['Engineering'],
status: 'published',
daysAgo: 24,
body: [
'A content site does not need a search cluster on day one. It needs a query that returns the right post.',
'',
'## Start simple',
'',
'Case-insensitive substring matching over title, excerpt and body covers most sites.',
'',
...fence('ts', 'const results = posts.filter((post) =>', ' post.body.toLowerCase().includes(query),', ');'),
'',
'## Grow later',
'',
'When the corpus outgrows a scan, swap the implementation behind the same results page.',
].join('\n'),
},
{
slug: 'roadmap-whats-next',
title: 'Roadmap: what is next',
excerpt: 'Draft notes on comments, newsletter capture and a richer editor.',
author: 'ada-lovelace',
tags: ['Product'],
status: 'draft',
daysAgo: 1,
body: [
'This post is a draft — it must never appear on the public blog.',
'',
'## Candidates',
'',
'- Comments with moderation',
'- Newsletter capture on posts',
'- A richer authoring editor',
].join('\n'),
},
];
type SeedDoc = { slug: string; title: string; section: string; order: number; status: 'draft' | 'published'; body: string };
const DOCS: SeedDoc[] = [
{
slug: 'installation',
title: 'Installation',
section: 'Getting started',
order: 1,
status: 'published',
body: ['Install dependencies, set up the database and start the dev server.', '', '## Steps', '', ...fence('bash', 'npm ci', 'cp .env.example .env', 'npm run db:setup', 'npm run dev'), '', '## Next', '', 'Read the project structure guide next.'].join('\n'),
},
{
slug: 'project-structure',
title: 'Project structure',
section: 'Getting started',
order: 2,
status: 'published',
body: ['Where things live.', '', '## Routes', '', 'Public pages live under `app/(marketing)`, the admin under `app/admin`.', '', '## Content', '', 'Content lives in the database, seeded from `prisma/seed.ts`.'].join('\n'),
},
{
slug: 'writing-content',
title: 'Writing content',
section: 'Guides',
order: 1,
status: 'published',
body: ['Author posts and docs from the admin.', '', '## Markdown', '', 'Use `##` and `###` headings, lists, links and fenced code blocks.', '', ...fence('md', '## A heading', '', 'Some text with `inline code`.', '', '```js', 'console.log("hi");', '```'), '', '## Publishing', '', 'Save as a draft, preview it, then publish when it is ready.'].join('\n'),
},
{
slug: 'deploying',
title: 'Deploying',
section: 'Guides',
order: 2,
status: 'published',
body: ['Build a standalone image and point `DATABASE_URL` at Postgres.', '', '## Docker', '', ...fence('bash', 'docker compose -f docker/docker-compose.yml up --build'), '', '## Environment', '', 'Set `NEXT_PUBLIC_SITE_URL` so sitemap and RSS links are absolute.'].join('\n'),
},
{
slug: 'configuration',
title: 'Configuration',
section: 'Reference',
order: 1,
status: 'published',
body: ['Every variable has a working default.', '', '## Variables', '', '| key | required | note |', '| --- | --- | --- |', '| DATABASE_URL | no | SQLite by default |', '| NEXT_PUBLIC_SITE_URL | no | public origin |', '| RESEND_API_KEY | no | log provider when absent |'].join('\n'),
},
{
slug: 'content-api',
title: 'Content API',
section: 'Reference',
order: 2,
status: 'published',
body: ['The admin API is role-gated server-side.', '', '## Endpoints', '', '- `GET /api/admin/posts` — list posts (draft and published)', '- `POST /api/admin/posts` — create', '- `PATCH /api/admin/posts/[id]` — update or publish', '- `DELETE /api/admin/posts/[id]` — delete', '', 'Same shape for `/api/admin/docs`.'].join('\n'),
},
];
async function main() {
const password = await bcrypt.hash('password', 10);
const mkUser = (email: string, name: string, role: string) =>
db.user.upsert({ where: { email }, update: { role }, create: { email, name, role, passwordHash: password } });
const admin = await mkUser('admin@example.com', 'Ada Admin', 'admin');
const editor = await mkUser('editor@example.com', 'Evan Editor', 'editor');
const user = await mkUser('user@example.com', 'Demo User', 'user');
for (const u of [admin, editor, user]) {
await db.subscription.upsert({ where: { userId: u.id }, update: {}, create: { userId: u.id, plan: 'free', status: 'active', provider: 'mock' } });
}
const authorIds = new Map<string, string>();
for (const author of AUTHORS) {
const row = await db.author.upsert({
where: { slug: author.slug },
update: { name: author.name, bio: author.bio, avatarColor: author.avatarColor },
create: author,
});
authorIds.set(author.slug, row.id);
}
for (const name of TAGS) {
const slug = name.toLowerCase();
await db.tag.upsert({ where: { slug }, update: {}, create: { name, slug } });
}
for (const post of POSTS) {
const published = post.status === 'published';
const authorId = authorIds.get(post.author);
if (!authorId) continue;
const publishedAt = new Date(Date.now() - post.daysAgo * 24 * 60 * 60 * 1000);
const data = {
title: post.title,
excerpt: post.excerpt,
body: post.body,
status: post.status,
publishedAt: published ? publishedAt : null,
readingMinutes: readingMinutes(post.body),
featured: post.featured ?? false,
authorId,
};
const row = await db.post.upsert({ where: { slug: post.slug }, update: data, create: { slug: post.slug, ...data } });
await db.postTag.deleteMany({ where: { postId: row.id } });
for (const tagName of post.tags) {
const tag = await db.tag.findUnique({ where: { slug: tagName.toLowerCase() } });
if (tag) await db.postTag.create({ data: { postId: row.id, tagId: tag.id } });
}
}
for (const doc of DOCS) {
await db.doc.upsert({
where: { slug: doc.slug },
update: { title: doc.title, section: doc.section, order: doc.order, body: doc.body, status: doc.status },
create: doc,
});
}
if ((await db.auditLog.count()) === 0) {
await db.auditLog.createMany({
data: [
{ actorId: admin.id, action: 'seed', target: 'system', meta: 'Initial demo content' },
{ actorId: admin.id, action: 'post.create', target: 'launch-checklist' },
{ actorId: editor.id, action: 'doc.create', target: 'installation' },
],
});
}
console.log(
`Seeded content: ${admin.email} (admin), ${editor.email} (editor), ${user.email} (user) — password: "password"; ` +
`${POSTS.length} posts, ${DOCS.length} docs, ${AUTHORS.length} authors, ${TAGS.length} tags`,
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await db.$disconnect();
});

4
public/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="7" fill="#4f46e5"/>
<path d="M9 22V10h3.2l3.8 7 3.8-7H22v12h-2.6v-7.2L16 21.6h-1.2L11.6 14.8V22H9z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

120
tests/e2e/smoke.mjs Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* End-to-end smoke test — drives the running server over HTTP (no browser needed).
* Start the app first (`npm start`), then: QA_BASE_URL=http://127.0.0.1:3000 node tests/e2e/smoke.mjs
* The QA harness runs this automatically while the server is up.
*/
const BASE = (process.env.QA_BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
const jar = new Map();
const failures = [];
function store(res) {
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
for (const raw of list) {
const pair = raw.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) jar.set(pair.slice(0, eq), pair.slice(eq + 1));
}
}
async function req(path, { method = 'GET', body } = {}) {
const headers = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (jar.size) headers.cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
const res = await fetch(BASE + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
store(res);
const json = await res.clone().json().catch(() => null);
return { status: res.status, json };
}
async function html(path) {
const headers = jar.size ? { cookie: [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ') } : {};
const res = await fetch(BASE + path, { headers, redirect: 'manual' });
store(res);
return { status: res.status, body: await res.text() };
}
function check(name, cond, detail = '') {
if (cond) console.log(` ok ${name}`);
else { console.log(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); failures.push(name); }
}
async function run() {
const email = `smoke_${Date.now().toString(36)}@example.com`;
const health = await req('/api/health');
check('health 200', health.status === 200 && health.json?.status === 'ok', `status=${health.status}`);
const reg = await req('/api/auth/register', { method: 'POST', body: { email, password: 'password123', name: 'Smoke' } });
check('register 201', reg.status === 201, `status=${reg.status}`);
const regMe = await req('/api/auth/me');
check('session after register', regMe.status === 200 && regMe.json?.user?.email === email, `status=${regMe.status}`);
const newUserAdmin = await req('/api/admin/users');
check('non-admin blocked from admin API', newUserAdmin.status === 403, `status=${newUserAdmin.status}`);
const mailProvider = (await req('/api/billing/checkout', { method: 'POST', body: { plan: 'pro' } }));
check('mock checkout returns a url', mailProvider.status === 200 && String(mailProvider.json?.url).includes('billing=success'), `status=${mailProvider.status}`);
await req('/api/auth/logout', { method: 'POST' });
const afterLogout = await req('/api/auth/me');
check('logout clears session', afterLogout.status === 401, `status=${afterLogout.status}`);
const adminLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'admin@example.com', password: 'password' } });
check('seeded admin can sign in', adminLogin.status === 200 && adminLogin.json?.user?.role === 'admin', `status=${adminLogin.status}`);
const users = await req('/api/admin/users');
check('admin sees users', users.status === 200 && Array.isArray(users.json?.users) && users.json.users.length >= 2, `status=${users.status}`);
const webhook = await req('/api/billing/webhook', { method: 'POST', body: { type: 'checkout.completed', userId: adminLogin.json?.user?.id, plan: 'pro' } });
check('mock webhook accepted', webhook.status === 200, `status=${webhook.status}`);
await req('/api/auth/logout', { method: 'POST' });
const userLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'user@example.com', password: 'password' } });
check('seeded user can sign in', userLogin.status === 200 && userLogin.json?.user?.role === 'user', `status=${userLogin.status}`);
const forbidden = await req('/api/admin/users');
check('member forbidden on admin API', forbidden.status === 403, `status=${forbidden.status}`);
// --- Content authoring flow -------------------------------------------------
await req('/api/auth/logout', { method: 'POST' });
const editorLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'editor@example.com', password: 'password' } });
check('seeded editor can sign in', editorLogin.status === 200 && editorLogin.json?.user?.role === 'editor', `status=${editorLogin.status}`);
const draftTitle = `Smoke draft ${Date.now().toString(36)}`;
const draftSlug = `smoke-draft-${Date.now().toString(36)}`;
const adminPosts = await req('/api/admin/posts');
check('editor can read the content admin API', adminPosts.status === 200 && Array.isArray(adminPosts.json?.posts), `status=${adminPosts.status}`);
const authorId = adminPosts.json?.posts?.[0]?.author?.id;
check('a seeded author is available', Boolean(authorId), `authorId=${authorId}`);
const draftPayload = {
title: draftTitle,
slug: draftSlug,
excerpt: 'An unpublished draft created by the smoke test.',
body: '## Draft body\n\nThis must not appear on the public blog until it is published.',
status: 'draft',
featured: false,
authorId,
tags: ['Engineering'],
};
const createdDraft = await req('/api/admin/posts', { method: 'POST', body: draftPayload });
check('editor can create a draft post', createdDraft.status === 201, `status=${createdDraft.status}`);
const draftId = createdDraft.json?.post?.id;
const blogBefore = await html('/blog');
check('draft is NOT visible on /blog', blogBefore.status === 200 && !blogBefore.body.includes(draftTitle), `status=${blogBefore.status}`);
const published = await req(`/api/admin/posts/${draftId}`, { method: 'PATCH', body: { ...draftPayload, status: 'published' } });
check('editor can publish the post', published.status === 200 && published.json?.post?.status === 'published', `status=${published.status}`);
const blogAfter = await html('/blog');
check('published post is visible on /blog', blogAfter.status === 200 && blogAfter.body.includes(draftTitle), `status=${blogAfter.status}`);
const removedDraft = await req(`/api/admin/posts/${draftId}`, { method: 'DELETE' });
check('editor can delete the post', removedDraft.status === 200, `status=${removedDraft.status}`);
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nAll e2e checks passed');
process.exit(failures.length ? 1 : 0);
}
run().catch((e) => { console.error(e); process.exit(1); });

19
tests/unit/auth.test.ts Normal file
View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { hashPassword, verifyPassword } from '@/lib/password';
import { slugify } from '@/lib/utils';
describe('password hashing', () => {
it('round-trips a password', async () => {
const hash = await hashPassword('password');
expect(hash).not.toBe('password');
expect(await verifyPassword('password', hash)).toBe(true);
expect(await verifyPassword('wrong', hash)).toBe(false);
});
});
describe('slugify', () => {
it('produces a url-safe slug', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
expect(slugify(' Already-slug ')).toBe('already-slug');
});
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { slugify } from '@/lib/utils';
import { excerptFromBody, extractHeadings, filterPublished, isPublished, readingMinutes } from '@/lib/content';
describe('slugify (frontmatter slug)', () => {
it('produces a url-safe slug', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
expect(slugify(' Already-slug ')).toBe('already-slug');
expect(slugify('Prisma + Next.js: a guide')).toBe('prisma-next-js-a-guide');
});
});
describe('readingMinutes', () => {
it('never returns less than one minute', () => {
expect(readingMinutes('short')).toBe(1);
});
it('rounds to the nearest minute at ~200 words per minute', () => {
const body = Array.from({ length: 600 }, () => 'word').join(' ');
expect(readingMinutes(body)).toBe(3);
});
});
describe('draft filtering', () => {
it('keeps only published items', () => {
const items = [
{ slug: 'a', status: 'published' },
{ slug: 'b', status: 'draft' },
{ slug: 'c', status: 'published' },
];
expect(filterPublished(items).map((i) => i.slug)).toEqual(['a', 'c']);
expect(isPublished('published')).toBe(true);
expect(isPublished('draft')).toBe(false);
});
});
describe('extractHeadings (table of contents)', () => {
it('collects h2/h3 headings with anchors', () => {
const body = ['# Title', 'intro', '', '## Before you ship', 'text', '', '### Details', 'more'].join('\n');
expect(extractHeadings(body)).toEqual([
{ id: 'before-you-ship', text: 'Before you ship', level: 2 },
{ id: 'details', text: 'Details', level: 3 },
]);
});
it('de-duplicates repeated heading anchors', () => {
const body = ['## Setup', 'a', '', '## Setup', 'b'].join('\n');
expect(extractHeadings(body).map((h) => h.id)).toEqual(['setup', 'setup-1']);
});
});
describe('excerptFromBody', () => {
it('strips markdown and truncates', () => {
const excerpt = excerptFromBody('# Title\n\nSome **bold** text with `code`.');
expect(excerpt).toBe('Title Some bold text with code.');
});
});

22
tests/unit/plans.test.ts Normal file
View file

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { getPlan, isPlanId, PLANS, withinLimit } from '@/lib/plans';
describe('plans', () => {
it('recognises valid plan ids only', () => {
expect(isPlanId('free')).toBe(true);
expect(isPlanId('pro')).toBe(true);
expect(isPlanId('enterprise')).toBe(false);
expect(isPlanId(undefined)).toBe(false);
});
it('falls back to the free plan for unknown values', () => {
expect(getPlan('nonsense').id).toBe('free');
expect(getPlan('pro').id).toBe('pro');
});
it('enforces usage limits inclusively below the cap', () => {
expect(withinLimit('free', 'seats', PLANS.free.limits.seats - 1)).toBe(true);
expect(withinLimit('free', 'seats', PLANS.free.limits.seats)).toBe(false);
expect(withinLimit('pro', 'apiCalls', 50_000)).toBe(true);
});
});

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

12
vitest.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
export default defineConfig({
resolve: {
alias: { '@': fileURLToPath(new URL('.', import.meta.url)) },
},
test: {
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
},
});