initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user