@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,573 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { RegisteredAsset, AssetRegistry, RelinkLogEntry } from "../../../lib/sanity/types";
5
+ import { formatDate, formatBytes } from "../../../lib/format-utils";
6
+
7
+ function getStatusColor(status: string): string {
8
+ switch (status) {
9
+ case "active":
10
+ return "text-green-500";
11
+ case "missing":
12
+ return "text-red-500";
13
+ case "moved":
14
+ return "text-yellow-500";
15
+ case "new":
16
+ return "text-blue-400";
17
+ default:
18
+ return "text-neutral-500";
19
+ }
20
+ }
21
+
22
+ function getStatusLabel(status: string): string {
23
+ switch (status) {
24
+ case "active":
25
+ return "Active";
26
+ case "missing":
27
+ return "Missing";
28
+ case "moved":
29
+ return "Moved";
30
+ case "new":
31
+ return "New";
32
+ default:
33
+ return status;
34
+ }
35
+ }
36
+
37
+ // ============================================
38
+ // Page Component
39
+ // ============================================
40
+
41
+ export default function AdminAssetsPage() {
42
+ const [registry, setRegistry] = useState<AssetRegistry | null>(null);
43
+ const [loading, setLoading] = useState(true);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [scanning, setScanning] = useState(false);
46
+ const [healthChecking, setHealthChecking] = useState(false);
47
+ const [healthResult, setHealthResult] = useState<{
48
+ healthy_count: number;
49
+ missing_count: number;
50
+ } | null>(null);
51
+ const [searchQuery, setSearchQuery] = useState("");
52
+ const [statusFilter, setStatusFilter] = useState<string>("all");
53
+ const [typeFilter, setTypeFilter] = useState<string>("all");
54
+ const [selectedAsset, setSelectedAsset] = useState<RegisteredAsset | null>(null);
55
+ const [page, setPage] = useState(0);
56
+ const PAGE_SIZE = 50;
57
+
58
+ // Fetch registry
59
+ const fetchRegistry = useCallback(async () => {
60
+ try {
61
+ setError(null);
62
+ const res = await fetch("/api/admin/assets/registry");
63
+ if (!res.ok) throw new Error("Failed to load registry");
64
+ const data = await res.json();
65
+ setRegistry(data.registry);
66
+ } catch (err) {
67
+ setError(err instanceof Error ? err.message : "Failed to load");
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ fetchRegistry();
75
+ }, [fetchRegistry]);
76
+
77
+ // Scan
78
+ const handleScan = async () => {
79
+ setScanning(true);
80
+ setError(null);
81
+ try {
82
+ const res = await fetch("/api/admin/assets/scan", { method: "POST" });
83
+ const data = await res.json();
84
+ if (!res.ok) throw new Error(data.error || "Scan failed");
85
+ await fetchRegistry();
86
+ alert(
87
+ `Scan complete: ${data.scanned_count} files found, ${data.new_assets} new, ${data.missing_assets} missing`
88
+ );
89
+ } catch (err) {
90
+ setError(err instanceof Error ? err.message : "Scan failed");
91
+ } finally {
92
+ setScanning(false);
93
+ }
94
+ };
95
+
96
+ // Health check
97
+ const handleHealthCheck = async () => {
98
+ setHealthChecking(true);
99
+ setHealthResult(null);
100
+ try {
101
+ const res = await fetch("/api/admin/assets/health");
102
+ const data = await res.json();
103
+ if (!res.ok) throw new Error(data.error || "Health check failed");
104
+ setHealthResult({
105
+ healthy_count: data.healthy_count,
106
+ missing_count: data.missing_count,
107
+ });
108
+ await fetchRegistry();
109
+ } catch (err) {
110
+ setError(err instanceof Error ? err.message : "Health check failed");
111
+ } finally {
112
+ setHealthChecking(false);
113
+ }
114
+ };
115
+
116
+ // Filter assets
117
+ const filteredAssets = (registry?.assets || []).filter((asset) => {
118
+ if (statusFilter !== "all" && asset.status !== statusFilter) return false;
119
+ if (typeFilter !== "all") {
120
+ const imageExts = ["jpg", "jpeg", "png", "webp", "gif", "svg"];
121
+ const videoExts = ["mp4", "webm", "mov"];
122
+ if (typeFilter === "image" && !imageExts.includes(asset.extension))
123
+ return false;
124
+ if (typeFilter === "video" && !videoExts.includes(asset.extension))
125
+ return false;
126
+ }
127
+ if (searchQuery.trim()) {
128
+ const q = searchQuery.toLowerCase();
129
+ if (
130
+ !asset.filename.toLowerCase().includes(q) &&
131
+ !asset.path.toLowerCase().includes(q)
132
+ )
133
+ return false;
134
+ }
135
+ return true;
136
+ });
137
+
138
+ // Pagination
139
+ const totalPages = Math.ceil(filteredAssets.length / PAGE_SIZE);
140
+ const paginatedAssets = filteredAssets.slice(
141
+ page * PAGE_SIZE,
142
+ (page + 1) * PAGE_SIZE
143
+ );
144
+
145
+ // Stats
146
+ const stats = {
147
+ total: registry?.assets?.length || 0,
148
+ active: registry?.assets?.filter((a) => a.status === "active").length || 0,
149
+ missing: registry?.assets?.filter((a) => a.status === "missing").length || 0,
150
+ newAssets: registry?.assets?.filter((a) => a.status === "new").length || 0,
151
+ };
152
+
153
+ if (loading) {
154
+ return (
155
+ <div className="flex items-center justify-center py-20">
156
+ <span className="font-mono text-sm text-neutral-500 animate-pulse">
157
+ Loading asset registry...
158
+ </span>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ return (
164
+ <div className="space-y-6">
165
+ {/* Header */}
166
+ <div className="flex items-center justify-between">
167
+ <h1 className="font-mono text-lg uppercase tracking-wider text-neutral-900">
168
+ Assets
169
+ </h1>
170
+ <div className="flex gap-2">
171
+ <button
172
+ onClick={handleHealthCheck}
173
+ disabled={healthChecking}
174
+ className="font-mono text-[11px] px-3 py-1.5 rounded border border-neutral-300 text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors disabled:opacity-50"
175
+ >
176
+ {healthChecking ? "Checking..." : "🏥 Health Check"}
177
+ </button>
178
+ <button
179
+ onClick={handleScan}
180
+ disabled={scanning}
181
+ className="font-mono text-[11px] px-3 py-1.5 rounded bg-[#076bff] text-white hover:bg-[#076bff]/80 transition-colors disabled:opacity-50"
182
+ >
183
+ {scanning ? "Scanning..." : "🔄 Scan Storage"}
184
+ </button>
185
+ </div>
186
+ </div>
187
+
188
+ {/* Error banner */}
189
+ {error && (
190
+ <div className="p-3 rounded border border-red-200 bg-red-50">
191
+ <p className="font-mono text-xs text-red-700">{error}</p>
192
+ </div>
193
+ )}
194
+
195
+ {/* Health check result */}
196
+ {healthResult && (
197
+ <div className="p-3 rounded border border-neutral-200 bg-white">
198
+ <p className="font-mono text-xs text-neutral-700">
199
+ Health Check:&nbsp;
200
+ <span className="text-green-500">{healthResult.healthy_count} healthy</span>
201
+ {healthResult.missing_count > 0 && (
202
+ <span className="text-red-500">
203
+ &nbsp;/ {healthResult.missing_count} missing
204
+ </span>
205
+ )}
206
+ </p>
207
+ </div>
208
+ )}
209
+
210
+ {/* Stats cards */}
211
+ <div className="grid grid-cols-4 gap-3">
212
+ {[
213
+ { label: "Total", value: stats.total, color: "text-neutral-900" },
214
+ { label: "Active", value: stats.active, color: "text-green-500" },
215
+ { label: "Missing", value: stats.missing, color: "text-red-500" },
216
+ { label: "New", value: stats.newAssets, color: "text-blue-400" },
217
+ ].map((stat) => (
218
+ <div
219
+ key={stat.label}
220
+ className="p-3 rounded border border-neutral-200 bg-white shadow-sm"
221
+ >
222
+ <p className="font-mono text-[10px] text-neutral-400 uppercase mb-1">
223
+ {stat.label}
224
+ </p>
225
+ <p className={`font-mono text-xl ${stat.color}`}>{stat.value}</p>
226
+ </div>
227
+ ))}
228
+ </div>
229
+
230
+ {/* Seed info */}
231
+ {registry && (
232
+ <div className="p-3 rounded border border-neutral-200 bg-white space-y-1">
233
+ <p className="font-mono text-[10px] text-neutral-400 uppercase">
234
+ Seed URL
235
+ </p>
236
+ <p className="font-mono text-xs text-neutral-700 break-all">
237
+ {registry.seed_url || "Not configured"}
238
+ </p>
239
+ <p className="font-mono text-[10px] text-neutral-400">
240
+ Last scan: {formatDate(registry.last_scanned_at)}
241
+ {registry.scan_error && (
242
+ <span className="text-red-500 ml-2">
243
+ Error: {registry.scan_error}
244
+ </span>
245
+ )}
246
+ </p>
247
+ </div>
248
+ )}
249
+
250
+ {/* Filters + Search */}
251
+ <div className="flex items-center gap-3">
252
+ <input
253
+ type="text"
254
+ value={searchQuery}
255
+ onChange={(e) => {
256
+ setSearchQuery(e.target.value);
257
+ setPage(0);
258
+ }}
259
+ placeholder="Search assets..."
260
+ className="flex-1 rounded border border-neutral-200 bg-white px-3 py-1.5 font-mono text-xs text-neutral-900 focus:border-[#076bff] focus:outline-none shadow-sm"
261
+ />
262
+ <select
263
+ value={statusFilter}
264
+ onChange={(e) => {
265
+ setStatusFilter(e.target.value);
266
+ setPage(0);
267
+ }}
268
+ className="rounded border border-neutral-200 bg-white px-2 py-1.5 font-mono text-xs text-neutral-900 focus:border-[#076bff] focus:outline-none"
269
+ >
270
+ <option value="all">All statuses</option>
271
+ <option value="active">Active</option>
272
+ <option value="missing">Missing</option>
273
+ <option value="new">New</option>
274
+ <option value="moved">Moved</option>
275
+ </select>
276
+ <select
277
+ value={typeFilter}
278
+ onChange={(e) => {
279
+ setTypeFilter(e.target.value);
280
+ setPage(0);
281
+ }}
282
+ className="rounded border border-neutral-200 bg-white px-2 py-1.5 font-mono text-xs text-neutral-900 focus:border-[#076bff] focus:outline-none"
283
+ >
284
+ <option value="all">All types</option>
285
+ <option value="image">Images</option>
286
+ <option value="video">Videos</option>
287
+ </select>
288
+ </div>
289
+
290
+ {/* Asset table */}
291
+ <div className="border border-neutral-200 rounded overflow-hidden">
292
+ {/* Table header */}
293
+ <div className="grid grid-cols-[1fr_80px_80px_60px_100px] gap-2 px-3 py-2 bg-neutral-50 border-b border-neutral-200">
294
+ <span className="font-mono text-[10px] text-neutral-400 uppercase">
295
+ Path
296
+ </span>
297
+ <span className="font-mono text-[10px] text-neutral-400 uppercase">
298
+ Type
299
+ </span>
300
+ <span className="font-mono text-[10px] text-neutral-400 uppercase">
301
+ Size
302
+ </span>
303
+ <span className="font-mono text-[10px] text-neutral-400 uppercase">
304
+ Status
305
+ </span>
306
+ <span className="font-mono text-[10px] text-neutral-400 uppercase">
307
+ Checked
308
+ </span>
309
+ </div>
310
+
311
+ {/* Table body */}
312
+ {paginatedAssets.length === 0 && (
313
+ <div className="flex items-center justify-center py-10">
314
+ <span className="font-mono text-xs text-neutral-400">
315
+ {stats.total === 0
316
+ ? "No assets registered. Click Scan to discover files from storage."
317
+ : "No assets match your filters."}
318
+ </span>
319
+ </div>
320
+ )}
321
+
322
+ {paginatedAssets.map((asset) => (
323
+ <button
324
+ key={asset._key}
325
+ onClick={() =>
326
+ setSelectedAsset(
327
+ selectedAsset?._key === asset._key ? null : asset
328
+ )
329
+ }
330
+ className={`w-full grid grid-cols-[1fr_80px_80px_60px_100px] gap-2 px-3 py-2 border-b border-neutral-100 text-left transition-colors ${
331
+ selectedAsset?._key === asset._key
332
+ ? "bg-[#076bff]/5"
333
+ : "hover:bg-neutral-50"
334
+ }`}
335
+ >
336
+ <span className="font-mono text-[11px] text-neutral-700 truncate">
337
+ {asset.path}
338
+ </span>
339
+ <span className="font-mono text-[10px] text-neutral-500 uppercase">
340
+ {asset.extension}
341
+ </span>
342
+ <span className="font-mono text-[10px] text-neutral-500">
343
+ {formatBytes(asset.file_size)}
344
+ </span>
345
+ <span
346
+ className={`font-mono text-[10px] font-bold ${getStatusColor(asset.status)}`}
347
+ >
348
+ {getStatusLabel(asset.status)}
349
+ </span>
350
+ <span className="font-mono text-[9px] text-neutral-400">
351
+ {asset.last_checked_at
352
+ ? new Date(asset.last_checked_at).toLocaleDateString()
353
+ : "—"}
354
+ </span>
355
+ </button>
356
+ ))}
357
+ </div>
358
+
359
+ {/* Pagination */}
360
+ {totalPages > 1 && (
361
+ <div className="flex items-center justify-between">
362
+ <span className="font-mono text-[10px] text-neutral-400">
363
+ Showing {page * PAGE_SIZE + 1}–
364
+ {Math.min((page + 1) * PAGE_SIZE, filteredAssets.length)} of{" "}
365
+ {filteredAssets.length}
366
+ </span>
367
+ <div className="flex gap-1">
368
+ <button
369
+ onClick={() => setPage(Math.max(0, page - 1))}
370
+ disabled={page === 0}
371
+ className="font-mono text-[10px] px-2 py-1 rounded border border-neutral-300 text-neutral-500 hover:text-neutral-900 disabled:opacity-30"
372
+ >
373
+ Prev
374
+ </button>
375
+ {Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
376
+ <button
377
+ key={i}
378
+ onClick={() => setPage(i)}
379
+ className={`font-mono text-[10px] px-2 py-1 rounded border transition-colors ${
380
+ page === i
381
+ ? "border-[#076bff] bg-[#076bff]/10 text-[#076bff]"
382
+ : "border-neutral-300 text-neutral-500 hover:text-neutral-900"
383
+ }`}
384
+ >
385
+ {i + 1}
386
+ </button>
387
+ ))}
388
+ <button
389
+ onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
390
+ disabled={page >= totalPages - 1}
391
+ className="font-mono text-[10px] px-2 py-1 rounded border border-neutral-300 text-neutral-500 hover:text-neutral-900 disabled:opacity-30"
392
+ >
393
+ Next
394
+ </button>
395
+ </div>
396
+ </div>
397
+ )}
398
+
399
+ {/* Selected asset detail */}
400
+ {selectedAsset && (
401
+ <div className="p-4 rounded border border-neutral-200 bg-white space-y-3">
402
+ <div className="flex items-center justify-between">
403
+ <h3 className="font-mono text-sm text-neutral-900">Asset Details</h3>
404
+ <button
405
+ onClick={() => setSelectedAsset(null)}
406
+ className="text-neutral-400 hover:text-neutral-900"
407
+ >
408
+
409
+ </button>
410
+ </div>
411
+
412
+ <div className="grid grid-cols-2 gap-4">
413
+ <div className="space-y-2">
414
+ <div>
415
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
416
+ Full Path
417
+ </p>
418
+ <p className="font-mono text-xs text-neutral-700 break-all">
419
+ {selectedAsset.path}
420
+ </p>
421
+ </div>
422
+ <div>
423
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
424
+ Filename
425
+ </p>
426
+ <p className="font-mono text-xs text-neutral-900">
427
+ {selectedAsset.filename}
428
+ </p>
429
+ </div>
430
+ <div className="flex gap-4">
431
+ <div>
432
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
433
+ Size
434
+ </p>
435
+ <p className="font-mono text-xs text-neutral-700">
436
+ {formatBytes(selectedAsset.file_size)}
437
+ </p>
438
+ </div>
439
+ <div>
440
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
441
+ MIME Type
442
+ </p>
443
+ <p className="font-mono text-xs text-neutral-700">
444
+ {selectedAsset.mime_type || "—"}
445
+ </p>
446
+ </div>
447
+ </div>
448
+ {selectedAsset.width && selectedAsset.height && (
449
+ <div>
450
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
451
+ Dimensions
452
+ </p>
453
+ <p className="font-mono text-xs text-neutral-700">
454
+ {selectedAsset.width} × {selectedAsset.height}px
455
+ </p>
456
+ </div>
457
+ )}
458
+ <div>
459
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
460
+ Status
461
+ </p>
462
+ <p
463
+ className={`font-mono text-xs font-bold ${getStatusColor(selectedAsset.status)}`}
464
+ >
465
+ {getStatusLabel(selectedAsset.status)}
466
+ </p>
467
+ </div>
468
+ {selectedAsset.used_in && selectedAsset.used_in.length > 0 && (
469
+ <div>
470
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
471
+ Used in ({selectedAsset.used_in.length} documents)
472
+ </p>
473
+ <div className="flex flex-wrap gap-1 mt-1">
474
+ {selectedAsset.used_in.map((docId) => (
475
+ <span
476
+ key={docId}
477
+ className="font-mono text-[9px] text-neutral-600 bg-neutral-100 px-1.5 py-0.5 rounded"
478
+ >
479
+ {docId}
480
+ </span>
481
+ ))}
482
+ </div>
483
+ </div>
484
+ )}
485
+ {selectedAsset.file_hash && (
486
+ <div>
487
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
488
+ Content Hash
489
+ </p>
490
+ <p className="font-mono text-[9px] text-neutral-500 break-all">
491
+ {selectedAsset.file_hash}
492
+ </p>
493
+ </div>
494
+ )}
495
+ </div>
496
+
497
+ {/* Preview */}
498
+ <div className="flex items-center justify-center bg-neutral-50 rounded border border-neutral-200 min-h-[150px]">
499
+ {registry?.seed_url &&
500
+ ["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(
501
+ selectedAsset.extension
502
+ ) ? (
503
+ // eslint-disable-next-line @next/next/no-img-element
504
+ <img
505
+ src={`${registry.seed_url.replace(/\/$/, "")}/${selectedAsset.path}`}
506
+ alt={selectedAsset.filename}
507
+ className="max-w-full max-h-[300px] object-contain"
508
+ onError={(e) => {
509
+ (e.target as HTMLImageElement).style.display = "none";
510
+ }}
511
+ />
512
+ ) : (
513
+ <span className="text-4xl">
514
+ {["mp4", "webm", "mov"].includes(selectedAsset.extension)
515
+ ? "🎬"
516
+ : "📄"}
517
+ </span>
518
+ )}
519
+ </div>
520
+ </div>
521
+
522
+ {/* Resolved URL */}
523
+ {registry?.seed_url && (
524
+ <div>
525
+ <p className="font-mono text-[9px] text-neutral-400 uppercase">
526
+ Resolved URL
527
+ </p>
528
+ <p className="font-mono text-[10px] text-neutral-500 break-all">
529
+ {registry.seed_url.replace(/\/$/, "")}/{selectedAsset.path}
530
+ </p>
531
+ </div>
532
+ )}
533
+ </div>
534
+ )}
535
+
536
+ {/* Relink Log */}
537
+ {registry?.relink_log && registry.relink_log.length > 0 && (
538
+ <div className="space-y-2">
539
+ <h3 className="font-mono text-xs text-neutral-500 uppercase tracking-wider">
540
+ Relink History
541
+ </h3>
542
+ <div className="space-y-1">
543
+ {[...registry.relink_log].reverse().map((entry: RelinkLogEntry) => (
544
+ <div
545
+ key={entry._key}
546
+ className="p-2 rounded border border-neutral-200 bg-white"
547
+ >
548
+ <div className="flex items-center justify-between">
549
+ <span className="font-mono text-[10px] text-neutral-500">
550
+ {formatDate(entry.date)}
551
+ </span>
552
+ <span className="font-mono text-[10px]">
553
+ <span className="text-green-500">
554
+ {entry.assets_relinked} relinked
555
+ </span>
556
+ {entry.assets_missing > 0 && (
557
+ <span className="text-red-500 ml-2">
558
+ {entry.assets_missing} missing
559
+ </span>
560
+ )}
561
+ </span>
562
+ </div>
563
+ <p className="font-mono text-[9px] text-neutral-400 mt-1 truncate">
564
+ {entry.old_seed} → {entry.new_seed}
565
+ </p>
566
+ </div>
567
+ ))}
568
+ </div>
569
+ </div>
570
+ )}
571
+ </div>
572
+ );
573
+ }