PrintVault: direct page navigation + GCode metadata cards
Deploy HouseHub / deploy (push) Successful in 2s

- Clicking a model card now navigates to /printvault-viewer.php (no popup)
- viewer page uses HouseHub header/nav (integrated look)
- GCode metadata displayed as cards: ⏱ time, 🧵 filament, 🔥 nozzle, 🟦 bed
- Two-column layout: Three.js viewer left + metadata sidebar right
- viewer.css: new stylesheet for detail page
- Removed now-unused detail/edit modals from printvault.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-05-15 11:05:58 +02:00
co-authored by Claude Sonnet 4.6
parent 1ee0ec820c
commit b756517ab5
4 changed files with 429 additions and 366 deletions
+2 -88
View File
@@ -128,7 +128,7 @@ function cardHtml(m) {
: icon; : icon;
const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)}mm` : ''; 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 || ''; const gcInfo = m.gcode_time || '';
return `<div class="pv-card" onclick="openDetail(${m.id})"> return `<a class="pv-card" href="/printvault-viewer.php?id=${m.id}" style="text-decoration:none">
<div class="pv-card-thumb">${thumbHtml}</div> <div class="pv-card-thumb">${thumbHtml}</div>
<div class="pv-card-body"> <div class="pv-card-body">
<div class="pv-card-name">${escHtml(m.name)}</div> <div class="pv-card-name">${escHtml(m.name)}</div>
@@ -139,93 +139,7 @@ function cardHtml(m) {
<span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span> <span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span>
</div> </div>
</div> </div>
</div>`; </a>`;
}
// ─── Detail panel ─────────────────────────────────────────────────────────────
let currentDetailId = null;
function openDetail(id) {
const m = allModels.find(x => x.id === id);
if (!m) return;
currentDetailId = id;
const ext = m.file_type.toLowerCase();
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 = `
<div class="pv-detail-thumb">
${thumbUrl ? `<img src="${escHtml(thumbUrl)}" alt="">` : icon}
</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" style="grid-column:1/-1"><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${m.volume > 0 ? ' · '+m.volume+' cm³' : ''}</span></div>` : ''}
${isGcode && m.gcode_time ? `<div class="pv-detail-item"><span class="pv-detail-label">Temps</span><span class="pv-detail-value">${escHtml(m.gcode_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 ? `<div class="pv-detail-item"><span class="pv-detail-label">Buse</span><span class="pv-detail-value">${escHtml(m.gcode_nozzle)}</span></div>` : ''}
${isGcode && m.gcode_bed ? `<div class="pv-detail-item"><span class="pv-detail-label">Plateau</span><span class="pv-detail-value">${escHtml(m.gcode_bed)}</span></div>` : ''}
</div>`;
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);
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'); }
}
// ─── 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 => `<option value="${escHtml(c.name)}"${c.name===m.category?' selected':''}>${escHtml(c.name)}</option>`).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 ─────────────────────────────────────────────────────────────────── // ─── Upload ───────────────────────────────────────────────────────────────────
+161
View File
@@ -0,0 +1,161 @@
/* PrintVault — detail/viewer page */
.pv-detail-page {
display: flex;
flex-direction: column;
height: calc(100vh - 64px);
overflow: hidden;
background: var(--bg-page);
}
/* Top bar */
.pv-detail-topbar {
display: flex;
align-items: center;
gap: .75rem;
padding: .75rem 1.25rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
flex-wrap: wrap;
}
.pv-detail-topbar-title {
display: flex;
align-items: center;
gap: .6rem;
min-width: 0;
}
.pv-detail-topbar-title h1 {
font-size: 1rem;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Body: viewer + sidebar */
.pv-detail-body {
display: grid;
grid-template-columns: 1fr 320px;
flex: 1;
overflow: hidden;
}
/* Viewer area */
.pv-detail-left {
overflow: hidden;
position: relative;
background: #0f172a;
}
.pv-viewer-wrap {
width: 100%;
height: 100%;
position: relative;
}
.pv-viewer-wrap canvas { display: block; width: 100% !important; height: 100% !important; }
.pv-viewer-loading {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; background: #0f172a; z-index: 10; color: #94a3b8; font-size: .9rem;
}
.pv-spinner {
width: 36px; height: 36px;
border: 3px solid #1e293b; border-top-color: #3b82f6;
border-radius: 50%; animation: pvSpin .8s linear infinite;
}
@keyframes pvSpin { to { transform: rotate(360deg); } }
.pv-viewer-controls-hint {
position: absolute; bottom: 1rem; left: 50%; transform: translateX(-50%);
font-size: .72rem; color: #475569; background: rgba(15,23,42,.7);
padding: .3rem .7rem; border-radius: 999px; pointer-events: none;
white-space: nowrap;
}
.pv-viewer-screenshot-btn {
position: absolute; top: .75rem; right: .75rem;
background: rgba(15,23,42,.8); border: 1px solid rgba(255,255,255,.15);
color: #e2e8f0; border-radius: 8px; padding: .35rem .6rem;
cursor: pointer; font-size: .85rem; transition: background .15s;
}
.pv-viewer-screenshot-btn:hover { background: rgba(59,130,246,.4); }
/* Thumbnail fallback */
.pv-detail-thumb-large {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
font-size: 8rem; background: #1e293b;
}
.pv-detail-thumb-large img { width: 100%; height: 100%; object-fit: contain; }
.pv-detail-thumb-placeholder { color: #334155; }
/* Right sidebar metadata */
.pv-detail-right {
overflow-y: auto;
background: var(--bg-panel);
border-left: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: 0;
}
.pv-meta-section {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-light);
}
.pv-meta-section:last-child { border-bottom: none; }
.pv-meta-title {
font-size: .72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--text-muted); margin-bottom: .75rem;
}
.pv-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.pv-meta-item { display: flex; flex-direction: column; gap: .15rem; }
.pv-meta-item--full { grid-column: 1 / -1; }
.pv-meta-label { font-size: .72rem; color: var(--text-muted); font-weight: 500; }
.pv-meta-value { font-size: .875rem; color: var(--text-main); font-weight: 500; }
/* Description & tags */
.pv-detail-description { font-size: .875rem; color: var(--text-muted); line-height: 1.5; }
.pv-tags-row { display: flex; gap: .35rem; flex-wrap: wrap; }
.pv-tag {
font-size: .72rem; background: var(--bg-page); border: 1px solid var(--border-light);
padding: .15rem .5rem; border-radius: 999px; color: var(--text-muted);
}
/* GCode metadata cards */
.pv-gcode-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .6rem;
}
.pv-gcode-card {
background: var(--bg-page);
border: 1px solid var(--border-light);
border-radius: 10px;
padding: .85rem .9rem;
display: flex; flex-direction: column; gap: .2rem;
transition: border-color .15s;
}
.pv-gcode-card:hover { border-color: #cbd5e1; }
.pv-gcode-icon { font-size: 1.3rem; margin-bottom: .1rem; }
.pv-gcode-value {
font-size: 1.05rem; font-weight: 700; color: var(--text-main);
line-height: 1.2;
}
.pv-gcode-label {
font-size: .72rem; color: var(--text-muted); line-height: 1.3;
}
/* Responsive */
@media (max-width: 900px) {
.pv-detail-body { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; }
.pv-detail-right { border-left: none; border-top: 1px solid var(--border-light); }
.pv-detail-page { height: auto; overflow: auto; }
.pv-detail-body { overflow: visible; }
}
/* Dark mode */
[data-theme="dark"] .pv-detail-topbar { background: var(--bg-panel); }
[data-theme="dark"] .pv-detail-right { background: var(--bg-panel); }
[data-theme="dark"] .pv-gcode-card { background: var(--bg-page); }
[data-theme="dark"] .pv-detail-thumb-large { background: #0d1117; }
+266 -226
View File
@@ -4,112 +4,239 @@ require_login();
require_once __DIR__ . '/includes/db.php'; require_once __DIR__ . '/includes/db.php';
$id = (int)($_GET['id'] ?? 0); $id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(404); exit('Modèle introuvable'); } if (!$id) { header('Location: /printvault.php'); exit; }
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]); $s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
$model = $s->fetch(); $model = $s->fetch();
if (!$model) { http_response_code(404); exit('Modèle introuvable'); } if (!$model) { header('Location: /printvault.php'); exit; }
$ext = strtolower($model['file_type']); $s2 = $pdo->query("SELECT name, color FROM pf_pv_categories ORDER BY name");
$canView = in_array($ext, ['stl','3mf']); $categories = $s2->fetchAll();
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($model['name']) ?> — PrintVault</title>
<link rel="icon" href="/favicon.png">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0f172a; color:#e2e8f0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; overflow:hidden; }
#canvas-container { width:100vw; height:100vh; position:relative; }
canvas { display:block; }
#ui-overlay {
position:fixed; top:0; left:0; right:0;
display:flex; align-items:center; justify-content:space-between;
padding:.75rem 1.25rem; background:rgba(15,23,42,.85); backdrop-filter:blur(8px);
border-bottom:1px solid rgba(255,255,255,.08); z-index:10;
}
#model-name { font-size:.95rem; font-weight:600; color:#f1f5f9; }
#model-meta { font-size:.75rem; color:#94a3b8; margin-top:.15rem; }
.ui-btns { display:flex; gap:.5rem; }
.ui-btn {
padding:.35rem .8rem; border-radius:8px; border:1px solid rgba(255,255,255,.15);
background:rgba(255,255,255,.08); color:#e2e8f0; font-size:.8rem; cursor:pointer;
transition:background .15s; white-space:nowrap;
}
.ui-btn:hover { background:rgba(255,255,255,.15); }
.ui-btn.primary { background:#3b82f6; border-color:#3b82f6; color:#fff; }
.ui-btn.primary:hover { background:#2563eb; }
#loading {
position:fixed; inset:0; display:flex; flex-direction:column;
align-items:center; justify-content:center; background:#0f172a; z-index:20;
gap:1rem; color:#94a3b8; font-size:.9rem;
}
.spinner { width:40px; height:40px; border:3px solid #1e293b; border-top-color:#3b82f6; border-radius:50%; animation:spin .8s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
#error { display:none; text-align:center; }
#controls-hint {
position:fixed; bottom:1rem; left:50%; transform:translateX(-50%);
font-size:.72rem; color:#475569; background:rgba(15,23,42,.7);
padding:.35rem .75rem; border-radius:999px; pointer-events:none;
}
#gcode-info {
position:fixed; inset:0; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:1.5rem; padding:2rem;
}
.info-card {
background:#1e293b; border:1px solid #334155; border-radius:14px;
padding:1.5rem 2rem; max-width:420px; width:100%;
}
.info-card h2 { font-size:1.1rem; margin-bottom:1rem; color:#f1f5f9; }
.info-row { display:flex; justify-content:space-between; padding:.5rem 0; border-bottom:1px solid #1e293b; font-size:.875rem; }
.info-row:last-child { border:none; }
.info-label { color:#94a3b8; }
.info-value { color:#e2e8f0; font-weight:500; }
</style>
</head>
<body>
<div id="ui-overlay"> $ext = strtolower($model['file_type']);
<div> $canView = in_array($ext, ['stl','3mf']);
<div id="model-name"><?= htmlspecialchars($model['name']) ?></div> $isGcode = in_array($ext, ['gcode','gco','g']);
<div id="model-meta"> $thumbUrl = $model['thumb'] ? '/uploads/printvault/thumbs/' . rawurlencode($model['thumb']) : null;
<?= htmlspecialchars(strtoupper($model['file_type'])) ?> $typeIcon = ['stl'=>'🟣','3mf'=>'🔵','gcode'=>'🟢','gco'=>'🟢','g'=>'🟢'][$ext] ?? '📄';
<?php if ($model['dim_x'] > 0): ?>
· <?= round($model['dim_x']) ?>×<?= round($model['dim_y']) ?>×<?= round($model['dim_z']) ?> mm function fmtSize(int $b): string {
<?php endif; ?> if ($b > 1048576) return round($b/1048576,1).' Mo';
· <?= round(($model['file_size']??0)/1024) ?> Ko return round($b/1024).' Ko';
}
function fmtDate(string $s): string {
return date('d/m/Y', strtotime($s));
}
$pageTitle = htmlspecialchars($model['name']) . ' — PrintVault';
$activePage = 'printvault';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/printvault/assets/printvault.css">
<link rel="stylesheet" href="/modules/printvault/assets/viewer.css">
<div id="pv-toasts" class="pv-toast-container"></div>
<div class="pv-detail-page">
<!-- ── TOP BAR ────────────────────────────────────────────────────────── -->
<div class="pv-detail-topbar">
<a href="/printvault.php" class="btn btn-secondary btn-sm">← Retour</a>
<div class="pv-detail-topbar-title">
<span class="pv-type-badge pv-type-<?= $ext ?>"><?= strtoupper($ext) ?></span>
<h1><?= htmlspecialchars($model['name']) ?></h1>
</div>
<div style="display:flex;gap:.5rem;margin-left:auto">
<button class="btn btn-secondary btn-sm" onclick="openEdit()">✏️ Modifier</button>
<a href="/modules/printvault/api.php?action=file&id=<?= $id ?>" class="btn btn-secondary btn-sm" download>⬇ Télécharger</a>
<button class="btn btn-danger btn-sm" onclick="deleteModel()">🗑 Supprimer</button>
</div> </div>
</div> </div>
<div class="ui-btns">
<button class="ui-btn" onclick="resetCamera()">⟳ Centrer</button> <!-- ── BODY ───────────────────────────────────────────────────────────── -->
<?php if ($canView): ?> <div class="pv-detail-body">
<button class="ui-btn" onclick="takeScreenshot()" id="thumb-btn">📸 Aperçu</button>
<?php endif; ?> <!-- Left: viewer or thumbnail -->
<button class="ui-btn" onclick="window.close()">✕ Fermer</button> <div class="pv-detail-left">
<a href="/modules/printvault/api.php?action=file&id=<?= $id ?>" class="ui-btn primary" download>⬇ Télécharger</a> <?php if ($canView): ?>
<div class="pv-viewer-wrap" id="viewer-wrap">
<div id="viewer-loading" class="pv-viewer-loading">
<div class="pv-spinner"></div>
<div id="viewer-loading-text">Chargement…</div>
</div>
<canvas id="viewer-canvas"></canvas>
<div class="pv-viewer-controls-hint">Clic+glisser : rotation · Scroll : zoom</div>
<button class="pv-viewer-screenshot-btn" onclick="takeScreenshot()" title="Sauvegarder comme aperçu">📸</button>
</div>
<?php elseif ($thumbUrl): ?>
<div class="pv-detail-thumb-large">
<img src="<?= htmlspecialchars($thumbUrl) ?>" alt="">
</div>
<?php else: ?>
<div class="pv-detail-thumb-large pv-detail-thumb-placeholder">
<span><?= $typeIcon ?></span>
</div>
<?php endif; ?>
</div>
<!-- Right: metadata -->
<div class="pv-detail-right">
<?php if ($model['description']): ?>
<div class="pv-meta-section">
<p class="pv-detail-description"><?= htmlspecialchars($model['description']) ?></p>
</div>
<?php endif; ?>
<?php if ($model['tags']): ?>
<div class="pv-meta-section pv-tags-row">
<?php foreach (array_filter(explode(',', $model['tags'])) as $tag): ?>
<span class="pv-tag"><?= htmlspecialchars(trim($tag)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- File info -->
<div class="pv-meta-section">
<div class="pv-meta-title">Informations</div>
<div class="pv-meta-grid">
<div class="pv-meta-item"><span class="pv-meta-label">Catégorie</span><span class="pv-meta-value"><?= htmlspecialchars($model['category']) ?></span></div>
<div class="pv-meta-item"><span class="pv-meta-label">Taille fichier</span><span class="pv-meta-value"><?= fmtSize((int)$model['file_size']) ?></span></div>
<div class="pv-meta-item"><span class="pv-meta-label">Ajouté le</span><span class="pv-meta-value"><?= fmtDate($model['created_at']) ?></span></div>
<?php if ($model['dim_x'] > 0): ?>
<div class="pv-meta-item pv-meta-item--full">
<span class="pv-meta-label">Dimensions</span>
<span class="pv-meta-value"><?= round($model['dim_x']) ?> × <?= round($model['dim_y']) ?> × <?= round($model['dim_z']) ?> mm<?= $model['volume'] > 0 ? ' · '.round($model['volume'],2).' cm³' : '' ?></span>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($isGcode && ($model['gcode_time'] || $model['gcode_filament'] || $model['gcode_nozzle'] || $model['gcode_bed'])): ?>
<!-- GCode metadata cards -->
<div class="pv-meta-section">
<div class="pv-meta-title">Métadonnées impression</div>
<div class="pv-gcode-cards">
<?php if ($model['gcode_time']): ?>
<div class="pv-gcode-card">
<div class="pv-gcode-icon">⏱</div>
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_time']) ?></div>
<div class="pv-gcode-label">Temps d'impression estimé</div>
</div>
<?php endif; ?>
<?php if ($model['gcode_filament']): ?>
<div class="pv-gcode-card">
<div class="pv-gcode-icon">🧵</div>
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_filament']) ?></div>
<div class="pv-gcode-label">Filament utilisé</div>
</div>
<?php endif; ?>
<?php if ($model['gcode_nozzle']): ?>
<div class="pv-gcode-card">
<div class="pv-gcode-icon">🔥</div>
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_nozzle']) ?></div>
<div class="pv-gcode-label">Température buse</div>
</div>
<?php endif; ?>
<?php if ($model['gcode_bed']): ?>
<div class="pv-gcode-card">
<div class="pv-gcode-icon">🟦</div>
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_bed']) ?></div>
<div class="pv-gcode-label">Température plateau</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
<?php if ($canView): ?> <!-- ── MODAL : ÉDITION ─────────────────────────────────────────────────────── -->
<div class="pv-modal-backdrop" id="pv-edit-modal">
<div id="loading"> <div class="pv-modal">
<div class="spinner"></div> <div class="pv-modal-header">
<div>Chargement du modèle</div> <h3>Modifier le modèle</h3>
<div id="error" style="color:#ef4444"></div> <button class="pv-modal-close" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">×</button>
</div>
<div class="pv-modal-body">
<div class="form-group">
<label class="form-label">Nom *</label>
<input type="text" id="edit-name" class="form-control" value="<?= htmlspecialchars($model['name']) ?>">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" id="edit-desc" class="form-control" value="<?= htmlspecialchars($model['description'] ?? '') ?>">
</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="edit-cat" class="form-control">
<?php foreach ($categories as $c): ?>
<option value="<?= htmlspecialchars($c['name']) ?>" <?= $c['name'] === $model['category'] ? 'selected' : '' ?>><?= htmlspecialchars($c['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" id="edit-tags" class="form-control" value="<?= htmlspecialchars($model['tags'] ?? '') ?>" placeholder="tag1, tag2">
</div>
</div>
</div>
<div class="pv-modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">Annuler</button>
<button class="btn btn-primary" onclick="saveEdit()">Enregistrer</button>
</div>
</div>
</div> </div>
<div id="canvas-container"></div> <script>
<div id="controls-hint">Clic+glisser : rotation · Scroll : zoom · Clic droit : déplacer</div> const MODEL_ID = <?= $id ?>;
const MODEL_EXT = '<?= $ext ?>';
const API = '/modules/printvault/api.php';
<script type="importmap"> function toast(msg, type='success') {
{ const c = document.getElementById('pv-toasts');
"imports": { const t = document.createElement('div');
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", t.className = 'pv-toast' + (type==='error'?' error':'');
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/" t.textContent = msg; c.appendChild(t); setTimeout(()=>t.remove(),3500);
}
} }
function openEdit() { document.getElementById('pv-edit-modal').classList.add('show'); }
async function saveEdit() {
const name = document.getElementById('edit-name').value.trim();
if (!name) { toast('Nom requis','error'); return; }
try {
const r = await fetch(API+'?action=models&id='+MODEL_ID, {
method:'PUT', credentials:'same-origin',
headers:{'Content-Type':'application/json','Accept':'application/json','X-Requested-With':'XMLHttpRequest'},
body: JSON.stringify({name, description:document.getElementById('edit-desc').value, category:document.getElementById('edit-cat').value, tags:document.getElementById('edit-tags').value})
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
toast('Mis à jour ✓');
document.getElementById('pv-edit-modal').classList.remove('show');
setTimeout(()=>location.reload(),600);
} catch(e) { toast(e.message,'error'); }
}
async function deleteModel() {
if (!confirm('Supprimer ce modèle ?')) return;
try {
const r = await fetch(API+'?action=models&id='+MODEL_ID, {method:'DELETE',credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
window.location.href = '/printvault.php';
} catch(e) { toast(e.message,'error'); }
}
document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
</script>
<?php if ($canView): ?>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script> </script>
<script type="module"> <script type="module">
import * as THREE from 'three'; import * as THREE from 'three';
@@ -119,171 +246,84 @@ import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js'; import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
<?php endif; ?> <?php endif; ?>
const MODEL_URL = '/modules/printvault/api.php?action=model_data&id=<?= $id ?>'; const canvas = document.getElementById('viewer-canvas');
const MODEL_ID = <?= $id ?>; const wrap = document.getElementById('viewer-wrap');
const API = '/modules/printvault/api.php'; const scene = new THREE.Scene();
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a); scene.background = new THREE.Color(0x0f172a);
scene.add(new THREE.AmbientLight(0xffffff, 0.6)); scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const key = new THREE.DirectionalLight(0xffffff, 1.2); key.position.set(5,10,7.5); scene.add(key);
const fill= new THREE.DirectionalLight(0x8ab4f8, 0.4); fill.position.set(-5,-3,-5); scene.add(fill);
const rim = new THREE.DirectionalLight(0xffffff, 0.3); rim.position.set(0,5,-10); scene.add(rim);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.2); const grid = new THREE.GridHelper(200,40,0x1e293b,0x1e293b);
keyLight.position.set(5, 10, 7.5); grid.material.opacity=0.4; grid.material.transparent=true; scene.add(grid);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x8ab4f8, 0.4); const camera = new THREE.PerspectiveCamera(45, wrap.clientWidth/wrap.clientHeight, 0.1, 10000);
fillLight.position.set(-5, -3, -5); camera.position.set(100,100,100);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 0.3); const renderer = new THREE.WebGLRenderer({canvas, antialias:true, preserveDrawingBuffer:true});
rimLight.position.set(0, 5, -10);
scene.add(rimLight);
// Grid
const grid = new THREE.GridHelper(200, 40, 0x1e293b, 0x1e293b);
grid.material.opacity = 0.5; grid.material.transparent = true;
scene.add(grid);
// Camera & renderer
const camera = new THREE.PerspectiveCamera(45, window.innerWidth/window.innerHeight, 0.1, 10000);
camera.position.set(100, 100, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(wrap.clientWidth, wrap.clientHeight);
renderer.shadowMap.enabled = true;
document.getElementById('canvas-container').appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.05; controls.enableDamping=true; controls.dampingFactor=0.05;
const material = new THREE.MeshPhysicalMaterial({color:0x4cc9f0,metalness:0.3,roughness:0.4,side:THREE.DoubleSide});
let meshGroup = null; let meshGroup = null;
function fitCamera(obj) {
function fitCamera(object) { const box = new THREE.Box3().setFromObject(obj);
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z); const dist = Math.abs(Math.max(size.x,size.y,size.z)/Math.sin(camera.fov*Math.PI/180/2))*0.75;
const fov = camera.fov * (Math.PI / 180); camera.position.set(center.x+dist*.7, center.y+dist*.5, center.z+dist*.7);
const dist = Math.abs(maxDim / Math.sin(fov / 2)) * 0.75;
camera.position.set(center.x + dist * 0.7, center.y + dist * 0.5, center.z + dist * 0.7);
controls.target.copy(center); controls.target.copy(center);
// Align grid to bottom of object
grid.position.y = box.min.y; grid.position.y = box.min.y;
controls.update(); controls.update();
} }
window.resetCamera = () => { if (meshGroup) fitCamera(meshGroup); };
const material = new THREE.MeshPhysicalMaterial({
color: 0x4cc9f0, metalness: 0.3, roughness: 0.4,
side: THREE.DoubleSide,
});
// Load model
<?php if ($ext === 'stl'): ?> <?php if ($ext === 'stl'): ?>
new STLLoader().load(MODEL_URL, new STLLoader().load('/modules/printvault/api.php?action=model_data&id=<?= $id ?>',
(geometry) => { geo => {
geometry.computeVertexNormals(); geo.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material); meshGroup = new THREE.Group(); meshGroup.add(new THREE.Mesh(geo,material));
meshGroup = new THREE.Group(); meshGroup.add(mesh); scene.add(meshGroup); fitCamera(meshGroup);
scene.add(meshGroup); document.getElementById('viewer-loading').style.display='none';
fitCamera(meshGroup);
document.getElementById('loading').style.display = 'none';
}, },
(xhr) => { xhr => { if(xhr.total) document.getElementById('viewer-loading-text').textContent='Chargement… '+Math.round(xhr.loaded/xhr.total*100)+'%'; },
if (xhr.total) document.querySelector('#loading div:last-child').textContent = err => { document.getElementById('viewer-loading-text').textContent='Erreur: '+err.message; }
'Chargement… ' + Math.round(xhr.loaded/xhr.total*100) + '%';
},
(err) => {
document.getElementById('error').style.display = '';
document.getElementById('error').textContent = 'Erreur chargement: ' + err.message;
}
); );
<?php elseif ($ext === '3mf'): ?> <?php elseif ($ext === '3mf'): ?>
new ThreeMFLoader().load(MODEL_URL, new ThreeMFLoader().load('/modules/printvault/api.php?action=model_data&id=<?= $id ?>',
(obj) => { obj => {
meshGroup = obj; obj.traverse(c=>{if(c.isMesh)c.material=material;});
// Apply material to all meshes meshGroup=obj; scene.add(meshGroup); fitCamera(meshGroup);
obj.traverse(child => { document.getElementById('viewer-loading').style.display='none';
if (child.isMesh) child.material = material;
});
scene.add(meshGroup);
fitCamera(meshGroup);
document.getElementById('loading').style.display = 'none';
}, },
(xhr) => { xhr => { if(xhr.total) document.getElementById('viewer-loading-text').textContent='Chargement… '+Math.round(xhr.loaded/xhr.total*100)+'%'; },
if (xhr.total) document.querySelector('#loading div:last-child').textContent = err => { document.getElementById('viewer-loading-text').textContent='Erreur: '+err.message; }
'Chargement… ' + Math.round(xhr.loaded/xhr.total*100) + '%';
},
(err) => {
document.getElementById('error').style.display = '';
document.getElementById('error').textContent = 'Erreur: ' + err.message;
}
); );
<?php endif; ?> <?php endif; ?>
// Animation loop (function animate(){requestAnimationFrame(animate);controls.update();renderer.render(scene,camera);})();
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => { window.addEventListener('resize',()=>{
camera.aspect = window.innerWidth / window.innerHeight; renderer.setSize(wrap.clientWidth,wrap.clientHeight);
camera.aspect=wrap.clientWidth/wrap.clientHeight;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}); });
// Screenshot → save as thumbnail
window.takeScreenshot = async () => { window.takeScreenshot = async () => {
renderer.render(scene, camera); renderer.render(scene,camera);
const canvas = renderer.domElement; canvas.toBlob(async blob=>{
canvas.toBlob(async (blob) => { const fd=new FormData(); fd.append('thumb',blob,'thumb.png');
const fd = new FormData(); const r=await fetch(API+'?action=thumb&id='+MODEL_ID,{method:'POST',credentials:'same-origin',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}});
fd.append('thumb', blob, 'thumb.png'); const j=await r.json();
try { if(j.ok){toast('Aperçu sauvegardé ✓');}
const r = await fetch(API + '?action=thumb&id=' + MODEL_ID, { },'image/png',0.9);
method: 'POST', credentials: 'same-origin', body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const j = await r.json();
if (j.ok) {
document.getElementById('thumb-btn').textContent = '✅ Sauvegardé';
setTimeout(() => { document.getElementById('thumb-btn').textContent = '📸 Aperçu'; }, 2000);
}
} catch(e) { console.error(e); }
}, 'image/png', 0.9);
}; };
</script> </script>
<?php else: ?>
<!-- GCode / non-viewable file: show metadata only -->
<div id="gcode-info">
<div class="info-card">
<h2>🟢 <?= htmlspecialchars($model['name']) ?></h2>
<?php $rows = [
['Fichier', $model['original_name']],
['Taille', round($model['file_size']/1024).' Ko'],
['Catégorie', $model['category']],
$model['gcode_time'] ? ['Temps impression', $model['gcode_time']] : null,
$model['gcode_filament'] ? ['Filament', $model['gcode_filament']]: null,
$model['gcode_nozzle'] ? ['Buse', $model['gcode_nozzle']] : null,
$model['gcode_bed'] ? ['Plateau', $model['gcode_bed']] : null,
['Ajouté le', date('d/m/Y', strtotime($model['created_at']))],
]; ?>
<?php foreach (array_filter($rows) as $r): ?>
<div class="info-row">
<span class="info-label"><?= htmlspecialchars($r[0]) ?></span>
<span class="info-value"><?= htmlspecialchars($r[1]) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?> <?php endif; ?>
</body> <?php require __DIR__ . '/footer.php'; ?>
</html>
-52
View File
@@ -45,58 +45,6 @@ require __DIR__ . '/header.php';
</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>
<button class="btn btn-secondary btn-sm" id="pv-detail-edit-btn">✏️ Modifier</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">🎮 Vue 3D</button>
</div>
</div>
</div>
<!-- ── MODAL : ÉDITION ─────────────────────────────────────────────────────── -->
<div class="pv-modal-backdrop" id="pv-edit-modal">
<div class="pv-modal">
<div class="pv-modal-header">
<h3>Modifier le modèle</h3>
<button class="pv-modal-close" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">×</button>
</div>
<div class="pv-modal-body">
<input type="hidden" id="pv-edit-id">
<div class="form-group">
<label class="form-label">Nom *</label>
<input type="text" id="pv-edit-name" class="form-control">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" id="pv-edit-desc" class="form-control">
</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-edit-cat" class="form-control"></select>
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" id="pv-edit-tags" class="form-control" placeholder="tag1, tag2">
</div>
</div>
</div>
<div class="pv-modal-footer">
<button class="btn btn-secondary" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">Annuler</button>
<button class="btn btn-primary" onclick="saveEdit()">Enregistrer</button>
</div>
</div>
</div>
<!-- ── MODAL : UPLOAD ──────────────────────────────────────────────────────── --> <!-- ── MODAL : UPLOAD ──────────────────────────────────────────────────────── -->
<div class="pv-modal-backdrop" id="pv-upload-modal"> <div class="pv-modal-backdrop" id="pv-upload-modal">
<div class="pv-modal"> <div class="pv-modal">