initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
26
.env.example
Normal file
26
.env.example
Normal 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
2
.gitattributes
vendored
Normal 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
10
.gitignore
vendored
Normal 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
33
Dockerfile
Normal 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
57
README.md
Normal 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.
|
||||||
35
app/api/categories/route.ts
Normal file
35
app/api/categories/route.ts
Normal 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
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
35
app/api/images/[slug]/route.ts
Normal file
35
app/api/images/[slug]/route.ts
Normal 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) });
|
||||||
|
});
|
||||||
51
app/api/posts/[slug]/route.ts
Normal file
51
app/api/posts/[slug]/route.ts
Normal 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
28
app/api/posts/route.ts
Normal 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
8
app/api/preview/route.ts
Normal 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
18
app/api/publish/route.ts
Normal 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
34
app/api/tags/route.ts
Normal 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
19
app/categories/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/img/[...path]/route.ts
Normal file
10
app/img/[...path]/route.ts
Normal 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
54
app/layout.tsx
Normal 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
287
app/manager.css
Normal 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
12
app/not-found.tsx
Normal 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't exist (it may have been renamed or deleted). <Link href="/">Back to posts</Link>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/page.tsx
Normal file
18
app/page.tsx
Normal 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
23
app/posts/[slug]/page.tsx
Normal 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
15
app/posts/new/page.tsx
Normal 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
20
app/publish/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/site/[...path]/route.ts
Normal file
9
app/site/[...path]/route.ts
Normal 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
18
app/tags/page.tsx
Normal 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
216
components/ImagePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
components/MarkdownEditor.tsx
Normal file
174
components/MarkdownEditor.tsx
Normal 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
257
components/PostEditor.tsx
Normal 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 & 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
127
components/PostTable.tsx
Normal 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
131
components/PreviewFrame.tsx
Normal 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
110
components/PublishPanel.tsx
Normal 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'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
57
components/TagInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
components/TaxonomyTable.tsx
Normal file
98
components/TaxonomyTable.tsx
Normal 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
38
docker-compose.yml
Normal 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
18
eslint.config.mjs
Normal 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
26
lib/client.ts
Normal 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
10
lib/errors.ts
Normal 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
45
lib/files.ts
Normal 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
80
lib/git.ts
Normal 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
40
lib/http.ts
Normal 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
107
lib/images.ts
Normal 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
20
lib/markdown.ts
Normal 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
26
lib/paths.ts
Normal 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
77
lib/publish.ts
Normal 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
118
lib/store.ts
Normal 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
38
lib/types.ts
Normal 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
20
lib/useSystemDark.ts
Normal 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
103
lib/validate.ts
Normal 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
7
next.config.ts
Normal 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
7964
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
7
scripts/git-askpass.sh
Normal 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
21
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user