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,375 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useParams, useRouter } from "next/navigation";
|
|
5
|
+
import {
|
|
6
|
+
DndContext,
|
|
7
|
+
DragOverlay,
|
|
8
|
+
PointerSensor,
|
|
9
|
+
useSensor,
|
|
10
|
+
useSensors,
|
|
11
|
+
type DragEndEvent,
|
|
12
|
+
type DragStartEvent,
|
|
13
|
+
} from "@dnd-kit/core";
|
|
14
|
+
import { ArrowLeft, Save, Globe2, ImagePlus, Languages } from "lucide-react";
|
|
15
|
+
import {
|
|
16
|
+
cloneWithNewIds,
|
|
17
|
+
createNode,
|
|
18
|
+
findNode,
|
|
19
|
+
findParent,
|
|
20
|
+
findAncestorChain,
|
|
21
|
+
insertNode,
|
|
22
|
+
isNodeOrDescendant,
|
|
23
|
+
moveNode,
|
|
24
|
+
removeNode,
|
|
25
|
+
duplicateNode,
|
|
26
|
+
updateNode,
|
|
27
|
+
getComponentDefinition,
|
|
28
|
+
SUPPORTED_LOCALES,
|
|
29
|
+
type CollectionItem,
|
|
30
|
+
type NavItem,
|
|
31
|
+
type PageNode,
|
|
32
|
+
type SiteTheme,
|
|
33
|
+
} from "@pgcms/shared";
|
|
34
|
+
import { api } from "@/lib/api";
|
|
35
|
+
import { Palette } from "@/components/builder/Palette";
|
|
36
|
+
import { Canvas } from "@/components/builder/Canvas";
|
|
37
|
+
import { Inspector } from "@/components/builder/Inspector";
|
|
38
|
+
import { IconRenderer } from "@/components/builder/IconRenderer";
|
|
39
|
+
import { MediaPickerModal } from "@/components/builder/MediaPickerModal";
|
|
40
|
+
|
|
41
|
+
type ItemWithCollection = CollectionItem & { collection: { name: string; slug: string } };
|
|
42
|
+
|
|
43
|
+
/** The same visual builder as pages, editing one collection item's content plus its
|
|
44
|
+
* card metadata (excerpt, cover image) shown by Collection List blocks. */
|
|
45
|
+
export default function CollectionItemBuilder() {
|
|
46
|
+
const params = useParams<{ itemId: string }>();
|
|
47
|
+
const router = useRouter();
|
|
48
|
+
|
|
49
|
+
const [item, setItem] = useState<ItemWithCollection | null>(null);
|
|
50
|
+
const [tree, setTree] = useState<PageNode | null>(null);
|
|
51
|
+
const [excerpt, setExcerpt] = useState("");
|
|
52
|
+
const [coverImage, setCoverImage] = useState("");
|
|
53
|
+
const [seoTitle, setSeoTitle] = useState("");
|
|
54
|
+
const [seoDescription, setSeoDescription] = useState("");
|
|
55
|
+
const [ogImage, setOgImage] = useState("");
|
|
56
|
+
const [noIndex, setNoIndex] = useState(false);
|
|
57
|
+
const [locale, setLocale] = useState("en");
|
|
58
|
+
const [translationOfId, setTranslationOfId] = useState("");
|
|
59
|
+
const [siblingItems, setSiblingItems] = useState<
|
|
60
|
+
Pick<CollectionItem, "id" | "title" | "slug" | "locale" | "translationOfId">[]
|
|
61
|
+
>([]);
|
|
62
|
+
const [seoOpen, setSeoOpen] = useState(false);
|
|
63
|
+
const [localeOpen, setLocaleOpen] = useState(false);
|
|
64
|
+
const [coverPicker, setCoverPicker] = useState(false);
|
|
65
|
+
const [theme, setTheme] = useState<SiteTheme | null>(null);
|
|
66
|
+
const [navItems, setNavItems] = useState<NavItem[]>([]);
|
|
67
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
68
|
+
const [activeDragType, setActiveDragType] = useState<string | null>(null);
|
|
69
|
+
const [saving, setSaving] = useState(false);
|
|
70
|
+
const [savedAt, setSavedAt] = useState<Date | null>(null);
|
|
71
|
+
|
|
72
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
api.get<ItemWithCollection>(`/api/collections/items/${params.itemId}`).then((it) => {
|
|
76
|
+
setItem(it);
|
|
77
|
+
setTree(it.content);
|
|
78
|
+
setExcerpt(it.excerpt ?? "");
|
|
79
|
+
setCoverImage(it.coverImage ?? "");
|
|
80
|
+
setSeoTitle(it.seoTitle ?? "");
|
|
81
|
+
setSeoDescription(it.seoDescription ?? "");
|
|
82
|
+
setOgImage(it.ogImage ?? "");
|
|
83
|
+
setNoIndex(it.noIndex ?? false);
|
|
84
|
+
setLocale(it.locale ?? "en");
|
|
85
|
+
setTranslationOfId(it.translationOfId ?? "");
|
|
86
|
+
// "Translation of" candidates are scoped to this item's own collection — a
|
|
87
|
+
// translation only makes sense among items sharing a collection.
|
|
88
|
+
api
|
|
89
|
+
.get<typeof siblingItems>(`/api/collections/${it.collectionId}/items`)
|
|
90
|
+
.then(setSiblingItems)
|
|
91
|
+
.catch(() => setSiblingItems([]));
|
|
92
|
+
});
|
|
93
|
+
api.get<SiteTheme>("/api/theme").then(setTheme);
|
|
94
|
+
api.get<NavItem[]>("/api/theme/nav").then(setNavItems);
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
}, [params.itemId]);
|
|
97
|
+
|
|
98
|
+
function updateTree(updater: (prev: PageNode) => PageNode) {
|
|
99
|
+
setTree((prev) => (prev ? updater(prev) : prev));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleDragStart(event: DragStartEvent) {
|
|
103
|
+
const id = String(event.active.id);
|
|
104
|
+
if (id.startsWith("palette:")) setActiveDragType(id.slice("palette:".length));
|
|
105
|
+
else setActiveDragType((tree && findNode(tree, id)?.type) ?? null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
109
|
+
setActiveDragType(null);
|
|
110
|
+
const { active, over } = event;
|
|
111
|
+
if (!over || !tree) return;
|
|
112
|
+
|
|
113
|
+
const activeId = String(active.id);
|
|
114
|
+
const overId = String(over.id);
|
|
115
|
+
const overData = over.data.current as { type?: string; parentId?: string } | undefined;
|
|
116
|
+
|
|
117
|
+
let targetParentId: string;
|
|
118
|
+
let targetIndex: number;
|
|
119
|
+
if (overData?.type === "container" && overData.parentId) {
|
|
120
|
+
targetParentId = overData.parentId;
|
|
121
|
+
targetIndex = findNode(tree, targetParentId)?.children.length ?? 0;
|
|
122
|
+
} else {
|
|
123
|
+
const parentInfo = findParent(tree, overId);
|
|
124
|
+
if (!parentInfo) return;
|
|
125
|
+
targetParentId = parentInfo.parent.id;
|
|
126
|
+
targetIndex = parentInfo.index;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const targetDef = getComponentDefinition(findNode(tree, targetParentId)?.type ?? "");
|
|
130
|
+
if (targetParentId !== tree.id && !targetDef?.allowsChildren) return;
|
|
131
|
+
|
|
132
|
+
if (activeId.startsWith("palette:")) {
|
|
133
|
+
const newNode = createNode(activeId.slice("palette:".length));
|
|
134
|
+
updateTree((prev) => insertNode(prev, targetParentId, newNode, targetIndex));
|
|
135
|
+
setSelectedId(newNode.id);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (activeId.startsWith("block:")) {
|
|
139
|
+
const blockId = activeId.slice("block:".length);
|
|
140
|
+
api
|
|
141
|
+
.get<{ id: string; content: PageNode }[]>("/api/blocks")
|
|
142
|
+
.then((blocks) => {
|
|
143
|
+
const block = blocks.find((b) => b.id === blockId);
|
|
144
|
+
if (!block) return;
|
|
145
|
+
const copy = cloneWithNewIds(block.content);
|
|
146
|
+
updateTree((prev) => insertNode(prev, targetParentId, copy, targetIndex));
|
|
147
|
+
setSelectedId(copy.id);
|
|
148
|
+
})
|
|
149
|
+
.catch(console.error);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const activeNode = findNode(tree, activeId);
|
|
154
|
+
if (!activeNode || isNodeOrDescendant(activeNode, targetParentId) || activeId === overId) return;
|
|
155
|
+
updateTree((prev) => {
|
|
156
|
+
const before = findParent(prev, activeId);
|
|
157
|
+
let idx = targetIndex;
|
|
158
|
+
if (before && before.parent.id === targetParentId && before.index < targetIndex) idx = targetIndex - 1;
|
|
159
|
+
return moveNode(prev, activeId, targetParentId, idx);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function save(publishToggle?: boolean) {
|
|
164
|
+
if (!item || !tree) return;
|
|
165
|
+
setSaving(true);
|
|
166
|
+
try {
|
|
167
|
+
const nextStatus = publishToggle ? (item.status === "PUBLISHED" ? "DRAFT" : "PUBLISHED") : undefined;
|
|
168
|
+
const updated = await api.put<ItemWithCollection>(`/api/collections/items/${item.id}`, {
|
|
169
|
+
content: tree,
|
|
170
|
+
excerpt: excerpt || null,
|
|
171
|
+
coverImage: coverImage || null,
|
|
172
|
+
seoTitle: seoTitle || null,
|
|
173
|
+
seoDescription: seoDescription || null,
|
|
174
|
+
ogImage: ogImage || null,
|
|
175
|
+
noIndex,
|
|
176
|
+
locale,
|
|
177
|
+
translationOfId: translationOfId || null,
|
|
178
|
+
...(nextStatus ? { status: nextStatus } : {}),
|
|
179
|
+
});
|
|
180
|
+
setItem((prev) => (prev ? { ...prev, ...updated, collection: prev.collection } : prev));
|
|
181
|
+
setSavedAt(new Date());
|
|
182
|
+
} finally {
|
|
183
|
+
setSaving(false);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const selectedNode = selectedId && tree ? findNode(tree, selectedId) : null;
|
|
188
|
+
const ancestors = selectedId && tree ? findAncestorChain(tree, selectedId) : [];
|
|
189
|
+
|
|
190
|
+
if (!item || !tree || !theme) {
|
|
191
|
+
return <div className="p-8 text-sm text-slate-400">Loading item…</div>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="flex flex-col h-full">
|
|
196
|
+
<div className="flex items-center justify-between border-b bg-white px-4 py-2 shrink-0">
|
|
197
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
198
|
+
<button onClick={() => router.push("/admin/collections")} className="text-slate-500 hover:text-slate-800">
|
|
199
|
+
<ArrowLeft size={18} />
|
|
200
|
+
</button>
|
|
201
|
+
<div className="min-w-0">
|
|
202
|
+
<div className="font-semibold text-sm truncate">{item.title}</div>
|
|
203
|
+
<div className="text-xs text-slate-400 truncate">
|
|
204
|
+
/{item.collection.slug}/{item.slug} · {item.status}
|
|
205
|
+
{savedAt && ` · saved ${savedAt.toLocaleTimeString()}`}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div className="flex items-center gap-2">
|
|
210
|
+
<input
|
|
211
|
+
value={excerpt}
|
|
212
|
+
onChange={(e) => setExcerpt(e.target.value)}
|
|
213
|
+
placeholder="Excerpt (shown on listing cards)"
|
|
214
|
+
className="w-72 border rounded-md px-2 py-1.5 text-xs"
|
|
215
|
+
/>
|
|
216
|
+
<button
|
|
217
|
+
onClick={() => setCoverPicker(true)}
|
|
218
|
+
title="Cover image (shown on listing cards)"
|
|
219
|
+
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs border rounded-md hover:bg-slate-50 ${
|
|
220
|
+
coverImage ? "text-green-700 border-green-300" : "text-slate-600"
|
|
221
|
+
}`}
|
|
222
|
+
>
|
|
223
|
+
<ImagePlus size={14} /> {coverImage ? "Cover ✓" : "Cover"}
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => setLocaleOpen((o) => !o)}
|
|
227
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs border rounded-md hover:bg-slate-50 text-slate-600"
|
|
228
|
+
>
|
|
229
|
+
<Languages size={14} /> {locale.toUpperCase()} {localeOpen ? "▲" : "▼"}
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => setSeoOpen((o) => !o)}
|
|
233
|
+
className="px-2.5 py-1.5 text-xs border rounded-md hover:bg-slate-50 text-slate-600"
|
|
234
|
+
>
|
|
235
|
+
SEO {seoOpen ? "▲" : "▼"}
|
|
236
|
+
</button>
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => save()}
|
|
239
|
+
disabled={saving}
|
|
240
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-md hover:bg-slate-50 disabled:opacity-50"
|
|
241
|
+
>
|
|
242
|
+
<Save size={15} /> Save
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
onClick={() => save(true)}
|
|
246
|
+
disabled={saving}
|
|
247
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
248
|
+
>
|
|
249
|
+
<Globe2 size={15} /> {item.status === "PUBLISHED" ? "Unpublish" : "Publish"}
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{localeOpen && (
|
|
255
|
+
<div className="border-b bg-slate-50 px-4 py-3 grid grid-cols-2 gap-3 text-sm shrink-0">
|
|
256
|
+
<div className="space-y-1">
|
|
257
|
+
<label className="text-xs font-medium text-slate-600">Language</label>
|
|
258
|
+
<select
|
|
259
|
+
value={locale}
|
|
260
|
+
onChange={(e) => setLocale(e.target.value)}
|
|
261
|
+
className="w-full border rounded-md px-2 py-1.5 text-xs bg-white"
|
|
262
|
+
>
|
|
263
|
+
{SUPPORTED_LOCALES.map((l) => (
|
|
264
|
+
<option key={l.code} value={l.code}>
|
|
265
|
+
{l.label} ({l.code}){l.rtl ? " · RTL" : ""}
|
|
266
|
+
</option>
|
|
267
|
+
))}
|
|
268
|
+
</select>
|
|
269
|
+
</div>
|
|
270
|
+
<div className="space-y-1">
|
|
271
|
+
<label className="text-xs font-medium text-slate-600">Translation of</label>
|
|
272
|
+
<select
|
|
273
|
+
value={translationOfId}
|
|
274
|
+
onChange={(e) => setTranslationOfId(e.target.value)}
|
|
275
|
+
className="w-full border rounded-md px-2 py-1.5 text-xs bg-white"
|
|
276
|
+
>
|
|
277
|
+
<option value="">Not a translation (original item)</option>
|
|
278
|
+
{siblingItems
|
|
279
|
+
.filter((s) => s.id !== item.id && !s.translationOfId)
|
|
280
|
+
.map((s) => (
|
|
281
|
+
<option key={s.id} value={s.id}>
|
|
282
|
+
{s.title} (/{s.slug} · {s.locale ?? "en"})
|
|
283
|
+
</option>
|
|
284
|
+
))}
|
|
285
|
+
</select>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{seoOpen && (
|
|
291
|
+
<div className="border-b bg-slate-50 px-4 py-3 grid grid-cols-2 gap-3 text-sm shrink-0">
|
|
292
|
+
<input
|
|
293
|
+
value={seoTitle}
|
|
294
|
+
onChange={(e) => setSeoTitle(e.target.value)}
|
|
295
|
+
placeholder="SEO title"
|
|
296
|
+
className="border rounded-md px-2 py-1.5 text-xs"
|
|
297
|
+
/>
|
|
298
|
+
<input
|
|
299
|
+
value={ogImage}
|
|
300
|
+
onChange={(e) => setOgImage(e.target.value)}
|
|
301
|
+
placeholder="OG image URL (or use cover)"
|
|
302
|
+
className="border rounded-md px-2 py-1.5 text-xs"
|
|
303
|
+
/>
|
|
304
|
+
<textarea
|
|
305
|
+
value={seoDescription}
|
|
306
|
+
onChange={(e) => setSeoDescription(e.target.value)}
|
|
307
|
+
placeholder="SEO description"
|
|
308
|
+
rows={2}
|
|
309
|
+
className="col-span-2 border rounded-md px-2 py-1.5 text-xs"
|
|
310
|
+
/>
|
|
311
|
+
<label className="col-span-2 flex items-center gap-2 text-xs text-slate-600">
|
|
312
|
+
<input type="checkbox" checked={noIndex} onChange={(e) => setNoIndex(e.target.checked)} />
|
|
313
|
+
Hide from search engines (noindex)
|
|
314
|
+
</label>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
319
|
+
<div className="flex flex-1 overflow-hidden">
|
|
320
|
+
<Palette />
|
|
321
|
+
<Canvas
|
|
322
|
+
tree={tree}
|
|
323
|
+
theme={theme}
|
|
324
|
+
navItems={navItems}
|
|
325
|
+
selectedId={selectedId}
|
|
326
|
+
onSelect={(id) => setSelectedId(id === "" ? null : id)}
|
|
327
|
+
onDelete={(id) => {
|
|
328
|
+
updateTree((prev) => removeNode(prev, id));
|
|
329
|
+
setSelectedId((sel) => (sel === id ? null : sel));
|
|
330
|
+
}}
|
|
331
|
+
onDuplicate={(id) => updateTree((prev) => duplicateNode(prev, id))}
|
|
332
|
+
onToggleHidden={(id) =>
|
|
333
|
+
updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, props: { ...n.props, hidden: !n.props.hidden } })))
|
|
334
|
+
}
|
|
335
|
+
onChangeStyle={(id, style) => updateTree((prev) => updateNode(prev, id, (n) => ({ ...n, style })))}
|
|
336
|
+
/>
|
|
337
|
+
<Inspector
|
|
338
|
+
node={selectedNode ?? null}
|
|
339
|
+
ancestors={ancestors}
|
|
340
|
+
onSelect={(id) => setSelectedId(id)}
|
|
341
|
+
onChangeProps={(props) =>
|
|
342
|
+
selectedId && updateTree((prev) => updateNode(prev, selectedId, (n) => ({ ...n, props })))
|
|
343
|
+
}
|
|
344
|
+
onChangeStyle={(style) =>
|
|
345
|
+
selectedId && updateTree((prev) => updateNode(prev, selectedId, (n) => ({ ...n, style })))
|
|
346
|
+
}
|
|
347
|
+
onChangeColumnCount={() => {}}
|
|
348
|
+
onChangeZoneCount={() => {}}
|
|
349
|
+
onChangeGridDimensions={() => {}}
|
|
350
|
+
onEnableHeaderZones={() => {}}
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
<DragOverlay>
|
|
354
|
+
{activeDragType && (
|
|
355
|
+
<div className="bg-slate-900 text-white text-xs rounded px-3 py-2 flex items-center gap-2 shadow-lg">
|
|
356
|
+
<IconRenderer name={getComponentDefinition(activeDragType)?.icon ?? "Square"} size={14} />
|
|
357
|
+
{getComponentDefinition(activeDragType)?.label ?? activeDragType}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</DragOverlay>
|
|
361
|
+
</DndContext>
|
|
362
|
+
|
|
363
|
+
{coverPicker && (
|
|
364
|
+
<MediaPickerModal
|
|
365
|
+
accept="image"
|
|
366
|
+
onSelect={(url) => {
|
|
367
|
+
setCoverImage(url);
|
|
368
|
+
setCoverPicker(false);
|
|
369
|
+
}}
|
|
370
|
+
onClose={() => setCoverPicker(false)}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { Plus, Trash2, Newspaper, PencilLine } from "lucide-react";
|
|
6
|
+
import type { Collection, PageStatus } from "@pgcms/shared";
|
|
7
|
+
import { api } from "@/lib/api";
|
|
8
|
+
|
|
9
|
+
type CollectionWithCount = Collection & { _count: { items: number } };
|
|
10
|
+
type ItemRow = { id: string; slug: string; title: string; status: PageStatus; publishedAt: string | null; updatedAt: string };
|
|
11
|
+
|
|
12
|
+
/** Master–detail: collections on the left, the selected collection's items on the
|
|
13
|
+
* right. Item content itself is edited in the full builder (Edit → item builder). */
|
|
14
|
+
export default function CollectionsAdmin() {
|
|
15
|
+
const [collections, setCollections] = useState<CollectionWithCount[]>([]);
|
|
16
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
17
|
+
const [items, setItems] = useState<ItemRow[] | null>(null);
|
|
18
|
+
const [newName, setNewName] = useState("");
|
|
19
|
+
const [newSlug, setNewSlug] = useState("");
|
|
20
|
+
const [newItemTitle, setNewItemTitle] = useState("");
|
|
21
|
+
const [newItemSlug, setNewItemSlug] = useState("");
|
|
22
|
+
const [error, setError] = useState<string | null>(null);
|
|
23
|
+
|
|
24
|
+
const selected = collections.find((c) => c.id === selectedId) ?? null;
|
|
25
|
+
|
|
26
|
+
async function loadCollections() {
|
|
27
|
+
const rows = await api.get<CollectionWithCount[]>("/api/collections");
|
|
28
|
+
setCollections(rows);
|
|
29
|
+
if (!selectedId && rows.length > 0) setSelectedId(rows[0].id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
loadCollections().catch(console.error);
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!selectedId) return;
|
|
39
|
+
setItems(null);
|
|
40
|
+
api.get<ItemRow[]>(`/api/collections/${selectedId}/items`).then(setItems).catch(console.error);
|
|
41
|
+
}, [selectedId]);
|
|
42
|
+
|
|
43
|
+
async function createCollection() {
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
const created = await api.post<Collection>("/api/collections", { name: newName, slug: newSlug });
|
|
47
|
+
setNewName("");
|
|
48
|
+
setNewSlug("");
|
|
49
|
+
await loadCollections();
|
|
50
|
+
setSelectedId(created.id);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
setError(err instanceof Error ? err.message : "Failed to create collection");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function deleteCollection(id: string) {
|
|
57
|
+
if (!confirm("Delete this collection AND all its items? This cannot be undone.")) return;
|
|
58
|
+
await api.delete(`/api/collections/${id}`);
|
|
59
|
+
setSelectedId(null);
|
|
60
|
+
await loadCollections();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function createItem() {
|
|
64
|
+
if (!selectedId) return;
|
|
65
|
+
setError(null);
|
|
66
|
+
try {
|
|
67
|
+
await api.post(`/api/collections/${selectedId}/items`, { title: newItemTitle, slug: newItemSlug });
|
|
68
|
+
setNewItemTitle("");
|
|
69
|
+
setNewItemSlug("");
|
|
70
|
+
const rows = await api.get<ItemRow[]>(`/api/collections/${selectedId}/items`);
|
|
71
|
+
setItems(rows);
|
|
72
|
+
await loadCollections();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError(err instanceof Error ? err.message : "Failed to create item");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function deleteItem(id: string) {
|
|
79
|
+
if (!confirm("Delete this item?")) return;
|
|
80
|
+
await api.delete(`/api/collections/items/${id}`);
|
|
81
|
+
if (selectedId) {
|
|
82
|
+
const rows = await api.get<ItemRow[]>(`/api/collections/${selectedId}/items`);
|
|
83
|
+
setItems(rows);
|
|
84
|
+
}
|
|
85
|
+
await loadCollections();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const slugify = (v: string) =>
|
|
89
|
+
v
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
92
|
+
.replace(/^-|-$/g, "");
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="p-6 max-w-5xl">
|
|
96
|
+
<h1 className="text-xl font-semibold mb-1">Collections</h1>
|
|
97
|
+
<p className="text-sm text-slate-500 mb-6">
|
|
98
|
+
Dynamic content types — a Blog, News, Events. Items live at /{"{collection}"}/{"{item}"} and are listed
|
|
99
|
+
anywhere via the "Collection List" block.
|
|
100
|
+
</p>
|
|
101
|
+
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">{error}</div>}
|
|
102
|
+
|
|
103
|
+
<div className="grid grid-cols-[260px_1fr] gap-6">
|
|
104
|
+
<div className="space-y-2">
|
|
105
|
+
{collections.map((c) => (
|
|
106
|
+
<div
|
|
107
|
+
key={c.id}
|
|
108
|
+
className={`flex items-center justify-between border rounded-md px-3 py-2 cursor-pointer ${
|
|
109
|
+
selectedId === c.id ? "border-blue-400 bg-blue-50" : "bg-white hover:bg-slate-50"
|
|
110
|
+
}`}
|
|
111
|
+
onClick={() => setSelectedId(c.id)}
|
|
112
|
+
>
|
|
113
|
+
<div className="flex items-center gap-2 text-sm min-w-0">
|
|
114
|
+
<Newspaper size={15} className="text-slate-400 shrink-0" />
|
|
115
|
+
<div className="min-w-0">
|
|
116
|
+
<div className="font-medium truncate">{c.name}</div>
|
|
117
|
+
<div className="text-xs text-slate-400 truncate">
|
|
118
|
+
/{c.slug} · {c._count.items} item{c._count.items === 1 ? "" : "s"}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<button
|
|
123
|
+
onClick={(e) => {
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
deleteCollection(c.id);
|
|
126
|
+
}}
|
|
127
|
+
className="text-slate-300 hover:text-red-600 shrink-0"
|
|
128
|
+
>
|
|
129
|
+
<Trash2 size={14} />
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
|
|
134
|
+
<div className="border rounded-md p-3 space-y-2 bg-slate-50">
|
|
135
|
+
<div className="text-xs font-medium text-slate-500">New collection</div>
|
|
136
|
+
<input
|
|
137
|
+
value={newName}
|
|
138
|
+
onChange={(e) => {
|
|
139
|
+
setNewName(e.target.value);
|
|
140
|
+
setNewSlug(slugify(e.target.value));
|
|
141
|
+
}}
|
|
142
|
+
placeholder="Name (e.g. Blog)"
|
|
143
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm"
|
|
144
|
+
/>
|
|
145
|
+
<input
|
|
146
|
+
value={newSlug}
|
|
147
|
+
onChange={(e) => setNewSlug(slugify(e.target.value))}
|
|
148
|
+
placeholder="Slug (e.g. blog)"
|
|
149
|
+
className="w-full border rounded-md px-2 py-1.5 text-sm font-mono"
|
|
150
|
+
/>
|
|
151
|
+
<button
|
|
152
|
+
onClick={createCollection}
|
|
153
|
+
disabled={!newName || !newSlug}
|
|
154
|
+
className="w-full flex items-center justify-center gap-1.5 bg-blue-600 text-white rounded-md py-1.5 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
155
|
+
>
|
|
156
|
+
<Plus size={14} /> Create
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div>
|
|
162
|
+
{!selected && <div className="text-sm text-slate-400">Create or select a collection.</div>}
|
|
163
|
+
{selected && (
|
|
164
|
+
<>
|
|
165
|
+
<div className="flex items-center gap-3 mb-3">
|
|
166
|
+
<input
|
|
167
|
+
value={newItemTitle}
|
|
168
|
+
onChange={(e) => {
|
|
169
|
+
setNewItemTitle(e.target.value);
|
|
170
|
+
setNewItemSlug(slugify(e.target.value));
|
|
171
|
+
}}
|
|
172
|
+
placeholder={`New ${selected.name} item title`}
|
|
173
|
+
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
|
174
|
+
/>
|
|
175
|
+
<input
|
|
176
|
+
value={newItemSlug}
|
|
177
|
+
onChange={(e) => setNewItemSlug(slugify(e.target.value))}
|
|
178
|
+
placeholder="slug"
|
|
179
|
+
className="w-44 border rounded-md px-3 py-2 text-sm font-mono"
|
|
180
|
+
/>
|
|
181
|
+
<button
|
|
182
|
+
onClick={createItem}
|
|
183
|
+
disabled={!newItemTitle || !newItemSlug}
|
|
184
|
+
className="flex items-center gap-1.5 bg-blue-600 text-white rounded-md px-3 py-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
185
|
+
>
|
|
186
|
+
<Plus size={14} /> Add
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{items === null && <div className="text-sm text-slate-400">Loading items…</div>}
|
|
191
|
+
{items?.length === 0 && <div className="text-sm text-slate-400">No items yet — add the first one above.</div>}
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
{items?.map((item) => (
|
|
194
|
+
<div key={item.id} className="flex items-center justify-between border rounded-md px-4 py-3 bg-white">
|
|
195
|
+
<div className="text-sm min-w-0">
|
|
196
|
+
<div className="font-medium truncate">{item.title}</div>
|
|
197
|
+
<div className="text-xs text-slate-400">
|
|
198
|
+
/{selected.slug}/{item.slug} ·{" "}
|
|
199
|
+
<span className={item.status === "PUBLISHED" ? "text-green-600" : "text-amber-600"}>
|
|
200
|
+
{item.status}
|
|
201
|
+
</span>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
205
|
+
<Link
|
|
206
|
+
href={`/admin/collections/item/${item.id}`}
|
|
207
|
+
className="flex items-center gap-1.5 text-xs border rounded-md px-2.5 py-1.5 hover:bg-slate-50"
|
|
208
|
+
>
|
|
209
|
+
<PencilLine size={13} /> Edit
|
|
210
|
+
</Link>
|
|
211
|
+
<button onClick={() => deleteItem(item.id)} className="text-slate-300 hover:text-red-600">
|
|
212
|
+
<Trash2 size={15} />
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
</>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { Trash2, Download } from "lucide-react";
|
|
5
|
+
import { api } from "@/lib/api";
|
|
6
|
+
|
|
7
|
+
type Submission = { id: string; formName: string; pageSlug: string; data: Record<string, string>; createdAt: string };
|
|
8
|
+
|
|
9
|
+
export default function FormSubmissionsPage() {
|
|
10
|
+
const [submissions, setSubmissions] = useState<Submission[] | null>(null);
|
|
11
|
+
const [formFilter, setFormFilter] = useState<string>("");
|
|
12
|
+
|
|
13
|
+
function refresh() {
|
|
14
|
+
const query = formFilter ? `?formName=${encodeURIComponent(formFilter)}` : "";
|
|
15
|
+
api.get<Submission[]>(`/api/forms/submissions${query}`).then(setSubmissions);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
useEffect(refresh, [formFilter]);
|
|
19
|
+
|
|
20
|
+
async function remove(id: string) {
|
|
21
|
+
if (!confirm("Delete this submission?")) return;
|
|
22
|
+
await api.delete(`/api/forms/submissions/${id}`);
|
|
23
|
+
refresh();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function exportCsv() {
|
|
27
|
+
const query = formFilter ? `?formName=${encodeURIComponent(formFilter)}` : "";
|
|
28
|
+
window.open(`/api/forms/submissions/export${query}`, "_blank");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const formNames = Array.from(new Set(submissions?.map((s) => s.formName) ?? []));
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="p-8 max-w-4xl">
|
|
35
|
+
<div className="flex items-center justify-between mb-6 gap-3 flex-wrap">
|
|
36
|
+
<h1 className="text-2xl font-semibold">Form Submissions</h1>
|
|
37
|
+
<div className="flex items-center gap-2">
|
|
38
|
+
<select
|
|
39
|
+
value={formFilter}
|
|
40
|
+
onChange={(e) => setFormFilter(e.target.value)}
|
|
41
|
+
className="border rounded-md px-3 py-2 text-sm bg-white"
|
|
42
|
+
>
|
|
43
|
+
<option value="">All forms</option>
|
|
44
|
+
{formNames.map((name) => (
|
|
45
|
+
<option key={name} value={name}>
|
|
46
|
+
{name}
|
|
47
|
+
</option>
|
|
48
|
+
))}
|
|
49
|
+
</select>
|
|
50
|
+
<button
|
|
51
|
+
onClick={exportCsv}
|
|
52
|
+
className="flex items-center gap-1.5 border rounded-md px-3 py-2 text-sm hover:bg-slate-50"
|
|
53
|
+
>
|
|
54
|
+
<Download size={15} /> Export CSV
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="bg-white border rounded-lg divide-y">
|
|
60
|
+
{submissions?.length === 0 && <div className="p-6 text-sm text-slate-500">No submissions yet.</div>}
|
|
61
|
+
{submissions?.map((sub) => (
|
|
62
|
+
<div key={sub.id} className="flex items-start justify-between gap-4 px-4 py-3">
|
|
63
|
+
<div className="min-w-0">
|
|
64
|
+
<div className="flex items-center gap-2 text-xs text-slate-400 mb-1">
|
|
65
|
+
<span className="font-medium text-slate-600">{sub.formName}</span>
|
|
66
|
+
<span>·</span>
|
|
67
|
+
<span>{sub.pageSlug || "/"}</span>
|
|
68
|
+
<span>·</span>
|
|
69
|
+
<span>{new Date(sub.createdAt).toLocaleString()}</span>
|
|
70
|
+
</div>
|
|
71
|
+
<dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-sm">
|
|
72
|
+
{Object.entries(sub.data).map(([key, value]) => (
|
|
73
|
+
<div key={key} className="contents">
|
|
74
|
+
<dt className="text-slate-400 capitalize">{key}:</dt>
|
|
75
|
+
<dd className="text-slate-800 break-words">{value}</dd>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</dl>
|
|
79
|
+
</div>
|
|
80
|
+
<button onClick={() => remove(sub.id)} className="text-slate-400 hover:text-red-600 shrink-0">
|
|
81
|
+
<Trash2 size={16} />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|