@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.
- package/LICENSE +21 -0
- package/README.md +46 -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 +210 -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,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { FolderNode } from "./types";
|
|
5
|
+
|
|
6
|
+
export function FolderTreeItem({
|
|
7
|
+
node,
|
|
8
|
+
depth,
|
|
9
|
+
currentFolder,
|
|
10
|
+
onSelectFolder,
|
|
11
|
+
}: {
|
|
12
|
+
node: FolderNode;
|
|
13
|
+
depth: number;
|
|
14
|
+
currentFolder: string;
|
|
15
|
+
onSelectFolder: (path: string) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const [expanded, setExpanded] = useState(depth < 2);
|
|
18
|
+
const isActive = currentFolder === node.path;
|
|
19
|
+
const hasChildren = node.children.size > 0;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
<button
|
|
24
|
+
onClick={() => {
|
|
25
|
+
onSelectFolder(node.path);
|
|
26
|
+
if (hasChildren) setExpanded(!expanded);
|
|
27
|
+
}}
|
|
28
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors rounded-md ${
|
|
29
|
+
isActive
|
|
30
|
+
? "bg-neutral-200 text-neutral-900 font-medium"
|
|
31
|
+
: "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-800"
|
|
32
|
+
}`}
|
|
33
|
+
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
|
34
|
+
>
|
|
35
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className={isActive ? "text-neutral-700" : "text-neutral-400"}>
|
|
36
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
37
|
+
</svg>
|
|
38
|
+
<span className="text-xs truncate flex-1">{node.name}</span>
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
{expanded &&
|
|
42
|
+
Array.from(node.children.values())
|
|
43
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
44
|
+
.map((child) => (
|
|
45
|
+
<FolderTreeItem
|
|
46
|
+
key={child.path}
|
|
47
|
+
node={child}
|
|
48
|
+
depth={depth + 1}
|
|
49
|
+
currentFolder={currentFolder}
|
|
50
|
+
onSelectFolder={onSelectFolder}
|
|
51
|
+
/>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react";
|
|
4
|
+
import type { RegisteredAsset } from "../../../lib/sanity/types";
|
|
5
|
+
import { VirtualAssetGrid } from "../VirtualAssetGrid";
|
|
6
|
+
import type { UploadingFile } from "./types";
|
|
7
|
+
import { formatFileSize, isImageType, isVideoType, isFontType, buildFolderTree } from "./helpers";
|
|
8
|
+
import { FolderTreeItem } from "./FolderTreeItem";
|
|
9
|
+
import { VideoThumbnail } from "./VideoThumbnail";
|
|
10
|
+
import { FileLightbox } from "./FileLightbox";
|
|
11
|
+
import { useR2Operations } from "./useR2Operations";
|
|
12
|
+
import { useR2DragDrop } from "./useR2DragDrop";
|
|
13
|
+
import { R2ContextMenu, type ContextMenuState } from "./R2ContextMenu";
|
|
14
|
+
import { ADMIN_ACCENT, BUILDER_GREEN } from "../../../lib/builder/constants";
|
|
15
|
+
|
|
16
|
+
// ============================================
|
|
17
|
+
// R2 Browser — Composition shell
|
|
18
|
+
// ============================================
|
|
19
|
+
|
|
20
|
+
export function R2BrowserContent({
|
|
21
|
+
assets, loading, error, currentFolder, setCurrentFolder,
|
|
22
|
+
selectedAsset, setSelectedAsset, onRetry, onDoubleClick,
|
|
23
|
+
filterType = "all", multiSelect = false, selectedAssets = [],
|
|
24
|
+
setSelectedAssets, uploading = [], onUpload, onClearUploadError,
|
|
25
|
+
searchQuery, setSearchQuery, toggleSlot, onMutationComplete,
|
|
26
|
+
onFolderCreated,
|
|
27
|
+
}: {
|
|
28
|
+
assets: RegisteredAsset[];
|
|
29
|
+
loading: boolean;
|
|
30
|
+
error: string | null;
|
|
31
|
+
currentFolder: string;
|
|
32
|
+
setCurrentFolder: (f: string) => void;
|
|
33
|
+
selectedAsset: RegisteredAsset | null;
|
|
34
|
+
setSelectedAsset: (a: RegisteredAsset | null) => void;
|
|
35
|
+
onRetry: () => void;
|
|
36
|
+
onDoubleClick?: (asset: RegisteredAsset) => void;
|
|
37
|
+
filterType?: "image" | "video" | "all";
|
|
38
|
+
multiSelect?: boolean;
|
|
39
|
+
selectedAssets?: RegisteredAsset[];
|
|
40
|
+
setSelectedAssets?: (assets: RegisteredAsset[]) => void;
|
|
41
|
+
uploading?: UploadingFile[];
|
|
42
|
+
onUpload?: (files: File[], folder: string) => void | Promise<void>;
|
|
43
|
+
onClearUploadError?: (id: string) => void;
|
|
44
|
+
searchQuery: string;
|
|
45
|
+
setSearchQuery: (q: string) => void;
|
|
46
|
+
toggleSlot?: React.ReactNode;
|
|
47
|
+
onMutationComplete?: () => void;
|
|
48
|
+
onFolderCreated?: (folderPath: string) => void;
|
|
49
|
+
}) {
|
|
50
|
+
const [lightboxAsset, setLightboxAsset] = useState<RegisteredAsset | null>(null);
|
|
51
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
52
|
+
const newFolderInputRef = useRef<HTMLInputElement>(null);
|
|
53
|
+
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
54
|
+
|
|
55
|
+
// Hooks
|
|
56
|
+
const ops = useR2Operations({ onRetry, onMutationComplete, onFolderCreated, currentFolder });
|
|
57
|
+
|
|
58
|
+
// Focus the new folder input when it appears — uses rAF to ensure focus
|
|
59
|
+
// survives portal-inside-modal contexts where autoFocus can fail.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (ops.showNewFolderInput && newFolderInputRef.current) {
|
|
62
|
+
requestAnimationFrame(() => newFolderInputRef.current?.focus());
|
|
63
|
+
}
|
|
64
|
+
}, [ops.showNewFolderInput]);
|
|
65
|
+
|
|
66
|
+
// Same for rename input
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (ops.renameTarget && renameInputRef.current) {
|
|
69
|
+
requestAnimationFrame(() => renameInputRef.current?.focus());
|
|
70
|
+
}
|
|
71
|
+
}, [ops.renameTarget]);
|
|
72
|
+
|
|
73
|
+
const dnd = useR2DragDrop({ currentFolder, onUpload });
|
|
74
|
+
|
|
75
|
+
// Context menu handler
|
|
76
|
+
const handleContextMenu = useCallback((e: React.MouseEvent, asset?: RegisteredAsset, folder?: import("./types").FolderNode) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
setContextMenu({ x: e.clientX, y: e.clientY, asset, folder });
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
// Only show assets that actually exist in storage
|
|
83
|
+
const activeAssets = useMemo(() => assets.filter((a) => a.status !== "missing"), [assets]);
|
|
84
|
+
|
|
85
|
+
// Folder tree (built from active assets only — includes synthetic .folder entries)
|
|
86
|
+
const folderTree = useMemo(() => buildFolderTree(activeAssets), [activeAssets]);
|
|
87
|
+
|
|
88
|
+
// Filter assets for current view (hide .folder placeholders from file grid)
|
|
89
|
+
const filteredAssets = useMemo(() => {
|
|
90
|
+
let filtered = activeAssets.filter((a) => a.filename !== ".folder");
|
|
91
|
+
|
|
92
|
+
if (currentFolder) {
|
|
93
|
+
filtered = filtered.filter(
|
|
94
|
+
(a) => a.path.startsWith(currentFolder + "/") && !a.path.slice(currentFolder.length + 1).includes("/")
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
filtered = filtered.filter((a) => !a.path.includes("/"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (filterType === "image") filtered = filtered.filter((a) => isImageType(a.extension));
|
|
101
|
+
else if (filterType === "video") filtered = filtered.filter((a) => isVideoType(a.extension));
|
|
102
|
+
|
|
103
|
+
if (searchQuery.trim()) {
|
|
104
|
+
const q = searchQuery.trim().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
105
|
+
filtered = activeAssets.filter(
|
|
106
|
+
(a) => {
|
|
107
|
+
const nameNorm = a.filename.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
108
|
+
const pathNorm = a.path.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
109
|
+
return a.filename !== ".folder" && (nameNorm.includes(q) || pathNorm.includes(q)) &&
|
|
110
|
+
(filterType === "all" || (filterType === "image" ? isImageType(a.extension) : isVideoType(a.extension)));
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return filtered;
|
|
116
|
+
}, [activeAssets, currentFolder, filterType, searchQuery]);
|
|
117
|
+
|
|
118
|
+
// Subfolders
|
|
119
|
+
const currentSubfolders = useMemo(() => {
|
|
120
|
+
if (searchQuery) return [];
|
|
121
|
+
if (!currentFolder) return Array.from(folderTree.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
122
|
+
const parts = currentFolder.split("/");
|
|
123
|
+
let node = folderTree;
|
|
124
|
+
for (const part of parts) {
|
|
125
|
+
const child = node.children.get(part);
|
|
126
|
+
if (!child) return [];
|
|
127
|
+
node = child;
|
|
128
|
+
}
|
|
129
|
+
return Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
130
|
+
}, [folderTree, currentFolder, searchQuery]);
|
|
131
|
+
|
|
132
|
+
// Breadcrumbs
|
|
133
|
+
const breadcrumbs = useMemo(() => {
|
|
134
|
+
if (!currentFolder) return [{ name: "All Files", path: "" }];
|
|
135
|
+
const parts = currentFolder.split("/");
|
|
136
|
+
const crumbs = [{ name: "All Files", path: "" }];
|
|
137
|
+
let path = "";
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
path = path ? `${path}/${part}` : part;
|
|
140
|
+
crumbs.push({ name: part, path });
|
|
141
|
+
}
|
|
142
|
+
return crumbs;
|
|
143
|
+
}, [currentFolder]);
|
|
144
|
+
|
|
145
|
+
const resolveAssetUrl = (path: string) => `/api/admin/assets/file?path=${encodeURIComponent(path)}`;
|
|
146
|
+
|
|
147
|
+
const handleItemDoubleClick = (asset: RegisteredAsset) => {
|
|
148
|
+
if (onDoubleClick) onDoubleClick(asset);
|
|
149
|
+
else setLightboxAsset(asset);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Thumbnail renderer
|
|
153
|
+
const renderThumbnail = (asset: RegisteredAsset) => {
|
|
154
|
+
if (isImageType(asset.extension)) {
|
|
155
|
+
return (
|
|
156
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
157
|
+
<img
|
|
158
|
+
src={resolveAssetUrl(asset.path)}
|
|
159
|
+
alt={asset.filename}
|
|
160
|
+
className="w-full h-full object-cover"
|
|
161
|
+
loading="lazy"
|
|
162
|
+
onError={(e) => {
|
|
163
|
+
const el = e.target as HTMLImageElement;
|
|
164
|
+
el.style.display = "none";
|
|
165
|
+
if (el.parentElement) {
|
|
166
|
+
const fallback = document.createElement("div");
|
|
167
|
+
fallback.className = "w-full h-full flex items-center justify-center bg-neutral-100 text-neutral-400";
|
|
168
|
+
fallback.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
|
|
169
|
+
el.parentElement.appendChild(fallback);
|
|
170
|
+
}
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (isVideoType(asset.extension)) {
|
|
177
|
+
return <VideoThumbnail src={resolveAssetUrl(asset.path)} alt={asset.filename} />;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isFontType(asset.extension)) {
|
|
181
|
+
return (
|
|
182
|
+
<div className="w-full h-full flex items-center justify-center bg-neutral-50">
|
|
183
|
+
<span className="text-3xl text-neutral-300 font-serif">Aa</span>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className="w-full h-full flex items-center justify-center bg-neutral-50">
|
|
190
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-300">
|
|
191
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
|
|
192
|
+
<polyline points="14 2 14 8 20 8" />
|
|
193
|
+
</svg>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
className="flex flex-1 min-h-0 relative"
|
|
201
|
+
onDragEnter={dnd.handleDragEnter}
|
|
202
|
+
onDragLeave={dnd.handleDragLeave}
|
|
203
|
+
onDragOver={dnd.handleDragOver}
|
|
204
|
+
onDrop={dnd.handleDrop}
|
|
205
|
+
>
|
|
206
|
+
{/* Hidden file input for upload button */}
|
|
207
|
+
<input
|
|
208
|
+
ref={ops.fileInputRef}
|
|
209
|
+
type="file"
|
|
210
|
+
multiple
|
|
211
|
+
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,video/mp4,video/webm,video/quicktime"
|
|
212
|
+
className="hidden"
|
|
213
|
+
onChange={dnd.handleFileInputChange}
|
|
214
|
+
/>
|
|
215
|
+
|
|
216
|
+
{/* Drag & drop overlay */}
|
|
217
|
+
{dnd.dragOver && (
|
|
218
|
+
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[#076bff]/10 border-2 border-dashed border-[#076bff] rounded-lg backdrop-blur-[2px]">
|
|
219
|
+
<div className="flex flex-col items-center gap-3">
|
|
220
|
+
<div className="w-16 h-16 rounded-full bg-[#076bff]/10 flex items-center justify-center">
|
|
221
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke={ADMIN_ACCENT} strokeWidth="1.5">
|
|
222
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
223
|
+
<polyline points="17 8 12 3 7 8" />
|
|
224
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
225
|
+
</svg>
|
|
226
|
+
</div>
|
|
227
|
+
<p className="text-sm font-medium text-[#076bff]">
|
|
228
|
+
Drop files or folders here{currentFolder ? ` to ${currentFolder}` : ""}
|
|
229
|
+
</p>
|
|
230
|
+
<p className="text-xs text-neutral-500">Supported formats: JPG, PNG, WebP, GIF, SVG, MP4, WebM, MOV</p>
|
|
231
|
+
<p className="text-xs text-neutral-400">Maximum file size: 500 MB</p>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Sidebar — Folder Tree */}
|
|
237
|
+
<div className="w-[220px] border-r border-neutral-200 flex flex-col bg-[#fafafa]">
|
|
238
|
+
<div className="flex items-center justify-between px-4 h-11 border-b border-neutral-200 shrink-0">
|
|
239
|
+
<span className="text-sm font-medium text-neutral-900">Folders</span>
|
|
240
|
+
{toggleSlot}
|
|
241
|
+
</div>
|
|
242
|
+
<div className="flex-1 overflow-y-auto py-2 px-1">
|
|
243
|
+
<button
|
|
244
|
+
onClick={() => setCurrentFolder("")}
|
|
245
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md transition-colors ${
|
|
246
|
+
currentFolder === "" ? "bg-neutral-200 text-neutral-900 font-medium" : "text-neutral-600 hover:bg-neutral-100"
|
|
247
|
+
}`}
|
|
248
|
+
>
|
|
249
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className={currentFolder === "" ? "text-neutral-700" : "text-neutral-400"}>
|
|
250
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
251
|
+
</svg>
|
|
252
|
+
<span className="text-xs">All Files</span>
|
|
253
|
+
</button>
|
|
254
|
+
{Array.from(folderTree.children.values())
|
|
255
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
256
|
+
.map((child) => (
|
|
257
|
+
<FolderTreeItem key={child.path} node={child} depth={1} currentFolder={currentFolder} onSelectFolder={setCurrentFolder} />
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Main content area */}
|
|
263
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
264
|
+
{/* Toolbar */}
|
|
265
|
+
<div className="flex items-center justify-between px-4 h-11 border-b border-neutral-200 shrink-0">
|
|
266
|
+
<div className="flex items-center gap-1 min-w-0 shrink">
|
|
267
|
+
{breadcrumbs.map((crumb, i) => (
|
|
268
|
+
<Fragment key={crumb.path + i}>
|
|
269
|
+
{i > 0 && <span className="text-neutral-300 text-xs mx-1">/</span>}
|
|
270
|
+
<button onClick={() => setCurrentFolder(crumb.path)} className="text-xs text-neutral-500 hover:text-neutral-800 transition-colors whitespace-nowrap">
|
|
271
|
+
{crumb.name}
|
|
272
|
+
</button>
|
|
273
|
+
</Fragment>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
<div className="flex items-center gap-2">
|
|
277
|
+
<button onClick={ops.openNewFolderInput} disabled={ops.actionLoading} className="inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-[11px] text-neutral-700 font-medium uppercase tracking-wider hover:bg-neutral-200 transition-colors disabled:opacity-50" title="Create a new folder" type="button">
|
|
278
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
279
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
280
|
+
<line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" />
|
|
281
|
+
</svg>
|
|
282
|
+
New Folder
|
|
283
|
+
</button>
|
|
284
|
+
<button
|
|
285
|
+
onClick={() => ops.fileInputRef.current?.click()}
|
|
286
|
+
disabled={uploading.some((u) => u.status === "uploading" || u.status === "registering")}
|
|
287
|
+
className="inline-flex items-center gap-1.5 rounded-lg bg-[#076bff] px-3 py-1.5 text-[11px] text-white font-medium uppercase tracking-wider hover:bg-[#076bff]/90 transition-colors disabled:opacity-50"
|
|
288
|
+
title={`Upload files${currentFolder ? ` to ${currentFolder}` : ""}`}
|
|
289
|
+
type="button"
|
|
290
|
+
>
|
|
291
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
292
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
293
|
+
<polyline points="17 8 12 3 7 8" />
|
|
294
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
295
|
+
</svg>
|
|
296
|
+
Upload
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{/* Upload progress bar */}
|
|
302
|
+
{uploading.length > 0 && (
|
|
303
|
+
<div className="border-b border-neutral-200 px-4 py-2 space-y-1.5 bg-neutral-50/50">
|
|
304
|
+
{uploading.map((u) => (
|
|
305
|
+
<div key={u.id} className="flex items-center gap-3">
|
|
306
|
+
{u.status === "done" ? (
|
|
307
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={BUILDER_GREEN} strokeWidth="2.5"><polyline points="20 6 9 17 4 12" /></svg>
|
|
308
|
+
) : u.status === "error" ? (
|
|
309
|
+
<button onClick={() => onClearUploadError?.(u.id)} title="Dismiss">
|
|
310
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
|
311
|
+
</button>
|
|
312
|
+
) : (
|
|
313
|
+
<div className="w-3.5 h-3.5 border-2 border-[#076bff] border-t-transparent rounded-full animate-spin" />
|
|
314
|
+
)}
|
|
315
|
+
<span className="text-[11px] text-neutral-600 truncate flex-1 min-w-0">
|
|
316
|
+
{u.file.name}
|
|
317
|
+
{u.status === "registering" && <span className="text-neutral-400 ml-1">Registering...</span>}
|
|
318
|
+
{u.status === "error" && <span className="text-red-500 ml-1">{u.error}</span>}
|
|
319
|
+
</span>
|
|
320
|
+
{(u.status === "uploading" || u.status === "registering") && (
|
|
321
|
+
<div className="w-24 h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
|
322
|
+
<div className="h-full bg-[#076bff] rounded-full transition-all duration-300" style={{ width: `${u.progress}%` }} />
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
<span className="text-[10px] text-neutral-400 tabular-nums">{formatFileSize(u.file.size)}</span>
|
|
326
|
+
</div>
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{/* Grid content */}
|
|
332
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
333
|
+
{loading && (
|
|
334
|
+
<div className="flex items-center justify-center h-40">
|
|
335
|
+
<span className="text-xs text-neutral-400 animate-pulse">Loading assets...</span>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{error && (
|
|
340
|
+
<div className="flex flex-col items-center justify-center h-40 gap-3 px-8">
|
|
341
|
+
<span className="text-xs text-red-500 text-center max-w-md leading-relaxed">{error}</span>
|
|
342
|
+
<button onClick={onRetry} className="text-xs text-[#076bff] hover:underline">Retry</button>
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
|
|
346
|
+
{!loading && !error && (
|
|
347
|
+
<>
|
|
348
|
+
{/* New folder input */}
|
|
349
|
+
{ops.showNewFolderInput && (
|
|
350
|
+
<div className="flex items-center gap-2 mb-4 p-3 bg-neutral-50 rounded-lg border border-neutral-200">
|
|
351
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-400 shrink-0">
|
|
352
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
353
|
+
</svg>
|
|
354
|
+
<input
|
|
355
|
+
ref={newFolderInputRef}
|
|
356
|
+
type="text" value={ops.newFolderName} onChange={(e) => ops.setNewFolderName(e.target.value)}
|
|
357
|
+
onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") ops.handleCreateFolder(); if (e.key === "Escape") ops.cancelNewFolderInput(); }}
|
|
358
|
+
placeholder="Folder name..." className="flex-1 text-sm text-neutral-900 bg-white border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:border-[#076bff]"
|
|
359
|
+
/>
|
|
360
|
+
<button onClick={ops.handleCreateFolder} disabled={!ops.newFolderName.trim() || ops.actionLoading} className="text-xs px-3 py-1 rounded bg-[#076bff] text-white disabled:opacity-50" type="button">Create</button>
|
|
361
|
+
<button onClick={ops.cancelNewFolderInput} className="text-xs px-2 py-1 text-neutral-500 hover:text-neutral-800" type="button">Cancel</button>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{/* Rename dialog */}
|
|
366
|
+
{ops.renameTarget && (
|
|
367
|
+
<div className="flex items-center gap-2 mb-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
|
368
|
+
<span className="text-xs text-neutral-500 shrink-0">Rename:</span>
|
|
369
|
+
<input
|
|
370
|
+
ref={renameInputRef}
|
|
371
|
+
type="text" value={ops.renameValue} onChange={(e) => ops.setRenameValue(e.target.value)}
|
|
372
|
+
onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") ops.handleRename(); if (e.key === "Escape") ops.cancelRename(); }}
|
|
373
|
+
className="flex-1 text-sm text-neutral-900 bg-white border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:border-[#076bff]"
|
|
374
|
+
/>
|
|
375
|
+
<button onClick={ops.handleRename} disabled={!ops.renameValue.trim() || ops.actionLoading} className="text-xs px-3 py-1 rounded bg-[#076bff] text-white disabled:opacity-50" type="button">Rename</button>
|
|
376
|
+
<button onClick={ops.cancelRename} className="text-xs px-2 py-1 text-neutral-500 hover:text-neutral-800" type="button">Cancel</button>
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{/* Subfolders */}
|
|
381
|
+
{currentSubfolders.length > 0 && (
|
|
382
|
+
<div className="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-3 mb-4">
|
|
383
|
+
{currentSubfolders.map((folder) => (
|
|
384
|
+
<button key={folder.path} onClick={() => setCurrentFolder(folder.path)} onContextMenu={(e) => handleContextMenu(e, undefined, folder)} className="flex flex-col items-center gap-1.5 p-3 rounded-lg hover:bg-neutral-100 transition-colors">
|
|
385
|
+
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-neutral-400">
|
|
386
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="#f5f5f5" />
|
|
387
|
+
</svg>
|
|
388
|
+
<span className="text-xs text-neutral-600 truncate w-full text-center">{folder.name}</span>
|
|
389
|
+
</button>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{/* Files grid */}
|
|
395
|
+
{filteredAssets.length > 0 && (
|
|
396
|
+
<VirtualAssetGrid
|
|
397
|
+
assets={filteredAssets} selectedAsset={selectedAsset} selectedAssets={selectedAssets}
|
|
398
|
+
multiSelect={multiSelect} setSelectedAsset={setSelectedAsset} setSelectedAssets={setSelectedAssets}
|
|
399
|
+
onDoubleClick={handleItemDoubleClick} onContextMenu={handleContextMenu} renderThumbnail={renderThumbnail} isImageType={isImageType}
|
|
400
|
+
/>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{filteredAssets.length === 0 && currentSubfolders.length === 0 && (
|
|
404
|
+
<div className="flex flex-col items-center justify-center h-40 gap-2">
|
|
405
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-neutral-300">
|
|
406
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
407
|
+
</svg>
|
|
408
|
+
<span className="text-xs text-neutral-400">
|
|
409
|
+
{assets.length === 0 ? "No assets yet. Upload files to get started." : "No files in this folder."}
|
|
410
|
+
</span>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
</>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
{/* Context menu */}
|
|
419
|
+
{contextMenu && (
|
|
420
|
+
<R2ContextMenu
|
|
421
|
+
menu={contextMenu}
|
|
422
|
+
onClose={() => setContextMenu(null)}
|
|
423
|
+
onRenameFile={ops.startRenameFile}
|
|
424
|
+
onRenameFolder={ops.startRenameFolder}
|
|
425
|
+
onDeleteFile={(key) => ops.handleDelete(key, false)}
|
|
426
|
+
onDeleteFolder={(key) => ops.handleDelete(key, true)}
|
|
427
|
+
/>
|
|
428
|
+
)}
|
|
429
|
+
|
|
430
|
+
{/* Lightbox */}
|
|
431
|
+
{lightboxAsset && (
|
|
432
|
+
<FileLightbox asset={lightboxAsset} resolveUrl={resolveAssetUrl} onClose={() => setLightboxAsset(null)} />
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import type { RegisteredAsset } from "../../../lib/sanity/types";
|
|
6
|
+
import type { FolderNode } from "./types";
|
|
7
|
+
|
|
8
|
+
// ============================================
|
|
9
|
+
// R2 Context Menu (right-click menu)
|
|
10
|
+
// ============================================
|
|
11
|
+
|
|
12
|
+
export interface ContextMenuState {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
asset?: RegisteredAsset;
|
|
16
|
+
folder?: FolderNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface R2ContextMenuProps {
|
|
20
|
+
menu: ContextMenuState;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
onRenameFile: (asset: RegisteredAsset) => void;
|
|
23
|
+
onRenameFolder: (folder: FolderNode) => void;
|
|
24
|
+
onDeleteFile: (key: string) => void;
|
|
25
|
+
onDeleteFolder: (key: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function R2ContextMenu({
|
|
29
|
+
menu,
|
|
30
|
+
onClose,
|
|
31
|
+
onRenameFile,
|
|
32
|
+
onRenameFolder,
|
|
33
|
+
onDeleteFile,
|
|
34
|
+
onDeleteFolder,
|
|
35
|
+
}: R2ContextMenuProps) {
|
|
36
|
+
// Close context menu on click outside
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const close = () => onClose();
|
|
39
|
+
window.addEventListener("click", close);
|
|
40
|
+
return () => window.removeEventListener("click", close);
|
|
41
|
+
}, [onClose]);
|
|
42
|
+
|
|
43
|
+
return createPortal(
|
|
44
|
+
<div
|
|
45
|
+
className="fixed z-[200] bg-white rounded-lg shadow-xl border border-neutral-200 py-1 min-w-[160px]"
|
|
46
|
+
style={{ left: menu.x, top: menu.y }}
|
|
47
|
+
onClick={(e) => e.stopPropagation()}
|
|
48
|
+
>
|
|
49
|
+
{menu.asset && (
|
|
50
|
+
<>
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => {
|
|
53
|
+
onRenameFile(menu.asset!);
|
|
54
|
+
onClose();
|
|
55
|
+
}}
|
|
56
|
+
className="w-full text-left px-4 py-2 text-xs text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
|
57
|
+
type="button"
|
|
58
|
+
>
|
|
59
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /></svg>
|
|
60
|
+
Rename
|
|
61
|
+
</button>
|
|
62
|
+
<button
|
|
63
|
+
onClick={() => onDeleteFile(menu.asset!.path)}
|
|
64
|
+
className="w-full text-left px-4 py-2 text-xs text-red-600 hover:bg-red-50 flex items-center gap-2"
|
|
65
|
+
type="button"
|
|
66
|
+
>
|
|
67
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
|
|
68
|
+
Delete
|
|
69
|
+
</button>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
{menu.folder && (
|
|
73
|
+
<>
|
|
74
|
+
<button
|
|
75
|
+
onClick={() => {
|
|
76
|
+
onRenameFolder(menu.folder!);
|
|
77
|
+
onClose();
|
|
78
|
+
}}
|
|
79
|
+
className="w-full text-left px-4 py-2 text-xs text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
|
80
|
+
type="button"
|
|
81
|
+
>
|
|
82
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /></svg>
|
|
83
|
+
Rename Folder
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => onDeleteFolder(menu.folder!.path)}
|
|
87
|
+
className="w-full text-left px-4 py-2 text-xs text-red-600 hover:bg-red-50 flex items-center gap-2"
|
|
88
|
+
type="button"
|
|
89
|
+
>
|
|
90
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
|
|
91
|
+
Delete Folder
|
|
92
|
+
</button>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
</div>,
|
|
96
|
+
document.body
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
export function VideoThumbnail({ src }: { src: string; alt?: string }) {
|
|
6
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
7
|
+
const [hasLoaded, setHasLoaded] = useState(false);
|
|
8
|
+
const [hasError, setHasError] = useState(false);
|
|
9
|
+
|
|
10
|
+
const handleMouseEnter = () => {
|
|
11
|
+
if (videoRef.current && hasLoaded) {
|
|
12
|
+
videoRef.current.play().catch(() => { /* Browser may reject autoplay — safe to ignore */ });
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const handleMouseLeave = () => {
|
|
17
|
+
if (videoRef.current && hasLoaded) {
|
|
18
|
+
videoRef.current.pause();
|
|
19
|
+
videoRef.current.currentTime = 0;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (hasError) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="w-full h-full flex items-center justify-center bg-neutral-100">
|
|
26
|
+
<div className="flex flex-col items-center gap-1">
|
|
27
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-400">
|
|
28
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
29
|
+
</svg>
|
|
30
|
+
<span className="text-[9px] text-neutral-400 uppercase tracking-wider">Video</span>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className="w-full h-full relative bg-neutral-900"
|
|
39
|
+
onMouseEnter={handleMouseEnter}
|
|
40
|
+
onMouseLeave={handleMouseLeave}
|
|
41
|
+
>
|
|
42
|
+
<video
|
|
43
|
+
ref={videoRef}
|
|
44
|
+
src={src}
|
|
45
|
+
muted
|
|
46
|
+
loop
|
|
47
|
+
playsInline
|
|
48
|
+
preload="metadata"
|
|
49
|
+
className="w-full h-full object-cover"
|
|
50
|
+
onLoadedData={() => setHasLoaded(true)}
|
|
51
|
+
onError={() => setHasError(true)}
|
|
52
|
+
/>
|
|
53
|
+
{/* Play icon overlay — hide when hovering */}
|
|
54
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-100 hover:opacity-0 transition-opacity pointer-events-none">
|
|
55
|
+
<div className="w-8 h-8 rounded-full bg-black/50 flex items-center justify-center backdrop-blur-sm">
|
|
56
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="ml-0.5">
|
|
57
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
58
|
+
</svg>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|