Add PrintVault module: 3D print file manager integrated in HouseHub
Deploy HouseHub / deploy (push) Successful in 1s

- printvault.php: SPA with model grid, sidebar (categories + type filters), search
- modules/printvault/api.php: PHP proxy to PrintVault API (auth + CORS)
- modules/printvault/assets/printvault.js: model grid, upload modal, detail modal
- modules/printvault/assets/printvault.css: full light/dark theme
- Upload: drag&drop + form (name, category, tags)
- Detail modal: metadata, dimensions, GCode info, download, delete, "Voir en 3D" → PrintVault
- Integrated in header nav, home page, settings; translations fr/en/ca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-05-15 10:14:03 +02:00
co-authored by Claude Sonnet 4.6
parent 9aa61c85df
commit 7272772c9f
10 changed files with 743 additions and 1 deletions
+2
View File
@@ -57,6 +57,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?> <?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('groceries', $mods)): ?><a href="/groceries.php" class="pf-nav-link <?= $activePage === 'groceries' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_groceries') ?></a><?php endif; ?> <?php if (in_array('groceries', $mods)): ?><a href="/groceries.php" class="pf-nav-link <?= $activePage === 'groceries' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_groceries') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?> <?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
</nav> </nav>
<?php endif; ?> <?php endif; ?>
@@ -106,6 +107,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?> <?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
<?php if (in_array('groceries',$mods)): ?><a href="/groceries.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_groceries') ?></a><?php endif; ?> <?php if (in_array('groceries',$mods)): ?><a href="/groceries.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_groceries') ?></a><?php endif; ?>
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?> <?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a> <a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
<?php if (!empty($_SESSION['user']['is_admin'])): ?> <?php if (!empty($_SESSION['user']['is_admin'])): ?>
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a> <a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
+4
View File
@@ -148,6 +148,10 @@ return [
'cta_check' => 'Marcar', 'cta_check' => 'Marcar',
'cta_cart' => 'Anar a comprar', 'cta_cart' => 'Anar a comprar',
'cta_sync' => 'Sincronitzar', 'cta_sync' => 'Sincronitzar',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimir',
// ========================================== // ==========================================
// FAMILY CALENDAR // FAMILY CALENDAR
+4
View File
@@ -149,6 +149,10 @@ return [
'cta_check' => 'Check off', 'cta_check' => 'Check off',
'cta_cart' => 'Go shopping', 'cta_cart' => 'Go shopping',
'cta_sync' => 'Sync', 'cta_sync' => 'Sync',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Print',
// ========================================== // ==========================================
// FAMILY CALENDAR // FAMILY CALENDAR
+4
View File
@@ -151,6 +151,10 @@ return [
'cta_check' => 'Cocher', 'cta_check' => 'Cocher',
'cta_cart' => 'Faire les courses', 'cta_cart' => 'Faire les courses',
'cta_sync' => 'Synchroniser', 'cta_sync' => 'Synchroniser',
'mod_printvault_name' => 'PrintVault',
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
'menu_printvault' => 'PrintVault',
'cta_print' => 'Imprimer',
// ========================================== // ==========================================
// FAMILY CALENDAR // FAMILY CALENDAR
+9
View File
@@ -127,6 +127,15 @@ if ($_has_custom_bg): ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if (in_array('printvault', $mods)): ?>
<a href="/printvault.php" class="pf-module-card">
<div class="pf-card-icon">🖨️</div>
<h3 class="pf-card-title"><?= tr('mod_printvault_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_printvault_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_print') ?></span>
</a>
<?php endif; ?>
</div> </div>
</section> </section>
</div> </div>
+111
View File
@@ -0,0 +1,111 @@
<?php
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;
});
define('PV_BASE', 'http://192.168.1.29:9500');
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 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]);
}
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['body' => $resp, 'code' => $code];
}
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
// ── MODELS ────────────────────────────────────────────────────────────────────
if ($action === 'models') {
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);
}
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);
}
}
// ── CATEGORIES ────────────────────────────────────────────────────────────────
if ($action === 'categories') {
$r = pvProxy(PV_BASE . '/api/categories');
if ($r['code'] !== 200) pvErr('PrintVault inaccessible', 502);
pvOk(json_decode($r['body'], true));
}
// ── FILE DOWNLOAD (proxy) ─────────────────────────────────────────────────────
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);
ob_end_clean();
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . rawurlencode($m['original_name'] ?? 'model') . '"');
header('Cache-Control: no-cache');
echo $data;
exit;
}
pvErr('Action inconnue', 404);
+190
View File
@@ -0,0 +1,190 @@
/* PrintVault module */
.pv-layout {
display: grid;
grid-template-columns: 220px 1fr;
height: calc(100vh - 64px);
overflow: hidden;
}
.pv-sidebar {
border-right: 1px solid var(--border-light);
background: var(--bg-panel);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.pv-sidebar-top {
padding: .75rem;
border-bottom: 1px solid var(--border-light);
position: sticky; top: 0; background: var(--bg-panel); z-index: 1;
display: flex; flex-direction: column; gap: .5rem;
}
.pv-search {
display: flex; align-items: center; gap: .4rem;
background: var(--bg-page); border: 1px solid var(--border-light);
border-radius: 8px; padding: .35rem .6rem;
}
.pv-search input { border: none; outline: none; background: transparent; font-size: .85rem; color: var(--text-main); flex: 1; }
.pv-search input::placeholder { color: var(--text-muted); }
.pv-main { overflow-y: auto; background: var(--bg-page); }
/* Sidebar nav */
.pv-nav-section { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: .75rem .75rem .25rem; }
.pv-nav-item {
display: flex; align-items: center; gap: .5rem;
padding: .45rem .75rem; cursor: pointer;
font-size: .85rem; color: var(--text-muted);
border-radius: 8px; margin: 1px .5rem;
transition: background .12s, color .12s;
}
.pv-nav-item:hover { background: var(--bg-page); color: var(--text-main); }
.pv-nav-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
.pv-nav-badge { margin-left: auto; font-size: .7rem; font-weight: 700; background: var(--bg-page); color: var(--text-muted); padding: 1px 6px; border-radius: 999px; }
.pv-nav-item.active .pv-nav-badge { background: #dbeafe; color: var(--primary); }
.pv-cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
/* File type filter chips */
.pv-type-chips { display: flex; gap: .3rem; flex-wrap: wrap; padding: .25rem .75rem .5rem; }
.pv-chip {
font-size: .72rem; font-weight: 600; padding: .2rem .5rem;
border-radius: 999px; cursor: pointer; border: 1.5px solid var(--border-light);
color: var(--text-muted); transition: all .12s;
}
.pv-chip:hover { border-color: var(--primary); color: var(--primary); }
.pv-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.pv-chip.stl.active { background: #8b5cf6; border-color: #8b5cf6; }
.pv-chip.3mf.active { background: #3b82f6; border-color: #3b82f6; }
.pv-chip.gcode.active { background: #10b981; border-color: #10b981; }
/* Main header */
.pv-main-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.5rem; background: var(--bg-panel);
border-bottom: 1px solid var(--border-light); gap: .75rem; flex-wrap: wrap;
}
.pv-main-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
.pv-subtitle { font-size: .8rem; color: var(--text-muted); }
/* Models grid */
.pv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1.25rem 1.5rem 2rem;
}
.pv-card {
background: var(--bg-panel); border: 1px solid var(--border-light);
border-radius: 12px; overflow: hidden; cursor: pointer;
transition: border-color .15s, box-shadow .12s;
display: flex; flex-direction: column;
}
.pv-card:hover { border-color: #cbd5e1; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.pv-card-thumb {
aspect-ratio: 4/3; background: #f1f5f9;
display: flex; align-items: center; justify-content: center;
font-size: 3rem; overflow: hidden;
}
.pv-card-thumb img { width: 100%; height: 100%; object-fit: cover; }
.pv-card-body { padding: .75rem; flex: 1; display: flex; flex-direction: column; gap: .25rem; }
.pv-card-name { font-size: .875rem; font-weight: 600; color: var(--text-main); line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pv-card-meta { font-size: .72rem; color: var(--text-muted); display: flex; gap: .4rem; flex-wrap: wrap; margin-top: auto; padding-top: .25rem; }
.pv-type-badge { font-size: .68rem; font-weight: 700; padding: .1rem .4rem; border-radius: 4px; text-transform: uppercase; }
.pv-type-stl { background: #ede9fe; color: #7c3aed; }
.pv-type-3mf { background: #dbeafe; color: #1d4ed8; }
.pv-type-gcode { background: #dcfce7; color: #15803d; }
.pv-type-g { background: #dcfce7; color: #15803d; }
.pv-type-gco { background: #dcfce7; color: #15803d; }
/* Empty state */
.pv-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
.pv-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
/* Modal */
.pv-modal-backdrop {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(15,23,42,.5); backdrop-filter: blur(4px);
align-items: center; justify-content: center; padding: 1rem;
}
.pv-modal-backdrop.show { display: flex; }
.pv-modal {
background: var(--bg-panel); border: 1px solid var(--border-light);
border-radius: 14px; width: 100%; max-width: 520px;
max-height: 92vh; display: flex; flex-direction: column;
box-shadow: 0 20px 40px rgba(0,0,0,.15);
animation: pvModalIn .18s ease;
}
@keyframes pvModalIn { from{opacity:0;transform:scale(.96) translateY(-8px)} to{opacity:1;transform:none} }
.pv-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); }
.pv-modal-header h3 { font-size: 1rem; font-weight: 700; margin: 0; }
.pv-modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.2rem; border-radius: 6px; padding: .2rem .4rem; }
.pv-modal-close:hover { background: var(--bg-page); }
.pv-modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: .9rem; }
.pv-modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); display: flex; justify-content: flex-end; gap: .5rem; }
/* Upload zone */
.pv-drop-zone {
border: 2px dashed var(--border-light); border-radius: 10px;
padding: 2rem; text-align: center; cursor: pointer;
transition: border-color .15s, background .15s; color: var(--text-muted);
}
.pv-drop-zone:hover, .pv-drop-zone.over { border-color: var(--primary); background: #eff6ff; color: var(--primary); }
.pv-drop-zone .icon { font-size: 2rem; margin-bottom: .5rem; }
.pv-file-chosen { font-size: .82rem; color: var(--primary); font-weight: 600; margin-top: .4rem; }
/* Form */
.form-group { display: flex; flex-direction: column; gap: .35rem; }
.form-label { font-size: .8rem; font-weight: 600; color: var(--text-muted); }
.form-control { background: #fff; border: 1px solid var(--border-light); color: var(--text-main); border-radius: 8px; padding: .5rem .75rem; font-size: .875rem; width: 100%; outline: none; transition: border-color .15s; font-family: inherit; }
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .4rem .85rem; border-radius: 8px; font-size: .875rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all .15s; white-space: nowrap; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
.btn-secondary:hover { background: var(--bg-page); }
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
.btn-danger:hover { background: #fef2f2; }
.btn-sm { padding: .25rem .55rem; font-size: .78rem; }
.btn-ghost { background: transparent; border: none; color: var(--text-muted); padding: .2rem .4rem; }
.btn-ghost:hover { color: var(--text-main); background: var(--bg-page); border-radius: 6px; }
.btn-icon { padding: .3rem; min-width: 30px; justify-content: center; }
/* Detail panel */
.pv-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem .75rem; }
.pv-detail-item { display: flex; flex-direction: column; gap: .1rem; }
.pv-detail-label { font-size: .72rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
.pv-detail-value { font-size: .875rem; color: var(--text-main); }
.pv-detail-thumb { width: 100%; aspect-ratio: 4/3; background: #f1f5f9; border-radius: 10px; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 4rem; margin-bottom: .75rem; }
.pv-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
/* Toasts */
.pv-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
.pv-toast { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 10px; padding: .7rem 1rem; font-size: .875rem; min-width: 200px; color: var(--text-main); box-shadow: 0 4px 16px rgba(0,0,0,.12); border-left: 4px solid var(--primary); animation: pvToastIn .2s ease; }
.pv-toast.error { border-left-color: var(--danger); }
@keyframes pvToastIn { from{opacity:0;transform:translateX(16px)} to{opacity:1;transform:none} }
/* Upload progress */
.pv-progress { height: 4px; background: var(--border-light); border-radius: 999px; overflow: hidden; margin-top: .4rem; }
.pv-progress-bar { height: 100%; background: var(--primary); border-radius: 999px; transition: width .3s; }
/* Responsive */
@media (max-width: 768px) {
.pv-layout { grid-template-columns: 1fr; height: auto; }
.pv-sidebar { display: none; }
.pv-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); padding: .75rem; }
.pv-main-header { padding: .75rem 1rem; }
}
/* Dark mode */
[data-theme="dark"] .pv-sidebar { background: var(--bg-panel); }
[data-theme="dark"] .pv-card { background: var(--bg-panel); border-color: var(--border-light); }
[data-theme="dark"] .pv-card-thumb { background: #1c2128; }
[data-theme="dark"] .pv-modal { background: var(--bg-panel); }
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
[data-theme="dark"] .pv-drop-zone { border-color: var(--border-light); }
[data-theme="dark"] .pv-drop-zone:hover { background: rgba(59,130,246,.1); }
[data-theme="dark"] .pv-main-header { background: var(--bg-panel); }
[data-theme="dark"] .pv-type-stl { background: rgba(139,92,246,.2); }
[data-theme="dark"] .pv-type-3mf { background: rgba(59,130,246,.2); }
[data-theme="dark"] .pv-type-gcode, [data-theme="dark"] .pv-type-g, [data-theme="dark"] .pv-type-gco { background: rgba(16,185,129,.2); }
+292
View File
@@ -0,0 +1,292 @@
// HouseHub — PrintVault module
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 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);
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);
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'); }
if (!j.ok) throw new Error(j.error || 'Erreur');
return j.data;
}
// ─── State ────────────────────────────────────────────────────────────────────
let currentCategory = null;
let currentType = null;
let searchQuery = '';
let allModels = [];
let categories = [];
// ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadSidebar();
loadModels();
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('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]);
});
}
});
// ─── Sidebar ──────────────────────────────────────────────────────────────────
async function loadSidebar() {
try {
const data = await api('categories');
categories = data.categories || [];
renderSidebar();
} catch (e) { console.error(e); }
}
function renderSidebar() {
const el = document.getElementById('pv-sidebar-nav');
let html = `
<div class="pv-nav-section">Catégories</div>
<div class="pv-nav-item${currentCategory === null ? ' active' : ''}" onclick="setCategory(null)">
🗂 Toutes <span class="pv-nav-badge">${allModels.length}</span>
</div>`;
categories.forEach(c => {
const count = allModels.filter(m => m.category === c.name).length;
html += `<div class="pv-nav-item${currentCategory === c.name ? ' active' : ''}" onclick="setCategory('${escHtml(c.name)}')">
<span class="pv-cat-dot" style="background:${escHtml(c.color)}"></span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.name)}</span>
<span class="pv-nav-badge">${count}</span>
</div>`;
});
el.innerHTML = html;
}
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);
});
renderModels();
}
// ─── Models ───────────────────────────────────────────────────────────────────
async function loadModels() {
try {
const data = await api('models');
allModels = data.models || [];
renderSidebar();
renderModels();
updateHeader();
} catch (e) { toast(e.message, 'error'); }
}
function filterModels() {
return allModels.filter(m => {
if (currentCategory && m.category !== currentCategory) return false;
if (currentType) {
const ext = m.file_type.toLowerCase();
if (currentType === 'gcode' && !['gcode','gco','g'].includes(ext)) return false;
if (currentType !== 'gcode' && ext !== currentType) return false;
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
if (!m.name.toLowerCase().includes(q) &&
!(m.description || '').toLowerCase().includes(q) &&
!(m.tags || '').toLowerCase().includes(q)) return false;
}
return true;
});
}
function renderModels() {
const el = document.getElementById('pv-grid');
const filtered = filterModels();
if (!filtered.length) {
el.innerHTML = `<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>Aucun modèle trouvé.</p></div>`;
updateHeader(0);
return;
}
el.innerHTML = filtered.map(m => modelCardHtml(m)).join('');
updateHeader(filtered.length);
}
function modelCardHtml(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
? `<img src="${escHtml(thumbSrc)}" alt="" loading="lazy" onerror="this.parentNode.innerHTML='${typeIcon}'">`
: 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 : '';
return `<div class="pv-card" onclick="openDetail(${m.id})">
<div class="pv-card-thumb">${thumbHtml}</div>
<div class="pv-card-body">
<div class="pv-card-name">${escHtml(m.name)}</div>
<div class="pv-card-meta">
<span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span>
${dims ? `<span>${escHtml(dims)}</span>` : ''}
${gcodeInfo ? `<span>⏱ ${escHtml(gcodeInfo)}</span>` : ''}
<span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span>
</div>
</div>
</div>`;
}
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 modal ─────────────────────────────────────────────────────────────
function openDetail(id) {
const m = allModels.find(x => x.id === id);
if (!m) return;
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 isGcode = ['gcode','gco','g'].includes(ext);
document.getElementById('pv-detail-body').innerHTML = `
<div class="pv-detail-thumb">
${thumbSrc ? `<img src="${escHtml(thumbSrc)}" alt="">` : typeIcon}
</div>
<div style="font-size:1rem;font-weight:700;color:var(--text-main)">${escHtml(m.name)}</div>
${m.description ? `<div style="font-size:.85rem;color:var(--text-muted)">${escHtml(m.description)}</div>` : ''}
${m.tags ? `<div style="display:flex;gap:.3rem;flex-wrap:wrap">${m.tags.split(',').filter(Boolean).map(t => `<span style="font-size:.72rem;background:var(--bg-page);border:1px solid var(--border-light);padding:.1rem .45rem;border-radius:999px;color:var(--text-muted)">${escHtml(t.trim())}</span>`).join('')}</div>` : ''}
<div class="pv-detail-grid">
<div class="pv-detail-item"><span class="pv-detail-label">Type</span><span class="pv-detail-value"><span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span></span></div>
<div class="pv-detail-item"><span class="pv-detail-label">Catégorie</span><span class="pv-detail-value">${escHtml(m.category)}</span></div>
<div class="pv-detail-item"><span class="pv-detail-label">Taille</span><span class="pv-detail-value">${escHtml(fmtSize(m.file_size))}</span></div>
<div class="pv-detail-item"><span class="pv-detail-label">Ajouté le</span><span class="pv-detail-value">${escHtml(fmtDate(m.created_at))}</span></div>
${m.dim_x > 0 ? `<div class="pv-detail-item"><span class="pv-detail-label">Dimensions</span><span class="pv-detail-value">${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)} mm</span></div>` : ''}
${m.volume > 0 ? `<div class="pv-detail-item"><span class="pv-detail-label">Volume</span><span class="pv-detail-value">${m.volume.toFixed(2)} cm³</span></div>` : ''}
${isGcode && m.gcode_print_time ? `<div class="pv-detail-item"><span class="pv-detail-label">Temps impression</span><span class="pv-detail-value">${escHtml(m.gcode_print_time)}</span></div>` : ''}
${isGcode && m.gcode_filament ? `<div class="pv-detail-item"><span class="pv-detail-label">Filament</span><span class="pv-detail-value">${escHtml(m.gcode_filament)}</span></div>` : ''}
${isGcode && m.gcode_nozzle_temp ? `<div class="pv-detail-item"><span class="pv-detail-label">Buse</span><span class="pv-detail-value">${escHtml(m.gcode_nozzle_temp)}</span></div>` : ''}
${isGcode && m.gcode_bed_temp ? `<div class="pv-detail-item"><span class="pv-detail-label">Plateau</span><span class="pv-detail-value">${escHtml(m.gcode_bed_temp)}</span></div>` : ''}
</div>`;
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);
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);
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'); }
}
// ─── Upload modal ─────────────────────────────────────────────────────────────
let uploadFile = null;
function openUpload() {
uploadFile = null;
document.getElementById('pv-file-input').value = '';
document.getElementById('pv-upload-filename').textContent = '';
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 => `<option value="${escHtml(c.name)}">${escHtml(c.name)}</option>`).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; }
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, ' ');
}
}
async function saveUpload() {
if (!uploadFile) { toast('Choisissez un fichier', 'error'); return; }
const name = document.getElementById('pv-upload-name').value.trim();
if (!name) { toast('Nom requis', 'error'); return; }
const fd = new FormData();
fd.append('file', uploadFile);
fd.append('name', name);
fd.append('description', document.getElementById('pv-upload-desc').value);
fd.append('category', document.getElementById('pv-upload-cat').value);
fd.append('tags', document.getElementById('pv-upload-tags').value);
const prog = document.getElementById('pv-upload-progress');
const bar = document.getElementById('pv-upload-bar');
prog.style.display = '';
bar.style.width = '30%';
try {
await api('models', 'POST', fd);
bar.style.width = '100%';
setTimeout(() => {
document.getElementById('pv-upload-modal').classList.remove('show');
toast('Modèle ajouté ✓');
loadModels();
}, 300);
} catch (e) {
prog.style.display = 'none';
toast(e.message, 'error');
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = 'PrintVault — HouseHub';
$activePage = 'printvault';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/printvault/assets/printvault.css">
<div id="pv-toasts" class="pv-toast-container"></div>
<div class="pv-layout">
<!-- ── SIDEBAR ──────────────────────────────────────────────────────────── -->
<aside class="pv-sidebar">
<div class="pv-sidebar-top">
<button class="btn btn-primary" onclick="openUpload()" style="width:100%;justify-content:center">+ Upload</button>
<div class="pv-search">
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
<input type="text" id="pv-search" placeholder="Rechercher…">
</div>
</div>
<!-- Type filter chips -->
<div class="pv-type-chips">
<span class="pv-chip stl" data-type="stl" onclick="setType('stl')">STL</span>
<span class="pv-chip 3mf" data-type="3mf" onclick="setType('3mf')">3MF</span>
<span class="pv-chip gcode" data-type="gcode" onclick="setType('gcode')">GCode</span>
</div>
<!-- Categories nav -->
<div id="pv-sidebar-nav"></div>
</aside>
<!-- ── MAIN ─────────────────────────────────────────────────────────────── -->
<div class="pv-main">
<div class="pv-main-header">
<div>
<h2>🖨️ PrintVault</h2>
<div class="pv-subtitle" id="pv-subtitle"></div>
</div>
<div style="display:flex;gap:.5rem">
<a href="https://printvault.nas.percolouco.com" target="_blank" class="btn btn-secondary btn-sm">Ouvrir PrintVault ↗</a>
<button class="btn btn-primary btn-sm" onclick="openUpload()">+ Upload</button>
</div>
</div>
<div class="pv-grid" id="pv-grid">
<div class="pv-empty" style="grid-column:1/-1">
<div class="icon">🖨️</div>
<p>Chargement…</p>
</div>
</div>
</div>
</div>
<!-- ── MODAL : DÉTAIL ──────────────────────────────────────────────────────── -->
<div class="pv-modal-backdrop" id="pv-detail-modal">
<div class="pv-modal">
<div class="pv-modal-header">
<h3>Détail du modèle</h3>
<button class="pv-modal-close" onclick="document.getElementById('pv-detail-modal').classList.remove('show')">×</button>
</div>
<div class="pv-modal-body" id="pv-detail-body"></div>
<div class="pv-modal-footer">
<button class="btn btn-danger btn-sm" id="pv-detail-del-btn" style="margin-right:auto">🗑 Supprimer</button>
<a class="btn btn-secondary btn-sm" id="pv-detail-dl-btn" download>⬇ Télécharger</a>
<button class="btn btn-primary btn-sm" id="pv-detail-3d-btn">🎮 Voir en 3D</button>
</div>
</div>
</div>
<!-- ── MODAL : UPLOAD ──────────────────────────────────────────────────────── -->
<div class="pv-modal-backdrop" id="pv-upload-modal">
<div class="pv-modal">
<div class="pv-modal-header">
<h3>Uploader un modèle</h3>
<button class="pv-modal-close" onclick="document.getElementById('pv-upload-modal').classList.remove('show')">×</button>
</div>
<div class="pv-modal-body">
<div class="pv-drop-zone" id="pv-drop-zone">
<div class="icon">📁</div>
<div>Glissez un fichier ici ou cliquez pour choisir</div>
<div style="font-size:.75rem;margin-top:.25rem">STL · 3MF · GCode</div>
<div class="pv-file-chosen" id="pv-upload-filename"></div>
<input type="file" id="pv-file-input" accept=".stl,.3mf,.gcode,.gco,.g" style="display:none">
</div>
<div class="form-group">
<label class="form-label">Nom *</label>
<input type="text" id="pv-upload-name" class="form-control" placeholder="Nom du modèle">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" id="pv-upload-desc" class="form-control" placeholder="Optionnel">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
<div class="form-group">
<label class="form-label">Catégorie</label>
<select id="pv-upload-cat" class="form-control"></select>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" id="pv-upload-tags" class="form-control" placeholder="tag1, tag2">
</div>
</div>
<div id="pv-upload-progress" style="display:none">
<div class="pv-progress"><div class="pv-progress-bar" id="pv-upload-bar" style="width:0%"></div></div>
</div>
</div>
<div class="pv-modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('pv-upload-modal').classList.remove('show')">Annuler</button>
<button class="btn btn-primary" onclick="saveUpload()">Uploader</button>
</div>
</div>
</div>
<script src="/modules/printvault/assets/printvault.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+2 -1
View File
@@ -36,7 +36,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'set_modules' && $family_id) { if ($action === 'set_modules' && $family_id) {
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'groceries', 'calendar_ios']; $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'groceries', 'calendar_ios', 'printvault'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m]))); $enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) { if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif."; $error = "Vous devez garder au moins un module actif.";
@@ -309,6 +309,7 @@ require __DIR__ . '/header.php';
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')], 'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
'groceries' => ['icon' => '🛒', 'label' => tr('menu_groceries')], 'groceries' => ['icon' => '🛒', 'label' => tr('menu_groceries')],
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')], 'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')],
]; ];
?> ?>
<form method="post"> <form method="post">