modern-cms 1.0.0

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 (180) hide show
  1. package/README.md +41 -0
  2. package/bin/lib.js +97 -0
  3. package/bin/modern-cms.js +197 -0
  4. package/package.json +37 -0
  5. package/template/.env.example +48 -0
  6. package/template/README.md +210 -0
  7. package/template/apps/api/Dockerfile +26 -0
  8. package/template/apps/api/package.json +45 -0
  9. package/template/apps/api/prisma/backfill-search-text.ts +31 -0
  10. package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
  11. package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
  12. package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
  13. package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
  14. package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
  15. package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
  16. package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
  17. package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
  18. package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
  19. package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
  20. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  21. package/template/apps/api/prisma/reset-admin.ts +19 -0
  22. package/template/apps/api/prisma/schema.prisma +251 -0
  23. package/template/apps/api/prisma/seed-homepage.ts +752 -0
  24. package/template/apps/api/prisma/seed-our-charities.ts +231 -0
  25. package/template/apps/api/prisma/seed-templates.ts +154 -0
  26. package/template/apps/api/prisma/seed.ts +191 -0
  27. package/template/apps/api/src/env.ts +61 -0
  28. package/template/apps/api/src/index.ts +66 -0
  29. package/template/apps/api/src/lib/revalidate.ts +28 -0
  30. package/template/apps/api/src/lib/storage/azure.ts +25 -0
  31. package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
  32. package/template/apps/api/src/lib/storage/index.ts +25 -0
  33. package/template/apps/api/src/lib/storage/s3.ts +39 -0
  34. package/template/apps/api/src/lib/storage/types.ts +8 -0
  35. package/template/apps/api/src/middleware/auditLog.ts +32 -0
  36. package/template/apps/api/src/middleware/auth.ts +53 -0
  37. package/template/apps/api/src/middleware/rateLimit.ts +31 -0
  38. package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
  39. package/template/apps/api/src/prisma.ts +3 -0
  40. package/template/apps/api/src/routes/audit.routes.ts +26 -0
  41. package/template/apps/api/src/routes/auth.routes.ts +46 -0
  42. package/template/apps/api/src/routes/backup.routes.ts +216 -0
  43. package/template/apps/api/src/routes/blocks.routes.ts +38 -0
  44. package/template/apps/api/src/routes/collections.routes.ts +185 -0
  45. package/template/apps/api/src/routes/forms.routes.ts +114 -0
  46. package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
  47. package/template/apps/api/src/routes/media.routes.ts +77 -0
  48. package/template/apps/api/src/routes/pages.routes.ts +319 -0
  49. package/template/apps/api/src/routes/redirects.routes.ts +52 -0
  50. package/template/apps/api/src/routes/search.routes.ts +83 -0
  51. package/template/apps/api/src/routes/templates.routes.ts +41 -0
  52. package/template/apps/api/src/routes/theme.routes.ts +75 -0
  53. package/template/apps/api/src/routes/users.routes.ts +39 -0
  54. package/template/apps/api/tsconfig.json +11 -0
  55. package/template/apps/web/Dockerfile +26 -0
  56. package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
  57. package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
  58. package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
  59. package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
  60. package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
  61. package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
  62. package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
  63. package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
  64. package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
  65. package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
  66. package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
  67. package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
  68. package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
  69. package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
  70. package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
  71. package/template/apps/web/app/admin/login/page.tsx +76 -0
  72. package/template/apps/web/app/api/revalidate/route.ts +34 -0
  73. package/template/apps/web/app/globals.css +120 -0
  74. package/template/apps/web/app/layout.tsx +39 -0
  75. package/template/apps/web/app/preview/[token]/page.tsx +70 -0
  76. package/template/apps/web/app/robots.ts +11 -0
  77. package/template/apps/web/app/sitemap.ts +27 -0
  78. package/template/apps/web/components/admin/AdminShell.tsx +104 -0
  79. package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
  80. package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
  81. package/template/apps/web/components/builder/Canvas.tsx +76 -0
  82. package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
  83. package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
  84. package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
  85. package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
  86. package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
  87. package/template/apps/web/components/builder/Inspector.tsx +956 -0
  88. package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
  89. package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
  90. package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
  91. package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
  92. package/template/apps/web/components/builder/Palette.tsx +106 -0
  93. package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
  94. package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
  95. package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
  96. package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
  97. package/template/apps/web/components/renderer/Renderer.tsx +146 -0
  98. package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
  99. package/template/apps/web/components/renderer/registry.tsx +87 -0
  100. package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
  101. package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
  102. package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
  103. package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
  104. package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
  105. package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
  106. package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
  107. package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
  108. package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
  109. package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
  110. package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
  111. package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
  112. package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
  113. package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
  114. package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
  115. package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
  116. package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
  117. package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
  118. package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
  119. package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
  120. package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
  121. package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
  122. package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
  123. package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
  124. package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
  125. package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
  126. package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
  127. package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
  128. package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
  129. package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
  130. package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
  131. package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
  132. package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
  133. package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
  134. package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
  135. package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
  136. package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
  137. package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
  138. package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
  139. package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
  140. package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
  141. package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
  142. package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
  143. package/template/apps/web/components/renderer/sections/types.ts +7 -0
  144. package/template/apps/web/components/seo/JsonLd.tsx +111 -0
  145. package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
  146. package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
  147. package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
  148. package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
  149. package/template/apps/web/eslint.config.mjs +16 -0
  150. package/template/apps/web/lib/api.ts +45 -0
  151. package/template/apps/web/lib/responsiveCss.ts +42 -0
  152. package/template/apps/web/lib/sanitizeHtml.ts +16 -0
  153. package/template/apps/web/lib/serverApi.ts +25 -0
  154. package/template/apps/web/lib/style.ts +73 -0
  155. package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
  156. package/template/apps/web/lib/useCurrentUser.ts +35 -0
  157. package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
  158. package/template/apps/web/next-env.d.ts +6 -0
  159. package/template/apps/web/next.config.mjs +32 -0
  160. package/template/apps/web/package.json +46 -0
  161. package/template/apps/web/postcss.config.mjs +6 -0
  162. package/template/apps/web/proxy.ts +23 -0
  163. package/template/apps/web/tailwind.config.ts +24 -0
  164. package/template/apps/web/tsconfig.json +41 -0
  165. package/template/docker-compose.yml +60 -0
  166. package/template/package.json +29 -0
  167. package/template/packages/shared/package.json +21 -0
  168. package/template/packages/shared/src/components.ts +1626 -0
  169. package/template/packages/shared/src/index.ts +9 -0
  170. package/template/packages/shared/src/schema/collections.ts +70 -0
  171. package/template/packages/shared/src/schema/fields.ts +26 -0
  172. package/template/packages/shared/src/schema/media.ts +13 -0
  173. package/template/packages/shared/src/schema/pageNode.ts +255 -0
  174. package/template/packages/shared/src/schema/templates.ts +20 -0
  175. package/template/packages/shared/src/schema/theme.ts +69 -0
  176. package/template/packages/shared/src/schema/user.ts +25 -0
  177. package/template/packages/shared/src/tree.test.ts +65 -0
  178. package/template/packages/shared/src/tree.ts +303 -0
  179. package/template/packages/shared/tsconfig.json +8 -0
  180. package/template/packages/shared/vitest.config.ts +8 -0
@@ -0,0 +1,254 @@
1
+ import { notFound, permanentRedirect, redirect } from "next/navigation";
2
+ import type { Metadata } from "next";
3
+ import {
4
+ isRtlLocale,
5
+ type NavItem,
6
+ type Page,
7
+ type PageNode,
8
+ type PageTranslationInfo,
9
+ type SiteTheme,
10
+ } from "@pgcms/shared";
11
+ import { ApiError } from "@/lib/api";
12
+ import { serverGet } from "@/lib/serverApi";
13
+ import { ThemeStyleTag } from "@/components/theme/ThemeProvider";
14
+ import { PublicPageView } from "@/components/renderer/PublicPageView";
15
+ import { JsonLd } from "@/components/seo/JsonLd";
16
+ import { DarkModeToggle } from "@/components/theme/DarkModeToggle";
17
+ import { AnalyticsScripts } from "@/components/theme/AnalyticsScripts";
18
+
19
+ type GlobalSection = { kind: string; enabled: boolean; content: PageNode };
20
+
21
+ type CollectionItemDetail = {
22
+ id: string;
23
+ slug: string;
24
+ title: string;
25
+ content: PageNode;
26
+ excerpt?: string | null;
27
+ coverImage?: string | null;
28
+ seoTitle?: string | null;
29
+ seoDescription?: string | null;
30
+ ogImage?: string | null;
31
+ noIndex?: boolean;
32
+ publishedAt?: string | null;
33
+ collectionSlug: string;
34
+ collectionName: string;
35
+ };
36
+
37
+ const SITE_URL = process.env.SITE_URL ?? "http://localhost:3000";
38
+
39
+ function pageUrl(slug: string) {
40
+ return `${SITE_URL}/${slug}`;
41
+ }
42
+
43
+ /** A URL that isn't a page may be a collection item: /{collectionSlug}/{itemSlug}. */
44
+ async function loadCollectionItemAsPage(
45
+ slug: string,
46
+ ): Promise<{ page: Page; datePublished: string | null } | null> {
47
+ const parts = slug.split("/");
48
+ if (parts.length < 2) return null;
49
+ const [collectionSlug, ...rest] = parts;
50
+ try {
51
+ const item = await serverGet<CollectionItemDetail>(
52
+ `/api/collections/public/${collectionSlug}/items/${rest.join("/")}`,
53
+ { tags: ["cms", `collection-item:${slug}`] },
54
+ );
55
+ const page = {
56
+ id: item.id,
57
+ slug,
58
+ title: item.title,
59
+ status: "PUBLISHED",
60
+ content: item.content,
61
+ seoTitle: item.seoTitle ?? item.title,
62
+ seoDescription: item.seoDescription ?? item.excerpt ?? null,
63
+ ogImage: item.ogImage ?? item.coverImage ?? null,
64
+ noIndex: item.noIndex ?? false,
65
+ locale: "en",
66
+ } as Page;
67
+ return { page, datePublished: item.publishedAt ?? null };
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** A collection-item slug is /{collectionSlug}/{itemSlug} — mirrors the split in
74
+ * loadCollectionItemAsPage. Returns [] harmlessly for an ordinary page slug (no second
75
+ * segment) or when nothing matches, so this can run unconditionally alongside the page
76
+ * translations fetch without knowing yet which kind of content this slug resolves to. */
77
+ async function loadCollectionItemTranslations(slug: string): Promise<PageTranslationInfo[]> {
78
+ const parts = slug.split("/");
79
+ if (parts.length < 2) return [];
80
+ const [collectionSlug, ...rest] = parts;
81
+ return serverGet<PageTranslationInfo[]>(
82
+ `/api/collections/public/${collectionSlug}/items/${rest.join("/")}/translations`,
83
+ { tags: ["cms", `collection-item:${slug}`] },
84
+ ).catch(() => [] as PageTranslationInfo[]);
85
+ }
86
+
87
+ async function loadData(slug: string) {
88
+ const pageTag = `page:${slug || "home"}`;
89
+ let contentType: "page" | "collectionItem" = "page";
90
+ let datePublished: string | null = null;
91
+ try {
92
+ const [page, theme, navItems, globalSections, pageTranslations, collectionItemTranslations] =
93
+ await Promise.all([
94
+ serverGet<Page>(`/api/pages/public/${slug || "home"}`, {
95
+ tags: ["cms", pageTag],
96
+ }).catch(async (err) => {
97
+ const item = await loadCollectionItemAsPage(slug);
98
+ if (item) {
99
+ contentType = "collectionItem";
100
+ datePublished = item.datePublished;
101
+ return item.page;
102
+ }
103
+ throw err;
104
+ }),
105
+ serverGet<SiteTheme>(`/api/theme/public`, {
106
+ tags: ["cms", "cms-theme"],
107
+ }),
108
+ serverGet<NavItem[]>(`/api/theme/nav/public`, {
109
+ tags: ["cms", "cms-nav"],
110
+ }),
111
+ serverGet<GlobalSection[]>(`/api/global-sections`, {
112
+ tags: ["cms", "cms-global-sections"],
113
+ }).catch(() => [] as GlobalSection[]),
114
+ serverGet<PageTranslationInfo[]>(
115
+ `/api/pages/public-translations?slug=${encodeURIComponent(slug || "home")}`,
116
+ { tags: ["cms", pageTag] },
117
+ ).catch(() => [] as PageTranslationInfo[]),
118
+ loadCollectionItemTranslations(slug),
119
+ ]);
120
+ // A slug is either an ordinary page or a collection item, never both — only one of
121
+ // these two ever comes back non-empty.
122
+ const translations = pageTranslations.length > 0 ? pageTranslations : collectionItemTranslations;
123
+ const globalHeader =
124
+ globalSections.find((s) => s.kind === "header" && s.enabled)?.content ??
125
+ null;
126
+ const globalFooter =
127
+ globalSections.find((s) => s.kind === "footer" && s.enabled)?.content ??
128
+ null;
129
+ const globalGoToTop =
130
+ globalSections.find((s) => s.kind === "goToTop" && s.enabled)?.content ??
131
+ null;
132
+ return {
133
+ page,
134
+ theme,
135
+ navItems,
136
+ globalHeader,
137
+ globalFooter,
138
+ globalGoToTop,
139
+ translations,
140
+ contentType,
141
+ datePublished,
142
+ };
143
+ } catch (err) {
144
+ if (err instanceof ApiError && err.status !== 404) {
145
+ console.error(
146
+ `Failed to load public page data for slug "${slug}":`,
147
+ err.status,
148
+ err.message,
149
+ );
150
+ } else if (!(err instanceof ApiError)) {
151
+ console.error(`Failed to load public page data for slug "${slug}":`, err);
152
+ }
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function buildMetadata(
158
+ data: NonNullable<Awaited<ReturnType<typeof loadData>>>,
159
+ slug: string,
160
+ ): Metadata {
161
+ const title = data.page.seoTitle || data.page.title;
162
+ const description = data.page.seoDescription ?? undefined;
163
+ const url = pageUrl(slug);
164
+ const ogImage = data.page.ogImage ?? undefined;
165
+
166
+ const languages: Record<string, string> = {};
167
+ for (const t of data.translations) {
168
+ languages[t.locale] = pageUrl(t.slug);
169
+ }
170
+ if (data.page.locale) {
171
+ languages[data.page.locale] = url;
172
+ }
173
+
174
+ return {
175
+ title,
176
+ description,
177
+ alternates: {
178
+ canonical: url,
179
+ ...(Object.keys(languages).length > 0 ? { languages } : {}),
180
+ },
181
+ openGraph: {
182
+ title,
183
+ description,
184
+ url,
185
+ type: "website",
186
+ ...(ogImage ? { images: [{ url: ogImage }] } : {}),
187
+ },
188
+ twitter: {
189
+ card: ogImage ? "summary_large_image" : "summary",
190
+ title,
191
+ description,
192
+ ...(ogImage ? { images: [ogImage] } : {}),
193
+ },
194
+ robots: data.page.noIndex
195
+ ? { index: false, follow: false }
196
+ : { index: true, follow: true },
197
+ };
198
+ }
199
+
200
+ export async function generateMetadata({
201
+ params,
202
+ }: {
203
+ params: Promise<{ slug?: string[] }>;
204
+ }): Promise<Metadata> {
205
+ const { slug: slugParts } = await params;
206
+ const slug = (slugParts ?? []).join("/");
207
+ const data = await loadData(slug);
208
+ if (!data) return {};
209
+ return buildMetadata(data, slug);
210
+ }
211
+
212
+ export default async function PublicPage({
213
+ params,
214
+ }: {
215
+ params: Promise<{ slug?: string[] }>;
216
+ }) {
217
+ const { slug: slugParts } = await params;
218
+ const slug = (slugParts ?? []).join("/");
219
+ const data = await loadData(slug);
220
+ if (!data) {
221
+ // Not a page, not a collection item — before 404ing, honor any redirect an editor
222
+ // configured for this old path (kept inbound links alive across restructures).
223
+ const target = await serverGet<{ toPath: string; permanent: boolean }>(
224
+ `/api/redirects/resolve?path=${encodeURIComponent("/" + slug)}`,
225
+ ).catch(() => null);
226
+ if (target) {
227
+ if (target.permanent) permanentRedirect(target.toPath);
228
+ redirect(target.toPath);
229
+ }
230
+ notFound();
231
+ }
232
+
233
+ const locale = data.page.locale || "en";
234
+ const url = pageUrl(slug);
235
+
236
+ return (
237
+ <div lang={locale} dir={isRtlLocale(locale) ? "rtl" : "ltr"}>
238
+ <JsonLd page={data.page} theme={data.theme} url={url} contentType={data.contentType} datePublished={data.datePublished} />
239
+ <ThemeStyleTag theme={data.theme} />
240
+ <AnalyticsScripts theme={data.theme} />
241
+ <PublicPageView
242
+ page={data.page}
243
+ theme={data.theme}
244
+ navItems={data.navItems}
245
+ globalHeader={data.globalHeader}
246
+ globalFooter={data.globalFooter}
247
+ globalGoToTop={data.globalGoToTop}
248
+ pageLocale={locale}
249
+ translations={data.translations}
250
+ />
251
+ {data.theme.darkModeEnabled && <DarkModeToggle />}
252
+ </div>
253
+ );
254
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Search, ScrollText } from "lucide-react";
5
+ import { api } from "@/lib/api";
6
+
7
+ type AuditEntry = {
8
+ id: string;
9
+ userEmail: string | null;
10
+ action: string;
11
+ entityPath: string;
12
+ status: number;
13
+ createdAt: string;
14
+ };
15
+
16
+ /** Read-only trail of every successful content/settings mutation: who, what URL,
17
+ * when. Captured automatically by API middleware; admin-only. */
18
+ export default function AuditLogPage() {
19
+ const [entries, setEntries] = useState<AuditEntry[] | null>(null);
20
+ const [search, setSearch] = useState("");
21
+
22
+ useEffect(() => {
23
+ const t = setTimeout(() => {
24
+ api
25
+ .get<AuditEntry[]>(`/api/audit${search.trim() ? `?search=${encodeURIComponent(search.trim())}` : ""}`)
26
+ .then(setEntries)
27
+ .catch(() => setEntries([]));
28
+ }, 250);
29
+ return () => clearTimeout(t);
30
+ }, [search]);
31
+
32
+ return (
33
+ <div className="p-8 max-w-4xl">
34
+ <div className="flex items-center justify-between mb-6 gap-4">
35
+ <h1 className="text-2xl font-semibold flex items-center gap-2 shrink-0">
36
+ <ScrollText size={22} /> Audit Log
37
+ </h1>
38
+ <div className="relative flex-1 max-w-sm">
39
+ <Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
40
+ <input
41
+ value={search}
42
+ onChange={(e) => setSearch(e.target.value)}
43
+ placeholder="Filter by user, action, or path…"
44
+ className="w-full border rounded-md pl-8 pr-3 py-2 text-sm"
45
+ />
46
+ </div>
47
+ </div>
48
+
49
+ <div className="border rounded-lg overflow-hidden">
50
+ <table className="w-full text-sm">
51
+ <thead className="bg-slate-50 text-left text-xs text-slate-500 uppercase tracking-wide">
52
+ <tr>
53
+ <th className="px-3 py-2">When</th>
54
+ <th className="px-3 py-2">Who</th>
55
+ <th className="px-3 py-2">Action</th>
56
+ <th className="px-3 py-2">Path</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody className="divide-y">
60
+ {entries === null && (
61
+ <tr><td colSpan={4} className="px-3 py-6 text-center text-slate-400">Loading…</td></tr>
62
+ )}
63
+ {entries?.length === 0 && (
64
+ <tr><td colSpan={4} className="px-3 py-6 text-center text-slate-400">No entries{search ? " match that filter" : " yet"}.</td></tr>
65
+ )}
66
+ {entries?.map((e) => (
67
+ <tr key={e.id}>
68
+ <td className="px-3 py-2 whitespace-nowrap text-slate-500">{new Date(e.createdAt).toLocaleString()}</td>
69
+ <td className="px-3 py-2 whitespace-nowrap">{e.userEmail ?? <span className="text-slate-400">anonymous</span>}</td>
70
+ <td className="px-3 py-2 whitespace-nowrap font-mono text-xs">{e.action}</td>
71
+ <td className="px-3 py-2 font-mono text-xs text-slate-500 truncate max-w-[280px]" title={e.entityPath}>{e.entityPath}</td>
72
+ </tr>
73
+ ))}
74
+ </tbody>
75
+ </table>
76
+ </div>
77
+ <p className="text-xs text-slate-400 mt-3">Latest 200 entries shown. Failed requests aren&apos;t recorded.</p>
78
+ </div>
79
+ );
80
+ }