@morphika/andami 0.1.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 (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +212 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. package/tsconfig.json +25 -0
@@ -0,0 +1,397 @@
1
+ "use client";
2
+
3
+ /**
4
+ * VirtualAssetGrid — Virtualized grid for large asset collections.
5
+ * Renders only visible rows + buffer for smooth scrolling with hundreds
6
+ * or thousands of assets. Zero external dependencies.
7
+ *
8
+ * Designed to sit inside an existing scrollable parent (overflow-y-auto).
9
+ * Finds the closest scrollable ancestor and observes its scroll position.
10
+ *
11
+ * Session 65: New file, replaces direct filteredAssets.map() in
12
+ * R2BrowserContent.
13
+ */
14
+
15
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
16
+ import type { RegisteredAsset } from "../../lib/sanity/types";
17
+ import { BREAKPOINTS } from "../../lib/builder/constants";
18
+
19
+ // ============================================
20
+ // Types
21
+ // ============================================
22
+
23
+ interface VirtualAssetGridProps {
24
+ assets: RegisteredAsset[];
25
+ selectedAsset: RegisteredAsset | null;
26
+ selectedAssets: RegisteredAsset[];
27
+ multiSelect: boolean;
28
+ setSelectedAsset: (asset: RegisteredAsset | null) => void;
29
+ setSelectedAssets?: (assets: RegisteredAsset[]) => void;
30
+ onDoubleClick: (asset: RegisteredAsset) => void;
31
+ onContextMenu?: (e: React.MouseEvent, asset: RegisteredAsset) => void;
32
+ renderThumbnail: (asset: RegisteredAsset) => React.ReactNode;
33
+ isImageType: (ext: string) => boolean;
34
+ }
35
+
36
+ // ============================================
37
+ // Constants
38
+ // ============================================
39
+
40
+ /** Grid gap in pixels (Tailwind gap-3 = 12px) */
41
+ const GRID_GAP = 12;
42
+
43
+ /** Extra height below the square thumbnail for filename label */
44
+ const LABEL_HEIGHT = 30;
45
+
46
+ /** Number of extra rows to render above/below the viewport */
47
+ const OVERSCAN = 3;
48
+
49
+ /**
50
+ * Threshold: only virtualize when assets exceed this count.
51
+ * Below this, render all items normally for simplicity.
52
+ */
53
+ const VIRTUALIZE_THRESHOLD = 50;
54
+
55
+ // ============================================
56
+ // Helpers
57
+ // ============================================
58
+
59
+ /** Find the closest scrollable ancestor of an element */
60
+ function findScrollParent(el: HTMLElement | null): HTMLElement | null {
61
+ let current = el?.parentElement;
62
+ while (current) {
63
+ const style = getComputedStyle(current);
64
+ if (
65
+ style.overflowY === "auto" ||
66
+ style.overflowY === "scroll" ||
67
+ style.overflow === "auto" ||
68
+ style.overflow === "scroll"
69
+ ) {
70
+ return current;
71
+ }
72
+ current = current.parentElement;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Compute column count from container width (matches Tailwind breakpoints) */
78
+ function getColumnCount(containerWidth: number): number {
79
+ if (containerWidth >= BREAKPOINTS.tablet) return 7; // lg:grid-cols-7
80
+ if (containerWidth >= BREAKPOINTS.mobileAnimation) return 5; // md:grid-cols-5
81
+ return 3; // grid-cols-3
82
+ }
83
+
84
+ // ============================================
85
+ // VirtualAssetGrid Component
86
+ // ============================================
87
+
88
+ export function VirtualAssetGrid({
89
+ assets,
90
+ selectedAsset,
91
+ selectedAssets,
92
+ multiSelect,
93
+ setSelectedAsset,
94
+ setSelectedAssets,
95
+ onDoubleClick,
96
+ onContextMenu,
97
+ renderThumbnail,
98
+ isImageType,
99
+ }: VirtualAssetGridProps) {
100
+ const containerRef = useRef<HTMLDivElement>(null);
101
+ const [containerWidth, setContainerWidth] = useState(0);
102
+ const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
103
+
104
+ // Below threshold: render everything, no virtualization
105
+ const shouldVirtualize = assets.length > VIRTUALIZE_THRESHOLD;
106
+
107
+ // Observe container width via ResizeObserver
108
+ useEffect(() => {
109
+ const el = containerRef.current;
110
+ if (!el) return;
111
+
112
+ const observer = new ResizeObserver((entries) => {
113
+ const entry = entries[0];
114
+ if (entry) setContainerWidth(entry.contentRect.width);
115
+ });
116
+ observer.observe(el);
117
+ return () => observer.disconnect();
118
+ }, []);
119
+
120
+ // Derived layout calculations
121
+ const columns = useMemo(() => getColumnCount(containerWidth), [containerWidth]);
122
+
123
+ const itemWidth = useMemo(
124
+ () => containerWidth > 0 ? (containerWidth - GRID_GAP * (columns - 1)) / columns : 0,
125
+ [containerWidth, columns]
126
+ );
127
+
128
+ const rowHeight = useMemo(
129
+ () => itemWidth + LABEL_HEIGHT + GRID_GAP,
130
+ [itemWidth]
131
+ );
132
+
133
+ const totalRows = useMemo(
134
+ () => Math.ceil(assets.length / columns),
135
+ [assets.length, columns]
136
+ );
137
+
138
+ const totalHeight = useMemo(
139
+ () => totalRows > 0 ? totalRows * rowHeight - GRID_GAP : 0,
140
+ [totalRows, rowHeight]
141
+ );
142
+
143
+ // Observe scroll position of the closest scrollable ancestor
144
+ useEffect(() => {
145
+ if (!shouldVirtualize) return;
146
+
147
+ const el = containerRef.current;
148
+ if (!el) return;
149
+
150
+ const scrollParent = findScrollParent(el);
151
+ if (!scrollParent) return;
152
+
153
+ let rafId = 0;
154
+
155
+ const updateVisibleRange = () => {
156
+ const parentRect = scrollParent.getBoundingClientRect();
157
+ const elRect = el.getBoundingClientRect();
158
+
159
+ // How far the grid top is above (negative) or below (positive) the viewport top
160
+ const offsetTop = elRect.top - parentRect.top;
161
+ const viewportHeight = parentRect.height;
162
+
163
+ if (rowHeight <= 0) return;
164
+
165
+ // First visible row
166
+ const firstVisible = Math.floor(Math.max(0, -offsetTop) / rowHeight);
167
+ // Last visible row
168
+ const lastVisible = Math.ceil(Math.max(0, -offsetTop + viewportHeight) / rowHeight);
169
+
170
+ const startRow = Math.max(0, firstVisible - OVERSCAN);
171
+ const endRow = Math.min(totalRows, lastVisible + OVERSCAN);
172
+
173
+ setVisibleRange({
174
+ start: startRow * columns,
175
+ end: endRow * columns,
176
+ });
177
+ };
178
+
179
+ const handleScroll = () => {
180
+ if (rafId) return;
181
+ rafId = requestAnimationFrame(() => {
182
+ updateVisibleRange();
183
+ rafId = 0;
184
+ });
185
+ };
186
+
187
+ // Initial calculation
188
+ updateVisibleRange();
189
+
190
+ scrollParent.addEventListener("scroll", handleScroll, { passive: true });
191
+ return () => {
192
+ scrollParent.removeEventListener("scroll", handleScroll);
193
+ if (rafId) cancelAnimationFrame(rafId);
194
+ };
195
+ }, [shouldVirtualize, rowHeight, totalRows, columns]);
196
+
197
+ const handleItemClick = useCallback(
198
+ (asset: RegisteredAsset) => {
199
+ if (multiSelect && setSelectedAssets) {
200
+ const isSelected = selectedAssets.some((a) => a._key === asset._key);
201
+ if (isSelected) {
202
+ setSelectedAssets(selectedAssets.filter((a) => a._key !== asset._key));
203
+ } else {
204
+ setSelectedAssets([...selectedAssets, asset]);
205
+ }
206
+ } else {
207
+ setSelectedAsset(asset);
208
+ }
209
+ },
210
+ [multiSelect, selectedAssets, setSelectedAssets, setSelectedAsset]
211
+ );
212
+
213
+ if (assets.length === 0) return null;
214
+
215
+ // Non-virtualized: render all items in a simple CSS grid
216
+ if (!shouldVirtualize) {
217
+ return (
218
+ <div
219
+ ref={containerRef}
220
+ className="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-3"
221
+ >
222
+ {assets.map((asset) => (
223
+ <AssetGridItem
224
+ key={asset._key}
225
+ asset={asset}
226
+ isSelected={
227
+ multiSelect
228
+ ? selectedAssets.some((a) => a._key === asset._key)
229
+ : selectedAsset?._key === asset._key
230
+ }
231
+ multiSelect={multiSelect}
232
+ onClick={handleItemClick}
233
+ onDoubleClick={onDoubleClick}
234
+ onContextMenu={onContextMenu}
235
+ renderThumbnail={renderThumbnail}
236
+ isImageType={isImageType}
237
+ />
238
+ ))}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ // Virtualized: absolute positioning within a fixed-height container
244
+ const startRow = Math.floor(visibleRange.start / columns);
245
+
246
+ return (
247
+ <div
248
+ ref={containerRef}
249
+ style={{ height: totalHeight, position: "relative" }}
250
+ >
251
+ <div
252
+ style={{
253
+ position: "absolute",
254
+ top: startRow * rowHeight,
255
+ left: 0,
256
+ right: 0,
257
+ }}
258
+ >
259
+ <div
260
+ className="grid gap-3"
261
+ style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
262
+ >
263
+ {assets.slice(visibleRange.start, visibleRange.end).map((asset) => (
264
+ <AssetGridItem
265
+ key={asset._key}
266
+ asset={asset}
267
+ isSelected={
268
+ multiSelect
269
+ ? selectedAssets.some((a) => a._key === asset._key)
270
+ : selectedAsset?._key === asset._key
271
+ }
272
+ multiSelect={multiSelect}
273
+ onClick={handleItemClick}
274
+ onDoubleClick={onDoubleClick}
275
+ onContextMenu={onContextMenu}
276
+ renderThumbnail={renderThumbnail}
277
+ isImageType={isImageType}
278
+ />
279
+ ))}
280
+ </div>
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ // ============================================
287
+ // AssetGridItem — Individual grid item
288
+ // ============================================
289
+
290
+ interface AssetGridItemProps {
291
+ asset: RegisteredAsset;
292
+ isSelected: boolean | undefined;
293
+ multiSelect: boolean;
294
+ onClick: (asset: RegisteredAsset) => void;
295
+ onDoubleClick: (asset: RegisteredAsset) => void;
296
+ onContextMenu?: (e: React.MouseEvent, asset: RegisteredAsset) => void;
297
+ renderThumbnail: (asset: RegisteredAsset) => React.ReactNode;
298
+ isImageType: (ext: string) => boolean;
299
+ }
300
+
301
+ function AssetGridItem({
302
+ asset,
303
+ isSelected,
304
+ multiSelect,
305
+ onClick,
306
+ onDoubleClick,
307
+ onContextMenu,
308
+ renderThumbnail,
309
+ isImageType,
310
+ }: AssetGridItemProps) {
311
+ return (
312
+ <button
313
+ onClick={() => onClick(asset)}
314
+ onDoubleClick={() => !multiSelect && onDoubleClick(asset)}
315
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, asset) : undefined}
316
+ className={`relative flex flex-col rounded-lg overflow-hidden transition-all ${
317
+ isSelected
318
+ ? "ring-2 ring-[#076bff] ring-offset-2 shadow-lg"
319
+ : "hover:shadow-md"
320
+ }`}
321
+ >
322
+ {/* Multi-select checkbox indicator */}
323
+ {multiSelect && (
324
+ <div
325
+ className={`absolute top-1.5 left-1.5 z-10 w-5 h-5 rounded flex items-center justify-center text-white text-[10px] font-bold transition-colors ${
326
+ isSelected ? "bg-[#076bff]" : "bg-black/30 border border-white/50"
327
+ }`}
328
+ >
329
+ {isSelected && (
330
+ <svg
331
+ width="12"
332
+ height="12"
333
+ viewBox="0 0 24 24"
334
+ fill="none"
335
+ stroke="currentColor"
336
+ strokeWidth="3"
337
+ >
338
+ <polyline points="20 6 9 17 4 12" />
339
+ </svg>
340
+ )}
341
+ </div>
342
+ )}
343
+ <div className="aspect-square bg-neutral-100 overflow-hidden relative">
344
+ {renderThumbnail(asset)}
345
+ {/* Thumbnail status badge — raster images only */}
346
+ {isImageType(asset.extension) && asset.extension !== "svg" && (
347
+ <div
348
+ className={`absolute bottom-1.5 right-1.5 w-4 h-4 rounded-full flex items-center justify-center backdrop-blur-sm ${
349
+ asset.has_thumbnail ? "bg-green-500/80" : "bg-amber-500/80"
350
+ }`}
351
+ title={
352
+ asset.has_thumbnail
353
+ ? "Thumbnail available"
354
+ : "No thumbnail — loading full resolution"
355
+ }
356
+ >
357
+ {asset.has_thumbnail ? (
358
+ <svg
359
+ width="9"
360
+ height="9"
361
+ viewBox="0 0 24 24"
362
+ fill="none"
363
+ stroke="white"
364
+ strokeWidth="3.5"
365
+ strokeLinecap="round"
366
+ strokeLinejoin="round"
367
+ >
368
+ <polyline points="20 6 9 17 4 12" />
369
+ </svg>
370
+ ) : (
371
+ <svg
372
+ width="9"
373
+ height="9"
374
+ viewBox="0 0 24 24"
375
+ fill="none"
376
+ stroke="white"
377
+ strokeWidth="3.5"
378
+ strokeLinecap="round"
379
+ strokeLinejoin="round"
380
+ >
381
+ <line x1="12" y1="8" x2="12" y2="12" />
382
+ <circle cx="12" cy="16" r="0.5" fill="white" />
383
+ </svg>
384
+ )}
385
+ </div>
386
+ )}
387
+ </div>
388
+ <div className="px-1 py-1.5 bg-white">
389
+ <p className="text-[11px] text-neutral-500 truncate text-center">
390
+ {asset.filename.length > 16
391
+ ? `...${asset.filename.slice(-14)}`
392
+ : asset.filename}
393
+ </p>
394
+ </div>
395
+ </button>
396
+ );
397
+ }
@@ -0,0 +1,178 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import type { RegisteredAsset } from "../../../lib/sanity/types";
6
+ import { useThumbStatus } from "../../../lib/contexts/ThumbStatusContext";
7
+ import type { AssetBrowserProps, AssetBrowserInlineProps } from "./types";
8
+ import { formatFileSize } from "./helpers";
9
+ import { R2BrowserContent } from "./R2BrowserContent";
10
+ import { useAssetBrowser } from "./useAssetBrowser";
11
+
12
+ // ============================================
13
+ // Modal AssetBrowser (used in page editor)
14
+ // ============================================
15
+
16
+ export default function AssetBrowser({
17
+ open,
18
+ onSelect,
19
+ onClose,
20
+ filterType = "all",
21
+ multiSelect = false,
22
+ onSelectMultiple,
23
+ }: AssetBrowserProps) {
24
+ const { refresh: refreshThumbStatus } = useThumbStatus();
25
+ const browser = useAssetBrowser(undefined, refreshThumbStatus);
26
+ const [selectedAssets, setSelectedAssets] = useState<RegisteredAsset[]>([]);
27
+ const modalRef = useRef<HTMLDivElement>(null);
28
+
29
+ // NOTE: We no longer use native stopPropagation on this modal div.
30
+ // That approach blocked React 18's event delegation (which runs on
31
+ // document.body for portals), preventing onChange from firing on inputs
32
+ // like the "create folder" text field. Instead, the builder's global
33
+ // keyboard handlers (page editor + BuilderCanvas) check for
34
+ // [data-asset-modal] via .closest() and bail early.
35
+
36
+ useEffect(() => {
37
+ if (open) {
38
+ browser.fetchAssets();
39
+ browser.checkProviderStatus();
40
+ browser.setSelectedAsset(null);
41
+ browser.setSearchQuery("");
42
+ setSelectedAssets([]);
43
+ }
44
+ // eslint-disable-next-line react-hooks/exhaustive-deps
45
+ }, [open]);
46
+
47
+ const handleSelect = () => {
48
+ if (multiSelect && onSelectMultiple) {
49
+ if (selectedAssets.length > 0) {
50
+ onSelectMultiple(selectedAssets.map((a) => a.path));
51
+ onClose();
52
+ }
53
+ } else if (browser.selectedAsset) {
54
+ onSelect(browser.selectedAsset.path);
55
+ onClose();
56
+ }
57
+ };
58
+
59
+ const handleDoubleClick = (asset: RegisteredAsset) => {
60
+ onSelect(asset.path); onClose();
61
+ };
62
+
63
+ if (!open) return null;
64
+
65
+ const hasSelection = multiSelect ? selectedAssets.length > 0 : !!browser.selectedAsset;
66
+ const selectionLabel = multiSelect && selectedAssets.length > 0
67
+ ? `${selectedAssets.length} image${selectedAssets.length !== 1 ? "s" : ""} selected`
68
+ : browser.selectedAsset
69
+ ? `${browser.selectedAsset.filename} · ${formatFileSize(browser.selectedAsset.file_size)}`
70
+ : "";
71
+
72
+ return createPortal(
73
+ <div
74
+ ref={modalRef}
75
+ data-asset-modal
76
+ className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
77
+ >
78
+ <form onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); }} className="contents">
79
+ <div className="bg-white rounded-xl shadow-2xl w-[1000px] max-w-[95vw] h-[650px] max-h-[90vh] flex flex-col overflow-hidden">
80
+ <R2BrowserContent
81
+ assets={browser.assets}
82
+ loading={browser.loading}
83
+ error={browser.error}
84
+ currentFolder={browser.currentFolder}
85
+ setCurrentFolder={browser.setCurrentFolder}
86
+ selectedAsset={browser.selectedAsset}
87
+ setSelectedAsset={browser.setSelectedAsset}
88
+ onRetry={browser.fetchAssets}
89
+ onDoubleClick={multiSelect ? undefined : handleDoubleClick}
90
+ filterType={filterType}
91
+ multiSelect={multiSelect}
92
+ selectedAssets={selectedAssets}
93
+ setSelectedAssets={setSelectedAssets}
94
+ uploading={browser.uploading}
95
+ onUpload={browser.handleUpload}
96
+ onClearUploadError={browser.clearUploadError}
97
+ searchQuery={browser.searchQuery}
98
+ setSearchQuery={browser.setSearchQuery}
99
+ onMutationComplete={refreshThumbStatus}
100
+ onFolderCreated={browser.addSyntheticFolder}
101
+ />
102
+
103
+ {/* Footer */}
104
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-200 bg-white">
105
+ <span className="text-xs text-neutral-400">
106
+ {browser.assets.length} file{browser.assets.length !== 1 ? "s" : ""}
107
+ {selectionLabel && (
108
+ <> &middot; <span className="text-neutral-600">{selectionLabel}</span></>
109
+ )}
110
+ </span>
111
+ <div className="flex gap-3">
112
+ <button
113
+ onClick={onClose}
114
+ className="text-xs px-5 py-2 text-neutral-500 hover:text-neutral-800 transition-colors"
115
+ >
116
+ Cancel
117
+ </button>
118
+ <button
119
+ onClick={handleSelect}
120
+ disabled={!hasSelection}
121
+ className="text-xs px-5 py-2 rounded-lg bg-neutral-200 text-neutral-400 transition-colors disabled:cursor-not-allowed enabled:bg-brand-accent-alt enabled:text-neutral-900 enabled:hover:bg-brand-accent"
122
+ >
123
+ Insert Media{multiSelect && selectedAssets.length > 1 ? ` (${selectedAssets.length})` : ""}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </form>
129
+ </div>,
130
+ document.body
131
+ );
132
+ }
133
+
134
+ // ============================================
135
+ // Inline AssetBrowser (used in /admin/storage)
136
+ // ============================================
137
+
138
+ export function AssetBrowserInline({ refreshKey, onScanComplete }: AssetBrowserInlineProps) {
139
+ const browser = useAssetBrowser(onScanComplete);
140
+
141
+ useEffect(() => {
142
+ browser.fetchAssets();
143
+ browser.checkProviderStatus();
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [refreshKey]);
146
+
147
+ return (
148
+ <div className="border border-neutral-200 rounded-xl bg-white overflow-hidden flex flex-col" style={{ height: "560px" }}>
149
+ <R2BrowserContent
150
+ assets={browser.assets}
151
+ loading={browser.loading}
152
+ error={browser.error}
153
+ currentFolder={browser.currentFolder}
154
+ setCurrentFolder={browser.setCurrentFolder}
155
+ selectedAsset={browser.selectedAsset}
156
+ setSelectedAsset={browser.setSelectedAsset}
157
+ onRetry={browser.fetchAssets}
158
+ filterType="all"
159
+ uploading={browser.uploading}
160
+ onUpload={browser.handleUpload}
161
+ onClearUploadError={browser.clearUploadError}
162
+ searchQuery={browser.searchQuery}
163
+ setSearchQuery={browser.setSearchQuery}
164
+ onFolderCreated={browser.addSyntheticFolder}
165
+ />
166
+
167
+ {/* Footer — info only, no insert/cancel */}
168
+ <div className="flex items-center px-5 py-3 border-t border-neutral-200 bg-white">
169
+ <span className="text-xs text-neutral-400">
170
+ {browser.assets.length} file{browser.assets.length !== 1 ? "s" : ""} in registry
171
+ {browser.selectedAsset && (
172
+ <> &middot; <span className="text-neutral-600">{browser.selectedAsset.filename}</span> &middot; {formatFileSize(browser.selectedAsset.file_size)} &middot; <span className="text-neutral-500 font-mono">{browser.selectedAsset.path}</span></>
173
+ )}
174
+ </span>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import type { RegisteredAsset } from "../../../lib/sanity/types";
5
+ import { isImageType, isVideoType, formatFileSize } from "./helpers";
6
+
7
+ export function FileLightbox({
8
+ asset,
9
+ resolveUrl,
10
+ onClose,
11
+ }: {
12
+ asset: RegisteredAsset;
13
+ resolveUrl: (path: string) => string;
14
+ onClose: () => void;
15
+ }) {
16
+ const [videoLoaded, setVideoLoaded] = useState(false);
17
+
18
+ useEffect(() => {
19
+ const handleKey = (e: KeyboardEvent) => {
20
+ if (e.key === "Escape") onClose();
21
+ };
22
+ window.addEventListener("keydown", handleKey);
23
+ return () => window.removeEventListener("keydown", handleKey);
24
+ }, [onClose]);
25
+
26
+ const isImage = isImageType(asset.extension);
27
+ const isVideo = isVideoType(asset.extension);
28
+ const url = resolveUrl(asset.path);
29
+ const sizeLabel = formatFileSize(asset.file_size);
30
+ const isLargeVideo = isVideo && (asset.file_size ?? 0) > 10 * 1024 * 1024; // >10 MB
31
+
32
+ return (
33
+ <div
34
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
35
+ onClick={onClose}
36
+ >
37
+ {/* Close button */}
38
+ <button
39
+ onClick={onClose}
40
+ className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors z-10"
41
+ >
42
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
43
+ <line x1="18" y1="6" x2="6" y2="18" />
44
+ <line x1="6" y1="6" x2="18" y2="18" />
45
+ </svg>
46
+ </button>
47
+
48
+ {/* File info */}
49
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/60 backdrop-blur-sm rounded-lg px-4 py-2 z-10">
50
+ <p className="text-xs text-white/80 text-center">
51
+ {asset.filename}
52
+ {sizeLabel ? ` · ${sizeLabel}` : ""}
53
+ </p>
54
+ </div>
55
+
56
+ {/* Content */}
57
+ <div
58
+ className="max-w-[90vw] max-h-[85vh] flex items-center justify-center"
59
+ onClick={(e) => e.stopPropagation()}
60
+ >
61
+ {isImage && (
62
+ // eslint-disable-next-line @next/next/no-img-element
63
+ // #12: SVGs rendered in <img> tags are sandboxed (no script execution).
64
+ // This is safe because <img> strips all interactive/scripting content.
65
+ <img
66
+ src={url}
67
+ alt={asset.filename}
68
+ className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
69
+ />
70
+ )}
71
+ {isVideo && (videoLoaded || !isLargeVideo) ? (
72
+ <video
73
+ src={url}
74
+ controls
75
+ autoPlay
76
+ muted
77
+ playsInline
78
+ preload="metadata"
79
+ className="max-w-full max-h-[85vh] rounded-lg shadow-2xl"
80
+ />
81
+ ) : isVideo ? (
82
+ /* Large video gate — show info + manual load button */
83
+ <div className="bg-neutral-900 rounded-xl p-10 flex flex-col items-center gap-5 max-w-sm">
84
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-500">
85
+ <polygon points="5 3 19 12 5 21 5 3" />
86
+ </svg>
87
+ <div className="text-center">
88
+ <p className="text-sm font-medium text-white">{asset.filename}</p>
89
+ <p className="text-xs text-neutral-400 mt-1">
90
+ {asset.extension.toUpperCase()} · {sizeLabel}
91
+ </p>
92
+ </div>
93
+ <button
94
+ onClick={() => setVideoLoaded(true)}
95
+ className="px-5 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-sm text-white transition-colors"
96
+ >
97
+ Load video ({sizeLabel})
98
+ </button>
99
+ </div>
100
+ ) : null}
101
+ {!isImage && !isVideo && (
102
+ <div className="bg-white rounded-xl p-12 flex flex-col items-center gap-4">
103
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-400">
104
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
105
+ <polyline points="14 2 14 8 20 8" />
106
+ </svg>
107
+ <div className="text-center">
108
+ <p className="text-sm font-medium text-neutral-700">{asset.filename}</p>
109
+ <p className="text-xs text-neutral-400 mt-1">{asset.extension.toUpperCase()} file · {sizeLabel}</p>
110
+ </div>
111
+ </div>
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ }