@rimelight/cms 0.0.9 → 0.0.11

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.
@@ -1,9 +1,8 @@
1
1
  ---
2
2
  import CMSDashboardLayout from "../layouts/CMSDashboardLayout.astro";
3
3
  import RLAButton from "@rimelight/ui/components/button/RLAButton.astro";
4
- import RLABadge from "@rimelight/ui/components/badge/RLABadge.astro";
5
4
  import RLATable from "@rimelight/ui/components/table/RLATable.astro";
6
- import type { TableColumn } from "@rimelight/ui/components/table/table.ts";
5
+ import type { TableColumn, TableRow } from "@rimelight/ui/components/table/table.ts";
7
6
  import { db } from "virtual:rimelight-cms/db";
8
7
  import { pages } from "../../schema/pages.ts";
9
8
  import { desc, isNull } from "drizzle-orm";
@@ -13,134 +12,296 @@ export const prerender = false;
13
12
 
14
13
  const currentLocale = Astro.currentLocale ?? "en";
15
14
 
16
- let pagesList: any[] = [];
15
+ let rawPages: any[] = [];
16
+
17
17
  if (db) {
18
18
  try {
19
- pagesList = await db
19
+ rawPages = await db
20
20
  .select()
21
21
  .from(pages)
22
22
  .where(isNull(pages.deletedAt))
23
- .orderBy(desc(pages.createdAt));
23
+ .orderBy(desc(pages.updatedAt), desc(pages.createdAt));
24
24
  } catch (err) {
25
25
  console.warn("Failed to fetch pages:", err);
26
26
  }
27
27
  }
28
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 || "");
29
+ // Format page data with resilient localized string parsing
30
+ const pagesList = rawPages.map((page) => {
31
+ let titleStr = "";
32
+ if (typeof page.title === "object" && page.title !== null) {
33
+ const titleObj = page.title as Record<string, string>;
34
+ titleStr = titleObj[currentLocale] || titleObj["en"] || (Object.values(titleObj)[0] as string | undefined) || "";
35
+ } else {
36
+ titleStr = String(page.title || "Untitled");
37
+ }
38
+
39
+ let descStr = "";
40
+ if (typeof page.description === "object" && page.description !== null) {
41
+ descStr = (page.description as Record<string, string>)[currentLocale] || (page.description as Record<string, string>)["en"] || "";
42
+ } else if (page.description) {
43
+ descStr = String(page.description);
44
+ }
45
+
46
+ const isPublished = Boolean(page.publishedVersionId);
47
+ const type = String(page.type || "page").toLowerCase();
48
+
34
49
  return {
35
50
  id: page.id,
36
- title: titleVal,
37
- slug: page.slug,
38
- type: page.type || "page",
39
- status: page.publishedVersionId ? "published" : "draft",
51
+ slug: page.slug || "",
52
+ title: titleStr,
53
+ description: descStr,
54
+ type,
55
+ status: isPublished ? "published" : "draft",
56
+ isPublished,
40
57
  publishedVersionId: page.publishedVersionId,
41
- createdAt: page.createdAt
58
+ postedAt: page.postedAt ? new Date(page.postedAt) : null,
59
+ createdAt: page.createdAt ? new Date(page.createdAt) : new Date(),
60
+ updatedAt: page.updatedAt ? new Date(page.updatedAt) : (page.createdAt ? new Date(page.createdAt) : new Date())
42
61
  };
43
62
  });
44
63
 
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" } } }
64
+ function formatDate(date: Date): string {
65
+ try {
66
+ return new Intl.DateTimeFormat("en-US", {
67
+ month: "short",
68
+ day: "numeric",
69
+ year: "numeric",
70
+ hour: "2-digit",
71
+ minute: "2-digit"
72
+ }).format(date);
73
+ } catch {
74
+ return date.toISOString().slice(0, 10);
75
+ }
76
+ }
77
+
78
+ function timeAgo(date: Date): string {
79
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
80
+ if (seconds < 60) return "Just now";
81
+ const minutes = Math.floor(seconds / 60);
82
+ if (minutes < 60) return `${minutes}m ago`;
83
+ const hours = Math.floor(minutes / 60);
84
+ if (hours < 24) return `${hours}h ago`;
85
+ const days = Math.floor(hours / 24);
86
+ if (days < 30) return `${days}d ago`;
87
+ return formatDate(date);
88
+ }
89
+
90
+ function getTypeBadgeColor(type: string): "primary" | "secondary" | "success" | "warning" | "error" | "neutral" {
91
+ switch (type) {
92
+ case "blog":
93
+ return "primary";
94
+ case "doc":
95
+ case "documentation":
96
+ return "secondary";
97
+ case "legal":
98
+ case "policy":
99
+ return "warning";
100
+ case "wiki":
101
+ return "success";
102
+ case "announcement":
103
+ return "error";
104
+ default:
105
+ return "neutral";
106
+ }
107
+ }
108
+
109
+ function getTypeIcon(type: string): string {
110
+ switch (type) {
111
+ case "blog":
112
+ return "i-lucide-newspaper";
113
+ case "doc":
114
+ case "documentation":
115
+ return "i-lucide-book-open";
116
+ case "legal":
117
+ case "policy":
118
+ return "i-lucide-shield-alert";
119
+ case "wiki":
120
+ return "i-lucide-library";
121
+ case "announcement":
122
+ return "i-lucide-megaphone";
123
+ default:
124
+ return "i-lucide-file-text";
125
+ }
126
+ }
127
+
128
+ const pageColumns: TableColumn<any>[] = [
129
+ {
130
+ accessorKey: "title",
131
+ header: "Page & Slug",
132
+ cell: ({ row }: { row: TableRow<any> }) => {
133
+ const page = row.original;
134
+ return `<div class="flex items-start gap-3 py-1">
135
+ <div class="mt-0.5 size-8 rounded-lg bg-elevated border border-default text-muted flex items-center justify-center shrink-0">
136
+ <span class="${getTypeIcon(page.type)} size-4"></span>
137
+ </div>
138
+ <div class="min-w-0 flex-1">
139
+ <a href="${getRelativeLocaleUrl(currentLocale, `/cms/pages/${page.id}/edit`)}" class="font-semibold text-highlighted hover:text-primary transition-colors text-sm truncate block" title="${page.title}">
140
+ ${page.title}
141
+ </a>
142
+ <div class="flex items-center gap-2 mt-1">
143
+ <span class="font-mono text-xs text-muted bg-elevated px-1.5 py-0.5 rounded border border-default/60 truncate max-w-xs">
144
+ /${page.slug}
145
+ </span>
146
+ ${page.isPublished ? `<a href="/${currentLocale}/${page.slug}" target="_blank" rel="noopener noreferrer" class="text-muted hover:text-primary p-0.5 rounded transition cursor-pointer" title="View on site"><span class="i-lucide-external-link size-3.5 inline-block"></span></a>` : ''}
147
+ </div>
148
+ ${page.description ? `<p class="text-xs text-muted/80 mt-1 line-clamp-1 max-w-md">${page.description}</p>` : ''}
149
+ </div>
150
+ </div>`;
151
+ }
152
+ },
153
+ {
154
+ accessorKey: "type",
155
+ header: "Type",
156
+ size: 130,
157
+ cell: ({ row }: { row: TableRow<any> }) => {
158
+ const page = row.original;
159
+ const color = getTypeBadgeColor(page.type);
160
+ return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wider bg-${color}-500/10 text-${color}-500 border border-${color}-500/20">${page.type}</span>`;
161
+ }
162
+ },
163
+ {
164
+ accessorKey: "status",
165
+ header: "Status",
166
+ size: 130,
167
+ cell: ({ row }: { row: TableRow<any> }) => {
168
+ const page = row.original;
169
+ return page.isPublished
170
+ ? `<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-500 border border-emerald-500/20"><span class="size-1.5 rounded-full bg-emerald-500 animate-pulse"></span><span>Published</span></div>`
171
+ : `<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-500 border border-amber-500/20"><span class="size-1.5 rounded-full bg-amber-500"></span><span>Draft</span></div>`;
172
+ }
173
+ },
174
+ {
175
+ accessorKey: "updatedAt",
176
+ header: "Last Updated",
177
+ size: 160,
178
+ cell: ({ row }: { row: TableRow<any> }) => {
179
+ const page = row.original;
180
+ return `<div class="text-xs font-medium text-highlighted" title="${formatDate(page.updatedAt)}">${timeAgo(page.updatedAt)}</div><div class="text-[11px] text-muted font-mono mt-0.5">${page.updatedAt.toISOString().slice(0, 10)}</div>`;
181
+ }
182
+ },
183
+ {
184
+ id: "actions",
185
+ actions: (row: TableRow<any>) => {
186
+ const page = row.original;
187
+ return [
188
+ {
189
+ label: "Edit Page",
190
+ icon: "i-lucide-pencil",
191
+ to: getRelativeLocaleUrl(currentLocale, `/cms/pages/${page.id}/edit`)
192
+ },
193
+ {
194
+ label: "Live Preview",
195
+ icon: "i-lucide-eye",
196
+ to: getRelativeLocaleUrl(currentLocale, `/cms/pages/${page.id}/preview`)
197
+ },
198
+ {
199
+ label: "Version History",
200
+ icon: "i-lucide-git-compare",
201
+ to: getRelativeLocaleUrl(currentLocale, `/cms/pages/${page.id}/review`)
202
+ },
203
+ ...(page.isPublished ? [
204
+ {
205
+ label: "View on Live Site",
206
+ icon: "i-lucide-external-link",
207
+ to: `/${currentLocale}/${page.slug}`,
208
+ target: "_blank"
209
+ }
210
+ ] : []),
211
+ {
212
+ type: "separator" as const
213
+ },
214
+ ...(page.isPublished ? [
215
+ {
216
+ label: "Unpublish to Draft",
217
+ icon: "i-lucide-archive",
218
+ color: "warning" as const,
219
+ id: `unpublish-page-${page.id}`
220
+ }
221
+ ] : []),
222
+ {
223
+ label: "Delete Page",
224
+ icon: "i-lucide-trash-2",
225
+ color: "error" as const,
226
+ id: `delete-page-${page.id}`
227
+ }
228
+ ];
229
+ }
230
+ }
51
231
  ];
52
232
  ---
53
233
 
54
- <CMSDashboardLayout title="Pages" description="Manage CMS pages" activeSection="pages">
234
+ <CMSDashboardLayout title="Pages" description="Manage, edit, and publish dynamic site content" activeSection="pages">
55
235
  <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>
236
+ <div class="flex items-center gap-2">
237
+ <RLAButton
238
+ href={getRelativeLocaleUrl(currentLocale, "/cms/templates")}
239
+ variant="outline"
240
+ color="neutral"
241
+ size="sm"
242
+ leadingIcon="i-lucide-layout-template"
243
+ >
244
+ Templates
245
+ </RLAButton>
246
+ <RLAButton
247
+ href={getRelativeLocaleUrl(currentLocale, "/cms/pages/new")}
248
+ variant="solid"
249
+ color="primary"
250
+ size="sm"
251
+ leadingIcon="i-lucide-plus"
252
+ >
253
+ Create Page
254
+ </RLAButton>
255
+ </div>
59
256
  </Fragment>
60
257
 
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>
258
+ <div class="flex flex-col gap-4">
259
+ <RLATable
260
+ data={pagesList}
261
+ columns={pageColumns}
262
+ striped
263
+ hoverable
264
+ searchable
265
+ showColumnsToggle
266
+ exportable
267
+ exportFilename="rimelight-pages-export"
268
+ />
269
+ </div>
123
270
 
124
271
  <script>
125
- document.querySelectorAll(".delete-page-btn").forEach((btn) => {
126
- btn.addEventListener("click", async () => {
127
- const pageId = (btn as HTMLElement).dataset["pageId"];
272
+ document.addEventListener("click", async (e) => {
273
+ const target = e.target as HTMLElement;
274
+
275
+ // Handle unpublish
276
+ const unpublishItem = target.closest<HTMLElement>("[data-item-id^='unpublish-page-']");
277
+ if (unpublishItem) {
278
+ const pageId = unpublishItem.getAttribute("data-item-id")?.replace("unpublish-page-", "") || "";
128
279
  if (!pageId) return;
129
- if (!confirm("Are you sure you want to delete this page?")) return;
280
+ if (!confirm("Are you sure you want to unpublish this page?")) return;
281
+ try {
282
+ const res = await fetch(`/api/cms/pages/${pageId}/unpublish`, { method: "POST" });
283
+ if (res.ok) window.location.reload();
284
+ else alert("Failed to unpublish page");
285
+ } catch {
286
+ alert("Network error unpublishing page");
287
+ }
288
+ return;
289
+ }
130
290
 
291
+ // Handle delete
292
+ const deleteItem = target.closest<HTMLElement>("[data-item-id^='delete-page-']");
293
+ if (deleteItem) {
294
+ const pageId = deleteItem.getAttribute("data-item-id")?.replace("delete-page-", "") || "";
295
+ if (!pageId) return;
296
+ if (!confirm("Are you sure you want to delete this page? This action cannot be undone.")) return;
131
297
  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
- }
298
+ const res = await fetch(`/api/cms/pages/${pageId}`, { method: "DELETE" });
299
+ if (res.ok) window.location.reload();
300
+ else alert("Failed to delete page");
140
301
  } catch {
141
- alert("Error deleting page");
302
+ alert("Network error deleting page");
142
303
  }
143
- });
304
+ }
144
305
  });
145
306
  </script>
146
307
  </CMSDashboardLayout>
@@ -18,9 +18,12 @@ if (!id) {
18
18
  }
19
19
 
20
20
  const token = Astro.url.searchParams.get("token");
21
- const secret = process.env["CMS_PREVIEW_SECRET"] || "rimelight-preview-secret-key";
21
+ const secret = process.env["CMS_PREVIEW_SECRET"] || process.env["AUTH0_SECRET"] || "rimelight-preview-secret-key";
22
22
  if (token) {
23
- await verifyPreviewToken(id, token, secret);
23
+ const isTokenValid = await verifyPreviewToken(id, token, secret);
24
+ if (!isTokenValid) {
25
+ return new Response("Forbidden: Invalid or expired preview token", { status: 403 });
26
+ }
24
27
  }
25
28
 
26
29
  let page: any = null;