Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 80 additions & 13 deletions src/app/components/NFTCollectionProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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)
Expand Down
30 changes: 29 additions & 1 deletion src/app/components/SwapComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2324,7 +2324,7 @@ const SwapComponent: React.FC = () => {
Upload NFT collection
<span
className={styles.tooltip}
title="Upload a ZIP file containing 'images' and 'json' folders. Images will be uploaded separately, and JSON metadata will be updated with bzz.link URLs pointing to the uploaded images."
title="ZIP must include images/ and json/ folders (any parent path is OK, e.g. build/images and build/json). Files are uploaded as flat collections; metadata image fields are rewritten to point at the images manifest."
>
?
</span>
Expand Down Expand Up @@ -2567,6 +2567,34 @@ const SwapComponent: React.FC = () => {
</div>
</div>
</div>

<div className={styles.nftPathHint}>
<p className={styles.nftPathHintTitle}>How to load each token file</p>
<p className={styles.nftPathHintGatewayLead}>
Example URLs (swap <code className={styles.nftPathHintCode}>1.json</code> /{' '}
<code className={styles.nftPathHintCode}>1.png</code> for your filenames):
</p>
<div className={styles.nftPathHintGatewayLinks}>
<a
href={`${BEE_GATEWAY_URL}${nftCollectionResult.metadataReference}/1.json`}
target="_blank"
rel="noopener noreferrer"
className={styles.nftPathHintLink}
>
{BEE_GATEWAY_URL}
{nftCollectionResult.metadataReference}/1.json
</a>
<a
href={`${BEE_GATEWAY_URL}${nftCollectionResult.imagesReference}/1.png`}
target="_blank"
rel="noopener noreferrer"
className={styles.nftPathHintLink}
>
{BEE_GATEWAY_URL}
{nftCollectionResult.imagesReference}/1.png
</a>
</div>
</div>
</div>
) : (
// Single file upload success
Expand Down
61 changes: 61 additions & 0 deletions src/app/components/css/SwapComponent.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@
border-radius: 8px;
color: #ff5a52;
font-size: 14px;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}

.warningMessage {
Expand Down Expand Up @@ -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 {
Expand Down
Loading