@rimelight/cms 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +16 -14
  3. package/src/admin/api/media-file.ts +39 -0
  4. package/src/admin/api/media.ts +199 -0
  5. package/src/admin/layouts/CMSDashboardLayout.astro +178 -0
  6. package/src/admin/layouts/DefaultParentLayout.astro +50 -0
  7. package/src/admin/pages/assets.astro +424 -0
  8. package/src/admin/pages/byline-schema.astro +62 -0
  9. package/src/admin/pages/bylines.astro +16 -0
  10. package/src/admin/pages/categories.astro +21 -0
  11. package/src/admin/pages/content-types.astro +55 -0
  12. package/src/admin/pages/dashboard.astro +61 -0
  13. package/src/admin/pages/pages-edit.astro +595 -0
  14. package/src/admin/pages/pages-index.astro +146 -0
  15. package/src/admin/pages/pages-new.astro +130 -0
  16. package/src/admin/pages/pages-preview.astro +111 -0
  17. package/src/admin/pages/pages-review.astro +236 -0
  18. package/src/admin/pages/settings.astro +203 -0
  19. package/src/admin/pages/tags.astro +21 -0
  20. package/src/admin/pages/templates-edit.astro +234 -0
  21. package/src/admin/pages/templates-index.astro +95 -0
  22. package/src/admin/pages/templates-new.astro +193 -0
  23. package/src/admin/pages/users.astro +74 -0
  24. package/src/admin/pages/versions.astro +74 -0
  25. package/src/astro/BlockRenderer.astro +44 -96
  26. package/src/astro/PageRenderer.astro +9 -2
  27. package/src/astro/blocks/CalloutBlock.astro +7 -7
  28. package/src/astro/blocks/CardBlock.astro +5 -5
  29. package/src/astro/blocks/CodeBlock.astro +2 -2
  30. package/src/astro/blocks/FileTreeBlock.astro +7 -7
  31. package/src/astro/blocks/ImageBlock.astro +2 -2
  32. package/src/astro/blocks/OrderedListBlock.astro +2 -2
  33. package/src/astro/blocks/ParagraphBlock.astro +3 -3
  34. package/src/astro/blocks/SectionBlock.astro +6 -6
  35. package/src/astro/blocks/StepItemBlock.astro +3 -3
  36. package/src/astro/blocks/StepsBlock.astro +1 -1
  37. package/src/astro/blocks/TableBlock.astro +6 -6
  38. package/src/astro/blocks/TabsBlock.astro +9 -9
  39. package/src/astro/blocks/UnorderedListBlock.astro +2 -2
  40. package/src/astro/blocks/index.ts +35 -0
  41. package/src/client/components/RimelightBlock.tsx +19 -19
  42. package/src/client/components/RimelightBlockPicker.tsx +9 -13
  43. package/src/client/components/RimelightBylinesManager.tsx +438 -0
  44. package/src/client/components/RimelightEditor.tsx +3 -3
  45. package/src/client/components/RimelightPreview.tsx +37 -45
  46. package/src/client/components/RimelightPropertiesPanel.tsx +368 -26
  47. package/src/client/components/RimelightTaxonomyManager.tsx +369 -0
  48. package/src/client/components/RimelightTaxonomyMasterDetail.tsx +412 -0
  49. package/src/client/components/RimelightTemplateEditor.tsx +6 -8
  50. package/src/client/components/editors/CalloutEditor.tsx +2 -4
  51. package/src/client/components/editors/CardsEditor.tsx +2 -4
  52. package/src/client/components/editors/CodeEditor.tsx +1 -3
  53. package/src/client/components/editors/FileTreeEditor.tsx +2 -4
  54. package/src/client/components/editors/ImageEditor.tsx +3 -3
  55. package/src/client/components/editors/ParagraphEditor.tsx +2 -2
  56. package/src/client/components/editors/SceneEditor.tsx +6 -12
  57. package/src/client/components/editors/ScriptEditor.tsx +8 -16
  58. package/src/client/components/editors/SectionEditor.tsx +4 -4
  59. package/src/client/components/editors/StepItemEditor.tsx +2 -2
  60. package/src/client/components/editors/StepsEditor.tsx +2 -4
  61. package/src/client/components/editors/TabItemEditor.tsx +3 -3
  62. package/src/client/components/editors/TableEditor.tsx +5 -5
  63. package/src/client/components/editors/TabsEditor.tsx +2 -4
  64. package/src/client/components/index.ts +3 -0
  65. package/src/client/loader.ts +3 -0
  66. package/src/core/diff.ts +145 -0
  67. package/src/core/excerpt.ts +159 -0
  68. package/src/core/preview.ts +87 -0
  69. package/src/core/registry.ts +0 -39
  70. package/src/core/types/blocks.ts +83 -6
  71. package/src/core/types/bylines.ts +14 -0
  72. package/src/core/types/media.ts +13 -0
  73. package/src/core/types/taxonomies.ts +20 -0
  74. package/src/cron/scheduled.ts +57 -0
  75. package/src/docs/agent.ts +116 -0
  76. package/src/docs/config.ts +25 -0
  77. package/src/docs/index.ts +5 -0
  78. package/src/docs/routing.ts +104 -0
  79. package/src/docs/sidebar.ts +251 -0
  80. package/src/docs/toc.ts +47 -0
  81. package/src/env.d.ts +13 -0
  82. package/src/index.ts +40 -6
  83. package/src/integration.ts +196 -0
  84. package/src/loader/live.ts +254 -0
  85. package/src/markdown/index.ts +0 -1
  86. package/src/mcp/index.ts +228 -0
  87. package/src/schema/bylines.ts +23 -0
  88. package/src/schema/index.ts +3 -0
  89. package/src/schema/page_versions.ts +2 -0
  90. package/src/schema/pages.ts +2 -0
  91. package/src/schema/site_settings.ts +41 -0
  92. package/src/schema/taxonomies.ts +49 -0
  93. package/src/services/bylines.ts +74 -0
  94. package/src/services/search-indexer.ts +61 -0
  95. package/src/services/site-settings.ts +108 -0
  96. package/src/services/taxonomies.ts +276 -0
  97. package/src/storage/index.ts +253 -0
  98. package/src/astro/blocks/DialogueBlock.astro +0 -23
  99. package/src/astro/blocks/SceneBlock.astro +0 -65
  100. package/src/astro/blocks/ScriptBlock.astro +0 -59
  101. package/src/core/template-sync.ts +0 -75
  102. package/src/markdown/parser.ts +0 -347
  103. package/src/migration.ts +0 -155
  104. package/src/search/sync.ts +0 -32
@@ -0,0 +1,146 @@
1
+ ---
2
+ import CMSDashboardLayout from "../layouts/CMSDashboardLayout.astro";
3
+ import RLAButton from "@rimelight/ui/components/button/RLAButton.astro";
4
+ import RLABadge from "@rimelight/ui/components/badge/RLABadge.astro";
5
+ import RLATable from "@rimelight/ui/components/table/RLATable.astro";
6
+ import type { TableColumn } from "@rimelight/ui/components/table/table.ts";
7
+ import { db } from "virtual:rimelight-cms/db";
8
+ import { pages } from "../../schema/pages.ts";
9
+ import { desc, isNull } from "drizzle-orm";
10
+ import { getRelativeLocaleUrl } from "@rimelight/i18n";
11
+
12
+ export const prerender = false;
13
+
14
+ const currentLocale = Astro.currentLocale ?? "en";
15
+
16
+ let pagesList: any[] = [];
17
+ if (db) {
18
+ try {
19
+ pagesList = await db
20
+ .select()
21
+ .from(pages)
22
+ .where(isNull(pages.deletedAt))
23
+ .orderBy(desc(pages.createdAt));
24
+ } catch (err) {
25
+ console.warn("Failed to fetch pages:", err);
26
+ }
27
+ }
28
+
29
+ const formattedPages = pagesList.map((page) => {
30
+ const titleVal =
31
+ typeof page.title === "object" && page.title !== null
32
+ ? (page.title as Record<string, string>).en || ""
33
+ : String(page.title || "");
34
+ return {
35
+ id: page.id,
36
+ title: titleVal,
37
+ slug: page.slug,
38
+ type: page.type || "page",
39
+ status: page.publishedVersionId ? "published" : "draft",
40
+ publishedVersionId: page.publishedVersionId,
41
+ createdAt: page.createdAt
42
+ };
43
+ });
44
+
45
+ const columns: TableColumn[] = [
46
+ { id: "title", accessorKey: "title", header: "Title", enableSorting: true },
47
+ { id: "slug", accessorKey: "slug", header: "Slug", enableSorting: true },
48
+ { id: "type", accessorKey: "type", header: "Type", enableSorting: true },
49
+ { id: "status", accessorKey: "status", header: "Status", enableSorting: true },
50
+ { id: "actions", header: "Actions", enableSorting: false, meta: { class: { th: "text-right", td: "text-right" } } }
51
+ ];
52
+ ---
53
+
54
+ <CMSDashboardLayout title="Pages" description="Manage CMS pages" activeSection="pages">
55
+ <Fragment slot="toolbar-right">
56
+ <RLAButton href={getRelativeLocaleUrl(currentLocale, "/cms/pages/new")} variant="solid" color="success" leadingIcon="i-lucide-plus">
57
+ Create Page
58
+ </RLAButton>
59
+ </Fragment>
60
+
61
+ <RLATable
62
+ columns={columns}
63
+ data={formattedPages}
64
+ searchable
65
+ searchPlaceholder="Search pages..."
66
+ showColumnsToggle
67
+ hoverable
68
+ striped
69
+ class="w-full"
70
+ >
71
+ {formattedPages.map((page) => (
72
+ <tr class="hover:bg-muted/20 transition-colors" data-slot="tr" data-row-id={page.id}>
73
+ <td class="p-3.5 text-sm font-medium text-highlighted" data-col-id="title" data-slot="td">
74
+ {page.title}
75
+ </td>
76
+ <td class="p-3.5 text-sm text-muted font-mono text-xs" data-col-id="slug" data-slot="td">
77
+ {page.slug}
78
+ </td>
79
+ <td class="p-3.5" data-col-id="type" data-slot="td">
80
+ <RLABadge variant="soft" color="blue" size="xs">
81
+ {page.type}
82
+ </RLABadge>
83
+ </td>
84
+ <td class="p-3.5" data-col-id="status" data-slot="td">
85
+ {page.status === "published" ? (
86
+ <RLABadge variant="soft" color="green" size="xs">Published</RLABadge>
87
+ ) : (
88
+ <RLABadge variant="soft" color="yellow" size="xs">Draft</RLABadge>
89
+ )}
90
+ </td>
91
+ <td class="p-3.5 text-right whitespace-nowrap" data-col-id="actions" data-slot="td">
92
+ <div class="flex items-center justify-end gap-2">
93
+ <RLAButton
94
+ href={getRelativeLocaleUrl(currentLocale, `/cms/pages/${page.id}/edit`)}
95
+ variant="ghost"
96
+ color="primary"
97
+ size="xs"
98
+ >
99
+ Edit
100
+ </RLAButton>
101
+ <RLAButton
102
+ href={getRelativeLocaleUrl(currentLocale, `/cms/pages/${page.id}/preview`)}
103
+ variant="ghost"
104
+ color="neutral"
105
+ size="xs"
106
+ >
107
+ Preview
108
+ </RLAButton>
109
+ <RLAButton
110
+ variant="ghost"
111
+ color="error"
112
+ size="xs"
113
+ class="delete-page-btn"
114
+ data-page-id={page.id}
115
+ >
116
+ Delete
117
+ </RLAButton>
118
+ </div>
119
+ </td>
120
+ </tr>
121
+ ))}
122
+ </RLATable>
123
+
124
+ <script>
125
+ document.querySelectorAll(".delete-page-btn").forEach((btn) => {
126
+ btn.addEventListener("click", async () => {
127
+ const pageId = (btn as HTMLElement).dataset.pageId;
128
+ if (!pageId) return;
129
+ if (!confirm("Are you sure you want to delete this page?")) return;
130
+
131
+ try {
132
+ const res = await fetch(`/api/cms/pages/${pageId}`, {
133
+ method: "DELETE"
134
+ });
135
+ if (res.ok) {
136
+ window.location.reload();
137
+ } else {
138
+ alert("Failed to delete page");
139
+ }
140
+ } catch {
141
+ alert("Error deleting page");
142
+ }
143
+ });
144
+ });
145
+ </script>
146
+ </CMSDashboardLayout>
@@ -0,0 +1,130 @@
1
+ ---
2
+ import CMSDashboardLayout from "../layouts/CMSDashboardLayout.astro";
3
+ import RLAButton from "@rimelight/ui/components/button/RLAButton.astro";
4
+ import RLAInput from "@rimelight/ui/components/input/RLAInput.astro";
5
+ import RLASelect from "@rimelight/ui/components/select/RLASelect.astro";
6
+ import { db } from "virtual:rimelight-cms/db";
7
+ import { pageTemplates } from "../../schema/page_templates.ts";
8
+ import { desc } from "drizzle-orm";
9
+ import { getRelativeLocaleUrl } from "@rimelight/i18n";
10
+
11
+ export const prerender = false;
12
+
13
+ const currentLocale = Astro.currentLocale ?? "en";
14
+
15
+ let templates: any[] = [];
16
+ if (db) {
17
+ try {
18
+ templates = await db
19
+ .select()
20
+ .from(pageTemplates)
21
+ .orderBy(desc(pageTemplates.createdAt));
22
+ } catch (err) {
23
+ console.warn("Failed to fetch templates:", err);
24
+ }
25
+ }
26
+
27
+ const url = new URL(Astro.request.url);
28
+ const selectedTemplateId = url.searchParams.get("templateId") || "";
29
+ const selectedTemplate = templates.find((t: any) => t.id === selectedTemplateId);
30
+
31
+ const templateOptions = [
32
+ { label: "Blank Page", value: "" },
33
+ ...templates.map((t: any) => ({
34
+ label: `${typeof t.title === "object" ? t.title.en || t.title : t.title} (${t.pageType})`,
35
+ value: t.id
36
+ }))
37
+ ];
38
+ ---
39
+
40
+ <CMSDashboardLayout title="Create Page" description="Create a new CMS page" activeSection="pages">
41
+ <div class="max-w-3xl mx-auto space-y-6">
42
+ <form id="page-form" class="bg-elevated/40 border border-default p-6 rounded-2xl shadow space-y-6">
43
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
44
+ <div>
45
+ <label class="block text-sm font-medium text-highlighted mb-2">Slug</label>
46
+ <RLAInput name="slug" required />
47
+ </div>
48
+ <div>
49
+ <label class="block text-sm font-medium text-highlighted mb-2">Type</label>
50
+ <RLASelect
51
+ name="type"
52
+ placeholder="Select page type..."
53
+ value={selectedTemplate?.pageType || "blog"}
54
+ items={[
55
+ { label: "Blog Post", value: "blog" },
56
+ { label: "Wiki Page", value: "wiki" },
57
+ { label: "Documentation", value: "doc" },
58
+ { label: "Announcement", value: "announcement" }
59
+ ]}
60
+ required
61
+ />
62
+ </div>
63
+ </div>
64
+
65
+ <div>
66
+ <label class="block text-sm font-medium text-highlighted mb-2">Title</label>
67
+ <RLAInput name="title" required />
68
+ </div>
69
+
70
+ <div>
71
+ <label class="block text-sm font-medium text-highlighted mb-2">Template</label>
72
+ <RLASelect
73
+ name="templateId"
74
+ placeholder="Select template..."
75
+ value={selectedTemplateId}
76
+ items={templateOptions}
77
+ />
78
+ </div>
79
+
80
+ <div class="border-t border-default pt-6 flex items-center justify-end gap-3">
81
+ <RLAButton href={getRelativeLocaleUrl(currentLocale, "/cms/pages")} variant="outline" color="neutral" size="sm">
82
+ Cancel
83
+ </RLAButton>
84
+ <RLAButton variant="solid" color="primary" size="sm" leadingIcon="i-lucide-plus" type="submit">
85
+ Create & Edit
86
+ </RLAButton>
87
+ </div>
88
+ </form>
89
+ </div>
90
+
91
+ <script is:inline define:vars={{ currentLocale }}>
92
+ const form = document.getElementById('page-form');
93
+
94
+ form?.addEventListener('submit', async (e) => {
95
+ e.preventDefault();
96
+
97
+ const formData = new FormData(form);
98
+ const rawTmplId = formData.get('templateId');
99
+ const templateId =
100
+ rawTmplId && rawTmplId !== 'null' && String(rawTmplId).trim() !== ''
101
+ ? String(rawTmplId).trim()
102
+ : null;
103
+
104
+ const data = {
105
+ slug: formData.get('slug'),
106
+ type: formData.get('type'),
107
+ title: formData.get('title'),
108
+ templateId
109
+ };
110
+
111
+ try {
112
+ const response = await fetch('/api/cms/pages', {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify(data)
116
+ });
117
+
118
+ if (response.ok) {
119
+ const result = (await response.json());
120
+ window.location.href = `/${currentLocale}/cms/pages/${result.page.id}/edit`;
121
+ } else {
122
+ const errorData = (await response.json().catch(() => ({})));
123
+ alert(`Failed to create page: ${errorData.error || 'Unknown error'}`);
124
+ }
125
+ } catch {
126
+ alert('Error creating page: Network error');
127
+ }
128
+ });
129
+ </script>
130
+ </CMSDashboardLayout>
@@ -0,0 +1,111 @@
1
+ ---
2
+ import PageRenderer from "../../astro/PageRenderer.astro";
3
+ import CMSDashboardLayout from "../layouts/CMSDashboardLayout.astro";
4
+ import RLAButton from "@rimelight/ui/components/button/RLAButton.astro";
5
+ import { db } from "virtual:rimelight-cms/db";
6
+ import { pages, pageDrafts } from "../../schema";
7
+ import { eq } from "drizzle-orm";
8
+ import { getRelativeLocaleUrl } from "@rimelight/i18n";
9
+ import { verifyPreviewToken } from "../../core/preview.ts";
10
+
11
+ export const prerender = false;
12
+
13
+ const currentLocale = Astro.currentLocale ?? "en";
14
+ const id = Astro.params.id;
15
+
16
+ if (!id) {
17
+ return Astro.redirect(getRelativeLocaleUrl(currentLocale, "/cms/pages"));
18
+ }
19
+
20
+ const token = Astro.url.searchParams.get("token");
21
+ const secret = process.env.BETTER_AUTH_SECRET || "rimelight-preview-secret-key";
22
+ if (token) {
23
+ await verifyPreviewToken(id, token, secret);
24
+ }
25
+
26
+ let page: any = null;
27
+ let draft: any = null;
28
+
29
+ if (db) {
30
+ try {
31
+ const pageResult = await db.select().from(pages).where(eq(pages.id, id as any)).limit(1);
32
+ page = pageResult[0] || null;
33
+ if (page) {
34
+ const draftResult = await db.select().from(pageDrafts).where(eq(pageDrafts.pageId, id as any)).limit(1);
35
+ draft = draftResult[0] || null;
36
+ }
37
+ } catch (err) {
38
+ console.warn("Failed to load page for preview:", err);
39
+ }
40
+ }
41
+
42
+ if (!page) {
43
+ return Astro.redirect(getRelativeLocaleUrl(currentLocale, "/cms/pages"));
44
+ }
45
+
46
+ const parseContent = (c: any) => (typeof c === "string" ? JSON.parse(c) : c);
47
+ const content = draft?.content ? parseContent(draft.content) : parseContent(page.content);
48
+
49
+ const titleVal =
50
+ typeof page.title === "object" && page.title !== null
51
+ ? (page.title as Record<string, string>)[currentLocale] || (page.title as Record<string, string>).en || ""
52
+ : String(page.title || "");
53
+ ---
54
+
55
+ <CMSDashboardLayout title={`Preview: ${titleVal}`} description="Preview CMS page" activeSection="pages">
56
+ <Fragment slot="toolbar-right">
57
+ <div class="flex items-center gap-2">
58
+ <button
59
+ id="share-preview-btn"
60
+ type="button"
61
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary bg-primary/10 border border-primary/20 rounded-md hover:bg-primary/20 transition-colors cursor-pointer"
62
+ >
63
+ <span class="i-lucide-share-2 size-3.5"></span>
64
+ <span id="share-btn-text">Share Preview Link</span>
65
+ </button>
66
+
67
+ <RLAButton href={getRelativeLocaleUrl(currentLocale, `/cms/pages/${id}/edit`)} variant="outline" color="neutral" size="sm" leadingIcon="i-lucide-arrow-left">
68
+ Back to Editor
69
+ </RLAButton>
70
+ </div>
71
+ </Fragment>
72
+
73
+ <div class="max-w-4xl mx-auto space-y-6">
74
+ <div class="bg-primary/10 border border-primary/20 p-4 rounded-lg flex items-center justify-between">
75
+ <p class="text-sm text-primary font-medium">Live Preview Mode — displaying current draft</p>
76
+ <span class="text-xs px-2.5 py-0.5 bg-primary/20 text-primary rounded-full font-mono">Draft</span>
77
+ </div>
78
+
79
+ <div class="bg-elevated/40 rounded-xl shadow-sm border border-default p-8">
80
+ <h1 class="text-3xl font-bold text-highlighted mb-6">{titleVal}</h1>
81
+ <PageRenderer
82
+ blocks={content?.blocks || []}
83
+ locale={currentLocale}
84
+ class="prose prose-lg max-w-none"
85
+ />
86
+ </div>
87
+ </div>
88
+
89
+ <script is:inline define:vars={{ pageId: id }}>
90
+ const shareBtn = document.getElementById("share-preview-btn");
91
+ const shareText = document.getElementById("share-btn-text");
92
+
93
+ shareBtn?.addEventListener("click", async () => {
94
+ if (shareText) shareText.textContent = "Generating...";
95
+ try {
96
+ const res = await fetch(`/api/cms/pages/${pageId}/preview-token`, { method: "POST" });
97
+ if (!res.ok) throw new Error("Failed to generate token");
98
+ const data = await res.json();
99
+ const fullUrl = `${window.location.origin}${data.previewUrl}`;
100
+ await navigator.clipboard.writeText(fullUrl);
101
+ if (shareText) shareText.textContent = "Copied to Clipboard!";
102
+ setTimeout(() => {
103
+ if (shareText) shareText.textContent = "Share Preview Link";
104
+ }, 3000);
105
+ } catch {
106
+ alert("Could not generate preview link");
107
+ if (shareText) shareText.textContent = "Share Preview Link";
108
+ }
109
+ });
110
+ </script>
111
+ </CMSDashboardLayout>
@@ -0,0 +1,236 @@
1
+ ---
2
+ import CMSDashboardLayout from "../layouts/CMSDashboardLayout.astro";
3
+ import RLAButton from "@rimelight/ui/components/button/RLAButton.astro";
4
+ import RLABadge from "@rimelight/ui/components/badge/RLABadge.astro";
5
+ import { db } from "virtual:rimelight-cms/db";
6
+ import { pages, pageDrafts, pageVersions } from "../../schema";
7
+ import { eq, desc } from "drizzle-orm";
8
+ import { getRelativeLocaleUrl } from "@rimelight/i18n";
9
+ import { diffPageSnapshots } from "../../core/diff.ts";
10
+
11
+ export const prerender = false;
12
+
13
+ const currentLocale = Astro.currentLocale ?? "en";
14
+ const id = Astro.params.id;
15
+
16
+ if (!id) {
17
+ return Astro.redirect(getRelativeLocaleUrl(currentLocale, "/cms/pages"));
18
+ }
19
+
20
+ let page: any = null;
21
+ let draft: any = null;
22
+ let versions: any[] = [];
23
+
24
+ if (db) {
25
+ try {
26
+ const pageResult = await db.select().from(pages).where(eq(pages.id, id as any)).limit(1);
27
+ page = pageResult[0] || null;
28
+ if (page) {
29
+ const draftResult = await db.select().from(pageDrafts).where(eq(pageDrafts.pageId, id as any)).limit(1);
30
+ draft = draftResult[0] || null;
31
+ versions = await db
32
+ .select()
33
+ .from(pageVersions)
34
+ .where(eq(pageVersions.pageId, id as any))
35
+ .orderBy(desc(pageVersions.versionNumber))
36
+ .limit(10);
37
+ }
38
+ } catch (err) {
39
+ console.warn("Failed to load review data:", err);
40
+ }
41
+ }
42
+
43
+ if (!page) {
44
+ return Astro.redirect(getRelativeLocaleUrl(currentLocale, "/cms/pages"));
45
+ }
46
+
47
+ const parseJson = (val: any) => (typeof val === "string" ? JSON.parse(val) : val);
48
+ const publishedSnapshot = { ...page, content: parseJson(page.content) };
49
+ const draftSnapshot = draft ? { ...page, content: parseJson(draft.content) } : publishedSnapshot;
50
+
51
+ const diffResult = diffPageSnapshots(publishedSnapshot, draftSnapshot);
52
+
53
+ const titleVal =
54
+ typeof page.title === "object" && page.title !== null
55
+ ? (page.title as Record<string, string>)[currentLocale] || (page.title as Record<string, string>).en || ""
56
+ : String(page.title || "");
57
+ ---
58
+
59
+ <CMSDashboardLayout title={`Review & Diffs: ${titleVal}`} description="Review content changes and block diffs" activeSection="pages">
60
+ <Fragment slot="toolbar-right">
61
+ <div class="flex items-center gap-2">
62
+ <RLAButton href={getRelativeLocaleUrl(currentLocale, `/cms/pages/${id}/preview`)} variant="outline" color="neutral" size="sm" leadingIcon="i-lucide-eye">
63
+ Live Preview
64
+ </RLAButton>
65
+ <button
66
+ id="approve-publish-btn"
67
+ type="button"
68
+ class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-md hover:bg-emerald-700 transition-colors shadow-sm cursor-pointer disabled:opacity-50"
69
+ >
70
+ <span class="i-lucide-check-circle size-4"></span>
71
+ <span id="approve-btn-text">Approve & Publish</span>
72
+ </button>
73
+ </div>
74
+ </Fragment>
75
+
76
+ <div class="max-w-4xl mx-auto space-y-8 pb-12">
77
+ <!-- Diff Summary Statistics -->
78
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
79
+ <div class="bg-elevated/40 p-4 rounded-xl border border-default shadow-sm text-center">
80
+ <p class="text-xs font-semibold text-emerald-500 uppercase">Blocks Added</p>
81
+ <p class="text-3xl font-bold text-emerald-500 mt-1">+{diffResult.summary.addedCount}</p>
82
+ </div>
83
+ <div class="bg-elevated/40 p-4 rounded-xl border border-default shadow-sm text-center">
84
+ <p class="text-xs font-semibold text-error-500 uppercase">Blocks Removed</p>
85
+ <p class="text-3xl font-bold text-error-500 mt-1">-{diffResult.summary.removedCount}</p>
86
+ </div>
87
+ <div class="bg-elevated/40 p-4 rounded-xl border border-default shadow-sm text-center">
88
+ <p class="text-xs font-semibold text-warning-500 uppercase">Blocks Modified</p>
89
+ <p class="text-3xl font-bold text-warning-500 mt-1">~{diffResult.summary.modifiedCount}</p>
90
+ </div>
91
+ <div class="bg-elevated/40 p-4 rounded-xl border border-default shadow-sm text-center">
92
+ <p class="text-xs font-semibold text-muted uppercase">Unchanged</p>
93
+ <p class="text-3xl font-bold text-highlighted mt-1">{diffResult.summary.unchangedCount}</p>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Metadata Diffs -->
98
+ {diffResult.metaChanges.length > 0 && (
99
+ <div class="bg-elevated/40 rounded-xl shadow-sm border border-default p-6">
100
+ <h2 class="text-lg font-semibold text-highlighted mb-4">Metadata Changes</h2>
101
+ <div class="divide-y divide-default">
102
+ {diffResult.metaChanges.map((change) => (
103
+ <div class="py-3 flex items-start justify-between">
104
+ <span class="text-sm font-mono text-highlighted font-semibold">{change.field}</span>
105
+ <div class="text-sm text-right space-y-1">
106
+ <p class="text-error-500 line-through font-mono text-xs">{JSON.stringify(change.oldValue)}</p>
107
+ <p class="text-emerald-500 font-mono text-xs">{JSON.stringify(change.newValue)}</p>
108
+ </div>
109
+ </div>
110
+ ))}
111
+ </div>
112
+ </div>
113
+ )}
114
+
115
+ <!-- Block-by-Block Visual Diff -->
116
+ <div class="bg-elevated/40 rounded-xl shadow-sm border border-default p-6 space-y-4">
117
+ <div class="flex items-center justify-between border-b border-default pb-4">
118
+ <div>
119
+ <h2 class="text-lg font-semibold text-highlighted">Block-Level Comparison</h2>
120
+ <p class="text-sm text-muted">Visual breakdown comparing published content vs working draft</p>
121
+ </div>
122
+ <RLABadge variant="soft" color="primary" size="sm">Smart Diff</RLABadge>
123
+ </div>
124
+
125
+ <div class="space-y-3">
126
+ {diffResult.blocks.map((blockDiff) => (
127
+ <div class={`p-4 rounded-lg border text-sm ${
128
+ blockDiff.status === 'added' ? 'bg-emerald-950/20 border-emerald-800/40' :
129
+ blockDiff.status === 'removed' ? 'bg-error-950/20 border-error-800/40' :
130
+ blockDiff.status === 'modified' ? 'bg-warning-950/20 border-warning-800/40' :
131
+ 'bg-elevated/20 border-default opacity-75'
132
+ }`}>
133
+ <div class="flex items-center justify-between mb-2">
134
+ <div class="flex items-center gap-2">
135
+ <span class={`text-xs px-2 py-0.5 rounded font-mono font-semibold uppercase ${
136
+ blockDiff.status === 'added' ? 'bg-emerald-500/20 text-emerald-400' :
137
+ blockDiff.status === 'removed' ? 'bg-error-500/20 text-error-400' :
138
+ blockDiff.status === 'modified' ? 'bg-warning-500/20 text-warning-400' :
139
+ 'bg-muted text-muted'
140
+ }`}>
141
+ {blockDiff.status}
142
+ </span>
143
+ <span class="font-semibold text-highlighted">{blockDiff.type}</span>
144
+ </div>
145
+ <span class="text-xs font-mono text-muted">{blockDiff.blockId.slice(0, 8)}...</span>
146
+ </div>
147
+
148
+ {blockDiff.fieldChanges && blockDiff.fieldChanges.length > 0 ? (
149
+ <div class="space-y-2 mt-2 pt-2 border-t border-warning-500/30">
150
+ {blockDiff.fieldChanges.map((change) => (
151
+ <div class="text-xs font-mono">
152
+ <span class="font-bold text-highlighted">{change.field}:</span>
153
+ <div class="grid grid-cols-2 gap-2 mt-1">
154
+ <div class="p-2 bg-error-950/30 text-error-400 rounded break-words">
155
+ - {JSON.stringify(change.oldValue)}
156
+ </div>
157
+ <div class="p-2 bg-emerald-950/30 text-emerald-400 rounded break-words">
158
+ + {JSON.stringify(change.newValue)}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ ))}
163
+ </div>
164
+ ) : (
165
+ <p class="text-xs text-muted font-mono">
166
+ {JSON.stringify((blockDiff.newBlock || blockDiff.oldBlock)?.props || {})}
167
+ </p>
168
+ )}
169
+ </div>
170
+ ))}
171
+ </div>
172
+ </div>
173
+
174
+ <!-- Version History List -->
175
+ <div class="bg-elevated/40 rounded-xl shadow-sm border border-default p-6">
176
+ <h2 class="text-lg font-semibold text-highlighted mb-4">Version History & Snapshots</h2>
177
+ <div class="space-y-2">
178
+ {versions.map((v) => (
179
+ <div class="flex items-center justify-between p-3 rounded-lg border border-default hover:bg-elevated/50 transition-colors">
180
+ <div class="flex items-center gap-3">
181
+ <span class="text-xs font-mono px-2 py-1 bg-elevated text-highlighted rounded font-semibold border border-default">
182
+ v{v.versionNumber}
183
+ </span>
184
+ <div>
185
+ <p class="text-sm font-medium text-highlighted">{v.changeSummary || "Snapshot"}</p>
186
+ <p class="text-xs text-muted">{new Date(v.createdAt).toLocaleString()}</p>
187
+ </div>
188
+ </div>
189
+ <span class={`text-xs px-2 py-0.5 rounded font-mono ${
190
+ v.status === 'published' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-muted text-muted'
191
+ }`}>
192
+ {v.status}
193
+ </span>
194
+ </div>
195
+ ))}
196
+ </div>
197
+ </div>
198
+ </div>
199
+
200
+ <script is:inline define:vars={{ pageId: id, draftContent: draftSnapshot.content }}>
201
+ const approveBtn = document.getElementById("approve-publish-btn");
202
+ const btnText = document.getElementById("approve-btn-text");
203
+
204
+ approveBtn?.addEventListener("click", async () => {
205
+ if (!confirm("Are you sure you want to approve and publish these changes?")) return;
206
+
207
+ if (approveBtn) approveBtn.disabled = true;
208
+ if (btnText) btnText.textContent = "Publishing...";
209
+
210
+ try {
211
+ const res = await fetch(`/api/cms/pages/${pageId}/publish`, {
212
+ method: "POST",
213
+ headers: { "Content-Type": "application/json" },
214
+ body: JSON.stringify({
215
+ content: draftContent,
216
+ changeSummary: "Approved via CMS Review"
217
+ })
218
+ });
219
+
220
+ if (!res.ok) {
221
+ const err = await res.json();
222
+ alert(err.error || "Failed to publish version");
223
+ } else {
224
+ if (btnText) btnText.textContent = "Published!";
225
+ setTimeout(() => {
226
+ window.location.reload();
227
+ }, 1500);
228
+ }
229
+ } catch (err) {
230
+ alert("Failed to publish version");
231
+ } finally {
232
+ if (approveBtn) approveBtn.disabled = false;
233
+ }
234
+ });
235
+ </script>
236
+ </CMSDashboardLayout>