| <script lang="ts"> |
| import { onMount } from "svelte"; |
| import { mergeConfig } from "@/admin/override-utils"; |
| import Icon from "@/components/common/Icon.svelte"; |
| import { renderMarkdownPreview } from "./markdown-preview"; |
|
|
| type BuildState = { |
| status: string; |
| startedAt: string | null; |
| finishedAt: string | null; |
| lastBuiltAt: string | null; |
| lastError: string; |
| logs: string[]; |
| }; |
|
|
| type DashboardState = { |
| posts: number; |
| drafts: number; |
| pages: number; |
| assets: number; |
| tags: number; |
| categories: number; |
| build: BuildState; |
| }; |
|
|
| type PostSummary = { |
| slug: string; |
| title: string; |
| description: string; |
| draft: boolean; |
| pinned: boolean; |
| }; |
|
|
| type PageRecord = { |
| slug: string; |
| content: string; |
| filePath: string; |
| }; |
|
|
| type AssetRecord = { |
| path: string; |
| url: string; |
| name: string; |
| size: number; |
| modifiedAt: string; |
| }; |
|
|
| type SectionKey = |
| | "site" |
| | "navbar" |
| | "sidebar" |
| | "profile" |
| | "background" |
| | "announcement" |
| | "footer" |
| | "comment" |
| | "font" |
| | "friends" |
| | "sponsor" |
| | "ads" |
| | "music" |
| | "pio" |
| | "license" |
| | "cover" |
| | "sakura"; |
|
|
| let { initialConfig } = $props<{ initialConfig: Record<string, any> }>(); |
| const baseConfig = structuredClone(initialConfig); |
|
|
| let session = $state({ |
| authenticated: false, |
| configured: true, |
| username: "", |
| }); |
| let activeTab = $state("dashboard"); |
| let notice = $state({ text: "", tone: "info" }); |
| let loading = $state(false); |
| let saving = $state(false); |
| let dashboard = $state<DashboardState>({ |
| posts: 0, |
| drafts: 0, |
| pages: 0, |
| assets: 0, |
| tags: 0, |
| categories: 0, |
| build: { |
| status: "idle", |
| startedAt: null, |
| finishedAt: null, |
| lastBuiltAt: null, |
| lastError: "", |
| logs: [], |
| }, |
| }); |
| let buildState = $state<BuildState>(dashboard.build); |
| let editors = $state<Record<SectionKey, string>>({ |
| site: "{}", |
| navbar: "{}", |
| sidebar: "{}", |
| profile: "{}", |
| background: "{}", |
| announcement: "{}", |
| footer: "{}", |
| comment: "{}", |
| font: "{}", |
| friends: "{}", |
| sponsor: "{}", |
| ads: "{}", |
| music: "{}", |
| pio: "{}", |
| license: "{}", |
| cover: "{}", |
| sakura: "{}", |
| }); |
| let footerHtml = $state(initialConfig.footerHtml || ""); |
| let loginForm = $state({ username: "", password: "" }); |
| let pages = $state<PageRecord[]>([]); |
| let selectedPageSlug = $state(""); |
| let pageContent = $state(""); |
| let posts = $state<PostSummary[]>([]); |
| let selectedPostSlug = $state<string | null>(null); |
| let postSearch = $state(""); |
| let postForm = $state(createBlankPost()); |
| let postTagsText = $state(""); |
| let assets = $state<AssetRecord[]>([]); |
| let uploadFolder = $state("uploads"); |
| let uploadName = $state(""); |
| let uploadDataUrl = $state(""); |
|
|
| function createBlankPost() { |
| return { |
| slug: "", |
| title: "", |
| published: new Date().toISOString().slice(0, 10), |
| updated: "", |
| description: "", |
| image: "", |
| tags: [], |
| category: "", |
| draft: false, |
| lang: "", |
| pinned: false, |
| author: "", |
| sourceLink: "", |
| licenseName: "", |
| licenseUrl: "", |
| comment: true, |
| content: "", |
| extension: "md", |
| }; |
| } |
|
|
| function setNotice(text: string, tone = "info") { |
| notice = { text, tone }; |
| } |
|
|
| function prettyJson(value: unknown) { |
| return JSON.stringify(value, null, 2); |
| } |
|
|
| function slugify(value: string) { |
| return value |
| .normalize("NFKD") |
| .toLowerCase() |
| .trim() |
| .replace(/[^\p{Letter}\p{Number}]+/gu, "-") |
| .replace(/^-+|-+$/g, ""); |
| } |
|
|
| function parseJson(text: string, label: string) { |
| try { |
| return JSON.parse(text || "{}"); |
| } catch (error) { |
| throw new Error(`${label} JSON 格式错误: ${error instanceof Error ? error.message : "unknown error"}`); |
| } |
| } |
|
|
| function fillEditors(config: Record<string, any>) { |
| for (const key of Object.keys(editors) as SectionKey[]) { |
| editors[key] = prettyJson(config[key] || {}); |
| } |
| } |
|
|
| function hydratePost(post: Record<string, any>) { |
| postForm = { ...createBlankPost(), ...post }; |
| postTagsText = Array.isArray(post.tags) ? post.tags.join(", ") : ""; |
| } |
|
|
| function selectPage(page: PageRecord) { |
| selectedPageSlug = page.slug; |
| pageContent = page.content; |
| } |
|
|
| const filteredPosts = $derived.by(() => { |
| const keyword = postSearch.trim().toLowerCase(); |
| if (!keyword) { |
| return posts; |
| } |
| return posts.filter((post) => |
| `${post.title} ${post.slug} ${post.description}`.toLowerCase().includes(keyword), |
| ); |
| }); |
|
|
| const pagePreview = $derived(renderMarkdownPreview(pageContent)); |
| const postPreview = $derived(renderMarkdownPreview(postForm.content)); |
|
|
| async function api<T>(url: string, options: RequestInit = {}): Promise<T> { |
| const response = await fetch(url, { |
| ...options, |
| headers: { |
| "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 || "Request failed"); |
| } |
| return data as T; |
| } |
|
|
| async function refreshSession() { |
| session = await api("/api/admin/session"); |
| } |
|
|
| async function loadData() { |
| loading = true; |
| try { |
| const [overrideData, dashboardData, pagesData, postsData, assetsData, currentBuild] = |
| await Promise.all([ |
| api<{ overrides: Record<string, any>; footerHtml: string }>("/api/admin/overrides"), |
| api<DashboardState>("/api/admin/dashboard"), |
| api<PageRecord[]>("/api/admin/pages"), |
| api<PostSummary[]>("/api/admin/posts"), |
| api<AssetRecord[]>("/api/admin/assets"), |
| api<BuildState>("/api/admin/build"), |
| ]); |
| const mergedConfig = mergeConfig(structuredClone(baseConfig), overrideData.overrides || {}); |
| fillEditors(mergedConfig); |
| footerHtml = overrideData.footerHtml || baseConfig.footerHtml || ""; |
| dashboard = dashboardData; |
| buildState = currentBuild; |
| pages = pagesData; |
| posts = postsData; |
| assets = assetsData; |
| if (!selectedPageSlug && pages.length > 0) { |
| selectPage(pages[0]); |
| } |
| } finally { |
| loading = false; |
| } |
| } |
|
|
| async function login() { |
| try { |
| loading = true; |
| await api("/api/admin/login", { |
| method: "POST", |
| body: JSON.stringify(loginForm), |
| }); |
| await refreshSession(); |
| await loadData(); |
| setNotice("登录成功", "success"); |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "登录失败", "error"); |
| } finally { |
| loading = false; |
| loginForm.password = ""; |
| } |
| } |
|
|
| async function logout() { |
| await api("/api/admin/logout", { method: "POST" }); |
| session = { authenticated: false, configured: session.configured, username: "" }; |
| setNotice("已退出登录", "info"); |
| } |
|
|
| async function saveSection(key: SectionKey, label: string) { |
| try { |
| saving = true; |
| await api(`/api/admin/config/${key}`, { |
| method: "PUT", |
| body: JSON.stringify(parseJson(editors[key], label)), |
| }); |
| setNotice(`${label}已保存,重建后生效`, "success"); |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "保存失败", "error"); |
| } finally { |
| saving = false; |
| } |
| } |
|
|
| async function saveFooter() { |
| try { |
| saving = true; |
| await Promise.all([ |
| api("/api/admin/config/footer", { |
| method: "PUT", |
| body: editors.footer, |
| }), |
| api("/api/admin/footer-html", { |
| method: "PUT", |
| body: JSON.stringify({ content: footerHtml }), |
| }), |
| ]); |
| setNotice("页脚已保存,重建后生效", "success"); |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "页脚保存失败", "error"); |
| } finally { |
| saving = false; |
| } |
| } |
|
|
| async function savePage(publish = false) { |
| if (!selectedPageSlug) { |
| setNotice("请先选择页面", "error"); |
| return; |
| } |
| try { |
| saving = true; |
| const response = await api<{ build: BuildState }>( |
| `/api/admin/pages/${encodeURIComponent(selectedPageSlug)}`, |
| { |
| method: "PUT", |
| body: JSON.stringify({ content: pageContent, publish }), |
| }, |
| ); |
| pages = pages.map((item) => |
| item.slug === selectedPageSlug ? { ...item, content: pageContent } : item, |
| ); |
| if (publish) { |
| buildState = response.build; |
| } |
| await api("/api/admin/dashboard").then((data) => { |
| dashboard = data as DashboardState; |
| }); |
| setNotice(publish ? "页面已保存并发布" : "页面已保存", "success"); |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "页面保存失败", "error"); |
| } finally { |
| saving = false; |
| } |
| } |
|
|
| async function openPost(slug: string) { |
| const post = await api<Record<string, any>>(`/api/admin/posts/${encodeURIComponent(slug)}`); |
| selectedPostSlug = slug; |
| hydratePost(post); |
| } |
|
|
| async function savePost(publish = false) { |
| try { |
| saving = true; |
| postForm.tags = postTagsText.split(",").map((item) => item.trim()).filter(Boolean); |
| const route = selectedPostSlug |
| ? `/api/admin/posts/${encodeURIComponent(selectedPostSlug)}` |
| : "/api/admin/posts"; |
| const method = selectedPostSlug ? "PUT" : "POST"; |
| const response = await api<{ post: Record<string, any>; build: BuildState }>(route, { |
| method, |
| body: JSON.stringify({ ...postForm, publish }), |
| }); |
| selectedPostSlug = response.post.slug; |
| hydratePost(response.post); |
| posts = await api("/api/admin/posts"); |
| if (publish) { |
| buildState = response.build; |
| } |
| dashboard = await api("/api/admin/dashboard"); |
| setNotice(publish ? "文章已保存并发布" : "文章已保存", "success"); |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "文章保存失败", "error"); |
| } finally { |
| saving = false; |
| } |
| } |
|
|
| async function deletePost() { |
| if (!selectedPostSlug || !window.confirm(`确认删除 ${selectedPostSlug} 吗?`)) { |
| return; |
| } |
| try { |
| saving = true; |
| await api(`/api/admin/posts/${encodeURIComponent(selectedPostSlug)}`, { method: "DELETE" }); |
| selectedPostSlug = null; |
| hydratePost(createBlankPost()); |
| posts = await api("/api/admin/posts"); |
| dashboard = await api("/api/admin/dashboard"); |
| setNotice("文章已删除", "success"); |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "文章删除失败", "error"); |
| } finally { |
| saving = false; |
| } |
| } |
|
|
| function chooseFile(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) { |
| setNotice("请先选择文件", "error"); |
| return; |
| } |
| try { |
| saving = true; |
| await api("/api/admin/assets", { |
| method: "POST", |
| body: JSON.stringify({ name: uploadName, folder: uploadFolder, dataUrl: uploadDataUrl }), |
| }); |
| assets = await api("/api/admin/assets"); |
| dashboard = await api("/api/admin/dashboard"); |
| setNotice("资源上传成功", "success"); |
| uploadName = ""; |
| uploadDataUrl = ""; |
| } catch (error) { |
| setNotice(error instanceof Error ? error.message : "资源上传失败", "error"); |
| } finally { |
| saving = false; |
| } |
| } |
|
|
| async function rebuildSite() { |
| const response = await api<{ state: BuildState }>("/api/admin/rebuild", { method: "POST" }); |
| buildState = response.state; |
| setNotice("已触发站点重建", "success"); |
| } |
|
|
| onMount(() => { |
| const timer = window.setInterval(async () => { |
| if (!session.authenticated) { |
| return; |
| } |
| try { |
| buildState = await api("/api/admin/build"); |
| } catch {} |
| }, 3000); |
|
|
| (async () => { |
| await refreshSession(); |
| if (session.authenticated) { |
| await loadData(); |
| } |
| })(); |
|
|
| return () => window.clearInterval(timer); |
| }); |
| </script> |
|
|
| <div class="admin-shell"> |
| {#if notice.text} |
| <div class={`admin-notice ${notice.tone}`}>{notice.text}</div> |
| {/if} |
|
|
| {#if !session.configured} |
| <div class="card-base admin-card"> |
| <h2 class="admin-title">请先设置 Space 环境变量</h2> |
| <p class="text-75">需要配置 `ADMIN` 和 `PASSWORD` 才能启用后台登录。</p> |
| </div> |
| {:else if !session.authenticated} |
| <div class="card-base admin-card admin-login"> |
| <h2 class="admin-title">后台登录</h2> |
| <input class="admin-input" placeholder="管理员账号" bind:value={loginForm.username} /> |
| <input class="admin-input" type="password" placeholder="密码" bind:value={loginForm.password} /> |
| <button class="btn-regular admin-button" onclick={login} disabled={loading}>登录</button> |
| </div> |
| {:else} |
| <div class="admin-toolbar card-base"> |
| <div class="admin-tabs"> |
| {#each [ |
| ["dashboard", "控制台"], |
| ["config", "配置"], |
| ["content", "内容"], |
| ["media", "资源"], |
| ["advanced", "高级"], |
| ] as [id, label]} |
| <button class:active={activeTab === id} class="btn-card admin-tab" onclick={() => (activeTab = id)}>{label}</button> |
| {/each} |
| </div> |
| <div class="admin-actions"> |
| <button class="btn-card admin-button secondary" onclick={loadData}>刷新</button> |
| <button class="btn-regular admin-button" onclick={rebuildSite}>重建站点</button> |
| <button class="btn-card admin-button secondary" onclick={logout}>退出</button> |
| </div> |
| </div> |
|
|
| {#if activeTab === "dashboard"} |
| <div class="admin-grid"> |
| {#each [ |
| ["文章", dashboard.posts], |
| ["草稿", dashboard.drafts], |
| ["页面", dashboard.pages], |
| ["图片", dashboard.assets], |
| ["标签", dashboard.tags], |
| ["分类", dashboard.categories], |
| ] as [label, value]} |
| <div class="card-base admin-card metric"> |
| <span>{label}</span> |
| <strong>{value}</strong> |
| </div> |
| {/each} |
| </div> |
| <div class="card-base admin-card"> |
| <h3 class="admin-title">构建状态:{buildState.status}</h3> |
| <p class="text-75">最近成功构建:{buildState.lastBuiltAt || "尚未构建"}</p> |
| <p class="text-75">最近错误:{buildState.lastError || "无"}</p> |
| <pre class="admin-code build-log">{buildState.logs.join("\n") || "这里会显示构建日志。"}</pre> |
| </div> |
| {/if} |
|
|
| {#if activeTab === "config"} |
| <div class="admin-stack"> |
| {#each [ |
| ["site", "站点配置"], |
| ["profile", "个人资料"], |
| ["announcement", "公告配置"], |
| ["navbar", "导航配置"], |
| ["sidebar", "侧栏配置"], |
| ["background", "壁纸配置"], |
| ] as [key, label]} |
| <div class="card-base admin-card"> |
| <div class="row"> |
| <h3 class="admin-title">{label}</h3> |
| <button class="btn-regular admin-button" onclick={() => saveSection(key as SectionKey, label)} disabled={saving}>保存</button> |
| </div> |
| <textarea class="admin-code section-editor" rows="14" bind:value={editors[key as SectionKey]}></textarea> |
| </div> |
| {/each} |
| <div class="card-base admin-card"> |
| <div class="row"> |
| <h3 class="admin-title">页脚 HTML</h3> |
| <button class="btn-regular admin-button" onclick={saveFooter} disabled={saving}>保存页脚</button> |
| </div> |
| <textarea class="admin-code section-editor" rows="10" bind:value={footerHtml}></textarea> |
| </div> |
| </div> |
| {/if} |
|
|
| {#if activeTab === "content"} |
| <div class="card-base admin-card"> |
| <div class="row"> |
| <h3 class="admin-title">独立页面</h3> |
| <div class="admin-actions"> |
| <button class="btn-card admin-button secondary" onclick={() => savePage(false)} disabled={saving}>保存</button> |
| <button class="btn-regular admin-button" onclick={() => savePage(true)} disabled={saving}>保存并发布</button> |
| </div> |
| </div> |
| <div class="editor-layout"> |
| <div class="editor-list"> |
| {#each pages as page} |
| <button class:active={selectedPageSlug === page.slug} class="btn-card editor-item" onclick={() => selectPage(page)}>{page.slug}</button> |
| {/each} |
| </div> |
| <textarea class="admin-code editor-input" bind:value={pageContent}></textarea> |
| <div class="editor-preview custom-md prose dark:prose-invert max-w-none"> |
| {@html pagePreview} |
| </div> |
| </div> |
| </div> |
|
|
| <div class="card-base admin-card"> |
| <div class="row"> |
| <h3 class="admin-title">文章编辑器</h3> |
| <div class="admin-actions"> |
| <button class="btn-card admin-button secondary" onclick={() => { selectedPostSlug = null; hydratePost(createBlankPost()); }}>新建</button> |
| <button class="btn-card admin-button secondary" onclick={() => savePost(false)} disabled={saving}>保存</button> |
| <button class="btn-regular admin-button" onclick={() => savePost(true)} disabled={saving}>保存并发布</button> |
| <button class="btn-card admin-button secondary" onclick={deletePost} disabled={!selectedPostSlug || saving}>删除</button> |
| </div> |
| </div> |
| <div class="editor-layout posts"> |
| <div class="editor-list"> |
| <input class="admin-input" placeholder="搜索文章" bind:value={postSearch} /> |
| {#each filteredPosts as post} |
| <button class:active={selectedPostSlug === post.slug} class="btn-card editor-item post-item" onclick={() => openPost(post.slug)}> |
| <span>{post.title || post.slug}</span> |
| <small>{post.slug}</small> |
| </button> |
| {/each} |
| </div> |
| <div class="post-form"> |
| <div class="post-meta-grid"> |
| <input class="admin-input" placeholder="标题" bind:value={postForm.title} /> |
| <div class="slug-row"> |
| <input class="admin-input" placeholder="slug" bind:value={postForm.slug} /> |
| <button class="btn-card admin-button secondary" onclick={() => (postForm.slug = slugify(postForm.title || postForm.slug))}>生成</button> |
| </div> |
| <input class="admin-input" placeholder="发布日期 YYYY-MM-DD" bind:value={postForm.published} /> |
| <input class="admin-input" placeholder="更新日期 YYYY-MM-DD" bind:value={postForm.updated} /> |
| <input class="admin-input" placeholder="封面地址" bind:value={postForm.image} /> |
| <input class="admin-input" placeholder="分类" bind:value={postForm.category} /> |
| <input class="admin-input" placeholder="标签,逗号分隔" bind:value={postTagsText} /> |
| <input class="admin-input" placeholder="语言" bind:value={postForm.lang} /> |
| <textarea class="admin-code" rows="3" placeholder="描述" bind:value={postForm.description}></textarea> |
| </div> |
| <div class="check-row"> |
| <label><input type="checkbox" bind:checked={postForm.draft} /> 草稿</label> |
| <label><input type="checkbox" bind:checked={postForm.pinned} /> 置顶</label> |
| <label><input type="checkbox" bind:checked={postForm.comment} /> 评论</label> |
| </div> |
| <textarea class="admin-code editor-input" bind:value={postForm.content}></textarea> |
| </div> |
| <div class="editor-preview custom-md prose dark:prose-invert max-w-none"> |
| {@html postPreview} |
| </div> |
| </div> |
| </div> |
| {/if} |
|
|
| {#if activeTab === "media"} |
| <div class="card-base admin-card"> |
| <div class="row"> |
| <h3 class="admin-title">图片与静态资源</h3> |
| <button class="btn-regular admin-button" onclick={uploadAsset} disabled={saving}>上传</button> |
| </div> |
| <div class="upload-row"> |
| <input class="admin-input" placeholder="上传目录,如 uploads/posts" bind:value={uploadFolder} /> |
| <input class="admin-input" type="file" onchange={chooseFile} /> |
| </div> |
| <div class="asset-grid"> |
| {#each assets as asset} |
| <div class="btn-card asset-item"> |
| {#if asset.url.match(/\.(png|jpe?g|webp|gif|svg)$/i)} |
| <img src={asset.url} alt={asset.name} /> |
| {/if} |
| <strong>{asset.name}</strong> |
| <small>{asset.url}</small> |
| </div> |
| {/each} |
| </div> |
| </div> |
| {/if} |
|
|
| {#if activeTab === "advanced"} |
| <div class="admin-stack"> |
| {#each [ |
| ["comment", "评论系统"], |
| ["font", "字体"], |
| ["friends", "友链"], |
| ["sponsor", "赞助"], |
| ["ads", "广告"], |
| ["music", "音乐播放器"], |
| ["pio", "看板娘"], |
| ["license", "许可证"], |
| ["cover", "封面图配置"], |
| ["sakura", "樱花特效"], |
| ] as [key, label]} |
| <div class="card-base admin-card"> |
| <div class="row"> |
| <h3 class="admin-title">{label}</h3> |
| <button class="btn-regular admin-button" onclick={() => saveSection(key as SectionKey, label)} disabled={saving}>保存</button> |
| </div> |
| <textarea class="admin-code section-editor" rows="14" bind:value={editors[key as SectionKey]}></textarea> |
| </div> |
| {/each} |
| </div> |
| {/if} |
| {/if} |
| </div> |
|
|
| <style> |
| .admin-shell, .admin-stack { display:flex; flex-direction:column; gap:1rem; } |
| .admin-card { padding:1.25rem; } |
| .admin-title { margin:0; font-size:1.2rem; font-weight:700; } |
| .admin-login { max-width:28rem; margin:0 auto; } |
| .admin-toolbar, .row, .admin-actions, .admin-tabs, .upload-row, .slug-row { display:flex; gap:.75rem; align-items:center; } |
| .admin-toolbar, .row { justify-content:space-between; } |
| .admin-tabs { flex-wrap:wrap; } |
| .admin-button { padding:.7rem 1rem; border-radius:.9rem; } |
| .admin-button.secondary { background:var(--btn-card-bg-hover); } |
| .admin-tab.active { background:var(--btn-regular-bg-hover); color:var(--primary); } |
| .admin-grid { display:grid; gap:.75rem; grid-template-columns:repeat(6,minmax(0,1fr)); } |
| .metric { display:flex; flex-direction:column; gap:.4rem; } |
| .metric strong { font-size:1.8rem; } |
| .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:.9rem; padding:.8rem .95rem; color:inherit; } |
| .admin-code { resize:vertical; font-family:"JetBrains Mono Variable",ui-monospace,Consolas,monospace; font-size:.85rem; line-height:1.6; } |
| .section-editor { min-height:18rem; } |
| .admin-notice { padding:.85rem 1rem; border-radius:1rem; border:1px solid var(--line-divider); } |
| .admin-notice.success { background:color-mix(in oklch, var(--primary) 14%, var(--card-bg) 86%); } |
| .admin-notice.error { background:color-mix(in oklch, oklch(0.68 0.18 25) 14%, var(--card-bg) 86%); } |
| .build-log { max-height:18rem; overflow:auto; } |
| .editor-layout { display:grid; gap:1rem; grid-template-columns:14rem minmax(0,1fr) minmax(0,1fr); } |
| .editor-layout.posts { grid-template-columns:16rem minmax(0,1.1fr) minmax(0,.9fr); } |
| .editor-list { display:flex; flex-direction:column; gap:.6rem; } |
| .editor-item { justify-content:flex-start; text-align:left; padding:.8rem .95rem; border-radius:.9rem; } |
| .editor-item.active { background:var(--btn-regular-bg-hover); } |
| .post-item { display:flex; flex-direction:column; align-items:flex-start; } |
| .editor-input { min-height:26rem; } |
| .editor-preview { min-height:26rem; overflow:auto; padding:1rem; border-radius:1rem; border:1px solid var(--line-divider); background:color-mix(in oklch, var(--card-bg) 92%, var(--btn-card-bg-hover) 8%); } |
| .post-form { display:flex; flex-direction:column; gap:.75rem; } |
| .post-meta-grid { display:grid; gap:.75rem; grid-template-columns:repeat(2,minmax(0,1fr)); } |
| .check-row { display:flex; gap:1rem; flex-wrap:wrap; } |
| .asset-grid { display:grid; gap:.75rem; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:1rem; } |
| .asset-item { flex-direction:column; align-items:flex-start; padding:.8rem; border-radius:1rem; gap:.45rem; } |
| .asset-item img { width:100%; aspect-ratio:16/10; object-fit:cover; border-radius:.8rem; } |
| .text-75 { color:rgba(0,0,0,.75); } |
| .dark .text-75 { color:rgba(255,255,255,.75); } |
| @media (max-width: 1100px) { |
| .admin-grid, .asset-grid, .post-meta-grid, .editor-layout, .editor-layout.posts { grid-template-columns:1fr 1fr; } |
| .editor-layout, .editor-layout.posts { grid-template-columns:1fr; } |
| } |
| @media (max-width: 720px) { |
| .admin-grid, .asset-grid, .post-meta-grid { grid-template-columns:1fr; } |
| .admin-toolbar, .row, .admin-actions, .upload-row, .slug-row { flex-direction:column; align-items:stretch; } |
| } |
| </style> |
|
|