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,5 @@
1
+ import { AdminShell } from "@/components/admin/AdminShell";
2
+
3
+ export default function ProtectedAdminLayout({ children }: { children: React.ReactNode }) {
4
+ return <AdminShell>{children}</AdminShell>;
5
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Upload, Trash2, Copy, Search, Check } from "lucide-react";
5
+ import type { MediaAsset } from "@pgcms/shared";
6
+ import { api } from "@/lib/api";
7
+
8
+ type AssetWithMeta = MediaAsset & { filename?: string; alt?: string | null };
9
+
10
+ export default function MediaLibraryPage() {
11
+ const [assets, setAssets] = useState<AssetWithMeta[] | null>(null);
12
+ const [uploading, setUploading] = useState(false);
13
+ const [search, setSearch] = useState("");
14
+ const [altDrafts, setAltDrafts] = useState<Record<string, string>>({});
15
+ const [altSavedId, setAltSavedId] = useState<string | null>(null);
16
+ const fileInput = useRef<HTMLInputElement>(null);
17
+
18
+ function refresh(query = search) {
19
+ api
20
+ .get<AssetWithMeta[]>(`/api/media${query.trim() ? `?search=${encodeURIComponent(query.trim())}` : ""}`)
21
+ .then(setAssets);
22
+ }
23
+
24
+ // Debounced server-side search across filename, alt text, and storage key.
25
+ useEffect(() => {
26
+ const t = setTimeout(() => refresh(search), 250);
27
+ return () => clearTimeout(t);
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps
29
+ }, [search]);
30
+
31
+ async function handleUpload(files: FileList | null) {
32
+ if (!files) return;
33
+ setUploading(true);
34
+ try {
35
+ for (const file of Array.from(files)) {
36
+ const formData = new FormData();
37
+ formData.append("file", file);
38
+ await api.post<MediaAsset>("/api/media", formData);
39
+ }
40
+ refresh();
41
+ } finally {
42
+ setUploading(false);
43
+ }
44
+ }
45
+
46
+ async function remove(id: string) {
47
+ if (!confirm("Delete this media asset?")) return;
48
+ await api.delete(`/api/media/${id}`);
49
+ refresh();
50
+ }
51
+
52
+ async function saveAlt(asset: AssetWithMeta) {
53
+ const draft = altDrafts[asset.id];
54
+ if (draft === undefined || draft === (asset.alt ?? "")) return;
55
+ await api.put(`/api/media/${asset.id}`, { alt: draft });
56
+ setAltSavedId(asset.id);
57
+ setTimeout(() => setAltSavedId((cur) => (cur === asset.id ? null : cur)), 1500);
58
+ refresh();
59
+ }
60
+
61
+ function copyUrl(url: string) {
62
+ navigator.clipboard.writeText(url);
63
+ }
64
+
65
+ return (
66
+ <div className="p-8 max-w-5xl">
67
+ <div className="flex items-center justify-between mb-6 gap-4">
68
+ <h1 className="text-2xl font-semibold shrink-0">Media Library</h1>
69
+ <div className="relative flex-1 max-w-sm">
70
+ <Search size={15} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
71
+ <input
72
+ value={search}
73
+ onChange={(e) => setSearch(e.target.value)}
74
+ placeholder="Search by filename or alt text…"
75
+ className="w-full border rounded-md pl-8 pr-3 py-2 text-sm"
76
+ />
77
+ </div>
78
+ <input
79
+ ref={fileInput}
80
+ type="file"
81
+ multiple
82
+ accept="image/*,video/*"
83
+ className="hidden"
84
+ onChange={(e) => handleUpload(e.target.files)}
85
+ />
86
+ <button
87
+ onClick={() => fileInput.current?.click()}
88
+ disabled={uploading}
89
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 shrink-0"
90
+ >
91
+ <Upload size={16} /> {uploading ? "Uploading..." : "Upload"}
92
+ </button>
93
+ </div>
94
+
95
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
96
+ {assets?.length === 0 && (
97
+ <div className="col-span-full text-sm text-slate-400 py-10 text-center">
98
+ {search ? "Nothing matches that search." : "No media uploaded yet."}
99
+ </div>
100
+ )}
101
+ {assets?.map((asset) => (
102
+ <div key={asset.id} className="border rounded-lg overflow-hidden bg-white flex flex-col">
103
+ <div className="aspect-square bg-slate-50 group relative">
104
+ {asset.mimeType.startsWith("image/") ? (
105
+ // eslint-disable-next-line @next/next/no-img-element
106
+ <img src={asset.url} alt={asset.alt ?? ""} className="w-full h-full object-cover" />
107
+ ) : (
108
+ <video src={asset.url} className="w-full h-full object-cover" muted />
109
+ )}
110
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
111
+ <button onClick={() => copyUrl(asset.url)} className="bg-white rounded-full p-2" title="Copy URL">
112
+ <Copy size={14} />
113
+ </button>
114
+ <button onClick={() => remove(asset.id)} className="bg-white rounded-full p-2 text-red-600" title="Delete">
115
+ <Trash2 size={14} />
116
+ </button>
117
+ </div>
118
+ </div>
119
+ <div className="p-2 space-y-1">
120
+ {asset.filename && <div className="text-[11px] text-slate-400 truncate" title={asset.filename}>{asset.filename}</div>}
121
+ <div className="flex items-center gap-1">
122
+ <input
123
+ value={altDrafts[asset.id] ?? asset.alt ?? ""}
124
+ onChange={(e) => setAltDrafts((prev) => ({ ...prev, [asset.id]: e.target.value }))}
125
+ onBlur={() => saveAlt(asset)}
126
+ onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
127
+ placeholder="Alt text…"
128
+ className="w-full border rounded px-1.5 py-1 text-xs"
129
+ />
130
+ {altSavedId === asset.id && <Check size={13} className="text-green-600 shrink-0" />}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter } from "next/navigation";
6
+ import { Plus, Trash2, ExternalLink, Pencil, Copy, Download, LayoutTemplate } from "lucide-react";
7
+ import type { Page, PageTemplate } from "@pgcms/shared";
8
+ import { api, ApiError } from "@/lib/api";
9
+
10
+ export default function PagesDashboard() {
11
+ const router = useRouter();
12
+ const [pages, setPages] = useState<Page[] | null>(null);
13
+ const [templates, setTemplates] = useState<PageTemplate[]>([]);
14
+ const [creating, setCreating] = useState(false);
15
+ const [title, setTitle] = useState("");
16
+ const [slug, setSlug] = useState("");
17
+ const [templateId, setTemplateId] = useState<string | null>(null);
18
+ const [error, setError] = useState<string | null>(null);
19
+
20
+ function refresh() {
21
+ api.get<Page[]>("/api/pages").then(setPages);
22
+ }
23
+
24
+ useEffect(refresh, []);
25
+ useEffect(() => {
26
+ api.get<PageTemplate[]>("/api/templates").then(setTemplates).catch(() => setTemplates([]));
27
+ }, []);
28
+
29
+ async function createPage() {
30
+ setError(null);
31
+ try {
32
+ const page = await api.post<Page>("/api/pages", { title, slug, ...(templateId ? { templateId } : {}) });
33
+ setCreating(false);
34
+ setTitle("");
35
+ setSlug("");
36
+ setTemplateId(null);
37
+ router.push(`/admin/pages/${page.id}`);
38
+ } catch (err) {
39
+ setError(err instanceof ApiError ? err.message : "Failed to create page");
40
+ }
41
+ }
42
+
43
+ async function deletePage(id: string) {
44
+ if (!confirm("Delete this page? This cannot be undone.")) return;
45
+ await api.delete(`/api/pages/${id}`);
46
+ refresh();
47
+ }
48
+
49
+ async function duplicatePage(id: string) {
50
+ const page = await api.post<Page>(`/api/pages/${id}/duplicate`, {});
51
+ refresh();
52
+ router.push(`/admin/pages/${page.id}`);
53
+ }
54
+
55
+ async function exportPage(id: string, slug: string) {
56
+ const res = await fetch(`/api/pages/${id}/export`, { credentials: "include" });
57
+ const blob = await res.blob();
58
+ const url = URL.createObjectURL(blob);
59
+ const a = document.createElement("a");
60
+ a.href = url;
61
+ a.download = `${slug || "home"}.page.json`;
62
+ a.click();
63
+ URL.revokeObjectURL(url);
64
+ }
65
+
66
+ return (
67
+ <div className="p-8 max-w-4xl">
68
+ <div className="flex items-center justify-between mb-6">
69
+ <h1 className="text-2xl font-semibold">Pages</h1>
70
+ <button
71
+ onClick={() => setCreating(true)}
72
+ className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700"
73
+ >
74
+ <Plus size={16} /> New page
75
+ </button>
76
+ </div>
77
+
78
+ {creating && (
79
+ <div className="mb-6 bg-white border rounded-lg p-4 space-y-4">
80
+ {error && <div className="text-sm text-red-600">{error}</div>}
81
+ <div className="flex gap-3">
82
+ <input
83
+ placeholder="Title"
84
+ value={title}
85
+ onChange={(e) => setTitle(e.target.value)}
86
+ className="flex-1 border rounded-md px-3 py-2 text-sm"
87
+ />
88
+ <input
89
+ placeholder="slug (e.g. about, leave empty for home)"
90
+ value={slug}
91
+ onChange={(e) => setSlug(e.target.value.toLowerCase())}
92
+ className="flex-1 border rounded-md px-3 py-2 text-sm"
93
+ />
94
+ </div>
95
+
96
+ <div className="space-y-2">
97
+ <label className="text-xs font-medium text-slate-600">Start from</label>
98
+ <div className="flex gap-3 overflow-x-auto pb-1">
99
+ <button
100
+ onClick={() => setTemplateId(null)}
101
+ className={`shrink-0 w-28 h-20 rounded-md border-2 flex flex-col items-center justify-center gap-1 text-xs font-medium ${
102
+ templateId === null ? "border-blue-500 bg-blue-50 text-blue-700" : "border-dashed text-slate-500 hover:border-slate-400"
103
+ }`}
104
+ >
105
+ <LayoutTemplate size={18} />
106
+ Blank page
107
+ </button>
108
+ {templates.map((t) => (
109
+ <button
110
+ key={t.id}
111
+ onClick={() => setTemplateId(t.id)}
112
+ className={`shrink-0 w-28 h-20 rounded-md border-2 flex flex-col items-center justify-center gap-1 text-xs font-medium overflow-hidden ${
113
+ templateId === t.id ? "border-blue-500 bg-blue-50 text-blue-700" : "border-slate-200 text-slate-600 hover:border-slate-400"
114
+ }`}
115
+ >
116
+ {t.thumbnail ? (
117
+ // eslint-disable-next-line @next/next/no-img-element
118
+ <img src={t.thumbnail} alt={t.name} className="w-full h-full object-cover" />
119
+ ) : (
120
+ <>
121
+ <LayoutTemplate size={18} />
122
+ <span className="px-1 text-center truncate w-full">{t.name}</span>
123
+ </>
124
+ )}
125
+ </button>
126
+ ))}
127
+ </div>
128
+ </div>
129
+
130
+ <div className="flex gap-2">
131
+ <button
132
+ onClick={createPage}
133
+ disabled={!title}
134
+ className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium disabled:opacity-50"
135
+ >
136
+ Create
137
+ </button>
138
+ <button onClick={() => setCreating(false)} className="px-4 py-2 text-sm text-slate-600">
139
+ Cancel
140
+ </button>
141
+ </div>
142
+ </div>
143
+ )}
144
+
145
+ <div className="bg-white border rounded-lg divide-y">
146
+ {pages?.length === 0 && <div className="p-6 text-sm text-slate-500">No pages yet.</div>}
147
+ {pages?.map((page) => (
148
+ <div key={page.id} className="flex items-center justify-between px-4 py-3">
149
+ <div>
150
+ <div className="font-medium">{page.title}</div>
151
+ <div className="text-xs text-slate-500">
152
+ /{page.slug}{" "}
153
+ <span
154
+ className={
155
+ page.status === "PUBLISHED" ? "text-green-600 font-medium" : "text-amber-600 font-medium"
156
+ }
157
+ >
158
+ {page.status}
159
+ </span>
160
+ </div>
161
+ </div>
162
+ <div className="flex items-center gap-3">
163
+ {page.status === "PUBLISHED" && (
164
+ <a href={`/${page.slug}`} target="_blank" rel="noreferrer" className="text-slate-400 hover:text-slate-600">
165
+ <ExternalLink size={16} />
166
+ </a>
167
+ )}
168
+ <button onClick={() => exportPage(page.id, page.slug)} title="Export as JSON" className="text-slate-400 hover:text-slate-600">
169
+ <Download size={16} />
170
+ </button>
171
+ <button onClick={() => duplicatePage(page.id)} title="Duplicate" className="text-slate-400 hover:text-slate-600">
172
+ <Copy size={16} />
173
+ </button>
174
+ <Link href={`/admin/pages/${page.id}`} className="text-slate-400 hover:text-slate-600">
175
+ <Pencil size={16} />
176
+ </Link>
177
+ <button onClick={() => deletePage(page.id)} className="text-slate-400 hover:text-red-600">
178
+ <Trash2 size={16} />
179
+ </button>
180
+ </div>
181
+ </div>
182
+ ))}
183
+ </div>
184
+ </div>
185
+ );
186
+ }