blog / src /components /admin /AdminApp.svelte
cacode's picture
Upload 439 files
99cf942 verified
<script lang="ts">
import { onMount } from "svelte";
import type { AdminSnapshot } from "@/admin/types";
import { renderAdminMarkdown } from "./markdown-preview";
type NavKey =
| "overview"
| "site"
| "appearance"
| "navigation"
| "posts"
| "pages"
| "media"
| "advanced"
| "build";
type ToastState = {
tone: "success" | "error" | "info";
text: string;
};
type BuildState = {
status: string;
startedAt: string | null;
finishedAt: string | null;
lastBuiltAt: string | null;
lastDurationMs: number | null;
lastError: string;
queueLength?: number;
queuedReason?: string;
logs: string[];
};
type DashboardState = {
posts: number;
drafts: number;
pages: number;
assets: number;
tags: number;
categories: number;
build: BuildState;
};
type PostListItem = {
slug: string;
title: string;
description?: string;
published?: string;
draft?: boolean;
pinned?: boolean;
};
type EditablePost = {
slug: string;
title: string;
published: string;
updated: string;
description: string;
image: string;
tags: string[];
category: string;
lang: string;
draft: boolean;
pinned: boolean;
comment: boolean;
author: string;
sourceLink: string;
licenseName: string;
licenseUrl: string;
body: string;
extension: "md" | "mdx";
__originalSlug?: string;
};
type PageRecord = {
slug: string;
filePath?: string;
body: string;
modifiedAt?: string;
};
type AssetRecord = {
root: "public" | "content";
path: string;
name: string;
directory: string;
size: number;
modifiedAt: string;
previewUrl: string;
reference: string;
};
type EditableConfig = {
site: Record<string, any>;
navbar: Record<string, any>;
sidebar: Record<string, any>;
profile: Record<string, any>;
wallpaper: Record<string, any>;
announcement: Record<string, any>;
footer: Record<string, any>;
comment: Record<string, any>;
friends: Record<string, any>;
sponsor: Record<string, any>;
music: Record<string, any>;
pio: Record<string, any>;
ad: Record<string, any>;
license: Record<string, any>;
coverImage: Record<string, any>;
font: Record<string, any>;
sakura: Record<string, any>;
};
type JsonSectionKey =
| "site"
| "navbar"
| "sidebar"
| "friends"
| "sponsor"
| "ad"
| "comment"
| "music"
| "pio"
| "coverImage"
| "font"
| "sakura";
export let initialSnapshot: AdminSnapshot;
export let entryPath = "/admin";
const navItems: Array<{ key: NavKey; label: string; hint: string }> = [
{ key: "overview", label: "总览", hint: "状态、统计与快捷操作" },
{ key: "site", label: "站点", hint: "标题、描述、分页与主题" },
{ key: "appearance", label: "外观", hint: "资料、公告、页脚与壁纸" },
{ key: "navigation", label: "导航", hint: "导航栏与侧栏布局" },
{ key: "posts", label: "文章", hint: "Markdown 写作与预览" },
{ key: "pages", label: "页面", hint: "关于页、友链页等固定页" },
{ key: "media", label: "图片", hint: "上传、预览、复制引用路径" },
{ key: "advanced", label: "扩展", hint: "评论、音乐、赞助等高级配置" },
{ key: "build", label: "构建", hint: "构建日志与发布状态" },
];
function cloneData<T>(value: T): T {
if (value === null || value === undefined || typeof value !== "object") {
return value;
}
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value)) as T;
}
}
function prettyJson(value: unknown) {
return JSON.stringify(value, null, 2);
}
function buildInitialConfig(snapshot: AdminSnapshot): EditableConfig {
return normalizeConfig({
site: cloneData(snapshot.configs.siteConfig),
navbar: { links: cloneData(snapshot.configs.navBarConfig.links) },
sidebar: cloneData(snapshot.configs.sidebarConfig),
profile: cloneData(snapshot.configs.profileConfig),
wallpaper: cloneData(snapshot.configs.backgroundWallpaper),
announcement: cloneData(snapshot.configs.announcementConfig),
footer: cloneData(snapshot.configs.footerConfig),
comment: cloneData(snapshot.configs.commentConfig),
friends: {
page: cloneData(snapshot.configs.friendsConfig.friendsPageConfig),
items: cloneData(snapshot.configs.friendsConfig.friendsConfig),
},
sponsor: cloneData(snapshot.configs.sponsorConfig),
music: cloneData(snapshot.configs.musicConfig),
pio: {
spine: cloneData(snapshot.configs.pioConfig.spineModelConfig),
live2d: cloneData(snapshot.configs.pioConfig.live2dModelConfig),
},
ad: {
ad1: cloneData(snapshot.configs.adConfig.adConfig1),
ad2: cloneData(snapshot.configs.adConfig.adConfig2),
},
license: cloneData(snapshot.configs.licenseConfig),
coverImage: cloneData(snapshot.configs.coverImageConfig),
font: cloneData(snapshot.configs.fontConfig),
sakura: cloneData(snapshot.configs.sakuraConfig),
});
}
function normalizeConfig(input: EditableConfig): EditableConfig {
const next = cloneData(input);
next.site ||= {};
next.site.themeColor ||= { hue: 165, fixed: false, defaultMode: "system" };
next.site.card ||= { border: true };
next.site.pages ||= { sponsor: false, guestbook: false, bangumi: false };
next.site.postListLayout ||= { defaultMode: "list", allowSwitch: true, grid: { masonry: false, columns: 2 } };
next.site.postListLayout.grid ||= { masonry: false, columns: 2 };
next.site.pagination ||= { postsPerPage: 8 };
next.site.analytics ||= { googleAnalyticsId: "", microsoftClarityId: "" };
next.font ||= cloneData(next.site.font || {});
next.site.font = cloneData(next.font);
next.navbar ||= { links: [] };
next.sidebar ||= { enable: true, leftComponents: [], rightComponents: [], mobileBottomComponents: [] };
next.profile ||= { links: [] };
next.profile.links ||= [];
next.wallpaper ||= { mode: "banner", switchable: true, src: { desktop: "", mobile: "" } };
if (typeof next.wallpaper.src !== "object" || next.wallpaper.src === null || Array.isArray(next.wallpaper.src)) {
next.wallpaper.src = { desktop: typeof next.wallpaper.src === "string" ? next.wallpaper.src : "", mobile: "" };
}
next.wallpaper.banner ||= {};
next.wallpaper.banner.homeText ||= { enable: true, title: "", subtitle: [], titleSize: "", subtitleSize: "", typewriter: { enable: true, speed: 100, deleteSpeed: 50, pauseTime: 2000 } };
next.wallpaper.banner.homeText.typewriter ||= { enable: true, speed: 100, deleteSpeed: 50, pauseTime: 2000 };
next.wallpaper.banner.credit ||= { enable: false, text: "", url: "" };
next.wallpaper.banner.waves ||= { enable: true };
next.wallpaper.banner.navbar ||= { transparentMode: "semifull", enableBlur: true, blur: 3 };
next.announcement ||= { link: { enable: false, text: "", url: "", external: false } };
next.announcement.link ||= { enable: false, text: "", url: "", external: false };
next.footer ||= { enable: false, customHtml: "" };
next.comment ||= {};
next.friends ||= { page: { columns: 2 }, items: [] };
next.friends.page ||= { columns: 2 };
next.friends.items ||= [];
next.sponsor ||= { methods: [], sponsors: [] };
next.sponsor.methods ||= [];
next.sponsor.sponsors ||= [];
next.music ||= {};
next.pio ||= { spine: {}, live2d: {} };
next.pio.spine ||= {};
next.pio.live2d ||= {};
next.ad ||= { ad1: {}, ad2: {} };
next.ad.ad1 ||= {};
next.ad.ad2 ||= {};
next.license ||= {};
next.coverImage ||= { randomCoverImage: { enable: false, apis: [] } };
next.coverImage.randomCoverImage ||= { enable: false, apis: [] };
next.sakura ||= {};
return next;
}
function createEmptyPost(): EditablePost {
return {
slug: "",
title: "",
published: new Date().toISOString().slice(0, 10),
updated: "",
description: "",
image: "",
tags: [],
category: "",
lang: "",
draft: true,
pinned: false,
comment: true,
author: "",
sourceLink: "",
licenseName: "",
licenseUrl: "",
body: "# 新文章\n\n从这里开始写作。",
extension: "md",
};
}
function mapPostDraft(value: Record<string, any>): EditablePost {
return {
...createEmptyPost(),
...cloneData(value),
body: String(value.body ?? value.content ?? ""),
extension: value.extension === "mdx" ? "mdx" : "md",
__originalSlug: String(value.slug ?? value.__originalSlug ?? "") || undefined,
};
}
function mapSnapshotAsset(image: any): AssetRecord {
const reference =
image.sitePath ||
(image.origin === "public"
? `/${String(image.path || "").replace(/^public\//, "")}`
: String(image.path || "").replace(/^src\/content\/posts\//, ""));
return {
root: image.origin,
path:
image.origin === "public"
? String(image.path || "").replace(/^public\//, "")
: String(image.path || "").replace(/^src\/content\/posts\//, ""),
name: image.name,
directory: image.directory || "",
size: Number(image.size || 0),
modifiedAt: image.updatedAt || new Date().toISOString(),
previewUrl: image.url,
reference,
};
}
let sessionLoading = true;
let sessionRefreshing = false;
let sessionResolved = false;
let authenticated = false;
let configured = true;
let username = "";
let loginSubmitting = false;
let activeNav: NavKey = "overview";
let toast: ToastState | null = null;
let loginForm = { username: "", password: "" };
let configs: EditableConfig = buildInitialConfig(initialSnapshot);
let sectionEditors: Record<JsonSectionKey, string> = {
site: "{}",
navbar: "{}",
sidebar: "{}",
friends: "{}",
sponsor: "{}",
ad: "{}",
comment: "{}",
music: "{}",
pio: "{}",
coverImage: "{}",
font: "{}",
sakura: "{}",
};
let profileLinksJson = "[]";
let postsList: PostListItem[] = initialSnapshot.posts.map((post) => ({
slug: post.slug,
title: post.title,
description: post.description,
published: post.published,
draft: post.draft,
pinned: post.pinned,
}));
let postDraft: EditablePost = initialSnapshot.posts.length > 0 ? mapPostDraft(initialSnapshot.posts[0]) : createEmptyPost();
let selectedPostSlug: string | null = initialSnapshot.posts[0]?.slug ?? null;
let postSearch = "";
let specPages: PageRecord[] = initialSnapshot.specPages.map((page) => cloneData(page));
let selectedPageSlug = initialSnapshot.specPages[0]?.slug ?? "";
let pageDraftBody = initialSnapshot.specPages[0]?.body ?? "";
let mediaItems: AssetRecord[] = initialSnapshot.images.map((image) => mapSnapshotAsset(image));
let mediaSearch = "";
let uploadRoot: "public" | "content" = "public";
let uploadFolder = "uploads/posts";
let uploadName = "";
let uploadDataUrl = "";
let buildState: BuildState = {
status: "idle",
startedAt: null,
finishedAt: null,
lastBuiltAt: null,
lastDurationMs: null,
lastError: "",
queueLength: 0,
queuedReason: "",
logs: [],
};
let dashboard: DashboardState = {
posts: postsList.length,
drafts: postsList.filter((post) => post.draft).length,
pages: specPages.length,
assets: mediaItems.length,
tags: 0,
categories: 0,
build: buildState,
};
let filteredPosts: PostListItem[] = postsList;
let filteredAssets: AssetRecord[] = mediaItems;
let postPreview = renderAdminMarkdown(postDraft.body || "");
let pagePreview = renderAdminMarkdown(pageDraftBody || "");
function syncEditors() {
configs = normalizeConfig(configs);
sectionEditors.site = prettyJson(configs.site);
sectionEditors.navbar = prettyJson(configs.navbar);
sectionEditors.sidebar = prettyJson(configs.sidebar);
sectionEditors.friends = prettyJson(configs.friends);
sectionEditors.sponsor = prettyJson(configs.sponsor);
sectionEditors.ad = prettyJson(configs.ad);
sectionEditors.comment = prettyJson(configs.comment);
sectionEditors.music = prettyJson(configs.music);
sectionEditors.pio = prettyJson(configs.pio);
sectionEditors.coverImage = prettyJson(configs.coverImage);
sectionEditors.font = prettyJson(configs.font);
sectionEditors.sakura = prettyJson(configs.sakura);
profileLinksJson = prettyJson(configs.profile.links || []);
}
syncEditors();
function showToast(tone: ToastState["tone"], text: string) {
toast = { tone, text };
window.setTimeout(() => {
if (toast?.text === text) toast = null;
}, 2600);
}
function formatDateTime(value?: string | null) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function buildLabel(status: string) {
switch (status) {
case "building":
return "构建中";
case "success":
return "最近成功";
case "error":
return "最近失败";
default:
return status === "idle" ? "待命中" : "排队中";
}
}
async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "same-origin",
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || data.message || `Request failed (${response.status})`);
}
return data as T;
}
function applyBuild(data: any) {
if (data?.build) {
buildState = data.build;
}
if (dashboard) {
dashboard = { ...dashboard, build: buildState };
}
}
async function refreshSession() {
sessionLoading = true;
sessionRefreshing = sessionResolved;
try {
const data = await api<any>("/api/admin/session");
authenticated = Boolean(data.authenticated);
configured = data.configured !== false;
username = String(data.username || "");
if (data.build) applyBuild(data);
} catch (error) {
authenticated = false;
configured = true;
showToast(
"error",
error instanceof Error ? error.message : "后台会话检查失败",
);
} finally {
sessionResolved = true;
sessionLoading = false;
sessionRefreshing = false;
}
}
async function loadBuild() {
const data = await api<BuildState>("/api/admin/build");
buildState = data;
if (dashboard) dashboard = { ...dashboard, build: buildState };
}
async function loadDashboard() {
const data = await api<DashboardState>("/api/admin/dashboard");
dashboard = data;
buildState = data.build;
}
async function loadConfig() {
const data = await api<{ config: EditableConfig }>("/api/admin/config");
configs = normalizeConfig(data.config);
syncEditors();
}
async function loadPosts() {
const data = await api<{ posts: PostListItem[] }>("/api/admin/posts");
postsList = data.posts || [];
filteredPosts = postsList;
}
async function loadPages() {
const data = await api<{ pages: PageRecord[] }>("/api/admin/pages");
specPages = data.pages || [];
if (selectedPageSlug) {
const current = specPages.find((page) => page.slug === selectedPageSlug);
if (current) pageDraftBody = current.body;
}
}
async function loadAssets() {
const data = await api<{ assets: AssetRecord[] }>("/api/admin/assets");
mediaItems = data.assets || [];
filteredAssets = mediaItems;
}
async function loadAll() {
await Promise.all([loadConfig(), loadDashboard(), loadPosts(), loadPages(), loadAssets(), loadBuild()]);
}
function parseKeywords(value: string) {
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
function setWallpaperSubtitles(value: string) {
configs.wallpaper.banner.homeText.subtitle = value
.split(/\r?\n/)
.map((item: string) => item.trim())
.filter(Boolean);
}
function getWallpaperSubtitlesText() {
const subtitle = configs.wallpaper.banner.homeText.subtitle;
return Array.isArray(subtitle) ? subtitle.join("\n") : String(subtitle || "");
}
function parseEditor<T>(label: string, raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch (error) {
showToast("error", `${label} 不是有效的 JSON:${error instanceof Error ? error.message : "解析失败"}`);
return null;
}
}
async function saveSection(section: string, label: string, data: unknown) {
const response = await api<any>(`/api/admin/config/${section}`, {
method: "PUT",
body: JSON.stringify(data),
});
applyBuild(response);
showToast("success", `${label}已保存并加入构建队列`);
}
async function saveSite() {
configs.site.font = cloneData(configs.font);
await saveSection("site", "站点设置", configs.site);
syncEditors();
}
async function saveProfile() {
const parsed = parseEditor<any[]>("资料链接", profileLinksJson);
if (!parsed) return;
configs.profile.links = parsed;
await saveSection("profile", "个人资料", configs.profile);
syncEditors();
}
async function saveWallpaper() {
await saveSection("wallpaper", "壁纸设置", configs.wallpaper);
}
async function saveAnnouncement() {
await saveSection("announcement", "公告设置", configs.announcement);
}
async function saveFooter() {
await saveSection("footer", "页脚设置", configs.footer);
}
async function saveJsonSection(section: JsonSectionKey, label: string) {
const parsed = parseEditor<Record<string, any>>(label, sectionEditors[section]);
if (!parsed) return;
if (section === "font") {
configs.font = parsed;
configs.site.font = cloneData(parsed);
} else {
(configs as Record<string, any>)[section] = parsed;
}
await saveSection(section, label, parsed);
syncEditors();
}
function applyLoginSearchFeedback() {
const currentUrl = new URL(window.location.href);
const loginStatus = currentUrl.searchParams.get("login");
if (loginStatus === "invalid") {
showToast("error", "账号或密码错误,请重新输入");
} else if (loginStatus === "disabled") {
showToast("error", "后台尚未启用,请先配置 ADMIN 和 PASSWORD");
}
if (!loginStatus) {
return;
}
currentUrl.searchParams.delete("login");
const nextPath = currentUrl.pathname + currentUrl.search + currentUrl.hash;
window.history.replaceState({}, "", nextPath || currentUrl.pathname);
}
async function handleLogin(event?: SubmitEvent) {
event?.preventDefault();
loginSubmitting = true;
try {
const response = await api<any>("/api/admin/login", {
method: "POST",
body: JSON.stringify(loginForm),
});
authenticated = Boolean(response.authenticated);
configured = response.configured !== false;
sessionResolved = true;
username = String(response.username || loginForm.username || "");
loginForm.password = "";
applyBuild(response);
await loadAll();
showToast("success", "登录成功");
} catch (error) {
showToast("error", error instanceof Error ? error.message : "\u767b\u5f55\u5931\u8d25");
} finally {
loginSubmitting = false;
}
}
async function handleLogout() {
await api("/api/admin/logout", { method: "POST", body: JSON.stringify({}) });
authenticated = false;
username = "";
sessionResolved = true;
loginForm.password = "";
showToast("info", "已退出后台");
}
function createNewPost() {
selectedPostSlug = null;
postDraft = createEmptyPost();
activeNav = "posts";
}
async function openPost(slug: string) {
const data = await api<Record<string, any>>(`/api/admin/posts/${encodeURIComponent(slug)}`);
selectedPostSlug = slug;
postDraft = mapPostDraft(data);
}
function duplicatePost() {
postDraft = {
...cloneData(postDraft),
slug: `${postDraft.slug || "post"}-copy`,
title: `${postDraft.title || "未命名文章"}(副本)`,
__originalSlug: undefined,
};
selectedPostSlug = null;
showToast("info", "已创建文章副本,请检查 slug 后保存");
}
async function savePost(publish = false) {
const payload = { ...postDraft, body: postDraft.body, publish };
const url = selectedPostSlug
? `/api/admin/posts/${encodeURIComponent(selectedPostSlug)}`
: "/api/admin/posts";
const method = selectedPostSlug ? "PUT" : "POST";
const response = await api<any>(url, {
method,
body: JSON.stringify(payload),
});
selectedPostSlug = response.post.slug;
postDraft = mapPostDraft(response.post);
applyBuild(response);
await Promise.all([loadPosts(), loadDashboard()]);
showToast("success", publish ? "文章已保存并加入构建队列" : "文章已保存");
}
async function removePost() {
if (!selectedPostSlug) return;
if (!window.confirm(`确定删除文章 ${selectedPostSlug} 吗?`)) return;
const response = await api<any>(`/api/admin/posts/${encodeURIComponent(selectedPostSlug)}`, {
method: "DELETE",
});
applyBuild(response);
createNewPost();
await Promise.all([loadPosts(), loadDashboard()]);
showToast("success", "文章已删除");
}
function selectPage(page: PageRecord) {
selectedPageSlug = page.slug;
pageDraftBody = page.body;
}
function createNewPage() {
const slug = window.prompt("请输入新页面的 slug", "notes");
if (!slug) return;
selectedPageSlug = slug.trim();
pageDraftBody = "# 新页面\n\n开始编辑这里的内容。";
activeNav = "pages";
}
async function savePage(publish = false) {
if (!selectedPageSlug) {
showToast("error", "请先选择或创建页面");
return;
}
const response = await api<any>(`/api/admin/pages/${encodeURIComponent(selectedPageSlug)}`, {
method: "PUT",
body: JSON.stringify({ body: pageDraftBody, publish }),
});
pageDraftBody = response.page.body;
selectedPageSlug = response.page.slug;
applyBuild(response);
await Promise.all([loadPages(), loadDashboard()]);
showToast("success", publish ? "页面已保存并加入构建队列" : "页面已保存");
}
function chooseUploadFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
uploadName = file?.name || "";
if (!file) {
uploadDataUrl = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
uploadDataUrl = String(reader.result || "");
};
reader.readAsDataURL(file);
}
async function uploadAsset() {
if (!uploadName || !uploadDataUrl) {
showToast("error", "请先选择图片文件");
return;
}
const response = await api<any>("/api/admin/assets", {
method: "POST",
body: JSON.stringify({
root: uploadRoot,
name: uploadName,
folder: uploadFolder,
dataUrl: uploadDataUrl,
}),
});
applyBuild(response);
uploadName = "";
uploadDataUrl = "";
await Promise.all([loadAssets(), loadDashboard()]);
showToast("success", "图片已上传并加入构建队列");
}
async function removeAsset(asset: AssetRecord) {
if (!window.confirm(`确定删除图片 ${asset.name} 吗?`)) return;
const response = await api<any>(`/api/admin/assets?root=${encodeURIComponent(asset.root)}&path=${encodeURIComponent(asset.path)}`, {
method: "DELETE",
});
applyBuild(response);
await Promise.all([loadAssets(), loadDashboard()]);
showToast("success", "图片已删除");
}
async function rebuildSite() {
const response = await api<any>("/api/admin/rebuild", { method: "POST", body: JSON.stringify({}) });
applyBuild(response);
showToast("info", response.queued ? "构建已排队" : "已开始重新构建站点");
}
async function copyText(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
showToast("success", `${label}已复制`);
} catch {
showToast("error", "复制失败,请手动复制");
}
}
$: filteredPosts = postsList.filter((post) => {
const keyword = postSearch.trim().toLowerCase();
if (!keyword) return true;
return `${post.title} ${post.slug} ${post.description || ""}`.toLowerCase().includes(keyword);
});
$: filteredAssets = mediaItems.filter((asset) => {
const keyword = mediaSearch.trim().toLowerCase();
if (!keyword) return true;
return `${asset.name} ${asset.path} ${asset.directory} ${asset.reference}`.toLowerCase().includes(keyword);
});
$: postPreview = renderAdminMarkdown(postDraft.body || "");
$: pagePreview = renderAdminMarkdown(pageDraftBody || "");
onMount(() => {
const timer = window.setInterval(async () => {
if (!authenticated) return;
try {
await loadBuild();
} catch {
// ignore background build refresh failures
}
}, 3000);
(async () => {
applyLoginSearchFeedback();
await refreshSession();
if (authenticated) {
await loadAll();
}
})();
return () => window.clearInterval(timer);
});
</script>
<div class="admin-shell">
{#if toast}
<div class={`admin-toast ${toast.tone}`}>{toast.text}</div>
{/if}
{#if !sessionResolved}
<section class="admin-auth-shell admin-auth-shell-pending">
<div class="card-base admin-panel admin-auth-copy">
<div class="admin-login-badge">Firefly Admin</div>
<div class="admin-auth-intro">
<h2>正在连接后台</h2>
<p>正在检查管理员会话与后台启用状态,确认完成后会显示登录入口或直接进入内容管理台。</p>
</div>
<div class="admin-loading-stack" aria-hidden="true">
<div class="admin-loading-line"></div>
<div class="admin-loading-line short"></div>
<div class="admin-loading-line medium"></div>
</div>
</div>
<div class="card-base admin-login admin-login-stage admin-login-pending">
<div class="admin-login-badge">会话检查中</div>
<h2>后台准备中</h2>
<p class="admin-login-status">正在从当前 Space 读取后台环境变量并确认登录状态。</p>
</div>
</section>
{:else if !configured}
<div class="card-base admin-panel admin-empty">
<h2>后台尚未启用</h2>
<p>请在 Hugging Face Space 中配置环境变量 <code>ADMIN</code><code>PASSWORD</code> 后再访问管理页。</p>
</div>
{:else if !authenticated}
<section class="admin-auth-shell">
<div class="card-base admin-panel admin-auth-copy">
<div class="admin-login-badge">Firefly Admin</div>
<div class="admin-auth-intro">
<h2>后台管理入口</h2>
<p>在这里统一管理博客标题、介绍、导航、侧栏、文章、图片素材与构建状态,界面风格与前台博客保持一致。</p>
</div>
<div class="admin-auth-facts">
<div class="admin-auth-fact">
<strong>账号来源</strong>
<span><code>ADMIN</code></span>
</div>
<div class="admin-auth-fact">
<strong>密码来源</strong>
<span><code>PASSWORD</code></span>
</div>
<div class="admin-auth-fact">
<strong>内容存储</strong>
<span>当前版本保存到容器本地目录</span>
</div>
</div>
</div>
<form class="card-base admin-login admin-login-stage" method="post" action={`${entryPath}/login`} on:submit|preventDefault={handleLogin}>
<div class="admin-login-badge">管理员登录</div>
<h2>输入管理员凭证</h2>
<p>登录成功后将进入内容工作台,可继续管理站点配置、文章与图片资源。</p>
<label class="admin-field">
<span>管理员账号</span>
<input class="admin-input" name="username" autocomplete="username" autocapitalize="none" placeholder="管理员账号" bind:value={loginForm.username} required />
</label>
<label class="admin-field">
<span>登录密码</span>
<input class="admin-input" name="password" autocomplete="current-password" type="password" placeholder="密码" bind:value={loginForm.password} required />
</label>
<button class="btn-regular admin-button primary" type="submit" disabled={loginSubmitting}>
{loginSubmitting ? "登录中..." : "登录后台"}
</button>
<p class="admin-login-status">后台凭证直接读取自 Hugging Face Space 环境变量,不写入前端代码。</p>
{#if sessionRefreshing || sessionLoading}
<p class="admin-login-status">正在刷新当前后台会话...</p>
{/if}
</form>
</section>
{:else}
<div class="admin-workbench">
<aside class="admin-sidebar">
<div class="card-base admin-panel admin-sidebar-card">
<div class="admin-login-badge">Firefly Admin</div>
<h3>内容后台</h3>
<p>当前管理员:<strong>{username}</strong></p>
<div class={`build-pill state-${buildState.status}`}>{buildLabel(buildState.status)}</div>
<div class="admin-quick-actions">
<button class="btn-regular admin-button primary" on:click={rebuildSite}>重新构建</button>
<button class="btn-plain admin-button ghost" on:click={handleLogout}>退出登录</button>
</div>
</div>
<div class="card-base admin-panel admin-sidebar-card nav-card">
{#each navItems as item}
<button class:active={activeNav === item.key} class="btn-plain admin-nav-button" on:click={() => (activeNav = item.key)}>
<span>{item.label}</span>
<small>{item.hint}</small>
</button>
{/each}
</div>
</aside>
<section class="admin-main">
{#if activeNav === "overview"}
<div class="admin-grid metrics-grid">
{#each [["文章", dashboard.posts], ["草稿", dashboard.drafts], ["页面", dashboard.pages], ["图片", dashboard.assets], ["标签", dashboard.tags], ["分类", dashboard.categories]] as [label, value]}
<div class="card-base admin-panel metric-card">
<span>{label}</span>
<strong>{value}</strong>
</div>
{/each}
</div>
<div class="admin-grid two-col">
<div class="card-base admin-panel">
<div class="section-head"><h3>快捷操作</h3></div>
<div class="admin-quick-actions wrap">
<button class="btn-regular admin-button primary" on:click={createNewPost}>新建文章</button>
<button class="btn-plain admin-button ghost" on:click={createNewPage}>新建页面</button>
<button class="btn-plain admin-button ghost" on:click={() => (activeNav = "media")}>上传图片</button>
<button class="btn-plain admin-button ghost" on:click={() => loadAll()}>刷新数据</button>
</div>
<div class="overview-list">
{#each postsList.slice(0, 6) as post}
<button class="btn-plain overview-item" on:click={() => openPost(post.slug)}>
<strong>{post.title || post.slug}</strong>
<small>{post.slug}</small>
</button>
{/each}
</div>
</div>
<div class="card-base admin-panel">
<div class="section-head"><h3>最近构建</h3></div>
<p>状态:<strong>{buildLabel(buildState.status)}</strong></p>
<p>开始时间:{formatDateTime(buildState.startedAt)}</p>
<p>完成时间:{formatDateTime(buildState.finishedAt)}</p>
<p>最近成功:{formatDateTime(buildState.lastBuiltAt)}</p>
<p>排队数量:{buildState.queueLength || 0}</p>
{#if buildState.lastError}<p class="danger-text">错误:{buildState.lastError}</p>{/if}
<pre class="admin-code build-log">{buildState.logs.join("\n") || "这里会显示构建日志。"}</pre>
</div>
</div>
{/if}
{#if activeNav === "site"}
<div class="admin-grid two-col">
<div class="card-base admin-panel">
<div class="section-head"><h3>基础信息</h3><button class="btn-regular admin-button primary" on:click={saveSite}>保存站点</button></div>
<div class="field-grid">
<input class="admin-input" placeholder="站点标题" bind:value={configs.site.title} />
<input class="admin-input" placeholder="站点副标题" bind:value={configs.site.subtitle} />
<input class="admin-input" placeholder="站点地址" bind:value={configs.site.site_url} />
<input class="admin-input" placeholder="语言,例如 zh_CN" bind:value={configs.site.lang} />
<input class="admin-input field-span-2" placeholder="站点描述" bind:value={configs.site.description} />
<input class="admin-input field-span-2" placeholder="关键词,逗号分隔" value={Array.isArray(configs.site.keywords) ? configs.site.keywords.join(', ') : ''} on:input={(event) => (configs.site.keywords = parseKeywords((event.currentTarget as HTMLInputElement).value))} />
<input class="admin-input" placeholder="站点开始日期 YYYY-MM-DD" bind:value={configs.site.siteStartDate} />
<input class="admin-input" placeholder="时区,例如 Asia/Shanghai" bind:value={configs.site.timezone} />
</div>
<div class="field-grid compact-grid">
<label>主题色 Hue<input class="admin-input" type="number" bind:value={configs.site.themeColor.hue} /></label>
<label>默认模式<select class="admin-input" bind:value={configs.site.themeColor.defaultMode}><option value="system">system</option><option value="light">light</option><option value="dark">dark</option></select></label>
<label>每页文章数<input class="admin-input" type="number" bind:value={configs.site.pagination.postsPerPage} /></label>
<label>过期阈值<input class="admin-input" type="number" bind:value={configs.site.outdatedThreshold} /></label>
</div>
<div class="check-row">
<label><input type="checkbox" bind:checked={configs.site.themeColor.fixed} /> 固定主题色</label>
<label><input type="checkbox" bind:checked={configs.site.card.border} /> 启用卡片边框</label>
<label><input type="checkbox" bind:checked={configs.site.showLastModified} /> 显示最后修改</label>
<label><input type="checkbox" bind:checked={configs.site.sharePoster} /> 显示分享海报</label>
<label><input type="checkbox" bind:checked={configs.site.pages.sponsor} /> 启用赞助页</label>
<label><input type="checkbox" bind:checked={configs.site.pages.guestbook} /> 启用留言页</label>
<label><input type="checkbox" bind:checked={configs.site.pages.bangumi} /> 启用 Bangumi 页</label>
</div>
</div>
<div class="card-base admin-panel">
<div class="section-head"><h3>站点 JSON</h3><button class="btn-plain admin-button ghost" on:click={() => saveJsonSection('site', '站点 JSON')}>按 JSON 保存</button></div>
<textarea class="admin-code section-editor" bind:value={sectionEditors.site}></textarea>
</div>
</div>
{/if}
{#if activeNav === "appearance"}
<div class="admin-grid two-col">
<div class="card-base admin-panel">
<div class="section-head"><h3>个人资料</h3><button class="btn-regular admin-button primary" on:click={saveProfile}>保存资料</button></div>
<input class="admin-input" placeholder="头像路径" bind:value={configs.profile.avatar} />
<input class="admin-input" placeholder="名称" bind:value={configs.profile.name} />
<textarea class="admin-code small-editor" placeholder="个人简介" bind:value={configs.profile.bio}></textarea>
<div class="section-subtitle">社交链接 JSON</div>
<textarea class="admin-code small-editor" bind:value={profileLinksJson}></textarea>
</div>
<div class="card-base admin-panel">
<div class="section-head"><h3>公告与页脚</h3><button class="btn-regular admin-button primary" on:click={saveAnnouncement}>保存公告</button></div>
<input class="admin-input" placeholder="公告标题" bind:value={configs.announcement.title} />
<textarea class="admin-code small-editor" placeholder="公告内容" bind:value={configs.announcement.content}></textarea>
<input class="admin-input" placeholder="公告链接文字" bind:value={configs.announcement.link.text} />
<input class="admin-input" placeholder="公告链接地址" bind:value={configs.announcement.link.url} />
<div class="check-row"><label><input type="checkbox" bind:checked={configs.announcement.closable} /> 可关闭</label><label><input type="checkbox" bind:checked={configs.announcement.link.enable} /> 启用链接</label></div>
<div class="section-head inline-head"><h4>页脚 HTML</h4><button class="btn-plain admin-button ghost" on:click={saveFooter}>保存页脚</button></div>
<textarea class="admin-code section-editor" bind:value={configs.footer.customHtml}></textarea>
</div>
<div class="card-base admin-panel full-span">
<div class="section-head"><h3>壁纸与首页文案</h3><button class="btn-regular admin-button primary" on:click={saveWallpaper}>保存壁纸</button></div>
<div class="field-grid">
<select class="admin-input" bind:value={configs.wallpaper.mode}><option value="banner">banner</option><option value="overlay">overlay</option><option value="none">none</option></select>
<label class="checkbox-inline"><input type="checkbox" bind:checked={configs.wallpaper.switchable} /> 允许切换壁纸模式</label>
<input class="admin-input" placeholder="桌面壁纸路径" bind:value={configs.wallpaper.src.desktop} />
<input class="admin-input" placeholder="移动壁纸路径" bind:value={configs.wallpaper.src.mobile} />
<input class="admin-input" placeholder="首页标题" bind:value={configs.wallpaper.banner.homeText.title} />
<input class="admin-input" placeholder="标题尺寸,例如 3.8rem" bind:value={configs.wallpaper.banner.homeText.titleSize} />
<input class="admin-input" placeholder="副标题尺寸,例如 1.4rem" bind:value={configs.wallpaper.banner.homeText.subtitleSize} />
<input class="admin-input" placeholder="Banner 位置,例如 center" bind:value={configs.wallpaper.banner.position} />
</div>
<div class="check-row"><label><input type="checkbox" bind:checked={configs.wallpaper.banner.homeText.enable} /> 启用首页文案</label><label><input type="checkbox" bind:checked={configs.wallpaper.banner.homeText.typewriter.enable} /> 启用打字机</label></div>
<textarea class="admin-code section-editor" placeholder="每行一个副标题" value={getWallpaperSubtitlesText()} on:input={(event) => setWallpaperSubtitles((event.currentTarget as HTMLTextAreaElement).value)}></textarea>
</div>
</div>
{/if}
{#if activeNav === "navigation"}
<div class="admin-grid two-col">
<div class="card-base admin-panel"><div class="section-head"><h3>导航栏 JSON</h3><button class="btn-regular admin-button primary" on:click={() => saveJsonSection('navbar', '导航栏配置')}>保存导航</button></div><textarea class="admin-code section-editor" bind:value={sectionEditors.navbar}></textarea></div>
<div class="card-base admin-panel"><div class="section-head"><h3>侧栏 JSON</h3><button class="btn-regular admin-button primary" on:click={() => saveJsonSection('sidebar', '侧栏配置')}>保存侧栏</button></div><textarea class="admin-code section-editor" bind:value={sectionEditors.sidebar}></textarea></div>
</div>
{/if}
{#if activeNav === "posts"}
<div class="card-base admin-panel">
<div class="section-head"><h3>文章管理</h3><div class="admin-quick-actions wrap"><button class="btn-plain admin-button ghost" on:click={createNewPost}>新建</button><button class="btn-plain admin-button ghost" on:click={duplicatePost}>复制</button><button class="btn-plain admin-button ghost" on:click={() => savePost(false)}>保存</button><button class="btn-regular admin-button primary" on:click={() => savePost(true)}>保存并发布</button><button class="btn-plain admin-button ghost danger" on:click={removePost}>删除</button></div></div>
<div class="editor-layout posts-layout">
<div class="editor-list"><input class="admin-input" placeholder="搜索文章" bind:value={postSearch} />{#each filteredPosts as post}<button class:active={selectedPostSlug === post.slug} class="btn-plain editor-item" on:click={() => openPost(post.slug)}><strong>{post.title || post.slug}</strong><small>{post.slug}</small></button>{/each}</div>
<div class="post-form"><div class="field-grid"><input class="admin-input" placeholder="标题" bind:value={postDraft.title} /><input class="admin-input" placeholder="slug" bind:value={postDraft.slug} /><input class="admin-input" placeholder="发布日期 YYYY-MM-DD" bind:value={postDraft.published} /><input class="admin-input" placeholder="更新日期 YYYY-MM-DD" bind:value={postDraft.updated} /><input class="admin-input" placeholder="封面路径" bind:value={postDraft.image} /><input class="admin-input" placeholder="分类" bind:value={postDraft.category} /><input class="admin-input" placeholder="标签,逗号分隔" value={postDraft.tags.join(', ')} on:input={(event) => (postDraft.tags = parseKeywords((event.currentTarget as HTMLInputElement).value))} /><input class="admin-input" placeholder="语言" bind:value={postDraft.lang} /><input class="admin-input" placeholder="作者" bind:value={postDraft.author} /><input class="admin-input" placeholder="来源链接" bind:value={postDraft.sourceLink} /><input class="admin-input" placeholder="版权名称" bind:value={postDraft.licenseName} /><input class="admin-input" placeholder="版权链接" bind:value={postDraft.licenseUrl} /><textarea class="admin-code small-editor field-span-2" placeholder="摘要" bind:value={postDraft.description}></textarea></div><div class="check-row"><label><input type="checkbox" bind:checked={postDraft.draft} /> 草稿</label><label><input type="checkbox" bind:checked={postDraft.pinned} /> 置顶</label><label><input type="checkbox" bind:checked={postDraft.comment} /> 启用评论</label><label><input type="checkbox" checked={postDraft.extension === "mdx"} on:change={(event) => (postDraft.extension = (event.currentTarget as HTMLInputElement).checked ? "mdx" : "md")} /> 使用 MDX</label></div><textarea class="admin-code editor-input" bind:value={postDraft.body}></textarea></div>
<div class="editor-preview custom-md prose dark:prose-invert max-w-none">{@html postPreview}</div>
</div>
</div>
{/if}
{#if activeNav === "pages"}
<div class="card-base admin-panel">
<div class="section-head"><h3>固定页面</h3><div class="admin-quick-actions wrap"><button class="btn-plain admin-button ghost" on:click={createNewPage}>新建页面</button><button class="btn-plain admin-button ghost" on:click={() => savePage(false)}>保存</button><button class="btn-regular admin-button primary" on:click={() => savePage(true)}>保存并发布</button></div></div>
<div class="editor-layout"><div class="editor-list">{#each specPages as page}<button class:active={selectedPageSlug === page.slug} class="btn-plain editor-item" on:click={() => selectPage(page)}><strong>{page.slug}</strong><small>{page.filePath}</small></button>{/each}</div><textarea class="admin-code editor-input" bind:value={pageDraftBody}></textarea><div class="editor-preview custom-md prose dark:prose-invert max-w-none">{@html pagePreview}</div></div>
</div>
{/if}
{#if activeNav === "media"}
<div class="card-base admin-panel"><div class="section-head"><h3>图片与素材</h3><button class="btn-regular admin-button primary" on:click={uploadAsset}>上传图片</button></div><div class="field-grid"><select class="admin-input" bind:value={uploadRoot}><option value="public">public</option><option value="content">content/posts</option></select><input class="admin-input" placeholder="目录,例如 uploads/posts 或 covers" bind:value={uploadFolder} /><input class="admin-input" type="file" accept="image/*" on:change={chooseUploadFile} /><input class="admin-input" placeholder="搜索图片" bind:value={mediaSearch} /></div><div class="asset-grid">{#each filteredAssets as asset}<div class="card-base asset-card">{#if asset.previewUrl}<img src={asset.previewUrl} alt={asset.name} />{/if}<strong>{asset.name}</strong><small>{asset.root} / {asset.path}</small><small>引用:{asset.reference}</small><small>{formatBytes(asset.size)} · {formatDateTime(asset.modifiedAt)}</small><div class="asset-actions"><button class="btn-plain admin-button ghost" on:click={() => copyText(asset.reference, '引用路径')}>复制引用</button><button class="btn-plain admin-button ghost" on:click={() => copyText(asset.previewUrl, '预览链接')}>复制预览</button><button class="btn-plain admin-button ghost danger" on:click={() => removeAsset(asset)}>删除</button></div></div>{/each}</div></div>
{/if}
{#if activeNav === "advanced"}
<div class="admin-grid two-col">{#each [['friends','友链配置'],['sponsor','赞助配置'],['ad','广告配置'],['comment','评论配置'],['music','音乐配置'],['pio','看板娘配置'],['coverImage','封面图配置'],['font','字体配置'],['sakura','樱花特效']] as [key, label]}<div class="card-base admin-panel"><div class="section-head"><h3>{label}</h3><button class="btn-regular admin-button primary" on:click={() => saveJsonSection(key as JsonSectionKey, label)}>保存</button></div><textarea class="admin-code section-editor" bind:value={sectionEditors[key as JsonSectionKey]}></textarea></div>{/each}</div>
{/if}
{#if activeNav === "build"}
<div class="card-base admin-panel"><div class="section-head"><h3>构建与发布</h3><button class="btn-regular admin-button primary" on:click={rebuildSite}>手动构建</button></div><div class="build-meta"><p>状态:<strong>{buildLabel(buildState.status)}</strong></p><p>开始:{formatDateTime(buildState.startedAt)}</p><p>完成:{formatDateTime(buildState.finishedAt)}</p><p>最近成功:{formatDateTime(buildState.lastBuiltAt)}</p><p>队列:{buildState.queueLength || 0}</p>{#if buildState.queuedReason}<p>排队原因:{buildState.queuedReason}</p>{/if}{#if buildState.lastError}<p class="danger-text">错误:{buildState.lastError}</p>{/if}</div><pre class="admin-code build-log">{buildState.logs.join("\n") || "这里会显示完整构建日志。"}</pre></div>
{/if}
</section>
</div>
{/if}
</div>
<style>
.admin-shell {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-workbench {
display: grid;
grid-template-columns: 18rem minmax(0, 1fr);
gap: 1rem;
}
.admin-sidebar, .admin-main {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-panel, .admin-login {
padding: 1.2rem;
}
.admin-auth-shell {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(22rem, 0.85fr);
gap: 1rem;
align-items: stretch;
}
.admin-auth-copy {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1.4rem;
min-height: 22rem;
background:
radial-gradient(circle at top right, rgb(255 255 255 / 0.18), transparent 32%),
linear-gradient(145deg, color-mix(in oklch, var(--card-bg) 78%, white 22%), color-mix(in oklch, var(--card-bg) 92%, var(--btn-card-bg-hover) 8%));
border: 1px solid color-mix(in oklch, var(--line-divider) 72%, white 28%);
box-shadow: 0 28px 80px rgb(8 20 28 / 0.12);
}
.admin-auth-intro {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin-auth-intro h2 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.08;
}
.admin-auth-intro p {
max-width: 40rem;
}
.admin-auth-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.admin-auth-fact {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.9rem;
border-radius: 1rem;
border: 1px solid color-mix(in oklch, var(--line-divider) 74%, white 26%);
background: color-mix(in oklch, var(--card-bg) 82%, white 18%);
}
.admin-auth-fact strong {
font-size: 0.95rem;
}
.admin-auth-fact span {
font-size: 0.92rem;
color: rgba(0,0,0,.72);
}
:root.dark .admin-auth-fact span {
color: rgba(255,255,255,.72);
}
.admin-login {
max-width: 30rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.admin-auth-shell .admin-login {
max-width: none;
width: 100%;
margin: 0;
justify-content: center;
}
.admin-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.admin-field > span {
font-size: 0.88rem;
font-weight: 700;
}
.admin-login-pending {
min-height: 22rem;
justify-content: center;
}
.admin-loading-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin-loading-line {
height: 0.9rem;
border-radius: 999px;
background: linear-gradient(90deg, rgb(255 255 255 / 0.28), rgb(255 255 255 / 0.62), rgb(255 255 255 / 0.22));
background-size: 200% 100%;
animation: adminPulse 1.8s ease-in-out infinite;
}
.admin-loading-line.short {
width: 48%;
}
.admin-loading-line.medium {
width: 70%;
}
.admin-login-stage {
position: relative;
z-index: 6;
border: 1px solid color-mix(in oklch, var(--line-divider) 68%, white 32%);
background: color-mix(in oklch, var(--card-bg) 86%, white 14%);
backdrop-filter: blur(18px);
box-shadow: 0 28px 80px rgb(8 20 28 / 0.18);
}
.admin-login-badge, .build-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: color-mix(in oklch, var(--card-bg) 68%, var(--btn-regular-bg-hover) 32%);
font-size: 0.82rem;
font-weight: 700;
width: fit-content;
}
.build-pill { margin-top: 0.5rem; }
.state-building, .state-success { color: var(--primary); }
.state-error { color: oklch(0.62 0.2 25); }
.admin-toast {
padding: 0.85rem 1rem;
border-radius: 1rem;
border: 1px solid var(--line-divider);
}
.admin-toast.success { background: color-mix(in oklch, var(--primary) 14%, var(--card-bg) 86%); }
.admin-toast.error { background: color-mix(in oklch, oklch(0.68 0.18 25) 16%, var(--card-bg) 84%); }
.admin-toast.info { background: color-mix(in oklch, var(--btn-regular-bg-hover) 35%, var(--card-bg) 65%); }
.admin-sidebar-card h3, .admin-panel h3, .admin-panel h4, .admin-login h2 { margin: 0; }
.admin-sidebar-card p, .admin-panel p, .admin-login p, .admin-login-status { margin: 0; color: rgba(0,0,0,.72); }
:root.dark .admin-sidebar-card p, :root.dark .admin-panel p, :root.dark .admin-login p, :root.dark .admin-login-status { color: rgba(255,255,255,.72); }
.admin-quick-actions { display: flex; gap: 0.6rem; align-items: center; }
.admin-quick-actions.wrap { flex-wrap: wrap; }
.admin-button { min-height: 2.7rem; padding: 0.65rem 1rem; border-radius: 0.95rem; }
.admin-button[disabled] { opacity: 0.7; cursor: wait; }
.admin-button.ghost { border: 1px solid var(--line-divider); }
.admin-button.danger { color: oklch(0.62 0.2 25); }
.admin-nav-button, .editor-item, .overview-item {
width: 100%;
padding: 0.85rem 0.95rem;
border-radius: 0.95rem;
justify-content: flex-start;
align-items: flex-start;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.admin-nav-button.active, .editor-item.active { background: var(--btn-regular-bg-hover); color: var(--primary); }
.admin-nav-button small, .editor-item small, .overview-item small { color: rgba(0,0,0,.55); }
:root.dark .admin-nav-button small, :root.dark .editor-item small, :root.dark .overview-item small { color: rgba(255,255,255,.55); }
.admin-grid { display: grid; gap: 1rem; }
.metrics-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.two-col { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metric-card { display: flex; flex-direction: column; gap: 0.45rem; }
.metric-card strong { font-size: 1.8rem; }
.field-grid { display: grid; gap: 0.8rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field-grid.compact-grid { margin-top: 0.8rem; }
.field-span-2, .full-span { grid-column: 1 / -1; }
.admin-input, .admin-code {
width: 100%;
border: 1px solid var(--line-divider);
background: color-mix(in oklch, var(--card-bg) 90%, var(--btn-card-bg-hover) 10%);
border-radius: 0.95rem;
padding: 0.8rem 0.95rem;
color: inherit;
}
.admin-code { font-family: "JetBrains Mono Variable", ui-monospace, Consolas, monospace; line-height: 1.6; resize: vertical; }
.small-editor { min-height: 7rem; }
.section-editor { min-height: 18rem; }
.editor-layout { display: grid; grid-template-columns: 15rem minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; }
.posts-layout { grid-template-columns: 16rem minmax(0, 1.1fr) minmax(0, 0.9fr); }
.editor-list, .post-form, .overview-list { display: flex; flex-direction: column; gap: 0.7rem; }
.editor-input, .editor-preview { min-height: 28rem; }
.editor-preview { overflow: auto; padding: 1rem; border: 1px solid var(--line-divider); border-radius: 1rem; background: color-mix(in oklch, var(--card-bg) 92%, var(--btn-card-bg-hover) 8%); }
.asset-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; margin-top: 1rem; }
.asset-card { padding: 0.9rem; display: flex; flex-direction: column; gap: 0.45rem; }
.asset-card img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 0.85rem; }
.asset-actions, .check-row, .section-head, .inline-head, .build-meta { display: flex; gap: 0.75rem; }
.asset-actions, .check-row, .build-meta { flex-wrap: wrap; }
.section-head, .inline-head { justify-content: space-between; align-items: center; margin-bottom: 0.8rem; }
.section-subtitle { font-size: 0.88rem; font-weight: 700; margin-top: 0.4rem; }
.build-log { max-height: 22rem; overflow: auto; }
.danger-text { color: oklch(0.62 0.2 25); }
.admin-empty { padding: 2rem; text-align: center; }
@keyframes adminPulse {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
@media (max-width: 1200px) {
.admin-auth-shell,
.admin-auth-facts,
.admin-workbench,
.two-col,
.metrics-grid,
.asset-grid,
.editor-layout,
.posts-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 800px) {
.admin-login {
max-width: 100%;
}
.field-grid { grid-template-columns: 1fr; }
.field-span-2 { grid-column: auto; }
}
</style>