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.
Files changed (206) hide show
  1. package/bin/create-nextblock.js +997 -0
  2. package/package.json +25 -0
  3. package/scripts/sync-template.js +284 -0
  4. package/templates/nextblock-template/.env.example +37 -0
  5. package/templates/nextblock-template/.swcrc +30 -0
  6. package/templates/nextblock-template/README.md +194 -0
  7. package/templates/nextblock-template/app/(auth-pages)/forgot-password/page.tsx +57 -0
  8. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -0
  9. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +28 -0
  10. package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +67 -0
  11. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +70 -0
  12. package/templates/nextblock-template/app/ToasterProvider.tsx +17 -0
  13. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +147 -0
  14. package/templates/nextblock-template/app/[slug]/page.tsx +145 -0
  15. package/templates/nextblock-template/app/[slug]/page.utils.ts +183 -0
  16. package/templates/nextblock-template/app/actions/email.ts +31 -0
  17. package/templates/nextblock-template/app/actions/formActions.ts +65 -0
  18. package/templates/nextblock-template/app/actions/languageActions.ts +130 -0
  19. package/templates/nextblock-template/app/actions/postActions.ts +80 -0
  20. package/templates/nextblock-template/app/actions.ts +146 -0
  21. package/templates/nextblock-template/app/api/process-image/route.ts +210 -0
  22. package/templates/nextblock-template/app/api/revalidate/route.ts +86 -0
  23. package/templates/nextblock-template/app/api/revalidate-log/route.ts +23 -0
  24. package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +106 -0
  25. package/templates/nextblock-template/app/api/upload/proxy/route.ts +84 -0
  26. package/templates/nextblock-template/app/auth/callback/route.ts +58 -0
  27. package/templates/nextblock-template/app/blog/[slug]/PostClientContent.tsx +169 -0
  28. package/templates/nextblock-template/app/blog/[slug]/page.tsx +177 -0
  29. package/templates/nextblock-template/app/blog/[slug]/page.utils.ts +136 -0
  30. package/templates/nextblock-template/app/blog/page.tsx +77 -0
  31. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +321 -0
  32. package/templates/nextblock-template/app/cms/blocks/actions.ts +434 -0
  33. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -0
  34. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +567 -0
  35. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +98 -0
  36. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +58 -0
  37. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +62 -0
  38. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +276 -0
  39. package/templates/nextblock-template/app/cms/blocks/components/DeleteBlockButtonClient.tsx +47 -0
  40. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +182 -0
  41. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +120 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +133 -0
  43. package/templates/nextblock-template/app/cms/blocks/components/SortableBlockItem.tsx +46 -0
  44. package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +85 -0
  45. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +182 -0
  46. package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +111 -0
  47. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +150 -0
  48. package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +79 -0
  49. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +337 -0
  50. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -0
  51. package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +64 -0
  52. package/templates/nextblock-template/app/cms/components/ConfirmationModal.tsx +51 -0
  53. package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +145 -0
  54. package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +203 -0
  55. package/templates/nextblock-template/app/cms/components/LanguageFilterSelect.tsx +69 -0
  56. package/templates/nextblock-template/app/cms/dashboard/page.tsx +247 -0
  57. package/templates/nextblock-template/app/cms/layout.tsx +10 -0
  58. package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -0
  59. package/templates/nextblock-template/app/cms/media/[id]/edit/page.tsx +80 -0
  60. package/templates/nextblock-template/app/cms/media/actions.ts +577 -0
  61. package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +53 -0
  62. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -0
  63. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -0
  64. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +157 -0
  65. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +275 -0
  66. package/templates/nextblock-template/app/cms/media/components/MediaImage.tsx +70 -0
  67. package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +195 -0
  68. package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +362 -0
  69. package/templates/nextblock-template/app/cms/media/page.tsx +120 -0
  70. package/templates/nextblock-template/app/cms/navigation/[id]/edit/page.tsx +101 -0
  71. package/templates/nextblock-template/app/cms/navigation/actions.ts +358 -0
  72. package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +52 -0
  73. package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +248 -0
  74. package/templates/nextblock-template/app/cms/navigation/components/NavigationLanguageSwitcher.tsx +132 -0
  75. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +701 -0
  76. package/templates/nextblock-template/app/cms/navigation/components/SortableNavItem.tsx +98 -0
  77. package/templates/nextblock-template/app/cms/navigation/new/page.tsx +26 -0
  78. package/templates/nextblock-template/app/cms/navigation/page.tsx +102 -0
  79. package/templates/nextblock-template/app/cms/navigation/utils.ts +51 -0
  80. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +121 -0
  81. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -0
  82. package/templates/nextblock-template/app/cms/pages/actions.ts +241 -0
  83. package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +47 -0
  84. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +253 -0
  85. package/templates/nextblock-template/app/cms/pages/new/page.tsx +52 -0
  86. package/templates/nextblock-template/app/cms/pages/page.tsx +232 -0
  87. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +183 -0
  88. package/templates/nextblock-template/app/cms/posts/actions.ts +309 -0
  89. package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +55 -0
  90. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +419 -0
  91. package/templates/nextblock-template/app/cms/posts/new/page.tsx +21 -0
  92. package/templates/nextblock-template/app/cms/posts/page.tsx +192 -0
  93. package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -0
  94. package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +201 -0
  95. package/templates/nextblock-template/app/cms/revisions/actions.ts +84 -0
  96. package/templates/nextblock-template/app/cms/revisions/service.ts +344 -0
  97. package/templates/nextblock-template/app/cms/revisions/utils.ts +127 -0
  98. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +68 -0
  99. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +78 -0
  100. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +32 -0
  101. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +117 -0
  102. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +216 -0
  103. package/templates/nextblock-template/app/cms/settings/languages/[id]/edit/page.tsx +77 -0
  104. package/templates/nextblock-template/app/cms/settings/languages/actions.ts +261 -0
  105. package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +76 -0
  106. package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +167 -0
  107. package/templates/nextblock-template/app/cms/settings/languages/new/page.tsx +34 -0
  108. package/templates/nextblock-template/app/cms/settings/languages/page.tsx +156 -0
  109. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +19 -0
  110. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +114 -0
  111. package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +177 -0
  112. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +11 -0
  113. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +118 -0
  114. package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -0
  115. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +91 -0
  116. package/templates/nextblock-template/app/cms/users/actions.ts +156 -0
  117. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +71 -0
  118. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +138 -0
  119. package/templates/nextblock-template/app/cms/users/page.tsx +183 -0
  120. package/templates/nextblock-template/app/favicon.ico +0 -0
  121. package/templates/nextblock-template/app/globals.css +401 -0
  122. package/templates/nextblock-template/app/layout.tsx +191 -0
  123. package/templates/nextblock-template/app/lib/sitemap-utils.ts +68 -0
  124. package/templates/nextblock-template/app/page.tsx +109 -0
  125. package/templates/nextblock-template/app/providers.tsx +43 -0
  126. package/templates/nextblock-template/app/robots.txt/route.ts +19 -0
  127. package/templates/nextblock-template/app/sitemap.xml/route.ts +63 -0
  128. package/templates/nextblock-template/app/unauthorized/page.tsx +27 -0
  129. package/templates/nextblock-template/backup/backup_2025-06-19.sql +8057 -0
  130. package/templates/nextblock-template/backup/backup_2025-06-20.sql +8159 -0
  131. package/templates/nextblock-template/backup/backup_2025-07-08.sql +8411 -0
  132. package/templates/nextblock-template/backup/backup_2025-07-09.sql +8442 -0
  133. package/templates/nextblock-template/backup/backup_2025-07-10.sql +8442 -0
  134. package/templates/nextblock-template/backup/backup_2025-10-01.sql +8803 -0
  135. package/templates/nextblock-template/backup/backup_2025-10-02.sql +9749 -0
  136. package/templates/nextblock-template/components/BlockRenderer.tsx +119 -0
  137. package/templates/nextblock-template/components/FooterNavigation.tsx +33 -0
  138. package/templates/nextblock-template/components/Header.tsx +42 -0
  139. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -0
  140. package/templates/nextblock-template/components/LanguageSwitcher.tsx +103 -0
  141. package/templates/nextblock-template/components/ResponsiveNav.tsx +372 -0
  142. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +17 -0
  143. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +93 -0
  144. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +180 -0
  145. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -0
  146. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +69 -0
  147. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +98 -0
  148. package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +41 -0
  149. package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +240 -0
  150. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +79 -0
  151. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +33 -0
  152. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +189 -0
  153. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +31 -0
  154. package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +59 -0
  155. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +51 -0
  156. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +40 -0
  157. package/templates/nextblock-template/components/blocks/types.ts +8 -0
  158. package/templates/nextblock-template/components/env-var-warning.tsx +33 -0
  159. package/templates/nextblock-template/components/form-message.tsx +26 -0
  160. package/templates/nextblock-template/components/header-auth.tsx +71 -0
  161. package/templates/nextblock-template/components/submit-button.tsx +23 -0
  162. package/templates/nextblock-template/components/theme-switcher.tsx +78 -0
  163. package/templates/nextblock-template/context/AuthContext.tsx +138 -0
  164. package/templates/nextblock-template/context/CurrentContentContext.tsx +42 -0
  165. package/templates/nextblock-template/context/LanguageContext.tsx +206 -0
  166. package/templates/nextblock-template/docs/cms-application-overview.md +56 -0
  167. package/templates/nextblock-template/docs/cms-architecture-overview.md +73 -0
  168. package/templates/nextblock-template/docs/files-structure.md +426 -0
  169. package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +174 -0
  170. package/templates/nextblock-template/eslint.config.mjs +28 -0
  171. package/templates/nextblock-template/index.d.ts +5 -0
  172. package/templates/nextblock-template/lib/blocks/README.md +670 -0
  173. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +1001 -0
  174. package/templates/nextblock-template/lib/ui/ColorPicker.ts +1 -0
  175. package/templates/nextblock-template/lib/ui/ConfirmationDialog.ts +1 -0
  176. package/templates/nextblock-template/lib/ui/CustomSelectWithInput.ts +1 -0
  177. package/templates/nextblock-template/lib/ui/Skeleton.ts +1 -0
  178. package/templates/nextblock-template/lib/ui/avatar.ts +1 -0
  179. package/templates/nextblock-template/lib/ui/badge.ts +1 -0
  180. package/templates/nextblock-template/lib/ui/button.ts +1 -0
  181. package/templates/nextblock-template/lib/ui/card.ts +1 -0
  182. package/templates/nextblock-template/lib/ui/checkbox.ts +1 -0
  183. package/templates/nextblock-template/lib/ui/dialog.ts +1 -0
  184. package/templates/nextblock-template/lib/ui/dropdown-menu.ts +1 -0
  185. package/templates/nextblock-template/lib/ui/input.ts +1 -0
  186. package/templates/nextblock-template/lib/ui/label.ts +1 -0
  187. package/templates/nextblock-template/lib/ui/popover.ts +1 -0
  188. package/templates/nextblock-template/lib/ui/progress.ts +1 -0
  189. package/templates/nextblock-template/lib/ui/select.ts +1 -0
  190. package/templates/nextblock-template/lib/ui/separator.ts +1 -0
  191. package/templates/nextblock-template/lib/ui/table.ts +1 -0
  192. package/templates/nextblock-template/lib/ui/textarea.ts +1 -0
  193. package/templates/nextblock-template/lib/ui/tooltip.ts +1 -0
  194. package/templates/nextblock-template/lib/ui/ui.ts +1 -0
  195. package/templates/nextblock-template/middleware.ts +206 -0
  196. package/templates/nextblock-template/next-env.d.ts +6 -0
  197. package/templates/nextblock-template/next.config.js +99 -0
  198. package/templates/nextblock-template/package.json +52 -0
  199. package/templates/nextblock-template/postcss.config.js +6 -0
  200. package/templates/nextblock-template/project.json +7 -0
  201. package/templates/nextblock-template/public/.gitkeep +0 -0
  202. package/templates/nextblock-template/scripts/backfill-image-meta.ts +149 -0
  203. package/templates/nextblock-template/scripts/backup.js +53 -0
  204. package/templates/nextblock-template/scripts/test-bundle-optimization.js +114 -0
  205. package/templates/nextblock-template/tailwind.config.ts +19 -0
  206. package/templates/nextblock-template/tsconfig.json +62 -0
@@ -0,0 +1,372 @@
1
+ "use client";
2
+
3
+ import Link from 'next/link';
4
+ import React, { useState, useEffect, useMemo, useRef } from 'react'
5
+ import type { Database } from '@nextblock-cms/db' // Relative path from components/
6
+ import { useCurrentContent } from '../context/CurrentContentContext';
7
+ import { useTranslations } from '@nextblock-cms/utils';
8
+
9
+ type Logo = Database['public']['Tables']['logos']['Row'] & { media: (Database['public']['Tables']['media']['Row'] & { alt_text: string | null }) | null };
10
+ type NavigationItem = Database['public']['Tables']['navigation_items']['Row'];
11
+ import Image from 'next/image'
12
+
13
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || ''
14
+
15
+ // Define a type for hierarchical navigation items
16
+ interface HierarchicalNavigationItem extends NavigationItem {
17
+ children: HierarchicalNavigationItem[]
18
+ }
19
+
20
+ // SVG Icon for dropdowns/expandable sections
21
+ const ChevronDownIcon = (props: React.SVGProps<SVGSVGElement>) => (
22
+ <svg
23
+ xmlns="http://www.w3.org/2000/svg"
24
+ viewBox="0 0 20 20"
25
+ fill="currentColor"
26
+ {...props}
27
+ >
28
+ <path
29
+ fillRule="evenodd"
30
+ d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L5.23 8.27a.75.75 0 01.02-1.06z"
31
+ clipRule="evenodd"
32
+ />
33
+ </svg>
34
+ )
35
+
36
+ // Utility function to build the hierarchy from a flat list
37
+ const buildHierarchy = (
38
+ items: NavigationItem[],
39
+ ): HierarchicalNavigationItem[] => {
40
+ const hierarchy: HierarchicalNavigationItem[] = []
41
+ const itemMap: { [id: string]: HierarchicalNavigationItem } = {}
42
+
43
+ items.forEach(item => {
44
+ itemMap[item.id] = { ...item, children: [] }
45
+ })
46
+
47
+ items.forEach(item => {
48
+ if (item.parent_id && itemMap[item.parent_id]) {
49
+ itemMap[item.parent_id].children.push(itemMap[item.id])
50
+ } else {
51
+ // Add to root if no parent_id or parent_id not in map (handles orphaned items gracefully)
52
+ if (itemMap[item.id]) {
53
+ // Ensure item itself exists in map
54
+ hierarchy.push(itemMap[item.id])
55
+ }
56
+ }
57
+ })
58
+ return hierarchy
59
+ }
60
+
61
+ interface ResponsiveNavProps {
62
+ homeLinkHref: string
63
+ logo?: Logo | null
64
+ siteTitle: string
65
+ navItems: NavigationItem[]
66
+ canAccessCms: boolean;
67
+ cmsDashboardLinkHref: string;
68
+ headerAuthComponent: React.ReactNode;
69
+ languageSwitcherComponent: React.ReactNode;
70
+ }
71
+
72
+ export default function ResponsiveNav({
73
+ homeLinkHref,
74
+ navItems,
75
+ canAccessCms,
76
+ cmsDashboardLinkHref,
77
+ headerAuthComponent,
78
+ languageSwitcherComponent,
79
+ logo,
80
+ siteTitle,
81
+ }: ResponsiveNavProps) {
82
+ const { t } = useTranslations();
83
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
84
+ const [expandedMobileItems, setExpandedMobileItems] = useState<Record<string, boolean>>({});
85
+
86
+ const menuButtonRef = useRef<HTMLButtonElement>(null);
87
+ const menuContainerRef = useRef<HTMLDivElement>(null);
88
+
89
+ const hierarchicalNavItems = useMemo(() => buildHierarchy(navItems), [navItems]);
90
+ const { currentContent } = useCurrentContent();
91
+
92
+ let editPathDetails: { href: string; label: string } | null = null;
93
+
94
+ if (canAccessCms && currentContent.id && currentContent.type) {
95
+ if (currentContent.type === 'page') {
96
+ editPathDetails = {
97
+ href: `/cms/pages/${currentContent.id}/edit`,
98
+ label: t('edit_page'),
99
+ };
100
+ } else if (currentContent.type === 'post') {
101
+ editPathDetails = {
102
+ href: `/cms/posts/${currentContent.id}/edit`,
103
+ label: t('edit_post'),
104
+ };
105
+ }
106
+ }
107
+ // The old path-based logic for determining editPathDetails is removed
108
+ // as the context is now the source of truth for ID and type.
109
+ // The link will only show if canAccessCms is true and context provides valid id and type.
110
+
111
+ const toggleMobileMenu = () => {
112
+ setIsMobileMenuOpen(!isMobileMenuOpen);
113
+ // Optionally reset expanded submenus when main menu closes
114
+ // if (!isMobileMenuOpen) setExpandedMobileItems({});
115
+ };
116
+
117
+ const toggleMobileSubmenu = (itemId: string) => {
118
+ setExpandedMobileItems(prev => ({ ...prev, [itemId]: !prev[itemId] }));
119
+ };
120
+
121
+ useEffect(() => { // Added opening curly brace
122
+ const handleResize = () => {
123
+ if (window.innerWidth >= 768 && isMobileMenuOpen) { // Tailwind 'md' breakpoint is 768px
124
+ setIsMobileMenuOpen(false);
125
+ }
126
+ };
127
+ window.addEventListener('resize', handleResize);
128
+ return () => window.removeEventListener('resize', handleResize);
129
+ }, [isMobileMenuOpen]);
130
+
131
+ useEffect(() => {
132
+ const menuElement = menuContainerRef.current;
133
+ if (!menuElement) return;
134
+
135
+ const handleKeyDown = (event: KeyboardEvent) => {
136
+ if (event.key !== 'Tab') return;
137
+
138
+ const focusableElements = menuElement.querySelectorAll<HTMLElement>(
139
+ 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), details, [tabindex]:not([tabindex="-1"])'
140
+ );
141
+
142
+ if (focusableElements.length === 0) return;
143
+
144
+ const firstElement = focusableElements[0];
145
+ const lastElement = focusableElements[focusableElements.length - 1];
146
+
147
+ if (event.shiftKey) {
148
+ // Shift + Tab
149
+ if (document.activeElement === firstElement) {
150
+ lastElement.focus();
151
+ event.preventDefault();
152
+ }
153
+ } else {
154
+ // Tab
155
+ if (document.activeElement === lastElement) {
156
+ firstElement.focus();
157
+ event.preventDefault();
158
+ }
159
+ }
160
+ };
161
+
162
+ if (isMobileMenuOpen) {
163
+ const focusableElements = menuElement.querySelectorAll<HTMLElement>(
164
+ 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), details, [tabindex]:not([tabindex="-1"])'
165
+ );
166
+
167
+ if (focusableElements.length > 0) {
168
+ focusableElements[0].focus();
169
+ }
170
+
171
+ document.addEventListener('keydown', handleKeyDown);
172
+ } else {
173
+ menuButtonRef.current?.focus();
174
+ }
175
+
176
+ return () => {
177
+ document.removeEventListener('keydown', handleKeyDown);
178
+ };
179
+ }, [isMobileMenuOpen]);
180
+
181
+ const renderMobileNavItems = (items: HierarchicalNavigationItem[], level = 0): React.JSX.Element[] => {
182
+ return items.map(item => (
183
+ <div key={item.id} className={`${level > 0 ? 'ml-0' : ''}`}>
184
+ <div
185
+ className={`flex items-center justify-between w-full text-base font-medium text-foreground rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 ${level > 0 ? 'px-3 py-2' : 'px-3 py-2'}`}
186
+ >
187
+ {/* Only the label is a clickable link */}
188
+ <Link
189
+ href={item.url}
190
+ className="py-0 px-0 mr-2 focus:underline focus:outline-none"
191
+ onClick={() => {
192
+ toggleMobileMenu();
193
+ }}
194
+ >
195
+ {item.label}
196
+ </Link>
197
+ {/* If item has children, the rest of the row (whitespace + chevron) is a single button to toggle submenu */}
198
+ {item.children && item.children.length > 0 && (
199
+ <button
200
+ type="button"
201
+ className="flex flex-1 items-center h-full cursor-pointer bg-transparent border-none outline-none px-1 justify-end"
202
+ style={{ minWidth: 0 }}
203
+ aria-expanded={!!expandedMobileItems[String(item.id)]}
204
+ aria-label={`Toggle submenu for ${item.label}`}
205
+ onClick={e => {
206
+ e.stopPropagation();
207
+ toggleMobileSubmenu(String(item.id));
208
+ }}
209
+ >
210
+ <span className="flex-1" /> {/* whitespace filler, clickable */}
211
+ <ChevronDownIcon className={`h-5 w-5 transform transition-transform duration-200 ${expandedMobileItems[String(item.id)] ? 'rotate-180' : ''}`} />
212
+ </button>
213
+ )}
214
+ </div>
215
+ {item.children && item.children.length > 0 && expandedMobileItems[String(item.id)] && (
216
+ <div className="pl-[calc(0.75rem+0.5rem)] mt-1 mb-1 border-l-2 border-gray-300 dark:border-gray-600 ml-[calc(0.75rem+1px)] mr-3">
217
+ {renderMobileNavItems(item.children, level + 1)}
218
+ </div>
219
+ )}
220
+ </div>
221
+ ));
222
+ };
223
+
224
+ const renderDesktopNavItems = (items: HierarchicalNavigationItem[], isSubmenu = false): React.JSX.Element[] => {
225
+ return items.map(item => (
226
+ <div key={item.id} className={`relative group ${isSubmenu ? 'w-full' : ''}`}>
227
+ <Link
228
+ href={item.url}
229
+ className={`flex items-center justify-between hover:underline px-3 py-2 text-sm text-foreground ${isSubmenu ? 'w-full hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md' : ''}`}
230
+ >
231
+ {item.label}
232
+ {item.children && item.children.length > 0 && (
233
+ <ChevronDownIcon className={`ml-1 h-4 w-4 transition-transform duration-200 group-hover:rotate-180 ${isSubmenu ? '' : ''}`} />
234
+ )}
235
+ </Link>
236
+ {item.children && item.children.length > 0 && (
237
+ <div
238
+ className={`
239
+ absolute top-full left-0 mt-0 w-56 bg-background border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1
240
+ opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-in-out z-50
241
+ ${isSubmenu ? 'left-full top-0 -mt-[2px] ml-0' : ''}
242
+ `}
243
+ > {/* -mt-[2px] to align better with parent item border */}
244
+ {renderDesktopNavItems(item.children, true)}
245
+ </div>
246
+ )}
247
+ </div>
248
+ ));
249
+ };
250
+
251
+ return (
252
+ <>
253
+ {/* Main container for desktop and mobile top bar elements */}
254
+ <div className="flex justify-between items-center w-full">
255
+ {/* Left side: Home link (visible on desktop and mobile) */}
256
+ <div className="flex items-center">
257
+ <Link
258
+ href={homeLinkHref}
259
+ className="flex items-center space-x-2 rtl:space-x-reverse"
260
+ >
261
+ {logo && logo.media ? (
262
+ <Image
263
+ src={`${R2_BASE_URL}/${logo.media.object_key}`}
264
+ alt={logo.media.alt_text || 'Home'}
265
+ width={logo.media.width || 100}
266
+ height={logo.media.height || 32}
267
+ className="h-14 w-auto object-contain"
268
+ priority
269
+ />
270
+ ) : (
271
+ <span className="text-xl font-semibold text-foreground">
272
+ {siteTitle}
273
+ </span>
274
+ )}
275
+ </Link>
276
+ {/* Desktop: Additional Nav items */}
277
+ <div className="hidden md:flex items-baseline font-semibold ml-6 space-x-1"> {/* Adjusted space-x for items with internal padding */}
278
+ {hierarchicalNavItems.length > 0 && renderDesktopNavItems(hierarchicalNavItems)}
279
+ </div>
280
+ </div>
281
+
282
+ {/* Right side: Auth, LangSwitcher (desktop), Hamburger (mobile) */}
283
+ <div className="hidden md:flex items-center space-x-4">
284
+ {canAccessCms && editPathDetails && (
285
+ <Link href={editPathDetails.href} className="hover:underline font-semibold text-sm text-foreground mr-3">
286
+ {editPathDetails.label}
287
+ </Link>
288
+ )}
289
+ {canAccessCms && (
290
+ <Link href={cmsDashboardLinkHref} className="hover:underline font-semibold text-sm text-foreground">
291
+ {t('cms_dashboard')}
292
+ </Link>
293
+ )}
294
+ {headerAuthComponent}
295
+ {languageSwitcherComponent}
296
+ </div>
297
+
298
+ <div className="md:hidden flex items-center z-[60]">
299
+ <button
300
+ ref={menuButtonRef}
301
+ onClick={toggleMobileMenu}
302
+ className="p-2 rounded-md text-foreground hover:text-primary focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary"
303
+ aria-label={t('open_main_menu')}
304
+ aria-expanded={isMobileMenuOpen}
305
+ >
306
+ {isMobileMenuOpen ? (
307
+ <svg className="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
308
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
309
+ </svg>
310
+ ) : (
311
+ <svg className="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
312
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
313
+ </svg>
314
+ )}
315
+ </button>
316
+ </div>
317
+ </div>
318
+
319
+ {/* Overlay for Mobile Menu - Fades In/Out */}
320
+ <div
321
+ className={`fixed inset-0 bg-black/50 backdrop-blur-sm z-40 transition-opacity ease-in-out duration-300 top-16 md:hidden ${
322
+ isMobileMenuOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
323
+ }`}
324
+ onClick={toggleMobileMenu}
325
+ aria-hidden={!isMobileMenuOpen}
326
+ />
327
+
328
+ {/* Slide-in Mobile Menu Container (for the sliding content) */}
329
+ <div
330
+ ref={menuContainerRef}
331
+ className={`fixed inset-0 z-40 transform transition-transform ease-in-out duration-300 md:hidden ${
332
+ isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
333
+ }`}
334
+ role="dialog"
335
+ aria-modal="true"
336
+ aria-label={t('mobile_navigation_menu')}
337
+ >
338
+ {/* Menu Content (this part slides with the container above) */}
339
+ <div className="fixed top-16 left-0 h-[calc(100vh-4rem)] w-full max-w-sm bg-background text-foreground shadow-xl p-5 z-50 flex flex-col">
340
+ <nav className="flex-grow flex flex-col space-y-1 overflow-y-auto pt-6">
341
+ {renderMobileNavItems(hierarchicalNavItems)}
342
+ {canAccessCms && editPathDetails && (
343
+ <Link
344
+ href={editPathDetails.href}
345
+ className="block px-3 py-2 rounded-md text-base font-medium text-foreground hover:bg-gray-100 dark:hover:bg-gray-700"
346
+ onClick={() => {
347
+ toggleMobileMenu();
348
+ }}
349
+ >
350
+ {editPathDetails.label}
351
+ </Link>
352
+ )}
353
+ {canAccessCms && (
354
+ <Link
355
+ href={cmsDashboardLinkHref}
356
+ className="block px-3 py-2 rounded-md text-base font-medium text-foreground hover:bg-gray-100 dark:hover:bg-gray-700"
357
+ onClick={toggleMobileMenu}
358
+ >
359
+ {t('cms_dashboard')}
360
+ </Link>
361
+ )}
362
+ </nav>
363
+
364
+ <div className="mt-auto pt-6 border-t border-foreground/20 space-y-4">
365
+ <div >{headerAuthComponent}</div>
366
+ <div >{languageSwitcherComponent}</div>
367
+ </div>
368
+ </div>
369
+ </div>
370
+ </>
371
+ );
372
+ }
@@ -0,0 +1,17 @@
1
+ import { Skeleton } from "@nextblock-cms/ui";
2
+
3
+ const PostCardSkeleton = () => {
4
+ return (
5
+ <div className="border rounded-lg overflow-hidden shadow-sm bg-card text-card-foreground">
6
+ <Skeleton className="h-48 w-full" />
7
+ <div className="p-4 space-y-3">
8
+ <Skeleton className="h-5 w-3/4" />
9
+ <Skeleton className="h-4 w-full" />
10
+ <Skeleton className="h-4 w-5/6" />
11
+ <Skeleton className="h-4 w-1/4 mt-2" />
12
+ </div>
13
+ </div>
14
+ );
15
+ };
16
+
17
+ export default PostCardSkeleton;
@@ -0,0 +1,93 @@
1
+ // components/blocks/PostsGridBlock.tsx
2
+ import React from 'react';
3
+ import type { Database } from '@nextblock-cms/db';
4
+ import { createClient } from '@nextblock-cms/db'; // Added import
5
+ import type { PostWithMediaDimensions } from './types';
6
+
7
+ type Block = Database['public']['Tables']['blocks']['Row'];
8
+ // import Link from 'next/link'; // Unused, PostsGridClient handles links
9
+ import PostsGridClient from './PostsGridClient';
10
+ import { fetchPaginatedPublishedPosts } from '../../app/actions/postActions'; // fetchInitialPublishedPosts removed
11
+
12
+ interface PostsGridBlockProps {
13
+ block: Block;
14
+ languageId: number;
15
+ }
16
+
17
+ const PostsGridBlock: React.FC<PostsGridBlockProps> = async ({ block, languageId }) => {
18
+ const {
19
+ title = "Recent Posts",
20
+ postsPerPage = 12,
21
+ columns = 3,
22
+ showPagination = true,
23
+ } = block.content as { title?: string, postsPerPage?: number, columns?: number, showPagination?: boolean };
24
+
25
+ const supabase = createClient();
26
+
27
+ const { data: postsData, error: queryError, count } = await supabase
28
+ .from('posts')
29
+ .select('id, title, slug, excerpt, published_at, language_id, status, created_at, updated_at, translation_group_id, feature_image_id, feature_media_object:media!feature_image_id(object_key, width, height)', { count: 'exact' })
30
+ .eq('status', 'published')
31
+ .eq('language_id', languageId)
32
+ .order('published_at', { ascending: false })
33
+ .limit(postsPerPage);
34
+
35
+ let initialPosts: PostWithMediaDimensions[] = [];
36
+ let totalCount = 0;
37
+ let postsError: string | null = null;
38
+
39
+ if (queryError) {
40
+ console.error("Error fetching initial posts directly in PostsGridBlock:", queryError);
41
+ postsError = queryError.message;
42
+ } else {
43
+ initialPosts = (postsData as any)?.map((p: any) => {
44
+ // feature_media_object is an object here, not an array, due to the query structure media!feature_image_id(object_key, width, height)
45
+ // Cast to 'unknown' then to the expected single object type to satisfy TypeScript, reflecting runtime reality.
46
+ const mediaObject = p.feature_media_object as unknown as { object_key: string; width?: number | null; height?: number | null; blur_data_url?: string | null } | null;
47
+ const imageUrl = mediaObject?.object_key
48
+ ? `${process.env.NEXT_PUBLIC_R2_BASE_URL}/${mediaObject.object_key}`
49
+ : null;
50
+ return {
51
+ ...p,
52
+ // Convert feature_media_object to array format to match the type
53
+ feature_media_object: mediaObject ? [{ object_key: mediaObject.object_key }] : null,
54
+ feature_image_url: imageUrl,
55
+ feature_image_width: mediaObject?.width || null,
56
+ feature_image_height: mediaObject?.height || null,
57
+ blur_data_url: mediaObject?.blur_data_url || null,
58
+ };
59
+ }) as PostWithMediaDimensions[] || [];
60
+ totalCount = count || 0;
61
+ }
62
+
63
+ if (postsError) {
64
+ return <div className="text-red-500">Error loading posts: {postsError}</div>;
65
+ }
66
+
67
+ if (!initialPosts || initialPosts.length === 0) {
68
+ return (
69
+ <section className="py-8 container mx-auto">
70
+ {title && <h2 className="text-2xl font-semibold mb-4">{title}</h2>}
71
+ <p>No posts found.</p>
72
+ </section>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <section className="py-8 container mx-auto">
78
+ {title && <h2 className="text-2xl font-semibold mb-6">{title}</h2>}
79
+ <PostsGridClient
80
+ initialPosts={initialPosts}
81
+ initialPage={1}
82
+ postsPerPage={postsPerPage}
83
+ totalCount={totalCount}
84
+ columns={columns}
85
+ languageId={languageId}
86
+ showPagination={showPagination}
87
+ fetchAction={fetchPaginatedPublishedPosts} // Pass the server action for pagination
88
+ />
89
+ </section>
90
+ );
91
+ };
92
+
93
+ export default PostsGridBlock;
@@ -0,0 +1,180 @@
1
+ // components/blocks/PostsGridClient.tsx
2
+ 'use client';
3
+
4
+ import React, { useState, useEffect } from 'react';
5
+ import type { Database } from '@nextblock-cms/db';
6
+ import Link from 'next/link';
7
+
8
+ type PostWithMediaDimensions = Database['public']['Tables']['posts']['Row'] & {
9
+ feature_image_url: string | null;
10
+ feature_image_width: number | null;
11
+ feature_image_height: number | null;
12
+ blur_data_url: string | null;
13
+ };
14
+ import Image from 'next/image';
15
+ import { Button } from '@nextblock-cms/ui'; // Adjusted path
16
+ import PostCardSkeleton from './PostCardSkeleton'; // Added import
17
+
18
+ interface PostsGridClientProps {
19
+ initialPosts: PostWithMediaDimensions[];
20
+ initialPage: number;
21
+ postsPerPage: number;
22
+ totalCount: number;
23
+ columns: number;
24
+ languageId: number;
25
+ showPagination: boolean;
26
+ fetchAction: (languageId: number, page: number, limit: number) => Promise<{ posts: PostWithMediaDimensions[], totalCount: number, error?: string }>;
27
+ }
28
+
29
+ const PostsGridClient: React.FC<PostsGridClientProps> = ({
30
+ initialPosts,
31
+ initialPage,
32
+ postsPerPage,
33
+ totalCount,
34
+ columns,
35
+ languageId,
36
+ showPagination,
37
+ fetchAction,
38
+ }) => {
39
+ const [currentPage, setCurrentPage] = useState(initialPage);
40
+ const [posts, setPosts] = useState<PostWithMediaDimensions[]>(initialPosts);
41
+ const [isLoading, setIsLoading] = useState(false);
42
+ const [error, setError] = useState<string | null>(null);
43
+ // Initialize skeletonCount to postsPerPage, or a sensible minimum if initialPosts is empty.
44
+ const [skeletonCount, setSkeletonCount] = useState(initialPosts.length > 0 ? initialPosts.length : postsPerPage);
45
+
46
+ const totalPages = Math.ceil(totalCount / postsPerPage);
47
+
48
+ useEffect(() => {
49
+ setPosts(initialPosts); // Sync if initialPosts change due to parent re-render
50
+ setCurrentPage(initialPage);
51
+ // When initialPosts change, update skeletonCount to reflect the number of items actually rendered initially,
52
+ // or fall back to postsPerPage if initialPosts is empty (e.g., for a client-side initial fetch)
53
+ setSkeletonCount(initialPosts.length > 0 ? initialPosts.length : postsPerPage);
54
+ }, [initialPosts, initialPage, postsPerPage]);
55
+
56
+ const handlePageChange = async (newPage: number) => {
57
+ if (newPage < 1 || newPage > totalPages || isLoading) return;
58
+
59
+ // For subsequent page loads, always show `postsPerPage` skeletons
60
+ setSkeletonCount(postsPerPage);
61
+ setIsLoading(true);
62
+ setError(null);
63
+ // Don't clear posts here immediately, skeletons will cover the loading state
64
+ try {
65
+ const result = await fetchAction(languageId, newPage, postsPerPage);
66
+
67
+ if (result.error) {
68
+ setError(result.error);
69
+ setPosts([]); // Clear posts on error
70
+ } else {
71
+ setPosts(result.posts);
72
+ setCurrentPage(newPage);
73
+ }
74
+ } catch (e: unknown) {
75
+ setError(e instanceof Error ? e.message : "Failed to fetch posts.");
76
+ setPosts([]); // Clear posts on error
77
+ }
78
+ setIsLoading(false);
79
+ };
80
+
81
+ const columnClasses: { [key: number]: string } = {
82
+ 1: 'grid-cols-1',
83
+ 2: 'grid-cols-1 md:grid-cols-2',
84
+ 3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
85
+ 4: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
86
+ };
87
+ const gridColsClass = columnClasses[columns] || columnClasses[3];
88
+
89
+ const getImageSizes = (cols: number): string => {
90
+ switch (cols) {
91
+ case 1:
92
+ return '100vw';
93
+ case 2:
94
+ return '(max-width: 767px) 100vw, 50vw';
95
+ case 4:
96
+ return '(max-width: 639px) 100vw, (max-width: 767px) 50vw, (max-width: 1023px) 33vw, 25vw';
97
+ case 3:
98
+ default:
99
+ return '(max-width: 767px) 100vw, (max-width: 1023px) 50vw, 33vw';
100
+ }
101
+ };
102
+
103
+ const imageSizes = getImageSizes(columns);
104
+
105
+ if (error && !isLoading) { // Only show full error if not also loading (e.g. initial load error after skeletons)
106
+ return <div className="text-red-500 py-10 text-center">Error: {error}</div>;
107
+ }
108
+
109
+ return (
110
+ <div>
111
+ <div className={`grid ${gridColsClass} gap-6`}>
112
+ {isLoading ? (
113
+ Array.from({ length: skeletonCount }).map((_, index) => (
114
+ <PostCardSkeleton key={`skeleton-${index}`} />
115
+ ))
116
+ ) : posts.length > 0 ? (
117
+ posts.map((post, index) => (
118
+ <Link href={`/blog/${post.slug}`} key={post.id} className="block group">
119
+ <div className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-card text-card-foreground">
120
+ {/* Basic Post Card Structure - Enhanced with Feature Image */}
121
+ {post.feature_image_url && typeof post.feature_image_width === 'number' && typeof post.feature_image_height === 'number' && post.feature_image_width > 0 && post.feature_image_height > 0 ? (
122
+ <div className="aspect-video overflow-hidden"> {/* Or other aspect ratio as desired, e.g., aspect-[16/9] or aspect-square */}
123
+ <Image
124
+ src={post.feature_image_url}
125
+ alt={`Feature image for ${post.title}`}
126
+ width={post.feature_image_width}
127
+ height={post.feature_image_height}
128
+ sizes={imageSizes}
129
+ priority={index === 0}
130
+ placeholder={post.blur_data_url ? 'blur' : 'empty'}
131
+ blurDataURL={post.blur_data_url ?? undefined}
132
+ quality={60}
133
+ className="h-full object-cover transition-transform duration-300 group-hover:scale-105"
134
+ />
135
+ </div>
136
+ ) : post.feature_image_url ? (
137
+ <div className="aspect-video overflow-hidden bg-gray-200 flex items-center justify-center">
138
+ <span className="text-gray-500">Image not available</span>
139
+ </div>
140
+ ) : null}
141
+ <div className="p-4">
142
+ <h3 className="text-lg font-semibold mb-2 group-hover:text-primary">{post.title}</h3>
143
+ {post.excerpt && <p className="text-sm text-muted-foreground mb-3 line-clamp-3">{post.excerpt}</p>}
144
+ <span className="text-xs text-primary group-hover:underline">Read more</span>
145
+ </div>
146
+ </div>
147
+ </Link>
148
+ ))
149
+ ) : (
150
+ !error && <div className="col-span-full text-center py-10">No posts found.</div> // Show if no posts and no error, and not loading
151
+ )}
152
+ </div>
153
+
154
+ {showPagination && totalPages > 1 && (
155
+ <div className="flex justify-center items-center mt-8 space-x-2">
156
+ <Button
157
+ onClick={() => handlePageChange(currentPage - 1)}
158
+ disabled={currentPage === 1 || isLoading}
159
+ variant="outline"
160
+ >
161
+ Previous
162
+ </Button>
163
+ <span className="text-sm">
164
+ Page {currentPage} of {totalPages}
165
+ </span>
166
+ <Button
167
+ onClick={() => handlePageChange(currentPage + 1)}
168
+ disabled={currentPage === totalPages || isLoading}
169
+ variant="outline"
170
+ >
171
+ Next
172
+ </Button>
173
+ </div>
174
+ )}
175
+ {/* {isLoading && <p className="text-center mt-4 text-sm text-muted-foreground">Fetching posts...</p>} */}
176
+ </div>
177
+ );
178
+ };
179
+
180
+ export default PostsGridClient;