Created
June 22, 2026 12:23
-
-
Save koeppelmann/be0f7452141b289fad851f11ac3d3d49 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Swarm File Upload</title> | |
| <script src="https://unpkg.com/@ethersphere/bee-js/dist/index.browser.min.js"></script> | |
| <!-- Helia for IPFS in browser --> | |
| <script type="module"> | |
| import { createHelia } from 'https://esm.sh/helia@4'; | |
| import { unixfs } from 'https://esm.sh/@helia/unixfs@3'; | |
| import { CID } from 'https://esm.sh/multiformats/cid'; | |
| // Make available globally | |
| window.heliaModules = { createHelia, unixfs, CID }; | |
| </script> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| min-height: 100vh; | |
| color: #fff; | |
| padding: 2rem; | |
| } | |
| .container { | |
| max-width: 800px; | |
| margin: 0 auto; | |
| } | |
| h1 { | |
| text-align: center; | |
| margin-bottom: 0.5rem; | |
| font-size: 2rem; | |
| } | |
| .subtitle { | |
| text-align: center; | |
| color: #888; | |
| margin-bottom: 2rem; | |
| } | |
| .config-section { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| .config-section h2 { | |
| font-size: 1rem; | |
| margin-bottom: 1rem; | |
| color: #f90; | |
| } | |
| .input-group { | |
| display: flex; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .input-group label { | |
| flex: 1; | |
| min-width: 250px; | |
| } | |
| .input-group label span { | |
| display: block; | |
| font-size: 0.85rem; | |
| color: #aaa; | |
| margin-bottom: 0.5rem; | |
| } | |
| .input-group input { | |
| width: 100%; | |
| padding: 0.75rem; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-size: 0.9rem; | |
| } | |
| .input-group input:focus { | |
| outline: none; | |
| border-color: #f90; | |
| } | |
| .connection-status { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-bottom: 1rem; | |
| padding: 0.5rem 1rem; | |
| background: rgba(0, 0, 0, 0.2); | |
| border-radius: 8px; | |
| font-size: 0.9rem; | |
| } | |
| .connection-status .status-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background: #888; | |
| transition: background 0.3s; | |
| } | |
| .connection-status.connected .status-dot { | |
| background: #4caf50; | |
| } | |
| .connection-status.gateway .status-dot { | |
| background: #ff9800; | |
| } | |
| .connection-status.disconnected .status-dot { | |
| background: #f44336; | |
| } | |
| .connection-status .status-text { | |
| color: #ccc; | |
| } | |
| .drop-zone { | |
| border: 2px dashed #444; | |
| border-radius: 16px; | |
| padding: 4rem 2rem; | |
| text-align: center; | |
| transition: all 0.3s ease; | |
| cursor: pointer; | |
| background: rgba(255, 255, 255, 0.02); | |
| } | |
| .drop-zone:hover, | |
| .drop-zone.drag-over { | |
| border-color: #f90; | |
| background: rgba(255, 153, 0, 0.05); | |
| } | |
| .drop-zone-icon { | |
| font-size: 3rem; | |
| margin-bottom: 1rem; | |
| } | |
| .drop-zone-text { | |
| font-size: 1.2rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .drop-zone-hint { | |
| color: #666; | |
| font-size: 0.9rem; | |
| } | |
| .file-input { | |
| display: none; | |
| } | |
| .upload-list { | |
| margin-top: 1.5rem; | |
| } | |
| .upload-item { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 12px; | |
| padding: 1rem 1.5rem; | |
| margin-bottom: 1rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 1rem; | |
| } | |
| .upload-item-icon { | |
| font-size: 1.5rem; | |
| } | |
| .upload-item-info { | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| .upload-item-name { | |
| font-weight: 500; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .upload-item-size { | |
| font-size: 0.85rem; | |
| color: #888; | |
| } | |
| .upload-item-status { | |
| font-size: 0.85rem; | |
| padding: 0.25rem 0.75rem; | |
| border-radius: 20px; | |
| white-space: nowrap; | |
| } | |
| .status-pending { | |
| background: #333; | |
| color: #888; | |
| } | |
| .status-uploading { | |
| background: rgba(255, 153, 0, 0.2); | |
| color: #f90; | |
| } | |
| .status-success { | |
| background: rgba(0, 200, 83, 0.2); | |
| color: #00c853; | |
| } | |
| .status-error { | |
| background: rgba(255, 82, 82, 0.2); | |
| color: #ff5252; | |
| } | |
| .upload-item-link { | |
| margin-top: 0.5rem; | |
| } | |
| .upload-item-link a { | |
| color: #f90; | |
| text-decoration: none; | |
| font-size: 0.85rem; | |
| word-break: break-all; | |
| } | |
| .upload-item-link a:hover { | |
| text-decoration: underline; | |
| } | |
| .copy-btn { | |
| background: transparent; | |
| border: 1px solid #444; | |
| color: #888; | |
| padding: 0.25rem 0.5rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 0.75rem; | |
| margin-left: 0.5rem; | |
| } | |
| .copy-btn:hover { | |
| border-color: #f90; | |
| color: #f90; | |
| } | |
| .error-message { | |
| background: rgba(255, 82, 82, 0.1); | |
| border: 1px solid #ff5252; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-bottom: 1rem; | |
| color: #ff5252; | |
| } | |
| .info-box { | |
| background: rgba(255, 153, 0, 0.1); | |
| border: 1px solid #f90; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-bottom: 1.5rem; | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| } | |
| .info-box a { | |
| color: #f90; | |
| } | |
| .debug-section { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-top: 2rem; | |
| border: 1px solid #333; | |
| } | |
| .debug-section h2 { | |
| font-size: 1rem; | |
| margin-bottom: 1rem; | |
| color: #f90; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .debug-input-row { | |
| display: flex; | |
| gap: 1rem; | |
| margin-bottom: 1rem; | |
| } | |
| .debug-input-row input { | |
| flex: 1; | |
| padding: 0.75rem; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-size: 0.9rem; | |
| font-family: monospace; | |
| } | |
| .debug-input-row input:focus { | |
| outline: none; | |
| border-color: #f90; | |
| } | |
| .debug-btn { | |
| background: #f90; | |
| border: none; | |
| color: #000; | |
| padding: 0.75rem 1.5rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| } | |
| .debug-btn:hover { | |
| background: #ffaa33; | |
| } | |
| .debug-btn:disabled { | |
| background: #666; | |
| cursor: not-allowed; | |
| } | |
| .debug-results { | |
| background: #0d0d1a; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| font-family: monospace; | |
| font-size: 0.85rem; | |
| max-height: 500px; | |
| overflow-y: auto; | |
| } | |
| .debug-results:empty { | |
| display: none; | |
| } | |
| .debug-item { | |
| margin-bottom: 1rem; | |
| padding-bottom: 1rem; | |
| border-bottom: 1px solid #333; | |
| } | |
| .debug-item:last-child { | |
| margin-bottom: 0; | |
| padding-bottom: 0; | |
| border-bottom: none; | |
| } | |
| .debug-item-title { | |
| color: #f90; | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .debug-item-content { | |
| color: #ccc; | |
| line-height: 1.6; | |
| } | |
| .debug-status { | |
| display: inline-block; | |
| padding: 0.15rem 0.5rem; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| } | |
| .debug-status.success { | |
| background: rgba(0, 200, 83, 0.2); | |
| color: #00c853; | |
| } | |
| .debug-status.error { | |
| background: rgba(255, 82, 82, 0.2); | |
| color: #ff5252; | |
| } | |
| .debug-status.warning { | |
| background: rgba(255, 193, 7, 0.2); | |
| color: #ffc107; | |
| } | |
| .debug-status.info { | |
| background: rgba(33, 150, 243, 0.2); | |
| color: #2196f3; | |
| } | |
| .debug-loading { | |
| color: #888; | |
| font-style: italic; | |
| } | |
| .debug-json { | |
| background: rgba(0, 0, 0, 0.3); | |
| padding: 0.5rem; | |
| border-radius: 4px; | |
| margin-top: 0.5rem; | |
| overflow-x: auto; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .url-section { | |
| background: rgba(33, 150, 243, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-top: 1.5rem; | |
| border: 1px solid #2196f3; | |
| } | |
| .url-section h2 { | |
| font-size: 1rem; | |
| margin-bottom: 1rem; | |
| color: #64b5f6; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .url-hint { | |
| font-size: 0.85rem; | |
| color: #888; | |
| margin-bottom: 1rem; | |
| line-height: 1.5; | |
| } | |
| .url-hint code { | |
| background: rgba(255, 255, 255, 0.1); | |
| padding: 0.2rem 0.4rem; | |
| border-radius: 4px; | |
| font-size: 0.8rem; | |
| } | |
| .url-input-row { | |
| display: flex; | |
| gap: 1rem; | |
| margin-bottom: 1rem; | |
| } | |
| .url-input-row input { | |
| flex: 1; | |
| padding: 0.75rem; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-size: 0.9rem; | |
| } | |
| .url-input-row input:focus { | |
| outline: none; | |
| border-color: #2196f3; | |
| } | |
| .url-btn { | |
| background: #2196f3; | |
| border: none; | |
| color: #fff; | |
| padding: 0.75rem 1.5rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| } | |
| .url-btn:hover { | |
| background: #42a5f5; | |
| } | |
| .url-btn:disabled { | |
| background: #666; | |
| cursor: not-allowed; | |
| } | |
| .url-status { | |
| background: #0d0d1a; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| font-size: 0.9rem; | |
| margin-top: 1rem; | |
| } | |
| .url-status:empty { | |
| display: none; | |
| } | |
| .url-progress { | |
| margin-top: 0.5rem; | |
| } | |
| .url-progress-bar { | |
| height: 8px; | |
| background: #333; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| margin-top: 0.5rem; | |
| } | |
| .url-progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #2196f3, #64b5f6); | |
| border-radius: 4px; | |
| transition: width 0.3s ease; | |
| } | |
| .ipfs-section { | |
| background: rgba(0, 188, 212, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-top: 1.5rem; | |
| border: 1px solid #00bcd4; | |
| } | |
| .ipfs-section h2 { | |
| font-size: 1rem; | |
| margin-bottom: 1rem; | |
| color: #4dd0e1; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .ipfs-hint { | |
| font-size: 0.85rem; | |
| color: #888; | |
| margin-bottom: 1rem; | |
| line-height: 1.5; | |
| } | |
| .ipfs-hint code { | |
| background: rgba(255, 255, 255, 0.1); | |
| padding: 0.2rem 0.4rem; | |
| border-radius: 4px; | |
| font-size: 0.8rem; | |
| } | |
| .ipfs-input-row { | |
| display: flex; | |
| gap: 1rem; | |
| margin-bottom: 1rem; | |
| } | |
| .ipfs-input-row input { | |
| flex: 1; | |
| padding: 0.75rem; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-size: 0.9rem; | |
| font-family: monospace; | |
| } | |
| .ipfs-input-row input:focus { | |
| outline: none; | |
| border-color: #00bcd4; | |
| } | |
| .ipfs-btn { | |
| background: #00bcd4; | |
| border: none; | |
| color: #000; | |
| padding: 0.75rem 1.5rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| } | |
| .ipfs-btn:hover { | |
| background: #4dd0e1; | |
| } | |
| .ipfs-btn:disabled { | |
| background: #666; | |
| cursor: not-allowed; | |
| color: #999; | |
| } | |
| .ipfs-status { | |
| background: #0d0d1a; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| font-size: 0.9rem; | |
| margin-top: 1rem; | |
| } | |
| .ipfs-status:empty { | |
| display: none; | |
| } | |
| .ipfs-progress { | |
| margin-top: 0.5rem; | |
| } | |
| .ipfs-progress-bar { | |
| height: 8px; | |
| background: #333; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| margin-top: 0.5rem; | |
| } | |
| .ipfs-progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #00bcd4, #4dd0e1); | |
| border-radius: 4px; | |
| transition: width 0.3s ease; | |
| } | |
| .ipfs-gateway-note { | |
| font-size: 0.8rem; | |
| color: #666; | |
| margin-top: 0.75rem; | |
| padding-top: 0.75rem; | |
| border-top: 1px solid #333; | |
| } | |
| .ipfs-gateway-note a { | |
| color: #4dd0e1; | |
| } | |
| .helia-status { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| font-size: 0.8rem; | |
| padding: 0.25rem 0.75rem; | |
| border-radius: 12px; | |
| background: rgba(0, 0, 0, 0.3); | |
| } | |
| .helia-status .dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #666; | |
| } | |
| .helia-status.connecting .dot { | |
| background: #ffc107; | |
| animation: pulse 1s infinite; | |
| } | |
| .helia-status.connected .dot { | |
| background: #00c853; | |
| } | |
| .helia-status.error .dot { | |
| background: #ff5252; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| /* Client-Side Stamping Section */ | |
| .client-stamp-section { | |
| background: rgba(76, 175, 80, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| border: 1px solid #4caf50; | |
| } | |
| .client-stamp-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.75rem; | |
| } | |
| .client-stamp-header h2 { | |
| font-size: 1rem; | |
| color: #81c784; | |
| margin: 0; | |
| } | |
| .client-stamp-toggle { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| color: #a5d6a7; | |
| } | |
| .client-stamp-toggle input[type="checkbox"] { | |
| width: 18px; | |
| height: 18px; | |
| cursor: pointer; | |
| accent-color: #4caf50; | |
| } | |
| .client-stamp-hint { | |
| font-size: 0.85rem; | |
| color: #888; | |
| margin-bottom: 1rem; | |
| line-height: 1.5; | |
| } | |
| .client-stamp-config { | |
| background: rgba(0, 0, 0, 0.2); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| } | |
| .client-stamp-config .input-group label span { | |
| color: #a5d6a7; | |
| } | |
| .client-stamp-config input { | |
| background: #1a1a2e; | |
| border: 1px solid #4caf50; | |
| color: #fff; | |
| } | |
| .client-stamp-config input:focus { | |
| border-color: #81c784; | |
| outline: none; | |
| } | |
| .client-stamp-actions { | |
| display: flex; | |
| gap: 0.75rem; | |
| margin-top: 1rem; | |
| } | |
| .client-stamp-btn { | |
| padding: 0.6rem 1.2rem; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| background: #4caf50; | |
| color: #fff; | |
| transition: background 0.2s; | |
| } | |
| .client-stamp-btn:hover { | |
| background: #66bb6a; | |
| } | |
| .client-stamp-btn.secondary { | |
| background: transparent; | |
| border: 1px solid #4caf50; | |
| color: #a5d6a7; | |
| } | |
| .client-stamp-btn.secondary:hover { | |
| background: rgba(76, 175, 80, 0.15); | |
| } | |
| .client-stamp-btn:disabled { | |
| background: #666; | |
| cursor: not-allowed; | |
| } | |
| .client-stamp-status { | |
| margin-top: 0.75rem; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| } | |
| .client-stamp-status:empty { | |
| display: none; | |
| } | |
| .client-stamp-status.success { | |
| background: rgba(76, 175, 80, 0.2); | |
| color: #a5d6a7; | |
| } | |
| .client-stamp-status.error { | |
| background: rgba(244, 67, 54, 0.2); | |
| color: #ef9a9a; | |
| } | |
| .client-stamp-status.info { | |
| background: rgba(33, 150, 243, 0.2); | |
| color: #90caf9; | |
| } | |
| .client-stamp-info { | |
| margin-top: 1rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid rgba(76, 175, 80, 0.3); | |
| } | |
| .stamp-info-row { | |
| display: flex; | |
| gap: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.85rem; | |
| } | |
| .stamp-info-label { | |
| color: #888; | |
| min-width: 120px; | |
| } | |
| .stamp-info-value { | |
| color: #a5d6a7; | |
| font-family: monospace; | |
| word-break: break-all; | |
| } | |
| .stamps-section { | |
| background: rgba(156, 39, 176, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| border: 1px solid #9c27b0; | |
| } | |
| .stamps-section h2 { | |
| font-size: 1rem; | |
| margin-bottom: 1rem; | |
| color: #ce93d8; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| .stamps-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 1rem; | |
| } | |
| .stamps-refresh-btn { | |
| background: transparent; | |
| border: 1px solid #9c27b0; | |
| color: #ce93d8; | |
| padding: 0.5rem 1rem; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| } | |
| .stamps-refresh-btn:hover { | |
| background: rgba(156, 39, 176, 0.1); | |
| } | |
| .stamps-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.75rem; | |
| } | |
| .stamp-card { | |
| background: rgba(0, 0, 0, 0.2); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| cursor: pointer; | |
| border: 2px solid transparent; | |
| transition: all 0.2s ease; | |
| } | |
| .stamp-card:hover { | |
| border-color: #9c27b0; | |
| } | |
| .stamp-card.selected { | |
| border-color: #ce93d8; | |
| background: rgba(156, 39, 176, 0.15); | |
| } | |
| .stamp-card-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| margin-bottom: 0.5rem; | |
| } | |
| .stamp-label { | |
| font-weight: 600; | |
| color: #ce93d8; | |
| } | |
| .stamp-status { | |
| display: inline-block; | |
| padding: 0.15rem 0.5rem; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| } | |
| .stamp-status.usable { | |
| background: rgba(0, 200, 83, 0.2); | |
| color: #00c853; | |
| } | |
| .stamp-status.unusable { | |
| background: rgba(255, 82, 82, 0.2); | |
| color: #ff5252; | |
| } | |
| .stamp-id { | |
| font-family: monospace; | |
| font-size: 0.75rem; | |
| color: #888; | |
| word-break: break-all; | |
| margin-bottom: 0.5rem; | |
| } | |
| .stamp-details { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); | |
| gap: 0.5rem; | |
| font-size: 0.85rem; | |
| } | |
| .stamp-detail { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .stamp-detail-label { | |
| color: #888; | |
| font-size: 0.75rem; | |
| } | |
| .stamp-detail-value { | |
| color: #fff; | |
| font-weight: 500; | |
| } | |
| .stamp-utilization-bar { | |
| height: 4px; | |
| background: #333; | |
| border-radius: 2px; | |
| margin-top: 0.75rem; | |
| overflow: hidden; | |
| } | |
| .stamp-utilization-fill { | |
| height: 100%; | |
| border-radius: 2px; | |
| transition: width 0.3s ease; | |
| } | |
| .stamps-loading { | |
| color: #888; | |
| font-style: italic; | |
| padding: 1rem; | |
| text-align: center; | |
| } | |
| .stamps-empty { | |
| color: #888; | |
| padding: 1rem; | |
| text-align: center; | |
| background: rgba(0, 0, 0, 0.2); | |
| border-radius: 8px; | |
| } | |
| .stamp-expanded { | |
| margin-top: 0.75rem; | |
| padding-top: 0.75rem; | |
| border-top: 1px solid #333; | |
| } | |
| .stamp-expanded-details { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 0.75rem 1rem; | |
| font-size: 0.8rem; | |
| } | |
| .stamp-expanded-details .stamp-detail.full-width { | |
| grid-column: 1 / -1; | |
| } | |
| .topup-row { | |
| display: flex; | |
| gap: 0.5rem; | |
| align-items: center; | |
| margin-top: 0.75rem; | |
| padding-top: 0.75rem; | |
| border-top: 1px solid #333; | |
| } | |
| .topup-input { | |
| flex: 1; | |
| padding: 0.5rem 0.75rem; | |
| border: 1px solid #9c27b0; | |
| border-radius: 6px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-size: 0.85rem; | |
| } | |
| .topup-input:focus { | |
| outline: none; | |
| border-color: #ce93d8; | |
| } | |
| .topup-btn { | |
| background: #9c27b0; | |
| border: none; | |
| color: #fff; | |
| padding: 0.5rem 1rem; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| white-space: nowrap; | |
| } | |
| .topup-btn:hover { | |
| background: #ab47bc; | |
| } | |
| .topup-btn:disabled { | |
| background: #666; | |
| cursor: not-allowed; | |
| } | |
| .topup-status { | |
| font-size: 0.8rem; | |
| margin-top: 0.5rem; | |
| } | |
| .topup-status.success { | |
| color: #00c853; | |
| } | |
| .topup-status.error { | |
| color: #ff5252; | |
| } | |
| .topup-hint { | |
| font-size: 0.75rem; | |
| color: #888; | |
| margin-top: 0.25rem; | |
| } | |
| .topup-cost { | |
| font-size: 0.85rem; | |
| color: #ce93d8; | |
| margin-top: 0.5rem; | |
| min-height: 1.2em; | |
| } | |
| .topup-cost .bzz-amount { | |
| font-weight: 600; | |
| color: #fff; | |
| } | |
| .pins-section { | |
| margin-top: 1rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid #333; | |
| } | |
| .pins-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.5rem; | |
| } | |
| .pins-title { | |
| font-size: 0.85rem; | |
| color: #ce93d8; | |
| font-weight: 600; | |
| } | |
| .pins-load-btn { | |
| background: transparent; | |
| border: 1px solid #9c27b0; | |
| color: #ce93d8; | |
| padding: 0.25rem 0.5rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 0.75rem; | |
| } | |
| .pins-load-btn:hover { | |
| background: rgba(156, 39, 176, 0.1); | |
| } | |
| .pins-list { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| font-size: 0.8rem; | |
| } | |
| .pin-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.4rem 0; | |
| border-bottom: 1px solid rgba(255,255,255,0.05); | |
| } | |
| .pin-item:last-child { | |
| border-bottom: none; | |
| } | |
| .pin-hash { | |
| font-family: monospace; | |
| font-size: 0.7rem; | |
| color: #aaa; | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .pin-actions { | |
| display: flex; | |
| gap: 0.25rem; | |
| } | |
| .pin-actions a, .pin-actions button { | |
| background: transparent; | |
| border: 1px solid #444; | |
| color: #888; | |
| padding: 0.15rem 0.4rem; | |
| border-radius: 3px; | |
| cursor: pointer; | |
| font-size: 0.7rem; | |
| text-decoration: none; | |
| } | |
| .pin-actions a:hover, .pin-actions button:hover { | |
| border-color: #ce93d8; | |
| color: #ce93d8; | |
| } | |
| .pins-empty { | |
| color: #666; | |
| font-style: italic; | |
| padding: 0.5rem 0; | |
| } | |
| .pins-note { | |
| font-size: 0.7rem; | |
| color: #666; | |
| margin-top: 0.5rem; | |
| font-style: italic; | |
| } | |
| .tooltip { | |
| position: relative; | |
| cursor: help; | |
| border-bottom: 1px dotted #888; | |
| } | |
| .tooltip::after { | |
| content: attr(data-tooltip); | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #222; | |
| color: #fff; | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 6px; | |
| font-size: 0.75rem; | |
| font-weight: 400; | |
| white-space: nowrap; | |
| max-width: 250px; | |
| white-space: normal; | |
| text-align: left; | |
| opacity: 0; | |
| visibility: hidden; | |
| transition: opacity 0.2s, visibility 0.2s; | |
| z-index: 1000; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| pointer-events: none; | |
| } | |
| .tooltip::before { | |
| content: ''; | |
| position: absolute; | |
| bottom: 100%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| border: 6px solid transparent; | |
| border-top-color: #222; | |
| opacity: 0; | |
| visibility: hidden; | |
| transition: opacity 0.2s, visibility 0.2s; | |
| z-index: 1000; | |
| } | |
| .tooltip:hover::after, | |
| .tooltip:hover::before { | |
| opacity: 1; | |
| visibility: visible; | |
| } | |
| .stamp-detail-label.tooltip { | |
| display: inline-block; | |
| } | |
| /* Message Board Section */ | |
| .board-section { | |
| background: rgba(255, 255, 255, 0.05); | |
| border-radius: 12px; | |
| padding: 1.5rem; | |
| margin-top: 1.5rem; | |
| border: 1px solid #2a5a4a; | |
| } | |
| .board-section h2 { | |
| font-size: 1.1rem; | |
| margin-bottom: 0.5rem; | |
| color: #4caf50; | |
| } | |
| .board-hint { | |
| font-size: 0.85rem; | |
| color: #888; | |
| margin-bottom: 1rem; | |
| } | |
| .board-key-section { | |
| background: rgba(76, 175, 80, 0.1); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-bottom: 1rem; | |
| } | |
| .board-key-row { | |
| display: flex; | |
| gap: 0.5rem; | |
| margin-bottom: 0.75rem; | |
| align-items: center; | |
| } | |
| .board-key-row input { | |
| flex: 1; | |
| padding: 0.6rem; | |
| border: 1px solid #333; | |
| border-radius: 6px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-family: monospace; | |
| font-size: 0.85rem; | |
| } | |
| .board-key-row input:focus { | |
| outline: none; | |
| border-color: #4caf50; | |
| } | |
| .board-btn { | |
| padding: 0.6rem 1rem; | |
| border: none; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-weight: 500; | |
| font-size: 0.85rem; | |
| transition: all 0.2s; | |
| } | |
| .board-btn-primary { | |
| background: #4caf50; | |
| color: #fff; | |
| } | |
| .board-btn-primary:hover { | |
| background: #66bb6a; | |
| } | |
| .board-btn-secondary { | |
| background: transparent; | |
| border: 1px solid #4caf50; | |
| color: #4caf50; | |
| } | |
| .board-btn-secondary:hover { | |
| background: rgba(76, 175, 80, 0.1); | |
| } | |
| .board-info { | |
| display: grid; | |
| grid-template-columns: auto 1fr auto; | |
| gap: 0.5rem; | |
| align-items: center; | |
| font-size: 0.8rem; | |
| margin-top: 0.75rem; | |
| padding: 0.75rem; | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 6px; | |
| } | |
| .board-info-label { | |
| color: #888; | |
| } | |
| .board-info-value { | |
| font-family: monospace; | |
| color: #a5d6a7; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .board-info-copy { | |
| background: transparent; | |
| border: 1px solid #444; | |
| color: #888; | |
| padding: 0.2rem 0.5rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 0.7rem; | |
| } | |
| .board-info-copy:hover { | |
| border-color: #4caf50; | |
| color: #4caf50; | |
| } | |
| .board-share-links { | |
| display: flex; | |
| gap: 0.5rem; | |
| margin-top: 0.75rem; | |
| } | |
| .board-share-btn { | |
| flex: 1; | |
| padding: 0.5rem; | |
| background: rgba(76, 175, 80, 0.15); | |
| border: 1px solid #4caf50; | |
| color: #a5d6a7; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.75rem; | |
| text-align: center; | |
| } | |
| .board-share-btn:hover { | |
| background: rgba(76, 175, 80, 0.25); | |
| } | |
| .board-compose { | |
| background: rgba(76, 175, 80, 0.1); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-bottom: 1rem; | |
| } | |
| .board-compose-title { | |
| font-size: 0.9rem; | |
| color: #a5d6a7; | |
| margin-bottom: 0.75rem; | |
| font-weight: 600; | |
| } | |
| .board-compose textarea { | |
| width: 100%; | |
| min-height: 80px; | |
| padding: 0.75rem; | |
| border: 1px solid #333; | |
| border-radius: 6px; | |
| background: #1a1a2e; | |
| color: #fff; | |
| font-size: 0.9rem; | |
| resize: vertical; | |
| margin-bottom: 0.75rem; | |
| } | |
| .board-compose textarea:focus { | |
| outline: none; | |
| border-color: #4caf50; | |
| } | |
| .board-compose-actions { | |
| display: flex; | |
| gap: 0.5rem; | |
| align-items: center; | |
| } | |
| .board-compose-actions input[type="file"] { | |
| display: none; | |
| } | |
| .board-image-preview { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| margin-bottom: 0.75rem; | |
| } | |
| .board-image-preview img { | |
| max-height: 60px; | |
| border-radius: 4px; | |
| border: 1px solid #333; | |
| } | |
| .board-image-preview .remove-image { | |
| position: relative; | |
| } | |
| .board-image-preview .remove-image::after { | |
| content: '×'; | |
| position: absolute; | |
| top: -5px; | |
| right: -5px; | |
| background: #f44336; | |
| color: #fff; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| font-size: 12px; | |
| line-height: 16px; | |
| text-align: center; | |
| cursor: pointer; | |
| } | |
| .board-messages { | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| max-height: 500px; | |
| overflow-y: auto; | |
| } | |
| .board-messages-title { | |
| font-size: 0.9rem; | |
| color: #888; | |
| margin-bottom: 0.75rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .board-message { | |
| background: rgba(255,255,255,0.03); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-bottom: 0.75rem; | |
| border-left: 3px solid #4caf50; | |
| } | |
| .board-message:last-child { | |
| margin-bottom: 0; | |
| } | |
| .board-message-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.75rem; | |
| color: #888; | |
| } | |
| .board-message-author { | |
| font-family: monospace; | |
| color: #a5d6a7; | |
| } | |
| .board-message-time { | |
| color: #666; | |
| } | |
| .board-message-text { | |
| font-size: 0.9rem; | |
| line-height: 1.5; | |
| color: #ddd; | |
| word-break: break-word; | |
| } | |
| .board-message-images { | |
| display: flex; | |
| gap: 0.5rem; | |
| flex-wrap: wrap; | |
| margin-top: 0.75rem; | |
| } | |
| .board-message-images img { | |
| max-width: 200px; | |
| max-height: 150px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: transform 0.2s; | |
| } | |
| .board-message-images img:hover { | |
| transform: scale(1.02); | |
| } | |
| .board-empty { | |
| text-align: center; | |
| color: #666; | |
| padding: 2rem; | |
| font-style: italic; | |
| } | |
| .board-loading { | |
| text-align: center; | |
| color: #888; | |
| padding: 1rem; | |
| } | |
| .board-status { | |
| margin-top: 0.75rem; | |
| padding: 0.5rem; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| } | |
| .board-status.success { | |
| background: rgba(76, 175, 80, 0.2); | |
| color: #a5d6a7; | |
| } | |
| .board-status.error { | |
| background: rgba(244, 67, 54, 0.2); | |
| color: #ef9a9a; | |
| } | |
| .board-status.info { | |
| background: rgba(33, 150, 243, 0.2); | |
| color: #90caf9; | |
| } | |
| .board-readonly-notice { | |
| background: rgba(255, 152, 0, 0.15); | |
| border: 1px solid #ff9800; | |
| color: #ffcc80; | |
| padding: 0.75rem; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| margin-bottom: 1rem; | |
| text-align: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Swarm File Upload</h1> | |
| <p class="subtitle">Drag and drop files to upload to Swarm decentralized storage</p> | |
| <div class="info-box"> | |
| <strong>Upload Options:</strong><br> | |
| 1. <strong>Local Bee Node:</strong> Run a <a href="https://docs.ethswarm.org/docs/bee/installation/quick-start" target="_blank">Bee full node</a> with a postage stamp.<br> | |
| 2. <strong>Gateway (Sponsored):</strong> Use a public gateway with free sponsored uploads (no node needed).<br> | |
| 3. <strong>Client-Side Stamping:</strong> Use your own stamp via any gateway - sign chunks locally without sharing your key. | |
| </div> | |
| <div class="config-section"> | |
| <h2>Configuration</h2> | |
| <div class="connection-status" id="connectionStatus"> | |
| <span class="status-dot"></span> | |
| <span class="status-text">Checking connection...</span> | |
| </div> | |
| <div class="input-group"> | |
| <label> | |
| <span>Bee Node URL (local)</span> | |
| <input type="text" id="beeUrl" value="http://localhost:1633" placeholder="http://localhost:1633"> | |
| </label> | |
| <label> | |
| <span>Gateway URL (fallback)</span> | |
| <input type="text" id="gatewayUrl" value="https://api.gateway.ethswarm.org" placeholder="https://api.gateway.ethswarm.org"> | |
| </label> | |
| </div> | |
| <div class="input-group" style="margin-top: 1rem;"> | |
| <label style="flex: 2;"> | |
| <span>Postage Batch ID <span id="stampRequirement" style="color: #888; font-size: 0.8em;">(required for uploads)</span></span> | |
| <input type="text" id="batchId" placeholder="Select a stamp below or enter ID manually"> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="client-stamp-section" id="clientStampSection"> | |
| <div class="client-stamp-header"> | |
| <h2>Client-Side Stamping (Bring Your Own Stamp)</h2> | |
| <label class="client-stamp-toggle"> | |
| <input type="checkbox" id="clientStampEnabled" onchange="toggleClientStamping()"> | |
| <span>Enable</span> | |
| </label> | |
| </div> | |
| <div class="client-stamp-hint"> | |
| Sign and stamp chunks locally with your own key and batch. Upload to any gateway without sharing your private key. | |
| Requires a batch you own (purchased with your Ethereum address). | |
| </div> | |
| <div class="client-stamp-config" id="clientStampConfig" style="display: none;"> | |
| <div class="input-group"> | |
| <label> | |
| <span>Signer Private Key <span style="color: #ff9800; font-size: 0.8em;">(kept local, never sent)</span></span> | |
| <input type="password" id="clientSignerKey" placeholder="64-char hex private key (owner of batch)"> | |
| </label> | |
| </div> | |
| <div class="input-group" style="margin-top: 0.75rem;"> | |
| <label> | |
| <span>Batch ID (owned by signer)</span> | |
| <input type="text" id="clientBatchId" placeholder="Your postage batch ID"> | |
| </label> | |
| <label style="max-width: 120px;"> | |
| <span>Batch Depth</span> | |
| <input type="number" id="clientBatchDepth" placeholder="20" value="20" min="17" max="32"> | |
| </label> | |
| </div> | |
| <div class="client-stamp-actions"> | |
| <button class="client-stamp-btn" onclick="initClientStamper()">Initialize Stamper</button> | |
| <button class="client-stamp-btn secondary" onclick="testClientStamping()">Test Upload</button> | |
| </div> | |
| <div class="client-stamp-status" id="clientStampStatus"></div> | |
| <div class="client-stamp-info" id="clientStampInfo" style="display: none;"> | |
| <div class="stamp-info-row"> | |
| <span class="stamp-info-label">Signer Address:</span> | |
| <span class="stamp-info-value" id="clientSignerAddress">-</span> | |
| </div> | |
| <div class="stamp-info-row"> | |
| <span class="stamp-info-label">Stamper State:</span> | |
| <span class="stamp-info-value" id="clientStamperState">Not initialized</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="stamps-section"> | |
| <div class="stamps-header"> | |
| <h2>Postage Stamps (Local Node)</h2> | |
| <button class="stamps-refresh-btn" onclick="loadStamps()">Refresh</button> | |
| </div> | |
| <div class="stamps-list" id="stampsList"> | |
| <div class="stamps-loading">Loading stamps...</div> | |
| </div> | |
| </div> | |
| <div id="errorContainer"></div> | |
| <div class="drop-zone" id="dropZone"> | |
| <div class="drop-zone-icon">📁</div> | |
| <div class="drop-zone-text">Drop files here or click to browse</div> | |
| <div class="drop-zone-hint">Supports any file type</div> | |
| </div> | |
| <input type="file" id="fileInput" class="file-input" multiple> | |
| <div class="upload-list" id="uploadList"></div> | |
| <div class="url-section"> | |
| <h2>URL to Swarm</h2> | |
| <div class="url-hint"> | |
| Fetch any public URL and upload to Swarm. Works with direct file links (images, PDFs, etc). | |
| For YouTube, use: <code>yt-dlp "URL" -o video.mp4</code> then drag the file here. | |
| </div> | |
| <div class="url-input-row"> | |
| <input type="text" id="fetchUrl" placeholder="Paste any public URL (e.g., https://example.com/image.png)"> | |
| <button class="url-btn" id="fetchBtn" onclick="fetchAndUploadUrl()">Fetch & Upload</button> | |
| </div> | |
| <div class="url-status" id="urlStatus"></div> | |
| </div> | |
| <div class="ipfs-section"> | |
| <h2> | |
| IPFS to Swarm | |
| <span class="helia-status" id="heliaStatus"> | |
| <span class="dot"></span> | |
| <span class="status-text">Loading...</span> | |
| </span> | |
| </h2> | |
| <div class="ipfs-hint"> | |
| Migrate content from IPFS to Swarm. Supports <strong>single files</strong> and <strong>entire websites/directories</strong>. | |
| Enter an IPFS CID like <code>QmXoypiz...</code> or <code>bafybeig...</code> | |
| </div> | |
| <div class="ipfs-input-row"> | |
| <input type="text" id="ipfsCid" placeholder="Enter IPFS CID (e.g., bafybeidtudmi6qajjcsfwnieepuuu6buz2lo7gip3no2iugut6f75vjmd4)"> | |
| <button class="ipfs-btn" id="ipfsBtn" onclick="fetchIpfsAndUpload()">Migrate to Swarm</button> | |
| </div> | |
| <div class="ipfs-status" id="ipfsStatus"></div> | |
| <div class="ipfs-gateway-note"> | |
| <strong>Note:</strong> Large websites may take several minutes. The tool uses IPFS gateways to fetch content. | |
| Progress is shown for each file during directory migration. | |
| </div> | |
| </div> | |
| <div class="debug-section"> | |
| <h2>Hash Debug Tool</h2> | |
| <div class="debug-input-row"> | |
| <input type="text" id="debugHash" placeholder="Paste a Swarm hash to inspect..."> | |
| <button class="debug-btn" id="debugBtn" onclick="debugHash()">Inspect</button> | |
| </div> | |
| <div class="debug-results" id="debugResults"></div> | |
| </div> | |
| <div class="board-section"> | |
| <h2>Message Board (Swarm Feed)</h2> | |
| <div class="board-hint"> | |
| Create or load a decentralized message board using Swarm Feeds. Messages are stored as a linked list on Swarm. | |
| </div> | |
| <div class="board-key-section"> | |
| <div class="board-key-row"> | |
| <input type="text" id="boardPrivateKey" placeholder="Private key (hex) or feed address for read-only"> | |
| <button class="board-btn board-btn-secondary" onclick="boardGenerateKey()">Generate New</button> | |
| <button class="board-btn board-btn-primary" onclick="boardLoadKey()">Load</button> | |
| </div> | |
| <div id="boardKeyInfo" style="display: none;"> | |
| <div class="board-info"> | |
| <span class="board-info-label">Owner:</span> | |
| <span class="board-info-value" id="boardOwnerAddress"></span> | |
| <button class="board-info-copy" onclick="copyToClipboard(document.getElementById('boardOwnerAddress').textContent)">Copy</button> | |
| <span class="board-info-label">Feed:</span> | |
| <span class="board-info-value" id="boardFeedAddress"></span> | |
| <button class="board-info-copy" onclick="copyToClipboard(document.getElementById('boardFeedAddress').textContent)">Copy</button> | |
| </div> | |
| <div class="board-share-links"> | |
| <button class="board-share-btn" onclick="boardCopyWriteLink()">Copy Write Link (with key)</button> | |
| <button class="board-share-btn" onclick="boardCopyReadLink()">Copy Read-Only Link</button> | |
| </div> | |
| </div> | |
| <div id="boardStatus"></div> | |
| </div> | |
| <div id="boardReadonlyNotice" class="board-readonly-notice" style="display: none;"> | |
| Read-only mode. To post messages, load with a private key. | |
| </div> | |
| <div id="boardCompose" class="board-compose" style="display: none;"> | |
| <div class="board-compose-title">New Message</div> | |
| <div class="board-image-preview" id="boardImagePreview"></div> | |
| <textarea id="boardMessageText" placeholder="Write your message..."></textarea> | |
| <div class="board-compose-actions"> | |
| <button class="board-btn board-btn-primary" onclick="boardPostMessage()">Post Message</button> | |
| <button class="board-btn board-btn-secondary" onclick="document.getElementById('boardImageInput').click()">Add Image</button> | |
| <input type="file" id="boardImageInput" accept="image/*" multiple onchange="boardHandleImageSelect(event)"> | |
| </div> | |
| </div> | |
| <div class="board-messages"> | |
| <div class="board-messages-title"> | |
| <span>Messages</span> | |
| <button class="board-btn board-btn-secondary" onclick="boardLoadMessages()" style="padding: 0.3rem 0.6rem; font-size: 0.75rem;">Refresh</button> | |
| </div> | |
| <div id="boardMessagesList"> | |
| <div class="board-empty">Load a feed to see messages</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const dropZone = document.getElementById('dropZone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const uploadList = document.getElementById('uploadList'); | |
| const errorContainer = document.getElementById('errorContainer'); | |
| const beeUrlInput = document.getElementById('beeUrl'); | |
| const gatewayUrlInput = document.getElementById('gatewayUrl'); | |
| const batchIdInput = document.getElementById('batchId'); | |
| const connectionStatus = document.getElementById('connectionStatus'); | |
| // Connection state | |
| let isLocalNodeAvailable = false; | |
| let isGatewayAvailable = false; | |
| // Client-side stamping state | |
| let clientStampingEnabled = false; | |
| let clientStamper = null; | |
| let clientSignerKey = null; | |
| let clientSignerAddress = null; | |
| // Load saved configuration | |
| const savedBeeUrl = localStorage.getItem('swarm-bee-url'); | |
| const savedGatewayUrl = localStorage.getItem('swarm-gateway-url'); | |
| const savedBatchId = localStorage.getItem('swarm-batch-id'); | |
| if (savedBeeUrl) beeUrlInput.value = savedBeeUrl; | |
| if (savedGatewayUrl) gatewayUrlInput.value = savedGatewayUrl; | |
| if (savedBatchId) batchIdInput.value = savedBatchId; | |
| // Load saved client-side stamping settings (not the private key for security) | |
| const savedClientBatchId = localStorage.getItem('swarm-client-batch-id'); | |
| const savedClientBatchDepth = localStorage.getItem('swarm-client-batch-depth'); | |
| if (savedClientBatchId) { | |
| document.getElementById('clientBatchId').value = savedClientBatchId; | |
| } | |
| if (savedClientBatchDepth) { | |
| document.getElementById('clientBatchDepth').value = savedClientBatchDepth; | |
| } | |
| // Get the active Bee URL (local if available, gateway otherwise) | |
| function getActiveUrl() { | |
| if (isLocalNodeAvailable) { | |
| return beeUrlInput.value.trim(); | |
| } | |
| if (isGatewayAvailable) { | |
| return gatewayUrlInput.value.trim(); | |
| } | |
| return beeUrlInput.value.trim(); // fallback | |
| } | |
| // Check if we can upload (local node with stamps) | |
| function canUpload() { | |
| return isLocalNodeAvailable && batchIdInput.value.trim(); | |
| } | |
| // Update connection status UI | |
| function updateConnectionStatus(status, message) { | |
| connectionStatus.className = 'connection-status ' + status; | |
| connectionStatus.querySelector('.status-text').textContent = message; | |
| } | |
| // Check connection to local node and gateway | |
| async function checkConnections() { | |
| updateConnectionStatus('', 'Checking connections...'); | |
| // Check local node | |
| const localUrl = beeUrlInput.value.trim(); | |
| const gatewayUrl = gatewayUrlInput.value.trim(); | |
| try { | |
| const localResp = await fetch(`${localUrl}/health`, { | |
| method: 'GET', | |
| signal: AbortSignal.timeout(3000) | |
| }); | |
| isLocalNodeAvailable = localResp.ok; | |
| } catch (e) { | |
| isLocalNodeAvailable = false; | |
| } | |
| // Check gateway using bee-js (handles CORS better) | |
| if (!isLocalNodeAvailable && gatewayUrl) { | |
| try { | |
| const bee = new BeeJs.Bee(gatewayUrl); | |
| // Try to download a zero hash - will fail with 404 but proves connectivity | |
| await bee.downloadData('0000000000000000000000000000000000000000000000000000000000000000'); | |
| isGatewayAvailable = true; | |
| } catch (e) { | |
| // 404 means gateway is reachable but hash not found - that's fine | |
| if (e.message.includes('404') || e.message.includes('Not Found') || e.status === 404) { | |
| isGatewayAvailable = true; | |
| } else { | |
| isGatewayAvailable = false; | |
| } | |
| } | |
| } | |
| // Update UI based on connection status | |
| if (isLocalNodeAvailable) { | |
| updateConnectionStatus('connected', `Connected to local Bee node (${localUrl})`); | |
| loadStamps(); | |
| } else if (isGatewayAvailable) { | |
| updateConnectionStatus('gateway', `Gateway mode: ${gatewayUrl} (limited uploads via sponsored batch)`); | |
| document.getElementById('stampsList').innerHTML = '<div class="stamps-empty">Gateway mode - using sponsored batch for uploads. Start a local Bee node for full control.</div>'; | |
| } else { | |
| updateConnectionStatus('disconnected', 'No connection - start a local Bee node or check gateway URL'); | |
| document.getElementById('stampsList').innerHTML = '<div class="stamps-empty">Not connected to any Swarm node.</div>'; | |
| } | |
| } | |
| // Save configuration on change | |
| beeUrlInput.addEventListener('change', () => { | |
| localStorage.setItem('swarm-bee-url', beeUrlInput.value); | |
| checkConnections(); | |
| }); | |
| gatewayUrlInput.addEventListener('change', () => { | |
| localStorage.setItem('swarm-gateway-url', gatewayUrlInput.value); | |
| checkConnections(); | |
| }); | |
| batchIdInput.addEventListener('change', () => { | |
| localStorage.setItem('swarm-batch-id', batchIdInput.value); | |
| updateSelectedStamp(); | |
| }); | |
| // Postage Stamps functionality | |
| let stampsData = []; | |
| let batchesData = {}; | |
| let nodeAddress = ''; | |
| async function loadStamps() { | |
| const beeUrl = beeUrlInput.value.trim(); | |
| const stampsList = document.getElementById('stampsList'); | |
| if (!beeUrl) { | |
| stampsList.innerHTML = '<div class="stamps-empty">Configure Bee Node URL first</div>'; | |
| return; | |
| } | |
| stampsList.innerHTML = '<div class="stamps-loading">Loading stamps...</div>'; | |
| try { | |
| // Fetch stamps, batches (for owner info), and node address in parallel | |
| const [stampsResp, batchesResp, addressResp] = await Promise.all([ | |
| fetch(`${beeUrl}/stamps`), | |
| fetch(`${beeUrl}/batches`).catch(() => null), | |
| fetch(`${beeUrl}/addresses`).catch(() => null) | |
| ]); | |
| if (!stampsResp.ok) { | |
| throw new Error(`HTTP ${stampsResp.status}`); | |
| } | |
| const stampsJson = await stampsResp.json(); | |
| stampsData = stampsJson.stamps || []; | |
| // Build a map of batchID -> owner from batches endpoint | |
| if (batchesResp && batchesResp.ok) { | |
| const batchesJson = await batchesResp.json(); | |
| batchesData = {}; | |
| (batchesJson.batches || []).forEach(b => { | |
| batchesData[b.batchID] = b.owner; | |
| }); | |
| } | |
| // Get node's ethereum address | |
| if (addressResp && addressResp.ok) { | |
| const addressJson = await addressResp.json(); | |
| nodeAddress = addressJson.ethereum || addressJson.chain_address || ''; | |
| } | |
| if (stampsData.length === 0) { | |
| stampsList.innerHTML = '<div class="stamps-empty">No postage stamps found. Buy a stamp in Swarm Desktop or via the API.</div>'; | |
| return; | |
| } | |
| renderStamps(); | |
| } catch (error) { | |
| stampsList.innerHTML = `<div class="stamps-empty" style="color: #ff5252;">Failed to load stamps: ${error.message}</div>`; | |
| } | |
| } | |
| function renderStamps() { | |
| const stampsList = document.getElementById('stampsList'); | |
| const currentBatchId = batchIdInput.value.trim(); | |
| stampsList.innerHTML = stampsData.map(stamp => { | |
| const isSelected = stamp.batchID === currentBatchId; | |
| const utilizationPercent = Math.min(100, (stamp.utilization / Math.pow(2, stamp.depth - stamp.bucketDepth)) * 100); | |
| const ttlHours = Math.floor(stamp.batchTTL / 3600); | |
| const ttlDays = Math.floor(ttlHours / 24); | |
| const ttlDisplay = ttlDays > 0 ? `${ttlDays}d ${ttlHours % 24}h` : `${ttlHours}h`; | |
| // Calculate capacity | |
| const capacityBytes = Math.pow(2, stamp.depth) * 4096; // 4KB chunks | |
| const capacityDisplay = formatFileSize(capacityBytes); | |
| // Calculate used capacity | |
| const usedBytes = (stamp.utilization / Math.pow(2, stamp.depth - stamp.bucketDepth)) * capacityBytes; | |
| const usedDisplay = formatFileSize(usedBytes); | |
| // Utilization bar color | |
| let utilizationColor = '#00c853'; // green | |
| if (utilizationPercent > 80) utilizationColor = '#ff5252'; // red | |
| else if (utilizationPercent > 50) utilizationColor = '#ffc107'; // yellow | |
| return ` | |
| <div class="stamp-card ${isSelected ? 'selected' : ''}" onclick="selectStamp('${stamp.batchID}')"> | |
| <div class="stamp-card-header"> | |
| <span class="stamp-label">${stamp.label || 'Unnamed Stamp'}</span> | |
| <span class="stamp-status ${stamp.usable ? 'usable' : 'unusable'}">${stamp.usable ? 'USABLE' : 'UNUSABLE'}</span> | |
| </div> | |
| <div class="stamp-id">${stamp.batchID}</div> | |
| <div class="stamp-details"> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Time To Live - How long until this stamp expires and data may be garbage collected">TTL</span> | |
| <span class="stamp-detail-value">${ttlDisplay}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Maximum storage capacity of this stamp (2^depth × 4KB chunks)">Capacity</span> | |
| <span class="stamp-detail-value">${capacityDisplay}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Amount of stamp capacity currently used by uploaded data">Used</span> | |
| <span class="stamp-detail-value">${usedDisplay} (${utilizationPercent.toFixed(1)}%)</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Determines stamp capacity. Each +1 depth doubles the capacity (and cost)">Depth</span> | |
| <span class="stamp-detail-value">${stamp.depth}</span> | |
| </div> | |
| </div> | |
| <div class="stamp-utilization-bar"> | |
| <div class="stamp-utilization-fill" style="width: ${utilizationPercent}%; background: ${utilizationColor};"></div> | |
| </div> | |
| ${isSelected ? (() => { | |
| const owner = batchesData[stamp.batchID] || ''; | |
| const ownerDisplay = owner ? '0x' + owner : 'Unknown'; | |
| const isOwnStamp = owner && nodeAddress && nodeAddress.toLowerCase().endsWith(owner.toLowerCase()); | |
| return ` | |
| <div class="stamp-expanded"> | |
| <div class="stamp-expanded-details"> | |
| <div class="stamp-detail" style="grid-column: 1 / -1;"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Ethereum address that owns this stamp batch on Gnosis Chain">Owner</span> | |
| <span class="stamp-detail-value" style="font-family: monospace; font-size: 0.75rem;"> | |
| ${ownerDisplay} | |
| ${isOwnStamp ? '<span style="color: #00c853; margin-left: 0.5rem;">(Your node)</span>' : ''} | |
| </span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="PLUR = smallest unit of BZZ token (1 BZZ = 10^16 PLUR). Amount paid for this stamp's storage duration.">Amount (PLUR)</span> | |
| <span class="stamp-detail-value">${Number(stamp.amount).toLocaleString()}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Gnosis Chain block number when this stamp was purchased">Block Number</span> | |
| <span class="stamp-detail-value">${stamp.blockNumber.toLocaleString()}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Number of collision buckets (2^bucketDepth). Higher = more even distribution of chunks">Bucket Depth</span> | |
| <span class="stamp-detail-value">${stamp.bucketDepth}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="If true, uploaded content cannot be overwritten or updated">Immutable</span> | |
| <span class="stamp-detail-value">${stamp.immutableFlag ? 'Yes' : 'No'}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Raw utilization counter - number of chunks stored across all buckets">Utilization</span> | |
| <span class="stamp-detail-value">${stamp.utilization}</span> | |
| </div> | |
| <div class="stamp-detail"> | |
| <span class="stamp-detail-label tooltip" data-tooltip="Whether this stamp batch exists on the blockchain">Exists</span> | |
| <span class="stamp-detail-value">${stamp.exists ? 'Yes' : 'No'}</span> | |
| </div> | |
| </div> | |
| <div class="topup-row" onclick="event.stopPropagation()"> | |
| <input type="number" class="topup-input" id="topup-days-${stamp.batchID}" | |
| placeholder="Days to add" min="1" step="1" | |
| oninput="updateTopupCost('${stamp.batchID}', ${stamp.batchTTL}, '${stamp.amount}', ${stamp.depth})" /> | |
| <button class="topup-btn" onclick="topUpStamp('${stamp.batchID}', ${stamp.batchTTL}, '${stamp.amount}', ${stamp.depth})">Top Up TTL</button> | |
| </div> | |
| <div class="topup-cost" id="topup-cost-${stamp.batchID}"></div> | |
| <div class="topup-status" id="topup-status-${stamp.batchID}"></div> | |
| <div class="pins-section" onclick="event.stopPropagation()"> | |
| <div class="pins-header"> | |
| <span class="pins-title">Pinned Content on Node</span> | |
| <button class="pins-load-btn" onclick="loadPins('${stamp.batchID}')">Load Pins</button> | |
| </div> | |
| <div class="pins-list" id="pins-list-${stamp.batchID}"> | |
| <div class="pins-empty">Click "Load Pins" to see pinned content</div> | |
| </div> | |
| <div class="pins-note">Note: Pins are node-wide, not stamp-specific. The Swarm API doesn't track which stamp was used for each upload.</div> | |
| </div> | |
| </div> | |
| `;})() : ''} | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function selectStamp(batchId) { | |
| batchIdInput.value = batchId; | |
| localStorage.setItem('swarm-batch-id', batchId); | |
| renderStamps(); | |
| } | |
| function updateSelectedStamp() { | |
| renderStamps(); | |
| } | |
| async function loadPins(batchId) { | |
| const beeUrl = beeUrlInput.value.trim(); | |
| const pinsListDiv = document.getElementById(`pins-list-${batchId}`); | |
| if (!beeUrl) { | |
| pinsListDiv.innerHTML = '<div class="pins-empty" style="color: #ff5252;">Configure Bee Node URL first</div>'; | |
| return; | |
| } | |
| pinsListDiv.innerHTML = '<div class="pins-empty">Loading pins...</div>'; | |
| try { | |
| const response = await fetch(`${beeUrl}/pins`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| const pins = data.references || []; | |
| if (pins.length === 0) { | |
| pinsListDiv.innerHTML = '<div class="pins-empty">No pinned content found on this node</div>'; | |
| return; | |
| } | |
| pinsListDiv.innerHTML = pins.map(hash => ` | |
| <div class="pin-item"> | |
| <span class="pin-hash" title="${hash}">${hash}</span> | |
| <div class="pin-actions"> | |
| <a href="${beeUrl}/bzz/${hash}/" target="_blank">Open</a> | |
| <a href="https://gateway.ethswarm.org/bzz/${hash}/" target="_blank">Gateway</a> | |
| <button onclick="copyToClipboard('${hash}'); event.stopPropagation();">Copy</button> | |
| <button onclick="document.getElementById('debugHash').value='${hash}'; debugHash(); event.stopPropagation();">Debug</button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } catch (error) { | |
| pinsListDiv.innerHTML = `<div class="pins-empty" style="color: #ff5252;">Failed to load pins: ${error.message}</div>`; | |
| } | |
| } | |
| function updateTopupCost(batchId, currentTTL, currentAmount, depth) { | |
| const daysInput = document.getElementById(`topup-days-${batchId}`); | |
| const costDiv = document.getElementById(`topup-cost-${batchId}`); | |
| const days = parseFloat(daysInput.value.trim()); | |
| if (!days || days <= 0) { | |
| costDiv.innerHTML = ''; | |
| return; | |
| } | |
| // Calculate PLUR amount needed | |
| // The amount in stamp data is per-chunk, total cost = amount * 2^depth | |
| // To add time: we need (amount_per_chunk / TTL_seconds) * seconds_to_add * 2^depth | |
| const secondsToAdd = days * 86400; | |
| const chunks = BigInt(2) ** BigInt(depth); | |
| const plurPerSecondPerChunk = BigInt(currentAmount) / BigInt(currentTTL); | |
| const plurToAdd = plurPerSecondPerChunk * BigInt(Math.ceil(secondsToAdd)) * chunks; | |
| // Convert to BZZ (1 BZZ = 10^16 PLUR) | |
| // Use string manipulation for BigInt to avoid precision loss | |
| const plurStr = plurToAdd.toString().padStart(17, '0'); | |
| const wholePart = plurStr.slice(0, -16) || '0'; | |
| const fracPart = plurStr.slice(-16); | |
| const bzzDisplay = `${wholePart}.${fracPart.slice(0, 6)}`; | |
| costDiv.innerHTML = `Cost: <span class="bzz-amount">~${bzzDisplay} BZZ</span>`; | |
| } | |
| async function topUpStamp(batchId, currentTTL, currentAmount, depth) { | |
| const beeUrl = beeUrlInput.value.trim(); | |
| const daysInput = document.getElementById(`topup-days-${batchId}`); | |
| const statusDiv = document.getElementById(`topup-status-${batchId}`); | |
| const days = parseFloat(daysInput.value.trim()); | |
| if (!days || days <= 0) { | |
| statusDiv.innerHTML = '<span class="error">Please enter a valid number of days</span>'; | |
| statusDiv.className = 'topup-status error'; | |
| return; | |
| } | |
| // Calculate PLUR amount based on current TTL and amount | |
| // The API expects the per-chunk amount to add, not the total | |
| // amount is per-chunk, so we calculate per-chunk amount to add for the days | |
| const secondsToAdd = days * 86400; // 86400 seconds per day | |
| const plurPerSecondPerChunk = BigInt(currentAmount) / BigInt(currentTTL); | |
| const plurToAdd = plurPerSecondPerChunk * BigInt(Math.ceil(secondsToAdd)); | |
| // Calculate total BZZ cost for display (per-chunk * 2^depth) | |
| const chunks = BigInt(2) ** BigInt(depth); | |
| const totalPlur = plurToAdd * chunks; | |
| const plurStr = totalPlur.toString().padStart(17, '0'); | |
| const bzzDisplay = `${plurStr.slice(0, -16) || '0'}.${plurStr.slice(-16).slice(0, 4)}`; | |
| // Find the button and disable it | |
| const btn = daysInput.nextElementSibling; | |
| const originalText = btn.textContent; | |
| btn.disabled = true; | |
| btn.textContent = 'Processing...'; | |
| statusDiv.innerHTML = `Sending top-up transaction for ${days} day(s) (~${bzzDisplay} BZZ)...`; | |
| statusDiv.className = 'topup-status'; | |
| try { | |
| const response = await fetch(`${beeUrl}/stamps/topup/${batchId}/${plurToAdd.toString()}`, { | |
| method: 'PATCH', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| let errorMsg = `HTTP ${response.status}`; | |
| try { | |
| const errorData = await response.json(); | |
| errorMsg = errorData.message || errorData.error || errorMsg; | |
| } catch (e) {} | |
| throw new Error(errorMsg); | |
| } | |
| const result = await response.json(); | |
| statusDiv.innerHTML = `Top-up successful! Added ${days} days. Batch ID: <code style="font-size: 0.7rem; word-break: break-all;">${result.batchID || batchId}</code>`; | |
| statusDiv.className = 'topup-status success'; | |
| daysInput.value = ''; | |
| // Reload stamps to show updated TTL | |
| setTimeout(() => { | |
| loadStamps(); | |
| }, 2000); | |
| } catch (error) { | |
| console.error('Top-up error:', error); | |
| statusDiv.innerHTML = `Top-up failed: ${error.message}`; | |
| statusDiv.className = 'topup-status error'; | |
| } | |
| btn.disabled = false; | |
| btn.textContent = originalText; | |
| } | |
| // Check connections and load stamps on page load | |
| setTimeout(checkConnections, 500); | |
| // ===== CLIENT-SIDE STAMPING FUNCTIONS ===== | |
| function toggleClientStamping() { | |
| const enabled = document.getElementById('clientStampEnabled').checked; | |
| const configDiv = document.getElementById('clientStampConfig'); | |
| clientStampingEnabled = enabled; | |
| configDiv.style.display = enabled ? 'block' : 'none'; | |
| if (!enabled) { | |
| // Reset state when disabled | |
| clientStamper = null; | |
| updateClientStampStatus('', ''); | |
| } | |
| } | |
| function updateClientStampStatus(message, type) { | |
| const statusDiv = document.getElementById('clientStampStatus'); | |
| if (!message) { | |
| statusDiv.innerHTML = ''; | |
| statusDiv.className = 'client-stamp-status'; | |
| return; | |
| } | |
| statusDiv.innerHTML = message; | |
| statusDiv.className = 'client-stamp-status ' + type; | |
| } | |
| async function initClientStamper() { | |
| const signerKeyInput = document.getElementById('clientSignerKey'); | |
| const batchIdInput = document.getElementById('clientBatchId'); | |
| const depthInput = document.getElementById('clientBatchDepth'); | |
| const signerKey = signerKeyInput.value.trim().replace(/^0x/, ''); | |
| const batchId = batchIdInput.value.trim().replace(/^0x/, ''); | |
| const depth = parseInt(depthInput.value) || 20; | |
| if (!signerKey || signerKey.length !== 64) { | |
| updateClientStampStatus('Please enter a valid 64-character hex private key', 'error'); | |
| return; | |
| } | |
| if (!batchId || batchId.length !== 64) { | |
| updateClientStampStatus('Please enter a valid 64-character batch ID', 'error'); | |
| return; | |
| } | |
| updateClientStampStatus('Initializing stamper...', 'info'); | |
| try { | |
| // Check if bee-js has the Stamper class | |
| if (!BeeJs.Stamper) { | |
| throw new Error('Stamper class not found in bee-js. Make sure you have the latest version (v10+).'); | |
| } | |
| // Derive address from private key | |
| let address; | |
| try { | |
| if (BeeJs.PrivateKey) { | |
| const pk = new BeeJs.PrivateKey(signerKey); | |
| address = pk.publicKey?.address?.toHex?.() || pk.address?.toHex?.(); | |
| if (!address) { | |
| // Try alternative methods | |
| const pubKey = pk.toPublicKey?.() || pk.publicKey; | |
| address = pubKey?.toAddress?.()?.toHex?.() || pubKey?.address?.toHex?.(); | |
| } | |
| } | |
| } catch (e) { | |
| console.log('PrivateKey method failed:', e.message); | |
| } | |
| // Fallback: derive address using feed writer | |
| if (!address) { | |
| try { | |
| const tempBee = new BeeJs.Bee(getActiveUrl() || 'http://localhost:1633'); | |
| const tempTopic = '0000000000000000000000000000000000000000000000000000000000000000'; | |
| const writer = tempBee.makeFeedWriter(tempTopic, signerKey); | |
| if (writer.owner) { | |
| address = typeof writer.owner === 'string' | |
| ? writer.owner.replace(/^0x/, '') | |
| : (writer.owner.toHex?.() || bytesToHex(writer.owner)).replace(/^0x/, ''); | |
| } | |
| } catch (e) { | |
| console.log('FeedWriter method failed:', e.message); | |
| } | |
| } | |
| if (!address) { | |
| throw new Error('Could not derive address from private key'); | |
| } | |
| // Create the stamper | |
| // Stamper.fromBlank(signer, batchId, depth) | |
| clientStamper = BeeJs.Stamper.fromBlank(signerKey, batchId, depth); | |
| clientSignerKey = signerKey; | |
| clientSignerAddress = address; | |
| // Update UI | |
| document.getElementById('clientSignerAddress').textContent = '0x' + address; | |
| document.getElementById('clientStamperState').textContent = 'Ready'; | |
| document.getElementById('clientStampInfo').style.display = 'block'; | |
| updateClientStampStatus('Stamper initialized successfully! Uploads will now use client-side stamping.', 'success'); | |
| // Save to localStorage (not the private key, just that it's enabled) | |
| localStorage.setItem('swarm-client-stamping-enabled', 'true'); | |
| localStorage.setItem('swarm-client-batch-id', batchId); | |
| localStorage.setItem('swarm-client-batch-depth', depth.toString()); | |
| } catch (e) { | |
| console.error('Error initializing stamper:', e); | |
| updateClientStampStatus('Error: ' + e.message, 'error'); | |
| clientStamper = null; | |
| } | |
| } | |
| async function testClientStamping() { | |
| if (!clientStamper) { | |
| updateClientStampStatus('Please initialize the stamper first', 'error'); | |
| return; | |
| } | |
| const gatewayUrl = gatewayUrlInput.value.trim(); | |
| if (!gatewayUrl) { | |
| updateClientStampStatus('Please configure a gateway URL', 'error'); | |
| return; | |
| } | |
| updateClientStampStatus('Testing client-side stamped upload...', 'info'); | |
| try { | |
| const bee = new BeeJs.Bee(gatewayUrl); | |
| const testData = new TextEncoder().encode('Hello from client-side stamping! ' + Date.now()); | |
| // Check if bee-js supports uploadChunk with envelope | |
| // The flow is: create chunk -> stamp it -> upload stamped chunk | |
| // For now, test with uploadData and see if we can pass the stamper | |
| // The latest bee-js might have uploadData accept a stamper option | |
| // Try using the stamper directly with the Bee instance | |
| if (typeof bee.uploadData === 'function') { | |
| // Check if uploadData accepts stamper or envelope options | |
| const result = await bee.uploadData(clientStamper, testData); | |
| const reference = typeof result.reference === 'string' | |
| ? result.reference | |
| : (result.reference.toHex?.() || bytesToHex(result.reference)); | |
| updateClientStampStatus( | |
| `Test upload successful!<br>Reference: <code style="font-size: 0.8em;">${reference}</code><br>` + | |
| `<a href="${gatewayUrl}/bytes/${reference}" target="_blank" style="color: #81c784;">View on gateway</a>`, | |
| 'success' | |
| ); | |
| document.getElementById('clientStamperState').textContent = 'Active - Last upload: ' + new Date().toLocaleTimeString(); | |
| } else { | |
| throw new Error('uploadData method not found on Bee instance'); | |
| } | |
| } catch (e) { | |
| console.error('Test upload error:', e); | |
| updateClientStampStatus('Test upload failed: ' + e.message, 'error'); | |
| } | |
| } | |
| // Check if client-side stamping can be used for an upload | |
| function canUseClientStamping() { | |
| return clientStampingEnabled && clientStamper !== null; | |
| } | |
| // Get the stamper or batch ID for uploads | |
| function getStampForUpload() { | |
| if (canUseClientStamping()) { | |
| return clientStamper; | |
| } | |
| if (isLocalNodeAvailable) { | |
| return batchIdInput.value.trim(); | |
| } | |
| if (isGatewayAvailable) { | |
| return '0000000000000000000000000000000000000000000000000000000000000000'; | |
| } | |
| return null; | |
| } | |
| // ===== END CLIENT-SIDE STAMPING FUNCTIONS ===== | |
| // Drag and drop handlers | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('drag-over'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => { | |
| dropZone.classList.remove('drag-over'); | |
| }); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| handleFiles(e.dataTransfer.files); | |
| }); | |
| fileInput.addEventListener('change', () => { | |
| handleFiles(fileInput.files); | |
| fileInput.value = ''; | |
| }); | |
| function showError(message) { | |
| errorContainer.innerHTML = `<div class="error-message">${message}</div>`; | |
| setTimeout(() => { | |
| errorContainer.innerHTML = ''; | |
| }, 5000); | |
| } | |
| function formatFileSize(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| function getFileIcon(type) { | |
| if (type.startsWith('image/')) return '🖼️'; | |
| if (type.startsWith('video/')) return '🎬'; | |
| if (type.startsWith('audio/')) return '🎵'; | |
| if (type.includes('pdf')) return '📄'; | |
| if (type.includes('zip') || type.includes('tar') || type.includes('rar')) return '📦'; | |
| if (type.includes('text') || type.includes('javascript') || type.includes('json')) return '📝'; | |
| return '📁'; | |
| } | |
| async function handleFiles(files) { | |
| let beeUrl, stamp; | |
| // Check if client-side stamping is enabled | |
| if (canUseClientStamping()) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = clientStamper; | |
| if (!beeUrl) { | |
| showError('Please configure a gateway URL for client-side stamping'); | |
| return; | |
| } | |
| } else if (isLocalNodeAvailable) { | |
| beeUrl = beeUrlInput.value.trim(); | |
| stamp = batchIdInput.value.trim(); | |
| if (!beeUrl || !stamp) { | |
| showError('Please configure Bee URL and select a postage stamp'); | |
| return; | |
| } | |
| } else if (isGatewayAvailable) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = '0000000000000000000000000000000000000000000000000000000000000000'; | |
| } else { | |
| showError('No connection available. Start a local Bee node or check gateway URL.'); | |
| return; | |
| } | |
| for (const file of files) { | |
| await uploadFile(file, beeUrl, stamp); | |
| } | |
| } | |
| async function uploadFile(file, beeUrl, stamp) { | |
| const itemId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); | |
| const isClientStamped = stamp && typeof stamp === 'object' && stamp.stamp; // Check if it's a Stamper | |
| const itemHtml = ` | |
| <div class="upload-item" id="item-${itemId}"> | |
| <div class="upload-item-icon">${getFileIcon(file.type)}</div> | |
| <div class="upload-item-info"> | |
| <div class="upload-item-name">${file.name}</div> | |
| <div class="upload-item-size">${formatFileSize(file.size)}${isClientStamped ? ' (client-stamped)' : ''}</div> | |
| <div class="upload-item-link" id="link-${itemId}"></div> | |
| </div> | |
| <div class="upload-item-status status-uploading" id="status-${itemId}">Uploading...</div> | |
| </div> | |
| `; | |
| uploadList.insertAdjacentHTML('afterbegin', itemHtml); | |
| try { | |
| const bee = new BeeJs.Bee(beeUrl); | |
| const result = await bee.uploadFile(stamp, file, file.name, { | |
| contentType: file.type, | |
| deferred: false // Upload immediately to the network | |
| }); | |
| const reference = result.reference; | |
| const swarmUrl = `${beeUrl}/bzz/${reference}/`; | |
| const gatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`; | |
| document.getElementById(`status-${itemId}`).className = 'upload-item-status status-success'; | |
| document.getElementById(`status-${itemId}`).textContent = 'Success'; | |
| document.getElementById(`link-${itemId}`).innerHTML = ` | |
| <a href="${gatewayUrl}" target="_blank">${reference}</a> | |
| <button class="copy-btn" onclick="copyToClipboard('${reference}')">Copy Hash</button> | |
| <button class="copy-btn" onclick="copyToClipboard('${gatewayUrl}')">Copy URL</button> | |
| `; | |
| } catch (error) { | |
| console.error('Upload error:', error); | |
| document.getElementById(`status-${itemId}`).className = 'upload-item-status status-error'; | |
| document.getElementById(`status-${itemId}`).textContent = 'Failed'; | |
| let errorMsg = error.message || 'Unknown error'; | |
| if (error.message && error.message.includes('Failed to fetch')) { | |
| errorMsg = 'Cannot connect to Bee node. Check if it\'s running and CORS is enabled.'; | |
| } | |
| document.getElementById(`link-${itemId}`).innerHTML = `<span style="color: #ff5252; font-size: 0.85rem;">${errorMsg}</span>`; | |
| } | |
| } | |
| function copyToClipboard(text) { | |
| navigator.clipboard.writeText(text).then(() => { | |
| const btn = event.target; | |
| const originalText = btn.textContent; | |
| btn.textContent = 'Copied!'; | |
| setTimeout(() => { | |
| btn.textContent = originalText; | |
| }, 1500); | |
| }); | |
| } | |
| // Debug functionality | |
| async function debugHash() { | |
| const hash = document.getElementById('debugHash').value.trim(); | |
| const beeUrl = getActiveUrl(); | |
| const resultsDiv = document.getElementById('debugResults'); | |
| const debugBtn = document.getElementById('debugBtn'); | |
| if (!hash) { | |
| resultsDiv.innerHTML = '<div class="debug-item"><div class="debug-item-content" style="color: #ff5252;">Please enter a hash to inspect</div></div>'; | |
| return; | |
| } | |
| if (!beeUrl) { | |
| resultsDiv.innerHTML = '<div class="debug-item"><div class="debug-item-content" style="color: #ff5252;">No Swarm connection available</div></div>'; | |
| return; | |
| } | |
| debugBtn.disabled = true; | |
| debugBtn.textContent = 'Inspecting...'; | |
| resultsDiv.innerHTML = '<div class="debug-loading">Gathering information from your Bee node...</div>'; | |
| const results = []; | |
| // Helper function to make API calls | |
| async function apiCall(endpoint, description) { | |
| try { | |
| const response = await fetch(`${beeUrl}${endpoint}`); | |
| const status = response.status; | |
| let data = null; | |
| const contentType = response.headers.get('content-type'); | |
| if (contentType && contentType.includes('application/json')) { | |
| data = await response.json(); | |
| } | |
| return { success: status >= 200 && status < 300, status, data, error: null }; | |
| } catch (error) { | |
| return { success: false, status: null, data: null, error: error.message }; | |
| } | |
| } | |
| // 1. Check if chunk exists locally | |
| try { | |
| const chunkResp = await fetch(`${beeUrl}/chunks/${hash}`, { method: 'HEAD' }); | |
| results.push({ | |
| title: 'Local Chunk Status', | |
| status: chunkResp.status === 200 ? 'success' : 'error', | |
| statusText: chunkResp.status === 200 ? 'EXISTS' : 'NOT FOUND', | |
| content: chunkResp.status === 200 | |
| ? 'The root chunk exists on your local node.' | |
| : 'The root chunk was not found on your local node.' | |
| }); | |
| } catch (e) { | |
| results.push({ | |
| title: 'Local Chunk Status', | |
| status: 'error', | |
| statusText: 'ERROR', | |
| content: `Failed to check: ${e.message}` | |
| }); | |
| } | |
| // 2. Check pin status | |
| const pinResult = await apiCall(`/pins/${hash}`, 'Pin Status'); | |
| if (pinResult.success && pinResult.data) { | |
| results.push({ | |
| title: 'Pin Status', | |
| status: 'success', | |
| statusText: 'PINNED', | |
| content: 'This content is pinned on your node.', | |
| json: pinResult.data | |
| }); | |
| } else { | |
| results.push({ | |
| title: 'Pin Status', | |
| status: 'warning', | |
| statusText: 'NOT PINNED', | |
| content: 'This content is not pinned on your node.' | |
| }); | |
| } | |
| // 3. Check stewardship / retrievability (this can take time) | |
| results.push({ | |
| title: 'Network Retrievability', | |
| status: 'info', | |
| statusText: 'CHECKING...', | |
| content: 'Checking if content is retrievable from the network...', | |
| id: 'stewardship-result' | |
| }); | |
| // Render current results while stewardship check runs | |
| renderDebugResults(results); | |
| // Run stewardship check (can be slow) | |
| try { | |
| const stewardshipResp = await fetch(`${beeUrl}/stewardship/${hash}`, { | |
| signal: AbortSignal.timeout(30000) | |
| }); | |
| const stewardshipIdx = results.findIndex(r => r.id === 'stewardship-result'); | |
| if (stewardshipResp.status === 200) { | |
| const stewardshipData = await stewardshipResp.json(); | |
| results[stewardshipIdx] = { | |
| title: 'Network Retrievability', | |
| status: stewardshipData.isRetrievable ? 'success' : 'error', | |
| statusText: stewardshipData.isRetrievable ? 'RETRIEVABLE' : 'NOT RETRIEVABLE', | |
| content: stewardshipData.isRetrievable | |
| ? 'Content can be retrieved from the Swarm network.' | |
| : 'Content cannot currently be retrieved from the network. It may not have propagated yet.', | |
| json: stewardshipData | |
| }; | |
| } else { | |
| results[stewardshipIdx] = { | |
| title: 'Network Retrievability', | |
| status: 'error', | |
| statusText: 'UNKNOWN', | |
| content: `Stewardship check returned status ${stewardshipResp.status}` | |
| }; | |
| } | |
| } catch (e) { | |
| const stewardshipIdx = results.findIndex(r => r.id === 'stewardship-result'); | |
| results[stewardshipIdx] = { | |
| title: 'Network Retrievability', | |
| status: 'warning', | |
| statusText: 'TIMEOUT', | |
| content: `Stewardship check timed out or failed: ${e.message}. This is common for content not yet on the network.` | |
| }; | |
| } | |
| // 4. Try to get file info via bzz endpoint | |
| try { | |
| const bzzResp = await fetch(`${beeUrl}/bzz/${hash}/`, { method: 'HEAD' }); | |
| const contentType = bzzResp.headers.get('content-type'); | |
| const contentLength = bzzResp.headers.get('content-length'); | |
| const swarmTag = bzzResp.headers.get('swarm-tag'); | |
| if (bzzResp.status === 200) { | |
| results.push({ | |
| title: 'File Information', | |
| status: 'success', | |
| statusText: 'AVAILABLE', | |
| content: `Content-Type: ${contentType || 'unknown'}\nContent-Length: ${contentLength ? formatFileSize(parseInt(contentLength)) : 'unknown'}${swarmTag ? `\nSwarm-Tag: ${swarmTag}` : ''}` | |
| }); | |
| } else { | |
| results.push({ | |
| title: 'File Information', | |
| status: 'warning', | |
| statusText: `HTTP ${bzzResp.status}`, | |
| content: 'Could not retrieve file metadata from bzz endpoint.' | |
| }); | |
| } | |
| } catch (e) { | |
| results.push({ | |
| title: 'File Information', | |
| status: 'error', | |
| statusText: 'ERROR', | |
| content: `Failed to get file info: ${e.message}` | |
| }); | |
| } | |
| // 5. Get node status for context | |
| const statusResult = await apiCall('/status', 'Node Status'); | |
| if (statusResult.success && statusResult.data) { | |
| const s = statusResult.data; | |
| results.push({ | |
| title: 'Your Node Status', | |
| status: 'info', | |
| statusText: s.beeMode.toUpperCase(), | |
| content: `Mode: ${s.beeMode}\nConnected Peers: ${s.connectedPeers}\nNeighborhood Size: ${s.neighborhoodSize}\nStorage Radius: ${s.storageRadius}\nReachable: ${s.isReachable ? 'Yes' : 'No'}`, | |
| json: s | |
| }); | |
| } | |
| // 6. Check topology for relevant peers | |
| const topoResult = await apiCall('/topology', 'Topology'); | |
| if (topoResult.success && topoResult.data) { | |
| const t = topoResult.data; | |
| // Calculate which bin the hash would fall into | |
| const hashPrefix = hash.substring(0, 2); | |
| const nodePrefix = t.baseAddr.substring(0, 2); | |
| results.push({ | |
| title: 'Network Topology', | |
| status: 'info', | |
| statusText: `${t.connected} PEERS`, | |
| content: `Total Known Peers: ${t.population}\nConnected Peers: ${t.connected}\nNetwork Depth: ${t.depth}\nReachability: ${t.reachability}\nNetwork: ${t.networkAvailability}\n\nYour node address: ${t.baseAddr.substring(0, 16)}...\nTarget hash prefix: ${hashPrefix}...` | |
| }); | |
| } | |
| // 7. Gateway check | |
| results.push({ | |
| title: 'Public Gateway', | |
| status: 'info', | |
| statusText: 'LINK', | |
| content: `Gateway URL: https://gateway.ethswarm.org/bzz/${hash}/\n\nNote: Public gateway access depends on network propagation.`, | |
| link: `https://gateway.ethswarm.org/bzz/${hash}/` | |
| }); | |
| renderDebugResults(results); | |
| debugBtn.disabled = false; | |
| debugBtn.textContent = 'Inspect'; | |
| } | |
| function renderDebugResults(results) { | |
| const resultsDiv = document.getElementById('debugResults'); | |
| resultsDiv.innerHTML = results.map(r => ` | |
| <div class="debug-item"> | |
| <div class="debug-item-title"> | |
| ${r.title} | |
| <span class="debug-status ${r.status}">${r.statusText}</span> | |
| </div> | |
| <div class="debug-item-content"> | |
| <pre style="margin: 0; white-space: pre-wrap;">${r.content}</pre> | |
| ${r.json ? `<div class="debug-json">${JSON.stringify(r.json, null, 2)}</div>` : ''} | |
| ${r.link ? `<a href="${r.link}" target="_blank" style="color: #f90;">Open in Gateway</a>` : ''} | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| // Allow Enter key to trigger debug | |
| document.getElementById('debugHash').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') debugHash(); | |
| }); | |
| // URL to Swarm functionality | |
| async function fetchAndUploadUrl() { | |
| const url = document.getElementById('fetchUrl').value.trim(); | |
| const statusDiv = document.getElementById('urlStatus'); | |
| const fetchBtn = document.getElementById('fetchBtn'); | |
| if (!url) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Please enter a URL</span>'; | |
| return; | |
| } | |
| let beeUrl, stamp; | |
| // Check if client-side stamping is enabled | |
| if (canUseClientStamping()) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = clientStamper; | |
| if (!beeUrl) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure a gateway URL for client-side stamping</span>'; | |
| return; | |
| } | |
| } else if (isLocalNodeAvailable) { | |
| beeUrl = beeUrlInput.value.trim(); | |
| stamp = batchIdInput.value.trim(); | |
| if (!beeUrl || !stamp) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure Bee URL and select a postage stamp</span>'; | |
| return; | |
| } | |
| } else if (isGatewayAvailable) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = '0000000000000000000000000000000000000000000000000000000000000000'; | |
| } else { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">No connection available</span>'; | |
| return; | |
| } | |
| // Basic URL validation | |
| try { | |
| new URL(url); | |
| } catch { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Invalid URL format</span>'; | |
| return; | |
| } | |
| fetchBtn.disabled = true; | |
| fetchBtn.textContent = 'Fetching...'; | |
| statusDiv.innerHTML = ` | |
| <div>Step 1/2: Fetching content...</div> | |
| <div class="url-progress"> | |
| <div class="url-progress-bar"> | |
| <div class="url-progress-fill" style="width: 20%"></div> | |
| </div> | |
| </div> | |
| `; | |
| try { | |
| // Fetch the URL | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const blob = await response.blob(); | |
| const contentType = response.headers.get('content-type') || 'application/octet-stream'; | |
| // Extract filename from URL | |
| let filename = url.split('/').pop().split('?')[0] || 'downloaded-file'; | |
| // Remove any invalid characters | |
| filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); | |
| if (!filename.includes('.')) { | |
| // Add extension based on content type | |
| const ext = contentType.split('/')[1]?.split(';')[0] || 'bin'; | |
| filename += '.' + ext; | |
| } | |
| statusDiv.innerHTML = ` | |
| <div>Step 2/2: Uploading to Swarm...</div> | |
| <div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;"> | |
| File: ${filename} (${formatFileSize(blob.size)}) | |
| </div> | |
| <div class="url-progress"> | |
| <div class="url-progress-bar"> | |
| <div class="url-progress-fill" style="width: 60%"></div> | |
| </div> | |
| </div> | |
| `; | |
| // Create File object and upload to Swarm | |
| const file = new File([blob], filename, { type: contentType }); | |
| const bee = new BeeJs.Bee(beeUrl); | |
| const result = await bee.uploadFile(stamp, file, filename, { | |
| contentType: contentType, | |
| deferred: false | |
| }); | |
| const reference = result.reference; | |
| const gatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`; | |
| statusDiv.innerHTML = ` | |
| <div style="color: #00c853; font-weight: 600;">Upload successful!</div> | |
| <div style="font-size: 0.85rem; margin-top: 0.5rem;"> | |
| <strong>Swarm Hash:</strong><br> | |
| <code style="color: #f90; word-break: break-all;">${reference}</code> | |
| </div> | |
| <div style="margin-top: 0.75rem;"> | |
| <button class="copy-btn" onclick="copyToClipboard('${reference}')" style="margin: 0;">Copy Hash</button> | |
| <button class="copy-btn" onclick="copyToClipboard('${gatewayUrl}')">Copy Gateway URL</button> | |
| <a href="${beeUrl}/bzz/${reference}/" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Local</a> | |
| <a href="${gatewayUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Gateway</a> | |
| </div> | |
| <div class="url-progress" style="margin-top: 0.5rem;"> | |
| <div class="url-progress-bar"> | |
| <div class="url-progress-fill" style="width: 100%; background: linear-gradient(90deg, #00c853, #69f0ae);"></div> | |
| </div> | |
| </div> | |
| `; | |
| // Also add to the upload list | |
| const itemId = Date.now() + '-url-' + Math.random().toString(36).substr(2, 9); | |
| const itemHtml = ` | |
| <div class="upload-item" id="item-${itemId}"> | |
| <div class="upload-item-icon">${getFileIcon(contentType)}</div> | |
| <div class="upload-item-info"> | |
| <div class="upload-item-name">${filename}</div> | |
| <div class="upload-item-size">${formatFileSize(blob.size)} (from URL)</div> | |
| <div class="upload-item-link"> | |
| <a href="${gatewayUrl}" target="_blank">${reference}</a> | |
| <button class="copy-btn" onclick="copyToClipboard('${reference}')">Copy Hash</button> | |
| <button class="copy-btn" onclick="copyToClipboard('${gatewayUrl}')">Copy URL</button> | |
| </div> | |
| </div> | |
| <div class="upload-item-status status-success">Success</div> | |
| </div> | |
| `; | |
| uploadList.insertAdjacentHTML('afterbegin', itemHtml); | |
| } catch (error) { | |
| console.error('URL fetch error:', error); | |
| let errorMessage = error.message; | |
| if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError') || error.name === 'TypeError') { | |
| errorMessage = ` | |
| <strong>CORS Error</strong><br> | |
| This URL doesn't allow cross-origin requests from browsers.<br><br> | |
| <strong>Alternatives:</strong><br> | |
| • Download the file manually and drag it here<br> | |
| • Use <code>curl "${url}" -o file</code> in terminal | |
| `; | |
| } | |
| statusDiv.innerHTML = ` | |
| <div style="color: #ff5252;"> | |
| <strong>Error:</strong><br> | |
| ${errorMessage} | |
| </div> | |
| `; | |
| } | |
| fetchBtn.disabled = false; | |
| fetchBtn.textContent = 'Fetch & Upload'; | |
| } | |
| // Allow Enter key to trigger URL fetch | |
| document.getElementById('fetchUrl').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') fetchAndUploadUrl(); | |
| }); | |
| // IPFS to Swarm functionality - supports files and directories | |
| let ipfsMigrationAborted = false; | |
| const IPFS_GATEWAYS = [ | |
| 'https://dweb.link', | |
| 'https://ipfs.io', | |
| 'https://cloudflare-ipfs.com', | |
| 'https://gateway.pinata.cloud' | |
| ]; | |
| function updateHeliaStatus(status, text) { | |
| const statusEl = document.getElementById('heliaStatus'); | |
| const textEl = statusEl.querySelector('.status-text'); | |
| statusEl.className = 'helia-status ' + status; | |
| textEl.textContent = text; | |
| } | |
| // Set gateway mode immediately | |
| setTimeout(() => updateHeliaStatus('connected', 'Gateway mode'), 100); | |
| // Fetch a single file from IPFS gateway | |
| async function fetchIpfsFile(cid, path = '') { | |
| const fullPath = path ? `${cid}/${path}` : cid; | |
| for (const gateway of IPFS_GATEWAYS) { | |
| try { | |
| const url = `${gateway}/ipfs/${fullPath}`; | |
| const response = await fetch(url, { | |
| signal: AbortSignal.timeout(30000) | |
| }); | |
| if (response.ok) { | |
| return { | |
| blob: await response.blob(), | |
| contentType: response.headers.get('content-type') || 'application/octet-stream', | |
| gateway | |
| }; | |
| } | |
| } catch (e) { | |
| console.log(`Gateway ${gateway} failed for ${fullPath}:`, e.message); | |
| } | |
| } | |
| throw new Error(`Failed to fetch ${fullPath} from all gateways`); | |
| } | |
| // List directory contents using IPFS gateway with dag-json format | |
| async function listIpfsDirectory(cid) { | |
| // Try subdomain gateway with dag-json format (most reliable) | |
| const subdomainGateways = [ | |
| `https://${cid}.ipfs.dweb.link`, | |
| `https://${cid}.ipfs.cf-ipfs.com`, | |
| `https://${cid}.ipfs.w3s.link` | |
| ]; | |
| for (const baseUrl of subdomainGateways) { | |
| try { | |
| const url = `${baseUrl}/?format=dag-json`; | |
| console.log('Trying dag-json:', url); | |
| const response = await fetch(url, { | |
| signal: AbortSignal.timeout(30000) | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| if (data.Links && Array.isArray(data.Links)) { | |
| console.log('Found', data.Links.length, 'entries via dag-json'); | |
| return data.Links.map(link => ({ | |
| name: link.Name, | |
| cid: link.Hash ? (link.Hash['/'] || link.Hash) : null, | |
| size: link.Tsize || 0, | |
| type: link.Name && !link.Name.includes('.') ? 'directory' : 'file' | |
| })); | |
| } | |
| } | |
| } catch (e) { | |
| console.log(`dag-json failed on subdomain gateway:`, e.message); | |
| } | |
| } | |
| // Fallback: try path-based gateways with dag-json | |
| for (const gateway of IPFS_GATEWAYS) { | |
| try { | |
| const url = `${gateway}/ipfs/${cid}?format=dag-json`; | |
| console.log('Trying path dag-json:', url); | |
| const response = await fetch(url, { | |
| signal: AbortSignal.timeout(30000) | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| if (data.Links && Array.isArray(data.Links)) { | |
| console.log('Found', data.Links.length, 'entries via path dag-json'); | |
| return data.Links.map(link => ({ | |
| name: link.Name, | |
| cid: link.Hash ? (link.Hash['/'] || link.Hash) : null, | |
| size: link.Tsize || 0, | |
| type: link.Name && !link.Name.includes('.') ? 'directory' : 'file' | |
| })); | |
| } | |
| } | |
| } catch (e) { | |
| console.log(`dag-json failed on ${gateway}:`, e.message); | |
| } | |
| } | |
| return null; // Not a directory or couldn't list | |
| } | |
| // Recursively collect all files from IPFS directory | |
| async function collectIpfsFiles(cid, basePath = '', statusDiv, stats) { | |
| if (ipfsMigrationAborted) throw new Error('Migration aborted'); | |
| const entries = await listIpfsDirectory(cid); | |
| if (!entries) { | |
| // It's a file, not a directory | |
| return null; | |
| } | |
| const files = []; | |
| for (const entry of entries) { | |
| if (ipfsMigrationAborted) throw new Error('Migration aborted'); | |
| const fullPath = basePath ? `${basePath}/${entry.name}` : entry.name; | |
| if (entry.type === 'directory') { | |
| // Recursively process subdirectory | |
| stats.dirsScanned++; | |
| statusDiv.innerHTML = ` | |
| <div>Scanning IPFS directory...</div> | |
| <div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;"> | |
| Found: ${stats.filesFound} files in ${stats.dirsScanned} directories<br> | |
| Current: ${fullPath.substring(0, 60)}${fullPath.length > 60 ? '...' : ''} | |
| </div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 15%"></div> | |
| </div> | |
| </div> | |
| <button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button> | |
| `; | |
| const subFiles = await collectIpfsFiles(entry.cid || cid, fullPath, statusDiv, stats); | |
| if (subFiles) { | |
| files.push(...subFiles); | |
| } | |
| } else { | |
| // It's a file | |
| stats.filesFound++; | |
| files.push({ | |
| path: fullPath, | |
| name: entry.name, | |
| entryCid: entry.cid | |
| }); | |
| } | |
| } | |
| return files; | |
| } | |
| // Create a tar file from an array of files | |
| // TAR format: 512-byte header + file content padded to 512 bytes, ends with 1024 zero bytes | |
| function createTar(files) { | |
| const encoder = new TextEncoder(); | |
| const chunks = []; | |
| for (const file of files) { | |
| // Create 512-byte header | |
| const header = new Uint8Array(512); | |
| // File name (100 bytes) - use path | |
| const nameBytes = encoder.encode(file.path); | |
| header.set(nameBytes.slice(0, 100), 0); | |
| // File mode (8 bytes) - octal string (match bee-js: 0000777) | |
| const mode = encoder.encode('0000777\0'); | |
| header.set(mode, 100); | |
| // Owner UID (8 bytes) - match bee-js: 0001750 | |
| const uid = encoder.encode('0001750\0'); | |
| header.set(uid, 108); | |
| // Owner GID (8 bytes) - match bee-js: 0001750 | |
| const gid = encoder.encode('0001750\0'); | |
| header.set(gid, 116); | |
| // File size in octal (12 bytes) | |
| const size = file.data.length; | |
| const sizeOctal = size.toString(8).padStart(11, '0') + '\0'; | |
| header.set(encoder.encode(sizeOctal), 124); | |
| // Modification time (12 bytes) | |
| const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0'; | |
| header.set(encoder.encode(mtime), 136); | |
| // Checksum placeholder (8 spaces) | |
| header.set(encoder.encode(' '), 148); | |
| // Type flag - '0' for regular file | |
| header[156] = 48; // ASCII '0' | |
| // Link name (100 bytes) - empty | |
| // USTAR magic at offset 257: 'ustar\0' then version '00' at 263 | |
| header.set(encoder.encode('ustar'), 257); | |
| header[262] = 0; // null after ustar | |
| header.set(encoder.encode('00'), 263); // version | |
| // Calculate checksum | |
| let checksum = 0; | |
| for (let i = 0; i < 512; i++) { | |
| checksum += header[i]; | |
| } | |
| const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 '; | |
| header.set(encoder.encode(checksumOctal), 148); | |
| chunks.push(header); | |
| // File content | |
| chunks.push(file.data); | |
| // Pad to 512-byte boundary | |
| const padding = (512 - (size % 512)) % 512; | |
| if (padding > 0) { | |
| chunks.push(new Uint8Array(padding)); | |
| } | |
| } | |
| // End of archive: two 512-byte blocks of zeros | |
| chunks.push(new Uint8Array(1024)); | |
| // Combine all chunks | |
| const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); | |
| const tarData = new Uint8Array(totalLength); | |
| let offset = 0; | |
| for (const chunk of chunks) { | |
| tarData.set(chunk, offset); | |
| offset += chunk.length; | |
| } | |
| return tarData; | |
| } | |
| // Determine content type from filename | |
| function getContentTypeFromPath(path) { | |
| const ext = path.split('.').pop().toLowerCase(); | |
| const types = { | |
| 'html': 'text/html', | |
| 'htm': 'text/html', | |
| 'css': 'text/css', | |
| 'js': 'application/javascript', | |
| 'json': 'application/json', | |
| 'xml': 'application/xml', | |
| 'txt': 'text/plain', | |
| 'md': 'text/markdown', | |
| 'png': 'image/png', | |
| 'jpg': 'image/jpeg', | |
| 'jpeg': 'image/jpeg', | |
| 'gif': 'image/gif', | |
| 'svg': 'image/svg+xml', | |
| 'webp': 'image/webp', | |
| 'ico': 'image/x-icon', | |
| 'mp4': 'video/mp4', | |
| 'webm': 'video/webm', | |
| 'mp3': 'audio/mpeg', | |
| 'ogg': 'audio/ogg', | |
| 'wav': 'audio/wav', | |
| 'pdf': 'application/pdf', | |
| 'woff': 'font/woff', | |
| 'woff2': 'font/woff2', | |
| 'ttf': 'font/ttf', | |
| 'eot': 'application/vnd.ms-fontobject' | |
| }; | |
| return types[ext] || 'application/octet-stream'; | |
| } | |
| async function fetchIpfsAndUpload() { | |
| const cidInput = document.getElementById('ipfsCid').value.trim(); | |
| const statusDiv = document.getElementById('ipfsStatus'); | |
| const ipfsBtn = document.getElementById('ipfsBtn'); | |
| ipfsMigrationAborted = false; | |
| // Clean up CID input - handle various formats | |
| let cid = cidInput; | |
| if (cid.startsWith('ipfs://')) cid = cid.slice(7); | |
| if (cid.startsWith('/ipfs/')) cid = cid.slice(6); | |
| const gatewayMatch = cid.match(/\/ipfs\/([a-zA-Z0-9]+)/); | |
| if (gatewayMatch) cid = gatewayMatch[1]; | |
| cid = cid.split('/')[0].split('?')[0]; | |
| if (!cid) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Please enter an IPFS CID</span>'; | |
| return; | |
| } | |
| let beeUrl, stamp; | |
| // Check if client-side stamping is enabled | |
| if (canUseClientStamping()) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = clientStamper; | |
| if (!beeUrl) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure a gateway URL for client-side stamping</span>'; | |
| return; | |
| } | |
| } else if (isLocalNodeAvailable) { | |
| beeUrl = beeUrlInput.value.trim(); | |
| stamp = batchIdInput.value.trim(); | |
| if (!beeUrl || !stamp) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Please configure Bee URL and select a postage stamp</span>'; | |
| return; | |
| } | |
| } else if (isGatewayAvailable) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = '0000000000000000000000000000000000000000000000000000000000000000'; | |
| } else { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">No connection available</span>'; | |
| return; | |
| } | |
| if (!cid.match(/^(Qm[a-zA-Z0-9]{44}|bafy[a-zA-Z0-9]+|bafk[a-zA-Z0-9]+|bafyb[a-zA-Z0-9]+)$/)) { | |
| statusDiv.innerHTML = '<span style="color: #ff5252;">Invalid CID format</span>'; | |
| return; | |
| } | |
| ipfsBtn.disabled = true; | |
| ipfsBtn.textContent = 'Migrating...'; | |
| try { | |
| // First, check if it's a directory | |
| statusDiv.innerHTML = ` | |
| <div>Checking if CID is a file or directory...</div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 5%"></div> | |
| </div> | |
| </div> | |
| `; | |
| const dirEntries = await listIpfsDirectory(cid); | |
| if (dirEntries && dirEntries.length > 0) { | |
| // It's a directory - migrate entire website | |
| await migrateIpfsDirectory(cid, dirEntries, beeUrl, stamp, statusDiv); | |
| } else { | |
| // It's a single file | |
| await migrateIpfsSingleFile(cid, beeUrl, stamp, statusDiv); | |
| } | |
| } catch (error) { | |
| console.error('IPFS migration error:', error); | |
| if (error.message === 'Migration aborted') { | |
| statusDiv.innerHTML = ` | |
| <div style="color: #ffc107;"> | |
| <strong>Migration cancelled</strong><br> | |
| The migration was stopped by user request. | |
| </div> | |
| `; | |
| } else { | |
| statusDiv.innerHTML = ` | |
| <div style="color: #ff5252;"> | |
| <strong>Error:</strong> ${error.message}<br><br> | |
| <strong>Troubleshooting:</strong><br> | |
| • Verify the CID is correct<br> | |
| • Try fetching from <a href="https://dweb.link/ipfs/${cid}" target="_blank" style="color: #4dd0e1;">dweb.link</a> to verify availability | |
| </div> | |
| `; | |
| } | |
| } | |
| ipfsBtn.disabled = false; | |
| ipfsBtn.textContent = 'Migrate to Swarm'; | |
| } | |
| async function migrateIpfsSingleFile(cid, beeUrl, stamp, statusDiv) { | |
| statusDiv.innerHTML = ` | |
| <div>Fetching file from IPFS...</div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 30%"></div> | |
| </div> | |
| </div> | |
| `; | |
| const fetchResult = await fetchIpfsFile(cid); | |
| const { blob, contentType } = fetchResult; | |
| const filename = `ipfs-${cid.substring(0, 12)}`; | |
| statusDiv.innerHTML = ` | |
| <div>Uploading to Swarm...</div> | |
| <div style="font-size: 0.85rem; color: #888;">Size: ${formatFileSize(blob.size)}</div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 60%"></div> | |
| </div> | |
| </div> | |
| `; | |
| const file = new File([blob], filename, { type: contentType }); | |
| const bee = new BeeJs.Bee(beeUrl); | |
| const result = await bee.uploadFile(stamp, file, filename, { | |
| contentType: contentType, | |
| deferred: false | |
| }); | |
| const reference = result.reference; | |
| const swarmGatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`; | |
| statusDiv.innerHTML = ` | |
| <div style="color: #00c853; font-weight: 600;">Successfully migrated file to Swarm!</div> | |
| <div style="font-size: 0.85rem; margin-top: 0.5rem;"> | |
| <strong>IPFS CID:</strong> <code style="color: #4dd0e1;">${cid}</code><br> | |
| <strong>Swarm Hash:</strong> <code style="color: #f90; word-break: break-all;">${reference}</code><br> | |
| <strong>Size:</strong> ${formatFileSize(blob.size)} | |
| </div> | |
| <div style="margin-top: 0.75rem;"> | |
| <button class="copy-btn" onclick="copyToClipboard('${reference}')" style="margin: 0;">Copy Swarm Hash</button> | |
| <a href="${swarmGatewayUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Gateway</a> | |
| </div> | |
| `; | |
| } | |
| async function migrateIpfsDirectory(cid, initialEntries, beeUrl, stamp, statusDiv) { | |
| const stats = { filesFound: 0, dirsScanned: 1 }; | |
| statusDiv.innerHTML = ` | |
| <div>Scanning IPFS directory structure...</div> | |
| <div style="font-size: 0.85rem; color: #888;">This may take a while for large websites...</div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 10%"></div> | |
| </div> | |
| </div> | |
| <button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button> | |
| `; | |
| // Collect all files recursively | |
| const allFiles = []; | |
| for (const entry of initialEntries) { | |
| if (ipfsMigrationAborted) throw new Error('Migration aborted'); | |
| if (entry.type === 'directory') { | |
| stats.dirsScanned++; | |
| const subFiles = await collectIpfsFiles(entry.cid || cid, entry.name, statusDiv, stats); | |
| if (subFiles) allFiles.push(...subFiles); | |
| } else { | |
| stats.filesFound++; | |
| allFiles.push({ | |
| path: entry.name, | |
| name: entry.name, | |
| entryCid: entry.cid | |
| }); | |
| } | |
| } | |
| if (allFiles.length === 0) { | |
| throw new Error('No files found in directory'); | |
| } | |
| statusDiv.innerHTML = ` | |
| <div>Found ${allFiles.length} files. Starting download...</div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 20%"></div> | |
| </div> | |
| </div> | |
| <button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button> | |
| `; | |
| // Download all files | |
| const downloadedFiles = []; | |
| let totalSize = 0; | |
| let downloadedCount = 0; | |
| let failedCount = 0; | |
| const failedFiles = []; | |
| for (const fileInfo of allFiles) { | |
| if (ipfsMigrationAborted) throw new Error('Migration aborted'); | |
| downloadedCount++; | |
| const progress = 20 + (downloadedCount / allFiles.length) * 50; | |
| statusDiv.innerHTML = ` | |
| <div>Downloading files from IPFS...</div> | |
| <div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;"> | |
| Progress: ${downloadedCount}/${allFiles.length} files (${failedCount} failed)<br> | |
| Downloaded: ${formatFileSize(totalSize)}<br> | |
| Current: ${fileInfo.path.substring(0, 50)}${fileInfo.path.length > 50 ? '...' : ''} | |
| </div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: ${progress}%"></div> | |
| </div> | |
| </div> | |
| <button class="copy-btn" style="margin-top: 0.5rem; background: #ff5252; border-color: #ff5252; color: #fff;" onclick="ipfsMigrationAborted = true;">Cancel</button> | |
| `; | |
| try { | |
| const result = await fetchIpfsFile(cid, fileInfo.path); | |
| const contentType = getContentTypeFromPath(fileInfo.path); | |
| downloadedFiles.push({ | |
| path: fileInfo.path, | |
| data: new Uint8Array(await result.blob.arrayBuffer()), | |
| contentType: contentType | |
| }); | |
| totalSize += result.blob.size; | |
| } catch (e) { | |
| console.error(`Failed to download ${fileInfo.path}:`, e.message); | |
| failedCount++; | |
| failedFiles.push(fileInfo.path); | |
| } | |
| } | |
| if (downloadedFiles.length === 0) { | |
| throw new Error('Failed to download any files'); | |
| } | |
| // Upload to Swarm as a collection | |
| statusDiv.innerHTML = ` | |
| <div>Uploading ${downloadedFiles.length} files to Swarm...</div> | |
| <div style="font-size: 0.85rem; color: #888; margin-top: 0.25rem;"> | |
| Total size: ${formatFileSize(totalSize)}<br> | |
| ${failedCount > 0 ? `<span style="color: #ffc107;">${failedCount} files could not be downloaded</span>` : ''} | |
| </div> | |
| <div class="ipfs-progress"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 75%"></div> | |
| </div> | |
| </div> | |
| `; | |
| // Find index document | |
| const indexPath = downloadedFiles.find(f => | |
| f.path === 'index.html' || f.path.endsWith('/index.html') | |
| )?.path || 'index.html'; | |
| const indexDocument = indexPath.split('/').pop() || 'index.html'; | |
| console.log('Uploading collection with', downloadedFiles.length, 'files'); | |
| console.log('Index document:', indexDocument); | |
| console.log('First 10 file paths:', downloadedFiles.slice(0, 10).map(f => f.path)); | |
| console.log('First 10 file sizes:', downloadedFiles.slice(0, 10).map(f => f.data.length)); | |
| // Verify files have valid data | |
| const validFiles = downloadedFiles.filter(f => f.path && f.data && f.data.length > 0); | |
| console.log('Valid files:', validFiles.length, 'of', downloadedFiles.length); | |
| if (validFiles.length === 0) { | |
| throw new Error('No valid files to upload'); | |
| } | |
| // Use tar upload - more reliable for preserving directory structure | |
| const tarData = createTar(validFiles); | |
| console.log('Created tar with', validFiles.length, 'files, size:', tarData.length); | |
| // Debug: show first 200 bytes of tar as hex | |
| const hexBytes = Array.from(tarData.slice(0, 200)).map(b => b.toString(16).padStart(2, '0')).join(' '); | |
| console.log('Tar header (first 200 bytes):', hexBytes); | |
| // Upload tar directly to bzz endpoint | |
| // Extract batch ID string from stamp (which could be a Stamper object or string) | |
| const batchIdStr = typeof stamp === 'string' | |
| ? stamp | |
| : (stamp.batchId?.toHex?.() || stamp.batchId?.toString?.() || stamp.batchId); | |
| const response = await fetch(`${beeUrl}/bzz`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/x-tar', | |
| 'Swarm-Collection': 'true', | |
| 'Swarm-Index-Document': indexDocument, | |
| 'Swarm-Postage-Batch-Id': batchIdStr | |
| }, | |
| body: tarData | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`Upload failed: ${response.status} ${errorText}`); | |
| } | |
| const result = await response.json(); | |
| const reference = result.reference; | |
| const swarmGatewayUrl = `https://gateway.ethswarm.org/bzz/${reference}/`; | |
| const localUrl = `${beeUrl}/bzz/${reference}/`; | |
| statusDiv.innerHTML = ` | |
| <div style="color: #00c853; font-weight: 600;">Successfully migrated website to Swarm!</div> | |
| <div style="font-size: 0.85rem; margin-top: 0.5rem;"> | |
| <strong>IPFS CID:</strong> <code style="color: #4dd0e1;">${cid}</code><br> | |
| <strong>Swarm Hash:</strong> <code style="color: #f90; word-break: break-all;">${reference}</code><br> | |
| <strong>Files:</strong> ${downloadedFiles.length} (${failedCount} failed)<br> | |
| <strong>Total Size:</strong> ${formatFileSize(totalSize)} | |
| </div> | |
| ${failedFiles.length > 0 ? ` | |
| <details style="margin-top: 0.5rem; font-size: 0.8rem;"> | |
| <summary style="color: #ffc107; cursor: pointer;">Show ${failedFiles.length} failed files</summary> | |
| <div style="max-height: 100px; overflow-y: auto; margin-top: 0.25rem; color: #888;"> | |
| ${failedFiles.map(f => `• ${f}`).join('<br>')} | |
| </div> | |
| </details> | |
| ` : ''} | |
| <div style="margin-top: 0.75rem;"> | |
| <button class="copy-btn" onclick="copyToClipboard('${reference}')" style="margin: 0;">Copy Swarm Hash</button> | |
| <button class="copy-btn" onclick="copyToClipboard('${swarmGatewayUrl}')">Copy Gateway URL</button> | |
| <a href="${localUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Local</a> | |
| <a href="${swarmGatewayUrl}" target="_blank" class="copy-btn" style="text-decoration: none; display: inline-block;">Open Gateway</a> | |
| </div> | |
| <div class="ipfs-progress" style="margin-top: 0.5rem;"> | |
| <div class="ipfs-progress-bar"> | |
| <div class="ipfs-progress-fill" style="width: 100%; background: linear-gradient(90deg, #00c853, #69f0ae);"></div> | |
| </div> | |
| </div> | |
| `; | |
| // Add to upload list | |
| const itemId = Date.now() + '-ipfs-dir-' + Math.random().toString(36).substr(2, 9); | |
| const itemHtml = ` | |
| <div class="upload-item" id="item-${itemId}"> | |
| <div class="upload-item-icon">🌐</div> | |
| <div class="upload-item-info"> | |
| <div class="upload-item-name">Website from IPFS</div> | |
| <div class="upload-item-size">${downloadedFiles.length} files, ${formatFileSize(totalSize)} (from IPFS: ${cid.substring(0, 12)}...)</div> | |
| <div class="upload-item-link"> | |
| <a href="${swarmGatewayUrl}" target="_blank">${reference}</a> | |
| <button class="copy-btn" onclick="copyToClipboard('${reference}')">Copy Hash</button> | |
| <button class="copy-btn" onclick="copyToClipboard('${swarmGatewayUrl}')">Copy URL</button> | |
| </div> | |
| </div> | |
| <div class="upload-item-status status-success">Migrated</div> | |
| </div> | |
| `; | |
| uploadList.insertAdjacentHTML('afterbegin', itemHtml); | |
| } | |
| // Allow Enter key to trigger IPFS fetch | |
| document.getElementById('ipfsCid').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter' && !document.getElementById('ipfsBtn').disabled) { | |
| fetchIpfsAndUpload(); | |
| } | |
| }); | |
| // ========================================== | |
| // Message Board (Swarm Feed) Functionality | |
| // ========================================== | |
| // Topic must be 32 bytes (64 hex chars) - this is "swarm-message-board-v1" padded with zeros | |
| const BOARD_TOPIC_NAME = 'swarm-message-board-v1'; | |
| // Convert to hex and pad to 32 bytes (44 hex chars + 20 zeros = 64) | |
| const BOARD_TOPIC = '737761726d2d6d6573736167652d626f6172642d763100000000000000000000'; | |
| let boardPrivateKey = null; | |
| let boardOwnerAddress = null; | |
| let boardFeedAddress = null; | |
| let boardIsReadOnly = false; | |
| let boardPendingImages = []; | |
| // Generate random bytes for private key | |
| function generateRandomBytes(length) { | |
| const array = new Uint8Array(length); | |
| crypto.getRandomValues(array); | |
| return Array.from(array).map(b => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| // Convert hex string to Uint8Array | |
| function hexToBytes(hex) { | |
| hex = hex.replace(/^0x/, ''); | |
| const bytes = new Uint8Array(hex.length / 2); | |
| for (let i = 0; i < bytes.length; i++) { | |
| bytes[i] = parseInt(hex.substr(i * 2, 2), 16); | |
| } | |
| return bytes; | |
| } | |
| // Convert Uint8Array to hex string | |
| function bytesToHex(bytes) { | |
| return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| // Derive Ethereum address from private key | |
| async function privateKeyToAddress(privateKeyHex) { | |
| // Ensure hex format with 0x prefix for bee-js | |
| const keyWithPrefix = privateKeyHex.startsWith('0x') ? privateKeyHex : '0x' + privateKeyHex; | |
| const keyWithoutPrefix = privateKeyHex.replace(/^0x/, ''); | |
| // Validate it's 64 hex characters | |
| if (!/^[a-fA-F0-9]{64}$/.test(keyWithoutPrefix)) { | |
| throw new Error('Invalid private key format'); | |
| } | |
| try { | |
| // Method 1: Try using BeeJs.PrivateKey if available (newer bee-js versions) | |
| if (typeof BeeJs.PrivateKey === 'function') { | |
| const pk = new BeeJs.PrivateKey(keyWithPrefix); | |
| const addr = pk.publicKey().address(); | |
| if (addr.toHex) return addr.toHex().replace(/^0x/, ''); | |
| if (addr instanceof Uint8Array) return bytesToHex(addr); | |
| return String(addr).replace(/^0x/, ''); | |
| } | |
| } catch (e) { | |
| console.log('PrivateKey method failed:', e.message); | |
| } | |
| try { | |
| // Method 2: Try Utils.Eth or similar | |
| if (BeeJs.Utils?.Eth?.makePrivateKeySigner) { | |
| const signer = BeeJs.Utils.Eth.makePrivateKeySigner(hexToBytes(keyWithoutPrefix)); | |
| return bytesToHex(signer.address); | |
| } | |
| } catch (e) { | |
| console.log('Utils.Eth method failed:', e.message); | |
| } | |
| try { | |
| // Method 3: Create a feed writer and extract owner from it | |
| const beeUrl = getActiveUrl() || 'http://localhost:1633'; | |
| const bee = new BeeJs.Bee(beeUrl); | |
| const writer = bee.makeFeedWriter(BOARD_TOPIC, keyWithPrefix); | |
| // The writer should have an owner property | |
| if (writer.owner) { | |
| const owner = writer.owner; | |
| if (typeof owner === 'string') return owner.replace(/^0x/, ''); | |
| if (owner instanceof Uint8Array) return bytesToHex(owner); | |
| if (owner.toHex) return owner.toHex().replace(/^0x/, ''); | |
| } | |
| } catch (e) { | |
| console.log('FeedWriter method failed:', e.message); | |
| } | |
| // Log what's available for debugging | |
| console.log('BeeJs exports:', Object.keys(BeeJs)); | |
| if (BeeJs.Utils) console.log('BeeJs.Utils:', Object.keys(BeeJs.Utils)); | |
| throw new Error('Cannot derive address from private key'); | |
| } | |
| // Compute feed address from owner + topic | |
| function computeFeedAddress(ownerAddress, topic) { | |
| // Feed address = keccak256(topic + owner) | |
| // We'll use a simplified approach and let bee-js handle it | |
| // For display, we can construct a predictable identifier | |
| return `${ownerAddress.substring(0, 16)}...${topic.substring(0, 8)}`; | |
| } | |
| // Generate new private key | |
| function boardGenerateKey() { | |
| const privateKey = generateRandomBytes(32); | |
| document.getElementById('boardPrivateKey').value = privateKey; | |
| boardShowStatus('New key generated. Click "Load" to activate.', 'info'); | |
| } | |
| // Load key or feed address | |
| async function boardLoadKey() { | |
| const input = document.getElementById('boardPrivateKey').value.trim(); | |
| if (!input) { | |
| boardShowStatus('Please enter a private key or feed address', 'error'); | |
| return; | |
| } | |
| const beeUrl = getActiveUrl(); | |
| if (!beeUrl) { | |
| boardShowStatus('No Swarm connection available', 'error'); | |
| return; | |
| } | |
| try { | |
| // Check if input looks like a private key (64 hex chars) or an address (40 hex chars) | |
| const cleanInput = input.replace(/^0x/, ''); | |
| if (cleanInput.length === 64) { | |
| // It's a private key - full write access | |
| boardPrivateKey = cleanInput; | |
| boardOwnerAddress = await privateKeyToAddress(cleanInput); | |
| boardIsReadOnly = false; | |
| document.getElementById('boardOwnerAddress').textContent = '0x' + boardOwnerAddress; | |
| document.getElementById('boardFeedAddress').textContent = BOARD_TOPIC_NAME; | |
| document.getElementById('boardKeyInfo').style.display = 'block'; | |
| document.getElementById('boardCompose').style.display = 'block'; | |
| document.getElementById('boardReadonlyNotice').style.display = 'none'; | |
| boardShowStatus('Feed loaded with write access', 'success'); | |
| } else if (cleanInput.length === 40 || cleanInput.length === 42) { | |
| // It's an address - read only | |
| boardPrivateKey = null; | |
| boardOwnerAddress = cleanInput.replace(/^0x/, ''); | |
| boardIsReadOnly = true; | |
| document.getElementById('boardOwnerAddress').textContent = '0x' + boardOwnerAddress; | |
| document.getElementById('boardFeedAddress').textContent = BOARD_TOPIC_NAME; | |
| document.getElementById('boardKeyInfo').style.display = 'block'; | |
| document.getElementById('boardCompose').style.display = 'none'; | |
| document.getElementById('boardReadonlyNotice').style.display = 'block'; | |
| boardShowStatus('Feed loaded in read-only mode', 'info'); | |
| } else { | |
| boardShowStatus('Invalid input. Enter a 64-char private key or 40-char address', 'error'); | |
| return; | |
| } | |
| // Load messages | |
| await boardLoadMessages(); | |
| // Check URL params for auto-load | |
| const urlParams = new URLSearchParams(window.location.search); | |
| if (!urlParams.has('board')) { | |
| // Save to localStorage for convenience | |
| if (!boardIsReadOnly) { | |
| localStorage.setItem('swarm-board-key', boardPrivateKey); | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Error loading key:', e); | |
| boardShowStatus('Error: ' + e.message, 'error'); | |
| } | |
| } | |
| // Show status message | |
| function boardShowStatus(message, type) { | |
| const statusDiv = document.getElementById('boardStatus'); | |
| statusDiv.innerHTML = `<div class="board-status ${type}">${message}</div>`; | |
| if (type === 'success' || type === 'info') { | |
| setTimeout(() => { | |
| statusDiv.innerHTML = ''; | |
| }, 5000); | |
| } | |
| } | |
| // Handle image selection | |
| function boardHandleImageSelect(event) { | |
| const files = event.target.files; | |
| const previewDiv = document.getElementById('boardImagePreview'); | |
| for (const file of files) { | |
| if (file.type.startsWith('image/')) { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| boardPendingImages.push({ | |
| data: e.target.result, | |
| type: file.type, | |
| name: file.name | |
| }); | |
| const imgContainer = document.createElement('div'); | |
| imgContainer.className = 'remove-image'; | |
| imgContainer.innerHTML = `<img src="${e.target.result}" alt="${file.name}">`; | |
| imgContainer.onclick = () => { | |
| const index = boardPendingImages.findIndex(img => img.data === e.target.result); | |
| if (index > -1) boardPendingImages.splice(index, 1); | |
| imgContainer.remove(); | |
| }; | |
| previewDiv.appendChild(imgContainer); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| // Reset input | |
| event.target.value = ''; | |
| } | |
| // Post a new message | |
| async function boardPostMessage() { | |
| if (boardIsReadOnly || !boardPrivateKey) { | |
| boardShowStatus('Cannot post in read-only mode', 'error'); | |
| return; | |
| } | |
| const text = document.getElementById('boardMessageText').value.trim(); | |
| if (!text && boardPendingImages.length === 0) { | |
| boardShowStatus('Please enter a message or add an image', 'error'); | |
| return; | |
| } | |
| // Determine which URL and stamp to use | |
| let beeUrl, stamp; | |
| // Check if client-side stamping is enabled | |
| if (canUseClientStamping()) { | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = clientStamper; | |
| if (!beeUrl) { | |
| boardShowStatus('Please configure a gateway URL for client-side stamping', 'error'); | |
| return; | |
| } | |
| boardShowStatus('Uploading via gateway (client-side stamped)...', 'info'); | |
| } else if (isLocalNodeAvailable) { | |
| beeUrl = beeUrlInput.value.trim(); | |
| stamp = batchIdInput.value.trim(); | |
| if (!beeUrl || !stamp) { | |
| boardShowStatus('Please configure Bee URL and select a postage stamp', 'error'); | |
| return; | |
| } | |
| } else if (isGatewayAvailable) { | |
| // Use gateway with zero batch (sponsored uploads) | |
| beeUrl = gatewayUrlInput.value.trim(); | |
| stamp = '0000000000000000000000000000000000000000000000000000000000000000'; | |
| boardShowStatus('Uploading via gateway (sponsored)...', 'info'); | |
| } else { | |
| boardShowStatus('No connection available', 'error'); | |
| return; | |
| } | |
| try { | |
| if (isLocalNodeAvailable && !canUseClientStamping()) { | |
| boardShowStatus('Uploading message...', 'info'); | |
| } | |
| const bee = new BeeJs.Bee(beeUrl); | |
| // Upload images first if any | |
| const imageRefs = []; | |
| for (const img of boardPendingImages) { | |
| // Convert data URL to Uint8Array | |
| const response = await fetch(img.data); | |
| const arrayBuffer = await response.arrayBuffer(); | |
| const uint8Array = new Uint8Array(arrayBuffer); | |
| const result = await bee.uploadFile(stamp, uint8Array, img.name, { | |
| contentType: img.type | |
| }); | |
| // Convert reference to hex string | |
| let refHex; | |
| if (result.reference.toHex) { | |
| refHex = result.reference.toHex(); | |
| } else if (result.reference.bytes) { | |
| refHex = bytesToHex(new Uint8Array(Object.values(result.reference.bytes))); | |
| } else { | |
| refHex = String(result.reference); | |
| } | |
| imageRefs.push({ | |
| reference: refHex, | |
| type: img.type, | |
| name: img.name | |
| }); | |
| } | |
| // Get current feed to find previous message reference | |
| let previousRef = null; | |
| try { | |
| const feedReader = bee.makeFeedReader(BOARD_TOPIC, '0x' + boardOwnerAddress); | |
| const feedResult = await feedReader.downloadReference(); | |
| // Extract reference bytes and convert to hex | |
| const refBytes = feedResult.reference.bytes | |
| ? new Uint8Array(Object.values(feedResult.reference.bytes)) | |
| : new Uint8Array(feedResult.reference); | |
| previousRef = bytesToHex(refBytes); | |
| } catch (e) { | |
| // No previous messages, this is the first one | |
| console.log('No previous messages found, starting new feed'); | |
| } | |
| // Create message object | |
| const message = { | |
| text: text, | |
| images: imageRefs, | |
| timestamp: Date.now(), | |
| author: '0x' + boardOwnerAddress.substring(0, 8), | |
| previous: previousRef | |
| }; | |
| // Upload message data | |
| const messageJson = JSON.stringify(message); | |
| const messageResult = await bee.uploadData(stamp, messageJson); | |
| // Store the reference in the message for next iteration | |
| message._ref = messageResult.reference; | |
| // Update feed to point to new message | |
| const feedWriter = bee.makeFeedWriter(BOARD_TOPIC, boardPrivateKey); | |
| await feedWriter.upload(stamp, messageResult.reference); | |
| // Clear form | |
| document.getElementById('boardMessageText').value = ''; | |
| document.getElementById('boardImagePreview').innerHTML = ''; | |
| boardPendingImages = []; | |
| boardShowStatus('Message posted successfully!', 'success'); | |
| // Reload messages | |
| await boardLoadMessages(); | |
| } catch (e) { | |
| console.error('Error posting message:', e); | |
| boardShowStatus('Error posting: ' + e.message, 'error'); | |
| } | |
| } | |
| // Load messages from feed | |
| async function boardLoadMessages() { | |
| if (!boardOwnerAddress) { | |
| return; | |
| } | |
| const beeUrl = getActiveUrl(); | |
| if (!beeUrl) { | |
| boardShowStatus('No Swarm connection available', 'error'); | |
| return; | |
| } | |
| const messagesListDiv = document.getElementById('boardMessagesList'); | |
| messagesListDiv.innerHTML = '<div class="board-loading">Loading messages...</div>'; | |
| try { | |
| const bee = new BeeJs.Bee(beeUrl); | |
| const feedReader = bee.makeFeedReader(BOARD_TOPIC, '0x' + boardOwnerAddress); | |
| // Get latest feed update reference | |
| let latestRef; | |
| try { | |
| const feedResult = await feedReader.downloadReference(); | |
| // Extract reference bytes and convert to hex | |
| const refBytes = feedResult.reference.bytes | |
| ? new Uint8Array(Object.values(feedResult.reference.bytes)) | |
| : new Uint8Array(feedResult.reference); | |
| latestRef = bytesToHex(refBytes); | |
| } catch (e) { | |
| console.log('Feed empty or not found:', e.message); | |
| messagesListDiv.innerHTML = '<div class="board-empty">No messages yet. Be the first to post!</div>'; | |
| return; | |
| } | |
| // Traverse linked list of messages | |
| const messages = []; | |
| let currentRef = latestRef; | |
| let maxMessages = 50; // Limit to prevent infinite loops | |
| while (currentRef && maxMessages > 0) { | |
| try { | |
| const data = await bee.downloadData(currentRef); | |
| // Handle bee-js returning object with bytes property | |
| let bytes; | |
| if (data.bytes) { | |
| bytes = new Uint8Array(Object.values(data.bytes)); | |
| } else if (data instanceof Uint8Array) { | |
| bytes = data; | |
| } else { | |
| bytes = new Uint8Array(data); | |
| } | |
| const message = JSON.parse(new TextDecoder().decode(bytes)); | |
| message._ref = currentRef; | |
| messages.push(message); | |
| currentRef = message.previous; | |
| maxMessages--; | |
| } catch (e) { | |
| console.log('Error loading message:', e.message); | |
| break; | |
| } | |
| } | |
| if (messages.length === 0) { | |
| messagesListDiv.innerHTML = '<div class="board-empty">No messages yet. Be the first to post!</div>'; | |
| return; | |
| } | |
| // Render messages (newest first - they're already in that order) | |
| messagesListDiv.innerHTML = messages.map(msg => { | |
| const date = new Date(msg.timestamp); | |
| const timeStr = date.toLocaleString(); | |
| let imagesHtml = ''; | |
| if (msg.images && msg.images.length > 0) { | |
| imagesHtml = ` | |
| <div class="board-message-images"> | |
| ${msg.images.map(img => ` | |
| <img src="${beeUrl}/bzz/${img.reference}/" | |
| alt="${img.name || 'image'}" | |
| onclick="window.open('${beeUrl}/bzz/${img.reference}/', '_blank')"> | |
| `).join('')} | |
| </div> | |
| `; | |
| } | |
| return ` | |
| <div class="board-message"> | |
| <div class="board-message-header"> | |
| <span class="board-message-author">${msg.author || 'Anonymous'}</span> | |
| <span class="board-message-time">${timeStr}</span> | |
| </div> | |
| <div class="board-message-text">${escapeHtml(msg.text || '')}</div> | |
| ${imagesHtml} | |
| </div> | |
| `; | |
| }).join(''); | |
| } catch (e) { | |
| console.error('Error loading messages:', e); | |
| messagesListDiv.innerHTML = `<div class="board-empty">Error loading messages: ${e.message}</div>`; | |
| } | |
| } | |
| // Escape HTML to prevent XSS | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Copy write link (includes private key) | |
| function boardCopyWriteLink() { | |
| if (!boardPrivateKey) { | |
| boardShowStatus('No private key loaded', 'error'); | |
| return; | |
| } | |
| const url = `${window.location.origin}${window.location.pathname}?board=${boardPrivateKey}`; | |
| copyToClipboard(url); | |
| boardShowStatus('Write link copied! Keep this private.', 'success'); | |
| } | |
| // Copy read-only link (just address) | |
| function boardCopyReadLink() { | |
| if (!boardOwnerAddress) { | |
| boardShowStatus('No feed loaded', 'error'); | |
| return; | |
| } | |
| const url = `${window.location.origin}${window.location.pathname}?board=${boardOwnerAddress}`; | |
| copyToClipboard(url); | |
| boardShowStatus('Read-only link copied!', 'success'); | |
| } | |
| // Check URL params on load | |
| function boardCheckUrlParams() { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const boardParam = urlParams.get('board'); | |
| if (boardParam) { | |
| document.getElementById('boardPrivateKey').value = boardParam; | |
| // Auto-load after a short delay to let bee-js initialize | |
| setTimeout(() => boardLoadKey(), 500); | |
| } else { | |
| // Check localStorage | |
| const savedKey = localStorage.getItem('swarm-board-key'); | |
| if (savedKey) { | |
| document.getElementById('boardPrivateKey').value = savedKey; | |
| } | |
| } | |
| } | |
| // Initialize board on page load | |
| boardCheckUrlParams(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment