initial commit

This commit is contained in:
Hunter
2026-11-01 13:23:44 -05:00
commit e03ea4f770
53 changed files with 10802 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
.gitignore
*.md
.env*
.vscode
.idea

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Copy to .env (gitignored) next to docker-compose.yml and fill in.
# Host port to expose (binds 0.0.0.0; restrict access with firewall rules).
BLOG_MANAGER_PORT=3051
# Host uid/gid that owns the portfolio checkout: run `id -u` and `id -g`.
BLOG_MANAGER_UID=1000
BLOG_MANAGER_GID=1000
# Path to the portfolio git checkout, relative to this folder.
PORTFOLIO_DIR=../portfolio
# Commit author for Publish.
GIT_AUTHOR_NAME=
GIT_AUTHOR_EMAIL=
# Push after committing (false = commit only, push by hand).
GIT_PUSH=true
# HTTPS remote credentials (e.g. an access token for git.hwilliams.dev).
GIT_PUSH_USER=
GIT_PUSH_TOKEN=
# Trigger the live site's redeploy after a successful push. Same secret as
# DEPLOY_TRIGGER_SECRET in portfolio/.env. Leave blank to skip.
DEPLOY_URL=https://hwilliams.dev/api/deploy
DEPLOY_TRIGGER_SECRET=

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# The askpass helper runs under /bin/sh in the container; CRLF would break it.
*.sh text eol=lf

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
/node_modules
/.next/
/out/
/build
.DS_Store
npm-debug.log*
.env*
!.env.example
*.tsbuildinfo
next-env.d.ts

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# ---- Build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Production ----
FROM node:20-alpine AS runner
# git is needed for the Publish page (commit + push of the mounted checkout).
RUN apk add --no-cache git
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3051
ENV REPO_DIR=/repo
# The container may run as an arbitrary host uid (see docker-compose.yml)
# with no home directory; git needs a writable HOME.
ENV HOME=/tmp
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/scripts ./scripts
RUN chmod 755 scripts/git-askpass.sh
# Overridden by `user:` in docker-compose.yml to match the checkout's owner.
USER node
EXPOSE 3051
CMD ["node", "server.js"]

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# blog-manager
A local web app for managing the portfolio blog: create, edit and delete posts in a markdown editor with live preview, upload images, rename and merge categories and tags, and publish through the existing git and deploy pipeline.
There is **no authentication**. The server listens on `0.0.0.0:3051`, so restrict access to that port with firewall rules.
## How it works
- **Store**: `portfolio/data/blog-posts.json`. The site imports it as `BLOG_POSTS` in `data/content.ts`. The manager edits this file in place, in the mounted portfolio checkout.
- **Images**: uploads go to `portfolio/public/img/blog/<slug>/` and are referenced as `img/blog/<slug>/<file>`.
- **Preview**: rendered with the site's own remark pipeline and styled with the site's own CSS, so it looks like the live post page.
- **Publish**:
1. Commits only `data/blog-posts.json` and `public/img/blog/`. Other changes in the checkout are never included.
2. Runs `git push`.
3. Calls the site's `POST /api/deploy` (optional). This triggers `scripts/deploy-watch.sh` on the server.
- **Safety**: writes are atomic. A save made from a stale copy, for example after a `git pull`, is rejected so you can reload. Browsers can't send cross-site writes, because requests whose `Origin` doesn't match the host are refused.
## Run with Docker
```bash
cp .env.example .env # set UID/GID, git identity, push token, deploy secret
docker compose up -d --build
```
Then open `http://<host>:3051`.
- The container mounts `../portfolio` at `/repo`. Set `PORTFOLIO_DIR` to use a different checkout.
- Set `BLOG_MANAGER_UID` and `BLOG_MANAGER_GID` to the owner of the checkout (`id -u` / `id -g`). Otherwise, on Linux, files the manager writes end up owned by a different user, and git and `deploy-watch` may then fail with permission errors.
- **Push credentials**:
- For an HTTPS remote, set `GIT_PUSH_USER` and `GIT_PUSH_TOKEN`. git receives them through `scripts/git-askpass.sh`, and they're never written to disk.
- Set `GIT_PUSH=false` to only commit and push by hand.
- If the manager runs on the deploy host against the same checkout that `deploy-watch` pulls, that pull is a no-op fast-forward after a publish.
## Run without Docker
```bash
npm install
npm run dev # http://0.0.0.0:3051, uses ../portfolio unless REPO_DIR is set
```
## Environment
| Variable | Default | Purpose |
|---|---|---|
| `REPO_DIR` | `../portfolio` (`/repo` in Docker) | Portfolio git checkout |
| `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL` | none | Commit identity (needed unless the repo config has `user.*`) |
| `GIT_PUSH` | `true` | `false` = commit only |
| `GIT_PUSH_USER`, `GIT_PUSH_TOKEN` | none | HTTPS push credentials |
| `DEPLOY_URL`, `DEPLOY_TRIGGER_SECRET` | none | Live-site redeploy hook; skipped when unset |
| `BLOG_MANAGER_PORT`, `BLOG_MANAGER_UID`, `BLOG_MANAGER_GID`, `PORTFOLIO_DIR` | `3051`, `1000`, `1000`, `../portfolio` | Compose only |
## Notes
- Categories come from the posts themselves. You create one by typing it into a post, and it disappears when no post uses it. Renaming a category to an existing one merges the two.
- Renaming a post's slug moves its image folder and rewrites the references to it.
- Every saved post goes live on the next publish; there are no drafts.
- `lib/types.ts` mirrors `BlogPost` in `portfolio/data/content.ts`. Keep the two in sync.

View File

@@ -0,0 +1,35 @@
import { HttpError } from "@/lib/errors";
import { readJson, route } from "@/lib/http";
import { categoryCounts, mutatePosts, readPosts } from "@/lib/store";
import { SLUG_RE } from "@/lib/validate";
export const dynamic = "force-dynamic";
export const GET = route(async () => {
const { posts, revision } = await readPosts();
return Response.json({ categories: categoryCounts(posts), revision });
});
/**
* Body: { from, to, revision? }. Moves every post in `from` to `to`; if `to`
* is already in use this is a merge. Categories are derived from posts, so a
* category disappears once no post uses it.
*/
export const PATCH = route(async (req) => {
const { from, to, revision: expected } = await readJson<{ from?: string; to?: string; revision?: string }>(req);
const target = to?.trim() ?? "";
if (!from) throw new HttpError(400, "from is required");
if (!SLUG_RE.test(target)) throw new HttpError(400, "category must be lowercase kebab-case");
const { result, revision } = await mutatePosts(expected, (posts) => {
let changed = 0;
for (const post of posts) {
if (post.category !== from) continue;
post.category = target;
changed++;
}
if (!changed) throw new HttpError(404, `no posts in category "${from}"`);
return changed;
});
return Response.json({ changed: result, revision });
});

5
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,35 @@
import { HttpError } from "@/lib/errors";
import { route } from "@/lib/http";
import { deleteImage, listImages, saveUpload } from "@/lib/images";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ slug: string }> };
export const GET = route<Ctx>(async (_req, { params }) => {
const { slug } = await params;
return Response.json({ files: await listImages(slug) });
});
/** multipart/form-data with one or more `file` fields. */
export const POST = route<Ctx>(async (req, { params }) => {
const { slug } = await params;
const form = await req.formData().catch(() => {
throw new HttpError(400, "expected multipart/form-data");
});
const files = form.getAll("file").filter((f): f is File => f instanceof File);
if (!files.length) throw new HttpError(400, "no files uploaded");
const saved = [];
for (const file of files) saved.push(await saveUpload(slug, file));
return Response.json({ saved, files: await listImages(slug) }, { status: 201 });
});
/** Query: ?name=<file name> */
export const DELETE = route<Ctx>(async (req, { params }) => {
const { slug } = await params;
const name = new URL(req.url).searchParams.get("name");
if (!name) throw new HttpError(400, "name is required");
await deleteImage(slug, name);
return Response.json({ files: await listImages(slug) });
});

View File

@@ -0,0 +1,51 @@
import { HttpError } from "@/lib/errors";
import { readJson, route } from "@/lib/http";
import { deleteImageDir, renameImageDir } from "@/lib/images";
import { findPost, mutatePosts, readPosts } from "@/lib/store";
import { validatePost } from "@/lib/validate";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ slug: string }> };
export const GET = route<Ctx>(async (_req, { params }) => {
const { slug } = await params;
const { posts, revision } = await readPosts();
return Response.json({ post: findPost(posts, slug), revision });
});
/** Body: { post: PostInput, revision? }. A changed slug also moves the
* post's image folder and rewrites references to it. */
export const PUT = route<Ctx>(async (req, { params }) => {
const { slug } = await params;
const body = await readJson<{ post?: unknown; revision?: string }>(req);
const { post: input, errors } = validatePost(body.post);
if (!input) throw new HttpError(400, "invalid post", errors);
const { result, revision } = await mutatePosts(body.revision, async (posts) => {
const existing = findPost(posts, slug);
const updated = { ...input, id: existing.id };
if (input.slug !== slug) {
if (posts.some((p) => p.slug === input.slug)) {
throw new HttpError(409, `a post with slug "${input.slug}" already exists`);
}
await renameImageDir(updated, slug, input.slug);
}
// images is optional: an editor that cleared the gallery drops the key.
if (!input.images) delete (updated as { images?: unknown }).images;
posts[posts.indexOf(existing)] = updated;
return updated;
});
return Response.json({ post: result, revision });
});
/** Query: ?revision=…&images=1 (also delete the post's image folder). */
export const DELETE = route<Ctx>(async (req, { params }) => {
const { slug } = await params;
const url = new URL(req.url);
const { revision } = await mutatePosts(url.searchParams.get("revision") ?? undefined, (posts) => {
posts.splice(posts.indexOf(findPost(posts, slug)), 1);
});
if (url.searchParams.get("images") === "1") await deleteImageDir(slug);
return Response.json({ deleted: slug, revision });
});

28
app/api/posts/route.ts Normal file
View File

@@ -0,0 +1,28 @@
import { HttpError } from "@/lib/errors";
import { readJson, route } from "@/lib/http";
import { categoryCounts, mutatePosts, readPosts, tagCounts } from "@/lib/store";
import { validatePost } from "@/lib/validate";
export const dynamic = "force-dynamic";
export const GET = route(async () => {
const { posts, revision } = await readPosts();
return Response.json({ posts, revision, categories: categoryCounts(posts), tags: tagCounts(posts) });
});
/** Body: { post: PostInput, revision? } */
export const POST = route(async (req) => {
const body = await readJson<{ post?: unknown; revision?: string }>(req);
const { post, errors } = validatePost(body.post);
if (!post) throw new HttpError(400, "invalid post", errors);
const { result, revision } = await mutatePosts(body.revision, (posts) => {
if (posts.some((p) => p.slug === post.slug)) {
throw new HttpError(409, `a post with slug "${post.slug}" already exists`);
}
const created = { id: Math.max(0, ...posts.map((p) => p.id)) + 1, ...post };
posts.push(created);
return created;
});
return Response.json({ post: result, revision }, { status: 201 });
});

8
app/api/preview/route.ts Normal file
View File

@@ -0,0 +1,8 @@
import { readJson, route } from "@/lib/http";
import { renderMarkdown } from "@/lib/markdown";
/** Body: { markdown } -> { html }, rendered by the site's own pipeline. */
export const POST = route(async (req) => {
const { markdown } = await readJson<{ markdown?: string }>(req);
return Response.json({ html: await renderMarkdown(markdown ?? "") });
});

18
app/api/publish/route.ts Normal file
View File

@@ -0,0 +1,18 @@
import { HttpError } from "@/lib/errors";
import { gitStatus } from "@/lib/git";
import { readJson, route } from "@/lib/http";
import { publish } from "@/lib/publish";
export const dynamic = "force-dynamic";
export const GET = route(async () => Response.json(await gitStatus()));
/** Body: { message } -> commit managed paths, push, trigger deploy. A failed
* step is reported in the body ({ ok: false, steps }), not as an HTTP error. */
export const POST = route(async (req) => {
const { message } = await readJson<{ message?: string }>(req);
const msg = message?.trim();
if (!msg) throw new HttpError(400, "commit message is required");
const result = await publish(msg);
return Response.json({ ...result, status: await gitStatus() });
});

34
app/api/tags/route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { HttpError } from "@/lib/errors";
import { readJson, route } from "@/lib/http";
import { mutatePosts, readPosts, tagCounts } from "@/lib/store";
import { normalizeTags } from "@/lib/validate";
export const dynamic = "force-dynamic";
export const GET = route(async () => {
const { posts, revision } = await readPosts();
return Response.json({ tags: tagCounts(posts), revision });
});
/**
* Body: { from, to?, revision? }. Renames `from` to `to` on every post
* (merging if a post already has `to`), or removes it when `to` is empty.
*/
export const PATCH = route(async (req) => {
const { from, to, revision: expected } = await readJson<{ from?: string; to?: string; revision?: string }>(req);
if (!from) throw new HttpError(400, "from is required");
const target = to?.trim() ?? "";
const { result, revision } = await mutatePosts(expected, (posts) => {
let changed = 0;
for (const post of posts) {
if (!post.tags.includes(from)) continue;
const replaced = post.tags.flatMap((t) => (t === from ? (target ? [target] : []) : [t]));
post.tags = normalizeTags(replaced);
changed++;
}
if (!changed) throw new HttpError(404, `no posts tagged "${from}"`);
return changed;
});
return Response.json({ changed: result, revision });
});

19
app/categories/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import TaxonomyTable from "@/components/TaxonomyTable";
import { categoryCounts, readPosts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function CategoriesPage() {
const { posts, revision } = await readPosts();
return (
<main>
<h1>Categories</h1>
<p className="muted">
Each post has exactly one category. Categories come from the posts themselves: create one by
typing it into a post&apos;s category field, and it disappears once no post uses it. Rename to an
existing category to merge the two.
</p>
<TaxonomyTable kind="category" items={categoryCounts(posts)} revision={revision} />
</main>
);
}

View File

@@ -0,0 +1,10 @@
import { route } from "@/lib/http";
import { servePublic } from "@/lib/files";
export const dynamic = "force-dynamic";
/** Post bodies link images as /img/...; serve those from the site's public/
* so absolute links resolve in the preview exactly as on the live site. */
export const GET = route<{ params: Promise<{ path: string[] }> }>(async (_req, { params }) =>
servePublic(["img", ...(await params).path])
);

54
app/layout.tsx Normal file
View File

@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { JetBrains_Mono, DM_Sans } from "next/font/google";
import { gitStatus } from "@/lib/git";
import "./manager.css";
export const dynamic = "force-dynamic";
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--mono", display: "swap" });
const sans = DM_Sans({ subsets: ["latin"], variable: "--sans", display: "swap" });
export const metadata: Metadata = {
title: "Blog Manager",
description: "Local editor for the portfolio blog",
};
async function PublishBadge() {
const status = await gitStatus();
if (!status.available) {
return <span className="badge err" title={status.error}>git unavailable</span>;
}
const pending = status.changes.length;
const ahead = status.ahead ?? 0;
if (!pending && !ahead) return <span className="badge ok">published</span>;
return (
<Link href="/publish" className="badge warn">
{pending ? `${pending} unpublished change${pending === 1 ? "" : "s"}` : `${ahead} commit(s) not pushed`}
</Link>
);
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${mono.variable} ${sans.variable}`}>
<body>
<div className="shell">
<header className="topbar">
<Link href="/" className="brand">blog-manager</Link>
<nav>
<Link href="/">posts</Link>
<Link href="/posts/new">new post</Link>
<Link href="/categories">categories</Link>
<Link href="/tags">tags</Link>
<Link href="/publish">publish</Link>
</nav>
<span className="spacer" />
<PublishBadge />
</header>
{children}
</div>
</body>
</html>
);
}

287
app/manager.css Normal file
View File

@@ -0,0 +1,287 @@
/* Palette copied from portfolio/app/globals.css so the manager looks like the site. */
:root {
--bg: #ffffff;
--bg-raised: #f8f8fa;
--surface: #f1f2f4;
--surface-hover: #e8e9ed;
--border: #d4d5db;
--border-strong: #b8bac2;
--fg: #131419;
--fg-secondary: #4e5264;
--muted: #7e8290;
--accent: #5468e0;
--accent-hover: #4758cc;
--accent-bg: rgba(84, 104, 224, 0.07);
--success: #1a8f54;
--success-bg: rgba(26, 143, 84, 0.07);
--warning: #b07818;
--warning-bg: rgba(176, 120, 24, 0.07);
--error: #c93c3c;
--error-bg: rgba(201, 60, 60, 0.07);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101114;
--bg-raised: #141518;
--surface: #1a1b1f;
--surface-hover: #212229;
--border: #2c2d34;
--border-strong: #3d3f48;
--fg: #e4e5e9;
--fg-secondary: #a0a3ad;
--muted: #6f7280;
--accent: #8a9dff;
--accent-hover: #9dacff;
--accent-bg: rgba(138, 157, 255, 0.08);
--success: #5ec98f;
--success-bg: rgba(94, 201, 143, 0.08);
--warning: #e0a855;
--warning-bg: rgba(224, 168, 85, 0.08);
--error: #e06565;
--error-bg: rgba(224, 101, 101, 0.08);
color-scheme: dark;
}
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans), system-ui, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { font-family: var(--mono), monospace; letter-spacing: -0.02em; margin: 0 0 16px; }
h1 { font-size: 22px; }
h2 { font-size: 16px; }
code, .mono { font-family: var(--mono), monospace; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
/* ---- shell ---- */
.shell { max-width: 1400px; margin: 0 auto; padding: 0 24px 48px; }
.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 18px 0;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.brand { font-family: var(--mono); font-weight: 700; color: var(--accent); }
.topbar nav { display: flex; gap: 16px; font-family: var(--mono); font-size: 13px; }
.topbar nav a { color: var(--fg-secondary); }
.topbar nav a:hover { color: var(--accent); text-decoration: none; }
.topbar .spacer { flex: 1; }
.badge {
font-family: var(--mono);
font-size: 11px;
padding: 3px 10px;
border-radius: var(--radius-sm);
background: var(--accent-bg);
color: var(--accent);
white-space: nowrap;
}
.badge.warn { background: var(--warning-bg); color: var(--warning); }
.badge.ok { background: var(--success-bg); color: var(--success); }
.badge.err { background: var(--error-bg); color: var(--error); }
/* ---- controls ---- */
button, .button {
font-family: var(--mono);
font-size: 12px;
padding: 7px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
button:hover:not(:disabled), .button:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover:not(:disabled), .button.primary:hover { background: var(--accent-hover); color: #fff; }
button.danger:hover:not(:disabled) { border-color: var(--error); color: var(--error); }
button.ghost { border-color: transparent; background: transparent; padding: 5px 8px; }
input, textarea, select {
font: inherit;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 7px 10px;
width: 100%;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
textarea { resize: vertical; }
label.field { display: flex; flex-direction: column; gap: 5px; }
label.field > span { font-family: var(--mono); font-size: 11px; color: var(--muted); text-transform: lowercase; }
.field-hint { font-size: 11px; color: var(--muted); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.toolbar-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.toolbar-row input, .toolbar-row select { width: auto; min-width: 200px; }
.notice {
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.notice.error { border-color: var(--error); background: var(--error-bg); color: var(--error); }
.notice.ok { border-color: var(--success); background: var(--success-bg); color: var(--success); }
.notice.warn { border-color: var(--warning); background: var(--warning-bg); color: var(--warning); }
/* ---- tables ---- */
table.list { width: 100%; border-collapse: collapse; }
table.list th, table.list td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
table.list th { font-family: var(--mono); font-size: 11px; color: var(--muted); font-weight: 500; }
table.list tr:hover td { background: var(--bg-raised); }
table.list td.actions { text-align: right; white-space: nowrap; }
table.list td.actions > * + * { margin-left: 6px; }
.title-cell { font-weight: 600; }
.chip {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
background: var(--accent-bg);
padding: 2px 8px;
border-radius: var(--radius-sm);
margin: 1px 4px 1px 0;
}
/* ---- editor ---- */
.editor-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.editor-head h1 { margin: 0; flex: 1; min-width: 200px; }
.meta-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.meta-grid .span-all { grid-column: 1 / -1; }
.meta-grid .span-2 { grid-column: span 2; }
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
min-height: 70vh;
}
.pane {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.pane-head {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
flex-wrap: wrap;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
.pane-head .spacer { flex: 1; }
.pane-head button { padding: 4px 8px; font-size: 12px; min-width: 28px; justify-content: center; }
.pane-body { flex: 1; min-height: 0; position: relative; }
.pane-body .cm-editor { height: 100%; font-size: 13.5px; }
.pane-body .cm-scroller { font-family: var(--mono), monospace; }
.preview-frame { width: 100%; height: 100%; border: 0; display: block; background: transparent; }
.tb-sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 4px; }
/* tag input */
.tag-input {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 6px;
background: var(--bg);
}
.tag-input:focus-within { border-color: var(--accent); }
.tag-input input { border: 0; padding: 4px; flex: 1; min-width: 100px; width: auto; }
.tag-input .chip { display: inline-flex; align-items: center; gap: 4px; margin: 0; }
.tag-input .chip button { border: 0; background: none; padding: 0 2px; color: inherit; font-size: 12px; }
/* images */
.images { margin-top: 24px; }
.image-drop {
border: 1px dashed var(--border-strong);
border-radius: var(--radius-lg);
padding: 18px;
text-align: center;
color: var(--muted);
margin-bottom: 16px;
cursor: pointer;
}
.image-drop.over { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); }
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.image-card {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.image-card.in-gallery { border-color: var(--accent); }
.image-card .thumb { height: 130px; background: var(--surface); display: flex; align-items: center; justify-content: center; }
.image-card .thumb img, .image-card .thumb video { max-width: 100%; max-height: 100%; object-fit: contain; }
.image-card .body { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.image-card .name { font-family: var(--mono); font-size: 11px; word-break: break-all; }
.image-card .row button { padding: 4px 8px; font-size: 11px; }
.log {
font-family: var(--mono);
font-size: 12px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
white-space: pre-wrap;
overflow-x: auto;
}
@media (max-width: 1000px) {
.split { grid-template-columns: 1fr; }
.split .pane { min-height: 50vh; }
.meta-grid { grid-template-columns: 1fr; }
.meta-grid .span-2 { grid-column: auto; }
}

12
app/not-found.tsx Normal file
View File

@@ -0,0 +1,12 @@
import Link from "next/link";
export default function NotFound() {
return (
<main>
<h1>Not found</h1>
<p className="muted">
That post doesn&apos;t exist (it may have been renamed or deleted). <Link href="/">Back to posts</Link>
</p>
</main>
);
}

18
app/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import PostTable from "@/components/PostTable";
import { categoryCounts, readPosts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function PostsPage() {
const { posts, revision } = await readPosts();
const sorted = [...posts].sort((a, b) => b.date.localeCompare(a.date));
return (
<main>
<PostTable
posts={sorted}
revision={revision}
categories={categoryCounts(posts).map((c) => c.name)}
/>
</main>
);
}

23
app/posts/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import PostEditor from "@/components/PostEditor";
import { listImages } from "@/lib/images";
import { categoryCounts, readPosts, tagCounts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function EditPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { posts, revision } = await readPosts();
const post = posts.find((p) => p.slug === slug);
if (!post) notFound();
return (
<PostEditor
post={post}
revision={revision}
files={await listImages(slug)}
categories={categoryCounts(posts).map((c) => c.name)}
tags={tagCounts(posts).map((t) => t.name)}
/>
);
}

15
app/posts/new/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import PostEditor from "@/components/PostEditor";
import { categoryCounts, readPosts, tagCounts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function NewPostPage() {
const { posts, revision } = await readPosts();
return (
<PostEditor
revision={revision}
categories={categoryCounts(posts).map((c) => c.name)}
tags={tagCounts(posts).map((t) => t.name)}
/>
);
}

20
app/publish/page.tsx Normal file
View File

@@ -0,0 +1,20 @@
import PublishPanel from "@/components/PublishPanel";
import { gitStatus } from "@/lib/git";
export const dynamic = "force-dynamic";
export default async function PublishPage() {
const status = await gitStatus();
const config = {
push: (process.env.GIT_PUSH ?? "true").toLowerCase() !== "false",
pushAuth: process.env.GIT_PUSH_TOKEN ? "token" : "git default credentials",
deploy: Boolean(process.env.DEPLOY_URL && process.env.DEPLOY_TRIGGER_SECRET),
deployUrl: process.env.DEPLOY_URL ?? null,
};
return (
<main>
<h1>Publish</h1>
<PublishPanel status={status} config={config} />
</main>
);
}

View File

@@ -0,0 +1,9 @@
import { route } from "@/lib/http";
import { serveSitePath } from "@/lib/files";
export const dynamic = "force-dynamic";
/** The site's stylesheets and public/ files, for the preview iframe. */
export const GET = route<{ params: Promise<{ path: string[] }> }>(async (_req, { params }) =>
serveSitePath((await params).path)
);

18
app/tags/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import TaxonomyTable from "@/components/TaxonomyTable";
import { readPosts, tagCounts } from "@/lib/store";
export const dynamic = "force-dynamic";
export default async function TagsPage() {
const { posts, revision } = await readPosts();
return (
<main>
<h1>Tags</h1>
<p className="muted">
Free-form labels shown on post cards. Rename to an existing tag to merge, or remove a tag from
every post.
</p>
<TaxonomyTable kind="tag" items={tagCounts(posts)} revision={revision} />
</main>
);
}

216
components/ImagePanel.tsx Normal file
View File

@@ -0,0 +1,216 @@
"use client";
import { useRef, useState } from "react";
import { api } from "@/lib/client";
import { imageDescription, imageSrc, type GalleryImage, type ImageFile } from "@/lib/types";
/* Two related things in one panel:
* - files: what's in public/img/blog/<slug>/ (upload / delete / insert)
* - gallery: the post's images[] (ordered; the first is the card cover).
* Uploading and deleting files happens immediately; gallery edits are part
* of the post and are saved with it. */
type Props = {
/** The saved slug; uploads are disabled until the post exists. */
slug: string | null;
files: ImageFile[];
onFilesChange: (files: ImageFile[]) => void;
gallery: GalleryImage[];
onGalleryChange: (gallery: GalleryImage[]) => void;
onInsert: (file: ImageFile) => void;
};
function formatSize(bytes: number): string {
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`;
}
function withDescription(src: string, description: string): GalleryImage {
return description.trim() ? { src, description } : src;
}
export default function ImagePanel({ slug, files, onFilesChange, gallery, onGalleryChange, onInsert }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [over, setOver] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const galleryIndex = (src: string) => gallery.findIndex((g) => imageSrc(g) === src);
async function upload(list: FileList | File[]) {
if (!slug || !list.length) return;
const form = new FormData();
for (const f of Array.from(list)) form.append("file", f);
setBusy(true);
setError(null);
try {
const res = await api<{ files: ImageFile[] }>(`/api/images/${slug}`, { method: "POST", body: form });
onFilesChange(res.files);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
async function remove(file: ImageFile) {
if (!slug || !window.confirm(`Delete ${file.name} from disk?`)) return;
try {
const res = await api<{ files: ImageFile[] }>(
`/api/images/${slug}?name=${encodeURIComponent(file.name)}`,
{ method: "DELETE" }
);
onFilesChange(res.files);
if (galleryIndex(file.src) >= 0) onGalleryChange(gallery.filter((g) => imageSrc(g) !== file.src));
} catch (err) {
setError((err as Error).message);
}
}
function move(index: number, delta: number) {
const next = [...gallery];
const [item] = next.splice(index, 1);
next.splice(index + delta, 0, item);
onGalleryChange(next);
}
// Gallery entries pointing outside this post's folder (e.g. hand-added
// paths) still need to be manageable, so list them too.
const fileSrcs = new Set(files.map((f) => f.src));
const external = gallery.filter((g) => !fileSrcs.has(imageSrc(g)));
return (
<section className="images">
<h2>Images</h2>
{error && <div className="notice error">{error}</div>}
{gallery.length > 0 && (
<>
<p className="muted small">
Gallery (shown below the post; the first image is also the card cover). Edits here are saved with the
post.
</p>
<table className="list" style={{ marginBottom: 20 }}>
<tbody>
{gallery.map((img, i) => (
<tr key={imageSrc(img)}>
<td className="mono small" style={{ width: 30 }}>
{i === 0 ? "cover" : i + 1}
</td>
<td style={{ width: 80 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={"/" + imageSrc(img)} alt="" style={{ height: 40, borderRadius: 4 }} />
</td>
<td className="mono small">{imageSrc(img)}</td>
<td>
<input
placeholder="description (alt text)"
value={imageDescription(img) ?? ""}
onChange={(e) =>
onGalleryChange(gallery.map((g, j) => (j === i ? withDescription(imageSrc(g), e.target.value) : g)))
}
/>
</td>
<td className="actions">
<button type="button" className="ghost" disabled={i === 0} onClick={() => move(i, -1)} title="Move up">
↑
</button>
<button
type="button"
className="ghost"
disabled={i === gallery.length - 1}
onClick={() => move(i, 1)}
title="Move down"
>
↓
</button>
<button type="button" onClick={() => onGalleryChange(gallery.filter((_, j) => j !== i))}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
{external.length > 0 && (
<p className="muted small">
{external.length} gallery image(s) live outside public/img/blog/{slug ?? "<slug>"}/.
</p>
)}
</>
)}
{!slug ? (
<div className="image-drop">Save the post once to enable image uploads.</div>
) : (
<div
className={`image-drop${over ? " over" : ""}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
upload(e.dataTransfer.files);
}}
>
{busy ? "Uploading…" : `Drop images here or click to upload → public/img/blog/${slug}/`}
<input
ref={inputRef}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.mp4"
hidden
onChange={(e) => {
if (e.target.files) upload(e.target.files);
e.target.value = "";
}}
/>
</div>
)}
<div className="image-grid">
{files.map((file) => {
const inGallery = galleryIndex(file.src) >= 0;
return (
<div key={file.name} className={`image-card${inGallery ? " in-gallery" : ""}`}>
<div className="thumb">
{/\.mp4$/i.test(file.name) ? (
<video src={"/" + file.src} muted />
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={"/" + file.src} alt={file.name} />
)}
</div>
<div className="body">
<div className="name">
{file.name} <span className="muted">· {formatSize(file.size)}</span>
</div>
<div className="row">
<button type="button" onClick={() => onInsert(file)} title="Insert at cursor in the body">
Insert
</button>
<button
type="button"
onClick={() =>
onGalleryChange(
inGallery ? gallery.filter((g) => imageSrc(g) !== file.src) : [...gallery, file.src]
)
}
>
{inGallery ? "− Gallery" : "+ Gallery"}
</button>
<button type="button" className="danger" onClick={() => remove(file)}>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useMemo, useState } from "react";
import CodeMirror, { EditorSelection, EditorView, keymap } from "@uiw/react-codemirror";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { useSystemDark } from "@/lib/useSystemDark";
/* Markdown source editor with a formatting toolbar. Source (not WYSIWYG) on
* purpose: posts embed raw HTML blocks that rich-text editors would mangle.
* The live preview beside it is what makes it visual. */
type Props = {
value: string;
onChange: (value: string) => void;
/** Receives the EditorView so the parent can insert text (e.g. images). */
onView?: (view: EditorView | null) => void;
};
/** Wrap each selection in `before`/`after`, or unwrap if already wrapped. */
export function wrap(view: EditorView, before: string, after = before, placeholder = "text") {
view.dispatch(
view.state.changeByRange((range) => {
const doc = view.state.doc;
const outerFrom = range.from - before.length;
const outerTo = range.to + after.length;
const isWrapped =
outerFrom >= 0 &&
outerTo <= doc.length &&
doc.sliceString(outerFrom, range.from) === before &&
doc.sliceString(range.to, outerTo) === after;
if (isWrapped) {
return {
changes: [
{ from: outerFrom, to: range.from, insert: "" },
{ from: range.to, to: outerTo, insert: "" },
],
range: EditorSelection.range(outerFrom, range.to - before.length),
};
}
const text = range.empty ? placeholder : doc.sliceString(range.from, range.to);
return {
changes: { from: range.from, to: range.to, insert: before + text + after },
range: EditorSelection.range(range.from + before.length, range.from + before.length + text.length),
};
})
);
view.focus();
}
/** Toggle a prefix on every line touched by the selection ("# ", "> ", "- "...). */
export function prefixLines(view: EditorView, prefix: string | ((i: number) => string)) {
const { state } = view;
const changes: { from: number; to?: number; insert: string }[] = [];
const seen = new Set<number>();
for (const range of state.selection.ranges) {
const first = state.doc.lineAt(range.from).number;
const last = state.doc.lineAt(range.to).number;
for (let n = first, i = 0; n <= last; n++, i++) {
if (seen.has(n)) continue;
seen.add(n);
const line = state.doc.line(n);
const p = typeof prefix === "function" ? prefix(i) : prefix;
// Strip any existing heading/quote/list marker so prefixes replace each other.
const existing = /^(#{1,6} |> |- |\d+\. )/.exec(line.text)?.[0] ?? "";
if (existing === p) changes.push({ from: line.from, to: line.from + existing.length, insert: "" });
else changes.push({ from: line.from, to: line.from + existing.length, insert: p });
}
}
view.dispatch({ changes });
view.focus();
}
/** Replace the selection with `text` (on its own lines when `block`). */
export function insertText(view: EditorView, text: string, block = false) {
const { state } = view;
const range = state.selection.main;
let insert = text;
if (block) {
const before = state.doc.sliceString(Math.max(0, range.from - 2), range.from);
const after = state.doc.sliceString(range.to, range.to + 1);
if (range.from > 0 && !before.endsWith("\n\n")) insert = (before.endsWith("\n") ? "\n" : "\n\n") + insert;
if (after !== "\n") insert += "\n";
}
view.dispatch({
changes: { from: range.from, to: range.to, insert },
selection: EditorSelection.cursor(range.from + insert.length),
});
view.focus();
}
function link(view: EditorView) {
const url = window.prompt("Link URL", "https://");
if (!url) return;
wrap(view, "[", `](${url})`, "link text");
}
const BLOCKQUOTE_HTML = "<blockquote>\nQuote text\n</blockquote>";
export default function MarkdownEditor({ value, onChange, onView }: Props) {
const [view, setView] = useState<EditorView | null>(null);
const dark = useSystemDark();
const extensions = useMemo(
() => [
markdown({ base: markdownLanguage, codeLanguages: languages }),
EditorView.lineWrapping,
keymap.of([
{ key: "Mod-b", run: (v) => (wrap(v, "**"), true) },
{ key: "Mod-i", run: (v) => (wrap(v, "*"), true) },
{ key: "Mod-k", run: (v) => (link(v), true) },
]),
],
[]
);
const tools: ({ label: string; title: string; run: (v: EditorView) => void } | "sep")[] = [
{ label: "H1", title: "Heading 1", run: (v) => prefixLines(v, "# ") },
{ label: "H2", title: "Heading 2", run: (v) => prefixLines(v, "## ") },
{ label: "H3", title: "Heading 3", run: (v) => prefixLines(v, "### ") },
"sep",
{ label: "B", title: "Bold (Ctrl+B)", run: (v) => wrap(v, "**") },
{ label: "I", title: "Italic (Ctrl+I)", run: (v) => wrap(v, "*") },
{ label: "</>", title: "Inline code", run: (v) => wrap(v, "`", "`", "code") },
{ label: "link", title: "Link (Ctrl+K)", run: link },
"sep",
{ label: "• list", title: "Bullet list", run: (v) => prefixLines(v, "- ") },
{ label: "1. list", title: "Numbered list", run: (v) => prefixLines(v, (i) => `${i + 1}. `) },
{ label: "> quote", title: "Markdown quote", run: (v) => prefixLines(v, "> ") },
{ label: "<quote>", title: "HTML blockquote (the site's raised style)", run: (v) => insertText(v, BLOCKQUOTE_HTML, true) },
{ label: "```", title: "Code block", run: (v) => wrap(v, "```\n", "\n```", "code") },
{ label: "—", title: "Horizontal rule", run: (v) => insertText(v, "---", true) },
];
return (
<div className="pane">
<div className="pane-head">
{tools.map((tool, i) =>
tool === "sep" ? (
<span key={i} className="tb-sep" />
) : (
<button
key={tool.label}
type="button"
title={tool.title}
disabled={!view}
onMouseDown={(e) => e.preventDefault()}
onClick={() => view && tool.run(view)}
>
{tool.label}
</button>
)
)}
<span className="spacer" />
<span>markdown</span>
</div>
<div className="pane-body">
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme={dark ? "dark" : "light"}
height="100%"
style={{ position: "absolute", inset: 0 }}
basicSetup={{ foldGutter: false, highlightActiveLine: false }}
onCreateEditor={(v) => {
setView(v);
onView?.(v);
}}
/>
</div>
</div>
);
}

257
components/PostEditor.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { EditorView } from "@uiw/react-codemirror";
import { api, ApiError } from "@/lib/client";
import { categoryLabel, slugify, todayIso, validatePost } from "@/lib/validate";
import type { BlogPost, GalleryImage, ImageFile, PostInput } from "@/lib/types";
import TagInput from "./TagInput";
import PreviewFrame from "./PreviewFrame";
import ImagePanel from "./ImagePanel";
import { insertText } from "./MarkdownEditor";
// CodeMirror touches the DOM on load; render it client-side only.
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), {
ssr: false,
loading: () => <div className="pane" />,
});
type Props = {
/** Absent for a new post. */
post?: BlogPost;
revision: string;
files?: ImageFile[];
categories: string[];
tags: string[];
};
type Form = PostInput & { images: GalleryImage[] };
function toForm(post?: BlogPost): Form {
return {
slug: post?.slug ?? "",
title: post?.title ?? "",
description: post?.description ?? "",
date: post?.date ?? todayIso(),
category: post?.category ?? "",
tags: post?.tags ?? [],
images: post?.images ?? [],
details: post?.details ?? "",
};
}
export default function PostEditor({ post, revision: initialRevision, files: initialFiles, categories, tags }: Props) {
const router = useRouter();
const categoryListId = useId();
const viewRef = useRef<EditorView | null>(null);
const [form, setForm] = useState<Form>(() => toForm(post));
const [baseline, setBaseline] = useState(() => JSON.stringify(toForm(post)));
const [savedSlug, setSavedSlug] = useState<string | null>(post?.slug ?? null);
const [slugTouched, setSlugTouched] = useState(Boolean(post));
const [revision, setRevision] = useState(initialRevision);
const [files, setFiles] = useState<ImageFile[]>(initialFiles ?? []);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const [conflict, setConflict] = useState(false);
const dirty = JSON.stringify(form) !== baseline;
const update = <K extends keyof Form>(key: K, value: Form[K]) => setForm((f) => ({ ...f, [key]: value }));
function setTitle(title: string) {
setForm((f) => ({ ...f, title, slug: slugTouched ? f.slug : slugify(title) }));
}
const save = useCallback(
async (opts: { close?: boolean; force?: boolean } = {}) => {
const { post: input, errors } = validatePost(form);
if (!input) {
setMessage({ kind: "error", text: errors.join("; ") });
return;
}
setSaving(true);
setMessage(null);
try {
const body = { post: input, revision: opts.force ? undefined : revision };
const res = savedSlug
? await api<{ post: BlogPost; revision: string }>(`/api/posts/${savedSlug}`, { method: "PUT", json: body })
: await api<{ post: BlogPost; revision: string }>("/api/posts", { method: "POST", json: body });
setRevision(res.revision);
setBaseline(JSON.stringify(form));
setConflict(false);
const slugChanged = res.post.slug !== savedSlug;
setSavedSlug(res.post.slug);
setMessage({ kind: "ok", text: `Saved ${new Date().toLocaleTimeString()}` });
if (opts.close) router.push("/");
else if (slugChanged) router.replace(`/posts/${res.post.slug}`);
router.refresh();
} catch (err) {
if (err instanceof ApiError && err.status === 409 && /changed on disk/.test(err.message)) setConflict(true);
else setMessage({ kind: "error", text: (err as Error).message });
} finally {
setSaving(false);
}
},
[form, revision, savedSlug, router]
);
// Ctrl/Cmd+S saves.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (!saving) save();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [save, saving]);
// Warn before losing unsaved edits: tab close/reload, and in-app links.
useEffect(() => {
if (!dirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement).closest("a[href]");
if (a && !a.closest(".preview-frame") && !window.confirm("Discard unsaved changes?")) {
e.preventDefault();
e.stopPropagation();
}
};
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onClick, true);
};
}, [dirty]);
function insertImage(file: ImageFile) {
const view = viewRef.current;
if (!view) return;
const src = "/" + file.src;
const snippet = /\.mp4$/i.test(file.name)
? `<video src="${src}" controls></video>`
: `![${file.name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ")}](${src})`;
insertText(view, snippet, true);
}
const isNew = !savedSlug;
return (
<main>
<div className="editor-head">
<h1>{isNew ? "New post" : form.title || savedSlug}</h1>
{dirty && <span className="badge warn">unsaved</span>}
<Link href="/" className="button">
Back
</Link>
<button disabled={saving} onClick={() => save()}>
{saving ? "Saving…" : "Save"}
</button>
<button className="primary" disabled={saving} onClick={() => save({ close: true })}>
Save &amp; close
</button>
</div>
{conflict && (
<div className="notice warn">
blog-posts.json changed on disk since this page loaded (another edit, or a git pull).
<button onClick={() => window.location.reload()}>Reload (discard my edits)</button>
<button className="danger" onClick={() => save({ force: true })}>
Save anyway (overwrite this post)
</button>
</div>
)}
{message && <div className={`notice ${message.kind}`}>{message.text}</div>}
<div className="meta-grid">
<label className="field">
<span>title</span>
<input value={form.title} onChange={(e) => setTitle(e.target.value)} placeholder="Post title" />
</label>
<label className="field">
<span>slug</span>
<input
className="mono"
value={form.slug}
onChange={(e) => {
setSlugTouched(true);
update("slug", e.target.value);
}}
onBlur={() => update("slug", slugify(form.slug))}
placeholder="post-slug"
/>
<span className="field-hint">
/blog/{form.slug || "…"}
{savedSlug && form.slug !== savedSlug && " · renaming moves the image folder"}
</span>
</label>
<label className="field">
<span>date</span>
<input type="date" value={form.date} onChange={(e) => update("date", e.target.value)} />
</label>
<label className="field span-2">
<span>description</span>
<textarea
rows={2}
value={form.description}
onChange={(e) => update("description", e.target.value)}
placeholder="1–2 sentence excerpt shown on the post card"
/>
<span className="field-hint">{form.description.length} characters</span>
</label>
<label className="field">
<span>category</span>
<input
list={categoryListId}
value={form.category}
onChange={(e) => update("category", e.target.value)}
onBlur={() => update("category", slugify(form.category))}
placeholder="pick or type a new one"
/>
<datalist id={categoryListId}>
{categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</datalist>
<span className="field-hint">
{form.category
? `shown as “${categoryLabel(form.category)}”${categories.includes(form.category) ? "" : " · new category"}`
: "one per post"}
</span>
</label>
<div className="field span-all">
<span className="mono small muted">tags</span>
<TagInput value={form.tags} onChange={(t) => update("tags", t)} suggestions={tags} />
</div>
</div>
<div className="split">
<MarkdownEditor
value={form.details}
onChange={(v) => update("details", v)}
onView={(v) => (viewRef.current = v)}
/>
<PreviewFrame markdown={form.details} category={form.category} images={form.images} title={form.title} />
</div>
<ImagePanel
slug={savedSlug && savedSlug === form.slug ? savedSlug : null}
files={files}
onFilesChange={setFiles}
gallery={form.images}
onGalleryChange={(g) => update("images", g)}
onInsert={insertImage}
/>
</main>
);
}

127
components/PostTable.tsx Normal file
View File

@@ -0,0 +1,127 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import { categoryLabel } from "@/lib/validate";
import type { BlogPost } from "@/lib/types";
type Props = { posts: BlogPost[]; revision: string; categories: string[] };
export default function PostTable({ posts, revision, categories }: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return posts.filter(
(p) =>
(!category || p.category === category) &&
(!q || [p.title, p.slug, p.description, ...p.tags].some((s) => s.toLowerCase().includes(q)))
);
}, [posts, query, category]);
async function remove(post: BlogPost) {
const typed = window.prompt(`Delete "${post.title}"?\n\nType the slug (${post.slug}) to confirm.`);
if (typed === null) return;
if (typed.trim() !== post.slug) {
setError("Slug didn't match; nothing was deleted.");
return;
}
const withImages = window.confirm(
`Also delete the image folder public/img/blog/${post.slug}/?\n\nOK = delete images too, Cancel = keep them.`
);
setBusy(post.slug);
setError(null);
try {
const params = new URLSearchParams({ revision, ...(withImages ? { images: "1" } : {}) });
await api(`/api/posts/${post.slug}?${params}`, { method: "DELETE" });
router.refresh();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
}
}
return (
<>
<div className="toolbar-row">
<h1 style={{ margin: 0, flex: 1 }}>Posts</h1>
<input placeholder="search title, slug, tags…" value={query} onChange={(e) => setQuery(e.target.value)} />
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">all categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
<Link href="/posts/new" className="button primary">
+ New post
</Link>
</div>
{error && <div className="notice error">{error}</div>}
<table className="list">
<thead>
<tr>
<th>title</th>
<th>date</th>
<th>category</th>
<th>tags</th>
<th>images</th>
<th />
</tr>
</thead>
<tbody>
{visible.map((post) => (
<tr key={post.slug}>
<td>
<Link href={`/posts/${post.slug}`} className="title-cell">
{post.title}
</Link>
<div className="mono small muted">/blog/{post.slug}</div>
</td>
<td className="mono small">{post.date}</td>
<td>
<span className="chip">{post.category}</span>
</td>
<td>
{post.tags.map((t) => (
<span key={t} className="chip">
{t}
</span>
))}
</td>
<td className="mono small">{post.images?.length ?? 0}</td>
<td className="actions">
<Link href={`/posts/${post.slug}`} className="button">
Edit
</Link>
<button className="danger" disabled={busy === post.slug} onClick={() => remove(post)}>
Delete
</button>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td colSpan={6} className="muted">
{posts.length ? "No posts match." : "No posts yet."}
</td>
</tr>
)}
</tbody>
</table>
<p className="muted small">
{visible.length} of {posts.length} posts
</p>
</>
);
}

131
components/PreviewFrame.tsx Normal file
View File

@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/client";
import { categoryLabel } from "@/lib/validate";
import { useSystemDark } from "@/lib/useSystemDark";
import { imageDescription, imageSrc, type GalleryImage } from "@/lib/types";
/* Live preview of a post page. The HTML comes from the site's own remark
* pipeline (/api/preview) and is painted inside an iframe that loads the
* site's real stylesheets (served from the checkout via /site/...), so the
* site's CSS can't leak into the manager and the preview matches the live
* page. The iframe shell is created once; updates swap its content in place
* so scroll position survives typing. */
const SHELL = `<!doctype html>
<html data-theme="light">
<head>
<meta charset="utf-8">
<base href="/site/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
<style>:root { --mono: "JetBrains Mono", monospace; --sans: "DM Sans", sans-serif; }</style>
<link rel="stylesheet" href="/site/app/globals.css">
<link rel="stylesheet" href="/site/styles/markdown-container.css">
<link rel="stylesheet" href="/site/styles/blog.css">
<style>
body { max-width: 880px; margin: 0 auto; padding: 8px 24px 48px; }
.bm-gallery { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 2em; }
.bm-gallery img, .bm-gallery video { height: 120px; border-radius: 4px; border: 1px solid var(--border); }
.bm-empty { color: var(--muted); font-family: var(--mono); font-size: 12px; }
</style>
</head>
<body>
<div id="blog-markdown-container" class="markdown-container"></div>
</body>
</html>`;
type Props = {
markdown: string;
category: string;
images: GalleryImage[];
title: string;
};
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
function galleryHtml(images: GalleryImage[], title: string): string {
if (!images.length) return "";
const items = images
.map((img) => {
const src = "/" + imageSrc(img);
const alt = escapeHtml(imageDescription(img) ?? title);
return /\.mp4$/i.test(src)
? `<video src="${escapeHtml(src)}" muted></video>`
: `<img src="${escapeHtml(src)}" alt="${alt}" title="${alt}">`;
})
.join("");
return `<section class="project-details-gallery"><h2>Gallery</h2><div class="bm-gallery">${items}</div></section>`;
}
export default function PreviewFrame({ markdown, category, images, title }: Props) {
const frameRef = useRef<HTMLIFrameElement>(null);
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [frameReady, setFrameReady] = useState(false);
// Follows the OS theme until toggled by hand.
const systemDark = useSystemDark();
const [themeOverride, setTheme] = useState<"light" | "dark" | null>(null);
const theme = themeOverride ?? (systemDark ? "dark" : "light");
// Debounced render through the site's pipeline.
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const res = await api<{ html: string }>("/api/preview", {
method: "POST",
json: { markdown },
signal: controller.signal,
});
setHtml(res.html);
setError(null);
} catch (err) {
if (!controller.signal.aborted) setError((err as Error).message);
}
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [markdown]);
useEffect(() => {
const doc = frameRef.current?.contentDocument;
if (!frameReady || !doc) return;
doc.documentElement.dataset.theme = theme;
const container = doc.getElementById("blog-markdown-container");
if (!container || html === null) return;
const chip = category
? `<a class="blog-category-chip is-active blog-post-category-chip">${escapeHtml(categoryLabel(category))}</a>`
: "";
const body = html.trim() ? `<main>${html}</main>` : `<p class="bm-empty">Nothing to preview yet.</p>`;
// Same structure as portfolio/app/blog/[slug]/page.tsx.
container.innerHTML = chip + body + galleryHtml(images, title);
}, [frameReady, html, category, images, title, theme]);
return (
<div className="pane">
<div className="pane-head">
<span>preview</span>
{error && <span className="badge err">{error}</span>}
<span className="spacer" />
<button type="button" onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme === "light" ? "☾ dark" : "☀ light"}
</button>
</div>
<div className="pane-body">
<iframe
ref={frameRef}
className="preview-frame"
title="Post preview"
srcDoc={SHELL}
onLoad={() => setFrameReady(true)}
/>
</div>
</div>
);
}

110
components/PublishPanel.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import type { GitStatus } from "@/lib/git";
import type { PublishStep } from "@/lib/publish";
type Config = { push: boolean; pushAuth: string; deploy: boolean; deployUrl: string | null };
function defaultMessage(status: GitStatus): string {
const n = status.changes.length;
return n === 1 && status.changes[0].path.endsWith("blog-posts.json") ? "blog: update posts" : `blog: update posts (${n} files)`;
}
export default function PublishPanel({ status: initial, config }: { status: GitStatus; config: Config }) {
const router = useRouter();
const [status, setStatus] = useState(initial);
const [message, setMessage] = useState(() => defaultMessage(initial));
const [running, setRunning] = useState(false);
const [result, setResult] = useState<{ ok: boolean; steps: PublishStep[] } | null>(null);
if (!status.available) {
return (
<div className="notice error">
git isn&apos;t usable on the portfolio checkout: <code>{status.error}</code>
</div>
);
}
const ahead = status.ahead ?? 0;
const nothingToDo = status.changes.length === 0 && ahead === 0;
async function run() {
setRunning(true);
setResult(null);
try {
const res = await api<{ ok: boolean; steps: PublishStep[]; status: GitStatus }>("/api/publish", {
method: "POST",
json: { message },
});
setResult(res);
setStatus(res.status);
} catch (err) {
setResult({ ok: false, steps: [{ step: "stage", status: "failed", output: (err as Error).message }] });
} finally {
setRunning(false);
router.refresh();
}
}
return (
<>
<p className="muted">
Branch <code>{status.branch}</code>
{status.ahead !== undefined && (
<>
{" "}· {status.ahead} ahead / {status.behind} behind upstream (as of last fetch)
</>
)}
. Only <code>data/blog-posts.json</code> and <code>public/img/blog/</code> are committed.
</p>
<h2>Pending changes</h2>
{status.changes.length === 0 ? (
<p className="muted">No uncommitted blog changes.{ahead > 0 && ` ${ahead} commit(s) waiting to be pushed.`}</p>
) : (
<table className="list" style={{ marginBottom: 12 }}>
<tbody>
{status.changes.map((c) => (
<tr key={c.path}>
<td className="mono small" style={{ width: 40 }}>
{c.code}
</td>
<td className="mono small">{c.path}</td>
</tr>
))}
</tbody>
</table>
)}
{status.diffStat && <pre className="log">{status.diffStat}</pre>}
<h2 style={{ marginTop: 24 }}>Publish</h2>
<p className="muted small">
commit → {config.push ? `push (${config.pushAuth})` : "push disabled (GIT_PUSH=false)"} →{" "}
{config.deploy ? `trigger redeploy at ${config.deployUrl}` : "redeploy trigger not configured"}
</p>
<div className="row" style={{ marginBottom: 16 }}>
<input
style={{ flex: 1, minWidth: 260 }}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="commit message"
/>
<button className="primary" disabled={running || nothingToDo || !message.trim()} onClick={run}>
{running ? "Publishing…" : "Publish"}
</button>
</div>
{result && (
<>
<div className={`notice ${result.ok ? "ok" : "error"}`}>{result.ok ? "Published." : "Publish failed. See log."}</div>
<pre className="log">
{result.steps.map((s) => `[${s.status}] ${s.step}${s.output ? `\n${s.output}` : ""}`).join("\n\n")}
</pre>
</>
)}
</>
);
}

57
components/TagInput.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useId, useState } from "react";
import { normalizeTags } from "@/lib/validate";
type Props = { value: string[]; onChange: (tags: string[]) => void; suggestions: string[] };
/** Chip-style tag entry: Enter or comma adds, Backspace on empty removes the last. */
export default function TagInput({ value, onChange, suggestions }: Props) {
const [draft, setDraft] = useState("");
const listId = useId();
function commit(text: string) {
if (!text.trim()) return;
onChange(normalizeTags([...value, ...text.split(",")]));
setDraft("");
}
return (
<div className="tag-input">
{value.map((tag) => (
<span key={tag} className="chip">
{tag}
<button type="button" aria-label={`remove ${tag}`} onClick={() => onChange(value.filter((t) => t !== tag))}>
×
</button>
</span>
))}
<input
list={listId}
value={draft}
placeholder={value.length ? "" : "add tags…"}
onChange={(e) => {
const v = e.target.value;
if (v.endsWith(",")) commit(v);
else setDraft(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(draft);
} else if (e.key === "Backspace" && !draft && value.length) {
onChange(value.slice(0, -1));
}
}}
onBlur={() => commit(draft)}
/>
<datalist id={listId}>
{suggestions
.filter((s) => !value.includes(s))
.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/client";
import { categoryLabel, slugify } from "@/lib/validate";
type Item = { name: string; count: number };
type Props = { kind: "category" | "tag"; items: Item[]; revision: string };
/** Rename / merge (both kinds) and remove (tags only) across every post. */
export default function TaxonomyTable({ kind, items, revision }: Props) {
const router = useRouter();
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const endpoint = kind === "category" ? "/api/categories" : "/api/tags";
const names = new Set(items.map((i) => i.name));
async function apply(from: string, to: string) {
const target = kind === "category" ? slugify(to) : to.trim();
if (kind === "category" && !target) return;
if (target === from) return;
if (!target && !window.confirm(`Remove the tag "${from}" from every post?`)) return;
if (target && names.has(target) && !window.confirm(`"${target}" already exists. Merge "${from}" into it?`)) return;
setBusy(from);
setMessage(null);
try {
const res = await api<{ changed: number }>(endpoint, { method: "PATCH", json: { from, to: target, revision } });
const verb = target ? `renamed to "${target}"` : "removed";
setMessage({ kind: "ok", text: `"${from}" ${verb} on ${res.changed} post(s).` });
setDrafts((d) => ({ ...d, [from]: "" }));
router.refresh();
} catch (err) {
setMessage({ kind: "error", text: (err as Error).message });
} finally {
setBusy(null);
}
}
return (
<>
{message && <div className={`notice ${message.kind}`}>{message.text}</div>}
<table className="list">
<thead>
<tr>
<th>{kind}</th>
{kind === "category" && <th>displayed as</th>}
<th>posts</th>
<th>rename / merge into</th>
<th />
</tr>
</thead>
<tbody>
{items.map((item) => {
const draft = drafts[item.name] ?? "";
return (
<tr key={item.name}>
<td>
<span className="chip">{item.name}</span>
</td>
{kind === "category" && <td>{categoryLabel(item.name)}</td>}
<td className="mono small">{item.count}</td>
<td>
<input
value={draft}
placeholder={kind === "category" ? "new-category-slug" : "new tag"}
onChange={(e) => setDrafts((d) => ({ ...d, [item.name]: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && draft.trim() && apply(item.name, draft)}
/>
</td>
<td className="actions">
<button disabled={!draft.trim() || busy === item.name} onClick={() => apply(item.name, draft)}>
{names.has(kind === "category" ? slugify(draft) : draft.trim()) ? "Merge" : "Rename"}
</button>
{kind === "tag" && (
<button className="danger" disabled={busy === item.name} onClick={() => apply(item.name, "")}>
Remove
</button>
)}
</td>
</tr>
);
})}
{items.length === 0 && (
<tr>
<td colSpan={5} className="muted">
None yet.
</td>
</tr>
)}
</tbody>
</table>
</>
);
}

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
blog-manager:
build:
context: .
dockerfile: Dockerfile
# No authentication: binds on all interfaces and relies on host firewall
# rules to restrict who can reach this port.
ports:
- "${BLOG_MANAGER_PORT:-3051}:3051"
# Run as the host user that owns the portfolio checkout, so files written
# into the bind mount (posts JSON, uploads, git objects) keep that owner.
# Set BLOG_MANAGER_UID / BLOG_MANAGER_GID in .env (`id -u`, `id -g`).
user: "${BLOG_MANAGER_UID:-1000}:${BLOG_MANAGER_GID:-1000}"
environment:
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
- REPO_DIR=/repo
# Commit identity (required unless the checkout's .git/config sets user.*).
- GIT_AUTHOR_NAME=${GIT_AUTHOR_NAME:-}
- GIT_AUTHOR_EMAIL=${GIT_AUTHOR_EMAIL:-}
# Push: set GIT_PUSH=false to only commit. For an HTTPS remote, supply a
# user + access token; they're handed to git via GIT_ASKPASS, never stored.
- GIT_PUSH=${GIT_PUSH:-true}
- GIT_PUSH_USER=${GIT_PUSH_USER:-}
- GIT_PUSH_TOKEN=${GIT_PUSH_TOKEN:-}
# Optional: the live site's redeploy hook (portfolio app/api/deploy).
- DEPLOY_URL=${DEPLOY_URL:-}
- DEPLOY_TRIGGER_SECRET=${DEPLOY_TRIGGER_SECRET:-}
volumes:
# The portfolio git checkout (read/write; .git is needed to publish).
- ${PORTFOLIO_DIR:-../portfolio}:/repo
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3051/api/health"]
interval: 60s
timeout: 5s
retries: 3
start_period: 10s

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

26
lib/client.ts Normal file
View File

@@ -0,0 +1,26 @@
/* Browser-side fetch helper for the JSON API. */
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown
) {
super(message);
}
}
export async function api<T = unknown>(url: string, init?: RequestInit & { json?: unknown }): Promise<T> {
const { json, ...rest } = init ?? {};
const res = await fetch(url, {
...rest,
headers: json !== undefined ? { "content-type": "application/json", ...rest.headers } : rest.headers,
body: json !== undefined ? JSON.stringify(json) : rest.body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const details = Array.isArray(data.details) ? `: ${data.details.join("; ")}` : "";
throw new ApiError(res.status, (data.error ?? res.statusText) + details, data.details);
}
return data as T;
}

10
lib/errors.ts Normal file
View File

@@ -0,0 +1,10 @@
/** An error that maps directly onto an HTTP response (see lib/http.ts). */
export class HttpError extends Error {
constructor(
public status: number,
message: string,
public details?: unknown
) {
super(message);
}
}

45
lib/files.ts Normal file
View File

@@ -0,0 +1,45 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { PUBLIC_DIR, REPO_DIR, safeJoin } from "./paths";
/* Read-only file serving from the portfolio checkout, so the preview iframe
* can load the site's real stylesheets and the post's images. */
const TYPES: Record<string, string> = {
".css": "text/css; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".mp4": "video/mp4",
};
/** Stylesheets the preview may load, keyed by their /site/... path. */
const SITE_CSS = new Set(["app/globals.css", "styles/markdown-container.css", "styles/blog.css"]);
async function send(file: string): Promise<Response> {
const type = TYPES[path.extname(file).toLowerCase()];
if (!type) return new Response("not found", { status: 404 });
try {
const body = await readFile(file);
return new Response(new Uint8Array(body), {
headers: { "content-type": type, "cache-control": "no-cache" },
});
} catch {
return new Response("not found", { status: 404 });
}
}
/** /site/<css allowlist> -> repo file; /site/<anything else> -> repo public/. */
export function serveSitePath(segments: string[]): Promise<Response> {
const rel = segments.join("/");
if (SITE_CSS.has(rel)) return send(path.join(REPO_DIR, rel));
return send(safeJoin(PUBLIC_DIR, ...segments));
}
export function servePublic(segments: string[]): Promise<Response> {
return send(safeJoin(PUBLIC_DIR, ...segments));
}

80
lib/git.ts Normal file
View File

@@ -0,0 +1,80 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { IMG_REL, POSTS_REL, REPO_DIR } from "./paths";
/* Thin wrapper over the git CLI, run against the portfolio checkout. No
* shell is involved (execFile), and every command is scoped to the paths the
* manager owns, so unrelated working-tree changes are never committed. */
export const MANAGED_PATHS = [POSTS_REL, IMG_REL];
export type GitResult = { ok: boolean; code: number; stdout: string; stderr: string };
function gitEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (process.env.GIT_PUSH_TOKEN) {
env.GIT_ASKPASS =
process.env.GIT_ASKPASS_PATH ?? path.join(/*turbopackIgnore: true*/ process.cwd(), "scripts", "git-askpass.sh");
}
return env;
}
function configArgs(): string[] {
// The bind-mounted repo is usually owned by a different uid than the
// container user; safe.directory from the command line is honored.
const args = ["-c", `safe.directory=${REPO_DIR}`];
if (process.env.GIT_AUTHOR_NAME) args.push("-c", `user.name=${process.env.GIT_AUTHOR_NAME}`);
if (process.env.GIT_AUTHOR_EMAIL) args.push("-c", `user.email=${process.env.GIT_AUTHOR_EMAIL}`);
return args;
}
export function git(args: string[], timeoutMs = 60_000): Promise<GitResult> {
return new Promise((resolve) => {
execFile(
"git",
[...configArgs(), ...args],
{ cwd: REPO_DIR, env: gitEnv(), timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
(err, stdout, stderr) => {
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
resolve({ ok: !err, code, stdout: String(stdout), stderr: String(stderr || err?.message || "") });
}
);
});
}
export type ChangedFile = { code: string; path: string };
export type GitStatus = {
available: boolean;
error?: string;
branch?: string;
/** Commits relative to the upstream as of the last fetch. */
ahead?: number;
behind?: number;
changes: ChangedFile[];
diffStat?: string;
};
export async function gitStatus(): Promise<GitStatus> {
const status = await git(["status", "--porcelain=v1", "-z", "-uall", "--", ...MANAGED_PATHS]);
if (!status.ok) return { available: false, error: status.stderr.trim(), changes: [] };
const changes: ChangedFile[] = [];
const entries = status.stdout.split("\0");
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (entry.length < 4) continue;
const code = entry.slice(0, 2);
changes.push({ code: code.trim() || code, path: entry.slice(3) });
if (code[0] === "R" || code[0] === "C") i++; // skip the rename source
}
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
let ahead: number | undefined;
let behind: number | undefined;
const counts = await git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]);
if (counts.ok) [behind, ahead] = counts.stdout.trim().split(/\s+/).map(Number);
const diffStat = (await git(["diff", "--stat", "HEAD", "--", ...MANAGED_PATHS])).stdout.trim();
return { available: true, branch, ahead, behind, changes, diffStat };
}

40
lib/http.ts Normal file
View File

@@ -0,0 +1,40 @@
import { HttpError } from "./errors";
/* There is deliberately no authentication (access is controlled by firewall
* rules). The one guard: mutating requests from a browser must originate
* from this app, so a page on another site can't POST to the LAN service. */
function checkOrigin(req: Request): void {
const origin = req.headers.get("origin");
if (!origin) return; // curl and other non-browser clients
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
throw new HttpError(403, "bad origin");
}
if (originHost !== req.headers.get("host")) throw new HttpError(403, "cross-origin request refused");
}
/** Wraps a route handler: origin check for writes + HttpError -> JSON response. */
export function route<C>(handler: (req: Request, ctx: C) => Promise<Response>) {
return async (req: Request, ctx: C): Promise<Response> => {
try {
if (req.method !== "GET" && req.method !== "HEAD") checkOrigin(req);
return await handler(req, ctx);
} catch (err) {
if (err instanceof HttpError) {
return Response.json({ error: err.message, details: err.details }, { status: err.status });
}
console.error("[blog-manager]", err);
return Response.json({ error: "internal error" }, { status: 500 });
}
};
}
export async function readJson<T = Record<string, unknown>>(req: Request): Promise<T> {
try {
return (await req.json()) as T;
} catch {
throw new HttpError(400, "request body must be JSON");
}
}

107
lib/images.ts Normal file
View File

@@ -0,0 +1,107 @@
import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { HttpError } from "./errors";
import { IMG_ROOT, safeJoin } from "./paths";
import { SLUG_RE } from "./validate";
import type { BlogPost, ImageFile } from "./types";
/* Uploaded media lives in portfolio/public/img/blog/<slug>/ and is referenced
* from posts as "img/blog/<slug>/<file>" (no leading slash, as the site's
* card and gallery components prepend "/"). */
export const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4"];
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
export function postImageDir(slug: string): string {
if (!SLUG_RE.test(slug)) throw new HttpError(400, "invalid slug");
return safeJoin(IMG_ROOT, slug);
}
export function imageRelSrc(slug: string, name: string): string {
return `img/blog/${slug}/${name}`;
}
async function exists(p: string): Promise<boolean> {
return stat(p).then(() => true, () => false);
}
export async function listImages(slug: string): Promise<ImageFile[]> {
const dir = postImageDir(slug);
let names: string[];
try {
names = await readdir(dir);
} catch {
return [];
}
const files: ImageFile[] = [];
for (const name of names.sort()) {
if (!ALLOWED_EXT.includes(path.extname(name).toLowerCase())) continue;
const info = await stat(path.join(dir, name));
if (info.isFile()) files.push({ name, src: imageRelSrc(slug, name), size: info.size });
}
return files;
}
/** "My Screenshot (2).PNG" -> "my-screenshot-2.png" */
function sanitizeName(original: string): string {
const ext = path.extname(original).toLowerCase();
const base = path
.basename(original, path.extname(original))
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[-.]+|-+$/g, "");
return (base || "image") + ext;
}
export async function saveUpload(slug: string, file: File): Promise<ImageFile> {
const name = sanitizeName(file.name);
const ext = path.extname(name);
if (!ALLOWED_EXT.includes(ext)) {
throw new HttpError(400, `${file.name}: only ${ALLOWED_EXT.join(", ")} files are allowed`);
}
if (file.size > MAX_UPLOAD_BYTES) {
throw new HttpError(413, `${file.name}: larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`);
}
const dir = postImageDir(slug);
await mkdir(dir, { recursive: true });
// Never overwrite: name.png, name-1.png, name-2.png, ...
const stem = path.basename(name, ext);
let finalName = name;
for (let i = 1; await exists(path.join(dir, finalName)); i++) finalName = `${stem}-${i}${ext}`;
await writeFile(safeJoin(dir, finalName), Buffer.from(await file.arrayBuffer()));
return { name: finalName, src: imageRelSrc(slug, finalName), size: file.size };
}
export async function deleteImage(slug: string, name: string): Promise<void> {
const target = safeJoin(postImageDir(slug), name);
if (path.dirname(target) !== postImageDir(slug)) throw new HttpError(400, "invalid file name");
await rm(target, { force: true });
}
export async function deleteImageDir(slug: string): Promise<void> {
await rm(postImageDir(slug), { recursive: true, force: true });
}
/**
* Moves a post's media folder on slug rename and rewrites every reference to
* it in the post (gallery entries and inline body links).
*/
export async function renameImageDir(post: BlogPost, from: string, to: string): Promise<void> {
const src = postImageDir(from);
const dest = postImageDir(to);
if (await exists(src)) {
if (await exists(dest)) throw new HttpError(409, `image folder for "${to}" already exists`);
await rename(src, dest);
}
const oldPrefix = `img/blog/${from}/`;
const newPrefix = `img/blog/${to}/`;
post.details = post.details.split(oldPrefix).join(newPrefix);
post.images = post.images?.map((img) =>
typeof img === "string"
? img.replace(oldPrefix, newPrefix)
: { ...img, src: img.src.replace(oldPrefix, newPrefix) }
);
}

20
lib/markdown.ts Normal file
View File

@@ -0,0 +1,20 @@
import { remark } from "remark";
import remarkParse from "remark-parse";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkRehype from "remark-rehype";
/**
* Identical to renderMarkdown() in portfolio/data/content.ts, so the preview
* renders exactly what the live post page will. allowDangerousHtml is
* intentional: posts embed raw HTML blocks (e.g. <blockquote>).
*/
export async function renderMarkdown(source: string): Promise<string> {
const re = await remark()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeStringify)
.process(source);
return re?.toString() ?? "";
}

26
lib/paths.ts Normal file
View File

@@ -0,0 +1,26 @@
import path from "node:path";
import { HttpError } from "./errors";
/** The portfolio checkout. In Docker it is bind-mounted at /repo; for local
* `npm run dev` it defaults to the sibling ../portfolio folder. */
export const REPO_DIR = path.resolve(/*turbopackIgnore: true*/
process.env.REPO_DIR ?? path.join(/*turbopackIgnore: true*/ process.cwd(), "..", "portfolio")
);
/** Repo-relative paths the manager owns. Publishing commits only these. */
export const POSTS_REL = "data/blog-posts.json";
export const IMG_REL = "public/img/blog";
export const POSTS_FILE = path.join(REPO_DIR, POSTS_REL);
export const PUBLIC_DIR = path.join(REPO_DIR, "public");
export const IMG_ROOT = path.join(REPO_DIR, IMG_REL);
/** path.resolve(root, ...segments), refusing anything that escapes root. */
export function safeJoin(root: string, ...segments: string[]): string {
const resolved = path.resolve(/*turbopackIgnore: true*/ root, ...segments);
const rel = path.relative(root, resolved);
if (rel.startsWith("..") || path.isAbsolute(rel)) {
throw new HttpError(400, "path escapes its root");
}
return resolved;
}

77
lib/publish.ts Normal file
View File

@@ -0,0 +1,77 @@
import { git, MANAGED_PATHS } from "./git";
/* Publish = commit the managed paths -> push -> ask the live site to
* redeploy via its existing POST /api/deploy route (which drops the flag
* scripts/deploy-watch.sh polls for). Each step is reported, and push /
* deploy are skipped rather than failed when they aren't configured. */
export type PublishStep = {
step: "stage" | "commit" | "push" | "deploy";
status: "ok" | "skipped" | "failed";
output: string;
};
function pushEnabled(): boolean {
return (process.env.GIT_PUSH ?? "true").toLowerCase() !== "false";
}
async function triggerDeploy(): Promise<PublishStep> {
const url = process.env.DEPLOY_URL;
const secret = process.env.DEPLOY_TRIGGER_SECRET;
if (!url || !secret) {
return { step: "deploy", status: "skipped", output: "DEPLOY_URL / DEPLOY_TRIGGER_SECRET not set" };
}
try {
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${secret}` },
signal: AbortSignal.timeout(15_000),
});
const body = await res.text();
return {
step: "deploy",
status: res.ok ? "ok" : "failed",
output: `${res.status} ${res.statusText} ${body}`.trim(),
};
} catch (err) {
return { step: "deploy", status: "failed", output: String(err) };
}
}
export async function publish(message: string): Promise<{ ok: boolean; steps: PublishStep[] }> {
const steps: PublishStep[] = [];
const done = (ok: boolean) => ({ ok, steps });
const add = await git(["add", "-A", "--", ...MANAGED_PATHS]);
steps.push({ step: "stage", status: add.ok ? "ok" : "failed", output: add.stderr.trim() });
if (!add.ok) return done(false);
const staged = await git(["diff", "--cached", "--name-only", "--", ...MANAGED_PATHS]);
const files = staged.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
if (files.length === 0) {
steps.push({ step: "commit", status: "skipped", output: "nothing to commit" });
} else {
// Naming the files commits exactly these, even if other changes are staged.
const commit = await git(["commit", "-m", message, "--", ...files]);
steps.push({
step: "commit",
status: commit.ok ? "ok" : "failed",
output: (commit.stdout + commit.stderr).trim(),
});
if (!commit.ok) return done(false);
}
if (!pushEnabled()) {
steps.push({ step: "push", status: "skipped", output: "GIT_PUSH=false" });
steps.push({ step: "deploy", status: "skipped", output: "push is disabled" });
return done(true);
}
const push = await git(["push"], 120_000);
steps.push({ step: "push", status: push.ok ? "ok" : "failed", output: (push.stdout + push.stderr).trim() });
if (!push.ok) return done(false);
const deploy = await triggerDeploy();
steps.push(deploy);
return done(deploy.status !== "failed");
}

118
lib/store.ts Normal file
View File

@@ -0,0 +1,118 @@
import { createHash } from "node:crypto";
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
import { HttpError } from "./errors";
import { POSTS_FILE, POSTS_REL } from "./paths";
import type { BlogPost } from "./types";
/* blog-posts.json is the store. Every mutation runs read -> check revision
* -> modify -> atomic write inside one in-process queue, so concurrent
* requests can't interleave and an edit made from a stale copy (e.g. after
* a git pull changed the file) is rejected with a 409 instead of clobbering. */
const KEY_ORDER: (keyof BlogPost)[] = [
"id", "slug", "title", "description", "date", "category", "tags", "images", "details",
];
function revisionOf(raw: string): string {
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
}
/** Stable key order + 2-space indent keeps git diffs small and readable. */
function serialize(posts: BlogPost[]): string {
const ordered = posts.map((post) => {
const out: Record<string, unknown> = {};
for (const key of KEY_ORDER) {
const value = post[key];
if (value === undefined) continue;
if (key === "images" && Array.isArray(value) && value.length === 0) continue;
out[key] = value;
}
// Preserve any fields this tool doesn't know about.
for (const [key, value] of Object.entries(post)) {
if (!(key in out) && !KEY_ORDER.includes(key as keyof BlogPost)) out[key] = value;
}
return out;
});
return JSON.stringify(ordered, null, 2) + "\n";
}
export async function readPosts(): Promise<{ posts: BlogPost[]; revision: string }> {
let raw: string;
try {
raw = await readFile(POSTS_FILE, "utf8");
} catch {
throw new HttpError(500, `cannot read ${POSTS_REL} (is REPO_DIR set to the portfolio checkout?)`);
}
let posts: unknown;
try {
posts = JSON.parse(raw);
} catch {
throw new HttpError(500, `${POSTS_REL} is not valid JSON`);
}
if (!Array.isArray(posts)) throw new HttpError(500, `${POSTS_REL} must contain an array`);
return { posts: posts as BlogPost[], revision: revisionOf(raw) };
}
async function writeAtomic(raw: string): Promise<void> {
const tmp = `${POSTS_FILE}.${process.pid}.tmp`;
await writeFile(tmp, raw, "utf8");
try {
await rename(tmp, POSTS_FILE);
} catch (err) {
// Windows can refuse to replace a file another process has open
// (editors, file watchers); fall back to an in-place write.
const code = (err as NodeJS.ErrnoException).code;
if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw err;
await writeFile(POSTS_FILE, raw, "utf8");
await unlink(tmp).catch(() => undefined);
}
}
let tail: Promise<unknown> = Promise.resolve();
/**
* Runs `fn` against the current posts (mutating the array in place) and
* writes the result. Pass the revision the client last saw to get optimistic
* concurrency; omit it to apply unconditionally.
*/
export function mutatePosts<T>(
expectedRevision: string | undefined,
fn: (posts: BlogPost[]) => T | Promise<T>
): Promise<{ result: T; revision: string }> {
const run = tail.then(async () => {
const { posts, revision } = await readPosts();
if (expectedRevision && expectedRevision !== revision) {
throw new HttpError(409, `${POSTS_REL} changed on disk since it was loaded`);
}
const result = await fn(posts);
const raw = serialize(posts);
await writeAtomic(raw);
return { result, revision: revisionOf(raw) };
});
tail = run.catch(() => undefined);
return run;
}
export function findPost(posts: BlogPost[], slug: string): BlogPost {
const post = posts.find((p) => p.slug === slug);
if (!post) throw new HttpError(404, `no post with slug "${slug}"`);
return post;
}
export type Count = { name: string; count: number };
/** Category slug -> post count, sorted by name. */
export function categoryCounts(posts: BlogPost[]): Count[] {
const counts = new Map<string, number>();
for (const p of posts) counts.set(p.category, (counts.get(p.category) ?? 0) + 1);
return [...counts].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
}
/** Tag -> post count, sorted by name (case-insensitive). */
export function tagCounts(posts: BlogPost[]): Count[] {
const counts = new Map<string, number>();
for (const p of posts) for (const t of p.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
return [...counts]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
}

38
lib/types.ts Normal file
View File

@@ -0,0 +1,38 @@
/* Mirrors BlogPost / GalleryImage in portfolio/data/content.ts. Keep in sync:
* the portfolio type-checks blog-posts.json against its own copy at build. */
export type GalleryImage = string | {
src: string;
/** Used as the image's alt text and tooltip. */
description?: string;
};
export type BlogPost = {
id: number;
slug: string; // kebab-case, URL-safe
title: string;
description: string; // 1-2 sentence excerpt shown on cards
date: string; // ISO date, e.g. "2026-09-14"
category: string; // slug, e.g. "meta" — one category per post
tags: string[];
images?: GalleryImage[]; // optional; first image is the card cover
details: string; // markdown body
};
/** A post as submitted by the editor: everything but the server-assigned id. */
export type PostInput = Omit<BlogPost, "id">;
export type ImageFile = {
name: string;
/** Path as stored in BlogPost.images (no leading slash). */
src: string;
size: number;
};
export function imageSrc(image: GalleryImage): string {
return typeof image === "string" ? image : image.src;
}
export function imageDescription(image: GalleryImage): string | undefined {
return typeof image === "string" ? undefined : image.description;
}

20
lib/useSystemDark.ts Normal file
View File

@@ -0,0 +1,20 @@
"use client";
import { useSyncExternalStore } from "react";
const QUERY = "(prefers-color-scheme: dark)";
function subscribe(onChange: () => void): () => void {
const mq = window.matchMedia(QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
/** Whether the OS is in dark mode (false during SSR). */
export function useSystemDark(): boolean {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(QUERY).matches,
() => false
);
}

103
lib/validate.ts Normal file
View File

@@ -0,0 +1,103 @@
/* Pure helpers shared by the editor (client) and the API (server). */
import type { GalleryImage, PostInput } from "./types";
export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/** "Hello, World!" -> "hello-world" */
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** Same as categoryLabel() in portfolio/data/content.ts. */
export function categoryLabel(slug: string): string {
return slug
.split(/[-_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
/** Trim, drop empties, dedupe case-insensitively (first spelling wins). */
export function normalizeTags(tags: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of tags) {
const tag = raw.trim();
const key = tag.toLowerCase();
if (!tag || seen.has(key)) continue;
seen.add(key);
out.push(tag);
}
return out;
}
export function todayIso(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function cleanImage(raw: unknown): GalleryImage | null {
const obj = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
const src = typeof raw === "string" ? raw : obj?.src;
if (typeof src !== "string") return null;
const cleaned = src.trim().replace(/^\/+/, "");
if (!cleaned || cleaned.split("/").includes("..")) return null;
const description = typeof obj?.description === "string" ? obj.description.trim() : "";
return description ? { src: cleaned, description } : cleaned;
}
/** Validates and normalizes an editor submission. */
export function validatePost(raw: unknown): { post: PostInput | null; errors: string[] } {
const errors: string[] = [];
const r = (raw ?? {}) as Record<string, unknown>;
const str = (key: string) => (typeof r[key] === "string" ? (r[key] as string).trim() : "");
const slug = str("slug");
const title = str("title");
const description = str("description");
const date = str("date");
const category = str("category");
const details = typeof r.details === "string" ? r.details : "";
if (!SLUG_RE.test(slug)) errors.push("slug must be lowercase kebab-case (a-z, 0-9, -)");
if (!title) errors.push("title is required");
if (!description) errors.push("description is required");
if (!DATE_RE.test(date) || Number.isNaN(Date.parse(date))) errors.push("date must be YYYY-MM-DD");
if (!SLUG_RE.test(category)) errors.push("category must be lowercase kebab-case");
if (!Array.isArray(r.tags) || r.tags.some((t) => typeof t !== "string")) {
errors.push("tags must be a list of strings");
}
const images: GalleryImage[] = [];
if (r.images !== undefined) {
if (!Array.isArray(r.images)) errors.push("images must be a list");
else
for (const item of r.images) {
const img = cleanImage(item);
if (img) images.push(img);
else errors.push("images contains an invalid path");
}
}
if (errors.length) return { post: null, errors };
return {
post: {
slug,
title,
description,
date,
category,
tags: normalizeTags(r.tags as string[]),
...(images.length ? { images } : {}),
details: !details || details.endsWith("\n") ? details : details + "\n",
},
errors,
};
}

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

7964
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "blog-manager",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3051",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3051",
"lint": "eslint"
},
"dependencies": {
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language-data": "^6.5.2",
"@uiw/react-codemirror": "^4.25.12",
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"typescript": "^5"
}
}

7
scripts/git-askpass.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
# GIT_ASKPASS helper: git calls this with a prompt and reads the answer from
# stdout. Credentials come from the environment and are never written to disk.
case "$1" in
Username*) echo "${GIT_PUSH_USER}" ;;
*) echo "${GIT_PUSH_TOKEN}" ;;
esac

21
tsconfig.json Normal file
View File

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