A spool fills one slot, and its colour has to be close to what the slot asks for.
`;
+
A spool fills one slot, and its colour has to be close to what the slot asks for.
+
+
+ ${yours ? '' : ''}
+
`;
+ el.querySelector('[data-slots="edit"]').addEventListener('click', () => editSlots(model, p.slots));
+ const revert = el.querySelector('[data-slots="revert"]');
+ if (revert) revert.addEventListener('click', () => revertSlots(model.id));
+}
+
+// Editing turns the list into fields: material and colour per slot, with slots
+// you can add or drop. Saved slots are marked as yours, so the background
+// reader stops overwriting them from the project file.
+function editSlots(model, slots) {
+ const el = document.getElementById('detailPrintable');
+ const rowHtml = (s = { material: 'PLA', colour: '#FFFFFF' }) => `
+
+
+
+
+
`;
+ el.innerHTML = `
+
${slots.map(rowHtml).join('')}
+
+
+
+
+
+
Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.
`;
+
+ const list = document.getElementById('slotEditList');
+ const bindDrop = (row) => row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove());
+ [...list.children].forEach(bindDrop);
+ document.getElementById('slotAdd').addEventListener('click', () => {
+ list.insertAdjacentHTML('beforeend', rowHtml());
+ bindDrop(list.lastElementChild);
+ });
+ document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model));
+ document.getElementById('slotSave').addEventListener('click', async () => {
+ const filaments = [...list.children].map((row) => ({
+ type: row.querySelector('.slot-material').value.trim(),
+ colour: row.querySelector('.slot-colour').value,
+ })).filter((f) => f.type);
+ const status = document.getElementById('slotStatus');
+ try {
+ await postJson(`/api/models/${model.id}/filaments`, { filaments }, 'PUT');
+ await loadData();
+ openModelDetail(model.id);
+ } catch (err) {
+ status.classList.add('is-error');
+ status.textContent = err.message;
+ }
+ });
+}
+
+async function revertSlots(id) {
+ try {
+ await getJson(`/api/models/${id}/filaments`, { method: 'DELETE' });
+ await loadData();
+ openModelDetail(id);
+ } catch (err) {
+ alert(err.message);
+ }
}
function renderDetailLicense(model) {
diff --git a/public/styles.css b/public/styles.css
index 0a57e02..37810f2 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -465,6 +465,10 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.watch-actions .btn-sm { align-self: auto; }
.slot-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
+.slot-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+.slot-edit .slot-material { width: 110px; padding: 5px 8px; font-size: 13px; }
+.slot-edit .slot-colour { width: 42px; padding: 2px; }
+.print-badge.yours { background: color-mix(in srgb, #6366f1 20%, transparent); color: #a5b4fc; }
.slot-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }
.print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
diff --git a/server.js b/server.js
index 735941f..110846f 100644
--- a/server.js
+++ b/server.js
@@ -464,6 +464,34 @@ async function refreshMetadata(model) {
// 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.
+// The filament slots a model needs — for when the project's own list is wrong,
+// or you plan to print it in something else. Saved as yours, so the background
+// reader won't overwrite it.
+app.put('/api/models/:id/filaments', async (req, res, next) => {
+ try {
+ const sent = (req.body || {}).filaments;
+ const cleaned = filaments.cleanSlots(sent);
+ if (!cleaned) return res.status(400).json({ error: 'Send { filaments: [{ type, colour }] }' });
+ if (sent.length > 32) return res.status(400).json({ error: 'That is more slots than any printer has.' });
+ if (!(await store.setModelFilaments(Number(req.params.id), cleaned))) {
+ return res.status(404).json({ error: 'Model not found' });
+ }
+ res.json(publicModel(await store.getModel(req.params.id)));
+ } catch (e) { next(e); }
+});
+
+// Throw away your edits and read the project's own list again.
+app.delete('/api/models/:id/filaments', async (req, res, next) => {
+ try {
+ const { rowCount } = await db.query(
+ `UPDATE models SET filaments = NULL, filaments_status = NULL WHERE id = $1 AND deleted_at IS NULL`,
+ [Number(req.params.id)]);
+ if (!rowCount) return res.status(404).json({ error: 'Model not found' });
+ fingerprint.kick(); // the reader picks it up again
+ res.json({ message: 'Back to the project’s own filament list' });
+ } catch (e) { next(e); }
+});
+
app.post('/api/models/:id/download-files', async (req, res, next) => {
try {
const model = await store.getModel(req.params.id);
diff --git a/test/filaments.test.js b/test/filaments.test.js
index fa20e00..c57e078 100644
--- a/test/filaments.test.js
+++ b/test/filaments.test.js
@@ -84,3 +84,24 @@ test('a model we know nothing about is not claimed to be printable', () => {
assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false);
assert.deepEqual(filaments.assignSpools(null, []), []);
});
+
+test('slots sent from the page are cleaned before they reach the database', () => {
+ const cleaned = filaments.cleanSlots([
+ { type: ' pla ', colour: '#ffffff' }, // trimmed, hex upper-cased
+ { type: 'PETG', colour: 'not a colour' }, // bad colour becomes "any colour"
+ { type: '', colour: '#000000' }, // no material: dropped entirely
+ { type: 'PLA', colour: '#8e9089' },
+ ]);
+ assert.deepEqual(cleaned, [
+ { type: 'pla', colour: '#FFFFFF' },
+ { type: 'PETG', colour: null },
+ { type: 'PLA', colour: '#8E9089' },
+ ]);
+});
+
+test('anything that isn’t a list of slots is refused outright', () => {
+ assert.equal(filaments.cleanSlots(null), null);
+ assert.equal(filaments.cleanSlots('PLA'), null);
+ assert.equal(filaments.cleanSlots({ type: 'PLA' }), null);
+ assert.deepEqual(filaments.cleanSlots([]), []);
+});