@morphika/webframe 0.1.0

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 +46 -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 +210 -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,88 @@
1
+ import type { RegisteredAsset } from "../../../lib/sanity/types";
2
+ import type { FolderNode } from "./types";
3
+
4
+ // ============================================
5
+ // Folder drag & drop helpers
6
+ // ============================================
7
+
8
+ /** Resolve a FileSystemEntry (file or directory) recursively into File objects with relative paths. */
9
+ export function readEntryAsFiles(entry: FileSystemEntry, basePath: string): Promise<{ file: File; relativePath: string }[]> {
10
+ return new Promise((resolve) => {
11
+ if (entry.isFile) {
12
+ (entry as FileSystemFileEntry).file(
13
+ (file) => resolve([{ file, relativePath: basePath }]),
14
+ () => resolve([]) // skip unreadable files
15
+ );
16
+ } else if (entry.isDirectory) {
17
+ const reader = (entry as FileSystemDirectoryEntry).createReader();
18
+ const allEntries: FileSystemEntry[] = [];
19
+
20
+ // readEntries returns batches of up to 100 — must loop until empty
21
+ const readBatch = () => {
22
+ reader.readEntries(
23
+ (batch) => {
24
+ if (batch.length === 0) {
25
+ // All entries read — recurse into each
26
+ Promise.all(
27
+ allEntries.map((child) =>
28
+ readEntryAsFiles(child, basePath ? `${basePath}/${child.name}` : child.name)
29
+ )
30
+ ).then((nested) => resolve(nested.flat()));
31
+ } else {
32
+ allEntries.push(...batch);
33
+ readBatch();
34
+ }
35
+ },
36
+ () => resolve([]) // skip unreadable directories
37
+ );
38
+ };
39
+ readBatch();
40
+ } else {
41
+ resolve([]);
42
+ }
43
+ });
44
+ }
45
+
46
+ // ============================================
47
+ // Helpers
48
+ // ============================================
49
+
50
+ export function formatFileSize(bytes: number | undefined): string {
51
+ if (!bytes) return "";
52
+ if (bytes > 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
53
+ if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`;
54
+ return `${bytes} B`;
55
+ }
56
+
57
+ export function isImageType(ext: string): boolean {
58
+ return ["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext);
59
+ }
60
+
61
+ export function isVideoType(ext: string): boolean {
62
+ return ["mp4", "webm", "mov"].includes(ext);
63
+ }
64
+
65
+ export function isFontType(ext: string): boolean {
66
+ return ["otf", "ttf", "woff", "woff2"].includes(ext);
67
+ }
68
+
69
+ export function buildFolderTree(assets: RegisteredAsset[]): FolderNode {
70
+ const root: FolderNode = { name: "Unsorted", path: "", children: new Map(), files: [] };
71
+
72
+ for (const asset of assets) {
73
+ const parts = asset.path.split("/");
74
+ parts.pop(); // remove filename
75
+ let current = root;
76
+ let currentPath = "";
77
+
78
+ for (const folder of parts) {
79
+ currentPath = currentPath ? `${currentPath}/${folder}` : folder;
80
+ if (!current.children.has(folder)) {
81
+ current.children.set(folder, { name: folder, path: currentPath, children: new Map(), files: [] });
82
+ }
83
+ current = current.children.get(folder)!;
84
+ }
85
+ current.files.push(asset);
86
+ }
87
+ return root;
88
+ }
@@ -0,0 +1 @@
1
+ export { default, AssetBrowserInline } from "./AssetBrowser";
@@ -0,0 +1,49 @@
1
+ import type { RegisteredAsset } from "../../../lib/sanity/types";
2
+
3
+ // ============================================
4
+ // Upload types & constants
5
+ // ============================================
6
+
7
+ export interface UploadingFile {
8
+ id: string;
9
+ file: File;
10
+ key: string; // R2 object key (folder/filename)
11
+ progress: number; // 0–100
12
+ status: "pending" | "uploading" | "registering" | "done" | "error";
13
+ error?: string;
14
+ }
15
+
16
+ export const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB per file
17
+ export const ALLOWED_EXTENSIONS = new Set([
18
+ "jpg", "jpeg", "png", "webp", "gif", "svg",
19
+ "mp4", "webm", "mov",
20
+ ]);
21
+
22
+ // ============================================
23
+ // Component props
24
+ // ============================================
25
+
26
+ export interface AssetBrowserProps {
27
+ open: boolean;
28
+ onSelect: (path: string) => void;
29
+ onClose: () => void;
30
+ filterType?: "image" | "video" | "all";
31
+ /** Enable multi-select mode: user can pick multiple assets at once */
32
+ multiSelect?: boolean;
33
+ /** Called with all selected paths when multiSelect is true */
34
+ onSelectMultiple?: (paths: string[]) => void;
35
+ }
36
+
37
+ export interface AssetBrowserInlineProps {
38
+ /** If provided, triggers a refetch when this value changes */
39
+ refreshKey?: number;
40
+ /** Called after a successful scan with the API response data */
41
+ onScanComplete?: (result: Record<string, unknown>) => void;
42
+ }
43
+
44
+ export interface FolderNode {
45
+ name: string;
46
+ path: string;
47
+ children: Map<string, FolderNode>;
48
+ files: RegisteredAsset[];
49
+ }
@@ -0,0 +1,344 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
4
+ import type { RegisteredAsset } from "../../../lib/sanity/types";
5
+ import { csrfHeaders } from "../../../lib/csrf-client";
6
+ import { generateThumbnail, isRasterImage, thumbKeyForPath } from "../../../lib/thumbnails/generate";
7
+ import type { UploadingFile } from "./types";
8
+ import { MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS } from "./types";
9
+
10
+ // ============================================
11
+ // Hook: shared asset fetching + scan logic
12
+ // ============================================
13
+
14
+ export function useAssetBrowser(onScanComplete?: (result: Record<string, unknown>) => void, onUploadComplete?: () => void) {
15
+ const [assets, setAssets] = useState<RegisteredAsset[]>([]);
16
+ const [loading, setLoading] = useState(false);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [seedUrl, setSeedUrl] = useState<string>("");
19
+ const [searchQuery, setSearchQuery] = useState("");
20
+ const [currentFolder, setCurrentFolder] = useState("");
21
+ const [selectedAsset, setSelectedAsset] = useState<RegisteredAsset | null>(null);
22
+ const [scanning, setScanning] = useState(false);
23
+ const [uploading, setUploading] = useState<UploadingFile[]>([]);
24
+ const [r2Available, setR2Available] = useState(false);
25
+ const uploadCleanupTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+
27
+ // Clean up pending timer on unmount
28
+ useEffect(() => {
29
+ return () => {
30
+ if (uploadCleanupTimer.current) clearTimeout(uploadCleanupTimer.current);
31
+ };
32
+ }, []);
33
+
34
+ const fetchAssets = useCallback(async () => {
35
+ setLoading(true);
36
+ setError(null);
37
+ try {
38
+ const res = await fetch("/api/admin/assets/registry");
39
+ if (!res.ok) throw new Error("Failed to load assets");
40
+ const data = await res.json();
41
+ setAssets(data.registry?.assets || []);
42
+ setSeedUrl(data.registry?.seed_url || "");
43
+ } catch (err) {
44
+ setError(err instanceof Error ? err.message : "Failed to load");
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ }, []);
49
+
50
+ // Check provider status
51
+ const checkProviderStatus = useCallback(async () => {
52
+ try {
53
+ const r2Res = await fetch("/api/admin/r2/status");
54
+ if (r2Res.ok) {
55
+ const data = await r2Res.json();
56
+ setR2Available(data.connected === true);
57
+ }
58
+ } catch {
59
+ // Best effort
60
+ }
61
+ }, []);
62
+
63
+ const handleScan = useCallback(async () => {
64
+ setScanning(true);
65
+ setError(null);
66
+ try {
67
+ const res = await fetch("/api/admin/assets/scan", { method: "POST", headers: { ...csrfHeaders() } });
68
+ const data = await res.json().catch(() => ({}));
69
+ if (!res.ok) {
70
+ throw new Error(data.error || `Scan failed (${res.status})`);
71
+ }
72
+ onScanComplete?.(data);
73
+ await fetchAssets();
74
+ } catch (err) {
75
+ setError(err instanceof Error ? err.message : "Scan failed");
76
+ } finally {
77
+ setScanning(false);
78
+ }
79
+ }, [fetchAssets, onScanComplete]);
80
+
81
+ /**
82
+ * Upload files to R2 via presigned URLs.
83
+ *
84
+ * Flow per file:
85
+ * 1. Request presigned PUT URL from server
86
+ * 2. PUT directly to R2 (XHR for progress tracking)
87
+ * 3. Register the asset in Sanity
88
+ * 4. Refresh the asset list
89
+ */
90
+ const handleUpload = useCallback(async (files: File[], targetFolder: string) => {
91
+ if (!files.length) return;
92
+
93
+ // Validate files — collect all errors before displaying
94
+ const validFiles: File[] = [];
95
+ const errors: string[] = [];
96
+ for (const file of files) {
97
+ const ext = file.name.split(".").pop()?.toLowerCase() || "";
98
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
99
+ errors.push(`"${file.name}" is not a supported file type.`);
100
+ continue;
101
+ }
102
+ if (file.size > MAX_UPLOAD_SIZE) {
103
+ errors.push(`"${file.name}" exceeds the 500 MB limit.`);
104
+ continue;
105
+ }
106
+ validFiles.push(file);
107
+ }
108
+
109
+ if (errors.length) setError(errors.join(" "));
110
+ if (!validFiles.length) return;
111
+
112
+ // Create upload entries
113
+ const uploads: UploadingFile[] = validFiles.map((file) => ({
114
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
115
+ file,
116
+ key: targetFolder ? `${targetFolder}/${file.name}` : file.name,
117
+ progress: 0,
118
+ status: "pending" as const,
119
+ }));
120
+
121
+ setUploading((prev) => [...prev, ...uploads]);
122
+
123
+ // Process uploads sequentially to avoid overwhelming the server
124
+ for (const upload of uploads) {
125
+ try {
126
+ // Step 0: Generate thumbnail for raster images (client-side, ~200ms)
127
+ let thumbBlob: Blob | null = null;
128
+ if (isRasterImage(upload.file.name)) {
129
+ try {
130
+ const thumbResult = await generateThumbnail(upload.file);
131
+ if (thumbResult) thumbBlob = thumbResult.blob;
132
+ } catch {
133
+ // Thumb generation failed — continue without thumbnail
134
+ console.warn(`[upload] Thumbnail generation failed for ${upload.file.name}`);
135
+ }
136
+ }
137
+
138
+ // Step 1: Get presigned URL(s)
139
+ setUploading((prev) =>
140
+ prev.map((u) => u.id === upload.id ? { ...u, status: "uploading" as const } : u)
141
+ );
142
+
143
+ // Build the thumb key: _thumbs/{folder}/{name_without_ext}.jpg
144
+ const thumbKey = thumbBlob
145
+ ? thumbKeyForPath(targetFolder ? `${targetFolder}/${upload.file.name}` : upload.file.name)
146
+ : null;
147
+ // thumbKey is e.g. "_thumbs/projects/hero.jpg" — split into folder + filename
148
+ const thumbFolder = thumbKey ? thumbKey.slice(0, thumbKey.lastIndexOf("/")) : null;
149
+ const thumbFilename = thumbKey ? thumbKey.slice(thumbKey.lastIndexOf("/") + 1) : null;
150
+
151
+ // Request presigned URLs in parallel (original + thumbnail if applicable)
152
+ const urlPromise = fetch("/api/admin/r2/upload-url", {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
155
+ body: JSON.stringify({
156
+ filename: upload.file.name,
157
+ folder: targetFolder,
158
+ contentType: upload.file.type || undefined,
159
+ }),
160
+ });
161
+
162
+ const thumbUrlPromise = thumbBlob && thumbFolder && thumbFilename
163
+ ? fetch("/api/admin/r2/upload-url", {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
166
+ body: JSON.stringify({
167
+ filename: thumbFilename,
168
+ folder: thumbFolder,
169
+ contentType: "image/jpeg",
170
+ }),
171
+ })
172
+ : null;
173
+
174
+ const [urlRes, thumbUrlRes] = await Promise.all([
175
+ urlPromise,
176
+ thumbUrlPromise,
177
+ ]);
178
+
179
+ if (!urlRes.ok) {
180
+ const urlErr = await urlRes.json().catch(() => ({}));
181
+ throw new Error(urlErr.error || `Failed to get upload URL (${urlRes.status})`);
182
+ }
183
+
184
+ const { uploadUrl, key } = await urlRes.json();
185
+
186
+ // Parse thumb presigned URL (best-effort — don't fail the original)
187
+ let thumbUploadUrl: string | null = null;
188
+ if (thumbUrlRes) {
189
+ if (thumbUrlRes.ok) {
190
+ const thumbData = await thumbUrlRes.json();
191
+ thumbUploadUrl = thumbData.uploadUrl;
192
+ } else {
193
+ console.warn(`[upload] Failed to get thumb upload URL for ${upload.file.name}`);
194
+ }
195
+ }
196
+
197
+ // Step 2: Upload original to R2 via XHR (progress tracking) + thumbnail in parallel
198
+ const originalUploadPromise = new Promise<void>((resolve, reject) => {
199
+ const xhr = new XMLHttpRequest();
200
+ xhr.open("PUT", uploadUrl, true);
201
+ xhr.setRequestHeader("Content-Type", upload.file.type || "application/octet-stream");
202
+
203
+ const onProgress = (e: ProgressEvent) => {
204
+ if (e.lengthComputable) {
205
+ const pct = Math.round((e.loaded / e.total) * 100);
206
+ setUploading((prev) =>
207
+ prev.map((u) => u.id === upload.id ? { ...u, progress: pct } : u)
208
+ );
209
+ }
210
+ };
211
+ const onLoad = () => {
212
+ cleanup();
213
+ if (xhr.status >= 200 && xhr.status < 300) {
214
+ resolve();
215
+ } else {
216
+ reject(new Error(`R2 upload failed (HTTP ${xhr.status})`));
217
+ }
218
+ };
219
+ const onError = () => { cleanup(); reject(new Error("Upload network error")); };
220
+ const onAbort = () => { cleanup(); reject(new Error("Upload cancelled")); };
221
+
222
+ const cleanup = () => {
223
+ xhr.upload.removeEventListener("progress", onProgress);
224
+ xhr.removeEventListener("load", onLoad);
225
+ xhr.removeEventListener("error", onError);
226
+ xhr.removeEventListener("abort", onAbort);
227
+ };
228
+
229
+ xhr.upload.addEventListener("progress", onProgress);
230
+ xhr.addEventListener("load", onLoad);
231
+ xhr.addEventListener("error", onError);
232
+ xhr.addEventListener("abort", onAbort);
233
+
234
+ xhr.send(upload.file);
235
+ });
236
+
237
+ // Thumbnail upload: fire-and-forget via simple fetch (no progress tracking)
238
+ let thumbUploadOk = false;
239
+ const thumbUploadPromise = thumbUploadUrl && thumbBlob
240
+ ? fetch(thumbUploadUrl, {
241
+ method: "PUT",
242
+ headers: { "Content-Type": "image/jpeg" },
243
+ body: thumbBlob,
244
+ })
245
+ .then((res) => { thumbUploadOk = res.ok; })
246
+ .catch(() => { thumbUploadOk = false; })
247
+ : Promise.resolve();
248
+
249
+ // Wait for both in parallel — original is required, thumb is best-effort
250
+ await Promise.all([originalUploadPromise, thumbUploadPromise]);
251
+
252
+ // Step 3: Register in Sanity (with has_thumbnail if thumb succeeded)
253
+ setUploading((prev) =>
254
+ prev.map((u) => u.id === upload.id ? { ...u, status: "registering" as const, progress: 100 } : u)
255
+ );
256
+
257
+ const regRes = await fetch("/api/admin/assets/register", {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
260
+ body: JSON.stringify({
261
+ key,
262
+ fileSize: upload.file.size,
263
+ contentType: upload.file.type || undefined,
264
+ ...(thumbUploadOk ? { has_thumbnail: true } : {}),
265
+ }),
266
+ });
267
+
268
+ if (!regRes.ok) {
269
+ const regErr = await regRes.json().catch(() => ({}));
270
+ throw new Error(regErr.error || "Failed to register asset");
271
+ }
272
+
273
+ // Step 4: Mark done
274
+ setUploading((prev) =>
275
+ prev.map((u) => u.id === upload.id ? { ...u, status: "done" as const } : u)
276
+ );
277
+
278
+ // Subtle warning if raster image but thumbnail failed
279
+ if (isRasterImage(upload.file.name) && !thumbUploadOk) {
280
+ console.warn(`[upload] Thumbnail not created for ${upload.file.name} — full resolution will be used`);
281
+ }
282
+ } catch (err) {
283
+ setUploading((prev) =>
284
+ prev.map((u) =>
285
+ u.id === upload.id
286
+ ? { ...u, status: "error" as const, error: err instanceof Error ? err.message : "Upload failed" }
287
+ : u
288
+ )
289
+ );
290
+ }
291
+ }
292
+
293
+ // Refresh assets after all uploads complete
294
+ await fetchAssets();
295
+
296
+ // Notify ThumbStatusProvider so builder badges update immediately
297
+ onUploadComplete?.();
298
+
299
+ // Clear completed uploads after a short delay (with cleanup on unmount)
300
+ if (uploadCleanupTimer.current) clearTimeout(uploadCleanupTimer.current);
301
+ uploadCleanupTimer.current = setTimeout(() => {
302
+ uploadCleanupTimer.current = null;
303
+ setUploading((prev) => prev.filter((u) => u.status !== "done"));
304
+ }, 2000);
305
+ }, [fetchAssets, onUploadComplete]);
306
+
307
+ const clearUploadError = useCallback((id: string) => {
308
+ setUploading((prev) => prev.filter((u) => u.id !== id));
309
+ }, []);
310
+
311
+ // Only expose active assets (exclude missing/deleted) — browser is a mirror of storage
312
+ const activeAssets = useMemo(() => assets.filter((a) => a.status !== "missing"), [assets]);
313
+
314
+ // Optimistically add a synthetic .folder entry so buildFolderTree sees the new folder
315
+ // immediately, without waiting for a full registry refresh.
316
+ const addSyntheticFolder = useCallback((folderPath: string) => {
317
+ const syntheticKey = `${folderPath}/.folder`;
318
+ setAssets((prev) => {
319
+ // Don't add if an asset already exists in this folder
320
+ if (prev.some((a) => a.path.startsWith(folderPath + "/"))) return prev;
321
+ return [
322
+ ...prev,
323
+ {
324
+ _key: `synthetic-${Date.now()}`,
325
+ path: syntheticKey,
326
+ filename: ".folder",
327
+ extension: "",
328
+ file_size: 0,
329
+ mime_type: "application/x-empty",
330
+ has_thumbnail: false,
331
+ status: "active",
332
+ } as RegisteredAsset,
333
+ ];
334
+ });
335
+ }, []);
336
+
337
+ return {
338
+ assets: activeAssets, loading, error, seedUrl, searchQuery, setSearchQuery,
339
+ currentFolder, setCurrentFolder, selectedAsset, setSelectedAsset,
340
+ scanning, fetchAssets, handleScan,
341
+ uploading, r2Available,
342
+ handleUpload, clearUploadError, checkProviderStatus, addSyntheticFolder,
343
+ };
344
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef } from "react";
4
+ import { readEntryAsFiles } from "./helpers";
5
+
6
+ // ============================================
7
+ // R2 Drag & Drop hook
8
+ // ============================================
9
+
10
+ export interface UseR2DragDropOptions {
11
+ currentFolder: string;
12
+ onUpload?: (files: File[], folder: string) => void | Promise<void>;
13
+ }
14
+
15
+ export function useR2DragDrop({ currentFolder, onUpload }: UseR2DragDropOptions) {
16
+ const [dragOver, setDragOver] = useState(false);
17
+ const dragCounterRef = useRef(0);
18
+
19
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
20
+ e.preventDefault();
21
+ e.stopPropagation();
22
+ dragCounterRef.current++;
23
+ if (e.dataTransfer?.types.includes("Files")) {
24
+ setDragOver(true);
25
+ }
26
+ }, []);
27
+
28
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
29
+ e.preventDefault();
30
+ e.stopPropagation();
31
+ dragCounterRef.current--;
32
+ if (dragCounterRef.current === 0) {
33
+ setDragOver(false);
34
+ }
35
+ }, []);
36
+
37
+ const handleDragOver = useCallback((e: React.DragEvent) => {
38
+ e.preventDefault();
39
+ e.stopPropagation();
40
+ }, []);
41
+
42
+ const handleDrop = useCallback(async (e: React.DragEvent) => {
43
+ e.preventDefault();
44
+ e.stopPropagation();
45
+ setDragOver(false);
46
+ dragCounterRef.current = 0;
47
+
48
+ if (!onUpload) return;
49
+
50
+ // Try webkitGetAsEntry() first — this detects directories vs files
51
+ const items = e.dataTransfer?.items;
52
+ if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === "function") {
53
+ const entries: FileSystemEntry[] = [];
54
+ for (let i = 0; i < items.length; i++) {
55
+ const entry = items[i].webkitGetAsEntry();
56
+ if (entry) entries.push(entry);
57
+ }
58
+
59
+ if (entries.length > 0) {
60
+ // Resolve directories recursively into flat file list with relative paths
61
+ const results = await Promise.all(
62
+ entries.map((entry) =>
63
+ readEntryAsFiles(entry, entry.isDirectory ? entry.name : "")
64
+ )
65
+ );
66
+ const flatFiles = results.flat();
67
+
68
+ if (flatFiles.length > 0) {
69
+ // Group files by their target subfolder so each group gets one handleUpload call.
70
+ const byFolder = new Map<string, File[]>();
71
+ for (const { file, relativePath } of flatFiles) {
72
+ const pathParts = relativePath.split("/");
73
+ pathParts.pop(); // remove filename → subfolder path
74
+ const subFolder = pathParts.join("/");
75
+ const targetFolder = subFolder
76
+ ? (currentFolder ? `${currentFolder}/${subFolder}` : subFolder)
77
+ : currentFolder;
78
+ const existing = byFolder.get(targetFolder) || [];
79
+ existing.push(file);
80
+ byFolder.set(targetFolder, existing);
81
+ }
82
+
83
+ // Upload each folder group sequentially to avoid race conditions
84
+ for (const [folder, files] of byFolder) {
85
+ await onUpload(files, folder);
86
+ }
87
+ return;
88
+ }
89
+ }
90
+ }
91
+
92
+ // Fallback: plain file drop (no webkitGetAsEntry support)
93
+ const files = Array.from(e.dataTransfer?.files || []);
94
+ if (files.length > 0) {
95
+ onUpload(files, currentFolder);
96
+ }
97
+ }, [onUpload, currentFolder]);
98
+
99
+ const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
100
+ if (!onUpload) return;
101
+ const files = Array.from(e.target.files || []);
102
+ if (files.length > 0) {
103
+ onUpload(files, currentFolder);
104
+ }
105
+ e.target.value = "";
106
+ }, [onUpload, currentFolder]);
107
+
108
+ return {
109
+ dragOver,
110
+ handleDragEnter,
111
+ handleDragLeave,
112
+ handleDragOver,
113
+ handleDrop,
114
+ handleFileInputChange,
115
+ };
116
+ }