Commit bb9d04f4 authored by xuchentao's avatar xuchentao

优化文章双版本管理流程

parent 467e65cb
......@@ -8,6 +8,7 @@ import type { BuildManager, SiteBuildContext } from "../build/build-manager.js";
import type { PreviewProcessManager } from "../preview/preview-process-manager.js";
import type { SiteRepository } from "../sites/site-repository.js";
import { ArticleService } from "./article-service.js";
import { syncPublishedArticles } from "./article-storage.js";
const exists = (target: string) => access(target).then(() => true).catch(() => false);
......@@ -15,6 +16,7 @@ test("article mutations use external content storage without creating Git-manage
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-articles-"));
const project = path.join(root, "project");
const articles = path.join(root, "content", "articles");
const liveArticles = path.join(root, "content", "live-articles");
const images = path.join(root, "content", "images");
const articleState = path.join(root, "metadata", "articles.json");
const dist = path.join(root, "dist");
......@@ -38,6 +40,7 @@ test("article mutations use external content storage without creating Git-manage
getProjectPath: () => project,
getDraftPath: () => path.join(root, "draft"),
getArticlesPath: () => articles,
getLiveArticlesPath: () => liveArticles,
getArticleImagesPath: () => images,
getArticleStatePath: () => articleState,
markArticlesCompiled: async () => {
......@@ -69,16 +72,31 @@ test("article mutations use external content storage without creating Git-manage
assert.equal(await exists(path.join(articles, "hello.md")), true);
assert.equal(await exists(path.join(project, "src", "content", "articles")), false);
assert.equal(buildCalls, 0);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, true);
const published = await new ArticleService(sites, builds, previews).update("tenant_test", "site_test", "hello", { ...article, status: "published" });
const service = new ArticleService(sites, builds, previews);
const initialList = await service.list("tenant_test", "site_test");
assert.equal(initialList.pendingBuild, true);
assert.equal(initialList.articles[0]?.liveState, "draft");
const published = await service.update("tenant_test", "site_test", "hello", { ...article, status: "published" });
assert.equal(published.article?.status, "published");
assert.match(await readFile(path.join(articles, "hello.md"), "utf8"), /status: published/);
assert.equal(buildCalls, 0);
await new ArticleService(sites, builds, previews).compilePreview("tenant_test", "site_test");
await service.compilePreview("tenant_test", "site_test");
assert.equal(buildCalls, 1);
assert.equal(buildArticlesDirectory, articles);
assert.equal((await new ArticleService(sites, builds, previews).list("tenant_test", "site_test")).pendingBuild, false);
const uploaded = await new ArticleService(sites, builds, previews).uploadImage("tenant_test", "site_test", {
assert.equal((await service.list("tenant_test", "site_test")).pendingBuild, false);
await syncPublishedArticles(articles, liveArticles);
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "live");
await service.update("tenant_test", "site_test", "hello", { ...article, body: "Updated draft", status: "published" });
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "modified");
await service.restoreLive("tenant_test", "site_test", "hello");
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "live");
const pendingRemoval = await service.remove("tenant_test", "site_test", "hello");
assert.equal(pendingRemoval.article?.liveState, "pending-removal");
assert.equal(await exists(path.join(articles, "hello.md")), true);
await syncPublishedArticles(articles, liveArticles);
assert.equal(await exists(path.join(liveArticles, "hello.md")), false);
assert.equal((await service.list("tenant_test", "site_test")).articles[0]?.liveState, "draft");
const uploaded = await service.uploadImage("tenant_test", "site_test", {
filename: "pixel.png",
dataUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
});
......
......@@ -8,9 +8,11 @@ import { getPublicPreviewUrl } from "../config.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { articleInputSchema } from "../schemas.js";
import { SiteRepository } from "../sites/site-repository.js";
import { articleLiveState } from "./article-storage.js";
type Frontmatter = Record<string, unknown>;
type ArticleBuildState = { pendingSlugs: string[]; lastCompiledAt?: string };
type StoredArticle = Omit<ArticleDocument, "liveState">;
export class ArticleService {
private readonly queues = new Map<string, Promise<unknown>>();
......@@ -23,13 +25,19 @@ export class ArticleService {
async list(tenantId: string, siteId: string): Promise<ArticleListResult> {
const site = await this.sites.get(tenantId, siteId);
if (!await this.supported(this.sites.getProjectPath(tenantId, siteId))) return { supported: false, articles: [], pendingBuild: false, pendingCount: 0 };
if (!await this.supported(this.sites.getProjectPath(tenantId, siteId))) return { supported: false, articles: [], pendingBuild: false, pendingCount: 0, pendingLiveCount: 0 };
const directory = this.sites.getArticlesPath(tenantId, siteId);
await mkdir(directory, { recursive: true });
const liveDirectory = this.sites.getLiveArticlesPath(tenantId, siteId);
await Promise.all([mkdir(directory, { recursive: true }), mkdir(liveDirectory, { recursive: true })]);
const files = (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && !entry.name.startsWith("_") && /\.md$/i.test(entry.name))
.map((entry) => entry.name);
const articles = await Promise.all(files.map((file) => this.readArticleFile(path.join(directory, file), file.replace(/\.md$/i, ""))));
const articles = await Promise.all(files.map(async (file) => {
const slug = file.replace(/\.md$/i, "");
const draftFile = path.join(directory, file);
const article = await this.readArticleFile(draftFile, slug);
return { ...article, liveState: await articleLiveState(draftFile, this.articlePath(liveDirectory, slug), article.status) };
}));
const state = await this.readState(tenantId, siteId);
const pending = new Set(state.pendingSlugs);
return {
......@@ -37,6 +45,7 @@ export class ArticleService {
pendingBuild: pending.size > 0,
pendingCount: pending.size,
lastCompiledAt: state.lastCompiledAt,
pendingLiveCount: articles.filter((article) => article.liveState === "modified" || article.liveState === "pending-removal" || (article.liveState === "draft" && article.status === "published")).length,
articles: articles.map(({ body: _body, ...article }) => ({ ...article, pendingBuild: pending.has(article.slug) })).sort((a, b) => {
const left = a.updatedAt || a.publishedAt || "";
const right = b.updatedAt || b.publishedAt || "";
......@@ -49,7 +58,9 @@ export class ArticleService {
this.assertSlug(slug);
await this.sites.get(tenantId, siteId);
await this.assertSupported(this.sites.getProjectPath(tenantId, siteId));
return this.readArticleFile(this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug), slug);
const draftFile = this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug);
const article = await this.readArticleFile(draftFile, slug);
return { ...article, liveState: await articleLiveState(draftFile, this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug), article.status) };
}
async create(tenantId: string, siteId: string, input: ArticleInput): Promise<ArticleMutationResult> {
......@@ -86,11 +97,29 @@ export class ArticleService {
const source = this.articlePath(directory, slug);
if (!await this.exists(source)) throw Object.assign(new Error("文章不存在"), { statusCode: 404 });
const article = await this.readArticleFile(source, slug);
const liveFile = this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug);
if (await this.exists(liveFile)) {
const next = { ...article, status: "draft" as const };
await writeFile(source, serializeArticle(next), "utf8");
return { article: next, changedSlugs: [slug] };
}
await rm(source, { force: true });
return { deletedSlug: slug, changedSlugs: [slug] };
});
}
async restoreLive(tenantId: string, siteId: string, slug: string): Promise<ArticleMutationResult> {
this.assertSlug(slug);
return this.mutate(tenantId, siteId, async (directory) => {
const liveFile = this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug);
if (!await this.exists(liveFile)) throw Object.assign(new Error("文章还没有线上版本"), { statusCode: 409 });
const destination = this.articlePath(directory, slug);
await cp(liveFile, destination);
const { wordCount: _wordCount, ...article } = await this.readArticleFile(liveFile, slug);
return { article, changedSlugs: [slug] };
});
}
async importMarkdown(tenantId: string, siteId: string, filename: string, markdown: string): Promise<ArticleMutationResult> {
const parsed = importedArticle(filename, markdown);
return this.create(tenantId, siteId, articleInputSchema.parse(parsed));
......@@ -178,9 +207,7 @@ export class ArticleService {
swapped = true;
const state = await this.readState(tenantId, siteId);
await this.writeState(tenantId, siteId, { ...state, pendingSlugs: [...new Set([...state.pendingSlugs, ...changed.changedSlugs])] });
const article = changed.article
? await this.readArticleFile(this.articlePath(articlesDirectory, changed.article.slug), changed.article.slug)
: undefined;
const article = changed.article ? await this.articleDocument(tenantId, siteId, changed.article.slug) : undefined;
return { article, deletedSlug: changed.deletedSlug, previewUrl: site.previewUrl, pendingBuild: true };
} catch (error) {
if (swapped) {
......@@ -204,7 +231,13 @@ export class ArticleService {
return draft;
}
private async readArticleFile(file: string, slug: string): Promise<ArticleDocument> {
private async articleDocument(tenantId: string, siteId: string, slug: string): Promise<ArticleDocument> {
const draftFile = this.articlePath(this.sites.getArticlesPath(tenantId, siteId), slug);
const article = await this.readArticleFile(draftFile, slug);
return { ...article, liveState: await articleLiveState(draftFile, this.articlePath(this.sites.getLiveArticlesPath(tenantId, siteId), slug), article.status) };
}
private async readArticleFile(file: string, slug: string): Promise<StoredArticle> {
try {
const parsed = parseMarkdown(await readFile(file, "utf8"));
const input = articleInputSchema.parse({ slug, ...parsed.data, body: parsed.body });
......
import crypto from "node:crypto";
import path from "node:path";
import { access, cp, mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
import { parse as parseYaml } from "yaml";
import type { ArticleLiveState, ArticleStatus } from "@webagent/shared";
export async function articleLiveState(draftFile: string, liveFile: string, status: ArticleStatus): Promise<ArticleLiveState> {
if (!await exists(liveFile)) return "draft";
if (status !== "published") return "pending-removal";
const [draft, live] = await Promise.all([readFile(draftFile, "utf8"), readFile(liveFile, "utf8")]);
return draft === live ? "live" : "modified";
}
export async function syncPublishedArticles(draftDirectory: string, liveDirectory: string): Promise<void> {
const suffix = crypto.randomBytes(5).toString("hex");
const staging = path.join(path.dirname(liveDirectory), `.live-articles-next-${suffix}`);
const previous = path.join(path.dirname(liveDirectory), `.live-articles-previous-${suffix}`);
await mkdir(draftDirectory, { recursive: true });
await mkdir(staging, { recursive: true });
const entries = await readdir(draftDirectory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || entry.name.startsWith("_") || !/\.md$/i.test(entry.name)) continue;
const source = path.join(draftDirectory, entry.name);
if (articleStatus(await readFile(source, "utf8")) !== "published") continue;
await cp(source, path.join(staging, entry.name));
}
const hasCurrent = await exists(liveDirectory);
if (hasCurrent) await rename(liveDirectory, previous);
try {
await rename(staging, liveDirectory);
} catch (error) {
if (hasCurrent) await rename(previous, liveDirectory).catch(() => undefined);
await rm(staging, { recursive: true, force: true });
throw error;
}
await rm(previous, { recursive: true, force: true });
}
function articleStatus(markdown: string): ArticleStatus {
const normalized = markdown.replaceAll("\r\n", "\n");
if (!normalized.startsWith("---\n")) return "draft";
const boundary = normalized.indexOf("\n---", 4);
if (boundary < 0) return "draft";
const data = parseYaml(normalized.slice(4, boundary));
return data && typeof data === "object" && "status" in data && data.status === "published" ? "published" : "draft";
}
function exists(target: string): Promise<boolean> {
return access(target).then(() => true).catch(() => false);
}
......@@ -45,7 +45,7 @@ export class DomainDeploymentService {
|| bindings.find((domain) => domain.ownershipStatus === "verified");
const dist = await this.builds.build(workspace, taskId, {
basePath: "/", publicOrigin: primary ? `https://${primary.hostname}` : undefined, indexable: true,
articlesDirectory: this.sites.getArticlesPath(tenantId, siteId),
articlesDirectory: this.sites.getLiveArticlesPath(tenantId, siteId),
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
await this.builds.publishCustomDomain(tenantId, siteId, dist);
......
......@@ -181,6 +181,9 @@ app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articl
app.delete<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug", async (request) => (
articles.remove(requireTenant(request.auth), request.params.siteId, request.params.slug)
));
app.post<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articles/:slug/restore-live", async (request) => (
articles.restoreLive(requireTenant(request.auth), request.params.siteId, request.params.slug)
));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/articles/import", async (request, reply) => {
const body = articleImportSchema.parse(request.body);
return reply.code(201).send(await articles.importMarkdown(requireTenant(request.auth), request.params.siteId, body.filename, body.markdown));
......
......@@ -28,8 +28,9 @@ export class CreateSiteService {
createdAt: now, updatedAt: now,
};
const articlesDirectory = this.sites.getArticlesPath(tenantId, siteId);
const liveArticlesDirectory = this.sites.getLiveArticlesPath(tenantId, siteId);
const articleImagesDirectory = this.sites.getArticleImagesPath(tenantId, siteId);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true }), mkdir(articleImagesDirectory, { recursive: true })]);
await Promise.all([mkdir(projectPath, { recursive: true }), mkdir(articlesDirectory, { recursive: true }), mkdir(liveArticlesDirectory, { recursive: true }), mkdir(articleImagesDirectory, { recursive: true })]);
await this.sites.save(site);
try {
await cp(config.templateDir, projectPath, { recursive: true, filter: (source) => !["node_modules", "dist", ".astro", ".git", "pnpm-lock.yaml", "examples"].includes(path.basename(source)) });
......
......@@ -2,6 +2,7 @@ import path from "node:path";
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import type { SiteInfo } from "@webagent/shared";
import { config, getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { syncPublishedArticles } from "../articles/article-storage.js";
export class SiteRepository {
private runtimeReady?: Promise<void>;
......@@ -40,6 +41,10 @@ export class SiteRepository {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "articles");
}
getLiveArticlesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "live-articles");
}
getArticleImagesPath(tenantId: string, siteId: string): string {
return path.join(this.getSiteRoot(tenantId, siteId), "content", "images");
}
......@@ -155,6 +160,7 @@ export class SiteRepository {
]);
await this.migrateLegacySites();
await this.migrateFlatSites();
await this.migrateArticleLiveSlots();
await Promise.all([
this.migrateLegacyPreviews(),
this.migrateFlatArtifacts(config.previewsDir),
......@@ -163,6 +169,26 @@ export class SiteRepository {
]);
}
private async migrateArticleLiveSlots(): Promise<void> {
const tenants = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
for (const tenant of tenants) {
if (!tenant.isDirectory() || !/^tenant_[a-z0-9_]+$/.test(tenant.name)) continue;
const tenantRoot = path.join(runtimePaths.sites, tenant.name);
const siteEntries = await readdir(tenantRoot, { withFileTypes: true }).catch(() => []);
for (const entry of siteEntries) {
if (!entry.isDirectory() || !/^site_[a-z0-9]+$/.test(entry.name)) continue;
const siteRoot = path.join(tenantRoot, entry.name);
const metadata = await readFile(path.join(siteRoot, "metadata", "site.json"), "utf8")
.then((raw) => JSON.parse(raw) as SiteInfo)
.catch(() => undefined);
if (!metadata?.publishedCommit) continue;
const liveDirectory = path.join(siteRoot, "content", "live-articles");
if (await stat(liveDirectory).then(() => true).catch(() => false)) continue;
await syncPublishedArticles(path.join(siteRoot, "content", "articles"), liveDirectory);
}
}
}
private async migrateLegacySites(): Promise<void> {
const legacySitesDir = path.join(config.runtimeDir, "sites");
if (path.resolve(legacySitesDir) === path.resolve(runtimePaths.sites)) return;
......
......@@ -8,6 +8,7 @@ import { GitManager } from "../git/git-manager.js";
import { PreviewProcessManager } from "../preview/preview-process-manager.js";
import { ensureEnvironmentConfig } from "./ensure-environment-config.js";
import { SiteRepository } from "./site-repository.js";
import { syncPublishedArticles } from "../articles/article-storage.js";
export class SiteVersionService {
constructor(
......@@ -180,6 +181,7 @@ export class SiteVersionService {
articleImagesDirectory: this.sites.getArticleImagesPath(tenantId, siteId),
});
await this.builds.publishProduction(tenantId, siteId, dist);
await syncPublishedArticles(this.sites.getArticlesPath(tenantId, siteId), this.sites.getLiveArticlesPath(tenantId, siteId));
const publishedAt = new Date().toISOString();
const productionUrl = getPublicProductionUrl(tenantId, siteId);
await this.sites.update(tenantId, siteId, {
......
This diff is collapsed.
......@@ -57,6 +57,7 @@ export const api = {
createArticle: (siteId: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles`, { method: "POST", body: JSON.stringify(input) }),
updateArticle: (siteId: string, slug: string, input: ArticleInput) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "PUT", body: JSON.stringify(input) }),
deleteArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}`, { method: "DELETE" }),
restoreLiveArticle: (siteId: string, slug: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/${slug}/restore-live`, { method: "POST" }),
importArticle: (siteId: string, filename: string, markdown: string) => request<ArticleMutationResult>(`/api/sites/${siteId}/articles/import`, { method: "POST", body: JSON.stringify({ filename, markdown }) }),
compileArticles: (siteId: string) => request<ArticleCompileResult>(`/api/sites/${siteId}/articles/compile`, { method: "POST" }),
uploadArticleImage: (siteId: string, filename: string, dataUrl: string) => request<ArticleImageUploadResult>(`/api/sites/${siteId}/article-images`, { method: "POST", body: JSON.stringify({ filename, dataUrl }) }),
......
......@@ -86,3 +86,5 @@
@media(max-width:800px){.article-workspace{display:none}.workspace.articles-mode .control-panel{display:none}.workspace.articles-mode .article-workspace{display:flex;grid-column:2}.article-layout{grid-template-columns:170px minmax(0,1fr)}.article-header{padding:0 12px}.article-editor{padding:14px}.article-editor-heading{align-items:flex-start;flex-direction:column}.article-editor-heading>div:last-child{flex-wrap:wrap}}
.article-editor-heading>div:first-child{flex-wrap:wrap}.article-status-badge{margin-left:8px;padding:4px 7px;border-radius:6px;font-size:7px;font-style:normal;font-weight:800}.article-status-badge.draft{color:#8c6200;background:#fff7df}.article-status-badge.published{color:#13765b;background:#e7f8f2}.article-editor-heading .article-save-draft{color:var(--primary);background:var(--soft);border-color:#d9ceff}.article-publish-help{display:flex;flex-direction:column;justify-content:center;gap:5px;padding:8px 10px;background:#fafafd;border:1px solid var(--border);border-radius:8px}.article-publish-help>span{color:var(--text-2);font-size:8px;font-weight:750}.article-publish-help strong{font-size:10px}.article-publish-help small{color:var(--muted);font-size:8px}
.article-editor-only{display:block;overflow:auto}.article-editor-only .article-editor{width:min(980px,100%);margin:0 auto}.article-side-panel{min-height:0;display:flex;flex-direction:column;padding:17px 13px;background:linear-gradient(180deg,#fff,#faf9ff)}.article-side-intro{display:flex;align-items:center;gap:10px;padding:0 4px}.article-side-intro h2{margin:0;font-size:15px}.article-side-intro p{margin:4px 0 0;color:var(--muted);font-size:8px;line-height:1.5}.article-side-intro .domain-side-icon{width:35px;height:35px;flex:0 0 auto}.article-flow-guide{margin-top:13px;padding:11px;background:#f7f5ff;border:1px solid #e2dcf8;border-radius:11px}.article-flow-guide>strong{color:var(--primary);font-size:8px}.article-flow-guide ol{display:flex;flex-direction:column;gap:7px;margin:9px 0 0;padding:0;list-style:none}.article-flow-guide li{display:flex;align-items:flex-start;gap:7px}.article-flow-guide li>b{display:grid;place-items:center;width:18px;height:18px;flex:0 0 auto;color:#fff;background:var(--primary);border-radius:6px;font-size:7px}.article-flow-guide li span{font-size:8px;font-weight:750}.article-flow-guide li small{display:block;margin-top:2px;color:var(--muted);font-size:7px;font-weight:400;line-height:1.4}.article-batch-card{display:grid;grid-template-columns:1fr auto;gap:7px;margin-top:9px;padding:10px;background:#fff;border:1px solid var(--border);border-radius:11px}.article-batch-card.pending{border-color:#decf9a;background:#fffdf5}.article-batch-card>div span,.article-batch-card>div small{display:block}.article-batch-card>div span{font-size:9px;font-weight:800}.article-batch-card>div small{margin-top:4px;color:var(--muted);font-size:7px}.article-batch-card button{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;color:var(--primary);background:var(--soft);border:1px solid #ddd4ff;border-radius:7px;font-size:7px;font-weight:800}.article-batch-card button:disabled{opacity:.5}.article-batch-card .article-go-live{grid-column:1/-1;color:#fff;background:var(--gradient);border:0}.article-side-notice{margin-top:8px;padding:7px 9px;color:#13765b;background:#e9f9f3;border-radius:7px;font-size:7px}.article-side-notice.error{color:#b42318;background:#fff0ee}.article-side-tools{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px}.article-side-tools button,.article-side-tools label{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;color:var(--text-2);background:#fff;border:1px solid var(--border);border-radius:7px;font-size:7px;font-weight:750;cursor:pointer}.article-side-tools input{display:none}.article-side-list-heading{display:flex;align-items:center;justify-content:space-between;margin:13px 3px 7px}.article-side-list-heading strong{font-size:9px}.article-side-list-heading span{color:var(--muted);font-size:7px}.article-side-list{min-height:0;overflow:auto;display:flex;flex-direction:column;gap:4px}.article-side-list>button{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px;padding:8px;color:var(--text);text-align:left;background:#fff;border:1px solid transparent;border-radius:8px}.article-side-list>button:hover,.article-side-list>button.active{background:#f5f2ff;border-color:#ddd4ff}.article-side-list>button>span{min-width:0}.article-side-list strong,.article-side-list small{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.article-side-list strong{font-size:8px}.article-side-list small{margin-top:3px;color:var(--muted);font-size:6px}.article-side-list em{padding:3px 4px;border-radius:4px;font-size:6px;font-style:normal;font-weight:800}.article-side-list em.draft{color:#8c6200;background:#fff7df}.article-side-list em.published{color:#13765b;background:#e7f8f2}.article-side-list i{grid-column:1/-1;width:max-content;color:#8c6200;font-size:6px;font-style:normal;font-weight:800}
.article-editor-heading{position:sticky;z-index:5;top:0;margin:-10px -10px 14px;padding:10px;background:rgba(244,245,249,.96);border-bottom:1px solid rgba(224,222,230,.8);backdrop-filter:blur(10px)}.article-editor-heading button:disabled{opacity:.45;cursor:not-allowed}.article-status-badge.pending,.article-side-list em.pending{color:#8c6200;background:#fff1c9}.article-dirty,.article-saved,.article-unsaved{display:flex;align-items:center;gap:4px;margin-left:3px;font-size:7px;font-weight:750}.article-dirty{color:#9a6500}.article-dirty i{width:6px;height:6px;background:#e2a322;border-radius:50%}.article-saved{color:#18805f}.article-unsaved{color:var(--muted)}.article-field-help{color:var(--muted);font-size:7px;line-height:1.45}.article-list-controls{display:grid;grid-template-columns:minmax(0,1fr) 80px;gap:5px;margin-bottom:7px}.article-list-controls label{height:29px;display:flex;align-items:center;gap:5px;padding:0 7px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:7px}.article-list-controls label:focus-within{color:var(--primary);border-color:#cfc0ff}.article-list-controls input,.article-list-controls select{min-width:0;width:100%;height:100%;padding:0;color:var(--text);background:transparent;border:0;outline:0;font-size:7px}.article-list-controls select{padding:0 5px;background:#fff;border:1px solid var(--border);border-radius:7px}.article-publish-blocker{grid-column:1/-1;margin:0!important;padding:0 2px;color:#9a6500!important;font-size:6px!important;line-height:1.4}.article-batch-card .article-go-live[aria-disabled="true"]{color:#8f8b97;background:#efeff4;border:1px solid #e0dde5;cursor:not-allowed}.article-empty{min-height:100px;height:auto}
.article-batch-card.staged{border-color:#cfc1fa;background:#faf8ff}.article-preview-link{height:29px;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;color:var(--primary);background:#fff;border:1px solid #d9ceff;border-radius:7px;font-size:7px;font-weight:800;text-decoration:none}.article-sync-check{display:flex;align-items:center;gap:4px;color:#16805f;font-size:7px;font-weight:750}.article-sync-check.pending{color:#9a6500}.article-side-list>button>span i{display:block;margin-top:4px;color:var(--muted);font-size:6px;font-style:normal;font-weight:650}.article-side-list em.live,.article-status-badge.live{color:#13765b;background:#e7f8f2}.article-side-list em.modified,.article-status-badge.modified{color:#7040c3;background:#eee8ff}.article-side-list em.pending-removal,.article-status-badge.pending-removal{color:#a54242;background:#fff0f0}.article-status-badge.draft{color:#8c6200;background:#fff7df}.article-editor-heading .article-restore{color:#6845c5}.article-fields input:disabled{color:#8c8994;background:#f0eff3;cursor:not-allowed}
......@@ -197,6 +197,7 @@ export interface DraftPreviewResult {
}
export type ArticleStatus = "draft" | "published";
export type ArticleLiveState = "draft" | "live" | "modified" | "pending-removal";
export interface ArticleSeoInput {
title?: string;
......@@ -223,10 +224,12 @@ export interface ArticleInput {
export interface ArticleSummary extends Omit<ArticleInput, "body"> {
wordCount: number;
pendingBuild: boolean;
liveState: ArticleLiveState;
}
export interface ArticleDocument extends ArticleInput {
wordCount: number;
liveState: ArticleLiveState;
}
export interface ArticleListResult {
......@@ -234,6 +237,7 @@ export interface ArticleListResult {
articles: ArticleSummary[];
pendingBuild: boolean;
pendingCount: number;
pendingLiveCount: number;
lastCompiledAt?: string;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment