diff --git a/docker/schema_family.sql b/docker/schema_family.sql index 713d1a4..9db723a 100644 --- a/docker/schema_family.sql +++ b/docker/schema_family.sql @@ -413,3 +413,38 @@ CREATE TABLE IF NOT EXISTS pf_calendar_event_links ( UNIQUE KEY uq_calendar_event (calendar_event_id), UNIQUE KEY uq_external_link (external_uid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ─── PrintVault ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS pf_pv_models ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + category VARCHAR(100) DEFAULT 'Non classé', + tags VARCHAR(500) DEFAULT '', + file_type VARCHAR(20) NOT NULL, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_size INT DEFAULT 0, + dim_x DECIMAL(10,3) DEFAULT 0, + dim_y DECIMAL(10,3) DEFAULT 0, + dim_z DECIMAL(10,3) DEFAULT 0, + volume DECIMAL(10,3) DEFAULT 0, + gcode_time VARCHAR(50) DEFAULT '', + gcode_filament VARCHAR(50) DEFAULT '', + gcode_nozzle VARCHAR(20) DEFAULT '', + gcode_bed VARCHAR(20) DEFAULT '', + thumb VARCHAR(255) DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS pf_pv_categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + color VARCHAR(20) DEFAULT '#8b5cf6' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO pf_pv_categories (name, color) VALUES + ('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'), + ('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'), + ('Architecture','#8b5cf6'),('Art','#06b6d4'); diff --git a/modules/printvault/api.php b/modules/printvault/api.php index ce97211..f861f04 100644 --- a/modules/printvault/api.php +++ b/modules/printvault/api.php @@ -2,110 +2,288 @@ ob_start(); require_once dirname(__DIR__, 2) . '/includes/auth.php'; require_login(); - header('Content-Type: application/json'); -if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; } - set_exception_handler(function(\Throwable $e) { if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); } - echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit; + echo json_encode(['ok'=>false,'error'=>$e->getMessage()]); exit; }); +require_once dirname(__DIR__, 2) . '/includes/db.php'; -define('PV_BASE', 'http://192.168.1.29:9500'); +define('PV_MODEL_DIR', '/uploads/printvault/models/'); +define('PV_THUMB_DIR', '/uploads/printvault/thumbs/'); -function pvOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; } -function pvErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; } +function pvOk($d) { echo json_encode(['ok'=>true,'data'=>$d], JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE); exit; } +function pvErr($m,$c=400){ http_response_code($c); echo json_encode(['ok'=>false,'error'=>$m]); exit; } +function pvBody() { return json_decode(file_get_contents('php://input'), true) ?? []; } -function pvProxy(string $url, string $method = 'GET', ?string $body = null, string $contentType = 'application/json'): array { - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_CUSTOMREQUEST => $method, - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: ' . $contentType]); +// ── STL parser ───────────────────────────────────────────────────────────────── +function parseSTL(string $path): array { + $data = file_get_contents($path, false, null, 0, 204); + if ($data === false || strlen($data) < 6) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $isAscii = strtolower(substr(ltrim($data), 0, 5)) === 'solid'; + + $size = filesize($path); + if ($size > 30 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; // skip >30MB + + $data = file_get_contents($path); + if ($isAscii) return parseSTLAscii($data); + return parseSTLBinary($data); +} + +function parseSTLBinary(string $data): array { + if (strlen($data) < 84) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $n = unpack('V', substr($data, 80, 4))[1]; + if (strlen($data) < 84 + $n * 50 || $n === 0) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + + $mins = [PHP_FLOAT_MAX, PHP_FLOAT_MAX, PHP_FLOAT_MAX]; + $maxs = [-PHP_FLOAT_MAX, -PHP_FLOAT_MAX, -PHP_FLOAT_MAX]; + $vol = 0.0; + + for ($i = 0; $i < $n; $i++) { + $off = 84 + $i * 50; + $v1 = array_values(unpack('fff', substr($data, $off+12, 12))); + $v2 = array_values(unpack('fff', substr($data, $off+24, 12))); + $v3 = array_values(unpack('fff', substr($data, $off+36, 12))); + for ($j = 0; $j < 3; $j++) { + $vals = [$v1[$j], $v2[$j], $v3[$j]]; + $mins[$j] = min($mins[$j], ...$vals); + $maxs[$j] = max($maxs[$j], ...$vals); + } + $vol += ($v1[0]*($v2[1]*$v3[2]-$v2[2]*$v3[1]) + -$v1[1]*($v2[0]*$v3[2]-$v2[2]*$v3[0]) + +$v1[2]*($v2[0]*$v3[1]-$v2[1]*$v3[0])) / 6.0; } - $resp = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - return ['body' => $resp, 'code' => $code]; + return [ + 'dim_x' => round($maxs[0]-$mins[0], 2), + 'dim_y' => round($maxs[1]-$mins[1], 2), + 'dim_z' => round($maxs[2]-$mins[2], 2), + 'volume'=> round(abs($vol)/1000.0, 3), + ]; +} + +function parseSTLAscii(string $data): array { + preg_match_all('/vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)/', $data, $m); + if (empty($m[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $xs = array_map('floatval', $m[1]); + $ys = array_map('floatval', $m[2]); + $zs = array_map('floatval', $m[3]); + return [ + 'dim_x' => round(max($xs)-min($xs), 2), + 'dim_y' => round(max($ys)-min($ys), 2), + 'dim_z' => round(max($zs)-min($zs), 2), + 'volume'=> 0, + ]; +} + +// ── 3MF parser ───────────────────────────────────────────────────────────────── +function parse3MF(string $path): array { + if (!class_exists('ZipArchive')) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + if (filesize($path) > 50 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $z = new ZipArchive(); + if ($z->open($path) !== true) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $xml = null; + for ($i = 0; $i < $z->numFiles; $i++) { + if (str_ends_with($z->getNameIndex($i), '.model')) { + $xml = $z->getFromIndex($i); break; + } + } + $z->close(); + if (!$xml) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + + libxml_use_internal_errors(true); + $dom = simplexml_load_string($xml); + if (!$dom) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + + preg_match_all('/x="([-\d.eE+]+)"/', $xml, $mx); + preg_match_all('/y="([-\d.eE+]+)"/', $xml, $my); + preg_match_all('/z="([-\d.eE+]+)"/', $xml, $mz); + if (empty($mx[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $xs = array_map('floatval', $mx[1]); $ys = array_map('floatval', $my[1]); $zs = array_map('floatval', $mz[1]); + return [ + 'dim_x' => round(max($xs)-min($xs), 2), + 'dim_y' => round(max($ys)-min($ys), 2), + 'dim_z' => round(max($zs)-min($zs), 2), + 'volume'=> 0, + ]; +} + +// ── GCode parser ─────────────────────────────────────────────────────────────── +function parseGCode(string $path): array { + $time = $filament = $nozzle = $bed = ''; + $handle = fopen($path, 'r'); + $lines = 0; + while (!feof($handle) && $lines < 300) { + $line = fgets($handle); $lines++; + if (preg_match('/;TIME:(\d+)/', $line, $m)) { + $s = (int)$m[1]; $time = ($s>=3600 ? floor($s/3600).'h':'').sprintf('%02d', ($s%3600)/60).'m'; + } elseif (preg_match('/;estimated printing time[^=]*=\s*(.+)/i', $line, $m)) { + $time = trim($m[1]); + } elseif (preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) { + $filament = $m[1].'m'; + } elseif (preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) { + $filament = round((float)$m[1]/1000, 2).'m'; + } elseif (preg_match('/M104\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; } + elseif (preg_match('/M109\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; } + elseif (preg_match('/M140\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; } + elseif (preg_match('/M190\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; } + } + // Scan tail for slicer comments + fseek($handle, -min(8192, filesize($path)), SEEK_END); + while (!feof($handle)) { + $line = fgets($handle); + if (!$time && preg_match('/;TIME:(\d+)/', $line, $m)) { $s=(int)$m[1]; $time=($s>=3600?floor($s/3600).'h':'').sprintf('%02d',($s%3600)/60).'m'; } + if (!$filament && preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) $filament = $m[1].'m'; + if (!$filament && preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) $filament = round((float)$m[1]/1000,2).'m'; + } + fclose($handle); + return compact('time', 'filament', 'nozzle', 'bed'); } $method = $_SERVER['REQUEST_METHOD']; $action = $_GET['action'] ?? ''; -// ── MODELS ──────────────────────────────────────────────────────────────────── -if ($action === 'models') { +// ── CATEGORIES ───────────────────────────────────────────────────────────────── +if ($action === 'categories') { if ($method === 'GET') { - $qs = http_build_query(array_intersect_key($_GET, array_flip(['category','file_type','search','limit','offset']))); - $r = pvProxy(PV_BASE . '/api/models?' . $qs); - if ($r['code'] !== 200) pvErr('PrintVault inaccessible', 502); - pvOk(json_decode($r['body'], true)); - } - if ($method === 'DELETE') { - $id = $_GET['id'] ?? null; if (!$id) pvErr('ID manquant'); - $r = pvProxy(PV_BASE . '/api/models/' . intval($id), 'DELETE'); - if ($r['code'] === 200) pvOk(['deleted' => true]); - pvErr('Erreur suppression', 500); + $rows = $pdo->query("SELECT c.*, COUNT(m.id) as count FROM pf_pv_categories c LEFT JOIN pf_pv_models m ON m.category=c.name GROUP BY c.id ORDER BY c.name")->fetchAll(); + pvOk($rows); } if ($method === 'POST') { - // Upload: forward multipart to PrintVault - if (empty($_FILES['file'])) pvErr('Fichier manquant'); - $f = $_FILES['file']; - if ($f['error'] !== UPLOAD_ERR_OK) pvErr('Erreur upload: ' . $f['error']); - - $postFields = [ - 'file' => new CURLFile($f['tmp_name'], $f['type'] ?: 'application/octet-stream', $f['name']), - 'name' => $_POST['name'] ?? pathinfo($f['name'], PATHINFO_FILENAME), - 'description' => $_POST['description'] ?? '', - 'category' => $_POST['category'] ?? 'Non classé', - 'tags' => $_POST['tags'] ?? '', - ]; - - $ch = curl_init(PV_BASE . '/api/models'); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 60, - CURLOPT_POSTFIELDS => $postFields, - ]); - $resp = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($code === 200) pvOk(json_decode($resp, true)); - pvErr('Upload échoué (' . $code . '): ' . substr($resp, 0, 200), 500); + $d = pvBody(); + $name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis'); + $pdo->prepare("INSERT IGNORE INTO pf_pv_categories (name,color) VALUES (?,?)")->execute([$name, $d['color'] ?? '#8b5cf6']); + pvOk(['id' => (int)$pdo->lastInsertId()]); } } -// ── CATEGORIES ──────────────────────────────────────────────────────────────── -if ($action === 'categories') { - $r = pvProxy(PV_BASE . '/api/categories'); - if ($r['code'] !== 200) pvErr('PrintVault inaccessible', 502); - pvOk(json_decode($r['body'], true)); +// ── MODELS ───────────────────────────────────────────────────────────────────── +if ($action === 'models') { + if ($method === 'GET') { + $id = $_GET['id'] ?? null; + if ($id) { + $s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]); + $r = $s->fetch(); if (!$r) pvErr('Introuvable', 404); + pvOk($r); + } + $where = []; $params = []; + if ($cat = ($_GET['category'] ?? '')) { $where[] = 'category=?'; $params[] = $cat; } + if ($ft = ($_GET['file_type'] ?? '')) { + if ($ft === 'gcode') { $where[] = "file_type IN ('gcode','gco','g')"; } + else { $where[] = 'file_type=?'; $params[] = $ft; } + } + if ($q = ($_GET['search'] ?? '')) { + $like = '%'.$q.'%'; + $where[] = '(name LIKE ? OR description LIKE ? OR tags LIKE ?)'; + array_push($params, $like, $like, $like); + } + $sql = "SELECT * FROM pf_pv_models" . ($where ? ' WHERE '.implode(' AND ',$where) : '') . " ORDER BY created_at DESC"; + $s = $pdo->prepare($sql); $s->execute($params); + pvOk($s->fetchAll()); + } + + if ($method === 'POST') { + if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) pvErr('Fichier manquant'); + $f = $_FILES['file']; + $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION)); + $allowed = ['stl','3mf','gcode','gco','g']; + if (!in_array($ext, $allowed)) pvErr('Format non supporté'); + + $fname = uniqid('pv', true) . '.' . $ext; + $dest = PV_MODEL_DIR . $fname; + if (!move_uploaded_file($f['tmp_name'], $dest)) pvErr('Erreur déplacement fichier'); + + // Parse geometry / metadata + $geo = ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; + $gcode = ['time'=>'','filament'=>'','nozzle'=>'','bed'=>'']; + if ($ext === 'stl') $geo = parseSTL($dest); + elseif ($ext === '3mf') $geo = parse3MF($dest); + elseif (in_array($ext, ['gcode','gco','g'])) $gcode = parseGCode($dest); + + $name = trim($_POST['name'] ?? pathinfo($f['name'], PATHINFO_FILENAME)); + $pdo->prepare("INSERT INTO pf_pv_models (name,description,category,tags,file_type,filename,original_name,file_size,dim_x,dim_y,dim_z,volume,gcode_time,gcode_filament,gcode_nozzle,gcode_bed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)") + ->execute([ + $name, + $_POST['description'] ?? '', + $_POST['category'] ?? 'Non classé', + $_POST['tags'] ?? '', + $ext, + $fname, + $f['name'], + filesize($dest), + $geo['dim_x'], $geo['dim_y'], $geo['dim_z'], $geo['volume'], + $gcode['time'], $gcode['filament'], $gcode['nozzle'], $gcode['bed'], + ]); + $id = (int)$pdo->lastInsertId(); + $row = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); + $row->execute([$id]); pvOk($row->fetch()); + } + + if ($method === 'PUT') { + $id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant'); + $d = pvBody(); + $name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis'); + $pdo->prepare("UPDATE pf_pv_models SET name=?,description=?,category=?,tags=?,updated_at=NOW() WHERE id=?") + ->execute([$name, $d['description']??'', $d['category']??'Non classé', $d['tags']??'', $id]); + pvOk(['updated'=>true]); + } + + if ($method === 'DELETE') { + $id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant'); + $s = $pdo->prepare("SELECT filename, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]); + $row = $s->fetch(); if (!$row) pvErr('Introuvable', 404); + @unlink(PV_MODEL_DIR . $row['filename']); + if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']); + $pdo->prepare("DELETE FROM pf_pv_models WHERE id=?")->execute([$id]); + pvOk(['deleted'=>true]); + } } -// ── FILE DOWNLOAD (proxy) ───────────────────────────────────────────────────── +// ── FILE SERVE ───────────────────────────────────────────────────────────────── if ($action === 'file') { - $id = intval($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant'); - $info = pvProxy(PV_BASE . '/api/models/' . $id); - if ($info['code'] !== 200) pvErr('Modèle introuvable', 404); - $m = json_decode($info['body'], true); - - $ch = curl_init(PV_BASE . '/api/models/' . $id . '/file'); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]); - $data = curl_exec($ch); - $mime = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream'; - curl_close($ch); - + $id = (int)($_GET['id'] ?? 0); + $s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]); + $row = $s->fetch(); if (!$row) { http_response_code(404); exit; } + $path = PV_MODEL_DIR . $row['filename']; + if (!file_exists($path)) { http_response_code(404); exit; } + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mime = ['stl'=>'model/stl','3mf'=>'model/3mf','gcode'=>'text/plain','gco'=>'text/plain','g'=>'text/plain'][$ext] ?? 'application/octet-stream'; ob_end_clean(); header('Content-Type: ' . $mime); - header('Content-Disposition: attachment; filename="' . rawurlencode($m['original_name'] ?? 'model') . '"'); + header('Content-Disposition: attachment; filename="' . rawurlencode($row['original_name']) . '"'); + header('Content-Length: ' . filesize($path)); header('Cache-Control: no-cache'); - echo $data; - exit; + readfile($path); exit; +} + +// ── MODEL FILE FOR VIEWER (inline, no download) ──────────────────────────────── +if ($action === 'model_data') { + $id = (int)($_GET['id'] ?? 0); + $s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]); + $row = $s->fetch(); if (!$row) { http_response_code(404); exit; } + $path = PV_MODEL_DIR . $row['filename']; + if (!file_exists($path)) { http_response_code(404); exit; } + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $mime = ['stl'=>'model/stl','3mf'=>'model/3mf'][$ext] ?? 'application/octet-stream'; + ob_end_clean(); + header('Content-Type: ' . $mime); + header('Content-Disposition: inline; filename="' . rawurlencode($row['filename']) . '"'); + header('Cache-Control: private, max-age=3600'); + readfile($path); exit; +} + +// ── THUMBNAIL SAVE ────────────────────────────────────────────────────────────── +if ($action === 'thumb' && $method === 'POST') { + $id = (int)($_GET['id'] ?? 0); + $s = $pdo->prepare("SELECT id, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]); + $row = $s->fetch(); if (!$row) pvErr('Introuvable', 404); + + if (empty($_FILES['thumb']) || $_FILES['thumb']['error'] !== UPLOAD_ERR_OK) pvErr('Pas de fichier'); + if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']); + + $thumbName = 'thumb_' . uniqid('', true) . '.png'; + if (!move_uploaded_file($_FILES['thumb']['tmp_name'], PV_THUMB_DIR . $thumbName)) pvErr('Erreur sauvegarde'); + $pdo->prepare("UPDATE pf_pv_models SET thumb=? WHERE id=?")->execute([$thumbName, $id]); + pvOk(['thumb' => $thumbName]); } pvErr('Action inconnue', 404); diff --git a/modules/printvault/assets/printvault.js b/modules/printvault/assets/printvault.js index 9235c00..ab4530e 100644 --- a/modules/printvault/assets/printvault.js +++ b/modules/printvault/assets/printvault.js @@ -1,32 +1,25 @@ -// HouseHub — PrintVault module +// HouseHub — PrintVault module (local storage) const API = '/modules/printvault/api.php'; -const PV_BASE = 'https://printvault.nas.percolouco.com'; function escHtml(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML; } -function fmtSize(b) { if (!b) return '—'; if (b > 1048576) return (b / 1048576).toFixed(1) + ' Mo'; return Math.round(b / 1024) + ' Ko'; } -function fmtDate(s) { if (!s) return '—'; return new Date(s).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); } +function fmtSize(b) { if (!b) return '—'; return b > 1048576 ? (b/1048576).toFixed(1)+' Mo' : Math.round(b/1024)+' Ko'; } +function fmtDate(s) { if (!s) return '—'; return new Date(s).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); } -function toast(msg, type = 'success') { +function toast(msg, type='success') { const c = document.getElementById('pv-toasts'); const t = document.createElement('div'); - t.className = 'pv-toast' + (type === 'error' ? ' error' : ''); - t.textContent = msg; - c.appendChild(t); + t.className = 'pv-toast' + (type==='error' ? ' error' : ''); + t.textContent = msg; c.appendChild(t); setTimeout(() => t.remove(), 3500); } -async function api(action, method = 'GET', data = null, extra = '') { - const opts = { method, credentials: 'same-origin', headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }; - if (data && !(data instanceof FormData)) { - opts.headers['Content-Type'] = 'application/json'; - opts.body = JSON.stringify(data); - } else if (data instanceof FormData) { - opts.body = data; - } - const r = await fetch(API + '?action=' + action + extra, opts); +async function api(action, method='GET', data=null, extra='') { + const opts = {method, credentials:'same-origin', headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}}; + if (data && !(data instanceof FormData)) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(data); } + else if (data instanceof FormData) opts.body = data; + const r = await fetch(API+'?action='+action+extra, opts); const text = await r.text(); - let j; - try { j = JSON.parse(text); } catch (e) { console.error('Bad JSON:', text.slice(0, 300)); throw new Error('Erreur serveur'); } + let j; try { j = JSON.parse(text); } catch(e) { console.error('Bad JSON:', text.slice(0,300)); throw new Error('Erreur serveur'); } if (!j.ok) throw new Error(j.error || 'Erreur'); return j.data; } @@ -40,54 +33,39 @@ let categories = []; // ─── Init ───────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { - loadSidebar(); - loadModels(); + loadAll(); + document.getElementById('pv-search')?.addEventListener('input', e => { searchQuery = e.target.value.trim(); renderModels(); }); + document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener('click', e => { if (e.target===m) m.classList.remove('show'); })); - document.getElementById('pv-search')?.addEventListener('input', e => { - searchQuery = e.target.value.trim(); - renderModels(); - }); - - document.querySelectorAll('.pv-modal-backdrop').forEach(m => - m.addEventListener('click', e => { if (e.target === m) m.classList.remove('show'); }) - ); - - // Upload drop zone const dz = document.getElementById('pv-drop-zone'); if (dz) { dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); }); dz.addEventListener('dragleave', () => dz.classList.remove('over')); - dz.addEventListener('drop', e => { - e.preventDefault(); dz.classList.remove('over'); - const f = e.dataTransfer.files[0]; - if (f) setUploadFile(f); - }); + dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]); }); dz.addEventListener('click', () => document.getElementById('pv-file-input').click()); - document.getElementById('pv-file-input').addEventListener('change', e => { - if (e.target.files[0]) setUploadFile(e.target.files[0]); - }); + document.getElementById('pv-file-input').addEventListener('change', e => { if (e.target.files[0]) setUploadFile(e.target.files[0]); }); } }); -// ─── Sidebar ────────────────────────────────────────────────────────────────── -async function loadSidebar() { +async function loadAll() { try { - const data = await api('categories'); - categories = data.categories || []; - renderSidebar(); - } catch (e) { console.error(e); } + const [cats] = await Promise.all([api('categories')]); + categories = cats; + await loadModels(); + } catch(e) { toast(e.message, 'error'); } } +// ─── Sidebar ────────────────────────────────────────────────────────────────── function renderSidebar() { const el = document.getElementById('pv-sidebar-nav'); let html = `
Aucun modèle trouvé.
${allModels.length ? 'Aucun modèle dans ce filtre.' : 'Aucun modèle. Uploadez votre premier fichier !'}
Chargement…
-Chargement…