printhive/public/index.html
dlawler489 d4b7e29348 Browse archives, preview companion files, and view FBX/STEP/3DM/OBJ+MTL
Archives
- ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background into
  archive_entries (7-Zip compiled to WASM in a worker thread; node-tar for
  tar). macOS/Windows clutter and paths that could escape the extraction
  folder are skipped.
- Models inside archives are extracted once per archive to hash and
  geometry-fingerprint them, so duplicates are found inside packs too.
  Pictures, PDFs and readmes are listed but never matched.
- New routes list an archive's entries and serve/convert a single entry,
  extracted on first use and cached under extracted/.

Viewer
- Model detail lists what's inside each archive with view/download links;
  opening an entry adds a viewer tab.
- New 3D formats: FBX (with sibling textures), OBJ with its MTL and
  textures, STEP/STP via occt-import-js, and Rhino 3DM via rhino3dm — the
  WASM readers are served from node_modules, loaded on first use.
- Pictures, PDFs and text files (jpg/png/gif/webp/bmp/svg, pdf, txt/md)
  can be uploaded and preview in the viewer; a model without an embedded
  3MF preview uses its first picture as the card thumbnail.
- Inline file responses send nosniff, and SVGs are sandboxed.
- GLB conversions write to a temp file and rename, so a half-written
  preview is never served; a later tab can't be overdrawn by a slow load.
- Better 3MF card image: the project thumbnail or plate 1 render, not a
  slicer pick map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:36:47 +10:00

518 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Printhive</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/favicon.svg">
<meta name="theme-color" content="#15171c">
<link rel="stylesheet" href="styles.css">
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<!-- Auth screen (shown when signed out / first run / password reset) -->
<div id="authScreen" class="auth-screen" style="display:none">
<div class="auth-card">
<h1 class="brand auth-brand">
<svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 2.5 20.5 7v10L12 21.5 3.5 17V7L12 2.5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 7.5 16.3 10v4L12 16.5 7.7 14v-4L12 7.5Z" fill="currentColor" opacity="0.85"/>
</svg>
<span>Print<span class="accent">hive</span></span>
</h1>
<form id="auth-login" class="auth-form">
<h2>Sign in</h2>
<input type="email" id="loginEmail" placeholder="Email" autocomplete="username" required>
<input type="password" id="loginPassword" placeholder="Password" autocomplete="current-password" required>
<button type="submit" class="btn btn-primary">Sign in</button>
<button type="button" class="btn btn-link auth-nav" data-auth="forgot">Forgot password?</button>
</form>
<form id="auth-register" class="auth-form">
<h2>Create your account</h2>
<p class="form-hint">First-time setup — this becomes your login.</p>
<input type="email" id="regEmail" placeholder="Email" autocomplete="username" required>
<input type="password" id="regPassword" placeholder="Password (min 8 characters)" autocomplete="new-password" required>
<button type="submit" class="btn btn-primary">Create account</button>
</form>
<form id="auth-forgot" class="auth-form">
<h2>Reset password</h2>
<p class="form-hint">Enter your email and we'll send a reset link (if email is configured).</p>
<input type="email" id="forgotEmail" placeholder="Email" required>
<button type="submit" class="btn btn-primary">Send reset link</button>
<button type="button" class="btn btn-link auth-nav" data-auth="login">Back to sign in</button>
</form>
<form id="auth-reset" class="auth-form">
<h2>Set a new password</h2>
<input type="password" id="resetPassword" placeholder="New password (min 8 characters)" autocomplete="new-password" required>
<button type="submit" class="btn btn-primary">Set new password</button>
</form>
<div id="authError" class="auth-error"></div>
</div>
</div>
<div class="container" style="display:none">
<header>
<h1 class="brand">
<svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 2.5 20.5 7v10L12 21.5 3.5 17V7L12 2.5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 7.5 16.3 10v4L12 16.5 7.7 14v-4L12 7.5Z" fill="currentColor" opacity="0.85"/>
</svg>
<span>Print<span class="accent">hive</span></span>
</h1>
<div class="header-actions">
<button id="logoutBtn" class="btn btn-secondary">Sign out</button>
<button id="settingsBtn" class="btn btn-secondary">Settings</button>
<button id="newProjectBtn" class="btn btn-secondary">New Collection</button>
<button id="importBtn" class="btn btn-secondary">Import from URL</button>
<button id="uploadBtn" class="btn btn-primary">Upload Files</button>
</div>
</header>
<div class="main-content">
<aside class="sidebar">
<div class="sidebar-section">
<h3>Library</h3>
<div id="libraryList" class="projects-list">
<div class="project-item active" data-scope="all">
<span>All Models</span>
<span class="count" id="allFilesCount">0</span>
</div>
<div class="project-item" data-scope="duplicates">
<span>Duplicates</span>
<span class="count" id="duplicatesCount">0</span>
</div>
<div class="project-item" data-scope="trash">
<span>Trash</span>
<span class="count" id="trashCount">0</span>
</div>
</div>
</div>
<div class="sidebar-section">
<h3>Collections</h3>
<div id="projectsList" class="projects-list"></div>
</div>
<div class="sidebar-section">
<div class="section-head">
<h3>Tags</h3>
<div class="tag-tools">
<button type="button" class="btn-link" id="clearTags" hidden>Clear</button>
<div class="seg" id="tagMatch" title="When several tags are included, match any or all of them">
<button type="button" data-match="any">Any</button>
<button type="button" data-match="all">All</button>
</div>
</div>
</div>
<div id="tagsList" class="tags-list"></div>
<p class="form-hint">Click a tag to include it, again to exclude it.</p>
</div>
</aside>
<main class="content">
<div class="content-header">
<h2 id="contentTitle">All Models</h2>
<div class="content-actions">
<span id="resultCount" class="result-count"></span>
<button type="button" id="emptyTrashBtn" class="btn btn-danger btn-sm" hidden>Empty trash</button>
<div class="view-options">
<button class="view-btn active" data-view="grid">Grid</button>
<button class="view-btn" data-view="list">List</button>
</div>
</div>
</div>
<div id="filterBar" class="filter-bar">
<input type="search" id="searchInput" class="search-input"
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
<select id="filterLicense" aria-label="Filter by license">
<option value="">Any license</option>
<option value="sellable">Sellable</option>
<option value="noncommercial">Non-commercial</option>
<option value="unknown">License unclear</option>
</select>
<select id="sortBy" aria-label="Sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="name-desc">Name Z–A</option>
<option value="size">Largest first</option>
<option value="modified">Recently edited</option>
</select>
<select id="groupBy" aria-label="Group">
<option value="">No grouping</option>
<option value="source">Group by source</option>
<option value="format">Group by format</option>
</select>
</div>
<div id="fileGrid" class="file-grid"></div>
</main>
</div>
</div>
<!-- First-run Setup Modal -->
<div id="setupModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Welcome to Printhive</h2>
</div>
<div class="modal-body">
<p class="form-hint" style="margin-bottom:18px">
Optionally connect import sources now — you can change these any time in Settings.
All are optional; you can also just upload files directly.
</p>
<form id="setupForm">
<div class="form-group">
<label for="setupThingiverse">Thingiverse API token</label>
<input type="password" id="setupThingiverse" data-setting="thingiverse_token" autocomplete="off"
placeholder="App Token from thingiverse.com/developers">
<p class="form-hint">Enables automatic Thingiverse imports.</p>
</div>
<div class="form-group">
<label for="setupFlaresolverr">FlareSolverr URL</label>
<input type="text" id="setupFlaresolverr" data-setting="flaresolverr_url" autocomplete="off"
placeholder="http://192.168.1.124:8191">
<p class="form-hint">Lets imports read Cloudflare-protected sites like MakerWorld.</p>
</div>
<div class="form-group">
<label for="setupMakerworld">MakerWorld auth token</label>
<input type="password" id="setupMakerworld" data-setting="makerworld_token" autocomplete="off"
placeholder="Bearer token from your logged-in session">
</div>
<div class="form-group">
<label for="setupPrintables">Printables auth token</label>
<input type="password" id="setupPrintables" data-setting="printables_token" autocomplete="off"
placeholder="Optional — for account-gated files">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="skipSetup">Skip for now</button>
<button type="submit" class="btn btn-primary">Save &amp; finish</button>
</div>
</form>
<div id="setupStatus" class="import-status"></div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Settings</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="settingsForm">
<h3 class="settings-group-title">Import sources</h3>
<div class="form-group">
<label for="thingiverseToken">Thingiverse API token</label>
<input type="password" id="thingiverseToken" data-setting="thingiverse_token"
placeholder="Paste your App Token" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="thingiverse_token">—</span>.
Get a free token at
<a href="https://www.thingiverse.com/developers" target="_blank" rel="noopener">thingiverse.com/developers</a>
(create an app → copy the App Token). Leave blank to keep the current value; clear it with the button.
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="thingiverse_token">Clear token</button>
</div>
<div class="form-group">
<label for="flaresolverrUrl">FlareSolverr URL</label>
<input type="text" id="flaresolverrUrl" data-setting="flaresolverr_url"
placeholder="http://192.168.1.124:8191" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="flaresolverr_url">—</span>.
Lets imports read Cloudflare-protected sites like MakerWorld by solving the challenge
with a headless browser. Point this at your FlareSolverr instance.
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="flaresolverr_url">Clear</button>
</div>
<div class="form-group">
<label for="makerworldToken">MakerWorld auth token</label>
<input type="password" id="makerworldToken" data-setting="makerworld_token"
placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="makerworld_token">—</span>.
Lets Printhive download files from MakerWorld using your account. Paste the bearer
token from your logged-in browser session (see instructions in chat).
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button>
</div>
<div class="form-group">
<label for="printablesToken">Printables auth token</label>
<input type="password" id="printablesToken" data-setting="printables_token"
placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="printables_token">—</span>.
Lets Printhive download files from Printables using your account. Paste the bearer
token from your logged-in browser session (see instructions in chat).
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="printables_token">Clear token</button>
</div>
<h3 class="settings-group-title">Email (SMTP) — for password resets</h3>
<div class="print-settings">
<input type="text" data-setting="smtp_host" placeholder="SMTP host" autocomplete="off">
<input type="text" data-setting="smtp_port" placeholder="Port (e.g. 587)" autocomplete="off">
<input type="text" data-setting="smtp_secure" placeholder="SSL: true/false" autocomplete="off">
<input type="text" data-setting="smtp_from" placeholder="From address" autocomplete="off">
<input type="text" data-setting="smtp_user" placeholder="Username" autocomplete="off">
<input type="password" data-setting="smtp_pass" placeholder="Password" autocomplete="off">
</div>
<p class="form-hint">Status: <span class="setting-status" data-status="smtp_host">—</span>. Needed for "forgot password" emails.</p>
<h3 class="settings-group-title">License types — which are sellable</h3>
<div id="licenseRules" class="license-rules"></div>
<p class="form-hint">New licenses auto-register as you import. Changing one updates every model with that license.</p>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
<div id="settingsStatus" class="import-status"></div>
</div>
</div>
</div>
<!-- Import from URL Modal -->
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Import from URL</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="importForm">
<div class="form-group">
<label for="importUrl">Model URL</label>
<input type="url" id="importUrl" placeholder="https://www.thingiverse.com/thing:12345" required>
<p class="form-hint" id="importHint"></p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelImport">Cancel</button>
<button type="submit" class="btn btn-primary" id="importSubmit">Import</button>
</div>
</form>
<div id="importStatus" class="import-status"></div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Upload Model Files</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="uploadForm">
<div class="form-group">
<label for="fileInput">Files * <span class="form-hint-inline">(models, archives, pictures, PDFs &amp; readmes — multiple allowed)</span></label>
<input type="file" id="fileInput" accept=".stl,.3mf,.obj,.fbx,.step,.stp,.3dm,.gcode,.gco,.zip,.7z,.rar,.tar,.tgz,.gz,.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg,.pdf,.txt,.md,.mtl" multiple required>
</div>
<div class="form-group">
<label for="fileName">Name *</label>
<input type="text" id="fileName" required>
</div>
<div class="form-group">
<label for="fileDescription">Description</label>
<textarea id="fileDescription" rows="3"></textarea>
</div>
<div class="form-group">
<label for="fileTags">Tags (comma-separated)</label>
<input type="text" id="fileTags" placeholder="miniature, terrain, hero">
</div>
<div class="form-group">
<label for="fileProjects">Collections</label>
<select id="fileProjects" multiple size="4"></select>
</div>
<div class="form-group">
<label>Print Settings</label>
<div class="print-settings">
<input type="text" id="printMaterial" placeholder="Material (e.g., PLA)">
<input type="text" id="printLayerHeight" placeholder="Layer Height (e.g., 0.2mm)">
<input type="text" id="printInfill" placeholder="Infill (e.g., 20%)">
<input type="text" id="printSupports" placeholder="Supports (e.g., Yes/No)">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelUpload">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div>
</div>
</div>
<!-- Collection Modal -->
<div id="projectModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>New Collection</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="projectForm">
<div class="form-group">
<label for="projectName">Collection Name *</label>
<input type="text" id="projectName" required>
</div>
<div class="form-group">
<label for="projectDescription">Description</label>
<textarea id="projectDescription" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelProject">Cancel</button>
<button type="submit" class="btn btn-primary">Create Collection</button>
</div>
</form>
</div>
</div>
</div>
<!-- Model Detail Modal -->
<div id="fileDetailModal" class="modal">
<div class="modal-content large">
<div class="modal-header">
<h2 id="detailFileName"></h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div class="file-detail-content">
<div class="file-detail-preview">
<div id="detailViewer" class="model-viewer"></div>
<div id="detailFileTabs" class="file-tabs"></div>
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
</div>
<div class="file-detail-info">
<div class="info-section">
<h3>Source</h3>
<div id="detailSource"></div>
</div>
<div class="info-section">
<h3>License</h3>
<div id="detailLicense"></div>
<button type="button" class="btn btn-secondary btn-sm" id="refreshLicenseBtn" style="margin-top:10px">Refresh from source</button>
</div>
<div class="info-section">
<h3>Description</h3>
<p id="detailDescription"></p>
</div>
<div class="info-section">
<h3>Notes <span id="notesStatus" class="notes-status"></span></h3>
<textarea id="detailNotes" rows="3" placeholder="Your notes — settings that worked, tweaks, who it's for…"></textarea>
</div>
<div class="info-section">
<h3>Tags</h3>
<div id="detailTags" class="tags"></div>
</div>
<div class="info-section">
<h3>Collections</h3>
<div id="detailProjects"></div>
</div>
<div class="info-section">
<h3>Print Settings</h3>
<div id="detailPrintSettings"></div>
</div>
<div class="info-section">
<h3>Files</h3>
<div id="detailFileInfo"></div>
<button type="button" class="btn btn-secondary btn-sm" id="downloadFilesBtn" style="margin-top:10px; display:none"></button>
</div>
<input type="file" id="addFilesInput" multiple style="display:none"
accept=".stl,.3mf,.obj,.fbx,.step,.stp,.3dm,.gcode,.gco,.zip,.7z,.rar,.tar,.tgz,.gz,.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg,.pdf,.txt,.md,.mtl">
<div class="form-actions" id="detailActions">
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
<button class="btn btn-primary" id="editModelBtn">Edit</button>
</div>
<div class="form-actions" id="trashActions" hidden>
<button class="btn btn-danger" id="purgeModelBtn">Delete forever</button>
<button class="btn btn-primary" id="restoreModelBtn">Restore</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Model Modal -->
<div id="editModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Edit model</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="editForm">
<div class="form-group">
<label for="editName">Name *</label>
<input type="text" id="editName" required>
</div>
<div class="form-group">
<label for="editDescription">Description</label>
<textarea id="editDescription" rows="3"></textarea>
</div>
<div class="form-group">
<label for="editTags">Tags (comma-separated)</label>
<input type="text" id="editTags" placeholder="miniature, terrain, hero">
</div>
<div class="form-group">
<label for="editProjects">Collections</label>
<select id="editProjects" multiple size="4"></select>
</div>
<div class="form-group">
<label for="editLicense">License</label>
<input type="text" id="editLicense">
<p class="form-hint">Sellable/non-commercial is set per license in Settings → License types.</p>
</div>
<div class="form-group">
<label>Print Settings</label>
<div class="print-settings">
<input type="text" id="editMaterial" placeholder="Material (e.g., PLA)">
<input type="text" id="editLayerHeight" placeholder="Layer Height (e.g., 0.2mm)">
<input type="text" id="editInfill" placeholder="Infill (e.g., 20%)">
<input type="text" id="editSupports" placeholder="Supports (e.g., Yes/No)">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelEdit">Cancel</button>
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
</div>
</div>
</div>
<script type="module" src="viewer.js"></script>
<script src="app.js"></script>
</body>
</html>