Pixel-Drive / public.html
NathMen12's picture
Update public.html
3ac53fb verified
Raw
History Blame Contribute Delete
24.6 kB
<!DOCTYPE html>
<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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>