create-nextblock 0.15.10 → 0.16.2

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 (266) hide show
  1. package/docker-template/.dockerignore +1 -1
  2. package/libs/db/tsconfig.lib.json +3 -3
  3. package/libs/editor/tsconfig.lib.json +3 -3
  4. package/libs/ui/tsconfig.lib.json +3 -3
  5. package/libs/utils/tsconfig.json +3 -3
  6. package/package.json +50 -50
  7. package/project.json +19 -19
  8. package/templates/nextblock-template/.dockerignore +1 -1
  9. package/templates/nextblock-template/.swcrc +30 -30
  10. package/templates/nextblock-template/AGENTS.md +9 -9
  11. package/templates/nextblock-template/CLAUDE.md +1 -1
  12. package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
  13. package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
  14. package/templates/nextblock-template/app/.well-known/ucp/route.ts +16 -16
  15. package/templates/nextblock-template/app/ToasterProvider.tsx +26 -26
  16. package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
  17. package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -280
  18. package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -222
  19. package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -62
  20. package/templates/nextblock-template/app/actions/email.ts +241 -241
  21. package/templates/nextblock-template/app/actions/formActions.ts +245 -245
  22. package/templates/nextblock-template/app/actions/interactions.ts +489 -489
  23. package/templates/nextblock-template/app/actions/threadActions.ts +166 -166
  24. package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -210
  25. package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
  26. package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
  27. package/templates/nextblock-template/app/api/checkout/freemius/sync/route.ts +29 -29
  28. package/templates/nextblock-template/app/api/checkout/route.ts +162 -162
  29. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +3092 -2900
  30. package/templates/nextblock-template/app/api/mcp/route.ts +415 -415
  31. package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
  32. package/templates/nextblock-template/app/api/search/route.ts +43 -43
  33. package/templates/nextblock-template/app/api/view/route.ts +114 -114
  34. package/templates/nextblock-template/app/api/visual-editing/block-draft/route.ts +47 -47
  35. package/templates/nextblock-template/app/api/visual-editing/product-draft/route.ts +47 -47
  36. package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
  37. package/templates/nextblock-template/app/cart/page.tsx +7 -7
  38. package/templates/nextblock-template/app/checkout/UcpCartHydrator.tsx +20 -20
  39. package/templates/nextblock-template/app/checkout/page.tsx +57 -57
  40. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +558 -552
  41. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
  42. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +241 -241
  43. package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
  44. package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -304
  45. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +180 -3
  46. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
  47. package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -75
  48. package/templates/nextblock-template/app/cms/components/CortexAiActiveContext.tsx +23 -23
  49. package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
  50. package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +12 -3
  51. package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
  52. package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -58
  53. package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -62
  54. package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -542
  55. package/templates/nextblock-template/app/cms/coupons/[id]/edit/page.tsx +16 -16
  56. package/templates/nextblock-template/app/cms/coupons/page.tsx +16 -16
  57. package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
  58. package/templates/nextblock-template/app/cms/dashboard/components/DashboardComponents.tsx +200 -200
  59. package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -66
  60. package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -12
  61. package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -12
  62. package/templates/nextblock-template/app/cms/layout.tsx +101 -101
  63. package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
  64. package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
  65. package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
  66. package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
  67. package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -661
  68. package/templates/nextblock-template/app/cms/messages/actions.ts +404 -404
  69. package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -333
  70. package/templates/nextblock-template/app/cms/messages/page.tsx +87 -87
  71. package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -37
  72. package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
  73. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
  74. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +248 -5
  75. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
  76. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +240 -5
  77. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -370
  78. package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
  79. package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
  80. package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
  81. package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
  82. package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -332
  83. package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -498
  84. package/templates/nextblock-template/app/cms/revisions/service.ts +569 -569
  85. package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -304
  86. package/templates/nextblock-template/app/cms/settings/cortex-ai/CortexAiSettingsClient.tsx +948 -948
  87. package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +628 -628
  88. package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +230 -230
  89. package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -34
  90. package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
  91. package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
  92. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -227
  93. package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
  94. package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +276 -276
  95. package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
  96. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeEditor.tsx +382 -382
  97. package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -267
  98. package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -40
  99. package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -259
  100. package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
  101. package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
  102. package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
  103. package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
  104. package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
  105. package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
  106. package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
  107. package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
  108. package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
  109. package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
  110. package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -51
  111. package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
  112. package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
  113. package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
  114. package/templates/nextblock-template/app/layout.tsx +671 -671
  115. package/templates/nextblock-template/app/lib/seo.ts +319 -319
  116. package/templates/nextblock-template/app/lib/ucp/protocol.ts +190 -190
  117. package/templates/nextblock-template/app/lib/ucp/server.test.ts +56 -56
  118. package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -502
  119. package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
  120. package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
  121. package/templates/nextblock-template/app/profile/account-links.ts +22 -22
  122. package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
  123. package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
  124. package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
  125. package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
  126. package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
  127. package/templates/nextblock-template/app/providers.tsx +96 -96
  128. package/templates/nextblock-template/app/robots.ts +123 -0
  129. package/templates/nextblock-template/app/sitemap.ts +1 -1
  130. package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -164
  131. package/templates/nextblock-template/app/thread/[token]/route.ts +57 -57
  132. package/templates/nextblock-template/app/thread/layout.tsx +15 -15
  133. package/templates/nextblock-template/app/thread/page.tsx +98 -98
  134. package/templates/nextblock-template/app/ucp/v1/carts/[id]/cancel/route.ts +38 -38
  135. package/templates/nextblock-template/app/ucp/v1/carts/[id]/route.ts +68 -68
  136. package/templates/nextblock-template/app/ucp/v1/carts/route.ts +35 -35
  137. package/templates/nextblock-template/app/ucp/v1/catalog/lookup/route.ts +35 -35
  138. package/templates/nextblock-template/app/ucp/v1/catalog/product/route.ts +35 -35
  139. package/templates/nextblock-template/app/ucp/v1/catalog/search/route.ts +34 -34
  140. package/templates/nextblock-template/components/BlockRenderer.tsx +312 -312
  141. package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
  142. package/templates/nextblock-template/components/CartTranslator.tsx +210 -210
  143. package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -188
  144. package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
  145. package/templates/nextblock-template/components/DeferredCartTranslator.tsx +51 -51
  146. package/templates/nextblock-template/components/DeferredGlobalSearch.tsx +68 -68
  147. package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
  148. package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
  149. package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
  150. package/templates/nextblock-template/components/GlobalSearch.tsx +557 -557
  151. package/templates/nextblock-template/components/Header.tsx +38 -38
  152. package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
  153. package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
  154. package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -378
  155. package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -426
  156. package/templates/nextblock-template/components/SiteScripts.tsx +56 -56
  157. package/templates/nextblock-template/components/StaffReplies.tsx +102 -102
  158. package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
  159. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  160. package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +48 -48
  161. package/templates/nextblock-template/components/blocks/TestimonialBlock.tsx +9 -9
  162. package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
  163. package/templates/nextblock-template/components/blocks/publicRendererLoaders.ts +25 -25
  164. package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -92
  165. package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -18
  166. package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -20
  167. package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -25
  168. package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -385
  169. package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +24 -24
  170. package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -157
  171. package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -34
  172. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -612
  173. package/templates/nextblock-template/components/blocks/renderers/TestimonialBlockRenderer.tsx +57 -57
  174. package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +2 -2
  175. package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +2 -2
  176. package/templates/nextblock-template/components/blocks/types.ts +7 -7
  177. package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -32
  178. package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
  179. package/templates/nextblock-template/components/form-message.tsx +32 -32
  180. package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
  181. package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
  182. package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
  183. package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
  184. package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
  185. package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
  186. package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
  187. package/templates/nextblock-template/components/submit-button.tsx +23 -23
  188. package/templates/nextblock-template/components/theme-icon.tsx +78 -78
  189. package/templates/nextblock-template/components/theme-switcher.tsx +85 -85
  190. package/templates/nextblock-template/context/AuthContext.tsx +23 -23
  191. package/templates/nextblock-template/context/ThemeCatalogContext.tsx +44 -44
  192. package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
  193. package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +77 -77
  194. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +372 -372
  195. package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -309
  196. package/templates/nextblock-template/docs/README.md +42 -42
  197. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12506 -12506
  198. package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
  199. package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
  200. package/templates/nextblock-template/index.d.ts +7 -7
  201. package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
  202. package/templates/nextblock-template/lib/blocks/blockColors.test.ts +134 -134
  203. package/templates/nextblock-template/lib/blocks/blockColors.ts +175 -175
  204. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -761
  205. package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -20
  206. package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -64
  207. package/templates/nextblock-template/lib/cms/payments-reminder.test.ts +135 -0
  208. package/templates/nextblock-template/lib/cms/payments-reminder.ts +30 -23
  209. package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -42
  210. package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -162
  211. package/templates/nextblock-template/lib/config/email-settings.ts +323 -323
  212. package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -57
  213. package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
  214. package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
  215. package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -59
  216. package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -39
  217. package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -70
  218. package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -93
  219. package/templates/nextblock-template/lib/messages/thread-token.ts +157 -157
  220. package/templates/nextblock-template/lib/messages/threads.ts +579 -579
  221. package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
  222. package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -26
  223. package/templates/nextblock-template/lib/search/types.ts +27 -27
  224. package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
  225. package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
  226. package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
  227. package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
  228. package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
  229. package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
  230. package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
  231. package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
  232. package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
  233. package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
  234. package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
  235. package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
  236. package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
  237. package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
  238. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +177 -167
  239. package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -71
  240. package/templates/nextblock-template/lib/site-scripts/types.ts +46 -46
  241. package/templates/nextblock-template/lib/site-url.test.ts +89 -89
  242. package/templates/nextblock-template/lib/site-url.ts +102 -102
  243. package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -124
  244. package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -31
  245. package/templates/nextblock-template/lib/themes/tokens.ts +143 -143
  246. package/templates/nextblock-template/lib/visual-editing/draft-content.test.ts +105 -105
  247. package/templates/nextblock-template/lib/visual-editing/draft-route.test.ts +42 -42
  248. package/templates/nextblock-template/lib/visual-editing/edit-info.test.ts +143 -143
  249. package/templates/nextblock-template/lib/visual-editing/edit-info.ts +94 -94
  250. package/templates/nextblock-template/lib/visual-editing/product-drafts.test.ts +81 -81
  251. package/templates/nextblock-template/lib/zod-config.ts +5 -5
  252. package/templates/nextblock-template/next-env.d.ts +0 -1
  253. package/templates/nextblock-template/package.json +1 -1
  254. package/templates/nextblock-template/postcss.config.js +6 -6
  255. package/templates/nextblock-template/proxy.ts +240 -20
  256. package/templates/nextblock-template/scripts/backup.js +115 -115
  257. package/templates/nextblock-template/scripts/restore.js +385 -385
  258. package/templates/nextblock-template/scripts/validate-editor-block-schema.ts +112 -112
  259. package/templates/nextblock-template/tailwind.config.js +25 -25
  260. package/templates/nextblock-template/tools/build-migrate.mjs +102 -102
  261. package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
  262. package/templates/nextblock-template/tools/deploy-supabase.js +159 -159
  263. package/templates/nextblock-template/tools/lib/migrate-core.mjs +569 -569
  264. package/templates/nextblock-template/tools/update.mjs +1303 -1303
  265. package/tsconfig.base.json +3 -3
  266. package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
@@ -1,122 +1,122 @@
1
- "use client";
2
-
3
- import React, { useMemo, useState } from "react";
4
- import { useRouter, useSearchParams } from "next/navigation";
5
- import { Button } from "@nextblock-cms/ui";
6
-
7
- interface FolderTreeProps {
8
- folders: string[];
9
- basePath: string;
10
- selectedFolder?: string; // exact folder filter
11
- selectedPrefix?: string; // prefix filter (e.g., pages/)
12
- }
13
-
14
- type Group = {
15
- name: string; // top-level segment, e.g., 'pages'
16
- count: number; // number of folders under this group
17
- children: string[]; // immediate subfolders like 'pages/slug/'
18
- };
19
-
20
- export default function FolderTree({ folders, basePath, selectedFolder, selectedPrefix }: FolderTreeProps) {
21
- const router = useRouter();
22
- const searchParams = useSearchParams();
23
- const [expanded, setExpanded] = useState<Record<string, boolean>>({});
24
-
25
- const groups = useMemo<Group[]>(() => {
26
- const map = new Map<string, Set<string>>();
27
- folders.forEach((f) => {
28
- const parts = f.replace(/^\/+/, '').split('/').filter(Boolean);
29
- const top = parts[0] || '';
30
- const childPrefix = parts.length >= 2 ? `${top}/${parts[1]}/` : `${top}/`;
31
- if (!map.has(top)) {
32
- map.set(top, new Set<string>());
33
- }
34
- const childSet = map.get(top);
35
- if (childSet) {
36
- childSet.add(childPrefix);
37
- }
38
- });
39
- return Array.from(map.entries()).map(([name, children]) => ({
40
- name,
41
- count: children.size,
42
- children: Array.from(children).sort((a, b) => a.localeCompare(b)),
43
- })).sort((a, b) => a.name.localeCompare(b.name));
44
- }, [folders]);
45
-
46
- const apply = (params: Record<string, string | undefined>) => {
47
- const current = new URLSearchParams(Array.from(searchParams.entries()));
48
- if (params.folder !== undefined) {
49
- if (params.folder) current.set('folder', params.folder); else current.delete('folder');
50
- }
51
- if (params.folderPrefix !== undefined) {
52
- if (params.folderPrefix) current.set('folderPrefix', params.folderPrefix); else current.delete('folderPrefix');
53
- }
54
- const query = current.toString();
55
- router.push(`${basePath}${query ? `?${query}` : ''}`);
56
- };
57
-
58
- const isPrefixActive = (prefix: string) => selectedPrefix === prefix && !selectedFolder;
59
- const isFolderActive = (folder: string) => selectedFolder === folder;
60
-
61
- const topLevel = groups.map((g) => `${g.name}/`);
62
-
63
- return (
64
- <div className="flex flex-col gap-3">
65
- <div className="flex flex-wrap gap-2">
66
- <Button size="sm" variant={!selectedFolder && !selectedPrefix ? 'default' : 'outline'} onClick={() => apply({ folder: undefined, folderPrefix: undefined })}>All</Button>
67
- {topLevel.map((prefix) => (
68
- <Button
69
- key={prefix}
70
- size="sm"
71
- variant={isPrefixActive(prefix) ? 'default' : 'outline'}
72
- onClick={() => apply({ folder: undefined, folderPrefix: prefix })}
73
- title={`${prefix}*`}
74
- >
75
- {prefix}*
76
- </Button>
77
- ))}
78
- </div>
79
-
80
- {/* Collapsible children (first N by default) */}
81
- {groups.map((g) => {
82
- const prefix = `${g.name}/`;
83
- const open = !!expanded[prefix];
84
- const children = g.children;
85
- const limit = 10;
86
- const visible = open ? children : children.slice(0, limit);
87
- return (
88
- <div key={g.name} className="border rounded-md p-2">
89
- <div className="flex items-center justify-between">
90
- <div className="flex items-center gap-2">
91
- <span className="text-sm font-medium">{prefix}</span>
92
- <span className="text-xs text-muted-foreground">{g.count} subfolder(s)</span>
93
- </div>
94
- {g.count > limit && (
95
- <Button size="sm" variant="outline" onClick={() => setExpanded((e) => ({ ...e, [prefix]: !open }))}>
96
- {open ? 'Show less' : `Show all ${g.count}`}
97
- </Button>
98
- )}
99
- </div>
100
- <div className="mt-2 flex flex-wrap gap-2">
101
- {visible.map((child) => (
102
- <Button
103
- key={child}
104
- size="sm"
105
- variant={isFolderActive(child) ? 'default' : 'outline'}
106
- onClick={() => apply({ folder: child, folderPrefix: undefined })}
107
- title={child}
108
- >
109
- {child}
110
- </Button>
111
- ))}
112
- {visible.length === 0 && (
113
- <span className="text-xs text-muted-foreground">No subfolders yet</span>
114
- )}
115
- </div>
116
- </div>
117
- );
118
- })}
119
- </div>
120
- );
121
- }
122
-
1
+ "use client";
2
+
3
+ import React, { useMemo, useState } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { Button } from "@nextblock-cms/ui";
6
+
7
+ interface FolderTreeProps {
8
+ folders: string[];
9
+ basePath: string;
10
+ selectedFolder?: string; // exact folder filter
11
+ selectedPrefix?: string; // prefix filter (e.g., pages/)
12
+ }
13
+
14
+ type Group = {
15
+ name: string; // top-level segment, e.g., 'pages'
16
+ count: number; // number of folders under this group
17
+ children: string[]; // immediate subfolders like 'pages/slug/'
18
+ };
19
+
20
+ export default function FolderTree({ folders, basePath, selectedFolder, selectedPrefix }: FolderTreeProps) {
21
+ const router = useRouter();
22
+ const searchParams = useSearchParams();
23
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
24
+
25
+ const groups = useMemo<Group[]>(() => {
26
+ const map = new Map<string, Set<string>>();
27
+ folders.forEach((f) => {
28
+ const parts = f.replace(/^\/+/, '').split('/').filter(Boolean);
29
+ const top = parts[0] || '';
30
+ const childPrefix = parts.length >= 2 ? `${top}/${parts[1]}/` : `${top}/`;
31
+ if (!map.has(top)) {
32
+ map.set(top, new Set<string>());
33
+ }
34
+ const childSet = map.get(top);
35
+ if (childSet) {
36
+ childSet.add(childPrefix);
37
+ }
38
+ });
39
+ return Array.from(map.entries()).map(([name, children]) => ({
40
+ name,
41
+ count: children.size,
42
+ children: Array.from(children).sort((a, b) => a.localeCompare(b)),
43
+ })).sort((a, b) => a.name.localeCompare(b.name));
44
+ }, [folders]);
45
+
46
+ const apply = (params: Record<string, string | undefined>) => {
47
+ const current = new URLSearchParams(Array.from(searchParams.entries()));
48
+ if (params.folder !== undefined) {
49
+ if (params.folder) current.set('folder', params.folder); else current.delete('folder');
50
+ }
51
+ if (params.folderPrefix !== undefined) {
52
+ if (params.folderPrefix) current.set('folderPrefix', params.folderPrefix); else current.delete('folderPrefix');
53
+ }
54
+ const query = current.toString();
55
+ router.push(`${basePath}${query ? `?${query}` : ''}`);
56
+ };
57
+
58
+ const isPrefixActive = (prefix: string) => selectedPrefix === prefix && !selectedFolder;
59
+ const isFolderActive = (folder: string) => selectedFolder === folder;
60
+
61
+ const topLevel = groups.map((g) => `${g.name}/`);
62
+
63
+ return (
64
+ <div className="flex flex-col gap-3">
65
+ <div className="flex flex-wrap gap-2">
66
+ <Button size="sm" variant={!selectedFolder && !selectedPrefix ? 'default' : 'outline'} onClick={() => apply({ folder: undefined, folderPrefix: undefined })}>All</Button>
67
+ {topLevel.map((prefix) => (
68
+ <Button
69
+ key={prefix}
70
+ size="sm"
71
+ variant={isPrefixActive(prefix) ? 'default' : 'outline'}
72
+ onClick={() => apply({ folder: undefined, folderPrefix: prefix })}
73
+ title={`${prefix}*`}
74
+ >
75
+ {prefix}*
76
+ </Button>
77
+ ))}
78
+ </div>
79
+
80
+ {/* Collapsible children (first N by default) */}
81
+ {groups.map((g) => {
82
+ const prefix = `${g.name}/`;
83
+ const open = !!expanded[prefix];
84
+ const children = g.children;
85
+ const limit = 10;
86
+ const visible = open ? children : children.slice(0, limit);
87
+ return (
88
+ <div key={g.name} className="border rounded-md p-2">
89
+ <div className="flex items-center justify-between">
90
+ <div className="flex items-center gap-2">
91
+ <span className="text-sm font-medium">{prefix}</span>
92
+ <span className="text-xs text-muted-foreground">{g.count} subfolder(s)</span>
93
+ </div>
94
+ {g.count > limit && (
95
+ <Button size="sm" variant="outline" onClick={() => setExpanded((e) => ({ ...e, [prefix]: !open }))}>
96
+ {open ? 'Show less' : `Show all ${g.count}`}
97
+ </Button>
98
+ )}
99
+ </div>
100
+ <div className="mt-2 flex flex-wrap gap-2">
101
+ {visible.map((child) => (
102
+ <Button
103
+ key={child}
104
+ size="sm"
105
+ variant={isFolderActive(child) ? 'default' : 'outline'}
106
+ onClick={() => apply({ folder: child, folderPrefix: undefined })}
107
+ title={child}
108
+ >
109
+ {child}
110
+ </Button>
111
+ ))}
112
+ {visible.length === 0 && (
113
+ <span className="text-xs text-muted-foreground">No subfolders yet</span>
114
+ )}
115
+ </div>
116
+ </div>
117
+ );
118
+ })}
119
+ </div>
120
+ );
121
+ }
122
+
@@ -15,7 +15,67 @@ import { useHotkeys } from '../../../../hooks/use-hotkeys';
15
15
  import { resolveMediaUrl } from '../../../../lib/media/resolveMediaUrl';
16
16
 
17
17
  type Media = Database['public']['Tables']['media']['Row'];
18
- import { FileText } from 'lucide-react';
18
+ import { FileText, Loader2, Sparkles } from 'lucide-react';
19
+ import { toast } from 'sonner';
20
+ import { useCortexAiActive } from '../../components/CortexAiActiveContext';
21
+ import { buildCortexAiRequestHeaders } from '../../../../lib/cortex-ai/sandbox-headers';
22
+ import {
23
+ buildAltTextContext,
24
+ toAbsoluteImageUrl,
25
+ UNRESOLVABLE_IMAGE_URL_MESSAGE,
26
+ } from '../../../../lib/cortex-ai/alt-text-request';
27
+ // The label the upload pipeline gives the untouched original file among a row's
28
+ // `variants`. It currently lives in the email module because email was the first consumer
29
+ // that needed the non-AVIF original; the constant itself is about the media pipeline, not
30
+ // about email, so importing it here is reuse rather than a layering violation — and it is
31
+ // far better than a second copy of the string drifting away from the pipeline that writes it.
32
+ import { ORIGINAL_UPLOAD_VARIANT_LABEL } from '../../../../lib/email/branding-format';
33
+
34
+ /** The `variants` JSONB rows the upload pipeline writes (camelCase keys). */
35
+ interface MediaVariantRecord {
36
+ fileType?: string | null;
37
+ objectKey?: string | null;
38
+ variantLabel?: string | null;
39
+ }
40
+
41
+ /**
42
+ * Choose which stored rendition of this media row to hand the vision model.
43
+ *
44
+ * `media.object_key` points at an AVIF derivative — that is what the pipeline produces and
45
+ * what the site renders, because AVIF is small. It is the wrong thing to send to a vision
46
+ * API: the mainstream image-understanding endpoints accept JPEG, PNG, GIF and WebP, and
47
+ * simply reject AVIF. So the mid-size derivatives (`medium_avif`, `large_avif`), which
48
+ * would otherwise be the ideal choice on download size, are unusable for this call.
49
+ *
50
+ * That leaves the `original_uploaded` variant: the untouched file the operator actually
51
+ * uploaded, in whatever format their camera or design tool produced. The tradeoff is real
52
+ * and worth naming — it can be several megabytes where a mid-size AVIF would have been a
53
+ * few dozen kilobytes, so this call is heavier than it looks. It is accepted here because
54
+ * a heavy call that works beats a cheap one that returns "unsupported image format", and
55
+ * because alt text is generated once per asset, by hand, not on a hot path.
56
+ *
57
+ * When no original was kept (the seeded default assets have no variants) we fall back to
58
+ * the preview URL the form already computed, and let the route decide.
59
+ */
60
+ function pickAltTextSourceObjectKey(media: Media): string | null {
61
+ // `variants` is typed as the generic `Json`, so the cast goes through `unknown`: the
62
+ // shape is a runtime contract with the upload pipeline, not something the generated
63
+ // Supabase types can express, and every field is re-checked below before it is used.
64
+ const variants = Array.isArray(media.variants)
65
+ ? (media.variants as unknown as MediaVariantRecord[])
66
+ : [];
67
+
68
+ const original = variants.find(
69
+ (variant) =>
70
+ variant &&
71
+ typeof variant === 'object' &&
72
+ variant.variantLabel === ORIGINAL_UPLOAD_VARIANT_LABEL &&
73
+ typeof variant.objectKey === 'string' &&
74
+ variant.objectKey.length > 0
75
+ );
76
+
77
+ return original?.objectKey ?? null;
78
+ }
19
79
 
20
80
  interface MediaEditFormProps {
21
81
  mediaItem: Media;
@@ -33,6 +93,12 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
33
93
  const [description, setDescription] = useState(mediaItem.description || "");
34
94
  const [formMessage, setFormMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
35
95
 
96
+ // Cortex AI is premium; hide the affordance entirely when the package is not activated
97
+ // rather than letting the operator click into a 403.
98
+ const isCortexAiActive = useCortexAiActive();
99
+ const [isGeneratingAltText, setIsGeneratingAltText] = useState(false);
100
+ const [altTextError, setAltTextError] = useState<string | null>(null);
101
+
36
102
  useEffect(() => {
37
103
  const successMessage = searchParams.get('success');
38
104
  const errorMessage = searchParams.get('error');
@@ -78,6 +144,86 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
78
144
  const formRef = React.useRef<HTMLFormElement>(null);
79
145
  useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
80
146
  const previewUrl = resolveMediaUrl(mediaItem.file_path || mediaItem.object_key);
147
+ const isImage = Boolean(mediaItem.file_type?.startsWith("image/"));
148
+
149
+ /**
150
+ * Draft this asset's alt text with Cortex AI.
151
+ *
152
+ * `media.description` IS the alt text in this schema — there is no `alt_text` column, and
153
+ * every consumer (the image block, the feature-image renderer, the media picker) already
154
+ * reads `description` as alt. So the generated string is written into the existing
155
+ * `description` state and nothing else changes: persistence still runs through the same
156
+ * `updateMediaItem(mediaId, { description })` submit path as a hand-typed value, and the
157
+ * operator still has to press Save. Generating is a draft, not a write.
158
+ *
159
+ * The URL handed to the route must be absolute http(s), because the route does not
160
+ * receive image bytes — it receives a URL the AI SDK then downloads server-side.
161
+ * `resolveMediaUrl()` returns a relative `/${objectKey}` whenever `NEXT_PUBLIC_R2_BASE_URL`
162
+ * is unset, which is the default for the native Supabase-storage backend, so on a stock
163
+ * install every asset would fail this call without the origin fix-up below.
164
+ */
165
+ const handleGenerateAltText = async () => {
166
+ if (!isImage || isGeneratingAltText) {
167
+ return;
168
+ }
169
+
170
+ const sourceObjectKey = pickAltTextSourceObjectKey(mediaItem);
171
+ const imageUrl = toAbsoluteImageUrl(
172
+ sourceObjectKey ? resolveMediaUrl(sourceObjectKey) : previewUrl
173
+ );
174
+
175
+ if (!imageUrl) {
176
+ setAltTextError(UNRESOLVABLE_IMAGE_URL_MESSAGE);
177
+ return;
178
+ }
179
+
180
+ setAltTextError(null);
181
+ setIsGeneratingAltText(true);
182
+
183
+ try {
184
+ // The filename is the only context this screen has, and it is often genuinely
185
+ // informative ("2026-harvest-crew.jpg"); passing it costs nothing and sometimes
186
+ // rescues a description that would otherwise be generically correct and useless.
187
+ const context = buildAltTextContext(
188
+ `Media library asset named “${mediaItem.file_name}”.`
189
+ );
190
+
191
+ const response = await fetch('/api/ai/seo/alt-text', {
192
+ // The route's body schema is a `z.strictObject` of optionals, so an absent field
193
+ // must be omitted rather than sent as null — a null would fail validation outright.
194
+ body: JSON.stringify({
195
+ ...(context ? { context } : {}),
196
+ imageUrl,
197
+ }),
198
+ headers: buildCortexAiRequestHeaders(),
199
+ method: 'POST',
200
+ });
201
+
202
+ const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
203
+
204
+ if (!response.ok || !payload) {
205
+ const message =
206
+ payload && typeof payload['error'] === 'string'
207
+ ? (payload['error'] as string)
208
+ : 'Cortex AI could not describe this image.';
209
+ throw new Error(message);
210
+ }
211
+
212
+ const generated = typeof payload['altText'] === 'string' ? payload['altText'].trim() : '';
213
+ if (!generated) {
214
+ throw new Error('Cortex AI returned an empty description.');
215
+ }
216
+
217
+ setDescription(generated);
218
+ } catch (error) {
219
+ const message =
220
+ error instanceof Error ? error.message : 'Cortex AI could not describe this image.';
221
+ setAltTextError(message);
222
+ toast.error(message);
223
+ } finally {
224
+ setIsGeneratingAltText(false);
225
+ }
226
+ };
81
227
 
82
228
  return (
83
229
  <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -125,7 +271,29 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
125
271
  </div>
126
272
 
127
273
  <div>
128
- <Label htmlFor="description">Description (Alt Text for Images)</Label>
274
+ <div className="flex items-center justify-between gap-2">
275
+ <Label htmlFor="description">Description (Alt Text for Images)</Label>
276
+ {/* Only for images, and only on a premium install: a non-activated Cortex AI
277
+ must never render a button that cannot do anything. */}
278
+ {isCortexAiActive && isImage && (
279
+ <Button
280
+ type="button"
281
+ variant="outline"
282
+ size="sm"
283
+ className="h-7 gap-1.5 px-2 text-[11px]"
284
+ onClick={handleGenerateAltText}
285
+ disabled={isGeneratingAltText}
286
+ title="Describe this image with Cortex AI"
287
+ >
288
+ {isGeneratingAltText ? (
289
+ <Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
290
+ ) : (
291
+ <Sparkles aria-hidden="true" className="h-3.5 w-3.5 text-amber-500" />
292
+ )}
293
+ {isGeneratingAltText ? "Generating…" : "Generate with AI"}
294
+ </Button>
295
+ )}
296
+ </div>
129
297
  <Textarea
130
298
  id="description"
131
299
  name="description"
@@ -135,6 +303,13 @@ export default function MediaEditForm({ mediaItem, formAction }: MediaEditFormPr
135
303
  rows={4}
136
304
  placeholder="e.g., A vibrant sunset over a mountain range"
137
305
  />
306
+ {altTextError && <p className="text-xs text-red-500 mt-1">{altTextError}</p>}
307
+ {isCortexAiActive && isImage && (
308
+ <p className="text-xs text-muted-foreground mt-1">
309
+ Generated text lands in this field as a draft — review it, then press
310
+ “Update Media Info” to save.
311
+ </p>
312
+ )}
138
313
  </div>
139
314
 
140
315
  <div className="flex justify-end space-x-3 pt-4">
@@ -17,10 +17,10 @@ import {
17
17
  DropdownMenuTrigger,
18
18
  DropdownMenuSeparator,
19
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
- import { resolveMediaUrl } from "../../../../lib/media/resolveMediaUrl";
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
+ import { resolveMediaUrl } from "../../../../lib/media/resolveMediaUrl";
24
24
 
25
25
  interface MediaGridClientProps {
26
26
  initialMediaItems: Media[];
@@ -210,71 +210,71 @@ export default function MediaGridClient({ initialMediaItems, r2BaseUrl }: MediaG
210
210
  </p>
211
211
  </div>
212
212
  ) : (
213
- <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">
214
- {mediaItems.map((item) => {
215
- const imageUrl = resolveMediaUrl(item.file_path || item.object_key, r2BaseUrl);
216
-
217
- return (
218
- <div
219
- key={item.id}
220
- className={`group relative border rounded-lg overflow-hidden shadow-sm aspect-square bg-muted/20 transition-all
221
- ${isSelected(item.id) ? "ring-2 ring-primary ring-offset-2" : ""}`}
222
- >
223
- <div className="absolute top-2 left-2 z-10">
224
- <Checkbox
225
- id={`select-${item.id}`}
226
- checked={isSelected(item.id)}
227
- onCheckedChange={(checked) => {
228
- handleSelectionChange(item.id, item.object_key, !!checked);
229
- }}
230
- aria-label={`Select ${item.file_name}`}
231
- className="bg-white/70 hover:bg-white border-slate-400 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
232
- />
233
- </div>
234
-
235
- {item.file_type?.startsWith("image/") && imageUrl ? (
236
- <MediaImage
237
- src={imageUrl}
238
- alt={item.description || item.file_name}
239
- width={item.width || 500} // Default width if null
240
- height={item.height || 500} // Default height if null
241
- blurDataURL={item.blur_data_url || undefined}
242
- className="h-full w-full object-contain transition-transform group-hover:scale-105"
243
- />
244
- ) : (
245
- <div className="h-full w-full bg-muted flex flex-col items-center justify-center p-2">
246
- <FileText className="h-12 w-12 text-muted-foreground mb-2" />
247
- <p className="text-xs text-center text-muted-foreground truncate w-full" title={item.file_name}>
248
- {item.file_name}
249
- </p>
250
- </div>
251
- )}
252
- <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">
253
- <div className="text-xs text-black truncate pt-1 ps-5" title={item.file_name}>{item.file_name}</div>
254
- <div className="self-end">
255
- <DropdownMenu>
256
- <DropdownMenuTrigger asChild>
257
- <Button variant="secondary" size="icon" className="text-white bg-black/40 hover:bg-black/60 h-7 w-7 rounded-full">
258
- <MoreHorizontal className="h-4 w-4" />
259
- </Button>
260
- </DropdownMenuTrigger>
261
- <DropdownMenuContent align="end">
262
- <DropdownMenuItem asChild>
263
- <Link href={`/cms/media/${item.id}/edit`} className="flex items-center cursor-pointer">
264
- <Edit3 className="mr-2 h-4 w-4" /> Edit Details
265
- </Link>
266
- </DropdownMenuItem>
267
- <DropdownMenuSeparator />
268
- <DeleteMediaButtonClient mediaItem={item} />
269
- </DropdownMenuContent>
270
- </DropdownMenu>
271
- </div>
272
- </div>
273
- </div>
274
- );
275
- })}
276
- </div>
277
- )}
213
+ <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">
214
+ {mediaItems.map((item) => {
215
+ const imageUrl = resolveMediaUrl(item.file_path || item.object_key, r2BaseUrl);
216
+
217
+ return (
218
+ <div
219
+ key={item.id}
220
+ className={`group relative border rounded-lg overflow-hidden shadow-sm aspect-square bg-muted/20 transition-all
221
+ ${isSelected(item.id) ? "ring-2 ring-primary ring-offset-2" : ""}`}
222
+ >
223
+ <div className="absolute top-2 left-2 z-10">
224
+ <Checkbox
225
+ id={`select-${item.id}`}
226
+ checked={isSelected(item.id)}
227
+ onCheckedChange={(checked) => {
228
+ handleSelectionChange(item.id, item.object_key, !!checked);
229
+ }}
230
+ aria-label={`Select ${item.file_name}`}
231
+ className="bg-white/70 hover:bg-white border-slate-400 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
232
+ />
233
+ </div>
234
+
235
+ {item.file_type?.startsWith("image/") && imageUrl ? (
236
+ <MediaImage
237
+ src={imageUrl}
238
+ alt={item.description || item.file_name}
239
+ width={item.width || 500} // Default width if null
240
+ height={item.height || 500} // Default height if null
241
+ blurDataURL={item.blur_data_url || undefined}
242
+ className="h-full w-full object-contain transition-transform group-hover:scale-105"
243
+ />
244
+ ) : (
245
+ <div className="h-full w-full bg-muted flex flex-col items-center justify-center p-2">
246
+ <FileText className="h-12 w-12 text-muted-foreground mb-2" />
247
+ <p className="text-xs text-center text-muted-foreground truncate w-full" title={item.file_name}>
248
+ {item.file_name}
249
+ </p>
250
+ </div>
251
+ )}
252
+ <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">
253
+ <div className="text-xs text-black truncate pt-1 ps-5" title={item.file_name}>{item.file_name}</div>
254
+ <div className="self-end">
255
+ <DropdownMenu>
256
+ <DropdownMenuTrigger asChild>
257
+ <Button variant="secondary" size="icon" className="text-white bg-black/40 hover:bg-black/60 h-7 w-7 rounded-full">
258
+ <MoreHorizontal className="h-4 w-4" />
259
+ </Button>
260
+ </DropdownMenuTrigger>
261
+ <DropdownMenuContent align="end">
262
+ <DropdownMenuItem asChild>
263
+ <Link href={`/cms/media/${item.id}/edit`} className="flex items-center cursor-pointer">
264
+ <Edit3 className="mr-2 h-4 w-4" /> Edit Details
265
+ </Link>
266
+ </DropdownMenuItem>
267
+ <DropdownMenuSeparator />
268
+ <DeleteMediaButtonClient mediaItem={item} />
269
+ </DropdownMenuContent>
270
+ </DropdownMenu>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ );
275
+ })}
276
+ </div>
277
+ )}
278
278
  </div>
279
279
  );
280
280
  }