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,275 @@
1
+ "use client";
2
+
3
+ import React, { useState, useTransition, useEffect } from "react";
4
+ import type { Database } from "@nextblock-cms/db";
5
+ import { Button } from "@nextblock-cms/ui";
6
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@nextblock-cms/ui";
7
+ import { Progress } from "@nextblock-cms/ui";
8
+
9
+ type Media = Database['public']['Tables']['media']['Row'];
10
+ import { Checkbox } from "@nextblock-cms/ui";
11
+ import { Trash2, Edit3, MoreHorizontal, FileText, Image as ImageIconLucideHost, AlertCircle } from "lucide-react";
12
+ import Link from "next/link";
13
+ import {
14
+ DropdownMenu,
15
+ DropdownMenuContent,
16
+ DropdownMenuItem,
17
+ DropdownMenuTrigger,
18
+ DropdownMenuSeparator,
19
+ } from "@nextblock-cms/ui";
20
+ import MediaImage from "./MediaImage";
21
+ import DeleteMediaButtonClient from "./DeleteMediaButtonClient"; // For single item deletion
22
+ import { deleteMultipleMediaItems, moveSingleMediaItem } from "../actions"; // Server actions for bulk ops
23
+
24
+ interface MediaGridClientProps {
25
+ initialMediaItems: Media[];
26
+ r2BaseUrl: string;
27
+ }
28
+
29
+ interface MessageState {
30
+ type: "success" | "error";
31
+ text: string;
32
+ }
33
+
34
+ export default function MediaGridClient({ initialMediaItems, r2BaseUrl }: MediaGridClientProps) {
35
+ const [selectedItems, setSelectedItems] = useState<Array<{ id: string; objectKey: string }>>([]);
36
+ const [mediaItems, setMediaItems] = useState<Media[]>(initialMediaItems);
37
+ const [message, setMessage] = useState<MessageState | null>(null);
38
+ const [isPending, startTransition] = useTransition();
39
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
40
+ const [moveFolder, setMoveFolder] = useState<string>("");
41
+ const [isMoving, setIsMoving] = useState(false);
42
+ const [moveProgress, setMoveProgress] = useState(0);
43
+ const [moveError, setMoveError] = useState<string | null>(null);
44
+
45
+ useEffect(() => {
46
+ setMediaItems(initialMediaItems);
47
+ }, [initialMediaItems]);
48
+
49
+ const handleSelectionChange = (itemId: string, objectKey: string, checked: boolean) => {
50
+ setSelectedItems((prevSelected) => {
51
+ if (checked) {
52
+ return [...prevSelected, { id: itemId, objectKey }];
53
+ } else {
54
+ return prevSelected.filter((item) => item.id !== itemId);
55
+ }
56
+ });
57
+ };
58
+
59
+ const handleBulkDelete = async () => {
60
+ if (selectedItems.length === 0) {
61
+ setMessage({ type: "error", text: "No items selected for deletion." });
62
+ return;
63
+ }
64
+
65
+ setMessage(null);
66
+ startTransition(async () => {
67
+ const result = await deleteMultipleMediaItems(selectedItems);
68
+ if (result.error) {
69
+ setMessage({ type: "error", text: result.error });
70
+ } else if (result.success) {
71
+ setMessage({ type: "success", text: result.success });
72
+ // The page should revalidate and fetch new mediaItems.
73
+ // For immediate UI update, we can filter out deleted items:
74
+ setMediaItems((prevItems) =>
75
+ prevItems.filter((item) => !selectedItems.find((selected) => selected.id === item.id))
76
+ );
77
+ setSelectedItems([]); // Clear selection
78
+ }
79
+ });
80
+ };
81
+
82
+ const beginBulkMove = () => {
83
+ if (selectedItems.length === 0) {
84
+ setMessage({ type: "error", text: "No items selected for move." });
85
+ return;
86
+ }
87
+ setMoveFolder("");
88
+ setMoveDialogOpen(true);
89
+ };
90
+
91
+ const confirmBulkMove = async () => {
92
+ const dest = moveFolder.trim();
93
+ if (!dest) {
94
+ setMoveError("Please enter a destination folder.");
95
+ return;
96
+ }
97
+ setMoveError(null);
98
+ setIsMoving(true);
99
+ setMoveProgress(0);
100
+ try {
101
+ const total = selectedItems.length;
102
+ let moved = 0;
103
+ let hadError = false;
104
+ for (const item of selectedItems) {
105
+ const res = await moveSingleMediaItem(item, dest);
106
+ if ((res as any)?.error) {
107
+ // Accumulate errors but keep going
108
+ hadError = true;
109
+ setMoveError((prev) => (prev ? prev + " | " : "") + (res as any).error);
110
+ } else {
111
+ // Update local list
112
+ setMediaItems((prev) => prev.map((m) => {
113
+ if (m.id !== item.id) return m;
114
+ const filename = m.object_key.substring(m.object_key.lastIndexOf('/') + 1);
115
+ const folder = ensureFolderSlash(dest);
116
+ return { ...m, object_key: `${folder}${filename}`, folder } as any;
117
+ }));
118
+ }
119
+ moved += 1;
120
+ setMoveProgress(Math.round((moved / total) * 100));
121
+ }
122
+ if (!hadError) setMessage({ type: "success", text: `Moved ${selectedItems.length} item(s).` });
123
+ setSelectedItems([]);
124
+ setMoveDialogOpen(false);
125
+ } finally {
126
+ setIsMoving(false);
127
+ }
128
+ };
129
+
130
+ const ensureFolderSlash = (f: string) => (f.endsWith('/') ? f : `${f}/`);
131
+
132
+ const isSelected = (itemId: string) => {
133
+ return selectedItems.some((item) => item.id === itemId);
134
+ };
135
+
136
+ return (
137
+ <div className="space-y-6">
138
+ {selectedItems.length > 0 && (
139
+ <div className="flex flex-col gap-3 p-4 border rounded-lg bg-card">
140
+ <div className="flex items-center justify-between">
141
+ <p className="text-sm font-medium">{selectedItems.length} item(s) selected</p>
142
+ <div className="flex items-center gap-2">
143
+ <Button onClick={beginBulkMove} disabled={isPending}>
144
+ Move Selected
145
+ </Button>
146
+ <Button
147
+ variant="destructive"
148
+ onClick={handleBulkDelete}
149
+ disabled={isPending}
150
+ >
151
+ <Trash2 className="mr-2 h-4 w-4" />
152
+ {isPending ? "Deleting..." : "Delete Selected"}
153
+ </Button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ )}
158
+
159
+ <Dialog open={moveDialogOpen} onOpenChange={(open) => { if (!isMoving) setMoveDialogOpen(open); }}>
160
+ <DialogContent>
161
+ <DialogHeader>
162
+ <DialogTitle>Move {selectedItems.length} item(s)</DialogTitle>
163
+ </DialogHeader>
164
+ <div className="space-y-3">
165
+ <input
166
+ type="text"
167
+ value={moveFolder}
168
+ onChange={(e) => setMoveFolder(e.target.value)}
169
+ placeholder="Destination folder e.g. uploads/images/"
170
+ className="w-full border rounded-md px-3 h-9"
171
+ disabled={isMoving}
172
+ />
173
+ {isMoving && (
174
+ <div className="space-y-2">
175
+ <Progress value={moveProgress} />
176
+ <p className="text-xs text-muted-foreground">Moving... {moveProgress}%</p>
177
+ </div>
178
+ )}
179
+ {moveError && <p className="text-sm text-red-600">{moveError}</p>}
180
+ </div>
181
+ <DialogFooter>
182
+ <Button variant="outline" onClick={() => setMoveDialogOpen(false)} disabled={isMoving}>Cancel</Button>
183
+ <Button onClick={confirmBulkMove} disabled={isMoving || selectedItems.length === 0}>Start Move</Button>
184
+ </DialogFooter>
185
+ </DialogContent>
186
+ </Dialog>
187
+
188
+ {message && (
189
+ <div
190
+ className={`p-4 rounded-md text-sm ${
191
+ message.type === "success"
192
+ ? "bg-green-100 border border-green-200 text-green-700"
193
+ : "bg-red-100 border border-red-200 text-red-700"
194
+ }`}
195
+ >
196
+ <div className="flex items-center">
197
+ {message.type === 'error' && <AlertCircle className="h-5 w-5 mr-2" />}
198
+ {message.text}
199
+ </div>
200
+ </div>
201
+ )}
202
+
203
+ {mediaItems.length === 0 && selectedItems.length === 0 ? (
204
+ <div className="text-center py-10 border rounded-lg mt-6">
205
+ <ImageIconLucideHost className="mx-auto h-12 w-12 text-muted-foreground" />
206
+ <h3 className="mt-2 text-sm font-medium text-foreground">No media found</h3>
207
+ <p className="mt-1 text-sm text-muted-foreground">
208
+ Upload some files to get started.
209
+ </p>
210
+ </div>
211
+ ) : (
212
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 mt-6">
213
+ {mediaItems.map((item) => (
214
+ <div
215
+ key={item.id}
216
+ className={`group relative border rounded-lg overflow-hidden shadow-sm aspect-square bg-muted/20 transition-all
217
+ ${isSelected(item.id) ? "ring-2 ring-primary ring-offset-2" : ""}`}
218
+ >
219
+ <div className="absolute top-2 left-2 z-10">
220
+ <Checkbox
221
+ id={`select-${item.id}`}
222
+ checked={isSelected(item.id)}
223
+ onCheckedChange={(checked) => {
224
+ handleSelectionChange(item.id, item.object_key, !!checked);
225
+ }}
226
+ aria-label={`Select ${item.file_name}`}
227
+ className="bg-white/70 hover:bg-white border-slate-400 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
228
+ />
229
+ </div>
230
+
231
+ {item.file_type?.startsWith("image/") ? (
232
+ <MediaImage
233
+ src={`${r2BaseUrl}/${item.object_key}`}
234
+ alt={item.description || item.file_name}
235
+ width={item.width || 500} // Default width if null
236
+ height={item.height || 500} // Default height if null
237
+ blurDataURL={item.blur_data_url || undefined}
238
+ className="h-full w-full object-contain transition-transform group-hover:scale-105"
239
+ />
240
+ ) : (
241
+ <div className="h-full w-full bg-muted flex flex-col items-center justify-center p-2">
242
+ <FileText className="h-12 w-12 text-muted-foreground mb-2" />
243
+ <p className="text-xs text-center text-muted-foreground truncate w-full" title={item.file_name}>
244
+ {item.file_name}
245
+ </p>
246
+ </div>
247
+ )}
248
+ <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2">
249
+ <div className="text-xs text-black truncate pt-1 ps-5" title={item.file_name}>{item.file_name}</div>
250
+ <div className="self-end">
251
+ <DropdownMenu>
252
+ <DropdownMenuTrigger asChild>
253
+ <Button variant="secondary" size="icon" className="text-white bg-black/40 hover:bg-black/60 h-7 w-7 rounded-full">
254
+ <MoreHorizontal className="h-4 w-4" />
255
+ </Button>
256
+ </DropdownMenuTrigger>
257
+ <DropdownMenuContent align="end">
258
+ <DropdownMenuItem asChild>
259
+ <Link href={`/cms/media/${item.id}/edit`} className="flex items-center cursor-pointer">
260
+ <Edit3 className="mr-2 h-4 w-4" /> Edit Details
261
+ </Link>
262
+ </DropdownMenuItem>
263
+ <DropdownMenuSeparator />
264
+ <DeleteMediaButtonClient mediaItem={item} />
265
+ </DropdownMenuContent>
266
+ </DropdownMenu>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ ))}
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ }
@@ -0,0 +1,70 @@
1
+ // app/cms/media/components/MediaImage.tsx
2
+ "use client";
3
+
4
+ import React from 'react'
5
+ import Image from 'next/image'
6
+ import { cn } from '@nextblock-cms/utils'
7
+
8
+ interface MediaImageProps {
9
+ src: string
10
+ alt: string
11
+ width?: number | null
12
+ height?: number | null
13
+ blurDataURL?: string | null
14
+ className?: string
15
+ priority?: boolean
16
+ }
17
+
18
+ const MediaImage: React.FC<MediaImageProps> = ({
19
+ src,
20
+ alt,
21
+ width,
22
+ height,
23
+ blurDataURL,
24
+ className,
25
+ priority = false,
26
+ }) => {
27
+ const isValid = src && width && height && width > 0 && height > 0
28
+
29
+ if (!isValid) {
30
+ const placeholderWidth = typeof width === 'number' && width > 0 ? width : 100
31
+ const placeholderHeight =
32
+ typeof height === 'number' && height > 0 ? height : 100
33
+
34
+ const hasSizeClass = className?.includes('w-') || className?.includes('h-')
35
+
36
+ return (
37
+ <div
38
+ className={cn(
39
+ 'bg-muted text-muted-foreground flex items-center justify-center',
40
+ className,
41
+ )}
42
+ style={
43
+ !hasSizeClass
44
+ ? {
45
+ width: placeholderWidth,
46
+ height: placeholderHeight,
47
+ }
48
+ : {}
49
+ }
50
+ >
51
+ Invalid Image
52
+ </div>
53
+ )
54
+ }
55
+
56
+ return (
57
+ <Image
58
+ src={src}
59
+ alt={alt}
60
+ width={width}
61
+ height={height}
62
+ className={className}
63
+ placeholder={blurDataURL ? 'blur' : 'empty'}
64
+ blurDataURL={blurDataURL || undefined}
65
+ priority={priority}
66
+ />
67
+ )
68
+ }
69
+
70
+ export default MediaImage
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
4
+ import type { Database } from "@nextblock-cms/db";
5
+ import { createClient as createBrowserClient } from "@nextblock-cms/db";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogTrigger,
12
+ DialogFooter,
13
+ DialogClose,
14
+ Button,
15
+ Input,
16
+ Separator,
17
+ } from "@nextblock-cms/ui";
18
+ import { Search, CheckCircle } from "lucide-react";
19
+ import Image from "next/image";
20
+ import MediaUploadForm from "./MediaUploadForm";
21
+
22
+ type Media = Database["public"]["Tables"]["media"]["Row"];
23
+
24
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
25
+
26
+ interface MediaPickerDialogProps {
27
+ triggerLabel?: string;
28
+ triggerVariant?: "default" | "outline" | "secondary" | "destructive" | "ghost";
29
+ onSelect: (media: Media) => void;
30
+ accept?: (m: Media) => boolean; // filter, e.g. only images
31
+ title?: string;
32
+ open?: boolean; onOpenChange?: (open: boolean) => void; hideTrigger?: boolean;
33
+ defaultFolder?: string; // optional folder to pre-populate upload
34
+ }
35
+
36
+ export default function MediaPickerDialog({
37
+ triggerLabel = "Select from Library",
38
+ triggerVariant = "outline",
39
+ onSelect,
40
+ accept,
41
+ title = "Select or Upload Media",
42
+ open,
43
+ onOpenChange,
44
+ hideTrigger,
45
+ defaultFolder,
46
+ }: MediaPickerDialogProps) {
47
+ const [internalOpen, setInternalOpen] = useState(false);
48
+ const isControlled = typeof open === "boolean";
49
+ const isOpen = isControlled ? (open as boolean) : internalOpen;
50
+ const setIsOpen = (v: boolean) => {
51
+ if (!isControlled) setInternalOpen(v);
52
+ onOpenChange?.(v);
53
+ };
54
+ const [searchTerm, setSearchTerm] = useState("");
55
+ const [isLoading, setIsLoading] = useState(false);
56
+ const [items, setItems] = useState<Media[]>([]);
57
+ const supabase = useMemo(() => createBrowserClient(), []);
58
+
59
+ const fetchLibrary = useCallback(async () => {
60
+ setIsLoading(true);
61
+ try {
62
+ let query = supabase
63
+ .from("media")
64
+ .select("*")
65
+ .order("created_at", { ascending: false })
66
+ .limit(50);
67
+ if (searchTerm) {
68
+ query = query.ilike("file_name", `%${searchTerm}%`);
69
+ }
70
+ const { data, error } = await query;
71
+ if (error) {
72
+ console.error("Error fetching media library:", error);
73
+ setItems([]);
74
+ } else {
75
+ setItems(data || []);
76
+ }
77
+ } finally {
78
+ setIsLoading(false);
79
+ }
80
+ }, [supabase, searchTerm]);
81
+
82
+ useEffect(() => {
83
+ if (isOpen) fetchLibrary();
84
+ }, [isOpen, fetchLibrary]);
85
+
86
+ const filtered = useMemo(() => {
87
+ return accept ? items.filter(accept) : items;
88
+ }, [items, accept]);
89
+
90
+ const handleSelect = (media: Media) => {
91
+ onSelect(media);
92
+ setIsOpen(false);
93
+ };
94
+
95
+ return (
96
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
97
+ {!hideTrigger && (
98
+ <DialogTrigger asChild>
99
+ <Button type="button" variant={triggerVariant} size="sm">
100
+ {triggerLabel}
101
+ </Button>
102
+ </DialogTrigger>
103
+ )}
104
+ <DialogContent className="sm:max-w-[650px] md:max-w-[800px] lg:max-w-[1000px] max-h-[90vh] flex flex-col">
105
+ <DialogHeader>
106
+ <DialogTitle>{title}</DialogTitle>
107
+ </DialogHeader>
108
+
109
+ <div className="p-1">
110
+ <MediaUploadForm
111
+ returnJustData={true}
112
+ defaultFolder={defaultFolder}
113
+ onUploadSuccess={(newMedia) => {
114
+ setItems((prev) => [newMedia, ...prev.filter((m) => m.id !== newMedia.id)]);
115
+ handleSelect(newMedia);
116
+ }}
117
+ />
118
+ </div>
119
+
120
+ <Separator className="my-4" />
121
+
122
+ <div className="flex flex-col flex-grow overflow-hidden">
123
+ <h3 className="text-lg font-medium mb-3 text-center">Or Select from Library</h3>
124
+ <div className="relative mb-2">
125
+ <Input
126
+ type="search"
127
+ placeholder="Search library..."
128
+ value={searchTerm}
129
+ onChange={(e) => setSearchTerm(e.target.value)}
130
+ className="pl-10"
131
+ />
132
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
133
+ </div>
134
+ {isLoading ? (
135
+ <div className="flex-grow flex items-center justify-center">
136
+ <p>Loading media...</p>
137
+ </div>
138
+ ) : filtered.length === 0 ? (
139
+ <div className="flex-grow flex items-center justify-center">
140
+ <p>No media found.</p>
141
+ </div>
142
+ ) : (
143
+ <div className="flex flex-wrap gap-3 overflow-y-auto min-h-0 pr-2 pb-2">
144
+ {filtered.map((media) => (
145
+ <button
146
+ key={media.id}
147
+ type="button"
148
+ className="relative aspect-square border rounded-md overflow-hidden group focus:outline-none focus:ring-2 focus:ring-primary min-w-0 w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6"
149
+ onClick={() => handleSelect(media)}
150
+ >
151
+ {media.file_type?.startsWith("image/") && typeof media.width === "number" && typeof media.height === "number" && media.width > 0 && media.height > 0 ? (
152
+ <>
153
+ <Image
154
+ src={`${R2_BASE_URL}/${media.object_key}`}
155
+ alt={media.description || media.file_name || "Media library image"}
156
+ width={media.width}
157
+ height={media.height}
158
+ className="absolute inset-0 w-full h-full object-cover"
159
+ placeholder={media.blur_data_url ? "blur" : "empty"}
160
+ blurDataURL={media.blur_data_url || undefined}
161
+ sizes="(max-width: 639px) 33vw, (max-width: 767px) 25vw, (max-width: 1023px) 20vw, 17vw"
162
+ />
163
+ <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity flex items-center justify-center">
164
+ <CheckCircle className="h-8 w-8 text-white" />
165
+ </div>
166
+ </>
167
+ ) : (
168
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground p-1 text-center">
169
+ Preview unavailable
170
+ </div>
171
+ )}
172
+ <p className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 truncate text-center">
173
+ {media.file_name}
174
+ </p>
175
+ </button>
176
+ ))}
177
+ </div>
178
+ )}
179
+ </div>
180
+ <DialogFooter className="mt-auto pt-4">
181
+ <DialogClose asChild>
182
+ <Button type="button" variant="outline">
183
+ Close
184
+ </Button>
185
+ </DialogClose>
186
+ </DialogFooter>
187
+ </DialogContent>
188
+ </Dialog>
189
+ );
190
+ }
191
+
192
+
193
+
194
+
195
+