Files
html/wevia-apple.html.v2-backup-1776648657
2026-04-20 03:35:01 +02:00

399 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>WEVIA Apple — Photos Scanner iPhone + iCloud</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="WEVIA Scan">
<style>
:root{--bg:#0a0e1a;--panel:#111827;--panel2:#1f2937;--br:#1f2937;--fg:#e5e7eb;--mute:#94a3b8;--accent:#60a5fa;--gold:#fbbf24;--ok:#22c55e;--warn:#f59e0b;--err:#ef4444}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(ellipse at top,#0f172a 0%,var(--bg) 60%);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",system-ui,sans-serif;min-height:100vh}
.wrap{max-width:1400px;margin:0 auto;padding:24px}
.hdr{display:flex;align-items:center;gap:16px;margin-bottom:24px;padding-bottom:20px;border-bottom:1px solid var(--br);flex-wrap:wrap}
.hdr .logo{width:48px;height:48px;background:linear-gradient(135deg,#000,#1d1d1f);border-radius:12px;display:grid;place-items:center;font-size:28px}
.hdr h1{margin:0;font-size:1.6rem;font-weight:700;letter-spacing:-0.3px}
.hdr .sub{color:var(--mute);font-size:.9rem;margin-top:2px}
.hdr .spacer{flex:1}
.hdr a.btn{background:rgba(96,165,250,.1);border:1px solid rgba(96,165,250,.3);color:var(--accent);padding:8px 14px;border-radius:8px;text-decoration:none;font-size:.85rem;font-weight:600;margin-left:8px}
.hdr a.btn:hover{background:rgba(96,165,250,.2)}
.hdr a.btn.shortcut{background:rgba(34,197,94,.1);border-color:rgba(34,197,94,.3);color:var(--ok)}
.kpi-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px}
.kpi{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--br);border-radius:12px;padding:16px;position:relative;overflow:hidden}
.kpi::before{content:"";position:absolute;top:0;left:0;width:3px;height:100%}
.kpi.k1::before{background:var(--accent)}.kpi.k2::before{background:var(--gold)}.kpi.k3::before{background:var(--ok)}.kpi.k4::before{background:#c084fc}
.kpi .lbl{color:var(--mute);font-size:.72rem;text-transform:uppercase;letter-spacing:.8px;font-weight:600}
.kpi .val{font-size:2rem;font-weight:700;margin-top:6px;letter-spacing:-.5px}
.kpi .hint{color:var(--mute);font-size:.75rem;margin-top:4px}
.grid{display:grid;grid-template-columns:2fr 1fr;gap:24px}
.panel{background:var(--panel);border:1px solid var(--br);border-radius:12px;padding:20px}
.panel h3{margin:0 0 16px;font-size:1.05rem;font-weight:600}
.uploader{border:2px dashed rgba(96,165,250,.4);border-radius:12px;padding:32px;text-align:center;background:rgba(96,165,250,.05);transition:all .2s}
.uploader.drag{background:rgba(96,165,250,.15);border-color:var(--accent)}
.uploader .ico{font-size:48px;margin-bottom:12px}
.uploader p{margin:8px 0;color:var(--mute)}
.uploader .big{color:var(--fg);font-size:1.05rem;font-weight:600}
.upload-btns{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:14px}
.btn-primary{background:linear-gradient(135deg,#3b82f6,#1d4ed8);border:none;color:white;padding:11px 18px;border-radius:8px;font-weight:600;cursor:pointer;font-size:.9rem;transition:transform .15s}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(59,130,246,.4)}
.btn-secondary{background:rgba(148,163,184,.1);border:1px solid var(--br);color:var(--fg);padding:11px 18px;border-radius:8px;font-weight:600;cursor:pointer;font-size:.9rem}
.btn-secondary:hover{background:rgba(148,163,184,.2)}
.hidden-input{display:none}
.queue{margin-top:20px;display:none}
.queue.active{display:block}
.queue-item{display:flex;align-items:center;gap:10px;padding:10px 14px;background:rgba(0,0,0,.25);border-radius:8px;margin-bottom:6px;font-size:.85rem}
.queue-item.done{border-left:3px solid var(--ok)}
.queue-item.err{border-left:3px solid var(--err)}
.queue-item.pending{border-left:3px solid var(--mute)}
.queue-item.scanning{border-left:3px solid var(--accent)}
.queue-item .n{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:400px}
.queue-item .s{color:var(--mute);font-size:.72rem;margin-left:8px}
.queue-item .badge-q{padding:2px 8px;border-radius:10px;font-size:.7rem;font-weight:600}
.queue-item.done .badge-q{background:rgba(34,197,94,.2);color:var(--ok)}
.queue-item.err .badge-q{background:rgba(239,68,68,.2);color:var(--err)}
.queue-item.scanning .badge-q{background:rgba(96,165,250,.2);color:var(--accent)}
.queue-item.pending .badge-q{background:rgba(148,163,184,.2);color:var(--mute)}
.progress-global{margin-top:12px;padding:10px 14px;background:rgba(96,165,250,.08);border:1px solid rgba(96,165,250,.2);border-radius:8px;font-size:.85rem;display:none}
.progress-global.active{display:block}
.progress-bar{height:6px;background:rgba(255,255,255,.1);border-radius:3px;margin-top:6px;overflow:hidden}
.progress-bar-fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--ok));transition:width .3s;width:0%}
.shortcut-hint{margin-top:18px;padding:14px;background:rgba(34,197,94,.05);border:1px solid rgba(34,197,94,.2);border-radius:8px;font-size:.82rem;color:var(--mute);text-align:left}
.shortcut-hint h4{margin:0 0 8px;color:var(--ok);font-size:.92rem}
.shortcut-hint ol{margin:4px 0 0;padding-left:20px}
.shortcut-hint ol li{margin-bottom:4px;line-height:1.4}
.shortcut-hint code{background:rgba(0,0,0,.4);padding:2px 6px;border-radius:4px;color:var(--gold);font-family:"SF Mono",monospace;font-size:.8em;word-break:break-all}
.copy-btn{background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.3);color:var(--gold);padding:6px 10px;border-radius:6px;cursor:pointer;font-size:.75rem;margin-left:8px}
.copy-btn:hover{background:rgba(251,191,36,.2)}
.scan-list{max-height:680px;overflow-y:auto}
.scan-item{padding:12px;border-radius:8px;margin-bottom:10px;background:rgba(0,0,0,.2);border:1px solid var(--br);cursor:pointer;transition:all .15s}
.scan-item:hover{background:rgba(96,165,250,.08);border-color:var(--accent);transform:translateX(2px)}
.scan-item .top{display:flex;justify-content:space-between;align-items:center;gap:8px}
.scan-item .name{font-weight:600;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:70%}
.scan-item .time{color:var(--mute);font-size:.72rem}
.scan-item .badges{display:flex;gap:6px;margin-top:8px;flex-wrap:wrap}
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:.7rem;font-weight:600}
.b-gh{background:rgba(34,197,94,.15);color:var(--ok);border:1px solid rgba(34,197,94,.3)}
.b-pj{background:rgba(251,191,36,.15);color:var(--gold);border:1px solid rgba(251,191,36,.3)}
.b-dk{background:rgba(96,165,250,.15);color:var(--accent);border:1px solid rgba(96,165,250,.3)}
.empty{text-align:center;padding:40px 20px;color:var(--mute);font-size:.9rem}
.detail{display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);backdrop-filter:blur(8px);padding:20px;overflow-y:auto}
.detail.open{display:block}
.detail .inner{max-width:1100px;margin:20px auto;background:var(--panel);border:1px solid var(--br);border-radius:16px;padding:24px;position:relative}
.detail .close{position:absolute;top:16px;right:16px;background:none;border:1px solid var(--br);color:var(--fg);width:32px;height:32px;border-radius:8px;cursor:pointer;font-size:18px}
.detail .close:hover{background:rgba(239,68,68,.2);border-color:var(--err)}
.detail img{max-width:100%;max-height:400px;border-radius:8px;display:block;margin-bottom:16px}
.detail .sec{background:rgba(0,0,0,.25);border-radius:8px;padding:14px;margin-top:14px}
.detail .sec h4{margin:0 0 8px;font-size:.85rem;color:var(--mute);text-transform:uppercase;letter-spacing:.5px}
.detail .sec .txt{font-family:"SF Mono",monospace;font-size:.82rem;color:#cbd5e1;white-space:pre-wrap;line-height:1.5;max-height:260px;overflow-y:auto}
.oss-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:10px}
.oss-card{background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.25);padding:10px;border-radius:8px;text-align:center;font-size:.82rem;text-decoration:none;color:var(--ok);transition:all .15s}
.oss-card:hover{background:rgba(34,197,94,.15);transform:translateY(-2px)}
.oss-card b{display:block;font-weight:700;margin-bottom:2px}
.oss-card .sub{font-size:.68rem;color:var(--mute);word-break:break-all}
.modal-shortcut{display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);backdrop-filter:blur(8px);padding:20px;overflow-y:auto}
.modal-shortcut.open{display:block}
.modal-shortcut .inner{max-width:800px;margin:20px auto;background:var(--panel);border:1px solid var(--br);border-radius:16px;padding:28px;position:relative}
.modal-shortcut .inner h2{margin:0 0 6px}
.modal-shortcut .sub{color:var(--mute);margin-bottom:20px;font-size:.9rem}
.modal-shortcut ol li{margin-bottom:10px;line-height:1.5}
.toast{position:fixed;bottom:20px;right:20px;background:var(--panel);border:1px solid var(--br);border-left:3px solid var(--ok);padding:14px 18px;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,.5);z-index:10000;max-width:360px;font-size:.88rem}
.toast.err{border-left-color:var(--err)}
@media(max-width:900px){.grid{grid-template-columns:1fr}.kpi-strip{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<div class="wrap">
<div class="hdr">
<div class="logo">🍎</div>
<div>
<h1>WEVIA Apple — Photos Scanner</h1>
<div class="sub">OCR + Gemini 2.5 Flash Vision · HEIC natif · iPhone Shortcuts compatible · Multi-upload + dossiers</div>
</div>
<div class="spacer"></div>
<a class="btn" href="/weval-technology-platform.html">← WTP</a>
<a class="btn" href="/wevia-master.html">WEVIA Master</a>
<a class="btn shortcut" href="javascript:openShortcut()">📲 iPhone Setup</a>
</div>
<div class="kpi-strip">
<div class="kpi k1"><div class="lbl">Photos scannées</div><div class="val" id="k-total">0</div><div class="hint" id="k-last">—</div></div>
<div class="kpi k2"><div class="lbl">Projets OSS identifiés</div><div class="val" id="k-oss">0</div><div class="hint">Word-boundary strict</div></div>
<div class="kpi k3"><div class="lbl">GitHub URLs</div><div class="val" id="k-gh">0</div><div class="hint">Repos mentionnés</div></div>
<div class="kpi k4"><div class="lbl">Top OSS</div><div class="val" id="k-top" style="font-size:1rem;line-height:1.3">—</div><div class="hint">Projet le plus cité</div></div>
</div>
<div class="grid">
<div class="panel">
<h3>📱 Upload — photos, dossiers, batch</h3>
<div class="uploader" id="drop">
<div class="ico">🍎</div>
<p class="big">Glisse des photos/dossiers OU clique pour choisir</p>
<p>JPG · PNG · <b style="color:var(--ok)">HEIC natif iPhone</b> · WEBP · GIF · Multi-sélection OK</p>
<input type="file" class="hidden-input" id="file-multi" accept="image/*,.heic,.heif" multiple>
<input type="file" class="hidden-input" id="file-dir" webkitdirectory directory multiple>
<div class="upload-btns">
<button class="btn-primary" onclick="document.getElementById('file-multi').click()">📷 Photos (multi)</button>
<button class="btn-secondary" onclick="document.getElementById('file-dir').click()">📁 Dossier entier</button>
<button class="btn-secondary" onclick="openShortcut()">📲 iPhone Shortcut</button>
</div>
</div>
<div class="progress-global" id="prog">
<div><b id="prog-txt">Queue vide</b> · <span id="prog-rate" style="color:var(--mute)">—</span></div>
<div class="progress-bar"><div class="progress-bar-fill" id="prog-fill"></div></div>
</div>
<div class="queue" id="queue"></div>
<div class="shortcut-hint">
<h4>🍎 Connecter ton iPhone (doctrine honnête)</h4>
Apple bloque l'accès direct à iCloud Photos depuis le web. <b>3 solutions qui marchent</b> :
<ol>
<li><b>iPhone Shortcut</b> (recommandé, 2 min setup): clique <b>📲 iPhone Setup</b> ci-dessus pour les étapes détaillées. Une fois installé, sélectionne N photos dans l'app Photos → Partager → <code>Scan WEVIA</code> → tout est scanné automatiquement.</li>
<li><b>Dossier iCloud Drive</b>: dépose tes photos dans un dossier iCloud Drive partagé avec ton Mac. Sur Mac, glisse le dossier ici (bouton 📁 Dossier entier). Le navigateur upload chaque image séquentiellement.</li>
<li><b>Automation iCloud</b>: crée un album iCloud "WEVIA Scan" → Automation iPhone "Quand photo ajoutée à album 'WEVIA Scan' → exécuter raccourci Scan WEVIA". Toutes les nouvelles photos sont scannées automatiquement.</li>
</ol>
Endpoint: <code>POST /api/wevia-apple-scan.php?action=upload</code> <button class="copy-btn" onclick="copyEndpoint()">📋 Copier</button>
</div>
</div>
<div class="panel">
<h3>📚 Historique scans <span style="font-weight:400;color:var(--mute);font-size:.85rem">· clique pour drill-down</span></h3>
<div class="scan-list" id="list"><div class="empty">Aucun scan pour le moment.</div></div>
</div>
</div>
</div>
<div class="detail" id="detail"><div class="inner"><button class="close" onclick="closeDetail()">×</button><div id="detail-content"></div></div></div>
<div class="modal-shortcut" id="modal-shortcut"><div class="inner">
<button class="close" onclick="closeShortcut()">×</button>
<h2>📲 Setup iPhone Shortcut — Scan WEVIA</h2>
<p class="sub">Connecte ton iPhone à WEVIA en 2 minutes. Une fois installé, toute photo iPhone peut être scannée en 1 tap.</p>
<ol id="shortcut-steps"><li>Chargement…</li></ol>
<div style="margin-top:16px;padding:14px;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.25);border-radius:8px;font-size:.85rem">
<b style="color:var(--gold)">💡 Multi-photos batch</b> : sélectionne plusieurs photos dans Photos iPhone, puis Partager → <code>Scan WEVIA</code>. Le raccourci les envoie toutes automatiquement.
</div>
<div style="margin-top:12px;padding:14px;background:rgba(96,165,250,.08);border:1px solid rgba(96,165,250,.2);border-radius:8px;font-size:.85rem">
<b style="color:var(--accent)">🤖 Auto-scan album iCloud</b>: app Raccourcis iPhone → onglet Automation → Nouvelle → "Quand photo ajoutée à l'album 'WEVIA'" → choisir <code>Scan WEVIA</code>. Tout ajout = scan auto.
</div>
</div></div>
<script>
const API = '/api/wevia-apple-scan.php';
let SCANS = []; let QUEUE = []; let PROCESSING = false;
async function loadAll(){
try {
const [stats, list] = await Promise.all([
fetch(API+'?action=stats&_='+Date.now()).then(r=>r.json()),
fetch(API+'?action=list&_='+Date.now()).then(r=>r.json())
]);
document.getElementById('k-total').textContent = stats.scans_total || 0;
document.getElementById('k-oss').textContent = stats.oss_total || 0;
document.getElementById('k-gh').textContent = stats.github_urls_total || 0;
const top = stats.top_projects ? Object.entries(stats.top_projects)[0] : null;
document.getElementById('k-top').textContent = top ? (top[0]+' ('+top[1]+')') : '—';
SCANS = list.scans || [];
renderList();
document.getElementById('k-last').textContent = SCANS[0] ? ('Dernier : '+new Date(SCANS[0].scanned_at).toLocaleString('fr')) : 'Aucun scan';
} catch(e){ console.error(e); }
}
function renderList(){
const el = document.getElementById('list');
if (!SCANS.length){ el.innerHTML = '<div class="empty">Aucun scan pour le moment.</div>'; return; }
el.innerHTML = SCANS.slice(0,50).map(s => `
<div class="scan-item" onclick="openDetail('${s.id}')">
<div class="top">
<div class="name">📸 ${escape(s.filename)}</div>
<div class="time">${new Date(s.scanned_at).toLocaleString('fr')}</div>
</div>
<div class="badges">
${s.counts.project_names ? `<span class="badge b-pj">📦 ${s.counts.project_names} OSS</span>`:''}
${s.counts.github_urls ? `<span class="badge b-gh">🐙 ${s.counts.github_urls} GitHub</span>`:''}
${s.counts.docker_images ? `<span class="badge b-dk">🐳 ${s.counts.docker_images} Docker</span>`:''}
<span class="badge" style="background:rgba(148,163,184,.15);color:var(--mute)">${s.scan_ms}ms</span>
</div>
</div>
`).join('');
}
async function openDetail(id){
const scan = SCANS.find(s => s.id === id); if (!scan) return;
const oss = scan.oss_extracted || {};
document.getElementById('detail-content').innerHTML = `
<h2 style="margin:0 0 12px">📸 ${escape(scan.filename)}</h2>
<div style="color:var(--mute);font-size:.85rem;margin-bottom:14px">${new Date(scan.scanned_at).toLocaleString('fr')} · ${scan.scan_ms}ms · ${Math.round(scan.size_bytes/1024)}KB</div>
<img src="${scan.image_url}" alt="scan">
${scan.caption ? `<div class="sec"><h4>Caption</h4><div class="txt">${escape(scan.caption)}</div></div>`:''}
${oss.project_names && oss.project_names.length ? `<div class="sec"><h4>📦 Projets OSS identifiés (${oss.project_names.length})</h4><div class="oss-grid">${oss.project_names.map(p => `<a class="oss-card" href="https://github.com/search?q=${encodeURIComponent(p)}" target="_blank"><b>${escape(p)}</b><span class="sub">github search</span></a>`).join('')}</div></div>`:''}
${oss.github_urls && oss.github_urls.length ? `<div class="sec"><h4>🐙 GitHub URLs (${oss.github_urls.length})</h4><div class="oss-grid">${oss.github_urls.map(u => `<a class="oss-card" href="${u.url}" target="_blank"><b>${escape(u.repo)}</b><span class="sub">${escape(u.owner)}</span></a>`).join('')}</div></div>`:''}
${oss.docker_images && oss.docker_images.length ? `<div class="sec"><h4>🐳 Docker images (${oss.docker_images.length})</h4><div class="txt">${oss.docker_images.map(escape).join('\n')}</div></div>`:''}
<div class="sec"><h4>🔍 Vision LLM (Gemini 2.5 Flash)</h4><div class="txt">${escape(scan.vision_text || '(pas de réponse vision)')}</div></div>
<div class="sec"><h4>📝 OCR brut (tesseract)</h4><div class="txt">${escape(scan.ocr_text || '(OCR vide)')}</div></div>
`;
document.getElementById('detail').classList.add('open');
}
function closeDetail(){ document.getElementById('detail').classList.remove('open'); }
async function openShortcut(){
document.getElementById('modal-shortcut').classList.add('open');
try {
const d = await fetch(API+'?action=shortcut').then(r=>r.json());
const ol = document.getElementById('shortcut-steps');
ol.innerHTML = d.setup_steps.map(s => `<li>${escape(s).replace(/https:\/\/[^\s]+/g, m => `<code>${m}</code>`).replace(/'([^']+)'/g, (_,x)=>`<code>${x}</code>`)}</li>`).join('');
} catch(e){}
}
function closeShortcut(){ document.getElementById('modal-shortcut').classList.remove('open'); }
function copyEndpoint(){ navigator.clipboard.writeText('https://weval-consulting.com/api/wevia-apple-scan.php?action=upload'); showToast('Endpoint copié ✓'); }
function escape(s){ return String(s||'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
document.addEventListener('keydown', e => { if (e.key === 'Escape'){ closeDetail(); closeShortcut(); } });
const drop = document.getElementById('drop');
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('drag'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', e => {
e.preventDefault(); drop.classList.remove('drag');
const files = Array.from(e.dataTransfer.files || []).filter(f => f.type.startsWith('image/') || /\.(heic|heif)$/i.test(f.name));
if (files.length) enqueue(files);
});
document.getElementById('file-multi').addEventListener('change', e => { if (e.target.files.length) enqueue(Array.from(e.target.files)); e.target.value=''; });
document.getElementById('file-dir').addEventListener('change', e => {
const files = Array.from(e.target.files).filter(f => f.type.startsWith('image/') || /\.(heic|heif|jpg|jpeg|png|webp|gif)$/i.test(f.name));
if (files.length) enqueue(files);
e.target.value='';
});
function enqueue(files){
files.forEach((f,i) => QUEUE.push({id:'q'+Date.now()+i, file:f, status:'pending'}));
renderQueue();
if (!PROCESSING) processQueue();
}
function renderQueue(){
const el = document.getElementById('queue');
const pending = QUEUE.filter(q=>q.status==='pending').length;
const scanning = QUEUE.filter(q=>q.status==='scanning').length;
const done = QUEUE.filter(q=>q.status==='done').length;
const err = QUEUE.filter(q=>q.status==='err').length;
const total = QUEUE.length;
if (total === 0){ el.classList.remove('active'); document.getElementById('prog').classList.remove('active'); return; }
el.classList.add('active');
document.getElementById('prog').classList.add('active');
document.getElementById('prog-txt').textContent = `${done+err}/${total} traités · ${pending} en attente · ${scanning} en cours`;
const rate = done>0 ? `≈${Math.round((done/(total))*100)}% done, ${err} err` : '—';
document.getElementById('prog-rate').textContent = rate;
document.getElementById('prog-fill').style.width = `${((done+err)/total)*100}%`;
el.innerHTML = QUEUE.slice(-12).reverse().map(q => {
const badge = {'pending':'⏳ Attente','scanning':'🔄 Scan…','done':'✅ OK','err':'❌'}[q.status];
const hint = q.status==='done' ? `${q.result?.counts?.project_names||0} OSS · ${q.result?.scan_ms||0}ms` : (q.status==='err' ? q.err : '');
return `<div class="queue-item ${q.status}"><div class="n">${escape(q.file.name)} <span class="s">${Math.round(q.file.size/1024)}KB</span></div><span class="badge-q">${badge}</span><span class="s">${escape(hint)}</span></div>`;
}).join('');
}
async function processQueue(){
PROCESSING = true;
const CONCURRENT = 2;
while (true){
const next = QUEUE.filter(q => q.status==='pending');
if (!next.length) break;
const batch = next.slice(0, CONCURRENT);
batch.forEach(q => q.status='scanning');
renderQueue();
await Promise.all(batch.map(q => uploadOne(q)));
renderQueue();
}
PROCESSING = false;
await loadAll();
const done = QUEUE.filter(q=>q.status==='done').length;
const err = QUEUE.filter(q=>q.status==='err').length;
showToast(`✅ Batch fini · ${done} scannées / ${err} erreurs`);
}
async function uploadOne(q){
const fd = new FormData();
fd.append('image', q.file);
fd.append('caption', 'batch upload web '+new Date().toISOString());
try {
const r = await fetch(API+'?action=upload', { method:'POST', body:fd });
const d = await r.json();
if (d.ok){ q.status='done'; q.result=d.scan; }
else { q.status='err'; q.err=d.error||'fail'; }
} catch(e){ q.status='err'; q.err=e.message; }
renderQueue();
}
function showToast(msg, isErr){
const t = document.createElement('div');
t.className = 'toast'+(isErr?' err':'');
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 4500);
}
loadAll();
setInterval(loadAll, 30000);
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN v1 19avr — append-only, doctrine #14 === -->
<script>
(function(){
if (window.__opusUniversalDrill) return; window.__opusUniversalDrill = true;
var d = document;
var m = d.createElement('div');
m.id = 'opus-udrill';
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:99995;padding:20px;cursor:pointer';
var inner = d.createElement('div');
inner.id = 'opus-udrill-in';
inner.style.cssText = 'max-width:900px;width:100%;max-height:90vh;overflow:auto;background:#0b0d15;border:1px solid rgba(99,102,241,0.35);border-radius:14px;padding:28px;cursor:default;box-shadow:0 20px 60px rgba(0,0,0,0.6);color:#e2e8f0;font:14px/1.55 Inter,system-ui,sans-serif';
inner.addEventListener('click', function(e){ e.stopPropagation(); });
m.appendChild(inner);
m.addEventListener('click', function(){ m.style.display='none'; });
d.addEventListener('keydown', function(e){ if(e.key==='Escape') m.style.display='none'; });
(d.body || d.documentElement).appendChild(m);
function openCard(card) {
var html = '<div style="display:flex;justify-content:flex-end;margin-bottom:14px"><button id="opus-udrill-close" style="padding:6px 14px;background:#171b2a;border:1px solid rgba(99,102,241,0.25);color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:12px">✕ Fermer (Esc)</button></div>';
html += '<div style="transform-origin:top left;font-size:1.05em">' + card.outerHTML + '</div>';
inner.innerHTML = html;
d.getElementById('opus-udrill-close').onclick = function(){ m.style.display='none'; };
m.style.display = 'flex';
}
function wire(root) {
var sels = '.card,[class*="card"],.kpi,[class*="kpi"],.stat,[class*="stat"],.tile,[class*="tile"],.metric,[class*="metric"],.widget,[class*="widget"]';
var cards = root.querySelectorAll(sels);
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
if (c.__opusWired) continue;
if (c.closest('button, a, input, select, textarea, #opus-udrill')) continue;
var r = c.getBoundingClientRect();
if (r.width < 60 || r.height < 40) continue;
c.__opusWired = true;
c.style.cursor = 'pointer';
c.setAttribute('role','button');
c.setAttribute('tabindex','0');
c.addEventListener('click', function(ev){
if (ev.target.closest('[data-pp-id]') && window.__opusDrillInit) return;
if (ev.target.closest('a,button,input,select')) return;
ev.preventDefault(); ev.stopPropagation();
openCard(this);
});
c.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();openCard(this);} });
}
}
var initRun = function(){ wire(d.body || d.documentElement); };
if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', initRun);
else initRun();
var mo = new MutationObserver(function(muts){
var newCard = false;
for (var i=0;i<muts.length;i++) if (muts[i].addedNodes.length) { newCard = true; break; }
if (newCard) initRun();
});
mo.observe(d.body || d.documentElement, {childList:true, subtree:true});
})();
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN END === -->
</body>
</html>