@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,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
|
+
}
|