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.
321 lines
8.3 KiB
JavaScript
321 lines
8.3 KiB
JavaScript
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();
|