diff --git a/src/app/components/NFTCollectionProcessor.ts b/src/app/components/NFTCollectionProcessor.ts index 6318e3b..f57c57a 100644 --- a/src/app/components/NFTCollectionProcessor.ts +++ b/src/app/components/NFTCollectionProcessor.ts @@ -2,6 +2,66 @@ import JSZip from 'jszip'; import Tar from 'tar-js'; import { SWARM_DEFERRED_UPLOAD } from './constants'; +/** Normalize archive entry paths (OS separators, leading junk). */ +function normalizeZipPath(filename: string): string { + return filename + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/^\/+/, ''); +} + +function shouldSkipZipEntry(normalizedPath: string): boolean { + const lower = normalizedPath.toLowerCase(); + return ( + lower.includes('__macosx/') || + lower.endsWith('.ds_store') || + normalizedPath.split('/').some(seg => seg === '.' || seg === '..') + ); +} + +/** + * Find collection files whether the ZIP root is `images/` or nested e.g. `build/images/`. + * Uses the last path segment named `images` or `json` before the filename (case-insensitive), + * so e.g. …/images/json/1.json is treated as metadata under json/. + */ +function classifyNftZipPath( + normalizedPath: string +): { kind: 'images' | 'json'; fileName: string } | null { + const parts = normalizedPath.split('/').filter(Boolean); + if (parts.length < 2) return null; + + const fileName = parts[parts.length - 1]; + const dirs = parts.slice(0, -1); + + for (let i = dirs.length - 1; i >= 0; i--) { + const seg = dirs[i].toLowerCase(); + if (seg === 'images') { + return { kind: 'images', fileName }; + } + if (seg === 'json') { + return { kind: 'json', fileName }; + } + } + return null; +} + +function sampleZipPaths(zipContents: JSZip): string[] { + const out: string[] = []; + for (const name of Object.keys(zipContents.files)) { + const entry = zipContents.files[name]; + if (entry.dir) continue; + const norm = normalizeZipPath(name); + if (shouldSkipZipEntry(norm)) continue; + out.push(norm); + if (out.length >= 8) break; + } + return out; +} + +const NFT_ZIP_EXPECTED_LAYOUT = + 'Put metadata JSON files under a json/ folder and image files under an images/ folder. ' + + 'Example: json/1.json, images/1.png. You may zip a parent folder (e.g. build/ containing build/images and build/json); that layout is supported.'; + export interface NFTCollectionResult { imagesReference: string; metadataReference: string; @@ -48,23 +108,22 @@ export const processNFTCollection = async ( const imageFiles: { [key: string]: Uint8Array } = {}; const jsonFiles: { [key: string]: string } = {}; - // Process all files in the ZIP + // Process all files in the ZIP (supports images/ and json/ at any depth, e.g. build/images/) for (const [filename, zipEntry] of Object.entries(zipContents.files)) { - if (zipEntry.dir) continue; // Skip directories + if (zipEntry.dir) continue; - // Determine if file is in images or json folder - const pathParts = filename.split('/'); - if (pathParts.length < 2) continue; // Skip files not in folders + const normalized = normalizeZipPath(filename); + if (shouldSkipZipEntry(normalized)) continue; - const folderName = pathParts[0].toLowerCase(); - const fileName = pathParts[pathParts.length - 1]; // Get just the filename + const classified = classifyNftZipPath(normalized); + if (!classified) continue; - if (folderName === 'images') { - // Process image files + const { kind, fileName } = classified; + + if (kind === 'images') { const content = await zipEntry.async('arraybuffer'); imageFiles[fileName] = new Uint8Array(content); - } else if (folderName === 'json') { - // Process JSON files + } else { const content = await zipEntry.async('string'); jsonFiles[fileName] = content; } @@ -74,12 +133,20 @@ export const processNFTCollection = async ( `Found ${Object.keys(imageFiles).length} images and ${Object.keys(jsonFiles).length} JSON files` ); + const samples = sampleZipPaths(zipContents); + const sampleSuffix = + samples.length > 0 ? ` Paths found in the ZIP (sample): ${samples.join('; ')}.` : ''; + if (Object.keys(imageFiles).length === 0) { - throw new Error('No images found in the images folder'); + throw new Error( + `No image files found under an images/ folder.${sampleSuffix} ${NFT_ZIP_EXPECTED_LAYOUT}` + ); } if (Object.keys(jsonFiles).length === 0) { - throw new Error('No JSON metadata files found in the json folder'); + throw new Error( + `No JSON metadata files found under a json/ folder.${sampleSuffix} ${NFT_ZIP_EXPECTED_LAYOUT}` + ); } // Step 1: Create TAR with images (without subfolder) diff --git a/src/app/components/SwapComponent.tsx b/src/app/components/SwapComponent.tsx index 8cc4550..01fc0f7 100644 --- a/src/app/components/SwapComponent.tsx +++ b/src/app/components/SwapComponent.tsx @@ -2324,7 +2324,7 @@ const SwapComponent: React.FC = () => { Upload NFT collection ? @@ -2567,6 +2567,34 @@ const SwapComponent: React.FC = () => { + +
+

How to load each token file

+

+ Example URLs (swap 1.json /{' '} + 1.png for your filenames): +

+
+ + {BEE_GATEWAY_URL} + {nftCollectionResult.metadataReference}/1.json + + + {BEE_GATEWAY_URL} + {nftCollectionResult.imagesReference}/1.png + +
+
) : ( // Single file upload success diff --git a/src/app/components/css/SwapComponent.module.css b/src/app/components/css/SwapComponent.module.css index 0c5f51e..ae0dcf6 100644 --- a/src/app/components/css/SwapComponent.module.css +++ b/src/app/components/css/SwapComponent.module.css @@ -246,6 +246,9 @@ border-radius: 8px; color: #ff5a52; font-size: 14px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .warningMessage { @@ -1438,6 +1441,64 @@ color: #ff7a00; } +.nftPathHint { + margin-top: 16px; + padding: 14px; + background-color: rgba(110, 118, 129, 0.12); + border: 1px solid #30363d; + border-radius: 8px; + font-size: 13px; + color: #8b949e; + line-height: 1.5; +} + +.nftPathHintTitle { + margin: 0 0 8px 0; + font-size: 13px; + font-weight: 600; + color: #e6edf3; +} + +.nftPathHint p { + margin: 0 0 10px 0; +} + +.nftPathHint p:last-child { + margin-bottom: 0; +} + +.nftPathHintCode { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #79c0ff; + word-break: break-all; +} + +.nftPathHintGatewayLead { + margin: 0 0 10px 0 !important; + font-size: 13px; + color: #8b949e; +} + +.nftPathHintGatewayLinks { + display: flex; + flex-direction: column; + gap: 8px; +} + +.nftPathHintLink { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #79c0ff; + word-break: break-all; + text-decoration: underline; + text-underline-offset: 3px; +} + +.nftPathHintLink:hover { + color: #a5d6ff; +} + /* Responsive adjustments for mobile */ @media (max-width: 480px) { .nftCollectionSummary {