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.
- package/README.md +41 -0
- package/bin/lib.js +97 -0
- package/bin/modern-cms.js +197 -0
- package/package.json +37 -0
- package/template/.env.example +48 -0
- package/template/README.md +210 -0
- package/template/apps/api/Dockerfile +26 -0
- package/template/apps/api/package.json +45 -0
- package/template/apps/api/prisma/backfill-search-text.ts +31 -0
- package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
- package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
- package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
- package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
- package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
- package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
- package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
- package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
- package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
- package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
- package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/template/apps/api/prisma/reset-admin.ts +19 -0
- package/template/apps/api/prisma/schema.prisma +251 -0
- package/template/apps/api/prisma/seed-homepage.ts +752 -0
- package/template/apps/api/prisma/seed-our-charities.ts +231 -0
- package/template/apps/api/prisma/seed-templates.ts +154 -0
- package/template/apps/api/prisma/seed.ts +191 -0
- package/template/apps/api/src/env.ts +61 -0
- package/template/apps/api/src/index.ts +66 -0
- package/template/apps/api/src/lib/revalidate.ts +28 -0
- package/template/apps/api/src/lib/storage/azure.ts +25 -0
- package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
- package/template/apps/api/src/lib/storage/index.ts +25 -0
- package/template/apps/api/src/lib/storage/s3.ts +39 -0
- package/template/apps/api/src/lib/storage/types.ts +8 -0
- package/template/apps/api/src/middleware/auditLog.ts +32 -0
- package/template/apps/api/src/middleware/auth.ts +53 -0
- package/template/apps/api/src/middleware/rateLimit.ts +31 -0
- package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
- package/template/apps/api/src/prisma.ts +3 -0
- package/template/apps/api/src/routes/audit.routes.ts +26 -0
- package/template/apps/api/src/routes/auth.routes.ts +46 -0
- package/template/apps/api/src/routes/backup.routes.ts +216 -0
- package/template/apps/api/src/routes/blocks.routes.ts +38 -0
- package/template/apps/api/src/routes/collections.routes.ts +185 -0
- package/template/apps/api/src/routes/forms.routes.ts +114 -0
- package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
- package/template/apps/api/src/routes/media.routes.ts +77 -0
- package/template/apps/api/src/routes/pages.routes.ts +319 -0
- package/template/apps/api/src/routes/redirects.routes.ts +52 -0
- package/template/apps/api/src/routes/search.routes.ts +83 -0
- package/template/apps/api/src/routes/templates.routes.ts +41 -0
- package/template/apps/api/src/routes/theme.routes.ts +75 -0
- package/template/apps/api/src/routes/users.routes.ts +39 -0
- package/template/apps/api/tsconfig.json +11 -0
- package/template/apps/web/Dockerfile +26 -0
- package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
- package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
- package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
- package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
- package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
- package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
- package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
- package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
- package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
- package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
- package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
- package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
- package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
- package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
- package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
- package/template/apps/web/app/admin/login/page.tsx +76 -0
- package/template/apps/web/app/api/revalidate/route.ts +34 -0
- package/template/apps/web/app/globals.css +120 -0
- package/template/apps/web/app/layout.tsx +39 -0
- package/template/apps/web/app/preview/[token]/page.tsx +70 -0
- package/template/apps/web/app/robots.ts +11 -0
- package/template/apps/web/app/sitemap.ts +27 -0
- package/template/apps/web/components/admin/AdminShell.tsx +104 -0
- package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
- package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
- package/template/apps/web/components/builder/Canvas.tsx +76 -0
- package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
- package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
- package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
- package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
- package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
- package/template/apps/web/components/builder/Inspector.tsx +956 -0
- package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
- package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
- package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
- package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
- package/template/apps/web/components/builder/Palette.tsx +106 -0
- package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
- package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
- package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
- package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
- package/template/apps/web/components/renderer/Renderer.tsx +146 -0
- package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
- package/template/apps/web/components/renderer/registry.tsx +87 -0
- package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
- package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
- package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
- package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
- package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
- package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
- package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
- package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
- package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
- package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
- package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
- package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
- package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
- package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
- package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
- package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
- package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
- package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
- package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
- package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
- package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
- package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
- package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
- package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
- package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
- package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
- package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
- package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
- package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
- package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
- package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
- package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
- package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
- package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
- package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
- package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
- package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
- package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
- package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
- package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
- package/template/apps/web/components/renderer/sections/types.ts +7 -0
- package/template/apps/web/components/seo/JsonLd.tsx +111 -0
- package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
- package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
- package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
- package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
- package/template/apps/web/eslint.config.mjs +16 -0
- package/template/apps/web/lib/api.ts +45 -0
- package/template/apps/web/lib/responsiveCss.ts +42 -0
- package/template/apps/web/lib/sanitizeHtml.ts +16 -0
- package/template/apps/web/lib/serverApi.ts +25 -0
- package/template/apps/web/lib/style.ts +73 -0
- package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
- package/template/apps/web/lib/useCurrentUser.ts +35 -0
- package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
- package/template/apps/web/next-env.d.ts +6 -0
- package/template/apps/web/next.config.mjs +32 -0
- package/template/apps/web/package.json +46 -0
- package/template/apps/web/postcss.config.mjs +6 -0
- package/template/apps/web/proxy.ts +23 -0
- package/template/apps/web/tailwind.config.ts +24 -0
- package/template/apps/web/tsconfig.json +41 -0
- package/template/docker-compose.yml +60 -0
- package/template/package.json +29 -0
- package/template/packages/shared/package.json +21 -0
- package/template/packages/shared/src/components.ts +1626 -0
- package/template/packages/shared/src/index.ts +9 -0
- package/template/packages/shared/src/schema/collections.ts +70 -0
- package/template/packages/shared/src/schema/fields.ts +26 -0
- package/template/packages/shared/src/schema/media.ts +13 -0
- package/template/packages/shared/src/schema/pageNode.ts +255 -0
- package/template/packages/shared/src/schema/templates.ts +20 -0
- package/template/packages/shared/src/schema/theme.ts +69 -0
- package/template/packages/shared/src/schema/user.ts +25 -0
- package/template/packages/shared/src/tree.test.ts +65 -0
- package/template/packages/shared/src/tree.ts +303 -0
- package/template/packages/shared/tsconfig.json +8 -0
- package/template/packages/shared/vitest.config.ts +8 -0
|
@@ -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
|
+
}
|