feat: add admin publishing workflow and yar theme

Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

View file

@ -0,0 +1,321 @@
const state = {
user: null,
posts: [],
currentPost: null,
};
const el = {
loginView: document.querySelector("#loginView"),
appView: document.querySelector("#appView"),
loginForm: document.querySelector("#loginForm"),
loginUsername: document.querySelector("#loginUsername"),
loginPassword: document.querySelector("#loginPassword"),
loginMessage: document.querySelector("#loginMessage"),
userBadge: document.querySelector("#userBadge"),
logoutButton: document.querySelector("#logoutButton"),
newPostButton: document.querySelector("#newPostButton"),
statusFilter: document.querySelector("#statusFilter"),
postList: document.querySelector("#postList"),
postForm: document.querySelector("#postForm"),
editorMode: document.querySelector("#editorMode"),
editorTitle: document.querySelector("#editorTitle"),
titleInput: document.querySelector("#titleInput"),
slugInput: document.querySelector("#slugInput"),
statusInput: document.querySelector("#statusInput"),
coverInput: document.querySelector("#coverInput"),
tagsInput: document.querySelector("#tagsInput"),
summaryInput: document.querySelector("#summaryInput"),
bodyInput: document.querySelector("#bodyInput"),
publishButton: document.querySelector("#publishButton"),
editorMessage: document.querySelector("#editorMessage"),
};
async function api(path, options = {}) {
const response = await fetch(`/api/admin${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (response.status === 204) {
return null;
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error ?? `HTTP ${response.status}`);
}
return data;
}
function showLogin() {
el.loginView.hidden = false;
el.appView.hidden = true;
el.loginPassword.focus();
}
function showApp() {
el.loginView.hidden = true;
el.appView.hidden = false;
el.userBadge.textContent = state.user?.username ?? "";
}
async function bootstrap() {
try {
const data = await api("/me");
state.user = data.user;
showApp();
await loadPosts();
resetEditor();
} catch {
showLogin();
}
}
async function loadPosts() {
const status = el.statusFilter.value;
const query = status ? `?status=${encodeURIComponent(status)}` : "";
const data = await api(`/posts${query}`);
state.posts = data.posts ?? [];
renderPostList();
}
function renderPostList() {
el.postList.innerHTML = "";
if (state.posts.length === 0) {
const empty = document.createElement("p");
empty.className = "message";
empty.textContent = "暂无文章";
el.postList.append(empty);
return;
}
for (const post of state.posts) {
const button = document.createElement("button");
button.type = "button";
button.className = "post-item";
if (state.currentPost?.id === post.id) {
button.classList.add("active");
}
button.innerHTML = `
<span class="post-item-title"></span>
<span class="post-item-meta"></span>
`;
button.querySelector(".post-item-title").textContent = post.title || "未命名";
button.querySelector(".post-item-meta").textContent = `${statusText(post.status)} / ${formatDate(post.updatedAt)}`;
button.addEventListener("click", () => selectPost(post.id));
el.postList.append(button);
}
}
async function selectPost(id) {
const data = await api(`/posts/${id}`);
state.currentPost = data.post;
fillEditor(data.post);
renderPostList();
}
function resetEditor() {
state.currentPost = null;
el.editorMode.textContent = "新文章";
el.editorTitle.textContent = "开始写作";
el.postForm.reset();
el.statusInput.value = "draft";
el.editorMessage.textContent = "";
renderPostList();
}
function fillEditor(post) {
el.editorMode.textContent = `版本 ${post.version}`;
el.editorTitle.textContent = post.title || "未命名";
el.titleInput.value = post.title ?? "";
el.slugInput.value = post.slug ?? "";
el.statusInput.value = post.status ?? "draft";
el.coverInput.value = post.cover ?? "";
el.tagsInput.value = (post.tags ?? []).join(", ");
el.summaryInput.value = post.summary ?? "";
el.bodyInput.value = post.bodyMarkdown ?? "";
el.editorMessage.textContent = "";
}
function readPostInput() {
return {
title: el.titleInput.value.trim(),
slug: el.slugInput.value.trim(),
status: el.statusInput.value,
cover: el.coverInput.value.trim(),
tags: parseTags(el.tagsInput.value),
summary: el.summaryInput.value.trim(),
bodyMarkdown: el.bodyInput.value,
slugSource: "manual",
slugLocked: true,
};
}
async function savePost() {
const input = readPostInput();
const path = state.currentPost ? `/posts/${state.currentPost.id}` : "/posts";
const method = state.currentPost ? "PUT" : "POST";
const data = await api(path, {
method,
body: JSON.stringify(input),
});
state.currentPost = data.post;
fillEditor(data.post);
await loadPosts();
el.editorMessage.textContent = "已保存";
}
async function publishPost() {
if (!state.currentPost) {
await savePost();
}
const data = await api(`/posts/${state.currentPost.id}/publish`, { method: "POST" });
state.currentPost = data.post;
fillEditor(data.post);
await loadPosts();
el.editorMessage.textContent = "已开始构建";
if (data.buildJob?.id) {
pollBuildJob(data.buildJob.id);
}
}
async function pollBuildJob(id) {
for (;;) {
await wait(1400);
const data = await api(`/build-jobs/${id}`);
const job = data.buildJob;
el.editorMessage.textContent = `构建状态:${buildStatusText(job.status)}`;
if (["success", "failed", "cancelled"].includes(job.status)) {
if (job.error) {
el.editorMessage.textContent = `构建失败:${job.error}`;
}
return;
}
}
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function statusText(status) {
return {
draft: "草稿",
published: "已发布",
archived: "归档",
deleted: "已删除",
}[status] ?? status;
}
function buildStatusText(status) {
return {
queued: "等待中",
running: "构建中",
success: "成功",
failed: "失败",
cancelled: "已取消",
}[status] ?? status;
}
function formatDate(value) {
if (!value) {
return "无时间";
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function slugify(value) {
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function parseTags(value) {
const seen = new Set();
return value
.split(/[,]/)
.map((tag) => tag.trim())
.filter((tag) => {
const key = tag.toLowerCase();
if (!tag || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
el.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
el.loginMessage.textContent = "";
try {
const data = await api("/login", {
method: "POST",
body: JSON.stringify({
username: el.loginUsername.value.trim(),
password: el.loginPassword.value,
}),
});
state.user = data.user;
showApp();
await loadPosts();
resetEditor();
} catch (error) {
el.loginMessage.textContent = error.message;
}
});
el.logoutButton.addEventListener("click", async () => {
await api("/logout", { method: "POST" });
state.user = null;
state.posts = [];
state.currentPost = null;
showLogin();
});
el.newPostButton.addEventListener("click", resetEditor);
el.statusFilter.addEventListener("change", loadPosts);
el.titleInput.addEventListener("input", () => {
if (!state.currentPost && !el.slugInput.value.trim()) {
el.slugInput.value = slugify(el.titleInput.value);
}
});
el.postForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await savePost();
} catch (error) {
el.editorMessage.textContent = error.message;
}
});
el.publishButton.addEventListener("click", async () => {
try {
await publishPost();
} catch (error) {
el.editorMessage.textContent = error.message;
}
});
bootstrap();