From 99e135ec74a620ffcdf36542b6dd7e5f68957739 Mon Sep 17 00:00:00 2001 From: perco Date: Fri, 15 May 2026 10:25:21 +0200 Subject: [PATCH] =?UTF-8?q?PrintVault:=20full=20native=20rewrite=20?= =?UTF-8?q?=E2=80=94=20local=20storage,=20MariaDB,=20Three.js=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All files stored in HouseHub (/uploads/printvault/), zero dependency on external service - PHP parsers: binary/ascii STL (dimensions + volume), 3MF (XML bounding box), GCode metadata - Three.js viewer (STL + 3MF) in printvault-viewer.php with importmap CDN · OrbitControls, metallic material, environment lighting, grid floor · Screenshot button saves thumbnail back to HouseHub - printvault.php: model grid + upload drag&drop + detail modal + edit modal - API: models CRUD, categories, file serve (inline for viewer), thumb save - pf_pv_models + pf_pv_categories tables added to schema Co-Authored-By: Claude Sonnet 4.6 --- docker/schema_family.sql | 35 +++ modules/printvault/api.php | 336 ++++++++++++++++++------ modules/printvault/assets/printvault.js | 223 ++++++++-------- printvault-viewer.php | 289 ++++++++++++++++++++ printvault.php | 69 +++-- 5 files changed, 732 insertions(+), 220 deletions(-) create mode 100644 printvault-viewer.php 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 = `
Catégories
-
+
🗂 Toutes ${allModels.length}
`; categories.forEach(c => { const count = allModels.filter(m => m.category === c.name).length; - html += `
+ html += `
${escHtml(c.name)} ${count} @@ -96,29 +74,20 @@ function renderSidebar() { el.innerHTML = html; } -function setCategory(cat) { - currentCategory = cat; - renderSidebar(); - renderModels(); -} - +function setCategory(cat) { currentCategory = cat; renderSidebar(); renderModels(); } function setType(type) { currentType = currentType === type ? null : type; - document.querySelectorAll('.pv-chip').forEach(c => { - c.classList.toggle('active', c.dataset.type === currentType); - }); + document.querySelectorAll('.pv-chip').forEach(c => c.classList.toggle('active', c.dataset.type === currentType)); renderModels(); } // ─── Models ─────────────────────────────────────────────────────────────────── async function loadModels() { try { - const data = await api('models'); - allModels = data.models || []; + allModels = await api('models'); renderSidebar(); renderModels(); - updateHeader(); - } catch (e) { toast(e.message, 'error'); } + } catch(e) { toast(e.message, 'error'); } } function filterModels() { @@ -131,9 +100,7 @@ function filterModels() { } if (searchQuery) { const q = searchQuery.toLowerCase(); - if (!m.name.toLowerCase().includes(q) && - !(m.description || '').toLowerCase().includes(q) && - !(m.tags || '').toLowerCase().includes(q)) return false; + if (!m.name.toLowerCase().includes(q) && !(m.description||'').toLowerCase().includes(q) && !(m.tags||'').toLowerCase().includes(q)) return false; } return true; }); @@ -142,28 +109,25 @@ function filterModels() { function renderModels() { const el = document.getElementById('pv-grid'); const filtered = filterModels(); - + document.getElementById('pv-subtitle').textContent = filtered.length + ' modèle' + (filtered.length > 1 ? 's' : ''); if (!filtered.length) { - el.innerHTML = `
🖨️

Aucun modèle trouvé.

`; - updateHeader(0); + el.innerHTML = `
🖨️

${allModels.length ? 'Aucun modèle dans ce filtre.' : 'Aucun modèle. Uploadez votre premier fichier !'}

`; return; } - - el.innerHTML = filtered.map(m => modelCardHtml(m)).join(''); - updateHeader(filtered.length); + el.innerHTML = filtered.map(m => cardHtml(m)).join(''); } -function modelCardHtml(m) { +const typeIcon = {stl:'🟣','3mf':'🔵',gcode:'🟢',g:'🟢',gco:'🟢'}; + +function cardHtml(m) { const ext = m.file_type.toLowerCase(); - const typeIcon = { stl: '🟣', '3mf': '🔵', gcode: '🟢', g: '🟢', gco: '🟢' }[ext] || '📄'; - const thumbSrc = m.thumb_filename ? `${PV_BASE}/thumbs/${m.thumb_filename}` : null; - const thumbHtml = thumbSrc - ? `` - : typeIcon; - - const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm` : ''; - const gcodeInfo = m.gcode_print_time ? m.gcode_print_time : ''; - + const icon = typeIcon[ext] || '📄'; + const thumbUrl = m.thumb ? `/uploads/printvault/thumbs/${encodeURIComponent(m.thumb)}` : null; + const thumbHtml = thumbUrl + ? `` + : icon; + const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)}mm` : ''; + const gcInfo = m.gcode_time || ''; return `
${thumbHtml}
@@ -171,69 +135,100 @@ function modelCardHtml(m) {
${escHtml(m.file_type.toUpperCase())} ${dims ? `${escHtml(dims)}` : ''} - ${gcodeInfo ? `⏱ ${escHtml(gcodeInfo)}` : ''} + ${gcInfo ? `⏱ ${escHtml(gcInfo)}` : ''} ${escHtml(fmtSize(m.file_size))}
`; } -function updateHeader(count) { - const el = document.getElementById('pv-subtitle'); - if (!el) return; - const total = count !== undefined ? count : filterModels().length; - el.textContent = total + ' modèle' + (total > 1 ? 's' : ''); -} +// ─── Detail panel ───────────────────────────────────────────────────────────── +let currentDetailId = null; -// ─── Detail modal ───────────────────────────────────────────────────────────── function openDetail(id) { const m = allModels.find(x => x.id === id); if (!m) return; + currentDetailId = id; const ext = m.file_type.toLowerCase(); - const thumbSrc = m.thumb_filename ? `${PV_BASE}/thumbs/${m.thumb_filename}` : null; - const typeIcon = { stl: '🟣', '3mf': '🔵', gcode: '🟢', g: '🟢', gco: '🟢' }[ext] || '📄'; + const canView = ['stl','3mf'].includes(ext); const isGcode = ['gcode','gco','g'].includes(ext); + const icon = typeIcon[ext] || '📄'; + const thumbUrl = m.thumb ? `/uploads/printvault/thumbs/${encodeURIComponent(m.thumb)}` : null; document.getElementById('pv-detail-body').innerHTML = `
- ${thumbSrc ? `` : typeIcon} + ${thumbUrl ? `` : icon}
${escHtml(m.name)}
${m.description ? `
${escHtml(m.description)}
` : ''} - ${m.tags ? `
${m.tags.split(',').filter(Boolean).map(t => `${escHtml(t.trim())}`).join('')}
` : ''} + ${m.tags ? `
${m.tags.split(',').filter(Boolean).map(t=>`${escHtml(t.trim())}`).join('')}
` : ''}
Type${escHtml(m.file_type.toUpperCase())}
Catégorie${escHtml(m.category)}
Taille${escHtml(fmtSize(m.file_size))}
Ajouté le${escHtml(fmtDate(m.created_at))}
- ${m.dim_x > 0 ? `
Dimensions${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm
` : ''} - ${m.volume > 0 ? `
Volume${m.volume.toFixed(2)} cm³
` : ''} - ${isGcode && m.gcode_print_time ? `
Temps impression${escHtml(m.gcode_print_time)}
` : ''} + ${m.dim_x > 0 ? `
Dimensions${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm${m.volume > 0 ? ' · '+m.volume+' cm³' : ''}
` : ''} + ${isGcode && m.gcode_time ? `
Temps${escHtml(m.gcode_time)}
` : ''} ${isGcode && m.gcode_filament ? `
Filament${escHtml(m.gcode_filament)}
` : ''} - ${isGcode && m.gcode_nozzle_temp ? `
Buse${escHtml(m.gcode_nozzle_temp)}
` : ''} - ${isGcode && m.gcode_bed_temp ? `
Plateau${escHtml(m.gcode_bed_temp)}
` : ''} + ${isGcode && m.gcode_nozzle ? `
Buse${escHtml(m.gcode_nozzle)}
` : ''} + ${isGcode && m.gcode_bed ? `
Plateau${escHtml(m.gcode_bed)}
` : ''}
`; - document.getElementById('pv-detail-3d-btn').onclick = () => window.open(`${PV_BASE}/model/${m.id}`, '_blank'); - document.getElementById('pv-detail-dl-btn').href = `${API}?action=file&id=${m.id}`; - document.getElementById('pv-detail-del-btn').onclick = () => deleteModel(m.id, m.name); + const btn3d = document.getElementById('pv-detail-3d-btn'); + if (canView) { + btn3d.style.display = ''; + btn3d.onclick = () => window.open(`/printvault-viewer.php?id=${id}`, '_blank', 'width=1200,height=800'); + } else { + btn3d.style.display = 'none'; + } + document.getElementById('pv-detail-dl-btn').href = `${API}?action=file&id=${id}`; + document.getElementById('pv-detail-edit-btn').onclick = () => openEdit(m); + document.getElementById('pv-detail-del-btn').onclick = () => deleteModel(id, m.name); document.getElementById('pv-detail-modal').classList.add('show'); } async function deleteModel(id, name) { if (!confirm(`Supprimer "${name}" ?`)) return; try { - await api('models', 'DELETE', null, '&id=' + id); + await api('models', 'DELETE', null, '&id='+id); document.getElementById('pv-detail-modal').classList.remove('show'); toast('Modèle supprimé'); allModels = allModels.filter(m => m.id !== id); - renderSidebar(); - renderModels(); - } catch (e) { toast(e.message, 'error'); } + renderSidebar(); renderModels(); + } catch(e) { toast(e.message, 'error'); } } -// ─── Upload modal ───────────────────────────────────────────────────────────── +// ─── Edit ───────────────────────────────────────────────────────────────────── +function openEdit(m) { + document.getElementById('pv-detail-modal').classList.remove('show'); + document.getElementById('pv-edit-id').value = m.id; + document.getElementById('pv-edit-name').value = m.name; + document.getElementById('pv-edit-desc').value = m.description || ''; + document.getElementById('pv-edit-tags').value = m.tags || ''; + const sel = document.getElementById('pv-edit-cat'); + sel.innerHTML = categories.map(c => ``).join(''); + document.getElementById('pv-edit-modal').classList.add('show'); +} + +async function saveEdit() { + const id = document.getElementById('pv-edit-id').value; + const name = document.getElementById('pv-edit-name').value.trim(); + if (!name) { toast('Nom requis', 'error'); return; } + try { + await api('models', 'PUT', { + name, + description: document.getElementById('pv-edit-desc').value, + category: document.getElementById('pv-edit-cat').value, + tags: document.getElementById('pv-edit-tags').value, + }, '&id='+id); + toast('Mis à jour ✓'); + document.getElementById('pv-edit-modal').classList.remove('show'); + await loadModels(); + } catch(e) { toast(e.message, 'error'); } +} + +// ─── Upload ─────────────────────────────────────────────────────────────────── let uploadFile = null; function openUpload() { @@ -243,21 +238,19 @@ function openUpload() { document.getElementById('pv-upload-name').value = ''; document.getElementById('pv-upload-desc').value = ''; document.getElementById('pv-upload-tags').value = ''; - const catSel = document.getElementById('pv-upload-cat'); - catSel.innerHTML = categories.map(c => ``).join(''); + const sel = document.getElementById('pv-upload-cat'); + sel.innerHTML = categories.map(c => ``).join(''); document.getElementById('pv-upload-progress').style.display = 'none'; document.getElementById('pv-upload-modal').classList.add('show'); } function setUploadFile(f) { - const allowed = ['stl', '3mf', 'gcode', 'gco', 'g']; const ext = f.name.split('.').pop().toLowerCase(); - if (!allowed.includes(ext)) { toast('Format non supporté (STL, 3MF, GCode)', 'error'); return; } + if (!['stl','3mf','gcode','gco','g'].includes(ext)) { toast('Format non supporté', 'error'); return; } uploadFile = f; document.getElementById('pv-upload-filename').textContent = f.name; - if (!document.getElementById('pv-upload-name').value) { - document.getElementById('pv-upload-name').value = f.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' '); - } + if (!document.getElementById('pv-upload-name').value) + document.getElementById('pv-upload-name').value = f.name.replace(/\.[^.]+$/,'').replace(/[_-]/g,' '); } async function saveUpload() { @@ -274,8 +267,7 @@ async function saveUpload() { const prog = document.getElementById('pv-upload-progress'); const bar = document.getElementById('pv-upload-bar'); - prog.style.display = ''; - bar.style.width = '30%'; + prog.style.display = ''; bar.style.width = '20%'; try { await api('models', 'POST', fd); @@ -285,8 +277,5 @@ async function saveUpload() { toast('Modèle ajouté ✓'); loadModels(); }, 300); - } catch (e) { - prog.style.display = 'none'; - toast(e.message, 'error'); - } + } catch(e) { prog.style.display = 'none'; toast(e.message, 'error'); } } diff --git a/printvault-viewer.php b/printvault-viewer.php new file mode 100644 index 0000000..476626e --- /dev/null +++ b/printvault-viewer.php @@ -0,0 +1,289 @@ +prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]); +$model = $s->fetch(); +if (!$model) { http_response_code(404); exit('Modèle introuvable'); } + +$ext = strtolower($model['file_type']); +$canView = in_array($ext, ['stl','3mf']); +?> + + + + + <?= htmlspecialchars($model['name']) ?> — PrintVault + + + + + +
+
+
+
+ + 0): ?> + · ×× mm + + · Ko +
+
+
+ + + + + + ⬇ Télécharger +
+
+ + + +
+
+
Chargement du modèle…
+
+
+ +
+
Clic+glisser : rotation · Scroll : zoom · Clic droit : déplacer
+ + + + + + +
+
+

🟢

+ + +
+ + +
+ +
+
+ + + + diff --git a/printvault.php b/printvault.php index ef8338c..c10b62e 100644 --- a/printvault.php +++ b/printvault.php @@ -22,15 +22,11 @@ require __DIR__ . '/header.php';
- -
- STL - 3MF - GCode + STL + 3MF + GCode
- -
@@ -41,17 +37,10 @@ require __DIR__ . '/header.php';

🖨️ PrintVault

-
- Ouvrir PrintVault ↗ - -
+ -
-
-
🖨️
-

Chargement…

-
+
🖨️

Chargement…

@@ -66,8 +55,44 @@ require __DIR__ . '/header.php';
+ + + + +
+
+
+

Modifier le modèle

+ +
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
@@ -80,25 +105,21 @@ require __DIR__ . '/header.php';
-
📁
-
Glissez un fichier ici ou cliquez pour choisir
-
STL · 3MF · GCode
+
Glissez un fichier ici ou cliquez
+
STL · 3MF · GCode
-
-
-
@@ -109,9 +130,9 @@ require __DIR__ . '/header.php';
-