commit e03ea4f770f416511abe40e575df82912daf4763 Author: Hunter Date: Sun Nov 1 13:23:44 2026 -0500 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..94ae479 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +*.md +.env* +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c97d664 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2258266 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The askpass helper runs under /bin/sh in the container; CRLF would break it. +*.sh text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45fff3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/node_modules +/.next/ +/out/ +/build +.DS_Store +npm-debug.log* +.env* +!.env.example +*.tsbuildinfo +next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..14886f0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..35f681b --- /dev/null +++ b/README.md @@ -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//` and are referenced as `img/blog//`. +- **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://: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. diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts new file mode 100644 index 0000000..7695a01 --- /dev/null +++ b/app/api/categories/route.ts @@ -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 }); +}); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..a5dfae0 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic"; + +export function GET() { + return Response.json({ ok: true }); +} diff --git a/app/api/images/[slug]/route.ts b/app/api/images/[slug]/route.ts new file mode 100644 index 0000000..a85e534 --- /dev/null +++ b/app/api/images/[slug]/route.ts @@ -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(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(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= */ +export const DELETE = route(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) }); +}); diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts new file mode 100644 index 0000000..fb54996 --- /dev/null +++ b/app/api/posts/[slug]/route.ts @@ -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(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(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(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 }); +}); diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts new file mode 100644 index 0000000..815c2b2 --- /dev/null +++ b/app/api/posts/route.ts @@ -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 }); +}); diff --git a/app/api/preview/route.ts b/app/api/preview/route.ts new file mode 100644 index 0000000..a7465d1 --- /dev/null +++ b/app/api/preview/route.ts @@ -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 ?? "") }); +}); diff --git a/app/api/publish/route.ts b/app/api/publish/route.ts new file mode 100644 index 0000000..6b6829c --- /dev/null +++ b/app/api/publish/route.ts @@ -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() }); +}); diff --git a/app/api/tags/route.ts b/app/api/tags/route.ts new file mode 100644 index 0000000..d3b0763 --- /dev/null +++ b/app/api/tags/route.ts @@ -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 }); +}); diff --git a/app/categories/page.tsx b/app/categories/page.tsx new file mode 100644 index 0000000..4c32a4f --- /dev/null +++ b/app/categories/page.tsx @@ -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 ( +
+

Categories

+

+ 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. +

+ +
+ ); +} diff --git a/app/img/[...path]/route.ts b/app/img/[...path]/route.ts new file mode 100644 index 0000000..d1572e9 --- /dev/null +++ b/app/img/[...path]/route.ts @@ -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]) +); diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7fa3b19 --- /dev/null +++ b/app/layout.tsx @@ -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 git unavailable; + } + const pending = status.changes.length; + const ahead = status.ahead ?? 0; + if (!pending && !ahead) return published; + return ( + + {pending ? `${pending} unpublished change${pending === 1 ? "" : "s"}` : `${ahead} commit(s) not pushed`} + + ); +} + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+
+ blog-manager + + + +
+ {children} +
+ + + ); +} diff --git a/app/manager.css b/app/manager.css new file mode 100644 index 0000000..ad8719b --- /dev/null +++ b/app/manager.css @@ -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; } +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..f25dc0c --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,12 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

Not found

+

+ That post doesn't exist (it may have been renamed or deleted). Back to posts +

+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..494c859 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+ c.name)} + /> +
+ ); +} diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx new file mode 100644 index 0000000..4dee90a --- /dev/null +++ b/app/posts/[slug]/page.tsx @@ -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 ( + c.name)} + tags={tagCounts(posts).map((t) => t.name)} + /> + ); +} diff --git a/app/posts/new/page.tsx b/app/posts/new/page.tsx new file mode 100644 index 0000000..81a730f --- /dev/null +++ b/app/posts/new/page.tsx @@ -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 ( + c.name)} + tags={tagCounts(posts).map((t) => t.name)} + /> + ); +} diff --git a/app/publish/page.tsx b/app/publish/page.tsx new file mode 100644 index 0000000..dd2e14c --- /dev/null +++ b/app/publish/page.tsx @@ -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 ( +
+

Publish

+ +
+ ); +} diff --git a/app/site/[...path]/route.ts b/app/site/[...path]/route.ts new file mode 100644 index 0000000..8620364 --- /dev/null +++ b/app/site/[...path]/route.ts @@ -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) +); diff --git a/app/tags/page.tsx b/app/tags/page.tsx new file mode 100644 index 0000000..86b40ac --- /dev/null +++ b/app/tags/page.tsx @@ -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 ( +
+

Tags

+

+ Free-form labels shown on post cards. Rename to an existing tag to merge, or remove a tag from + every post. +

+ +
+ ); +} diff --git a/components/ImagePanel.tsx b/components/ImagePanel.tsx new file mode 100644 index 0000000..5662f3d --- /dev/null +++ b/components/ImagePanel.tsx @@ -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// (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(null); + const [over, setOver] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+

Images

+ {error &&
{error}
} + + {gallery.length > 0 && ( + <> +

+ Gallery (shown below the post; the first image is also the card cover). Edits here are saved with the + post. +

+ + + {gallery.map((img, i) => ( + + + + + + + + ))} + +
+ {i === 0 ? "cover" : i + 1} + + {/* eslint-disable-next-line @next/next/no-img-element */} + + {imageSrc(img)} + + onGalleryChange(gallery.map((g, j) => (j === i ? withDescription(imageSrc(g), e.target.value) : g))) + } + /> + + + + +
+ {external.length > 0 && ( +

+ {external.length} gallery image(s) live outside public/img/blog/{slug ?? ""}/. +

+ )} + + )} + + {!slug ? ( +
Save the post once to enable image uploads.
+ ) : ( +
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}/`} + { + if (e.target.files) upload(e.target.files); + e.target.value = ""; + }} + /> +
+ )} + +
+ {files.map((file) => { + const inGallery = galleryIndex(file.src) >= 0; + return ( +
+
+ {/\.mp4$/i.test(file.name) ? ( +
+
+
+ {file.name} · {formatSize(file.size)} +
+
+ + + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/components/MarkdownEditor.tsx b/components/MarkdownEditor.tsx new file mode 100644 index 0000000..6744c7a --- /dev/null +++ b/components/MarkdownEditor.tsx @@ -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(); + 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 = "
\nQuote text\n
"; + +export default function MarkdownEditor({ value, onChange, onView }: Props) { + const [view, setView] = useState(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: "", 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 ( +
+
+ {tools.map((tool, i) => + tool === "sep" ? ( + + ) : ( + + ) + )} + + markdown +
+
+ { + setView(v); + onView?.(v); + }} + /> +
+
+ ); +} diff --git a/components/PostEditor.tsx b/components/PostEditor.tsx new file mode 100644 index 0000000..1bc1a53 --- /dev/null +++ b/components/PostEditor.tsx @@ -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: () =>
, +}); + +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(null); + + const [form, setForm] = useState
(() => toForm(post)); + const [baseline, setBaseline] = useState(() => JSON.stringify(toForm(post))); + const [savedSlug, setSavedSlug] = useState(post?.slug ?? null); + const [slugTouched, setSlugTouched] = useState(Boolean(post)); + const [revision, setRevision] = useState(initialRevision); + const [files, setFiles] = useState(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 = (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) + ? `` + : `![${file.name.replace(/\.[^.]+$/, "").replace(/[-_]+/g, " ")}](${src})`; + insertText(view, snippet, true); + } + + const isNew = !savedSlug; + + return ( +
+
+

{isNew ? "New post" : form.title || savedSlug}

+ {dirty && unsaved} + + Back + + + +
+ + {conflict && ( +
+ blog-posts.json changed on disk since this page loaded (another edit, or a git pull). + + +
+ )} + {message &&
{message.text}
} + +
+ + + + +