Initial commit: Printhive 3D-print file library
Self-hosted Express + Postgres app for organizing STL/3MF/OBJ/etc. with: - Model/file library, tags, collections, print settings - URL import: Thingiverse (API), Printables (GraphQL), MakerWorld (FlareSolverr + token), cubee3d/other (metadata + manual files) - In-browser 3D viewer; 3MF rendered via Python/trimesh GLB conversion - Auto thumbnails, license/commercial-use tracking, in-app editing - Settings (API tokens / FlareSolverr) stored in DB with .env fallback Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
a37db15a3f
22 changed files with 4673 additions and 0 deletions
6
.env.example
Normal file
6
.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Copy to .env and fill in.
|
||||||
|
DATABASE_URL=postgres://USER@localhost:5432/stl_library
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Thingiverse import — get a free app token at https://www.thingiverse.com/developers
|
||||||
|
THINGIVERSE_TOKEN=
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
thumbnails/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.env
|
||||||
|
glb/
|
||||||
|
tools/venv/
|
||||||
57
README.md
Normal file
57
README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Printhive
|
||||||
|
|
||||||
|
A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP, GCODE, ZIP) into a searchable library — with in-browser 3D previews, collections, license/commercial-use tracking, and one-paste **import from popular model sites**.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Library** of models, each owning one or more files; tags, collections, descriptions, print settings.
|
||||||
|
- **Import from URL**:
|
||||||
|
- **Thingiverse** — downloads files automatically via the official API (needs a free app token).
|
||||||
|
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
|
||||||
|
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
|
||||||
|
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
|
||||||
|
- **In-browser 3D viewer** (Three.js) for STL/OBJ, and for 3MF via a server-side **trimesh GLB conversion** (handles Bambu/MakerWorld production-extension files the JS loader can't).
|
||||||
|
- **Auto thumbnails** rendered from the model; embedded 3MF thumbnails used when present.
|
||||||
|
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
|
||||||
|
- **Collections** — group models (a model can be in many); rename/delete without losing models.
|
||||||
|
- **Editing** — rename/retag, edit print settings, override commercial-use, refresh metadata from source, re-download files, download all files as a zip.
|
||||||
|
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
|
||||||
|
- Three.js (viewer) via CDN import map.
|
||||||
|
- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion and volume.
|
||||||
|
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
|
||||||
|
2. **Python sidecar** (for 3MF previews/volume):
|
||||||
|
```bash
|
||||||
|
python3 -m venv tools/venv
|
||||||
|
tools/venv/bin/pip install trimesh numpy networkx lxml
|
||||||
|
```
|
||||||
|
3. **Install deps and run**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env # set DATABASE_URL (+ optional tokens)
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
4. Open http://localhost:3000
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables:
|
||||||
|
|
||||||
|
| Setting | Env var | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Thingiverse API token | `THINGIVERSE_TOKEN` | Thingiverse auto-download |
|
||||||
|
| FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) |
|
||||||
|
| MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) |
|
||||||
|
| Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Auto-download from MakerWorld/Printables relies on their private/session APIs and can break if they change; failed/partial imports can be recovered with "Download files from source".
|
||||||
|
- The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling.
|
||||||
84
db/schema.sql
Normal file
84
db/schema.sql
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
-- STL Library Manager schema
|
||||||
|
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a
|
||||||
|
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
|
||||||
|
|
||||||
|
-- Simple key/value app settings (API tokens, site auth). Editable from the UI.
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS designers (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source_site TEXT, -- thingiverse | printables | makerworld | ...
|
||||||
|
profile_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (name, source_site)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
date_created TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
date_modified TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
source_site TEXT, -- where it came from, null for manual upload
|
||||||
|
source_url TEXT, -- original page URL (for attribution)
|
||||||
|
source_id TEXT, -- the site's id for the thing/model
|
||||||
|
license TEXT,
|
||||||
|
designer_id INTEGER REFERENCES designers(id) ON DELETE SET NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
print_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
date_modified TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
file_name TEXT NOT NULL, -- on-disk name (unique suffix)
|
||||||
|
original_name TEXT NOT NULL, -- name as downloaded/uploaded
|
||||||
|
file_path TEXT NOT NULL, -- path on disk
|
||||||
|
format TEXT NOT NULL, -- stl | 3mf | obj | step | gcode | zip | ...
|
||||||
|
size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
volume DOUBLE PRECISION, -- cm^3 (from node-stl), when computable
|
||||||
|
bbox JSONB, -- {x,y,z} bounding box
|
||||||
|
facets INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS model_projects (
|
||||||
|
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (model_id, project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS model_tags (
|
||||||
|
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (model_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cached GLB conversion of a 3MF, for in-browser 3D preview.
|
||||||
|
ALTER TABLE files ADD COLUMN IF NOT EXISTS glb_path TEXT;
|
||||||
|
|
||||||
|
-- Whether the model's license permits commercial use (selling). true/false/null.
|
||||||
|
ALTER TABLE models ADD COLUMN IF NOT EXISTS commercial_use BOOLEAN;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);
|
||||||
32
lib/converter.js
Normal file
32
lib/converter.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
|
||||||
|
// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't).
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
|
||||||
|
const PYTHON = path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
|
||||||
|
const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
|
||||||
|
|
||||||
|
// Returns { glb, volume_cm3, bbox, faces } or throws.
|
||||||
|
function convert3mfToGlb(inputPath, outputPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
PYTHON,
|
||||||
|
[SCRIPT, inputPath, outputPath],
|
||||||
|
{ maxBuffer: 16 * 1024 * 1024, timeout: 120000 },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) return reject(new Error(stderr ? stderr.split('\n').slice(-3).join(' ') : err.message));
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(stdout.trim().split('\n').pop());
|
||||||
|
} catch {
|
||||||
|
return reject(new Error('Converter returned unparseable output.'));
|
||||||
|
}
|
||||||
|
if (result.error) return reject(new Error(result.error));
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { convert3mfToGlb };
|
||||||
39
lib/db.js
Normal file
39
lib/db.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('Unexpected Postgres pool error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply schema.sql on startup (idempotent — uses CREATE TABLE IF NOT EXISTS).
|
||||||
|
async function init() {
|
||||||
|
const schema = fs.readFileSync(path.join(__dirname, '..', 'db', 'schema.sql'), 'utf8');
|
||||||
|
await pool.query(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(text, params) {
|
||||||
|
return pool.query(text, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a function inside a transaction, passing it a dedicated client.
|
||||||
|
async function transaction(fn) {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await fn(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { pool, query, transaction, init };
|
||||||
60
lib/fileProcessor.js
Normal file
60
lib/fileProcessor.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const NodeStl = require('node-stl');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
|
const THUMBNAIL_DIR = './thumbnails';
|
||||||
|
|
||||||
|
const MODEL_FORMATS = ['stl', '3mf', 'obj', 'step', 'stp', 'gcode', 'gco', 'zip'];
|
||||||
|
|
||||||
|
function ensureThumbnailDir() {
|
||||||
|
if (!fs.existsSync(THUMBNAIL_DIR)) fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOf(filename) {
|
||||||
|
return path.extname(filename).slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowed(filename) {
|
||||||
|
return MODEL_FORMATS.includes(formatOf(filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geometry metadata. Only STL is computed natively today; others return nulls
|
||||||
|
// (3MF/OBJ/STEP volume can be added later via a Python/trimesh sidecar).
|
||||||
|
function extractMetadata(filePath, format) {
|
||||||
|
if (format !== 'stl') return { volume: null, bbox: null, facets: null };
|
||||||
|
try {
|
||||||
|
const stl = new NodeStl(filePath);
|
||||||
|
const bb = stl.boundingBox; // [x, y, z]
|
||||||
|
return {
|
||||||
|
volume: Number.isFinite(stl.volume) ? stl.volume : null,
|
||||||
|
bbox: Array.isArray(bb) ? { x: bb[0], y: bb[1], z: bb[2] } : null,
|
||||||
|
facets: null, // node-stl does not expose facet count
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('STL metadata extraction failed:', err.message);
|
||||||
|
return { volume: null, bbox: null, facets: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A .3mf is a zip; slicers embed a PNG preview under /Metadata/. If present,
|
||||||
|
// pull it out as the model thumbnail. Returns a path under thumbnails/ or null.
|
||||||
|
function extract3mfThumbnail(filePath, baseName) {
|
||||||
|
try {
|
||||||
|
ensureThumbnailDir();
|
||||||
|
const zip = new AdmZip(filePath);
|
||||||
|
const entry = zip.getEntries().find((e) => {
|
||||||
|
const n = e.entryName.toLowerCase();
|
||||||
|
return n.endsWith('.png') && (n.includes('thumbnail') || n.includes('/metadata/'));
|
||||||
|
});
|
||||||
|
if (!entry) return null;
|
||||||
|
const out = path.join(THUMBNAIL_DIR, `${baseName}.png`);
|
||||||
|
fs.writeFileSync(out, entry.getData());
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('3MF thumbnail extraction failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MODEL_FORMATS, formatOf, isAllowed, extractMetadata, extract3mfThumbnail };
|
||||||
55
lib/flaresolverr.js
Normal file
55
lib/flaresolverr.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Minimal FlareSolverr client. FlareSolverr runs a headless browser that solves
|
||||||
|
// Cloudflare's anti-bot challenge and returns the resolved page HTML.
|
||||||
|
// https://github.com/FlareSolverr/FlareSolverr
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// opts: { maxTimeout, cookies: [{ name, value, domain }], retries }
|
||||||
|
async function solve(flaresolverrUrl, targetUrl, opts = {}) {
|
||||||
|
const endpoint = flaresolverrUrl.replace(/\/+$/, '') + '/v1';
|
||||||
|
const body = { cmd: 'request.get', url: targetUrl, maxTimeout: opts.maxTimeout || 60000 };
|
||||||
|
if (opts.cookies) body.cookies = opts.cookies;
|
||||||
|
|
||||||
|
// FlareSolverr serializes browser sessions and returns 500 on back-to-back
|
||||||
|
// requests, so retry transient failures with a growing backoff.
|
||||||
|
const retries = opts.retries ?? 4;
|
||||||
|
let lastErr;
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
if (attempt > 0) await sleep(2000 * attempt);
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (res.status >= 500) { lastErr = new Error(`FlareSolverr HTTP ${res.status}`); continue; }
|
||||||
|
if (!res.ok) throw new Error(`FlareSolverr HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status !== 'ok' || !data.solution) throw new Error(`FlareSolverr: ${data.message || 'no solution'}`);
|
||||||
|
return data.solution.response;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network/i.test(err.message)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like solve(), but for endpoints that return JSON. A real browser wraps JSON
|
||||||
|
// in a <pre> tag, so strip tags before parsing.
|
||||||
|
async function solveJson(flaresolverrUrl, targetUrl, opts = {}) {
|
||||||
|
const html = await solve(flaresolverrUrl, targetUrl, opts);
|
||||||
|
// The browser wraps JSON in <pre> and HTML-encodes entities (notably & -> &
|
||||||
|
// inside signed URLs), so strip tags and decode before parsing.
|
||||||
|
const text = html.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/&/g, '&')
|
||||||
|
.trim();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error('FlareSolverr did not return parseable JSON.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { solve, solveJson };
|
||||||
78
lib/importers/generic.js
Normal file
78
lib/importers/generic.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// Generic page-metadata importer for sites without a usable download API
|
||||||
|
// (Printables, MakerWorld, or any URL). Reads Open Graph / <title> tags so we
|
||||||
|
// can create a model with name, description, and a preview image; the actual
|
||||||
|
// model files are attached by the user afterwards.
|
||||||
|
|
||||||
|
function detectSite(url) {
|
||||||
|
let host;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!/^https?:$/.test(parsed.protocol)) return null;
|
||||||
|
host = parsed.hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (host.includes('thingiverse.com')) return { site: 'thingiverse' };
|
||||||
|
if (host.includes('printables.com')) return { site: 'printables', id: (url.match(/\/model\/(\d+)/) || [])[1] || null };
|
||||||
|
if (host.includes('makerworld.com')) return { site: 'makerworld', id: (url.match(/\/models\/(\d+)/) || [])[1] || null };
|
||||||
|
if (host.includes('cubee3d.com')) {
|
||||||
|
let slug = null;
|
||||||
|
try { slug = new URL(url).pathname.split('/').filter(Boolean).pop() || null; } catch { /* ignore */ }
|
||||||
|
return { site: 'cubee3d', id: slug };
|
||||||
|
}
|
||||||
|
return { site: 'web', id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaContent(html, attr, value) {
|
||||||
|
// Matches <meta {attr}="{value}" ... content="..."> in either attribute order.
|
||||||
|
const a = html.match(new RegExp(`<meta[^>]+${attr}=["']${value}["'][^>]+content=["']([^"']*)["']`, 'i'));
|
||||||
|
if (a) return decodeEntities(a[1]);
|
||||||
|
const b = html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${value}["']`, 'i'));
|
||||||
|
return b ? decodeEntities(b[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36';
|
||||||
|
|
||||||
|
// A page is a Cloudflare interstitial (not the real content) when it looks like this.
|
||||||
|
function isCloudflareChallenge(html) {
|
||||||
|
return /just a moment|cf-browser-verification|challenge-platform|cf_chl_opt/i.test(html || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHtml(url) {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': BROWSER_UA, Accept: 'text/html' } });
|
||||||
|
const html = await res.text();
|
||||||
|
if (!res.ok && !html) throw new Error(`Could not fetch page (HTTP ${res.status}).`);
|
||||||
|
return { ok: res.ok, status: res.status, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Open Graph / title metadata from raw HTML.
|
||||||
|
function parseMetadata(html, url) {
|
||||||
|
const titleTag = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1];
|
||||||
|
return {
|
||||||
|
title: metaContent(html, 'property', 'og:title') || (titleTag ? decodeEntities(titleTag).trim() : null),
|
||||||
|
description: metaContent(html, 'property', 'og:description') || metaContent(html, 'name', 'description'),
|
||||||
|
image: metaContent(html, 'property', 'og:image'),
|
||||||
|
author: metaContent(html, 'name', 'author') || metaContent(html, 'property', 'og:site_name'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort human name from a URL slug, e.g.
|
||||||
|
// /models/98765-cool-widget -> "Cool widget". Used when a page can't be read.
|
||||||
|
function nameFromUrl(url) {
|
||||||
|
try {
|
||||||
|
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
|
||||||
|
const words = decodeURIComponent(seg).replace(/^\d+-?/, '').replace(/[-_]+/g, ' ').trim();
|
||||||
|
if (!words) return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectSite, fetchHtml, parseMetadata, isCloudflareChallenge, nameFromUrl };
|
||||||
44
lib/importers/makerworld.js
Normal file
44
lib/importers/makerworld.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// MakerWorld file downloader. MakerWorld is behind Cloudflare and its downloads
|
||||||
|
// are auth-gated, so we go through FlareSolverr (solves Cloudflare) carrying the
|
||||||
|
// user's `token` cookie (authenticates). The download API returns a short-lived
|
||||||
|
// signed CDN URL, which we then fetch directly.
|
||||||
|
|
||||||
|
const flaresolverr = require('../flaresolverr');
|
||||||
|
|
||||||
|
// A model page embeds its printable "instances" in __NEXT_DATA__. Each instance
|
||||||
|
// id is what the download API takes.
|
||||||
|
function parseInstances(html) {
|
||||||
|
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
||||||
|
if (!m) return [];
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(m[1]); } catch { return []; }
|
||||||
|
|
||||||
|
let result = [];
|
||||||
|
(function walk(node) {
|
||||||
|
if (result.length || !node || typeof node !== 'object') return;
|
||||||
|
if (Array.isArray(node.instances) && node.instances.length && typeof node.instances[0].id === 'number') {
|
||||||
|
result = node.instances
|
||||||
|
.filter((x) => x && typeof x.id === 'number')
|
||||||
|
.map((x) => ({ id: x.id, title: x.title || '' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(node)) { walk(node[key]); if (result.length) return; }
|
||||||
|
})(data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve an instance to { name, url } (a signed, short-lived CDN download URL).
|
||||||
|
async function resolveDownload(flaresolverrUrl, instanceId, token) {
|
||||||
|
const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`;
|
||||||
|
return flaresolverr.solveJson(flaresolverrUrl, api, {
|
||||||
|
cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
|
||||||
|
function parseLicense(html) {
|
||||||
|
const m = html.match(/"license"\s*:\s*"([^"]+)"/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseInstances, resolveDownload, parseLicense };
|
||||||
67
lib/importers/printables.js
Normal file
67
lib/importers/printables.js
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Printables importer. Printables exposes a public GraphQL API at
|
||||||
|
// api.printables.com/graphql — the file list and per-file download links are
|
||||||
|
// available anonymously for free models. A bearer token (optional) is sent when
|
||||||
|
// configured, for anything that needs an account.
|
||||||
|
|
||||||
|
const ENDPOINT = 'https://api.printables.com/graphql/';
|
||||||
|
|
||||||
|
async function gql(query, variables, token) {
|
||||||
|
const headers = { 'Content-Type': 'application/json', Origin: 'https://www.printables.com' };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query, variables }) });
|
||||||
|
if (!res.ok) throw new Error(`Printables GraphQL HTTP ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.errors) throw new Error('Printables GraphQL: ' + json.errors.map((e) => e.message).join('; '));
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRINT_QUERY = `query($id: ID!) {
|
||||||
|
print(id: $id) {
|
||||||
|
id name
|
||||||
|
license { name abbreviation content }
|
||||||
|
stls { id name fileSize }
|
||||||
|
slas { id name fileSize }
|
||||||
|
otherFiles { id name fileSize }
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Returns { name, license, licenseContent, files: [{ id, name, fileType }] }.
|
||||||
|
// (Commercial use is decided by the caller from licenseContent — the on-page
|
||||||
|
// excludeCommercialUsage flag is unreliable, e.g. false for the strictly
|
||||||
|
// non-commercial "Standard Digital File License".)
|
||||||
|
async function fetchModel(printId, token) {
|
||||||
|
const data = await gql(PRINT_QUERY, { id: String(printId) }, token);
|
||||||
|
const p = data.print;
|
||||||
|
if (!p) throw new Error('Print not found');
|
||||||
|
const files = [];
|
||||||
|
(p.stls || []).forEach((f) => files.push({ id: f.id, name: f.name, fileType: 'stl' }));
|
||||||
|
(p.slas || []).forEach((f) => files.push({ id: f.id, name: f.name, fileType: 'sla' }));
|
||||||
|
(p.otherFiles || []).forEach((f) => files.push({ id: f.id, name: f.name, fileType: 'other' }));
|
||||||
|
return {
|
||||||
|
name: p.name,
|
||||||
|
license: p.license ? p.license.name : null,
|
||||||
|
licenseContent: p.license ? p.license.content : null,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOWNLOAD_MUTATION = `mutation($printId: ID!, $files: [DownloadFileInput], $src: DownloadSourceEnum!) {
|
||||||
|
getDownloadLink(printId: $printId, files: $files, source: $src) {
|
||||||
|
ok
|
||||||
|
output { link }
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Resolve one file to a direct CDN download URL.
|
||||||
|
async function getDownloadLink(printId, fileType, fileId, token) {
|
||||||
|
const data = await gql(
|
||||||
|
DOWNLOAD_MUTATION,
|
||||||
|
{ printId: String(printId), files: [{ fileType, ids: [String(fileId)] }], src: 'model_detail' },
|
||||||
|
token
|
||||||
|
);
|
||||||
|
const r = data.getDownloadLink;
|
||||||
|
if (!r || !r.ok || !r.output || !r.output.link) throw new Error('No download link returned');
|
||||||
|
return r.output.link;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fetchModel, getDownloadLink };
|
||||||
55
lib/importers/thingiverse.js
Normal file
55
lib/importers/thingiverse.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Thingiverse importer — uses the official REST API (https://www.thingiverse.com/developers).
|
||||||
|
// Needs a free app token in THINGIVERSE_TOKEN.
|
||||||
|
|
||||||
|
const API = 'https://api.thingiverse.com';
|
||||||
|
|
||||||
|
// Accepts .../thing:12345, .../thing:12345/files, or a bare id. Returns id or null.
|
||||||
|
function parse(url) {
|
||||||
|
if (/^\d+$/.test(url.trim())) return url.trim();
|
||||||
|
const m = String(url).match(/thing:(\d+)/i);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(pathname, token) {
|
||||||
|
const res = await fetch(`${API}${pathname}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Thingiverse API ${res.status} for ${pathname}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a normalized model description + the list of files to download.
|
||||||
|
async function fetchModel(thingId, token) {
|
||||||
|
const [thing, files, tags] = await Promise.all([
|
||||||
|
api(`/things/${thingId}`, token),
|
||||||
|
api(`/things/${thingId}/files`, token),
|
||||||
|
api(`/things/${thingId}/tags`, token).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const creator = thing.creator || {};
|
||||||
|
return {
|
||||||
|
source_site: 'thingiverse',
|
||||||
|
source_id: String(thingId),
|
||||||
|
source_url: thing.public_url || `https://www.thingiverse.com/thing:${thingId}`,
|
||||||
|
name: thing.name || `Thing ${thingId}`,
|
||||||
|
description: stripHtml(thing.description || ''),
|
||||||
|
license: thing.license || null,
|
||||||
|
designer: creator.name
|
||||||
|
? { name: creator.name, source_site: 'thingiverse', profile_url: creator.public_url }
|
||||||
|
: null,
|
||||||
|
tags: (tags || []).map((t) => t.name).filter(Boolean),
|
||||||
|
downloads: (files || []).map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
// download_url is an API endpoint that 302s to the real file; needs the token.
|
||||||
|
url: `${f.download_url}?access_token=${encodeURIComponent(token)}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html) {
|
||||||
|
return String(html).replace(/<[^>]*>/g, '').replace(/\s+\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parse, fetchModel };
|
||||||
21
lib/licenses.js
Normal file
21
lib/licenses.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Best-effort guess at whether a license permits commercial use (selling prints),
|
||||||
|
// from its name/abbreviation or — preferably — its full license text. Returns
|
||||||
|
// true / false / null (unknown). This is a hint only; the UI tells the user to
|
||||||
|
// verify before selling, and it deliberately leans toward NOT sellable when
|
||||||
|
// ambiguous (the safer error for "can I sell this?").
|
||||||
|
function inferCommercial(text) {
|
||||||
|
if (!text) return null;
|
||||||
|
const s = String(text).replace(/<[^>]*>/g, ' ').toLowerCase();
|
||||||
|
|
||||||
|
// Restrictive / non-commercial — checked first so it wins over stray "sell"s.
|
||||||
|
if (/non-?commercial|personal use|community use|[-_\s]nc([-_\s]|$)|exclusive|standard digital file|all rights reserved|^\s*none\s*$|charge money|shall not[^.]*\bsell|not be sold/.test(s)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Clearly permissive (commercial use OK). NC variants are already excluded above.
|
||||||
|
if (/cc0|public ?domain|creative commons|attribution|cc[-_\s]?by|^\s*mit\b|^\s*bsd\b|^\s*l?gpl\b|royalty[- ]free|commercial use (is )?(allowed|permitted)|may be sold/.test(s)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { inferCommercial };
|
||||||
244
lib/store.js
Normal file
244
lib/store.js
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
// ---- shared SELECT that hydrates a model with tags, projects, files, designer ----
|
||||||
|
const MODEL_SELECT = `
|
||||||
|
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
||||||
|
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
|
||||||
|
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
||||||
|
COALESCE(t.tags, '[]'::json) AS tags,
|
||||||
|
COALESCE(p.projects, '[]'::json) AS projects,
|
||||||
|
COALESCE(f.files, '[]'::json) AS files
|
||||||
|
FROM models m
|
||||||
|
LEFT JOIN designers d ON d.id = m.designer_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(tg.name ORDER BY tg.name) AS tags
|
||||||
|
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
|
||||||
|
WHERE mt.model_id = m.id
|
||||||
|
) t ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(json_build_object('id', pr.id, 'name', pr.name) ORDER BY pr.name) AS projects
|
||||||
|
FROM model_projects mp JOIN projects pr ON pr.id = mp.project_id
|
||||||
|
WHERE mp.model_id = m.id
|
||||||
|
) p ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(json_build_object(
|
||||||
|
'id', fl.id, 'originalName', fl.original_name, 'fileName', fl.file_name,
|
||||||
|
'format', fl.format, 'size', fl.size, 'volume', fl.volume,
|
||||||
|
'bbox', fl.bbox, 'facets', fl.facets) ORDER BY fl.id) AS files
|
||||||
|
FROM files fl WHERE fl.model_id = m.id
|
||||||
|
) f ON true
|
||||||
|
`;
|
||||||
|
|
||||||
|
function shapeModel(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const { designer_id, designer_name, designer_url, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
designer: designer_id ? { id: designer_id, name: designer_name, url: designer_url } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- models
|
||||||
|
async function getAllModels() {
|
||||||
|
const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
|
||||||
|
return rows.map(shapeModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getModel(id) {
|
||||||
|
const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = $1`, [id]);
|
||||||
|
return shapeModel(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findModelBySource(site, sourceId) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2`, [site, sourceId]);
|
||||||
|
return shapeModel(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// data = { name, description, source_site, source_url, source_id, license,
|
||||||
|
// designer:{name,source_site,profile_url}, thumbnail_path, print_settings,
|
||||||
|
// tags:[string], projects:[id], files:[{...}] }
|
||||||
|
async function createModel(data) {
|
||||||
|
return db.transaction(async (client) => {
|
||||||
|
let designerId = null;
|
||||||
|
if (data.designer && data.designer.name) {
|
||||||
|
designerId = await upsertDesigner(client, data.designer);
|
||||||
|
}
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`INSERT INTO models (name, description, source_site, source_url, source_id,
|
||||||
|
license, commercial_use, designer_id, thumbnail_path, print_settings)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
|
||||||
|
[data.name, data.description || '', data.source_site || null, data.source_url || null,
|
||||||
|
data.source_id || null, data.license || null, data.commercial_use ?? null, designerId,
|
||||||
|
data.thumbnail_path || null, data.print_settings || {}]
|
||||||
|
);
|
||||||
|
const modelId = rows[0].id;
|
||||||
|
await setTags(client, modelId, data.tags || []);
|
||||||
|
await setProjects(client, modelId, data.projects || []);
|
||||||
|
for (const file of data.files || []) {
|
||||||
|
await insertFile(client, modelId, file);
|
||||||
|
}
|
||||||
|
return modelId;
|
||||||
|
}).then(getModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateModel(id, updates) {
|
||||||
|
await db.transaction(async (client) => {
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
let i = 1;
|
||||||
|
for (const col of ['name', 'description', 'license', 'commercial_use', 'thumbnail_path']) {
|
||||||
|
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
||||||
|
}
|
||||||
|
if (updates.print_settings !== undefined) {
|
||||||
|
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
|
||||||
|
}
|
||||||
|
if (fields.length) {
|
||||||
|
fields.push(`date_modified = now()`);
|
||||||
|
values.push(id);
|
||||||
|
await client.query(`UPDATE models SET ${fields.join(', ')} WHERE id = $${i}`, values);
|
||||||
|
}
|
||||||
|
if (updates.tags !== undefined) await setTags(client, id, updates.tags);
|
||||||
|
if (updates.projects !== undefined) await setProjects(client, id, updates.projects);
|
||||||
|
});
|
||||||
|
return getModel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the on-disk file rows so the caller can unlink them.
|
||||||
|
async function deleteModel(id) {
|
||||||
|
const { rows } = await db.query('SELECT file_path, glb_path FROM files WHERE model_id = $1', [id]);
|
||||||
|
const model = await db.query('SELECT thumbnail_path FROM models WHERE id = $1', [id]);
|
||||||
|
const result = await db.query('DELETE FROM models WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) return null;
|
||||||
|
return {
|
||||||
|
filePaths: rows.map((r) => r.file_path),
|
||||||
|
glbPaths: rows.map((r) => r.glb_path).filter(Boolean),
|
||||||
|
thumbnailPath: model.rows[0] && model.rows[0].thumbnail_path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFiles(modelId, files) {
|
||||||
|
await db.transaction(async (client) => {
|
||||||
|
for (const file of files) await insertFile(client, modelId, file);
|
||||||
|
});
|
||||||
|
return getModel(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a 3MF's GLB conversion + computed geometry on the file row.
|
||||||
|
async function updateFileConversion(id, { glb_path, volume, bbox }) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE files SET glb_path = $2,
|
||||||
|
volume = COALESCE($3, volume),
|
||||||
|
bbox = COALESCE($4, bbox)
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, glb_path, volume ?? null, bbox ?? null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertFile(client, modelId, file) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO files (model_id, file_name, original_name, file_path, format, size, volume, bbox, facets)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||||
|
[modelId, file.fileName, file.originalName, file.filePath, file.format,
|
||||||
|
file.size || 0, file.volume ?? null, file.bbox ?? null, file.facets ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- tags
|
||||||
|
async function setTags(client, modelId, tagNames) {
|
||||||
|
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
|
||||||
|
for (const raw of tagNames) {
|
||||||
|
const name = String(raw).trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`INSERT INTO tags (name) VALUES ($1)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]);
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[modelId, rows[0].id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllTags() {
|
||||||
|
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
|
||||||
|
return rows.map((r) => r.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- projects
|
||||||
|
async function setProjects(client, modelId, projectIds) {
|
||||||
|
await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]);
|
||||||
|
for (const pid of projectIds) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[modelId, pid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllProjects() {
|
||||||
|
const { rows } = await db.query(`
|
||||||
|
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
|
||||||
|
COUNT(mp.model_id)::int AS model_count
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN model_projects mp ON mp.project_id = p.id
|
||||||
|
GROUP BY p.id ORDER BY p.name`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProject(id) {
|
||||||
|
const { rows } = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject({ name, description }) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
'INSERT INTO projects (name, description) VALUES ($1,$2) RETURNING *',
|
||||||
|
[name, description || '']);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProject(id, { name, description }) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`UPDATE projects SET name = COALESCE($2, name),
|
||||||
|
description = COALESCE($3, description), date_modified = now()
|
||||||
|
WHERE id = $1 RETURNING *`, [id, name ?? null, description ?? null]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(id) {
|
||||||
|
const { rowCount } = await db.query('DELETE FROM projects WHERE id = $1', [id]);
|
||||||
|
return rowCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- settings
|
||||||
|
async function getSetting(key) {
|
||||||
|
const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]);
|
||||||
|
return rows.length ? rows[0].value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// value of '' or null clears the setting.
|
||||||
|
async function setSetting(key, value) {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
await db.query('DELETE FROM settings WHERE key = $1', [key]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||||
|
[key, value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- designers
|
||||||
|
async function upsertDesigner(client, { name, source_site, profile_url }) {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`INSERT INTO designers (name, source_site, profile_url) VALUES ($1,$2,$3)
|
||||||
|
ON CONFLICT (name, source_site) DO UPDATE SET profile_url = EXCLUDED.profile_url
|
||||||
|
RETURNING id`, [name, source_site || null, profile_url || null]);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
||||||
|
updateFileConversion,
|
||||||
|
getAllTags,
|
||||||
|
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
||||||
|
getSetting, setSetting,
|
||||||
|
};
|
||||||
1544
package-lock.json
generated
Normal file
1544
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "printhive",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Printhive — a web app for organizing 3D-printing files (STL, 3MF, OBJ, ...) with URL import",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"stl",
|
||||||
|
"3d-printing",
|
||||||
|
"library",
|
||||||
|
"manager"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.17",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-stl": "^0.7.0",
|
||||||
|
"pg": "^8.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
778
public/app.js
Normal file
778
public/app.js
Normal file
|
|
@ -0,0 +1,778 @@
|
||||||
|
// State
|
||||||
|
let models = [];
|
||||||
|
let projects = [];
|
||||||
|
let config = { importers: {} };
|
||||||
|
let selectedProject = 'all';
|
||||||
|
let selectedTags = [];
|
||||||
|
let currentView = 'grid';
|
||||||
|
|
||||||
|
// DOM
|
||||||
|
const fileGrid = document.getElementById('fileGrid');
|
||||||
|
const projectsList = document.getElementById('projectsList');
|
||||||
|
const tagsList = document.getElementById('tagsList');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const uploadModal = document.getElementById('uploadModal');
|
||||||
|
const projectModal = document.getElementById('projectModal');
|
||||||
|
const importModal = document.getElementById('importModal');
|
||||||
|
const settingsModal = document.getElementById('settingsModal');
|
||||||
|
const editModal = document.getElementById('editModal');
|
||||||
|
const fileDetailModal = document.getElementById('fileDetailModal');
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadData();
|
||||||
|
setupEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const [modelsRes, projectsRes, configRes] = await Promise.all([
|
||||||
|
fetch('/api/models'),
|
||||||
|
fetch('/api/projects'),
|
||||||
|
fetch('/api/config'),
|
||||||
|
]);
|
||||||
|
models = await modelsRes.json();
|
||||||
|
projects = await projectsRes.json();
|
||||||
|
config = await configRes.json();
|
||||||
|
|
||||||
|
renderProjects();
|
||||||
|
renderTags();
|
||||||
|
renderModels();
|
||||||
|
updateCounts();
|
||||||
|
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render thumbnails in the browser for models that don't have one yet, then
|
||||||
|
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
|
||||||
|
const thumbAttempted = new Set();
|
||||||
|
async function generateMissingThumbnails() {
|
||||||
|
if (!window.ModelViewer) return;
|
||||||
|
for (const m of models) {
|
||||||
|
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
|
||||||
|
const file = (m.files || []).find((f) => window.ModelViewer.RENDERABLE.includes(f.format));
|
||||||
|
if (!file) continue;
|
||||||
|
thumbAttempted.add(m.id);
|
||||||
|
try {
|
||||||
|
const src = viewerSourceFor(file.id, file.format);
|
||||||
|
const image = await window.ModelViewer.snapshot(src.url, src.format, 512);
|
||||||
|
if (!image) continue;
|
||||||
|
const res = await fetch(`/api/models/${m.id}/thumbnail`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image }),
|
||||||
|
});
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const updated = await res.json();
|
||||||
|
m.thumbnailUrl = updated.thumbnailUrl;
|
||||||
|
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`);
|
||||||
|
if (thumb && updated.thumbnailUrl) {
|
||||||
|
thumb.innerHTML = `<img src="${updated.thumbnailUrl}" alt="" loading="lazy">`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Thumbnail generation failed for model', m.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- rendering
|
||||||
|
function renderProjects() {
|
||||||
|
const items = projects.map((p) => `
|
||||||
|
<div class="project-item${String(selectedProject) === String(p.id) ? ' active' : ''}" data-project-id="${p.id}">
|
||||||
|
<span class="project-name">${escapeHtml(p.name)}</span>
|
||||||
|
<span class="project-meta">
|
||||||
|
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">✎</button>
|
||||||
|
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">×</button>
|
||||||
|
<span class="count" data-project-count="${p.id}">${p.model_count}</span>
|
||||||
|
</span>
|
||||||
|
</div>`).join('');
|
||||||
|
const allItem = projectsList.querySelector('[data-project-id="all"]');
|
||||||
|
allItem.classList.toggle('active', selectedProject === 'all');
|
||||||
|
projectsList.innerHTML = '';
|
||||||
|
projectsList.appendChild(allItem);
|
||||||
|
projectsList.insertAdjacentHTML('beforeend', items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTags() {
|
||||||
|
const all = new Set();
|
||||||
|
models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
|
||||||
|
tagsList.innerHTML = Array.from(all).sort().map((tag) => `
|
||||||
|
<span class="tag${selectedTags.includes(tag) ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModels() {
|
||||||
|
const filtered = getFilteredModels();
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
fileGrid.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No models found</h3>
|
||||||
|
<p>Upload files or import from a URL to get started</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileGrid.className = currentView === 'grid' ? 'file-grid' : 'file-grid list-view';
|
||||||
|
fileGrid.innerHTML = filtered.map((m) => {
|
||||||
|
const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
|
||||||
|
const thumb = m.thumbnailUrl
|
||||||
|
? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">`
|
||||||
|
: '<span>📦</span>';
|
||||||
|
return `
|
||||||
|
<div class="file-card" data-model-id="${m.id}">
|
||||||
|
<div class="file-thumbnail">${thumb}</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">${escapeHtml(m.name)}</div>
|
||||||
|
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
|
||||||
|
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
|
||||||
|
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
|
||||||
|
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||||
|
<div class="file-meta">${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredModels() {
|
||||||
|
let result = models;
|
||||||
|
if (selectedProject !== 'all') {
|
||||||
|
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
|
||||||
|
}
|
||||||
|
if (selectedTags.length > 0) {
|
||||||
|
result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t)));
|
||||||
|
}
|
||||||
|
const term = searchInput.value.toLowerCase().trim();
|
||||||
|
if (term) {
|
||||||
|
result = result.filter((m) =>
|
||||||
|
m.name.toLowerCase().includes(term) ||
|
||||||
|
(m.description && m.description.toLowerCase().includes(term)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
document.getElementById('allFilesCount').textContent = models.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- events
|
||||||
|
function setupEventListeners() {
|
||||||
|
document.getElementById('uploadBtn').addEventListener('click', openUploadModal);
|
||||||
|
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
|
||||||
|
document.getElementById('importBtn').addEventListener('click', openImportModal);
|
||||||
|
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
|
||||||
|
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
|
||||||
|
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
|
||||||
|
document.querySelectorAll('.clear-setting').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => handleClearSetting(btn.dataset.setting));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.close-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal')));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal').forEach((modal) => {
|
||||||
|
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('uploadForm').addEventListener('submit', handleUpload);
|
||||||
|
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
|
||||||
|
document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
|
||||||
|
document.getElementById('cancelProject').addEventListener('click', () => closeModal(projectModal));
|
||||||
|
document.getElementById('importForm').addEventListener('submit', handleImport);
|
||||||
|
document.getElementById('cancelImport').addEventListener('click', () => closeModal(importModal));
|
||||||
|
|
||||||
|
document.getElementById('fileInput').addEventListener('change', (e) => {
|
||||||
|
const f = e.target.files[0];
|
||||||
|
const nameField = document.getElementById('fileName');
|
||||||
|
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
projectsList.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.project-item');
|
||||||
|
if (!item) return;
|
||||||
|
const id = item.dataset.projectId;
|
||||||
|
const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
|
||||||
|
|
||||||
|
if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
|
||||||
|
if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; }
|
||||||
|
|
||||||
|
document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
selectedProject = id;
|
||||||
|
document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name;
|
||||||
|
renderModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
tagsList.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.classList.contains('tag')) return;
|
||||||
|
const tag = e.target.dataset.tag;
|
||||||
|
e.target.classList.toggle('active');
|
||||||
|
if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
|
||||||
|
else selectedTags.push(tag);
|
||||||
|
renderModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', renderModels);
|
||||||
|
|
||||||
|
document.querySelectorAll('.view-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
|
||||||
|
e.target.classList.add('active');
|
||||||
|
currentView = e.target.dataset.view;
|
||||||
|
renderModels();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fileGrid.addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('.file-card');
|
||||||
|
if (card) openModelDetail(card.dataset.modelId);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
|
||||||
|
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
|
||||||
|
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
|
||||||
|
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
|
||||||
|
document.getElementById('downloadFilesBtn').addEventListener('click', handleDownloadFiles);
|
||||||
|
document.getElementById('editForm').addEventListener('submit', handleEditModel);
|
||||||
|
document.getElementById('cancelEdit').addEventListener('click', () => closeModal(editModal));
|
||||||
|
document.getElementById('addFilesBtn').addEventListener('click', () => document.getElementById('addFilesInput').click());
|
||||||
|
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
|
||||||
|
let currentDetailFile = null;
|
||||||
|
let currentDetailThumb = null; // model image, used as a viewer fallback
|
||||||
|
|
||||||
|
function closeModal(modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
if (modal === fileDetailModal && window.ModelViewer) window.ModelViewer.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- modals
|
||||||
|
function openUploadModal() {
|
||||||
|
document.getElementById('fileProjects').innerHTML =
|
||||||
|
projects.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
|
||||||
|
uploadModal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProjectModal() {
|
||||||
|
document.getElementById('projectForm').reset();
|
||||||
|
projectModal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettingsModal() {
|
||||||
|
document.getElementById('settingsForm').reset();
|
||||||
|
document.getElementById('settingsStatus').textContent = '';
|
||||||
|
try {
|
||||||
|
const settings = await (await fetch('/api/settings')).json();
|
||||||
|
document.querySelectorAll('.setting-status').forEach((el) => {
|
||||||
|
const s = settings[el.dataset.status];
|
||||||
|
if (!s) { el.textContent = '—'; return; }
|
||||||
|
el.textContent = s.set
|
||||||
|
? `set ${s.hint || ''}${s.source === 'env' ? ' (from .env — edit here to override)' : ''}`
|
||||||
|
: 'not set';
|
||||||
|
el.className = `setting-status ${s.set ? 'is-set' : 'is-unset'}`;
|
||||||
|
});
|
||||||
|
// Prefill non-secret fields (e.g. the FlareSolverr URL) with their current value.
|
||||||
|
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
|
||||||
|
const s = settings[input.dataset.setting];
|
||||||
|
if (s && !s.secret && s.value) input.value = s.value;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
|
||||||
|
}
|
||||||
|
settingsModal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportModal() {
|
||||||
|
document.getElementById('importForm').reset();
|
||||||
|
document.getElementById('importStatus').textContent = '';
|
||||||
|
const tvEnabled = config.importers && config.importers.thingiverse;
|
||||||
|
const flareEnabled = config.importers && config.importers.flaresolverr;
|
||||||
|
const mw = flareEnabled ? 'MakerWorld' : 'MakerWorld (needs FlareSolverr in Settings)';
|
||||||
|
document.getElementById('importHint').innerHTML = tvEnabled
|
||||||
|
? `Thingiverse imports files automatically. Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded.`
|
||||||
|
: `Printables, ${mw} & other sites import the title, description & preview image — then add the files you downloaded. Add a Thingiverse token in Settings for automatic file downloads.`;
|
||||||
|
document.getElementById('importSubmit').disabled = false;
|
||||||
|
importModal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModelDetail(modelId) {
|
||||||
|
const model = models.find((m) => String(m.id) === String(modelId));
|
||||||
|
if (!model) return;
|
||||||
|
fileDetailModal.dataset.currentModelId = modelId;
|
||||||
|
currentDetailThumb = model.thumbnailUrl || null;
|
||||||
|
|
||||||
|
document.getElementById('detailFileName').textContent = model.name;
|
||||||
|
document.getElementById('detailDescription').textContent = model.description || 'No description';
|
||||||
|
|
||||||
|
// Source
|
||||||
|
const src = document.getElementById('detailSource');
|
||||||
|
if (model.source_url) {
|
||||||
|
const designer = model.designer ? ` by ${escapeHtml(model.designer.name)}` : '';
|
||||||
|
src.innerHTML = `<a href="${escapeHtml(model.source_url)}" target="_blank" rel="noopener">${escapeHtml(model.source_site || 'link')}${designer}</a>`;
|
||||||
|
} else {
|
||||||
|
src.textContent = 'Manual upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDetailLicense(model);
|
||||||
|
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
|
||||||
|
|
||||||
|
// "Download files from source" — only for sites we can auto-download from.
|
||||||
|
const autoDownloadSites = ['thingiverse', 'printables', 'makerworld'];
|
||||||
|
const dlBtn = document.getElementById('downloadFilesBtn');
|
||||||
|
if (model.source_url && autoDownloadSites.includes(model.source_site)) {
|
||||||
|
dlBtn.style.display = '';
|
||||||
|
dlBtn.textContent = model.files.length === 0 ? 'Download files from source' : 'Re-check source for files';
|
||||||
|
} else {
|
||||||
|
dlBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags / projects / print settings
|
||||||
|
document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length)
|
||||||
|
? model.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('') : 'No tags';
|
||||||
|
const ps = model.projects || [];
|
||||||
|
document.getElementById('detailProjects').innerHTML = ps.length
|
||||||
|
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).join('') : 'Not in any collection';
|
||||||
|
const settings = model.print_settings || {};
|
||||||
|
const settingRows = Object.entries(settings).filter(([, v]) => v);
|
||||||
|
document.getElementById('detailPrintSettings').innerHTML = settingRows.length
|
||||||
|
? settingRows.map(([k, v]) => `<div><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).join('')
|
||||||
|
: 'No print settings';
|
||||||
|
|
||||||
|
// File list (+ a "download all as zip" action when there's more than one file)
|
||||||
|
const fileInfoEl = document.getElementById('detailFileInfo');
|
||||||
|
if (model.files.length === 0) {
|
||||||
|
const autoSite = ['thingiverse', 'printables', 'makerworld'].includes(model.source_site);
|
||||||
|
const hint = !model.source_url
|
||||||
|
? 'No files yet — use “Add files” below.'
|
||||||
|
: autoSite
|
||||||
|
? 'No files yet — use “Download files from source” below.'
|
||||||
|
: 'Manual source — download the files from the source page, then use “Add files” below.';
|
||||||
|
fileInfoEl.innerHTML = `<div class="lic-note">${hint}</div>`;
|
||||||
|
} else {
|
||||||
|
const downloadAll = model.files.length > 1
|
||||||
|
? `<a class="btn btn-secondary btn-sm download-all" href="/api/models/${model.id}/download">Download all ${model.files.length} files (.zip)</a>`
|
||||||
|
: '';
|
||||||
|
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
|
||||||
|
<div class="file-row">
|
||||||
|
<span>${escapeHtml(f.originalName)} <em>(${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''})</em></span>
|
||||||
|
<a href="/api/files/${f.id}/raw" download>download</a>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewer tabs
|
||||||
|
const renderable = model.files.filter((f) => window.ModelViewer.RENDERABLE.includes(f.format));
|
||||||
|
const tabs = document.getElementById('detailFileTabs');
|
||||||
|
tabs.innerHTML = renderable.map((f, i) => `
|
||||||
|
<button class="file-tab${i === 0 ? ' active' : ''}" data-file-id="${f.id}" data-format="${f.format}">${escapeHtml(f.originalName)}</button>`).join('');
|
||||||
|
tabs.querySelectorAll('.file-tab').forEach((tab) => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
tabs.querySelectorAll('.file-tab').forEach((t) => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
loadViewer(tab.dataset.fileId, tab.dataset.format);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fileDetailModal.classList.add('active');
|
||||||
|
currentDetailFile = null;
|
||||||
|
if (renderable.length) {
|
||||||
|
loadViewer(renderable[0].id, renderable[0].format);
|
||||||
|
} else {
|
||||||
|
document.getElementById('regenThumbBtn').style.display = 'none';
|
||||||
|
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', { fallbackImage: currentDetailThumb });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a file to what the viewer should load: 3MF goes through the server's
|
||||||
|
// GLB conversion; everything else is served raw.
|
||||||
|
function viewerSourceFor(fileId, format) {
|
||||||
|
return format === '3mf'
|
||||||
|
? { url: `/api/files/${fileId}/glb`, format: 'glb' }
|
||||||
|
: { url: `/api/files/${fileId}/raw`, format };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailLicense(model) {
|
||||||
|
const com = model.commercial_use; // true / false / null
|
||||||
|
const badge = com === true
|
||||||
|
? '<span class="lic-badge ok">Commercial use allowed</span>'
|
||||||
|
: com === false
|
||||||
|
? '<span class="lic-badge no">Non-commercial only</span>'
|
||||||
|
: '<span class="lic-badge unknown">Commercial use unclear</span>';
|
||||||
|
document.getElementById('detailLicense').innerHTML =
|
||||||
|
`<div>${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}</div>` +
|
||||||
|
'<div class="lic-note">A guess from the license name — always confirm on the source page before selling.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
const id = fileDetailModal.dataset.currentModelId;
|
||||||
|
const model = models.find((m) => String(m.id) === String(id));
|
||||||
|
if (!model) return;
|
||||||
|
editModal.dataset.editId = id;
|
||||||
|
|
||||||
|
document.getElementById('editName').value = model.name || '';
|
||||||
|
document.getElementById('editDescription').value = model.description || '';
|
||||||
|
document.getElementById('editTags').value = (model.tags || []).join(', ');
|
||||||
|
document.getElementById('editLicense').value = model.license || '';
|
||||||
|
document.getElementById('editCommercial').value =
|
||||||
|
model.commercial_use === true ? 'true' : model.commercial_use === false ? 'false' : '';
|
||||||
|
|
||||||
|
const ps = model.print_settings || {};
|
||||||
|
document.getElementById('editMaterial').value = ps.material || '';
|
||||||
|
document.getElementById('editLayerHeight').value = ps.layerHeight || '';
|
||||||
|
document.getElementById('editInfill').value = ps.infill || '';
|
||||||
|
document.getElementById('editSupports').value = ps.supports || '';
|
||||||
|
|
||||||
|
const current = new Set((model.projects || []).map((p) => String(p.id)));
|
||||||
|
document.getElementById('editProjects').innerHTML = projects
|
||||||
|
.map((p) => `<option value="${p.id}"${current.has(String(p.id)) ? ' selected' : ''}>${escapeHtml(p.name)}</option>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
editModal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditModel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = editModal.dataset.editId;
|
||||||
|
const commercial = document.getElementById('editCommercial').value;
|
||||||
|
const body = {
|
||||||
|
name: document.getElementById('editName').value,
|
||||||
|
description: document.getElementById('editDescription').value,
|
||||||
|
license: document.getElementById('editLicense').value || null,
|
||||||
|
commercial_use: commercial === '' ? null : commercial === 'true',
|
||||||
|
tags: splitTags(document.getElementById('editTags').value),
|
||||||
|
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
|
||||||
|
print_settings: {
|
||||||
|
material: document.getElementById('editMaterial').value,
|
||||||
|
layerHeight: document.getElementById('editLayerHeight').value,
|
||||||
|
infill: document.getElementById('editInfill').value,
|
||||||
|
supports: document.getElementById('editSupports').value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
closeModal(editModal);
|
||||||
|
await loadData();
|
||||||
|
openModelDetail(id); // refresh the (still-open) detail view
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to save changes: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadFiles() {
|
||||||
|
const id = fileDetailModal.dataset.currentModelId;
|
||||||
|
const btn = document.getElementById('downloadFilesBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Downloading… (this can take a moment)';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
await loadData();
|
||||||
|
openModelDetail(id);
|
||||||
|
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
|
||||||
|
} catch (err) {
|
||||||
|
alert('Download failed: ' + err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshMetadata() {
|
||||||
|
const modelId = fileDetailModal.dataset.currentModelId;
|
||||||
|
const btn = document.getElementById('refreshLicenseBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Refreshing…';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/${modelId}/refresh-metadata`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
const updated = await res.json();
|
||||||
|
// update in-memory state, the license panel, and the card badge
|
||||||
|
const idx = models.findIndex((m) => String(m.id) === String(modelId));
|
||||||
|
if (idx !== -1) models[idx] = updated;
|
||||||
|
renderDetailLicense(updated);
|
||||||
|
renderModels();
|
||||||
|
btn.textContent = 'Updated ✓';
|
||||||
|
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
btn.textContent = 'Refresh failed';
|
||||||
|
console.warn('Refresh metadata failed:', err.message);
|
||||||
|
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1800);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadViewer(fileId, format) {
|
||||||
|
const src = viewerSourceFor(fileId, format);
|
||||||
|
currentDetailFile = { id: fileId, viewerUrl: src.url, viewerFormat: src.format };
|
||||||
|
document.getElementById('regenThumbBtn').style.display = '';
|
||||||
|
window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format, { fallbackImage: currentDetailThumb });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardThumbnail(modelId, thumbnailUrl) {
|
||||||
|
const m = models.find((x) => String(x.id) === String(modelId));
|
||||||
|
if (m) m.thumbnailUrl = thumbnailUrl;
|
||||||
|
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${modelId}"] .file-thumbnail`);
|
||||||
|
if (thumb && thumbnailUrl) thumb.innerHTML = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenThumbnail() {
|
||||||
|
const modelId = fileDetailModal.dataset.currentModelId;
|
||||||
|
if (!currentDetailFile) { alert('No previewable file to render.'); return; }
|
||||||
|
const btn = document.getElementById('regenThumbBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Rendering…';
|
||||||
|
try {
|
||||||
|
const image = await window.ModelViewer.snapshot(currentDetailFile.viewerUrl, currentDetailFile.viewerFormat, 512);
|
||||||
|
const res = await fetch(`/api/models/${modelId}/thumbnail`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
const updated = await res.json();
|
||||||
|
updateCardThumbnail(modelId, updated.thumbnailUrl);
|
||||||
|
thumbAttempted.add(Number(modelId));
|
||||||
|
btn.textContent = 'Done ✓';
|
||||||
|
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Thumbnail regen failed:', err.message);
|
||||||
|
btn.textContent = "Can't render this file";
|
||||||
|
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1800);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddFiles(e) {
|
||||||
|
const input = e.target;
|
||||||
|
if (!input.files.length) return;
|
||||||
|
const modelId = fileDetailModal.dataset.currentModelId;
|
||||||
|
const formData = new FormData();
|
||||||
|
Array.from(input.files).forEach((f) => formData.append('files', f));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/${modelId}/files`, { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
input.value = '';
|
||||||
|
await loadData();
|
||||||
|
openModelDetail(modelId); // reopen with the new files
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to add files: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- handlers
|
||||||
|
async function handleUpload(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData();
|
||||||
|
const input = document.getElementById('fileInput');
|
||||||
|
Array.from(input.files).forEach((f) => formData.append('files', f));
|
||||||
|
formData.append('name', document.getElementById('fileName').value);
|
||||||
|
formData.append('description', document.getElementById('fileDescription').value);
|
||||||
|
formData.append('tags', JSON.stringify(splitTags(document.getElementById('fileTags').value)));
|
||||||
|
formData.append('projects', JSON.stringify(
|
||||||
|
Array.from(document.getElementById('fileProjects').selectedOptions).map((o) => Number(o.value))));
|
||||||
|
formData.append('printSettings', JSON.stringify({
|
||||||
|
material: document.getElementById('printMaterial').value,
|
||||||
|
layerHeight: document.getElementById('printLayerHeight').value,
|
||||||
|
infill: document.getElementById('printInfill').value,
|
||||||
|
supports: document.getElementById('printSupports').value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/models', { method: 'POST', body: formData });
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal(uploadModal);
|
||||||
|
document.getElementById('uploadForm').reset();
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + (await res.json()).error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Upload failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById('importStatus');
|
||||||
|
const submit = document.getElementById('importSubmit');
|
||||||
|
status.textContent = 'Importing… fetching metadata and downloading files.';
|
||||||
|
submit.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: document.getElementById('importUrl').value }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
await loadData();
|
||||||
|
if (data.metadataOnly) {
|
||||||
|
status.textContent = data.metadataFetched
|
||||||
|
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
|
||||||
|
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
|
||||||
|
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200);
|
||||||
|
} else {
|
||||||
|
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
|
||||||
|
setTimeout(() => closeModal(importModal), 900);
|
||||||
|
}
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
status.textContent = 'That model is already in your library.';
|
||||||
|
} else {
|
||||||
|
status.textContent = 'Import failed: ' + data.error;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Import failed: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSettings(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById('settingsStatus');
|
||||||
|
// Only send fields the user actually typed into (blank = leave unchanged).
|
||||||
|
const payload = {};
|
||||||
|
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
|
||||||
|
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
|
||||||
|
});
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
status.textContent = 'Nothing to save.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
status.textContent = 'Saved.';
|
||||||
|
await loadData(); // refresh config so import button reflects new token
|
||||||
|
setTimeout(() => closeModal(settingsModal), 600);
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Save failed: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearSetting(key) {
|
||||||
|
if (!confirm('Clear this value?')) return;
|
||||||
|
const status = document.getElementById('settingsStatus');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ [key]: null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
status.textContent = 'Cleared.';
|
||||||
|
await loadData();
|
||||||
|
await openSettingsModal();
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Clear failed: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenameCollection(id, currentName) {
|
||||||
|
const name = prompt('Rename collection:', currentName);
|
||||||
|
if (name === null) return;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed || trimmed === currentName) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: trimmed }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
if (String(selectedProject) === String(id)) {
|
||||||
|
document.getElementById('contentTitle').textContent = trimmed;
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to rename collection: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteCollection(id, name) {
|
||||||
|
if (!confirm(`Delete the collection "${name}"?\n\nThe models in it are kept — only the collection is removed.`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error);
|
||||||
|
if (String(selectedProject) === String(id)) {
|
||||||
|
selectedProject = 'all';
|
||||||
|
document.getElementById('contentTitle').textContent = 'All Models';
|
||||||
|
}
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete collection: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateProject(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: document.getElementById('projectName').value,
|
||||||
|
description: document.getElementById('projectDescription').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal(projectModal);
|
||||||
|
document.getElementById('projectForm').reset();
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
alert('Failed to create collection:' + (await res.json()).error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to create collection:' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteModel() {
|
||||||
|
const id = fileDetailModal.dataset.currentModelId;
|
||||||
|
if (!id || !confirm('Delete this model and all its files?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal(fileDetailModal);
|
||||||
|
await loadData();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete: ' + (await res.json()).error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- utils
|
||||||
|
function splitTags(value) {
|
||||||
|
return value.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text == null ? '' : text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
bytes = Number(bytes) || 0;
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
361
public/index.html
Normal file
361
public/index.html
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
<!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="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>
|
||||||
|
<div class="container">
|
||||||
|
<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="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>Collections</h3>
|
||||||
|
<div id="projectsList" class="projects-list">
|
||||||
|
<div class="project-item active" data-project-id="all">
|
||||||
|
<span>All Models</span>
|
||||||
|
<span class="count" id="allFilesCount">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Tags</h3>
|
||||||
|
<div id="tagsList" class="tags-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<h3>Search</h3>
|
||||||
|
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<div class="content-header">
|
||||||
|
<h2 id="contentTitle">All Models</h2>
|
||||||
|
<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 id="fileGrid" class="file-grid"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settingsModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button class="close-btn">×</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>
|
||||||
|
|
||||||
|
<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">×</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">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="uploadForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fileInput">Files * <span class="form-hint-inline">(stl, 3mf, obj, step, gcode, zip — multiple allowed)</span></label>
|
||||||
|
<input type="file" id="fileInput" accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip" 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">×</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">×</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>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,.step,.stp,.gcode,.gco,.zip">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-danger" id="deleteFileBtn">Delete</button>
|
||||||
|
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
|
||||||
|
<button class="btn btn-primary" id="editModelBtn">Edit</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">×</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">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editCommercial">Commercial use (sellable?)</label>
|
||||||
|
<select id="editCommercial">
|
||||||
|
<option value="">Unknown</option>
|
||||||
|
<option value="true">Allowed — sellable</option>
|
||||||
|
<option value="false">Non-commercial only</option>
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
244
public/styles.css
Normal file
244
public/styles.css
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
/* Printhive — Workshop Dark theme */
|
||||||
|
:root {
|
||||||
|
--bg: #15171c;
|
||||||
|
--surface: #1e2128;
|
||||||
|
--surface-2: #262a32;
|
||||||
|
--surface-3: #2d323b;
|
||||||
|
--border: #2f343d;
|
||||||
|
--border-strong: #3b424d;
|
||||||
|
--text: #e7e9ee;
|
||||||
|
--text-muted: #9aa0ab;
|
||||||
|
--text-dim: #6b7280;
|
||||||
|
--accent: #f0972a;
|
||||||
|
--accent-hover: #ffab45;
|
||||||
|
--accent-soft: rgba(240, 151, 42, 0.14);
|
||||||
|
--accent-text: #1a1205;
|
||||||
|
--danger: #e35d5b;
|
||||||
|
--danger-soft: rgba(227, 93, 91, 0.14);
|
||||||
|
--success: #4cc38a;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 7px;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.container { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* ---- header ---- */
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 28px;
|
||||||
|
background: rgba(21, 23, 28, 0.85);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.brand .logo { width: 26px; height: 26px; color: var(--accent); }
|
||||||
|
.brand .accent { color: var(--accent); }
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* ---- buttons ---- */
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.05s;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn-sm { font-size: 12px; padding: 6px 12px; align-self: flex-start; }
|
||||||
|
.download-all { display: inline-block; text-decoration: none; margin-bottom: 10px; }
|
||||||
|
.download-all:hover { text-decoration: none; }
|
||||||
|
.btn-primary { background: var(--accent); color: var(--accent-text); }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-secondary { background: transparent; color: var(--text-muted); border-color: var(--border-strong); }
|
||||||
|
.btn-secondary:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||||
|
.btn-danger:hover { background: var(--danger-soft); }
|
||||||
|
|
||||||
|
/* ---- layout ---- */
|
||||||
|
.main-content { flex: 1; display: grid; grid-template-columns: 260px 1fr; gap: 24px; padding: 24px 28px; max-width: 1500px; width: 100%; margin: 0 auto; }
|
||||||
|
|
||||||
|
.sidebar { display: flex; flex-direction: column; gap: 22px; }
|
||||||
|
.sidebar-section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
|
||||||
|
.sidebar-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.projects-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.project-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 10px; border-radius: var(--radius-sm); cursor: pointer;
|
||||||
|
color: var(--text-muted); font-size: 14px; transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.project-item:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.project-item.active { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.project-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.project-meta { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||||
|
.collection-action { opacity: 0; background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 13px; line-height: 1; padding: 2px 3px; font-family: inherit; transition: color 0.12s, opacity 0.12s; }
|
||||||
|
.project-item:hover .collection-action { opacity: 1; }
|
||||||
|
.collection-action:hover { color: var(--text); }
|
||||||
|
.collection-delete:hover { color: var(--danger); }
|
||||||
|
.project-item .count { font-family: var(--mono); font-size: 12px; background: var(--surface-3); color: var(--text-muted); padding: 1px 8px; border-radius: 10px; }
|
||||||
|
.project-item.active .count { background: var(--accent); color: var(--accent-text); }
|
||||||
|
|
||||||
|
.tags-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.tag {
|
||||||
|
font-size: 12px; padding: 4px 10px; border-radius: 12px; cursor: pointer;
|
||||||
|
background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.tag:hover { border-color: var(--border-strong); color: var(--text); }
|
||||||
|
.tag.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- form fields ---- */
|
||||||
|
.search-input, input[type="text"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
transition: border-color 0.12s, box-shadow 0.12s;
|
||||||
|
}
|
||||||
|
input::placeholder, textarea::placeholder { color: var(--text-dim); }
|
||||||
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||||
|
input[type="file"] { padding: 8px; color: var(--text-muted); }
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
label { display: block; font-size: 13px; font-weight: 500; color: var(--text-muted); margin-bottom: 7px; }
|
||||||
|
|
||||||
|
/* ---- content ---- */
|
||||||
|
.content-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||||
|
.content-header h2 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
|
||||||
|
.view-options { display: flex; gap: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px; }
|
||||||
|
.view-btn { font-family: inherit; font-size: 13px; padding: 5px 14px; border: none; background: transparent; color: var(--text-muted); border-radius: 5px; cursor: pointer; }
|
||||||
|
.view-btn.active { background: var(--surface-3); color: var(--text); }
|
||||||
|
|
||||||
|
/* ---- model grid ---- */
|
||||||
|
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 18px; }
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.file-card:hover { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); }
|
||||||
|
|
||||||
|
.file-thumbnail {
|
||||||
|
width: 100%; height: 185px;
|
||||||
|
background: radial-gradient(circle at 50% 40%, #232730 0%, #14161a 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); font-size: 46px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.file-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.file-info { padding: 14px 16px; }
|
||||||
|
.file-name { font-size: 15px; font-weight: 600; margin-bottom: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.source-badge { display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; background: var(--accent-soft); color: var(--accent); padding: 2px 8px; border-radius: 10px; margin-bottom: 8px; }
|
||||||
|
.com-badge { display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; margin: 0 0 8px 6px; }
|
||||||
|
.com-badge.ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||||
|
.com-badge.no { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
|
||||||
|
.lic-badge { display: inline-block; font-size: 11px; padding: 2px 9px; border-radius: 10px; margin-left: 6px; }
|
||||||
|
.lic-badge.ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||||
|
.lic-badge.no { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.lic-badge.unknown { background: var(--surface-3); color: var(--text-muted); }
|
||||||
|
.lic-note { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
|
||||||
|
.file-description { font-size: 13px; color: var(--text-muted); margin-bottom: 10px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.file-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 10px; }
|
||||||
|
.file-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--surface-2); color: var(--text-muted); }
|
||||||
|
.file-meta { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* list view */
|
||||||
|
.file-grid.list-view { grid-template-columns: 1fr; }
|
||||||
|
.file-grid.list-view .file-card { display: flex; align-items: stretch; }
|
||||||
|
.file-grid.list-view .file-thumbnail { width: 130px; height: auto; min-height: 90px; flex-shrink: 0; border-bottom: none; border-right: 1px solid var(--border); font-size: 30px; }
|
||||||
|
.file-grid.list-view .file-info { flex: 1; }
|
||||||
|
|
||||||
|
.empty-state { grid-column: 1 / -1; text-align: center; padding: 80px 20px; color: var(--text-dim); }
|
||||||
|
.empty-state h3 { font-size: 18px; color: var(--text-muted); margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* ---- modals ---- */
|
||||||
|
.modal {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 100;
|
||||||
|
background: rgba(8, 9, 12, 0.7); backdrop-filter: blur(3px);
|
||||||
|
align-items: flex-start; justify-content: center; padding: 60px 20px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal.active { display: flex; }
|
||||||
|
.modal-content { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); width: 100%; max-width: 520px; }
|
||||||
|
.modal-content.large { max-width: 960px; }
|
||||||
|
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-header h2 { font-size: 18px; font-weight: 600; }
|
||||||
|
.close-btn { background: none; border: none; color: var(--text-dim); font-size: 26px; line-height: 1; cursor: pointer; transition: color 0.12s; }
|
||||||
|
.close-btn:hover { color: var(--text); }
|
||||||
|
.modal-body { padding: 22px; }
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
.print-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||||
|
|
||||||
|
/* ---- model detail ---- */
|
||||||
|
.file-detail-content { display: grid; grid-template-columns: 1.1fr 1fr; gap: 26px; }
|
||||||
|
.file-detail-preview { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.model-viewer { width: 100%; height: 380px; background: radial-gradient(circle at 50% 40%, #232730 0%, #131519 100%); border: 1px solid var(--border); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.model-viewer canvas { display: block; border-radius: var(--radius); }
|
||||||
|
.viewer-msg { color: var(--text-dim); font-size: 14px; padding: 20px; text-align: center; }
|
||||||
|
.viewer-fallback { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px; }
|
||||||
|
.viewer-fallback img { max-width: 100%; max-height: 320px; object-fit: contain; border-radius: var(--radius-sm); }
|
||||||
|
.viewer-fallback span { color: var(--text-dim); font-size: 12px; text-align: center; }
|
||||||
|
|
||||||
|
.file-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; }
|
||||||
|
.file-tab:hover { color: var(--text); }
|
||||||
|
.file-tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.info-section { margin-bottom: 22px; }
|
||||||
|
.info-section:last-of-type { margin-bottom: 0; }
|
||||||
|
.info-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; }
|
||||||
|
.info-section p, .info-section div { color: var(--text); font-size: 14px; line-height: 1.6; }
|
||||||
|
.info-section .tag { cursor: default; }
|
||||||
|
|
||||||
|
.file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
|
||||||
|
.file-row:last-child { border-bottom: none; }
|
||||||
|
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---- import / settings ---- */
|
||||||
|
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }
|
||||||
|
.form-hint { font-size: 12px; color: var(--text-dim); margin-top: 7px; line-height: 1.5; }
|
||||||
|
.form-hint-inline { font-size: 12px; color: var(--text-dim); font-weight: 400; }
|
||||||
|
.settings-group-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 14px; }
|
||||||
|
.setting-status.is-set { color: var(--success); font-weight: 500; }
|
||||||
|
.setting-status.is-unset { color: var(--danger); font-weight: 500; }
|
||||||
|
.btn-link { background: none; border: none; color: var(--danger); padding: 6px 0 0; font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||||
|
.btn-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.main-content { grid-template-columns: 1fr; }
|
||||||
|
.file-detail-content { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
162
public/viewer.js
Normal file
162
public/viewer.js
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// In-browser 3D preview for STL / 3MF / OBJ files, loaded as an ES module.
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||||
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
|
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
||||||
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
|
// Formats the renderer can draw directly. 3MF is rendered via server-converted
|
||||||
|
// GLB (see RENDERABLE for what the app treats as previewable input).
|
||||||
|
const SUPPORTED = ['stl', 'obj', 'glb'];
|
||||||
|
const RENDERABLE = ['stl', '3mf', 'obj'];
|
||||||
|
let active = null; // { renderer, controls, rafId }
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
if (!active) return;
|
||||||
|
cancelAnimationFrame(active.rafId);
|
||||||
|
active.controls.dispose();
|
||||||
|
active.renderer.dispose();
|
||||||
|
const el = active.renderer.domElement;
|
||||||
|
if (el.parentNode) el.parentNode.removeChild(el);
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(container, text) {
|
||||||
|
container.innerHTML = `<div class="viewer-msg">${text}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When 3D rendering isn't possible (e.g. Bambu/MakerWorld 3MF project files that
|
||||||
|
// the loader can't parse), show the model's preview image instead of an error.
|
||||||
|
function showFallback(container, image, text) {
|
||||||
|
if (image) {
|
||||||
|
container.innerHTML =
|
||||||
|
`<div class="viewer-fallback"><img src="${image}" alt=""><span>${text}</span></div>`;
|
||||||
|
} else {
|
||||||
|
message(container, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadObject(url, format) {
|
||||||
|
if (format === 'stl') {
|
||||||
|
const geometry = await new STLLoader().loadAsync(url);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
const material = new THREE.MeshStandardMaterial({ color: 0xc4cad6, metalness: 0.15, roughness: 0.65 });
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.rotation.x = -Math.PI / 2; // STL is typically Z-up; sit it flat
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
if (format === 'obj') return new OBJLoader().loadAsync(url);
|
||||||
|
if (format === '3mf') return new ThreeMFLoader().loadAsync(url);
|
||||||
|
if (format === 'glb') {
|
||||||
|
const gltf = await new GLTFLoader().loadAsync(url);
|
||||||
|
gltf.scene.rotation.x = -Math.PI / 2; // 3MF/trimesh is Z-up; sit it upright
|
||||||
|
return gltf.scene;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported format ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(container, url, format, opts = {}) {
|
||||||
|
dispose();
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!SUPPORTED.includes(format)) {
|
||||||
|
showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = container.clientWidth || 600;
|
||||||
|
const height = container.clientHeight || 420;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1e6);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(1, 1, 1); scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill);
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
|
||||||
|
let object;
|
||||||
|
try {
|
||||||
|
object = await loadObject(url, format);
|
||||||
|
} catch (err) {
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.forceContextLoss();
|
||||||
|
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||||
|
const note = format === '3mf'
|
||||||
|
? 'Interactive 3D isn’t supported for this 3MF (Bambu/MakerWorld project file).'
|
||||||
|
: 'This file couldn’t be rendered in 3D.';
|
||||||
|
showFallback(container, opts.fallbackImage, note);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the object and frame the camera around it.
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
object.position.sub(center);
|
||||||
|
scene.add(object);
|
||||||
|
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||||
|
camera.position.set(maxDim * 1.4, maxDim * 1.1, maxDim * 1.8);
|
||||||
|
camera.near = maxDim / 100;
|
||||||
|
camera.far = maxDim * 100;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
active = { renderer, controls, rafId: 0 };
|
||||||
|
const loop = () => {
|
||||||
|
if (!active) return;
|
||||||
|
active.rafId = requestAnimationFrame(loop);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a model once to an offscreen canvas and return a PNG data URL.
|
||||||
|
// Used to generate card thumbnails. Transparent background so it composites
|
||||||
|
// over the card surface.
|
||||||
|
async function snapshot(url, format, size = 512) {
|
||||||
|
if (!SUPPORTED.includes(format)) return null;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
||||||
|
renderer.setSize(size, size);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1e6);
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(1, 1, 1); scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const object = await loadObject(url, format);
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
const dim = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
object.position.sub(center);
|
||||||
|
scene.add(object);
|
||||||
|
|
||||||
|
const maxDim = Math.max(dim.x, dim.y, dim.z) || 1;
|
||||||
|
camera.position.set(maxDim * 1.3, maxDim * 1.0, maxDim * 1.7);
|
||||||
|
camera.near = maxDim / 100;
|
||||||
|
camera.far = maxDim * 100;
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
return renderer.domElement.toDataURL('image/png');
|
||||||
|
} finally {
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.forceContextLoss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ModelViewer = { render, dispose, snapshot, SUPPORTED, RENDERABLE };
|
||||||
659
server.js
Normal file
659
server.js
Normal file
|
|
@ -0,0 +1,659 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
|
const db = require('./lib/db');
|
||||||
|
const store = require('./lib/store');
|
||||||
|
const fileProcessor = require('./lib/fileProcessor');
|
||||||
|
const thingiverse = require('./lib/importers/thingiverse');
|
||||||
|
const generic = require('./lib/importers/generic');
|
||||||
|
const flaresolverr = require('./lib/flaresolverr');
|
||||||
|
const makerworld = require('./lib/importers/makerworld');
|
||||||
|
const printables = require('./lib/importers/printables');
|
||||||
|
const converter = require('./lib/converter');
|
||||||
|
const licenses = require('./lib/licenses');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const UPLOAD_DIR = './uploads';
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '8mb' })); // generous for base64 thumbnail PNGs
|
||||||
|
app.use(express.static('public'));
|
||||||
|
app.use('/thumbnails', express.static('thumbnails'));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- helpers
|
||||||
|
function ensureUploadDir() {
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(originalName) {
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||||
|
return suffix + path.extname(originalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a files[] entry from a file already on disk: extracts geometry metadata.
|
||||||
|
function describeFile(filePath, fileName, originalName) {
|
||||||
|
const format = fileProcessor.formatOf(originalName);
|
||||||
|
const size = fs.statSync(filePath).size;
|
||||||
|
const meta = fileProcessor.extractMetadata(filePath, format);
|
||||||
|
return { fileName, filePath, originalName, format, size, ...meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose a thumbnail for a model from its files (embedded 3MF preview, if any).
|
||||||
|
function pickThumbnail(files) {
|
||||||
|
const threeMf = files.find((f) => f.format === '3mf');
|
||||||
|
if (!threeMf) return null;
|
||||||
|
return fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform a stored model into the public shape (thumbnail path -> URL).
|
||||||
|
function publicModel(m) {
|
||||||
|
if (!m) return m;
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
thumbnailUrl: m.thumbnail_path ? `/thumbnails/${path.basename(m.thumbnail_path)}` : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- multer
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => { ensureUploadDir(); cb(null, UPLOAD_DIR); },
|
||||||
|
filename: (req, file, cb) => cb(null, uniqueName(file.originalname)),
|
||||||
|
});
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (fileProcessor.isAllowed(file.originalname)) cb(null, true);
|
||||||
|
else cb(new Error(`Unsupported file type. Allowed: ${fileProcessor.MODEL_FORMATS.join(', ')}`));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- settings
|
||||||
|
// Editable secrets. Resolved from the DB first, then the .env fallback.
|
||||||
|
const SETTINGS = {
|
||||||
|
thingiverse_token: { env: 'THINGIVERSE_TOKEN', label: 'Thingiverse API token', secret: true },
|
||||||
|
flaresolverr_url: { env: 'FLARESOLVERR_URL', label: 'FlareSolverr URL', secret: false },
|
||||||
|
makerworld_token: { env: 'MAKERWORLD_TOKEN', label: 'MakerWorld auth token', secret: true },
|
||||||
|
printables_token: { env: 'PRINTABLES_TOKEN', label: 'Printables auth token', secret: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveSetting(key) {
|
||||||
|
const fromDb = await store.getSetting(key);
|
||||||
|
if (fromDb) return { value: fromDb, source: 'database' };
|
||||||
|
const fromEnv = process.env[SETTINGS[key].env];
|
||||||
|
if (fromEnv) return { value: fromEnv, source: 'env' };
|
||||||
|
return { value: null, source: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
return value.length <= 4 ? '••••' : `••••${value.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/config', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const thingiverse = await resolveSetting('thingiverse_token');
|
||||||
|
const flare = await resolveSetting('flaresolverr_url');
|
||||||
|
res.json({
|
||||||
|
importers: { thingiverse: Boolean(thingiverse.value), flaresolverr: Boolean(flare.value) },
|
||||||
|
formats: fileProcessor.MODEL_FORMATS,
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings for the UI — secrets are masked, never returned in full.
|
||||||
|
app.get('/api/settings', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const out = {};
|
||||||
|
for (const key of Object.keys(SETTINGS)) {
|
||||||
|
const resolved = await resolveSetting(key);
|
||||||
|
const secret = SETTINGS[key].secret;
|
||||||
|
out[key] = {
|
||||||
|
label: SETTINGS[key].label,
|
||||||
|
set: Boolean(resolved.value),
|
||||||
|
source: resolved.source, // 'database' | 'env' | null
|
||||||
|
secret,
|
||||||
|
hint: secret ? maskSecret(resolved.value) : resolved.value, // show non-secrets in full
|
||||||
|
value: secret ? null : resolved.value, // non-secrets can be prefilled in the form
|
||||||
|
editable: resolved.source !== 'env', // env-provided values are read-only here
|
||||||
|
};
|
||||||
|
}
|
||||||
|
res.json(out);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/settings', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
for (const [key, value] of Object.entries(req.body || {})) {
|
||||||
|
if (!SETTINGS[key]) continue; // ignore unknown keys
|
||||||
|
if (value === undefined) continue; // omitted = leave unchanged
|
||||||
|
await store.setSetting(key, value === null ? '' : String(value).trim());
|
||||||
|
}
|
||||||
|
res.json({ message: 'Settings saved' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- models
|
||||||
|
app.get('/api/models', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const models = await store.getAllModels();
|
||||||
|
res.json(models.map(publicModel));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/models/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const model = await store.getModel(req.params.id);
|
||||||
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
res.json(publicModel(model));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload one or more files as a single model.
|
||||||
|
app.post('/api/models', upload.array('files'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.files || req.files.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No files uploaded' });
|
||||||
|
}
|
||||||
|
const files = req.files.map((f) => describeFile(f.path, f.filename, f.originalname));
|
||||||
|
const model = await store.createModel({
|
||||||
|
name: req.body.name || req.files[0].originalname,
|
||||||
|
description: req.body.description || '',
|
||||||
|
print_settings: parseJson(req.body.printSettings, {}),
|
||||||
|
tags: parseJson(req.body.tags, []),
|
||||||
|
projects: parseJson(req.body.projects, []),
|
||||||
|
thumbnail_path: pickThumbnail(files),
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
res.status(201).json(publicModel(model));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add more files to an existing model.
|
||||||
|
app.post('/api/models/:id/files', upload.array('files'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await store.getModel(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
const files = (req.files || []).map((f) => describeFile(f.path, f.filename, f.originalname));
|
||||||
|
const model = await store.addFiles(req.params.id, files);
|
||||||
|
res.json(publicModel(model));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/models/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const model = await store.updateModel(req.params.id, req.body);
|
||||||
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
res.json(publicModel(model));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch metadata (license, commercial use, description) from the source —
|
||||||
|
// without re-downloading any files.
|
||||||
|
app.post('/api/models/:id/refresh-metadata', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const model = await store.getModel(req.params.id);
|
||||||
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
if (!model.source_site || (!model.source_url && !model.source_id)) {
|
||||||
|
return res.status(400).json({ error: 'This model has no source to refresh from.' });
|
||||||
|
}
|
||||||
|
const info = await refreshMetadata(model);
|
||||||
|
const updates = { license: info.license ?? null, commercial_use: info.commercial ?? null };
|
||||||
|
if (info.description) updates.description = info.description;
|
||||||
|
const updated = await store.updateModel(model.id, updates);
|
||||||
|
res.json(publicModel(updated));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Returns { license, commercial, description } from the model's source.
|
||||||
|
async function refreshMetadata(model) {
|
||||||
|
const site = model.source_site;
|
||||||
|
if (site === 'cubee3d') return { license: 'Cubee3D Commercial License', commercial: true };
|
||||||
|
if (site === 'printables' && model.source_id) {
|
||||||
|
const { value: token } = await resolveSetting('printables_token');
|
||||||
|
const p = await printables.fetchModel(model.source_id, token);
|
||||||
|
return { license: p.license, commercial: licenses.inferCommercial(p.licenseContent || p.license) };
|
||||||
|
}
|
||||||
|
if (site === 'thingiverse' && model.source_id) {
|
||||||
|
const { value: token } = await resolveSetting('thingiverse_token');
|
||||||
|
if (!token) throw new Error('Thingiverse token required (set it in Settings).');
|
||||||
|
const meta = await thingiverse.fetchModel(model.source_id, token);
|
||||||
|
return { license: meta.license, commercial: licenses.inferCommercial(meta.license), description: meta.description };
|
||||||
|
}
|
||||||
|
// makerworld / web: read the page (FlareSolverr handles Cloudflare).
|
||||||
|
const html = await fetchPageHtml(model.source_url);
|
||||||
|
const meta = generic.parseMetadata(html, model.source_url);
|
||||||
|
const license = site === 'makerworld' ? makerworld.parseLicense(html) : meta.license;
|
||||||
|
return { license, commercial: licenses.inferCommercial(license), description: meta.description };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-download files from the source into an existing model (recovers imports
|
||||||
|
// that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds
|
||||||
|
// files not already present.
|
||||||
|
app.post('/api/models/:id/download-files', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const model = await store.getModel(req.params.id);
|
||||||
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
if (!model.source_site || (!model.source_url && !model.source_id)) {
|
||||||
|
return res.status(400).json({ error: 'This model has no source to download from.' });
|
||||||
|
}
|
||||||
|
const existingNames = new Set((model.files || []).map((f) => f.originalName));
|
||||||
|
const newFiles = await fetchSourceFiles(model, existingNames);
|
||||||
|
if (newFiles.length === 0) {
|
||||||
|
return res.status(200).json({ ...publicModel(model), added: 0 });
|
||||||
|
}
|
||||||
|
const updated = await store.addFiles(model.id, newFiles);
|
||||||
|
res.json({ ...publicModel(updated), added: newFiles.length });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Returns model files[] downloaded from the model's source, skipping any whose
|
||||||
|
// original name is already present.
|
||||||
|
async function fetchSourceFiles(model, skipNames) {
|
||||||
|
if (model.source_site === 'makerworld') {
|
||||||
|
const html = await fetchPageHtml(model.source_url);
|
||||||
|
return downloadMakerworldFiles(html, skipNames);
|
||||||
|
}
|
||||||
|
if (model.source_site === 'printables' && model.source_id) {
|
||||||
|
const { files } = await downloadPrintablesFiles(model.source_id, skipNames);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
if (model.source_site === 'thingiverse' && model.source_id) {
|
||||||
|
const { value: token } = await resolveSetting('thingiverse_token');
|
||||||
|
if (!token) { const e = new Error('Thingiverse token required (set it in Settings).'); e.status = 400; throw e; }
|
||||||
|
const meta = await thingiverse.fetchModel(model.source_id, token);
|
||||||
|
return downloadThingiverseFiles(meta, skipNames);
|
||||||
|
}
|
||||||
|
const e = new Error('No downloadable file source for this model.');
|
||||||
|
e.status = 400;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.delete('/api/models/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const removed = await store.deleteModel(req.params.id);
|
||||||
|
if (!removed) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
||||||
|
for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
||||||
|
if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
|
||||||
|
res.json({ message: 'Model deleted' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store a client-rendered thumbnail (PNG data URL) for a model.
|
||||||
|
app.post('/api/models/:id/thumbnail', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const model = await store.getModel(req.params.id);
|
||||||
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
|
||||||
|
const match = /^data:image\/png;base64,(.+)$/.exec(req.body.image || '');
|
||||||
|
if (!match) return res.status(400).json({ error: 'Expected a PNG data URL' });
|
||||||
|
|
||||||
|
if (!fs.existsSync('./thumbnails')) fs.mkdirSync('./thumbnails', { recursive: true });
|
||||||
|
const name = `model-${model.id}-${Date.now()}.png`;
|
||||||
|
fs.writeFileSync(path.join('./thumbnails', name), Buffer.from(match[1], 'base64'));
|
||||||
|
|
||||||
|
// Remove the previous generated thumbnail, if any.
|
||||||
|
if (model.thumbnail_path && fs.existsSync(model.thumbnail_path)) {
|
||||||
|
try { fs.unlinkSync(model.thumbnail_path); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await store.updateModel(model.id, { thumbnail_path: `./thumbnails/${name}` });
|
||||||
|
res.json(publicModel(updated));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download all of a model's files as a single zip.
|
||||||
|
app.get('/api/models/:id/download', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
'SELECT original_name, file_path FROM files WHERE model_id = $1 ORDER BY id', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'No files for this model' });
|
||||||
|
|
||||||
|
const model = await store.getModel(req.params.id);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
const used = new Set();
|
||||||
|
for (const f of rows) {
|
||||||
|
const abs = path.resolve(f.file_path);
|
||||||
|
if (!fs.existsSync(abs)) continue;
|
||||||
|
// de-dup entry names so identically-named files don't clobber each other
|
||||||
|
let name = f.original_name;
|
||||||
|
for (let i = 2; used.has(name); i++) {
|
||||||
|
const ext = path.extname(f.original_name);
|
||||||
|
name = `${path.basename(f.original_name, ext)} (${i})${ext}`;
|
||||||
|
}
|
||||||
|
used.add(name);
|
||||||
|
zip.addLocalFile(abs, '', name);
|
||||||
|
}
|
||||||
|
const zipName = `${(model && model.name ? model.name : 'model').replace(/[^\w.-]+/g, '_')}.zip`;
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${zipName}"`);
|
||||||
|
res.set('Content-Type', 'application/zip');
|
||||||
|
res.send(zip.toBuffer());
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve a raw model file (used by the in-browser 3D viewer).
|
||||||
|
app.get('/api/files/:id/raw', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query('SELECT file_path, original_name FROM files WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
|
||||||
|
const abs = path.resolve(rows[0].file_path);
|
||||||
|
if (!fs.existsSync(abs)) return res.status(404).json({ error: 'File missing on disk' });
|
||||||
|
res.download(abs, rows[0].original_name);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve a 3MF as GLB, converting on first request (cached thereafter). This lets
|
||||||
|
// the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox.
|
||||||
|
const glbConversions = new Map(); // fileId -> in-flight Promise
|
||||||
|
|
||||||
|
app.get('/api/files/:id/glb', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await db.query('SELECT id, file_path, format, glb_path FROM files WHERE id = $1', [req.params.id]);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
|
||||||
|
const file = rows[0];
|
||||||
|
if (file.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' });
|
||||||
|
|
||||||
|
let glbPath = file.glb_path;
|
||||||
|
if (!glbPath || !fs.existsSync(glbPath)) {
|
||||||
|
if (!glbConversions.has(file.id)) {
|
||||||
|
glbConversions.set(file.id, convertFileToGlb(file).finally(() => glbConversions.delete(file.id)));
|
||||||
|
}
|
||||||
|
glbPath = await glbConversions.get(file.id);
|
||||||
|
}
|
||||||
|
res.sendFile(path.resolve(glbPath));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function convertFileToGlb(file) {
|
||||||
|
if (!fs.existsSync('./glb')) fs.mkdirSync('./glb', { recursive: true });
|
||||||
|
const out = path.join('./glb', `${file.id}.glb`);
|
||||||
|
const result = await converter.convert3mfToGlb(path.resolve(file.file_path), path.resolve(out));
|
||||||
|
await store.updateFileConversion(file.id, { glb_path: out, volume: result.volume_cm3, bbox: result.bbox });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- import
|
||||||
|
// Unified import. Thingiverse downloads files automatically (needs a token);
|
||||||
|
// Printables / MakerWorld / any other URL import page metadata + preview image,
|
||||||
|
// and the user attaches the files afterwards.
|
||||||
|
app.post('/api/import', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const url = (req.body.url || '').trim();
|
||||||
|
const projects = req.body.projects || [];
|
||||||
|
const detected = generic.detectSite(url);
|
||||||
|
if (!detected) return res.status(400).json({ error: 'That does not look like a valid URL.' });
|
||||||
|
|
||||||
|
if (detected.site === 'thingiverse') {
|
||||||
|
const model = await importThingiverse(url, projects);
|
||||||
|
return res.status(201).json(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata-only import for everything else.
|
||||||
|
if (detected.id) {
|
||||||
|
const existing = await store.findModelBySource(detected.site, detected.id);
|
||||||
|
if (existing) return res.status(409).json({ error: 'Already imported', model: publicModel(existing) });
|
||||||
|
}
|
||||||
|
// Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked).
|
||||||
|
// If it still can't be read, fall back to a stub so it's not a dead end.
|
||||||
|
let html = null;
|
||||||
|
let meta = { title: null, description: '', image: null, author: null };
|
||||||
|
let metadataFetched = false;
|
||||||
|
try {
|
||||||
|
html = await fetchPageHtml(url);
|
||||||
|
meta = generic.parseMetadata(html, url);
|
||||||
|
// Printables og:title carries a " | Download free STL model | Printables.com" suffix.
|
||||||
|
if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim();
|
||||||
|
// cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead.
|
||||||
|
if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url);
|
||||||
|
metadataFetched = Boolean(meta.title);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Metadata fetch failed, creating stub model:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the actual files where we can (MakerWorld via FlareSolverr+token,
|
||||||
|
// Printables via its public GraphQL API), and capture the license.
|
||||||
|
let files = [];
|
||||||
|
let license = meta.license || null;
|
||||||
|
let commercial = null;
|
||||||
|
if (detected.site === 'makerworld' && html) {
|
||||||
|
files = await downloadMakerworldFiles(html).catch((err) => {
|
||||||
|
console.warn('MakerWorld file download failed:', err.message);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
license = makerworld.parseLicense(html) || license;
|
||||||
|
commercial = licenses.inferCommercial(license);
|
||||||
|
} else if (detected.site === 'printables' && detected.id) {
|
||||||
|
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
|
||||||
|
console.warn('Printables file download failed:', err.message);
|
||||||
|
return { files: [] };
|
||||||
|
});
|
||||||
|
files = result.files;
|
||||||
|
if (result.license) license = result.license;
|
||||||
|
commercial = result.commercial ?? licenses.inferCommercial(license);
|
||||||
|
} else if (detected.site === 'cubee3d') {
|
||||||
|
// Auth-gated Wix site — files added manually. You hold a commercial license.
|
||||||
|
license = 'Cubee3D Commercial License';
|
||||||
|
commercial = true;
|
||||||
|
} else {
|
||||||
|
commercial = licenses.inferCommercial(license);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
|
||||||
|
const model = await store.createModel({
|
||||||
|
name: meta.title || generic.nameFromUrl(url),
|
||||||
|
description: meta.description || '',
|
||||||
|
source_site: detected.site,
|
||||||
|
source_url: url,
|
||||||
|
source_id: detected.id,
|
||||||
|
license,
|
||||||
|
commercial_use: commercial,
|
||||||
|
designer: meta.author ? { name: meta.author, source_site: detected.site } : null,
|
||||||
|
thumbnail_path: thumbnail,
|
||||||
|
tags: [],
|
||||||
|
projects,
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importThingiverse(url, projects) {
|
||||||
|
const { value: token } = await resolveSetting('thingiverse_token');
|
||||||
|
if (!token) { const e = new Error('Thingiverse needs an API token — add one in Settings.'); e.status = 400; throw e; }
|
||||||
|
const thingId = thingiverse.parse(url);
|
||||||
|
if (!thingId) { const e = new Error('Could not find a Thingiverse thing id in that URL.'); e.status = 400; throw e; }
|
||||||
|
const existing = await store.findModelBySource('thingiverse', String(thingId));
|
||||||
|
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
|
||||||
|
const meta = await thingiverse.fetchModel(thingId, token);
|
||||||
|
return publicModel(await downloadAndCreateModel(meta, projects));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a page's HTML. Tries a direct request first; if the response is a
|
||||||
|
// Cloudflare challenge (or the fetch fails) and FlareSolverr is configured,
|
||||||
|
// retries through FlareSolverr to solve the challenge.
|
||||||
|
async function fetchPageHtml(url) {
|
||||||
|
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
||||||
|
let direct = null;
|
||||||
|
try {
|
||||||
|
direct = await generic.fetchHtml(url);
|
||||||
|
if (direct.ok && !generic.isCloudflareChallenge(direct.html)) return direct.html;
|
||||||
|
} catch (err) {
|
||||||
|
if (!flareUrl) throw err;
|
||||||
|
}
|
||||||
|
if (flareUrl) return flaresolverr.solve(flareUrl, url);
|
||||||
|
if (direct && direct.ok) return direct.html; // 200 but looked like a challenge
|
||||||
|
throw new Error(`Could not fetch page (HTTP ${direct ? direct.status : 'error'}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
|
||||||
|
// skipNames: original filenames already present (so a re-download only adds new ones).
|
||||||
|
async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
||||||
|
const { value: token } = await resolveSetting('makerworld_token');
|
||||||
|
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
||||||
|
if (!token || !flareUrl) return [];
|
||||||
|
|
||||||
|
const instances = makerworld.parseInstances(html);
|
||||||
|
ensureUploadDir();
|
||||||
|
const files = [];
|
||||||
|
for (let i = 0; i < instances.length; i++) {
|
||||||
|
if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls
|
||||||
|
const inst = instances[i];
|
||||||
|
let dl;
|
||||||
|
try {
|
||||||
|
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!dl || !dl.url) continue;
|
||||||
|
const original = dl.name || `instance-${inst.id}.3mf`;
|
||||||
|
if (skipNames.has(original)) continue;
|
||||||
|
const resp = await fetch(dl.url);
|
||||||
|
if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; }
|
||||||
|
const fileName = uniqueName(original);
|
||||||
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
||||||
|
files.push(describeFile(filePath, fileName, original));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download a Printables model's files via its public GraphQL API (token optional).
|
||||||
|
// Returns { files, license, commercial }.
|
||||||
|
async function downloadPrintablesFiles(printId, skipNames = new Set()) {
|
||||||
|
const { value: token } = await resolveSetting('printables_token');
|
||||||
|
const model = await printables.fetchModel(printId, token);
|
||||||
|
ensureUploadDir();
|
||||||
|
const files = [];
|
||||||
|
for (const f of model.files) {
|
||||||
|
if (!fileProcessor.isAllowed(f.name)) continue; // skip pdfs/images/etc.
|
||||||
|
if (skipNames.has(f.name)) continue;
|
||||||
|
let link;
|
||||||
|
try {
|
||||||
|
link = await printables.getDownloadLink(printId, f.fileType, f.id, token);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Printables link failed for ${f.name}:`, err.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resp = await fetch(link);
|
||||||
|
if (!resp.ok) { console.warn(`Printables CDN download ${resp.status} for ${f.name}`); continue; }
|
||||||
|
const fileName = uniqueName(f.name);
|
||||||
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
||||||
|
files.push(describeFile(filePath, fileName, f.name));
|
||||||
|
}
|
||||||
|
const commercial = licenses.inferCommercial(model.licenseContent || model.license);
|
||||||
|
return { files, license: model.license, commercial };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download an og:image to thumbnails/ and return its path (or null on failure).
|
||||||
|
async function downloadImageThumbnail(imageUrl) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(imageUrl);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const type = resp.headers.get('content-type') || '';
|
||||||
|
const ext = type.includes('png') ? 'png' : type.includes('webp') ? 'webp' : type.includes('gif') ? 'gif' : 'jpg';
|
||||||
|
if (!fs.existsSync('./thumbnails')) fs.mkdirSync('./thumbnails', { recursive: true });
|
||||||
|
const name = `import-${Date.now()}-${Math.round(Math.random() * 1e6)}.${ext}`;
|
||||||
|
fs.writeFileSync(path.join('./thumbnails', name), Buffer.from(await resp.arrayBuffer()));
|
||||||
|
return `./thumbnails/${name}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview image download failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the model files from a Thingiverse metadata object.
|
||||||
|
async function downloadThingiverseFiles(meta, skipNames = new Set()) {
|
||||||
|
ensureUploadDir();
|
||||||
|
const files = [];
|
||||||
|
for (const dl of meta.downloads) {
|
||||||
|
if (!fileProcessor.isAllowed(dl.name)) continue; // skip readmes/images
|
||||||
|
if (skipNames.has(dl.name)) continue;
|
||||||
|
const resp = await fetch(dl.url);
|
||||||
|
if (!resp.ok) { console.error(`Download failed (${resp.status}): ${dl.name}`); continue; }
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const fileName = uniqueName(dl.name);
|
||||||
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
files.push(describeFile(filePath, fileName, dl.name));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download every file referenced by an importer's metadata, then create the model.
|
||||||
|
async function downloadAndCreateModel(meta, projects) {
|
||||||
|
const files = await downloadThingiverseFiles(meta);
|
||||||
|
if (files.length === 0) throw new Error('No importable model files found on that page.');
|
||||||
|
|
||||||
|
return store.createModel({
|
||||||
|
name: meta.name,
|
||||||
|
description: meta.description,
|
||||||
|
source_site: meta.source_site,
|
||||||
|
source_url: meta.source_url,
|
||||||
|
source_id: meta.source_id,
|
||||||
|
license: meta.license,
|
||||||
|
commercial_use: licenses.inferCommercial(meta.license),
|
||||||
|
designer: meta.designer,
|
||||||
|
tags: meta.tags,
|
||||||
|
projects,
|
||||||
|
thumbnail_path: pickThumbnail(files),
|
||||||
|
files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- projects
|
||||||
|
app.get('/api/projects', async (req, res, next) => {
|
||||||
|
try { res.json(await store.getAllProjects()); } catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.body.name) return res.status(400).json({ error: 'Collection name is required' });
|
||||||
|
res.status(201).json(await store.createProject(req.body));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/projects/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const project = await store.updateProject(req.params.id, req.body);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Collection not found' });
|
||||||
|
res.json(project);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ok = await store.deleteProject(req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ error: 'Collection not found' });
|
||||||
|
res.json({ message: 'Collection deleted' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- tags
|
||||||
|
app.get('/api/tags', async (req, res, next) => {
|
||||||
|
try { res.json(await store.getAllTags()); } catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- misc
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central error handler.
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (!err.status) console.error('Request error:', err.message);
|
||||||
|
res.status(err.status || 500).json({ error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
db.init()
|
||||||
|
.then(() => app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)))
|
||||||
|
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
||||||
44
tools/convert_3mf.py
Normal file
44
tools/convert_3mf.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
|
||||||
|
|
||||||
|
Usage: convert_3mf.py <input.3mf> <output.glb>
|
||||||
|
Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import trimesh
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(json.dumps({"error": "usage: convert_3mf.py <in.3mf> <out.glb>"}))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
in_path, out_path = sys.argv[1], sys.argv[2]
|
||||||
|
scene = trimesh.load(in_path, force="scene")
|
||||||
|
geoms = list(scene.geometry.values())
|
||||||
|
if not geoms:
|
||||||
|
print(json.dumps({"error": "no geometry found in 3MF"}))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
scene.export(out_path, file_type="glb")
|
||||||
|
|
||||||
|
combined = trimesh.util.concatenate(geoms)
|
||||||
|
ext = combined.bounds[1] - combined.bounds[0]
|
||||||
|
summary = {
|
||||||
|
"glb": out_path,
|
||||||
|
"bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)},
|
||||||
|
"faces": int(sum(len(g.faces) for g in geoms)),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
# trimesh volume is in model units (mm) -> cm^3 to match the rest of the app
|
||||||
|
summary["volume_cm3"] = round(float(combined.volume) / 1000.0, 3)
|
||||||
|
except Exception:
|
||||||
|
summary["volume_cm3"] = None
|
||||||
|
|
||||||
|
print(json.dumps(summary))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue