@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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +212 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- 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
|
+
<> · <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
|
+
<> · <span className="text-neutral-600">{browser.selectedAsset.filename}</span> · {formatFileSize(browser.selectedAsset.file_size)} · <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
|
+
}
|