create-nextblock 0.0.1
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/bin/create-nextblock.js +997 -0
- package/package.json +25 -0
- package/scripts/sync-template.js +284 -0
- package/templates/nextblock-template/.env.example +37 -0
- package/templates/nextblock-template/.swcrc +30 -0
- package/templates/nextblock-template/README.md +194 -0
- package/templates/nextblock-template/app/(auth-pages)/forgot-password/page.tsx +57 -0
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -0
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +28 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +67 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +70 -0
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -0
- package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +147 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +145 -0
- package/templates/nextblock-template/app/[slug]/page.utils.ts +183 -0
- package/templates/nextblock-template/app/actions/email.ts +31 -0
- package/templates/nextblock-template/app/actions/formActions.ts +65 -0
- package/templates/nextblock-template/app/actions/languageActions.ts +130 -0
- package/templates/nextblock-template/app/actions/postActions.ts +80 -0
- package/templates/nextblock-template/app/actions.ts +146 -0
- package/templates/nextblock-template/app/api/process-image/route.ts +210 -0
- package/templates/nextblock-template/app/api/revalidate/route.ts +86 -0
- package/templates/nextblock-template/app/api/revalidate-log/route.ts +23 -0
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +106 -0
- package/templates/nextblock-template/app/api/upload/proxy/route.ts +84 -0
- package/templates/nextblock-template/app/auth/callback/route.ts +58 -0
- package/templates/nextblock-template/app/blog/[slug]/PostClientContent.tsx +169 -0
- package/templates/nextblock-template/app/blog/[slug]/page.tsx +177 -0
- package/templates/nextblock-template/app/blog/[slug]/page.utils.ts +136 -0
- package/templates/nextblock-template/app/blog/page.tsx +77 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +321 -0
- package/templates/nextblock-template/app/cms/blocks/actions.ts +434 -0
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +567 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +98 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +58 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +62 -0
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +276 -0
- package/templates/nextblock-template/app/cms/blocks/components/DeleteBlockButtonClient.tsx +47 -0
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +182 -0
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +120 -0
- package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +133 -0
- package/templates/nextblock-template/app/cms/blocks/components/SortableBlockItem.tsx +46 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +85 -0
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +182 -0
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +111 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +150 -0
- package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +79 -0
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +337 -0
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -0
- package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +64 -0
- package/templates/nextblock-template/app/cms/components/ConfirmationModal.tsx +51 -0
- package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +145 -0
- package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +203 -0
- package/templates/nextblock-template/app/cms/components/LanguageFilterSelect.tsx +69 -0
- package/templates/nextblock-template/app/cms/dashboard/page.tsx +247 -0
- package/templates/nextblock-template/app/cms/layout.tsx +10 -0
- package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -0
- package/templates/nextblock-template/app/cms/media/[id]/edit/page.tsx +80 -0
- package/templates/nextblock-template/app/cms/media/actions.ts +577 -0
- package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +53 -0
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -0
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -0
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +157 -0
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +275 -0
- package/templates/nextblock-template/app/cms/media/components/MediaImage.tsx +70 -0
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +195 -0
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +362 -0
- package/templates/nextblock-template/app/cms/media/page.tsx +120 -0
- package/templates/nextblock-template/app/cms/navigation/[id]/edit/page.tsx +101 -0
- package/templates/nextblock-template/app/cms/navigation/actions.ts +358 -0
- package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +52 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +248 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationLanguageSwitcher.tsx +132 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +701 -0
- package/templates/nextblock-template/app/cms/navigation/components/SortableNavItem.tsx +98 -0
- package/templates/nextblock-template/app/cms/navigation/new/page.tsx +26 -0
- package/templates/nextblock-template/app/cms/navigation/page.tsx +102 -0
- package/templates/nextblock-template/app/cms/navigation/utils.ts +51 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +121 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +241 -0
- package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +47 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +253 -0
- package/templates/nextblock-template/app/cms/pages/new/page.tsx +52 -0
- package/templates/nextblock-template/app/cms/pages/page.tsx +232 -0
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +183 -0
- package/templates/nextblock-template/app/cms/posts/actions.ts +309 -0
- package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +55 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +419 -0
- package/templates/nextblock-template/app/cms/posts/new/page.tsx +21 -0
- package/templates/nextblock-template/app/cms/posts/page.tsx +192 -0
- package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -0
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +201 -0
- package/templates/nextblock-template/app/cms/revisions/actions.ts +84 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +344 -0
- package/templates/nextblock-template/app/cms/revisions/utils.ts +127 -0
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +68 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +78 -0
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +32 -0
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +117 -0
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +216 -0
- package/templates/nextblock-template/app/cms/settings/languages/[id]/edit/page.tsx +77 -0
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +261 -0
- package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +76 -0
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +167 -0
- package/templates/nextblock-template/app/cms/settings/languages/new/page.tsx +34 -0
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +156 -0
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +19 -0
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +114 -0
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +177 -0
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +11 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +118 -0
- package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -0
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +91 -0
- package/templates/nextblock-template/app/cms/users/actions.ts +156 -0
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +71 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +138 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +183 -0
- package/templates/nextblock-template/app/favicon.ico +0 -0
- package/templates/nextblock-template/app/globals.css +401 -0
- package/templates/nextblock-template/app/layout.tsx +191 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +68 -0
- package/templates/nextblock-template/app/page.tsx +109 -0
- package/templates/nextblock-template/app/providers.tsx +43 -0
- package/templates/nextblock-template/app/robots.txt/route.ts +19 -0
- package/templates/nextblock-template/app/sitemap.xml/route.ts +63 -0
- package/templates/nextblock-template/app/unauthorized/page.tsx +27 -0
- package/templates/nextblock-template/backup/backup_2025-06-19.sql +8057 -0
- package/templates/nextblock-template/backup/backup_2025-06-20.sql +8159 -0
- package/templates/nextblock-template/backup/backup_2025-07-08.sql +8411 -0
- package/templates/nextblock-template/backup/backup_2025-07-09.sql +8442 -0
- package/templates/nextblock-template/backup/backup_2025-07-10.sql +8442 -0
- package/templates/nextblock-template/backup/backup_2025-10-01.sql +8803 -0
- package/templates/nextblock-template/backup/backup_2025-10-02.sql +9749 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +119 -0
- package/templates/nextblock-template/components/FooterNavigation.tsx +33 -0
- package/templates/nextblock-template/components/Header.tsx +42 -0
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -0
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +103 -0
- package/templates/nextblock-template/components/ResponsiveNav.tsx +372 -0
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +17 -0
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +93 -0
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +180 -0
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -0
- package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +69 -0
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +98 -0
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +41 -0
- package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +240 -0
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +79 -0
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +33 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +189 -0
- package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +31 -0
- package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +59 -0
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +51 -0
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +40 -0
- package/templates/nextblock-template/components/blocks/types.ts +8 -0
- package/templates/nextblock-template/components/env-var-warning.tsx +33 -0
- package/templates/nextblock-template/components/form-message.tsx +26 -0
- package/templates/nextblock-template/components/header-auth.tsx +71 -0
- package/templates/nextblock-template/components/submit-button.tsx +23 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +78 -0
- package/templates/nextblock-template/context/AuthContext.tsx +138 -0
- package/templates/nextblock-template/context/CurrentContentContext.tsx +42 -0
- package/templates/nextblock-template/context/LanguageContext.tsx +206 -0
- package/templates/nextblock-template/docs/cms-application-overview.md +56 -0
- package/templates/nextblock-template/docs/cms-architecture-overview.md +73 -0
- package/templates/nextblock-template/docs/files-structure.md +426 -0
- package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +174 -0
- package/templates/nextblock-template/eslint.config.mjs +28 -0
- package/templates/nextblock-template/index.d.ts +5 -0
- package/templates/nextblock-template/lib/blocks/README.md +670 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +1001 -0
- package/templates/nextblock-template/lib/ui/ColorPicker.ts +1 -0
- package/templates/nextblock-template/lib/ui/ConfirmationDialog.ts +1 -0
- package/templates/nextblock-template/lib/ui/CustomSelectWithInput.ts +1 -0
- package/templates/nextblock-template/lib/ui/Skeleton.ts +1 -0
- package/templates/nextblock-template/lib/ui/avatar.ts +1 -0
- package/templates/nextblock-template/lib/ui/badge.ts +1 -0
- package/templates/nextblock-template/lib/ui/button.ts +1 -0
- package/templates/nextblock-template/lib/ui/card.ts +1 -0
- package/templates/nextblock-template/lib/ui/checkbox.ts +1 -0
- package/templates/nextblock-template/lib/ui/dialog.ts +1 -0
- package/templates/nextblock-template/lib/ui/dropdown-menu.ts +1 -0
- package/templates/nextblock-template/lib/ui/input.ts +1 -0
- package/templates/nextblock-template/lib/ui/label.ts +1 -0
- package/templates/nextblock-template/lib/ui/popover.ts +1 -0
- package/templates/nextblock-template/lib/ui/progress.ts +1 -0
- package/templates/nextblock-template/lib/ui/select.ts +1 -0
- package/templates/nextblock-template/lib/ui/separator.ts +1 -0
- package/templates/nextblock-template/lib/ui/table.ts +1 -0
- package/templates/nextblock-template/lib/ui/textarea.ts +1 -0
- package/templates/nextblock-template/lib/ui/tooltip.ts +1 -0
- package/templates/nextblock-template/lib/ui/ui.ts +1 -0
- package/templates/nextblock-template/middleware.ts +206 -0
- package/templates/nextblock-template/next-env.d.ts +6 -0
- package/templates/nextblock-template/next.config.js +99 -0
- package/templates/nextblock-template/package.json +52 -0
- package/templates/nextblock-template/postcss.config.js +6 -0
- package/templates/nextblock-template/project.json +7 -0
- package/templates/nextblock-template/public/.gitkeep +0 -0
- package/templates/nextblock-template/scripts/backfill-image-meta.ts +149 -0
- package/templates/nextblock-template/scripts/backup.js +53 -0
- package/templates/nextblock-template/scripts/test-bundle-optimization.js +114 -0
- package/templates/nextblock-template/tailwind.config.ts +19 -0
- package/templates/nextblock-template/tsconfig.json +62 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// app/cms/pages/actions.ts
|
|
2
|
+
"use server";
|
|
3
|
+
|
|
4
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
5
|
+
import { revalidatePath } from "next/cache";
|
|
6
|
+
import { redirect } from "next/navigation";
|
|
7
|
+
import type { Database } from "@nextblock-cms/db";
|
|
8
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
9
|
+
|
|
10
|
+
type PageStatus = Database['public']['Enums']['page_status'];
|
|
11
|
+
import { encodedRedirect } from "@nextblock-cms/utils/server";
|
|
12
|
+
import { getFullPageContent } from "../revisions/utils";
|
|
13
|
+
import { createPageRevision } from "../revisions/service";
|
|
14
|
+
|
|
15
|
+
// --- createPage and updatePage functions remain unchanged ---
|
|
16
|
+
|
|
17
|
+
export async function createPage(formData: FormData) {
|
|
18
|
+
const supabase = createClient();
|
|
19
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
20
|
+
if (!user) return encodedRedirect("error", "/cms/pages/new", "User not authenticated.");
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const rawFormData = {
|
|
24
|
+
title: formData.get("title") as string,
|
|
25
|
+
slug: formData.get("slug") as string,
|
|
26
|
+
language_id: parseInt(formData.get("language_id") as string, 10),
|
|
27
|
+
status: formData.get("status") as PageStatus,
|
|
28
|
+
meta_title: formData.get("meta_title") as string || null,
|
|
29
|
+
meta_description: formData.get("meta_description") as string || null,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id) || !rawFormData.status) {
|
|
33
|
+
return encodedRedirect("error", "/cms/pages/new", "Missing required fields: title, slug, language, or status.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const translation_group_id = formData.get("translation_group_id") as string || uuidv4();
|
|
37
|
+
|
|
38
|
+
// Check if a translation for this language already exists
|
|
39
|
+
if (formData.get("translation_group_id")) {
|
|
40
|
+
const { data: existingTranslation, error: checkError } = await supabase
|
|
41
|
+
.from("pages")
|
|
42
|
+
.select("id")
|
|
43
|
+
.eq("translation_group_id", formData.get("translation_group_id") as string)
|
|
44
|
+
.eq("language_id", rawFormData.language_id)
|
|
45
|
+
.maybeSingle();
|
|
46
|
+
|
|
47
|
+
if (checkError) {
|
|
48
|
+
console.error("Error checking for existing translation:", checkError);
|
|
49
|
+
// Decide if we should halt or just log. For now, we'll proceed.
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (existingTranslation) {
|
|
53
|
+
// A translation for this language already exists, redirect to its edit page.
|
|
54
|
+
redirect(`/cms/pages/${existingTranslation.id}/edit?warning=${encodeURIComponent("A page for this language already exists. You are now editing it.")}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const pageData: UpsertPagePayload = {
|
|
59
|
+
...rawFormData,
|
|
60
|
+
author_id: user.id,
|
|
61
|
+
translation_group_id: translation_group_id,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const { data: newPage, error: createError } = await supabase
|
|
65
|
+
.from("pages")
|
|
66
|
+
.insert(pageData)
|
|
67
|
+
.select("id, title, slug, language_id, translation_group_id")
|
|
68
|
+
.single();
|
|
69
|
+
|
|
70
|
+
if (createError) {
|
|
71
|
+
console.error("Error creating page:", createError);
|
|
72
|
+
if (createError.code === '23505' && createError.message.includes('pages_language_id_slug_key')) {
|
|
73
|
+
return encodedRedirect("error", "/cms/pages/new", `The slug "${pageData.slug}" already exists for the selected language. Please use a unique slug.`);
|
|
74
|
+
}
|
|
75
|
+
return encodedRedirect("error", "/cms/pages/new", `Failed to create page: ${createError.message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
revalidatePath("/cms/pages");
|
|
79
|
+
if (newPage?.slug) revalidatePath(`/${newPage.slug}`);
|
|
80
|
+
|
|
81
|
+
if (newPage?.id) {
|
|
82
|
+
redirect(`/cms/pages/${newPage.id}/edit?success=${encodeURIComponent("Page created successfully.")}`);
|
|
83
|
+
} else {
|
|
84
|
+
redirect(`/cms/pages?success=${encodeURIComponent("Page created successfully.")}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function updatePage(pageId: number, formData: FormData) {
|
|
89
|
+
const supabase = createClient();
|
|
90
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
91
|
+
const pageEditPath = `/cms/pages/${pageId}/edit`;
|
|
92
|
+
|
|
93
|
+
if (!user) return encodedRedirect("error", pageEditPath, "User not authenticated.");
|
|
94
|
+
|
|
95
|
+
const { data: existingPage, error: fetchError } = await supabase
|
|
96
|
+
.from("pages")
|
|
97
|
+
.select("translation_group_id, slug")
|
|
98
|
+
.eq("id", pageId)
|
|
99
|
+
.single();
|
|
100
|
+
|
|
101
|
+
if (fetchError || !existingPage) {
|
|
102
|
+
return encodedRedirect("error", "/cms/pages", "Original page not found or error fetching it.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rawFormData = {
|
|
106
|
+
title: formData.get("title") as string,
|
|
107
|
+
slug: formData.get("slug") as string,
|
|
108
|
+
language_id: parseInt(formData.get("language_id") as string, 10),
|
|
109
|
+
status: formData.get("status") as PageStatus,
|
|
110
|
+
meta_title: formData.get("meta_title") as string || null,
|
|
111
|
+
meta_description: formData.get("meta_description") as string || null,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!rawFormData.title || !rawFormData.slug || isNaN(rawFormData.language_id) || !rawFormData.status) {
|
|
115
|
+
return encodedRedirect("error", pageEditPath, "Missing required fields: title, slug, language, or status.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pageUpdateData: Partial<Omit<UpsertPagePayload, 'translation_group_id' | 'author_id'>> = {
|
|
119
|
+
title: rawFormData.title,
|
|
120
|
+
slug: rawFormData.slug,
|
|
121
|
+
language_id: rawFormData.language_id,
|
|
122
|
+
status: rawFormData.status,
|
|
123
|
+
meta_title: rawFormData.meta_title,
|
|
124
|
+
meta_description: rawFormData.meta_description,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// capture previous full content before update
|
|
128
|
+
const previousContent = await getFullPageContent(pageId);
|
|
129
|
+
|
|
130
|
+
const { error: updateError } = await supabase
|
|
131
|
+
.from("pages")
|
|
132
|
+
.update(pageUpdateData)
|
|
133
|
+
.eq("id", pageId);
|
|
134
|
+
|
|
135
|
+
if (updateError) {
|
|
136
|
+
console.error("Error updating page:", updateError);
|
|
137
|
+
if (updateError.code === '23505' && updateError.message.includes('pages_language_id_slug_key')) {
|
|
138
|
+
return encodedRedirect("error", pageEditPath, `The slug "${pageUpdateData.slug}" already exists for the selected language. Please use a unique slug.`);
|
|
139
|
+
}
|
|
140
|
+
return encodedRedirect("error", pageEditPath, `Failed to update page: ${updateError.message}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// create revision after update
|
|
144
|
+
if (previousContent && user) {
|
|
145
|
+
const newContent = await getFullPageContent(pageId);
|
|
146
|
+
if (newContent) {
|
|
147
|
+
await createPageRevision(pageId, user.id, previousContent, newContent);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
revalidatePath("/cms/pages");
|
|
152
|
+
if (existingPage.slug) revalidatePath(`/${existingPage.slug}`);
|
|
153
|
+
if (rawFormData.slug && rawFormData.slug !== existingPage.slug) {
|
|
154
|
+
revalidatePath(`/${rawFormData.slug}`);
|
|
155
|
+
}
|
|
156
|
+
revalidatePath(pageEditPath);
|
|
157
|
+
redirect(`${pageEditPath}?success=Page updated successfully`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
export async function deletePage(pageId: number) {
|
|
162
|
+
const supabase = createClient();
|
|
163
|
+
|
|
164
|
+
// 1. Fetch the Translation Group
|
|
165
|
+
const { data: page, error: fetchError } = await supabase
|
|
166
|
+
.from("pages")
|
|
167
|
+
.select("translation_group_id")
|
|
168
|
+
.eq("id", pageId)
|
|
169
|
+
.single();
|
|
170
|
+
|
|
171
|
+
if (fetchError || !page) {
|
|
172
|
+
console.error("Error fetching page for deletion:", fetchError);
|
|
173
|
+
return encodedRedirect("error", "/cms/pages", "Page not found.");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const { translation_group_id } = page;
|
|
177
|
+
|
|
178
|
+
// 2. Find All Related Pages
|
|
179
|
+
const { data: relatedPages, error: relatedPagesError } = await supabase
|
|
180
|
+
.from("pages")
|
|
181
|
+
.select("slug")
|
|
182
|
+
.eq("translation_group_id", translation_group_id);
|
|
183
|
+
|
|
184
|
+
if (relatedPagesError) {
|
|
185
|
+
console.error("Error fetching related pages:", relatedPagesError);
|
|
186
|
+
return encodedRedirect("error", "/cms/pages", "Could not fetch related pages for deletion.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. Delete All Associated Navigation Links
|
|
190
|
+
if (relatedPages && relatedPages.length > 0) {
|
|
191
|
+
const slugs = relatedPages.map(p => p.slug).filter((s): s is string => s !== null);
|
|
192
|
+
if (slugs.length > 0) {
|
|
193
|
+
const pathsToDelete = slugs.map(slug => `/${slug}`);
|
|
194
|
+
const { error: navError } = await supabase
|
|
195
|
+
.from("navigation_items")
|
|
196
|
+
.delete()
|
|
197
|
+
.in("url", pathsToDelete);
|
|
198
|
+
|
|
199
|
+
if (navError) {
|
|
200
|
+
console.error("Error deleting navigation links:", navError);
|
|
201
|
+
// Do not block deletion of pages if nav items fail to delete
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 4. Delete All Related Pages
|
|
207
|
+
const { error: deletePagesError } = await supabase
|
|
208
|
+
.from("pages")
|
|
209
|
+
.delete()
|
|
210
|
+
.eq("translation_group_id", translation_group_id);
|
|
211
|
+
|
|
212
|
+
if (deletePagesError) {
|
|
213
|
+
console.error("Error deleting pages:", deletePagesError);
|
|
214
|
+
return encodedRedirect("error", "/cms/pages", `Failed to delete pages: ${deletePagesError.message}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Revalidate paths to reflect the deletion
|
|
218
|
+
revalidatePath("/cms/pages");
|
|
219
|
+
revalidatePath("/cms/navigation");
|
|
220
|
+
if (relatedPages) {
|
|
221
|
+
relatedPages.forEach(p => {
|
|
222
|
+
if (p.slug) {
|
|
223
|
+
revalidatePath(`/${p.slug}`);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 5. Update Redirect Message
|
|
229
|
+
redirect(`/cms/pages?success=${encodeURIComponent("Page and all its translations were deleted successfully.")}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type UpsertPagePayload = {
|
|
233
|
+
language_id: number;
|
|
234
|
+
author_id: string | null;
|
|
235
|
+
title: string;
|
|
236
|
+
slug: string; // Now language-specific
|
|
237
|
+
status: PageStatus;
|
|
238
|
+
meta_title?: string | null;
|
|
239
|
+
meta_description?: string | null;
|
|
240
|
+
translation_group_id: string; // UUID
|
|
241
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// app/cms/pages/components/DeletePageButtonClient.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import React, { useState, useTransition } from 'react';
|
|
5
|
+
import { DropdownMenuItem } from "@nextblock-cms/ui";
|
|
6
|
+
import { Trash2 } from "lucide-react";
|
|
7
|
+
import { deletePage } from "../actions";
|
|
8
|
+
import { ConfirmationModal } from '@/app/cms/components/ConfirmationModal';
|
|
9
|
+
|
|
10
|
+
interface DeletePageButtonClientProps {
|
|
11
|
+
pageId: number;
|
|
12
|
+
pageTitle: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function DeletePageButtonClient({ pageId, pageTitle }: DeletePageButtonClientProps) {
|
|
16
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
17
|
+
const [isPending, startTransition] = useTransition();
|
|
18
|
+
|
|
19
|
+
const handleDelete = () => {
|
|
20
|
+
startTransition(() => {
|
|
21
|
+
deletePage(pageId);
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<DropdownMenuItem
|
|
28
|
+
className="text-red-600 hover:!text-red-600 hover:!bg-red-50 dark:hover:!bg-red-700/20 cursor-pointer"
|
|
29
|
+
onSelect={(e) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
setIsModalOpen(true);
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
35
|
+
Delete
|
|
36
|
+
</DropdownMenuItem>
|
|
37
|
+
<ConfirmationModal
|
|
38
|
+
isOpen={isModalOpen}
|
|
39
|
+
onClose={() => setIsModalOpen(false)}
|
|
40
|
+
onConfirm={handleDelete}
|
|
41
|
+
title="Are you sure?"
|
|
42
|
+
description={`This will permanently delete the page '${pageTitle}'. This action cannot be undone.`}
|
|
43
|
+
confirmText={isPending ? "Deleting..." : "Delete"}
|
|
44
|
+
/>
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// app/cms/pages/components/PageForm.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState, useTransition } from "react";
|
|
5
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
6
|
+
import { Button } from "@nextblock-cms/ui";
|
|
7
|
+
import { Input } from "@nextblock-cms/ui";
|
|
8
|
+
import { Label } from "@nextblock-cms/ui";
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "@nextblock-cms/ui";
|
|
16
|
+
import { Textarea } from "@nextblock-cms/ui";
|
|
17
|
+
import type { Database } from "@nextblock-cms/db";
|
|
18
|
+
import { useAuth } from "@/context/AuthContext";
|
|
19
|
+
|
|
20
|
+
type Page = Database['public']['Tables']['pages']['Row'];
|
|
21
|
+
type PageStatus = Database['public']['Enums']['page_status'];
|
|
22
|
+
type Language = Database['public']['Tables']['languages']['Row'];
|
|
23
|
+
// Remove: import { getActiveLanguagesClientSide } from "@nextblock-cms/db";
|
|
24
|
+
|
|
25
|
+
interface PageFormProps {
|
|
26
|
+
page?: Page | null;
|
|
27
|
+
formAction: (formData: FormData) => Promise<{ error?: string } | void>;
|
|
28
|
+
actionButtonText?: string;
|
|
29
|
+
isEditing?: boolean;
|
|
30
|
+
availableLanguagesProp: Language[]; // New prop
|
|
31
|
+
translationGroupId?: string;
|
|
32
|
+
target_lang_id?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function PageForm({
|
|
36
|
+
page,
|
|
37
|
+
formAction,
|
|
38
|
+
actionButtonText = "Save Page",
|
|
39
|
+
isEditing = false,
|
|
40
|
+
availableLanguagesProp, // Use the new prop
|
|
41
|
+
translationGroupId,
|
|
42
|
+
target_lang_id,
|
|
43
|
+
}: PageFormProps) {
|
|
44
|
+
const router = useRouter();
|
|
45
|
+
const searchParams = useSearchParams();
|
|
46
|
+
const [isPending, startTransition] = useTransition();
|
|
47
|
+
const { user, isLoading: authLoading } = useAuth();
|
|
48
|
+
|
|
49
|
+
const [title, setTitle] = useState(page?.title || "");
|
|
50
|
+
const [slug, setSlug] = useState(page?.slug || "");
|
|
51
|
+
const [languageId, setLanguageId] = useState<string>(() => {
|
|
52
|
+
// If editing, use the page's language
|
|
53
|
+
if (page?.language_id) {
|
|
54
|
+
return page.language_id.toString();
|
|
55
|
+
}
|
|
56
|
+
// If creating a translation, use the target language
|
|
57
|
+
if (target_lang_id) {
|
|
58
|
+
return target_lang_id;
|
|
59
|
+
}
|
|
60
|
+
// Otherwise, find the default language from the available languages
|
|
61
|
+
if (availableLanguagesProp && availableLanguagesProp.length > 0) {
|
|
62
|
+
const defaultLang = availableLanguagesProp.find((l) => l.is_default);
|
|
63
|
+
if (defaultLang) {
|
|
64
|
+
return defaultLang.id.toString();
|
|
65
|
+
}
|
|
66
|
+
// As a fallback, use the first available language
|
|
67
|
+
return availableLanguagesProp[0].id.toString();
|
|
68
|
+
}
|
|
69
|
+
// If no languages are available, default to an empty string
|
|
70
|
+
return "";
|
|
71
|
+
});
|
|
72
|
+
const [status, setStatus] = useState<PageStatus>(page?.status || "draft");
|
|
73
|
+
const [metaTitle, setMetaTitle] = useState(page?.meta_title || "");
|
|
74
|
+
const [metaDescription, setMetaDescription] = useState(
|
|
75
|
+
page?.meta_description || ""
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Use the passed-in languages
|
|
79
|
+
const [availableLanguages] = useState<Language[]>(availableLanguagesProp);
|
|
80
|
+
// languagesLoading is no longer needed if languages are passed as props
|
|
81
|
+
// const [languagesLoading, setLanguagesLoading] = useState(true); // Remove or set to false initially
|
|
82
|
+
|
|
83
|
+
const [formMessage, setFormMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const successMessage = searchParams.get('success');
|
|
87
|
+
const errorMessage = searchParams.get('error');
|
|
88
|
+
if (successMessage) {
|
|
89
|
+
setFormMessage({ type: 'success', text: successMessage });
|
|
90
|
+
} else if (errorMessage) {
|
|
91
|
+
setFormMessage({ type: 'error', text: errorMessage });
|
|
92
|
+
}
|
|
93
|
+
}, [searchParams]);
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
98
|
+
const newTitle = e.target.value;
|
|
99
|
+
setTitle(newTitle);
|
|
100
|
+
if (!isEditing || !slug) {
|
|
101
|
+
setSlug(newTitle.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]+/g, ""));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
setFormMessage(null);
|
|
108
|
+
const formData = new FormData(event.currentTarget);
|
|
109
|
+
|
|
110
|
+
startTransition(async () => {
|
|
111
|
+
const result = await formAction(formData);
|
|
112
|
+
if (result?.error) {
|
|
113
|
+
setFormMessage({ type: 'error', text: result.error });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Removed languagesLoading from this condition
|
|
119
|
+
if (authLoading) {
|
|
120
|
+
return <div>Loading form...</div>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!user) {
|
|
124
|
+
return <div>Please log in to manage pages.</div>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<form onSubmit={handleSubmit} className="space-y-6 w-full mx-auto px-6">
|
|
129
|
+
{/* ... (rest of the form remains the same, but `availableLanguages` is now populated by the prop) ... */}
|
|
130
|
+
{formMessage && (
|
|
131
|
+
<div
|
|
132
|
+
className={`p-3 rounded-md text-sm ${
|
|
133
|
+
formMessage.type === 'success'
|
|
134
|
+
? 'bg-green-100 text-green-700 border border-green-200'
|
|
135
|
+
: 'bg-red-100 text-red-700 border border-red-200'
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
{formMessage.text}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
{translationGroupId && (
|
|
142
|
+
<input type="hidden" name="translation_group_id" value={translationGroupId} />
|
|
143
|
+
)}
|
|
144
|
+
<div>
|
|
145
|
+
<Label htmlFor="title">Title</Label>
|
|
146
|
+
<Input
|
|
147
|
+
id="title"
|
|
148
|
+
name="title"
|
|
149
|
+
value={title}
|
|
150
|
+
onChange={handleTitleChange}
|
|
151
|
+
required
|
|
152
|
+
className="mt-1"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div>
|
|
157
|
+
<Label htmlFor="slug">Slug</Label>
|
|
158
|
+
<Input
|
|
159
|
+
id="slug"
|
|
160
|
+
name="slug"
|
|
161
|
+
value={slug}
|
|
162
|
+
onChange={(e) => setSlug(e.target.value)}
|
|
163
|
+
required
|
|
164
|
+
className="mt-1"
|
|
165
|
+
/>
|
|
166
|
+
<p className="text-xs text-muted-foreground mt-1">URL-friendly identifier. Auto-generated from title if left empty on creation.</p>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div>
|
|
170
|
+
<Label htmlFor="language_id">Language</Label>
|
|
171
|
+
{availableLanguages.length > 0 ? (
|
|
172
|
+
<Select
|
|
173
|
+
name="language_id"
|
|
174
|
+
defaultValue={target_lang_id}
|
|
175
|
+
value={languageId}
|
|
176
|
+
onValueChange={setLanguageId}
|
|
177
|
+
required
|
|
178
|
+
>
|
|
179
|
+
<SelectTrigger className="mt-1">
|
|
180
|
+
<SelectValue placeholder="Select language" />
|
|
181
|
+
</SelectTrigger>
|
|
182
|
+
<SelectContent>
|
|
183
|
+
{availableLanguages.map((lang) => (
|
|
184
|
+
<SelectItem key={lang.id} value={lang.id.toString()}>
|
|
185
|
+
{lang.name} ({lang.code})
|
|
186
|
+
</SelectItem>
|
|
187
|
+
))}
|
|
188
|
+
</SelectContent>
|
|
189
|
+
</Select>
|
|
190
|
+
) : (
|
|
191
|
+
<p className="text-sm text-muted-foreground mt-1">No languages available. Please add languages in CMS settings.</p>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div>
|
|
196
|
+
<Label htmlFor="status">Status</Label>
|
|
197
|
+
<Select
|
|
198
|
+
name="status"
|
|
199
|
+
value={status}
|
|
200
|
+
onValueChange={(value) => setStatus(value as PageStatus)}
|
|
201
|
+
required
|
|
202
|
+
>
|
|
203
|
+
<SelectTrigger className="mt-1">
|
|
204
|
+
<SelectValue placeholder="Select status" />
|
|
205
|
+
</SelectTrigger>
|
|
206
|
+
<SelectContent>
|
|
207
|
+
<SelectItem value="draft">Draft</SelectItem>
|
|
208
|
+
<SelectItem value="published">Published</SelectItem>
|
|
209
|
+
<SelectItem value="archived">Archived</SelectItem>
|
|
210
|
+
</SelectContent>
|
|
211
|
+
</Select>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div>
|
|
215
|
+
<Label htmlFor="meta_title">Meta Title (SEO)</Label>
|
|
216
|
+
<Input
|
|
217
|
+
id="meta_title"
|
|
218
|
+
name="meta_title"
|
|
219
|
+
value={metaTitle}
|
|
220
|
+
onChange={(e) => setMetaTitle(e.target.value)}
|
|
221
|
+
className="mt-1"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div>
|
|
226
|
+
<Label htmlFor="meta_description">Meta Description (SEO)</Label>
|
|
227
|
+
<Textarea
|
|
228
|
+
id="meta_description"
|
|
229
|
+
name="meta_description"
|
|
230
|
+
value={metaDescription}
|
|
231
|
+
onChange={(e) => setMetaDescription(e.target.value)}
|
|
232
|
+
className="mt-1"
|
|
233
|
+
rows={3}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div className="flex justify-end space-x-3">
|
|
238
|
+
<Button
|
|
239
|
+
type="button"
|
|
240
|
+
variant="outline"
|
|
241
|
+
onClick={() => router.push("/cms/pages")}
|
|
242
|
+
disabled={isPending}
|
|
243
|
+
>
|
|
244
|
+
Cancel
|
|
245
|
+
</Button>
|
|
246
|
+
{/* Ensure button is not disabled due to removed languagesLoading */}
|
|
247
|
+
<Button type="submit" disabled={isPending || authLoading || availableLanguages.length === 0}>
|
|
248
|
+
{isPending ? "Saving..." : actionButtonText}
|
|
249
|
+
</Button>
|
|
250
|
+
</div>
|
|
251
|
+
</form>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// app/cms/pages/new/page.tsx
|
|
2
|
+
import PageForm from "../components/PageForm";
|
|
3
|
+
import { createPage } from "../actions"; // Server action for creating a page
|
|
4
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
5
|
+
import type { Database } from "@nextblock-cms/db";
|
|
6
|
+
|
|
7
|
+
type Language = Database['public']['Tables']['languages']['Row'];
|
|
8
|
+
|
|
9
|
+
interface NewPageProps {
|
|
10
|
+
searchParams?: Promise<{
|
|
11
|
+
from_group?: string | string[];
|
|
12
|
+
target_lang_id?: string | string[];
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function NewPage(props: NewPageProps) {
|
|
17
|
+
const searchParams = (await props.searchParams) || {};
|
|
18
|
+
const supabase = createClient();
|
|
19
|
+
const { data: fetchedLanguages, error: languagesError } = await supabase
|
|
20
|
+
.from("languages")
|
|
21
|
+
.select("*")
|
|
22
|
+
.order("name");
|
|
23
|
+
|
|
24
|
+
if (languagesError) {
|
|
25
|
+
console.error("Error fetching languages for NewPage:", languagesError.message);
|
|
26
|
+
// Optionally, you could redirect or show a more user-friendly error
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const availableLanguages: Language[] = fetchedLanguages || [];
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="max-w-2xl mx-auto">
|
|
33
|
+
<h1 className="text-2xl font-bold mb-6">Create New Page</h1>
|
|
34
|
+
<PageForm
|
|
35
|
+
formAction={createPage}
|
|
36
|
+
actionButtonText="Create Page"
|
|
37
|
+
isEditing={false}
|
|
38
|
+
availableLanguagesProp={availableLanguages}
|
|
39
|
+
translationGroupId={
|
|
40
|
+
Array.isArray(searchParams.from_group)
|
|
41
|
+
? searchParams.from_group[0]
|
|
42
|
+
: searchParams.from_group
|
|
43
|
+
}
|
|
44
|
+
target_lang_id={
|
|
45
|
+
Array.isArray(searchParams.target_lang_id)
|
|
46
|
+
? searchParams.target_lang_id[0]
|
|
47
|
+
: searchParams.target_lang_id
|
|
48
|
+
}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|