Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>RadarDrop v2</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} | |
| :root{ | |
| --bg:#000; | |
| --surface:rgba(255,255,255,0.04); | |
| --surface-hover:rgba(255,255,255,0.08); | |
| --border:rgba(255,255,255,0.1); | |
| --text:#fff; | |
| --muted:rgba(255,255,255,0.45); | |
| --glow:rgba(255,255,255,0.12); | |
| --danger:rgba(255,80,80,0.85); | |
| --green:rgba(80,255,140,0.85); | |
| } | |
| html,body{height:100%;background:var(--bg);color:var(--text);font-family:'Inter',system-ui,sans-serif;overflow:hidden} | |
| /* ββ Layout ββ */ | |
| .app-container{display:none;grid-template-columns:300px 1fr 340px;height:100vh} | |
| .app-container.active{display:grid} | |
| /* ββ Landing Page ββ */ | |
| .landing{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;text-align:center} | |
| .landing.hidden{display:none} | |
| .brand-title{font-size:3rem;font-weight:700;letter-spacing:1px;margin-bottom:8px;display:flex;align-items:center;gap:16px} | |
| .brand-dot{width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 0 16px #fff,0 0 32px #fff} | |
| .landing-sub{color:var(--muted);font-size:1.1rem;margin-bottom:40px} | |
| .room-box{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:32px;width:100%;max-width:400px} | |
| .input-code{width:100%;background:rgba(0,0,0,0.3);border:1px solid var(--border);color:#fff;font-size:1.5rem;text-align:center;padding:16px;border-radius:12px;letter-spacing:8px;outline:none;font-family:monospace;transition:border .2s} | |
| .input-code:focus{border-color:rgba(255,255,255,0.5)} | |
| .input-code::placeholder{color:var(--muted);letter-spacing:2px;font-family:'Inter',sans-serif;font-size:1rem} | |
| .btn{ | |
| display:inline-flex;align-items:center;justify-content:center;gap:8px; | |
| padding:12px 24px;border-radius:10px;font-size:.9rem;font-weight:600; | |
| cursor:pointer;transition:all .2s;border:1px solid var(--border); | |
| background:rgba(255,255,255,0.08);color:#fff;font-family:inherit;width:100%; | |
| } | |
| .btn:hover{background:rgba(255,255,255,0.15);border-color:rgba(255,255,255,0.3);transform:translateY(-1px)} | |
| .btn-primary{background:#fff;color:#000;border:none} | |
| .btn-primary:hover{background:rgba(255,255,255,0.85);box-shadow:0 0 16px var(--glow)} | |
| .btn-sm{padding:6px 14px;font-size:.75rem;border-radius:8px;width:auto} | |
| .btn-danger{background:rgba(255,50,50,0.1);border-color:rgba(255,80,80,0.3);color:rgba(255,130,130,1)} | |
| .divider{display:flex;align-items:center;text-align:center;color:var(--muted);font-size:.8rem;margin:24px 0} | |
| .divider::before,.divider::after{content:'';flex:1;border-bottom:1px solid var(--border)} | |
| .divider:not(:empty)::before{margin-right:.5em} | |
| .divider:not(:empty)::after{margin-left:.5em} | |
| /* ββ Sidebar (Left) ββ */ | |
| .sidebar{ | |
| background:rgba(255,255,255,0.02);border-right:1px solid var(--border); | |
| display:flex;flex-direction:column;align-items:center;padding:24px 16px;gap:24px;overflow-y:auto; | |
| } | |
| .room-badge{background:#fff;color:#000;padding:6px 16px;border-radius:20px;font-weight:700;letter-spacing:2px;font-size:1.1rem;font-family:monospace} | |
| /* ββ Radar SVG ββ */ | |
| .radar-wrap{position:relative;width:180px;height:180px} | |
| .radar-wrap svg{width:100%;height:100%} | |
| .radar-ring{fill:none;stroke:rgba(255,255,255,0.15);stroke-width:1} | |
| .radar-sweep{transform-origin:90px 90px;animation:sweep 3s linear infinite} | |
| @keyframes sweep{to{transform:rotate(360deg)}} | |
| .radar-dot{fill:#fff;animation:blink 2s ease-in-out infinite} | |
| @keyframes blink{0%,100%{opacity:1}50%{opacity:.3}} | |
| .blip{fill:#fff;opacity:0;animation:blipAnim 4s ease-in-out infinite} | |
| @keyframes blipAnim{0%,100%{opacity:0}40%{opacity:.9}60%{opacity:.9}80%{opacity:0}} | |
| /* ββ QR Toggle ββ */ | |
| .qr-wrap{width:100%;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:16px;display:flex;flex-direction:column;align-items:center;gap:10px} | |
| .qr-wrap img{width:140px;height:140px;border-radius:8px;border:2px solid rgba(255,255,255,0.1)} | |
| .qr-toggle{display:flex;width:100%;border:1px solid var(--border);border-radius:8px;overflow:hidden} | |
| .qr-tab{flex:1;padding:6px;font-size:.7rem;font-weight:600;text-align:center;cursor:pointer;border:none;background:transparent;color:var(--muted);transition:all .2s} | |
| .qr-tab.active{background:rgba(255,255,255,0.15);color:#fff} | |
| .public-url-text{font-size:.65rem;color:var(--muted);word-break:break-all;text-align:center} | |
| /* ββ Main area (Center) ββ */ | |
| .main{display:flex;flex-direction:column;padding:32px;overflow-y:auto} | |
| .section-title{font-size:.75rem;text-transform:uppercase;letter-spacing:2px;color:var(--muted);margin-bottom:16px} | |
| .drop-zone{ | |
| border:2px dashed rgba(255,255,255,0.2);border-radius:16px; | |
| padding:40px 20px;text-align:center;cursor:pointer; | |
| background:var(--surface);transition:all .2s;margin-bottom:32px; | |
| } | |
| .drop-zone:hover,.drop-zone.drag-over{border-color:rgba(255,255,255,0.5);background:var(--surface-hover)} | |
| .drop-icon{font-size:2.5rem;margin-bottom:8px;display:block} | |
| #fileInput{display:none} | |
| /* ββ File list ββ */ | |
| .files-grid{display:flex;flex-direction:column;gap:8px} | |
| .file-card{ | |
| display:flex;align-items:center;gap:16px; | |
| background:var(--surface);border:1px solid var(--border); | |
| border-radius:12px;padding:12px 16px;transition:transform .2s; | |
| } | |
| .file-card:hover{background:var(--surface-hover);transform:translateX(4px)} | |
| .file-icon{font-size:1.5rem;width:40px;text-align:center} | |
| .file-info{flex:1;min-width:0} | |
| .file-name{font-size:.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .file-meta{font-size:.65rem;color:var(--muted);margin-top:4px} | |
| /* ββ Chat area (Right) ββ */ | |
| .chat-panel{ | |
| background:rgba(255,255,255,0.015);border-left:1px solid var(--border); | |
| display:flex;flex-direction:column;height:100vh; | |
| } | |
| .chat-header{padding:20px;border-bottom:1px solid var(--border);font-weight:600;font-size:.9rem;display:flex;align-items:center;gap:10px} | |
| .chat-messages{flex:1;padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:16px} | |
| .chat-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:85%} | |
| .chat-msg.self{align-items:flex-end;align-self:flex-end} | |
| .msg-sender{font-size:.65rem;color:var(--muted);margin-bottom:4px;margin-left:4px} | |
| .chat-msg.self .msg-sender{margin-right:4px} | |
| .msg-bubble{background:var(--surface);border:1px solid var(--border);padding:10px 14px;border-radius:14px;font-size:.85rem;line-height:1.4;word-wrap:break-word} | |
| .chat-msg.self .msg-bubble{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.2)} | |
| .msg-time{font-size:.6rem;color:var(--muted);margin-top:4px;align-self:flex-end} | |
| .chat-input-wrap{padding:16px;border-top:1px solid var(--border);display:flex;gap:10px;background:rgba(0,0,0,0.5)} | |
| .chat-input{flex:1;background:var(--surface);border:1px solid var(--border);color:#fff;padding:12px 16px;border-radius:24px;outline:none;font-size:.85rem;font-family:inherit;transition:border .2s} | |
| .chat-input:focus{border-color:rgba(255,255,255,0.4)} | |
| .chat-send{background:#fff;color:#000;border:none;width:40px;height:40px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .2s} | |
| .chat-send:hover{transform:scale(1.05)} | |
| /* ββ Toast ββ */ | |
| .toast-wrap{position:fixed;top:24px;right:24px;display:flex;flex-direction:column;gap:10px;z-index:9999} | |
| .toast{ | |
| background:rgba(255,255,255,0.1);backdrop-filter:blur(16px); | |
| border:1px solid var(--border);border-radius:10px; | |
| padding:12px 18px;font-size:.8rem;animation:toastIn .3s ease; | |
| } | |
| .toast.success{border-color:rgba(80,255,140,0.4);color:rgba(180,255,200,1)} | |
| @keyframes toastIn{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:none}} | |
| /* ββ Name Modal ββ */ | |
| .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.8);backdrop-filter:blur(4px);display:none;align-items:center;justify-content:center;z-index:100} | |
| .modal-overlay.active{display:flex} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="toast-wrap" id="toastWrap"></div> | |
| <!-- Name Modal --> | |
| <div class="modal-overlay" id="nameModal"> | |
| <div class="room-box"> | |
| <h2 style="margin-bottom:8px">What's your name?</h2> | |
| <p class="landing-sub" style="margin-bottom:20px;font-size:.9rem">This will be shown in the room chat.</p> | |
| <input type="text" id="usernameInput" class="input-code" placeholder="Enter name" style="letter-spacing:normal;text-align:left;font-family:inherit;margin-bottom:16px"> | |
| <button class="btn btn-primary" onclick="setName()">Continue</button> | |
| </div> | |
| </div> | |
| <!-- Landing --> | |
| <div class="landing" id="landingPage"> | |
| <div class="brand-title"><span class="brand-dot"></span> RadarDrop</div> | |
| <div class="landing-sub">Private, secure, real-time file sharing rooms.</div> | |
| <div class="room-box"> | |
| <button class="btn btn-primary" style="padding:16px;font-size:1.05rem" onclick="createRoom()">οΌ Create New Room</button> | |
| <div class="divider">OR</div> | |
| <input type="text" id="joinCode" class="input-code" placeholder="PIN" maxlength="4" style="margin-bottom:12px"> | |
| <button class="btn" onclick="joinRoom()">Join Room</button> | |
| </div> | |
| </div> | |
| <!-- Main App --> | |
| <div class="app-container" id="app"> | |
| <!-- Sidebar --> | |
| <aside class="sidebar"> | |
| <div style="font-weight:700;font-size:1.1rem;display:flex;align-items:center;gap:8px"> | |
| <span class="brand-dot" style="width:10px;height:10px"></span> RadarDrop | |
| </div> | |
| <div style="text-align:center;margin-top:10px"> | |
| <div style="font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px">Room Code</div> | |
| <div class="room-badge" id="roomBadge">----</div> | |
| </div> | |
| <!-- Radar --> | |
| <div class="radar-wrap"> | |
| <svg viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="90" cy="90" r="88" fill="rgba(255,255,255,0.02)"/> | |
| <circle class="radar-ring" cx="90" cy="90" r="70"/> | |
| <circle class="radar-ring" cx="90" cy="90" r="45"/> | |
| <circle class="radar-ring" cx="90" cy="90" r="20"/> | |
| <line x1="90" y1="10" x2="90" y2="170" stroke="rgba(255,255,255,0.1)"/> | |
| <line x1="10" y1="90" x2="170" y2="90" stroke="rgba(255,255,255,0.1)"/> | |
| <g class="radar-sweep"> | |
| <defs> | |
| <radialGradient id="sweepGrad" cx="90" cy="90" r="70" gradientUnits="userSpaceOnUse"> | |
| <stop offset="0%" stop-color="rgba(255,255,255,0)"/> | |
| <stop offset="100%" stop-color="rgba(255,255,255,0.2)"/> | |
| </radialGradient> | |
| </defs> | |
| <path d="M90,90 L90,20 A70,70 0 0,1 160,90 Z" fill="url(#sweepGrad)"/> | |
| <line x1="90" y1="90" x2="90" y2="20" stroke="rgba(255,255,255,0.8)" stroke-width="1.5"/> | |
| </g> | |
| <circle class="blip" cx="120" cy="50" r="3"/> | |
| <circle class="blip" cx="60" cy="120" r="2" style="animation-delay:1.5s"/> | |
| <circle class="radar-dot" cx="90" cy="90" r="3"/> | |
| </svg> | |
| </div> | |
| <!-- QR --> | |
| <div class="qr-wrap"> | |
| <div class="qr-toggle"> | |
| <button class="qr-tab active" id="tabLocal" onclick="switchQR('local')">Local</button> | |
| <button class="qr-tab" id="tabPublic" onclick="switchQR('public')">Internet</button> | |
| </div> | |
| <img id="qrImg" src="" alt="QR Code"/> | |
| <div class="public-url-text" id="publicUrlText" style="margin-top:4px">Loading tunnel...</div> | |
| </div> | |
| <button class="btn btn-danger btn-sm" onclick="leaveRoom()" style="margin-top:auto">β Leave Room</button> | |
| </aside> | |
| <!-- Center: Files --> | |
| <main class="main"> | |
| <div class="section-title">Share Files</div> | |
| <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()"> | |
| <span class="drop-icon">π‘</span> | |
| <div style="font-weight:600;font-size:1.1rem;margin-bottom:6px">Drop files here</div> | |
| <div style="font-size:.8rem;color:var(--muted)">or click to browse</div> | |
| <input type="file" id="fileInput" multiple/> | |
| </div> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"> | |
| <div class="section-title" style="margin:0">Room Files</div> | |
| </div> | |
| <div class="files-grid" id="filesList"></div> | |
| </main> | |
| <!-- Right: Chat --> | |
| <aside class="chat-panel"> | |
| <div class="chat-header"> | |
| π¬ Room Chat | |
| </div> | |
| <div class="chat-messages" id="chatMsgs"> | |
| <div style="text-align:center;color:var(--muted);font-size:.75rem;margin:auto">Chat history is synced automatically.</div> | |
| </div> | |
| <div class="chat-input-wrap"> | |
| <input type="text" id="chatInput" class="chat-input" placeholder="Type a message..." autocomplete="off"> | |
| <button class="chat-send" onclick="sendChat()"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg> | |
| </button> | |
| </div> | |
| </aside> | |
| </div> | |
| <script> | |
| // ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let roomCode = null; | |
| let username = localStorage.getItem('radardrop_name') || ''; | |
| let ws = null; | |
| let publicUrl = null; | |
| let localUrl = null; | |
| // ββ Init βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.onload = async () => { | |
| if(!username) { | |
| document.getElementById('nameModal').classList.add('active'); | |
| } | |
| // Check URL params for room invite | |
| const params = new URLSearchParams(window.location.search); | |
| const invite = params.get('room'); | |
| if(invite && invite.length === 4){ | |
| document.getElementById('joinCode').value = invite; | |
| } | |
| // Load IP for QR codes | |
| try{ | |
| const r = await fetch('/ip'); | |
| const d = await r.json(); | |
| localUrl = d.local_url; | |
| publicUrl = d.public_url; | |
| }catch(e){} | |
| }; | |
| function setName(){ | |
| const val = document.getElementById('usernameInput').value.trim(); | |
| if(!val) return; | |
| username = val; | |
| localStorage.setItem('radardrop_name', username); | |
| document.getElementById('nameModal').classList.remove('active'); | |
| } | |
| function toast(msg, type='success'){ | |
| const el = document.createElement('div'); | |
| el.className = `toast ${type}`; | |
| el.textContent = msg; | |
| document.getElementById('toastWrap').appendChild(el); | |
| setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .4s'; setTimeout(()=>el.remove(),400); }, 3000); | |
| } | |
| // ββ Rooms ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function generateCode(){ return Math.floor(1000 + Math.random() * 9000).toString(); } | |
| function createRoom(){ | |
| const code = generateCode(); | |
| enterRoom(code); | |
| } | |
| function joinRoom(){ | |
| const code = document.getElementById('joinCode').value.trim(); | |
| if(code.length !== 4) return toast('Enter 4 digit PIN', 'error'); | |
| enterRoom(code); | |
| } | |
| function enterRoom(code){ | |
| roomCode = code; | |
| document.getElementById('landingPage').classList.add('hidden'); | |
| document.getElementById('app').classList.add('active'); | |
| document.getElementById('roomBadge').textContent = code; | |
| // Update URL without reload | |
| const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + "?room=" + code; | |
| window.history.pushState({path:newUrl},'',newUrl); | |
| switchQR('local'); | |
| loadFiles(); | |
| connectWS(); | |
| } | |
| function leaveRoom(){ | |
| if(ws) ws.close(); | |
| roomCode = null; | |
| document.getElementById('app').classList.remove('active'); | |
| document.getElementById('landingPage').classList.remove('hidden'); | |
| window.history.pushState({},'',window.location.pathname); | |
| } | |
| function switchQR(mode){ | |
| const img = document.getElementById('qrImg'); | |
| const txt = document.getElementById('publicUrlText'); | |
| document.getElementById('tabLocal').classList.toggle('active', mode==='local'); | |
| document.getElementById('tabPublic').classList.toggle('active', mode==='public'); | |
| if(mode === 'local'){ | |
| img.src = `/qr?room=${roomCode}&t=${Date.now()}`; | |
| txt.textContent = 'Works on Local Wi-Fi'; | |
| } else { | |
| if(publicUrl && publicUrl !== 'FAILED'){ | |
| img.src = `/qr/public?room=${roomCode}&t=${Date.now()}`; | |
| txt.textContent = publicUrl; | |
| } else { | |
| img.src = ''; | |
| txt.textContent = 'Tunnel not active'; | |
| } | |
| } | |
| } | |
| // ββ WebSockets βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function connectWS(){ | |
| if(ws) ws.close(); | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/${roomCode}`; | |
| ws = new WebSocket(wsUrl); | |
| ws.onmessage = (e) => { | |
| const data = JSON.parse(e.data); | |
| if(data.type === 'chat'){ | |
| renderMessage(data); | |
| } else if(data.type === 'files_updated'){ | |
| loadFiles(); | |
| } | |
| }; | |
| ws.onclose = () => { | |
| if(roomCode) setTimeout(connectWS, 2000); // Reconnect if still in room | |
| }; | |
| } | |
| function renderMessage(msg){ | |
| const box = document.getElementById('chatMsgs'); | |
| const isSelf = msg.sender === username; | |
| const el = document.createElement('div'); | |
| el.className = `chat-msg ${isSelf ? 'self' : ''}`; | |
| el.innerHTML = ` | |
| <div class="msg-sender">${isSelf ? 'You' : msg.sender}</div> | |
| <div class="msg-bubble">${msg.text}</div> | |
| <div class="msg-time">${msg.timestamp || ''}</div> | |
| `; | |
| box.appendChild(el); | |
| box.scrollTop = box.scrollHeight; | |
| } | |
| function sendChat(){ | |
| const input = document.getElementById('chatInput'); | |
| const text = input.value.trim(); | |
| if(!text || !ws) return; | |
| ws.send(JSON.stringify({type: 'chat', sender: username, text: text})); | |
| input.value = ''; | |
| } | |
| document.getElementById('chatInput').addEventListener('keypress', e => { | |
| if(e.key === 'Enter') sendChat(); | |
| }); | |
| // ββ Files ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function fileIcon(mime=''){ | |
| if(mime.startsWith('image/')) return 'πΌοΈ'; | |
| if(mime.startsWith('video/')) return 'π¬'; | |
| if(mime.startsWith('audio/')) return 'π΅'; | |
| if(mime.includes('pdf')) return 'π'; | |
| if(mime.includes('zip')) return 'ποΈ'; | |
| if(mime.includes('text')||mime.includes('json')||mime.includes('javascript')) return 'π'; | |
| return 'π'; | |
| } | |
| async function loadFiles(){ | |
| if(!roomCode) return; | |
| try{ | |
| const r = await fetch(`/api/rooms/${roomCode}/files`); | |
| const files = await r.json(); | |
| const el = document.getElementById('filesList'); | |
| if(!files.length){ | |
| el.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">No files in this room yet.</div>`; | |
| return; | |
| } | |
| el.innerHTML = files.map(f=>` | |
| <div class="file-card"> | |
| <div class="file-icon">${fileIcon(f.mime)}</div> | |
| <div class="file-info"> | |
| <div class="file-name" title="${f.name}">${f.name}</div> | |
| <div class="file-meta">${f.size_hr} Β· ${f.modified}</div> | |
| </div> | |
| <div style="display:flex;gap:6px"> | |
| <a href="/api/rooms/${roomCode}/download/${encodeURIComponent(f.name)}" download class="btn btn-sm">β¬</a> | |
| <button class="btn btn-sm btn-danger" onclick="deleteFile('${f.name.replace(/'/g,"\\'")}')">β</button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| }catch(e){ | |
| console.error(e); | |
| } | |
| } | |
| async function deleteFile(name){ | |
| if(!confirm(`Delete "${name}"?`)) return; | |
| const r = await fetch(`/api/rooms/${roomCode}/files/${encodeURIComponent(name)}`, {method:'DELETE'}); | |
| if(r.ok) toast('Deleted', 'success'); | |
| } | |
| const dz = document.getElementById('dropZone'); | |
| dz.ondragover = e => { e.preventDefault(); dz.classList.add('drag-over'); }; | |
| dz.ondragleave = () => dz.classList.remove('drag-over'); | |
| dz.ondrop = e => { e.preventDefault(); dz.classList.remove('drag-over'); uploadFiles([...e.dataTransfer.files]); }; | |
| document.getElementById('fileInput').onchange = e => uploadFiles([...e.target.files]); | |
| async function uploadFiles(files){ | |
| if(!files.length || !roomCode) return; | |
| for(let f of files){ | |
| const fd = new FormData(); fd.append('file', f); | |
| await fetch(`/api/rooms/${roomCode}/upload`, { method:'POST', body:fd }); | |
| } | |
| toast(`Uploaded ${files.length} file(s)`, 'success'); | |
| } | |
| </script> | |
| </body> | |
| </html> | |