Add first-run setup wizard for import sources
All checks were successful
build-and-push / docker (push) Successful in 11s

On a fresh install (no integration settings yet), prompt once for the
Thingiverse/MakerWorld/Printables tokens and FlareSolverr URL, with a
"skip for now" option. /api/config exposes setupComplete (true if any
setting is configured or setup was dismissed); POST /api/setup/complete
records dismissal. DB password stays a deploy-time env var by necessity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-22 20:35:18 +10:00
parent 5d69acd718
commit 5fb3939bcd
3 changed files with 103 additions and 0 deletions

View file

@ -15,6 +15,7 @@ const uploadModal = document.getElementById('uploadModal');
const projectModal = document.getElementById('projectModal'); const projectModal = document.getElementById('projectModal');
const importModal = document.getElementById('importModal'); const importModal = document.getElementById('importModal');
const settingsModal = document.getElementById('settingsModal'); const settingsModal = document.getElementById('settingsModal');
const setupModal = document.getElementById('setupModal');
const editModal = document.getElementById('editModal'); const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal'); const fileDetailModal = document.getElementById('fileDetailModal');
@ -39,6 +40,11 @@ async function loadData() {
renderModels(); renderModels();
updateCounts(); updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
// First-run onboarding: prompt for import sources once.
if (config.setupComplete === false && !setupModal.classList.contains('active')) {
setupModal.classList.add('active');
}
} catch (error) { } catch (error) {
console.error('Error loading data:', error); console.error('Error loading data:', error);
} }
@ -170,9 +176,13 @@ function setupEventListeners() {
btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal'))); btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal')));
}); });
document.querySelectorAll('.modal').forEach((modal) => { document.querySelectorAll('.modal').forEach((modal) => {
if (modal.id === 'setupModal') return; // must be finished or skipped, not backdrop-dismissed
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); }); modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
}); });
document.getElementById('setupForm').addEventListener('submit', handleFinishSetup);
document.getElementById('skipSetup').addEventListener('click', handleSkipSetup);
document.getElementById('uploadForm').addEventListener('submit', handleUpload); document.getElementById('uploadForm').addEventListener('submit', handleUpload);
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal)); document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
document.getElementById('projectForm').addEventListener('submit', handleCreateProject); document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
@ -259,6 +269,42 @@ function openProjectModal() {
projectModal.classList.add('active'); projectModal.classList.add('active');
} }
async function markSetupComplete() {
try {
await fetch('/api/setup/complete', { method: 'POST' });
config.setupComplete = true;
} catch { /* non-fatal */ }
}
async function handleFinishSetup(e) {
e.preventDefault();
const status = document.getElementById('setupStatus');
const payload = {};
document.querySelectorAll('#setupForm [data-setting]').forEach((input) => {
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
});
try {
if (Object.keys(payload).length) {
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);
}
await markSetupComplete();
closeModal(setupModal);
await loadData();
} catch (err) {
status.textContent = 'Could not save: ' + err.message;
}
}
async function handleSkipSetup() {
await markSetupComplete();
closeModal(setupModal);
}
async function openSettingsModal() { async function openSettingsModal() {
document.getElementById('settingsForm').reset(); document.getElementById('settingsForm').reset();
document.getElementById('settingsStatus').textContent = ''; document.getElementById('settingsStatus').textContent = '';

View file

@ -69,6 +69,50 @@
</div> </div>
</div> </div>
<!-- First-run Setup Modal -->
<div id="setupModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Welcome to Printhive</h2>
</div>
<div class="modal-body">
<p class="form-hint" style="margin-bottom:18px">
Optionally connect import sources now — you can change these any time in Settings.
All are optional; you can also just upload files directly.
</p>
<form id="setupForm">
<div class="form-group">
<label for="setupThingiverse">Thingiverse API token</label>
<input type="password" id="setupThingiverse" data-setting="thingiverse_token" autocomplete="off"
placeholder="App Token from thingiverse.com/developers">
<p class="form-hint">Enables automatic Thingiverse imports.</p>
</div>
<div class="form-group">
<label for="setupFlaresolverr">FlareSolverr URL</label>
<input type="text" id="setupFlaresolverr" data-setting="flaresolverr_url" autocomplete="off"
placeholder="http://192.168.1.124:8191">
<p class="form-hint">Lets imports read Cloudflare-protected sites like MakerWorld.</p>
</div>
<div class="form-group">
<label for="setupMakerworld">MakerWorld auth token</label>
<input type="password" id="setupMakerworld" data-setting="makerworld_token" autocomplete="off"
placeholder="Bearer token from your logged-in session">
</div>
<div class="form-group">
<label for="setupPrintables">Printables auth token</label>
<input type="password" id="setupPrintables" data-setting="printables_token" autocomplete="off"
placeholder="Optional — for account-gated files">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="skipSetup">Skip for now</button>
<button type="submit" class="btn btn-primary">Save &amp; finish</button>
</div>
</form>
<div id="setupStatus" class="import-status"></div>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settingsModal" class="modal"> <div id="settingsModal" class="modal">
<div class="modal-content"> <div class="modal-content">

View file

@ -98,9 +98,14 @@ app.get('/api/config', async (req, res, next) => {
try { try {
const thingiverse = await resolveSetting('thingiverse_token'); const thingiverse = await resolveSetting('thingiverse_token');
const flare = await resolveSetting('flaresolverr_url'); const flare = await resolveSetting('flaresolverr_url');
const makerworld = await resolveSetting('makerworld_token');
const printables = await resolveSetting('printables_token');
const alreadyConfigured = Boolean(thingiverse.value || flare.value || makerworld.value || printables.value);
const setupComplete = alreadyConfigured || Boolean(await store.getSetting('setup_complete'));
res.json({ res.json({
importers: { thingiverse: Boolean(thingiverse.value), flaresolverr: Boolean(flare.value) }, importers: { thingiverse: Boolean(thingiverse.value), flaresolverr: Boolean(flare.value) },
formats: fileProcessor.MODEL_FORMATS, formats: fileProcessor.MODEL_FORMATS,
setupComplete,
}); });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -126,6 +131,14 @@ app.get('/api/settings', async (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// Mark the first-run setup wizard as done (so it won't show again).
app.post('/api/setup/complete', async (req, res, next) => {
try {
await store.setSetting('setup_complete', 'true');
res.json({ ok: true });
} catch (e) { next(e); }
});
app.put('/api/settings', async (req, res, next) => { app.put('/api/settings', async (req, res, next) => {
try { try {
for (const [key, value] of Object.entries(req.body || {})) { for (const [key, value] of Object.entries(req.body || {})) {