Spaces:
Runtime error
Runtime error
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>PixelDrive</title> | |
| <style> | |
| :root{--bg:#0d0d12;--panel:#16161d;--border:#2a2a3e;--text:#e8e8ed;--muted:#888;--acc:#4da3ff;--acc-h:#3d8bd9;--err:#ff4d6a;--ok:#4ade80;--warn:#fbbf24} | |
| *{box-sizing:border-box;margin:0;padding:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif} | |
| body{background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden} | |
| a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline} | |
| button{cursor:pointer;border:none;background:var(--acc);color:#000;padding:.6rem 1rem;border-radius:6px;font-weight:600;transition:.15s} | |
| button:hover{background:var(--acc-h)}button:disabled{opacity:.5;cursor:not-allowed} | |
| button.secondary{background:var(--border);color:var(--text)}button.secondary:hover{background:#3a3a4e} | |
| button.danger{background:var(--err);color:#fff}input,select{background:var(--panel);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;width:100%} | |
| input:focus,select:focus{outline:none;border-color:var(--acc);box-shadow:0 0 0 2px #4da3ff44} | |
| .modal-overlay{position:fixed;inset:0;background:#000c;display:flex;align-items:center;justify-content:center;z-index:100;padding:1rem} | |
| .modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:2rem;width:100%;max-width:420px;box-shadow:0 20px 40px #0008} | |
| .modal h2{margin-bottom:1.5rem;text-align:center} | |
| .form-group{margin-bottom:1rem}.form-row{display:flex;gap:1rem}.form-row>.form-group{flex:1} | |
| .toast{position:fixed;bottom:1.5rem;right:1.5rem;padding:1rem 1.5rem;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 10px 30px #0008;z-index:200;animation:slide .3s;display:flex;gap:.75rem;align-items:center;max-width:350px} | |
| .toast.ok{border-color:var(--ok)}.toast.err{border-color:var(--err)}.toast.warn{border-color:var(--warn)} | |
| @keyframes slide{from{transform:translateX(120%);opacity:0}to{transform:translateX(0);opacity:1}} | |
| .hidden{display:none!important} | |
| .header{display:flex;justify-content:space-between;align-items:center;padding:0 1.5rem;height:56px;background:var(--panel);border-bottom:1px solid var(--border);flex-shrink:0} | |
| .header h1{font-size:1.25rem;font-weight:700;letter-spacing:-.5px;color:var(--acc)} | |
| .header-actions{display:flex;gap:.5rem;align-items:center} | |
| .main{display:flex;flex:1;overflow:hidden;min-height:0} | |
| .sidebar{width:260px;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden} | |
| .sidebar-header{padding:1rem;border-bottom:1px solid var(--border);font-weight:600;font-size:.85rem;text-transform:uppercase;color:var(--muted);letter-spacing:.5px} | |
| .sidebar-nav{flex:1;overflow-y:auto;padding:.5rem} | |
| .nav-item{display:flex;align-items:center;gap:.75rem;padding:.6rem .75rem;border-radius:6px;color:var(--text);margin-bottom:.25rem;transition:.1s} | |
| .nav-item:hover{background:var(--border)}.nav-item.active{background:var(--acc);color:#000;font-weight:600} | |
| .nav-item i{width:20px;text-align:center;font-style:normal;font-size:1.1rem} | |
| .content{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0} | |
| .toolbar{display:flex;gap:.5rem;padding:1rem;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap;flex-shrink:0} | |
| .toolbar-spacer{flex:1} | |
| .file-grid{flex:1;overflow-y:auto;padding:1rem;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;align-content:start} | |
| .file-card{background:var(--panel);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:.15s;position:relative} | |
| .file-card:hover{border-color:var(--acc);box-shadow:0 8px 20px #0006;transform:translateY(-2px)} | |
| .file-card.selected{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc)} | |
| .file-thumb{aspect-ratio:16/10;background:#0a0a0f;overflow:hidden;position:relative} | |
| .file-thumb img,.file-thumb video{width:100%;height:100%;object-fit:cover} | |
| .file-thumb .play-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:#0006;color:#fff;font-size:2rem;opacity:0;transition:.2s} | |
| .file-card:hover .play-overlay{opacity:1} | |
| .file-info{padding:.75rem;display:flex;flex-direction:column;gap:.25rem;min-height:0} | |
| .file-name{font-size:.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .file-meta{font-size:.7rem;color:var(--muted);display:flex;justify-content:space-between} | |
| .file-progress{height:3px;background:var(--border);border-radius:3px;overflow:hidden;margin-top:auto} | |
| .file-progress-bar{height:100%;background:var(--acc);width:0%;transition:width .3s} | |
| .context-menu{position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 10px 30px #0008;z-index:150;min-width:180px;overflow:hidden} | |
| .context-menu button{width:100%;text-align:left;padding:.6rem 1rem;background:none;color:var(--text);font-weight:400;border-radius:0;border-bottom:1px solid var(--border)} | |
| .context-menu button:last-child{border-bottom:none} | |
| .context-menu button:hover{background:var(--border)} | |
| .progress-modal .progress-bar{height:8px;background:var(--border);border-radius:4px;overflow:hidden;margin:1rem 0} | |
| .progress-modal .progress-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--ok));width:0%;transition:width .2s} | |
| .progress-modal .progress-stats{display:flex;justify-content:space-between;font-size:.8rem;color:var(--muted)} | |
| .tos-content{max-height:300px;overflow-y:auto;padding:1rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin:1rem 0;font-size:.85rem;line-height:1.6;white-space:pre-wrap} | |
| @media(max-width:768px){.sidebar{position:fixed;left:0;top:56px;bottom:0;z-index:50;transform:translateX(-100%);transition:.3s}.sidebar.open{transform:translateX(0)}.sidebar-overlay{display:none;position:fixed;inset:0;background:#0008;z-index:40}.sidebar-overlay.open{display:block}.header .menu-btn{display:block}} | |
| @media(min-width:769px){.header .menu-btn{display:none}} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="toast-container"></div> | |
| <!-- AUTH MODAL --> | |
| <div id="authModal" class="modal-overlay hidden"> | |
| <div class="modal" role="dialog" aria-labelledby="authTitle"> | |
| <h2 id="authTitle">Connexion</h2> | |
| <div id="authForms"> | |
| <!-- Login --> | |
| <form id="loginForm"> | |
| <div class="form-group"><label>Utilisateur</label><input name="username" required autocomplete="username"></div> | |
| <div class="form-group"><label>Mot de passe</label><input name="password" type="password" required autocomplete="current-password"></div> | |
| <button type="submit">Se connecter</button> | |
| </form> | |
| <!-- Register --> | |
| <form id="registerForm" class="hidden"> | |
| <div class="form-group"><label>Utilisateur</label><input name="username" required autocomplete="username"></div> | |
| <div class="form-group"><label>Mot de passe</label><input name="password" type="password" required minlength="8" autocomplete="new-password"></div> | |
| <div class="form-group"><label>Confirmer</label><input name="confirm" type="password" required autocomplete="new-password"></div> | |
| <div class="form-group"><label><input type="checkbox" name="tos" required> J'accepte les <a href="#" id="tosLink">CGU</a> (Responsabilité fichiers, pas de garantie, projet test)</label></div> | |
| <button type="submit">Créer compte</button> | |
| </form> | |
| </div> | |
| <p style="text-align:center;margin-top:1rem"><button type="button" class="secondary" id="switchAuth">Pas de compte ? S'inscrire</button></p> | |
| </div> | |
| </div> | |
| <!-- TOS MODAL --> | |
| <div id="tosModal" class="modal-overlay hidden"> | |
| <div class="modal" style="max-width:600px"> | |
| <h2>Conditions d'utilisation (CGU)</h2> | |
| <div class="tos-content" id="tosText">Chargement...</div> | |
| <label><input type="checkbox" id="tosAccept"> J'ai lu et j'accepte</label> | |
| <div style="display:flex;gap:.5rem;margin-top:1rem"><button class="secondary" id="tosCancel">Annuler</button><button id="tosConfirm" disabled>Valider</button></div> | |
| </div> | |
| </div> | |
| <!-- PROGRESS MODAL --> | |
| <div id="progressModal" class="modal-overlay hidden"> | |
| <div class="modal progress-modal"> | |
| <h3 id="progressTitle">Transfert en cours...</h3> | |
| <div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div> | |
| <div class="progress-stats"><span id="progressSpeed">--</span><span id="progressEta">--</span></div> | |
| <div style="text-align:right;margin-top:1rem"><button class="secondary" id="progressCancel">Annuler</button></div> | |
| </div> | |
| </div> | |
| <!-- MAIN APP --> | |
| <header class="header"> | |
| <button class="menu-btn secondary" id="menuToggle" aria-label="Menu">☰</button> | |
| <h1>PixelDrive</h1> | |
| <div class="header-actions"> | |
| <span id="userBadge" class="hidden" style="font-size:.8rem;padding:.25rem .5rem;background:var(--border);border-radius:4px"></span> | |
| <button class="secondary" id="logoutBtn" class="hidden">Déconnexion</button> | |
| </div> | |
| </header> | |
| <div class="sidebar-overlay" id="sidebarOverlay"></div> | |
| <aside class="sidebar" id="sidebar"> | |
| <div class="sidebar-header">Navigation</div> | |
| <nav class="sidebar-nav" id="sidebarNav"> | |
| <div class="nav-item active" data-path="root"><span>📁</span> Mes Fichiers</div> | |
| <div class="nav-item" data-path="shared"><span>🔗</span> Partagés</div> | |
| <div class="nav-item" data-path="recent"><span>🕒</span> Récents</div> | |
| <hr style="margin:.5rem 0;border-color:var(--border)"> | |
| <div id="folderTree"></div> | |
| </nav> | |
| </aside> | |
| <main class="content"> | |
| <div class="toolbar"> | |
| <input type="file" id="fileInput" multiple hidden accept="*/*"> | |
| <button id="uploadBtn" title="Uploader (Fichiers/Dossiers)"><span>⬆</span> Uploader</button> | |
| <button class="secondary" id="newFolderBtn" title="Nouveau dossier"><span>➕</span> Dossier</button> | |
| <div class="toolbar-spacer"></div> | |
| <select id="sortSelect" style="width:auto"><option value="name">Nom (A-Z)</option><option value="name_desc">Nom (Z-A)</option><option value="size">Taille</option><option value="date">Date</option></select> | |
| <button class="secondary" id="refreshBtn" title="Rafraîchir">⟳</button> | |
| </div> | |
| <div class="file-grid" id="fileGrid" role="list"></div> | |
| </main> | |
| <!-- CONTEXT MENU --> | |
| <div class="context-menu hidden" id="ctxMenu" role="menu"> | |
| <button data-action="preview" role="menuitem">👁 Aperçu / Embed</button> | |
| <button data-action="download" role="menuitem">⬇ Télécharger</button> | |
| <button data-action="share_embed" role="menuitem">🔗 Lien Embed</button> | |
| <button data-action="share_dl" role="menuitem">📥 Lien Téléchargement</button> | |
| <hr style="margin:.25rem 0;border-color:var(--border)"> | |
| <button data-action="rename" class="secondary" role="menuitem">✏ Renommer</button> | |
| <button data-action="move" class="secondary" role="menuitem">📂 Déplacer</button> | |
| <button data-action="delete" class="danger" role="menuitem">🗑 Supprimer</button> | |
| </div> | |
| <script> | |
| // ========================================== | |
| // PIXELDRIVE FRONTEND - VANILLA JS ES6 | |
| // ========================================== | |
| const API = ''; | |
| let currentFolder = 'root'; // 'root' ou folderId | |
| let currentSort = 'name'; | |
| let selectedFiles = new Set(); | |
| let ctxTarget = null; | |
| let uploadAbortController = null; | |
| // --- UTILS --- | |
| const $ = (s, r=document) => r.querySelector(s); | |
| const $$ = (s, r=document) => [...r.querySelectorAll(s)]; | |
| const fmtSize = b => { if(b<1024)return b+'B'; const u=['KB','MB','GB','TB']; let i=0; while(b>=1024&&i<u.length-1){b/=1024;i++} return b.toFixed(b>=100?0:1)+u[i]; }; | |
| const fmtTime = s => { if(!s||s<0)return'--'; const h=Math.floor(s/3600),m=Math.floor((s%3600)/60); return (h?h+'h ':'')+m+'m'; }; | |
| const escapeHtml = s => s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| function toast(msg, type='') { const c=$('#toastContainer'); const t=document.createElement('div'); t.className=`toast ${type}`; t.textContent=msg; c.appendChild(t); setTimeout(()=>{t.style.animation='slide .3s reverse';setTimeout(()=>t.remove(),300)},5000); } | |
| function showModal(id) { $(id).classList.remove('hidden'); } | |
| function hideModal(id) { $(id).classList.add('hidden'); } | |
| // --- API WRAPPER --- | |
| async function api(path, opts={}) { | |
| const res = await fetch(API+path, { credentials:'include', headers:{'Content-Type':'application/json',...opts.headers}, ...opts }); | |
| const ct = res.headers.get('content-type')||''; | |
| const data = ct.includes('json') ? await res.json() : await res.text(); | |
| if(!res.ok) throw new Error(data?.error || data || `HTTP ${res.status}`); | |
| return data; | |
| } | |
| // --- AUTH --- | |
| async function checkAuth() { | |
| try { | |
| const me = await api('/api/me'); | |
| $('#userBadge').textContent = me.username + (me.isAdmin?' ⚡':''); | |
| $('#userBadge').classList.remove('hidden'); | |
| $('#logoutBtn').classList.remove('hidden'); | |
| $('#authModal').classList.add('hidden'); | |
| if(!me.tosAccepted) showTosModal(true); | |
| loadFolders(); | |
| loadFiles(); | |
| } catch { showModal('#authModal'); } | |
| } | |
| $('#loginForm').onsubmit = async e => { e.preventDefault(); const fd=new FormData(e.target); try{await api('/api/login',{method:'POST',body:JSON.stringify(Object.fromEntries(fd))});checkAuth();toast('Connecté','ok')}catch(err){toast(err.message,'err')} }; | |
| $('#registerForm').onsubmit = async e => { e.preventDefault(); const fd=new FormData(e.target); if(fd.get('password')!==fd.get('confirm'))return toast('Mots de passe différents','err'); try{await api('/api/register',{method:'POST',body:JSON.stringify(Object.fromEntries(fd))});toast('Compte créé, connectez-vous','ok');switchAuth('login')}catch(err){toast(err.message,'err')} }; | |
| $('#switchAuth').onclick = () => switchAuth($('#loginForm').classList.contains('hidden')?'login':'register'); | |
| function switchAuth(mode) { $('#loginForm').classList.toggle('hidden',mode!=='login'); $('#registerForm').classList.toggle('hidden',mode!=='register'); $('#authTitle').textContent = mode==='login'?'Connexion':'Inscription'; } | |
| $('#logoutBtn').onclick = async () => { try{await api('/api/logout',{method:'POST'});location.reload()}catch(toast('Erreur','err')) }; | |
| $('#tosLink').onclick = e => { e.preventDefault(); showTosModal(false); }; | |
| // --- TOS --- | |
| async function showTosModal(forced) { | |
| try { const t=await api('/api/tos'); $('#tosText').textContent=t; } catch{ $('#tosText').textContent='Erreur chargement CGU'; } | |
| $('#tosAccept').checked=false; $('#tosConfirm').disabled=true; | |
| $('#tosAccept').onchange=()=>$('#tosConfirm').disabled=!$('#tosAccept').checked; | |
| $('#tosConfirm').onclick=async()=>{try{await api('/api/tos/accept',{method:'POST'});toast('CGU acceptées','ok');hideModal('#tosModal');if(forced)checkAuth()}catch(err){toast(err.message,'err')}}; | |
| $('#tosCancel').onclick=()=>{if(forced)location.reload();else hideModal('#tosModal')}; | |
| showModal('#tosModal'); | |
| } | |
| // --- FOLDERS --- | |
| async function loadFolders() { | |
| try { | |
| const folders = await api('/api/folders'); | |
| const tree = $('#folderTree'); | |
| tree.innerHTML = ''; | |
| const roots = folders.filter(f=>!f.parent_id); | |
| function render(f, depth=0) { | |
| const div=document.createElement('div'); div.className='nav-item'; div.dataset.fid=f.id; div.style.paddingLeft=`${.75+depth*1}rem`; | |
| div.innerHTML=`<span>📂</span>${escapeHtml(f.name)}`; | |
| div.onclick=e=>{e.stopPropagation();currentFolder=f.id;loadFiles();$$('.nav-item').forEach(x=>x.classList.remove('active'));div.classList.add('active');closeSidebar()}; | |
| const children=folders.filter(c=>c.parent_id===f.id); | |
| children.forEach(c=>div.appendChild(render(c,depth+1))); | |
| return div; | |
| } | |
| roots.forEach(r=>tree.appendChild(render(r))); | |
| } catch(err){console.error(err)} | |
| } | |
| $('#newFolderBtn').onclick = async () => { const name=prompt('Nom du dossier:'); if(!name)return; try{await api('/api/folders',{method:'POST',body:JSON.stringify({name,parent_id:currentFolder==='root'?null:currentFolder})});toast('Dossier créé','ok');loadFolders()}catch(err){toast(err.message,'err')} }; | |
| // --- FILES --- | |
| async function loadFiles() { | |
| const grid=$('#fileGrid'); grid.innerHTML='<div style="grid-column:1/-1;text-align:center;color:var(--muted);padding:2rem">Chargement...</div>'; | |
| try { | |
| const params=new URLSearchParams(); if(currentFolder!=='root')params.set('folder_id',currentFolder); params.set('sort',currentSort); | |
| const files=await api('/api/files?'+params); | |
| renderFiles(files); | |
| } catch(err){grid.innerHTML=`<div style="grid-column:1/-1;text-align:center;color:var(--err);padding:2rem">${err.message}</div>`} | |
| } | |
| function renderFiles(files) { | |
| const grid=$('#fileGrid'); grid.innerHTML=''; | |
| if(!files.length){grid.innerHTML='<div style="grid-column:1/-1;text-align:center;color:var(--muted);padding:2rem">Aucun fichier</div>';return} | |
| files.forEach(f=>{ | |
| const card=document.createElement('div'); card.className='file-card'; card.dataset.id=f.id; card.role='listitem'; card.tabIndex=0; | |
| const isImg=f.mime.startsWith('image/'), isVid=f.mime.startsWith('video/'), isAud=f.mime.startsWith('audio/'), isPdf=f.mime==='application/pdf'; | |
| let thumbHtml='<div class="file-thumb">'; | |
| if(isImg) thumbHtml+=`<img src="/api/files/${f.id}/thumb" loading="lazy" alt="">`; | |
| else if(isVid) thumbHtml+=`<video src="/api/files/${f.id}/thumb" muted playsinline loop preload="metadata"></video><div class="play-overlay">▶</div>`; | |
| else thumbHtml+=`<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">${isAud?'🔊':isPdf?'📄':'📦'}</div>`; | |
| thumbHtml+='</div>'; | |
| card.innerHTML=thumbHtml+`<div class="file-info"><div class="file-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</div><div class="file-meta"><span>${fmtSize(f.size)}</span><span>${f.status==='ready'?'✅':f.status==='error'?'❌':'⏳'}</span></div>${f.status!=='ready'?`<div class="file-progress"><div class="file-progress-bar" style="width:${f.progress}%"></div></div>`:''}</div>`; | |
| card.onclick=e=>{if(e.target.closest('.play-overlay'))return;handleSelect(f.id,e.shiftKey,e.ctrlKey||e.metaKey);}; | |
| card.ondblclick=()=>{if(f.status==='ready')openPreview(f.id);}; | |
| card.oncontextmenu=e=>{e.preventDefault();handleSelect(f.id,false,false);showContextMenu(e.clientX,e.clientY,f);}; | |
| grid.appendChild(card); | |
| // SSE Progress | |
| if(f.status==='uploading'||f.status==='processing') listenProgress(f.id,card); | |
| }); | |
| } | |
| function listenProgress(fileId, card) { | |
| const evtSource=new EventSource(`/api/files/${fileId}/progress`); | |
| evtSource.onmessage=e=>{const d=JSON.parse(e.data);const bar=card.querySelector('.file-progress-bar');if(bar)bar.style.width=d.progress+'%';if(d.type==='done'){evtSource.close();loadFiles();}}; | |
| evtSource.onerror=()=>evtSource.close(); | |
| } | |
| function handleSelect(id, shift, multi) { | |
| const cards=$$('.file-card'); | |
| if(shift&&selectedFiles.size){ | |
| const last=[...selectedFiles].pop(); const lastIdx=cards.findIndex(c=>c.dataset.id===last); const curIdx=cards.findIndex(c=>c.dataset.id===id); | |
| const [s,e]=[lastIdx,curIdx].sort((a,b)=>a-b); for(let i=s;i<=e;i++)selectCard(cards[i].dataset.id,true); | |
| }else if(multi){toggleSelect(id);} | |
| else {clearSelection();selectCard(id,true);} | |
| } | |
| function selectCard(id, add){const card=$(`[data-id="${id}"]`);if(!card)return;if(add){selectedFiles.add(id);card.classList.add('selected')}else{selectedFiles.delete(id);card.classList.remove('selected')}}; | |
| function toggleSelect(id){const card=$(`[data-id="${id}"]`);if(!card)return;if(selectedFiles.has(id))selectCard(id,false);else selectCard(id,true);} | |
| function clearSelection(){$$('.file-card.selected').forEach(c=>c.classList.remove('selected'));selectedFiles.clear();} | |
| function showContextMenu(x,y,file){ctxTarget=file;const m=$('#ctxMenu');m.style.left=x+'px';m.style.top=y+'px';m.classList.remove('hidden');m.onclick=handleCtxAction;document.onclick=()=>{m.classList.add('hidden');document.onclick=null};} | |
| async function handleCtxAction(e){const btn=e.target.closest('button');if(!btn)return;const act=btn.dataset.action;$('#ctxMenu').classList.add('hidden'); | |
| try{ | |
| if(act==='download')downloadFile(ctxTarget.id); | |
| else if(act==='preview')openPreview(ctxTarget.id); | |
| else if(act==='share_embed')await createShare(ctxTarget.id,'embed'); | |
| else if(act==='share_dl')await createShare(ctxTarget.id,'download'); | |
| else if(act==='delete')await deleteFile(ctxTarget.id); | |
| else if(act==='rename'){const n=prompt('Nouveau nom:',ctxTarget.name);if(n)await renameFile(ctxTarget.id,n);} | |
| else if(act==='move')await moveFile(ctxTarget.id); | |
| }catch(err){toast(err.message,'err')} | |
| } | |
| async function createShare(fileId,type){const r=await api('/api/files/'+fileId+'/share',{method:'POST',body:JSON.stringify({type})});navigator.clipboard.writeText(location.origin+r.url);toast(`Lien ${type} copié !`,`ok`);} | |
| async function deleteFile(id){if(!confirm('Supprimer définitivement ?'))return;await api('/api/files/'+id,{method:'DELETE'});toast('Supprimé','ok');loadFiles();} | |
| async function renameFile(id,name){await api('/api/files/'+id,{method:'PATCH',body:JSON.stringify({name})});toast('Renommé','ok');loadFiles();} | |
| async function moveFile(id){const folders=await api('/api/folders');const opts=folders.map(f=>`<option value="${f.id}">${' '.repeat(f.depth||0)}${f.name}</option>`).join('');const target=prompt('ID dossier cible (0=racine):\n'+folders.map(f=>f.id+': '+f.name).join('\n'));if(target!==null){await api('/api/files/'+id,{method:'PATCH',body:JSON.stringify({folder_id:target||null})});toast('Déplacé','ok');loadFiles();}} | |
| function downloadFile(fileId){window.open(`/api/files/${fileId}/download`,'_blank');} | |
| function openPreview(fileId){window.open(`/api/files/${fileId}/preview`,'_blank');} | |
| // --- UPLOAD --- | |
| $('#uploadBtn').onclick=()=>$('#fileInput').click(); | |
| $('#fileInput').onchange=async e=>{ | |
| const files=[...e.target.files]; if(!files.length)return; | |
| $('#fileInput').value=''; | |
| for(const file of files){await uploadFile(file);} | |
| }; | |
| async function uploadFile(file) { | |
| const form=new FormData(); form.append('file',file); form.append('folder_id',currentFolder==='root'?'':currentFolder); | |
| const xhr=new XMLHttpRequest(); | |
| return new Promise((resolve,reject)=>{ | |
| const fileId='temp_'+Date.now(); | |
| showProgressModal(file.name); | |
| xhr.upload.onprogress=e=>{if(e.lengthComputable)updateProgress(e.loaded,e.total,xhr)}; | |
| xhr.onload=()=>{hideProgressModal();if(xhr.status>=200&&xhr.status<300){const r=JSON.parse(xhr.responseText);toast(`${file.name} en file d'attente`,'ok');setTimeout(loadFiles,2000);resolve(r)}else reject(new Error(xhr.responseText))}; | |
| xhr.onerror=()=>reject(new Error('Erreur réseau')); | |
| xhr.open('POST','/api/files/upload'); xhr.send(form); | |
| uploadAbortController={abort:()=>xhr.abort()}; | |
| }); | |
| } | |
| function showProgressModal(name){$('#progressTitle').textContent=`Upload: ${name}`;$('#progressFill').style.width='0%';$('#progressSpeed').textContent='--';$('#progressEta').textContent='--';showModal('#progressModal');} | |
| function hideProgressModal(){hideModal('#progressModal');} | |
| function updateProgress(loaded,total,xhr){ | |
| const pct=(loaded/total*100).toFixed(1); $('#progressFill').style.width=pct+'%'; | |
| // Speed/ETA estimation (rough) | |
| const elapsed=(Date.now()-xhr.startTime)/1000; const speed=loaded/elapsed; | |
| $('#progressSpeed').textContent=`${fmtSize(speed)}/s`; $('#progressEta').textContent=`ETA: ${fmtTime((total-loaded)/speed)}`; | |
| } | |
| $('#progressCancel').onclick=()=>{if(uploadAbortController)uploadAbortController.abort();hideProgressModal();toast('Annulé','warn')}; | |
| // Patch xhr startTime | |
| const origOpen=XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open=function(){this.startTime=Date.now();return origOpen.apply(this,arguments)}; | |
| // --- SORT / REFRESH --- | |
| $('#sortSelect').onchange=e=>{currentSort=e.target.value;loadFiles();}; | |
| $('#refreshBtn').onclick=loadFiles; | |
| // --- MOBILE SIDEBAR --- | |
| $('#menuToggle').onclick=()=>{$('#sidebar').classList.toggle('open');$('#sidebarOverlay').classList.toggle('open')}; | |
| $('#sidebarOverlay').onclick=closeSidebar; | |
| function closeSidebar(){$('#sidebar').classList.remove('open');$('#sidebarOverlay').classList.remove('open')} | |
| // --- INIT --- | |
| checkAuth(); | |
| // Global click deselect | |
| document.addEventListener('click',e=>{if(!e.target.closest('.file-card')&&!e.target.closest('.context-menu'))clearSelection()}); | |
| </script> | |
| </body> | |
| </html> |