Add backend admin/auth wiring and pattern viewer

This commit is contained in:
chris 2025-12-15 14:27:56 -05:00
parent 0d6f4a9df3
commit d750cd88f4
14 changed files with 4823 additions and 104 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
npm-debug.log
yarn-error.log
.npmrc
.DS_Store
server/node_modules
server/.env
db_data
server/uploads
coverage
dist

8
.gitignore vendored
View File

@ -1,2 +1,10 @@
# macOS metadata
.DS_Store
# Dependencies and build artifacts
node_modules/
server/node_modules/
db_data/
uploads/
server/uploads/
*.log

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Build deps for sharp on alpine
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
# Install dependencies first (better layer caching)
COPY server/package*.json ./server/
WORKDIR /app/server
RUN npm install --production
# Copy the rest of the app (frontend + backend)
WORKDIR /app
COPY . .
WORKDIR /app/server
EXPOSE 4000
CMD ["npm", "start"]

View File

@ -2,6 +2,14 @@
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || [];
if (!Array.isArray(patterns)) patterns = [];
let auth = {
token: localStorage.getItem('authToken') || '',
email: '',
isAdmin: false,
status: 'unknown',
mode: 'login'
};
let pendingUsers = [];
let saveFlashTimer = null;
const crochetAbbrev = [
{ code: 'alt', desc: 'alternate' },
@ -278,6 +286,7 @@ function normalizePatternDraft(d = {}) {
}
let patternDraft = normalizePatternDraft(JSON.parse(localStorage.getItem('crochetPatternDraft')) || {});
let currentPatternId = null;
// New Earthy/Woodland Palette extracted from image vibes
const colors = [
'#a17d63', // Soft oak
@ -467,6 +476,7 @@ function clearPatternOutput() {
patternDraft.output = '';
patternDraft.currentRow = 1;
patternDraft.line = '';
currentPatternId = null;
patternDraft.meta = { title: '', designer: '' };
patternDraft.materials = '';
patternDraft.gauge = '';
@ -478,6 +488,7 @@ function clearPatternOutput() {
patternDraft.stitches = '';
patternDraft.notes = '';
patternDraft.steps = [];
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
if (patternOutput) patternOutput.value = '';
persistPatternDraft();
syncPatternUI();
@ -592,6 +603,7 @@ function exportPatternPDF() {
function showPatternTab(tab) {
document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab));
document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab));
try { localStorage.setItem('patternActiveTab', tab); } catch (e) {}
}
function updateMetaField(field, value) {
@ -690,6 +702,196 @@ function renderSteps() {
container.appendChild(addRow);
}
function renderPatternView() {
const container = document.getElementById('patternView');
if (!container) return;
const steps = patternDraft.steps || [];
const meta = patternDraft.meta || {};
const mats = patternDraft.materials || '';
const gauge = patternDraft.gauge || '';
const gaugeSts = patternDraft.gaugeSts || '';
const gaugeRows = patternDraft.gaugeRows || '';
const gaugeHook = patternDraft.gaugeHook || '';
const size = patternDraft.size || '';
container.innerHTML = `
<div class="view-card">
<h3 class="view-title">${meta.title || 'Pattern'}</h3>
<p class="view-sub">${meta.designer || ''}</p>
${mats ? `<h4>Materials</h4><pre>${mats}</pre>` : ''}
${(gauge || gaugeSts || gaugeRows || gaugeHook || size) ? `<h4>Gauge / Size</h4><pre>${[gaugeSts, gaugeRows, gaugeHook, size, gauge].filter(Boolean).join(' • ')}</pre>` : ''}
</div>
<div class="view-card">
<h4>Steps</h4>
<div class="view-steps">
${steps.map((st, i) => `
<div class="view-step ${st.finished ? 'is-finished' : ''}">
<header>
<div class="title">Step ${i + 1}${st.title ? ': ' + st.title : ''}</div>
<label class="view-check">
<input type="checkbox" data-view-step="${i}" ${st.finished ? 'checked' : ''}>
<i class="fa-regular fa-square"></i>
<i class="fa-solid fa-square-check"></i>
</label>
</header>
<div class="view-step-body">
<ul class="rows">${(st.rows || []).map((r, idx) => `<li>Row ${idx + 1}: ${r}</li>`).join('') || '<li class="muted">No rows yet.</li>'}</ul>
<div style="margin-top:6px;">
<label>Notes</label>
<textarea data-view-note="${i}" placeholder="Notes for this step...">${st.viewNote || ''}</textarea>
</div>
</div>
</div>
`).join('')}
</div>
</div>
<div class="view-card">
<h4>Notes</h4>
<textarea data-view-global-note placeholder="Notes or reminders...">${patternDraft.viewGlobalNote || ''}</textarea>
</div>
`;
container.querySelectorAll('input[type="checkbox"][data-view-step]').forEach(cb => {
cb.addEventListener('change', (e) => {
const idx = Number(e.target.dataset.viewStep);
if (!patternDraft.steps[idx]) return;
patternDraft.steps[idx].finished = e.target.checked;
persistPatternDraft();
save();
const card = e.target.closest('.view-step');
if (card) card.classList.toggle('is-finished', e.target.checked);
});
});
container.querySelectorAll('textarea[data-view-note]').forEach(area => {
area.addEventListener('input', (e) => {
const idx = Number(e.target.dataset.viewNote);
if (!patternDraft.steps[idx]) return;
patternDraft.steps[idx].viewNote = e.target.value;
persistPatternDraft();
});
});
const globalNote = container.querySelector('textarea[data-view-global-note]');
if (globalNote) {
globalNote.addEventListener('input', (e) => {
patternDraft.viewGlobalNote = e.target.value;
persistPatternDraft();
});
}
}
function renderPatternLibrary() {
const lib = document.getElementById('patternLibrary');
if (!lib) return;
if (!patterns.length) {
lib.innerHTML = `<p style="color: var(--text-muted);">No saved patterns yet. Save your draft to add it here.</p>`;
return;
}
lib.innerHTML = patterns.map(p => {
const subtitle = p.draft?.meta?.designer || '';
return `
<div class="pattern-library-item">
<div>
<div class="pattern-lib-title">${p.name || 'Pattern'}</div>
<div class="pattern-lib-subtitle">${subtitle}</div>
</div>
<div class="pattern-lib-actions">
<button class="secondary" onclick="loadPatternFromLibrary('${p.id}')">Load</button>
<button class="secondary danger" onclick="deletePatternFromLibrary('${p.id}')">Delete</button>
</div>
</div>
`;
}).join('');
}
function savePatternDraft() {
const name = patternDraft.meta.title?.trim() || 'Pattern';
const draftCopy = JSON.parse(JSON.stringify(patternDraft));
if (!Array.isArray(patterns)) patterns = [];
const stepsForProjects = draftCopy.steps || [];
if (currentPatternId) {
const idx = patterns.findIndex(p => p.id === currentPatternId);
if (idx >= 0) {
patterns[idx] = { id: currentPatternId, name, draft: draftCopy };
} else {
const id = currentPatternId;
patterns.push({ id, name, draft: draftCopy });
}
} else {
const id = `pat-${Date.now()}`;
currentPatternId = id;
patterns.push({ id, name, draft: draftCopy });
}
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
// Propagate instructions to linked projects
let changed = false;
projects.forEach(project => {
if (project.patternId === currentPatternId && Array.isArray(project.parts)) {
project.parts.forEach((part, idx) => {
const st = stepsForProjects[idx];
if (!st) return;
part.instructions = { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] };
if (part.instructionsCollapsed === undefined) part.instructionsCollapsed = true;
changed = true;
});
}
});
if (changed) save();
renderPatternLibrary();
showAlert({ title: 'Saved', text: `"${name}" added to your basket.` });
}
function loadPatternFromLibrary(id) {
if (!Array.isArray(patterns)) patterns = [];
const found = patterns.find(p => p.id === id);
if (!found) {
showAlert({ title: 'Not found', text: 'That saved pattern was not found.' });
return;
}
try {
const draftCopy = JSON.parse(JSON.stringify(found.draft || {}));
patternDraft = normalizePatternDraft(draftCopy);
currentPatternId = found.id;
// Recompute row counters based on existing output
const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== '');
patternDraft.currentRow = Math.max(1, lines.length + 1);
// Update any linked projects to use latest instructions
let changed = false;
projects.forEach(project => {
if (project.patternId === currentPatternId && Array.isArray(project.parts)) {
(patternDraft.steps || []).forEach((st, idx) => {
if (!project.parts[idx]) return;
project.parts[idx].instructions = { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] };
project.parts[idx].instructionsCollapsed = project.parts[idx].instructionsCollapsed ?? true;
changed = true;
});
}
});
if (changed) save();
} catch (err) {
showAlert({ title: 'Load failed', text: 'Saved pattern data is invalid.' });
return;
}
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
syncPatternUI();
renderPatternLibrary();
showPatternTab('steps');
showAlert({ title: 'Loaded', text: `"${found.name}" loaded into the composer.` });
}
function deletePatternFromLibrary(id) {
patterns = patterns.filter(p => p.id !== id);
if (currentPatternId === id) currentPatternId = null;
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
renderPatternLibrary();
}
function togglePartInstructions(pId, partId) {
const project = projects.find(p => p.id === pId);
if (!project) return;
const part = project.parts.find(pt => pt.id === partId);
if (!part || !part.instructions) return;
part.instructionsCollapsed = !part.instructionsCollapsed;
save();
}
function addStep() {
patternDraft.steps.push({ title: '', rows: [], rowDraft: '', note: '', image: '' });
persistPatternDraft();
@ -875,7 +1077,10 @@ function openPatternComposer() {
if (!patternOverlay) return;
patternOverlay.classList.add('active');
syncPatternUI();
showPatternTab('steps');
renderPatternLibrary();
renderPatternView();
const savedTab = localStorage.getItem('patternActiveTab') || 'steps';
showPatternTab(savedTab);
}
function closePatternComposer() {
@ -892,6 +1097,130 @@ let fireflyTimer = null;
let fireflyActive = false;
let titleClicks = [];
let easterEggCooling = false;
// --- Auth State (backend) ---
function updateAuthUI() {
const badge = document.getElementById('authStatusBadge');
const lastSync = document.getElementById('authLastSync');
const whenIn = document.querySelector('.auth-when-in');
const whenOut = document.querySelector('.auth-when-out');
const tabs = document.querySelector('.auth-tabs');
const displayNameInput = document.getElementById('authDisplayName');
const noteInput = document.getElementById('authNote');
const signedIn = !!auth.token;
if (badge) {
badge.textContent = signedIn ? `Signed in: ${auth.email || 'Account'}` : 'Signed out';
badge.classList.toggle('is-on', signedIn);
}
if (lastSync) {
lastSync.textContent = `Status: ${auth.status || 'unknown'}`;
}
if (whenIn) whenIn.style.display = signedIn ? 'block' : 'none';
if (whenOut) whenOut.style.display = signedIn ? 'none' : 'block';
if (tabs) tabs.style.display = signedIn ? 'none' : 'flex';
if (displayNameInput && auth.profile) displayNameInput.value = auth.profile.display_name || '';
if (noteInput && auth.profile) noteInput.value = auth.profile.note || '';
const adminPanel = document.getElementById('adminPanel');
if (adminPanel) adminPanel.style.display = signedIn && auth.isAdmin ? 'block' : 'none';
}
function openAuthModal() {
const overlay = document.getElementById('authOverlay');
if (!overlay) return;
overlay.classList.add('active');
updateAuthUI();
}
function closeAuthModal() {
const overlay = document.getElementById('authOverlay');
if (!overlay) return;
overlay.classList.remove('active');
}
function setAuthMode(mode) {
document.querySelectorAll('.auth-tab').forEach(btn => {
btn.classList.toggle('is-active', btn.dataset.mode === mode);
});
auth.mode = mode;
}
async function submitAuth(event) {
if (event) event.preventDefault();
const email = (document.getElementById('authEmail') || {}).value || '';
const password = (document.getElementById('authPassword') || {}).value || '';
if (!email || !password) {
alert('Please enter email and password to continue.');
return false;
}
try {
const endpoint = auth.mode === 'signup' ? '/api/signup' : '/api/login';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Auth failed', text: data.error || 'Invalid credentials' });
return false;
}
auth = { token: data.token, email: data.email, isAdmin: !!data.is_admin, status: data.status || 'active', mode: 'login' };
localStorage.setItem('authToken', auth.token);
updateAuthUI();
closeAuthModal();
await fetchProfile();
} catch (err) {
showAlert({ title: 'Auth failed', text: err.message });
}
return false;
}
async function autoSync() {
await fetchProfile();
}
async function saveProfile() {
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
const note = (document.getElementById('authNote') || {}).value || '';
try {
await fetch('/api/me', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${auth.token}` },
body: JSON.stringify({ displayName, note })
});
await fetchProfile();
showAlert({ title: 'Profile saved' });
} catch (err) {
showAlert({ title: 'Profile save failed', text: err.message });
}
}
async function fetchProfile() {
if (!auth.token) return;
const resp = await fetch('/api/me', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Profile fetch failed', text: data.error || 'Error' });
return;
}
auth.profile = data.profile;
auth.isAdmin = !!data.profile.is_admin;
auth.status = data.profile.status || 'active';
document.getElementById('authDisplayName').value = data.profile.display_name || '';
document.getElementById('authNote').value = data.profile.note || '';
updateAuthUI();
}
async function logoutAuth() {
try {
await fetch('/api/logout', { method: 'POST', headers: { Authorization: `Bearer ${auth.token}` } });
} catch (e) {}
auth = { token: '', email: '', isAdmin: false, status: 'unknown' };
localStorage.removeItem('authToken');
updateAuthUI();
closeAuthModal();
}
// --- Service Worker ---
if ('serviceWorker' in navigator) {
@ -938,6 +1267,29 @@ if (installBtn) {
if (isStandalone()) hideInstall();
// Initialize auth UI
updateAuthUI();
// expose auth helpers
window.openAuthModal = openAuthModal;
window.closeAuthModal = closeAuthModal;
window.setAuthMode = setAuthMode;
window.submitAuth = submitAuth;
window.autoSync = autoSync;
window.saveProfile = saveProfile;
window.logoutAuth = logoutAuth;
window.savePatternDraft = savePatternDraft;
window.loadPatternFromLibrary = loadPatternFromLibrary;
window.deletePatternFromLibrary = deletePatternFromLibrary;
window.sharePattern = sharePattern;
window.togglePartInstructions = togglePartInstructions;
window.fetchPendingUsers = fetchPendingUsers;
window.downloadBackup = downloadBackup;
window.uploadRestore = uploadRestore;
window.setAuthMode = setAuthMode;
window.approveUser = approveUser;
window.suspendUser = suspendUser;
window.makeAdmin = makeAdmin;
// --- Sweet-ish Alerts ---
function removeSwal() {
const existing = document.querySelector('.swal-overlay');
@ -1492,7 +1844,54 @@ async function resetCount(pId, partId) {
function saveProjectAsPattern(pId) {
const project = projects.find(p => p.id === pId);
if (!project) return;
openModal('savePattern', pId);
const patternName = project ? `${project.name}` : 'Pattern';
// If a different pattern is loaded, warn before overwriting the active draft
if (currentPatternId) {
showConfirm({
title: 'Overwrite current draft?',
text: 'Creating a pattern from this project will replace the draft currently in the composer.',
confirmText: 'Replace draft',
cancelText: 'Cancel',
danger: false
}).then(ok => {
if (ok) buildPatternFromProject(project, patternName);
});
return;
}
buildPatternFromProject(project, patternName);
}
function buildPatternFromProject(project, patternName) {
// build patternDraft from project parts
const steps = (project.parts || []).map((part, idx) => ({
title: part.name || `Part ${idx + 1}`,
rows: [],
rowDraft: '',
note: part.note || '',
image: ''
}));
const newDraft = normalizePatternDraft({
mode: 'crochet',
meta: { title: patternName, designer: '' },
materials: '',
gauge: '',
gaugeSts: '',
gaugeRows: '',
gaugeHook: '',
size: '',
abbrev: patternDraft.abbrev,
abbrevSelection: patternDraft.abbrevSelection || [],
stitches: '',
notes: project.note || '',
steps
});
patternDraft = newDraft;
currentPatternId = null;
persistPatternDraft();
syncPatternUI();
renderPatternLibrary();
showPatternTab('steps');
showAlert({ title: 'Pattern created', text: `"${patternName}" is ready in the composer.` });
}
// --- Modal Logic ---
@ -1552,31 +1951,34 @@ function closeModal() {
if (modalState.type === 'addProject') {
const nextColor = colors[projects.length % colors.length];
const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [] };
const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [], patternId: null };
const selectedPatternId = patternSelect ? patternSelect.value : '';
const chosenPattern = selectedPatternId ? patterns.find(p => String(p.id) === selectedPatternId) : null;
if (chosenPattern && Array.isArray(chosenPattern.parts) && chosenPattern.parts.length) {
chosenPattern.parts.forEach((pt, idx) => {
if (chosenPattern && chosenPattern.draft && Array.isArray(chosenPattern.draft.steps) && chosenPattern.draft.steps.length) {
newProject.patternId = chosenPattern.id;
chosenPattern.draft.steps.forEach((st, idx) => {
newProject.parts.push({
id: Date.now() + idx + 1,
name: pt.name || `Part ${idx + 1}`,
name: st.title || `Step ${idx + 1}`,
count: 0,
locked: false,
finished: false,
minimized: false,
max: pt.max ?? null,
color: pt.color || newProject.color,
note: ''
max: null,
color: nextColor,
note: '',
instructionsCollapsed: true,
instructions: { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] }
});
});
} else {
newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '' });
newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '', instructionsCollapsed: true, instructions: null });
}
projects.push(newProject);
}
else if (modalState.type === 'addPart') {
const project = projects.find(p => p.id === modalState.pId);
project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color, note: '' });
project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color, note: '', instructionsCollapsed: true, instructions: null });
project.collapsed = false;
}
else if (modalState.type === 'renamePart') {
@ -1710,6 +2112,19 @@ function render() {
<button class="action-btn ${lockBtnClass}" onclick="togglePartLock(${project.id}, ${part.id})">${lockIcon}</button>
<button class="action-btn btn-plus ${controlsDimmed}" onclick="updateCount(${project.id}, ${part.id}, 1)">+</button>
</div>
${part.instructions ? `
<div class="instructions-block ${part.instructionsCollapsed ? 'collapsed' : ''}">
<div class="instructions-head">
<span>Pattern</span>
<button class="note-toggle" onclick="togglePartInstructions(${project.id}, ${part.id})">${part.instructionsCollapsed ? 'Show' : 'Hide'}</button>
</div>
<div class="instructions-body">
<div class="instruction-title">${part.instructions.title || ''}</div>
<ul class="instruction-rows">
${(part.instructions.rows || []).map((r,i)=>`<li>Row ${i+1}: ${r}</li>`).join('') || '<li class="muted">No rows</li>'}
</ul>
</div>
</div>` : ''}
<div class="note-area" id="${partNoteId}">
<textarea placeholder="Notes for this part..." oninput="updatePartNote(event, ${project.id}, ${part.id})">${part.note || ''}</textarea>
</div>
@ -1730,7 +2145,13 @@ function render() {
</div>
<div class="project-actions">
<button class="btn-add-part" onclick="openModal('addPart', ${project.id})">+ Part</button>
${project.patternId ? `
<span class="project-link-icon" title="Linked to pattern">
<i class="fa-solid fa-link"></i>
</span>
` : `
<button class="btn-save-pattern" onclick="saveProjectAsPattern(${project.id})" title="Save as pattern"><i class="fa-solid fa-swatchbook"></i></button>
`}
<button class="btn-delete-project" onclick="deleteProject(${project.id})">×</button>
</div>
</div>
@ -1749,13 +2170,126 @@ function render() {
}
render();
if (auth.token) {
fetchProfile().catch(()=>{});
}
function flashSave() {
const el = document.getElementById('patternSaveIndicator');
const mini = document.getElementById('patternSaveIndicatorMini');
if (!el) return;
el.textContent = 'Saved';
el.style.opacity = '1';
if (mini) mini.textContent = 'Saved';
if (saveFlashTimer) clearTimeout(saveFlashTimer);
saveFlashTimer = setTimeout(() => {
el.style.opacity = '0.6';
}, 1200);
}
function sharePattern() {
try {
const payload = { patternDraft };
const json = JSON.stringify(payload);
const b64 = btoa(unescape(encodeURIComponent(json)));
const base = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}`;
const url = `${base}pattern-viewer.html?data=${encodeURIComponent(b64)}`;
navigator.clipboard?.writeText(url);
showAlert({ title: 'Share link ready', text: 'Link copied to clipboard. Open on any device to view checklist.' });
} catch (err) {
showAlert({ title: 'Share failed', text: err.message });
}
}
async function fetchPendingUsers() {
if (!auth.token || !auth.isAdmin) return;
const resp = await fetch('/api/admin/users/pending', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Failed', text: data.error || 'Could not load pending users' });
return;
}
pendingUsers = data.users || [];
const list = document.getElementById('pendingList');
if (list) {
list.innerHTML = pendingUsers.map(u => `
<div class="admin-item">
<div class="meta">
<strong>${u.email}</strong>
<span>${u.display_name || ''}</span>
</div>
<div class="admin-actions">
<button class="modal-btn btn-save" onclick="approveUser('${u.id}')">Approve</button>
<button class="modal-btn btn-cancel" onclick="suspendUser('${u.id}')">Suspend</button>
<button class="modal-btn btn-save" onclick="makeAdmin('${u.id}')">Make admin</button>
</div>
</div>
`).join('') || '<p class="auth-hint">No pending users.</p>';
}
}
async function updateUserStatus(id, status) {
const resp = await fetch(`/api/admin/users/${id}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ status })
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Failed', text: data.error || 'Update failed' });
} else {
fetchPendingUsers();
}
}
async function approveUser(id) { return updateUserStatus(id, 'active'); }
async function suspendUser(id) { return updateUserStatus(id, 'suspended'); }
async function makeAdmin(id) {
const resp = await fetch(`/api/admin/users/${id}/admin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify({ is_admin: true })
});
const data = await resp.json();
if (!resp.ok) showAlert({ title: 'Failed', text: data.error || 'Admin update failed' });
else fetchPendingUsers();
}
async function downloadBackup() {
const resp = await fetch('/api/admin/backup', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Backup failed', text: data.error || 'Error' });
return;
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup_${new Date().toISOString()}.json`;
a.click();
URL.revokeObjectURL(url);
}
async function uploadRestore(event) {
const file = event.target.files[0];
if (!file) return;
try {
const text = await file.text();
const payload = JSON.parse(text);
const resp = await fetch('/api/admin/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Restore failed', text: data.error || 'Error' });
} else {
showAlert({ title: 'Restore complete', text: 'Data restored.' });
}
} catch (err) {
showAlert({ title: 'Restore failed', text: err.message });
} finally {
event.target.value = '';
}
}

View File

@ -184,6 +184,33 @@ h1 {
.pattern-mode { border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 6px 10px; border-radius: 10px; cursor: pointer; }
.pattern-mode.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
.pattern-save-indicator { color: var(--text-muted); font-size: 0.9rem; }
.pattern-save-indicator.small { font-size: 0.85rem; }
.pattern-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--input-bg);
margin-bottom: 12px;
}
.pattern-toolbar-left { display: flex; gap: 8px; flex-wrap: wrap; }
.pattern-toolbar-right { display: flex; align-items: center; }
.toolbar-btn {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
padding: 8px 12px;
display: inline-flex;
gap: 6px;
align-items: center;
cursor: pointer;
}
.toolbar-btn i { color: var(--project-color); }
.toolbar-btn:hover { border-color: var(--project-color); }
.pattern-body { display: grid; gap: 12px; }
.pattern-row-info { color: var(--text-muted); font-size: 0.95rem; }
.pattern-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
@ -193,14 +220,45 @@ h1 {
.pattern-tab {
background: var(--input-bg);
border: 1px solid var(--border);
padding: 6px 10px;
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
color: var(--text);
font-weight: 700;
}
.pattern-tab.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
.pattern-section { display: none; border: 1px dashed var(--border); padding: 12px; border-radius: 12px; background: var(--card-bg); }
.pattern-section { display: none; border: 1px solid var(--border); padding: 14px; border-radius: 14px; background: var(--card-bg); box-shadow: 0 10px 22px rgba(0,0,0,0.08); }
.pattern-section.is-active { display: block; }
.pattern-view { display: grid; gap: 10px; }
.view-card {
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
background: var(--card-bg);
box-shadow: 0 6px 14px rgba(0,0,0,0.08);
}
.view-title { margin: 0 0 4px; font-size: 1.1rem; }
.view-sub { margin: 0; color: var(--text-muted); font-size: 0.95rem; }
.view-steps { display: grid; gap: 8px; }
.view-step {
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
background: var(--input-bg);
}
.view-step header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
.view-step header .title { font-weight: 800; }
.view-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.view-check input { position: absolute; opacity: 0; pointer-events: none; }
.view-check .fa-square { color: var(--text-muted); }
.view-check .fa-square-check { color: var(--project-color); display: none; }
.view-check input:checked + .fa-square { display: none; }
.view-check input:checked ~ .fa-square-check { display: inline; }
.view-step .rows { margin: 0; padding-left: 16px; color: var(--text); }
.view-step textarea { width: 100%; min-height: 60px; border-radius: 10px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); padding: 8px; }
.view-step.is-finished .view-step-body { display: none; }
.view-step.is-finished { opacity: 0.8; }
.field-label { display: block; margin: 8px 0 4px; color: var(--text-muted); font-size: 0.9rem; }
.pattern-section input, .pattern-section textarea {
width: 100%;
@ -257,6 +315,37 @@ h1 {
.add-step-row .add-step-btn { padding: 10px 16px; border-radius: 12px; border: none; background: var(--project-color); color: var(--card-bg); cursor: pointer; font-weight: 700; }
.add-step-row .add-step-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.12); }
.add-step-row .add-step-btn:active { transform: translateY(0); box-shadow: none; }
.pattern-library { display: grid; gap: 10px; max-height: 320px; overflow-y: auto; }
.pattern-library-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: var(--input-bg);
border: 1px solid var(--border);
}
.pattern-lib-title { font-weight: 800; color: var(--text); }
.pattern-lib-subtitle { color: var(--text-muted); font-size: 0.9rem; }
.pattern-lib-actions { display: flex; gap: 8px; }
.instructions-block {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
padding: 8px 10px;
margin-top: 8px;
}
.instructions-head { display: flex; justify-content: space-between; align-items: center; }
.instructions-body { margin-top: 6px; display: ${'' /* fallback */}; }
.instructions-block.collapsed .instructions-body { display: none; }
.instruction-title { font-weight: 700; margin-bottom: 4px; }
.instruction-rows { margin: 0; padding-left: 16px; }
.instruction-rows li { margin: 2px 0; color: var(--text); }
.instruction-rows .muted { color: var(--text-muted); }
.step-number {
font-weight: 800;
color: var(--project-color);
@ -416,6 +505,65 @@ h1 {
.save-actions { display: flex; justify-content: flex-end; gap: 8px; }
.icon-woodland { width: 22px; height: 22px; }
/* Auth Modal */
.auth-overlay {
position: fixed;
inset: 0;
background: rgba(44, 35, 25, 0.6);
display: none;
align-items: center;
justify-content: center;
z-index: 220;
padding: 20px;
backdrop-filter: blur(2px);
}
.auth-overlay.active { display: flex; }
.auth-modal {
background: var(--card-bg);
color: var(--text);
border-radius: 16px;
padding: 18px 18px 14px;
width: min(440px, 92vw);
box-shadow: 0 14px 32px rgba(0,0,0,0.22);
border: 1px solid var(--border);
}
.auth-modal-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }
.auth-subtext { margin: 4px 0 10px; color: var(--text-muted); font-size: 0.92rem; }
.auth-status { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 6px; flex-wrap: wrap; }
.status-pill { padding: 6px 10px; border-radius: 999px; background: var(--input-bg); border: 1px solid var(--border); font-weight: 700; color: var(--text); }
.status-pill.is-on { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
.status-subtext { color: var(--text-muted); font-size: 0.9rem; }
.auth-tabs { display: flex; gap: 8px; margin: 6px 0 12px; }
.auth-tab {
background: var(--input-bg);
border: 1px solid var(--border);
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
color: var(--text);
font-weight: 700;
}
.auth-tab.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
.auth-form { display: grid; gap: 8px; }
.auth-hint { margin: 4px 0; color: var(--text-muted); font-size: 0.9rem; }
.auth-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.auth-actions-stack { flex-wrap: wrap; justify-content: flex-start; }
.auth-when-in { display: none; }
.auth-when-out { display: block; }
.admin-panel { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: grid; gap: 8px; }
.admin-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.admin-list { display: grid; gap: 8px; max-height: 200px; overflow-y: auto; }
.admin-item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); }
.admin-item .meta { display: grid; gap: 2px; }
.admin-item .modal-btn { padding: 6px 10px; }
@media (max-width: 820px) {
.pattern-sheet { padding: 12px; }
.pattern-toolbar { flex-direction: column; align-items: flex-start; }
.pattern-tabs { justify-content: center; }
.pattern-tab { flex: 1 1 120px; text-align: center; }
}
.container {
max-width: 1200px;
margin: 0 auto;
@ -485,6 +633,17 @@ h1 {
border-radius: 14px; padding: 6px 10px; font-size: 0.9rem; cursor: pointer;
}
.btn-save-pattern:hover { color: var(--project-color); border-color: var(--project-color); }
.project-link-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--input-bg);
border: 1px solid var(--border);
color: var(--text);
}
.btn-delete-project {
background: none; border: none; color: var(--text-muted); font-size: 1.2rem; cursor: pointer; padding: 5px;

31
docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
services:
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_USER: toadstool
POSTGRES_PASSWORD: toadstool
POSTGRES_DB: toadstool
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "5440:5432"
api:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
PORT: 4000
UPLOAD_DIR: /app/server/uploads
ADMIN_EMAIL: chris@chrisedwards.tech
ADMIN_PASSWORD: R4e3w2q1
volumes:
- ./server/uploads:/app/server/uploads
depends_on:
- db
ports:
- "4000:4000"
volumes:
db_data:

View File

@ -28,6 +28,7 @@
<button class="header-btn" id="themeBtn" onclick="toggleTheme()" title="Toggle Dark Mode"><i class="fa-solid fa-moon"></i></button>
<button class="header-btn" id="focusBtn" onclick="toggleFocusMode()" title="Focus Mode (Keeps Screen On)"><i class="fa-solid fa-eye"></i></button>
<button class="header-btn" id="saveLoadBtn" onclick="openSaveModal()" title="Save/Load"><i class="fa-solid fa-floppy-disk"></i></button>
<button class="header-btn" id="authBtn" onclick="openAuthModal()" title="Sign in to sync"><i class="fa-solid fa-user"></i></button>
</div>
</header>
<input type="file" id="importFile" accept="application/json" class="hidden-input" />
@ -87,6 +88,62 @@
</div>
</div>
<div class="auth-overlay" id="authOverlay">
<div class="auth-modal">
<div class="auth-modal-head">
<div>
<h3 class="color-title">Sign in (optional)</h3>
<p class="auth-subtext">Stay free forever. Sign in only if you want cloud backups and device switching.</p>
</div>
<button class="pattern-close" onclick="closeAuthModal()">&times;</button>
</div>
<div class="auth-status">
<span id="authStatusBadge" class="status-pill">Signed out</span>
<span id="authLastSync" class="status-subtext">Last sync: never</span>
</div>
<div class="auth-tabs">
<button class="auth-tab" data-mode="login" onclick="setAuthMode('login')">Login</button>
<button class="auth-tab" data-mode="signup" onclick="setAuthMode('signup')">Sign up</button>
</div>
<form class="auth-form" onsubmit="return submitAuth(event)">
<div class="auth-when-out">
<label class="field-label" for="authEmail">Email</label>
<input id="authEmail" type="email" placeholder="you@example.com" autocomplete="email">
<label class="field-label" for="authPassword">Password</label>
<input id="authPassword" type="password" placeholder="••••••••" autocomplete="current-password">
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device. When enabled, well sync projects/patterns securely.</p>
<div class="auth-actions">
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
<button type="submit" class="modal-btn btn-save">Continue</button>
</div>
</div>
<div class="auth-when-in">
<label class="field-label" for="authDisplayName">Display name</label>
<input id="authDisplayName" type="text" placeholder="Your name">
<label class="field-label" for="authNote">Profile note</label>
<textarea id="authNote" rows="2" placeholder="Add a note for your patterns..."></textarea>
<div class="auth-actions auth-actions-stack">
<button type="button" class="modal-btn btn-save" onclick="autoSync()">Sync now</button>
<button type="button" class="modal-btn btn-save" onclick="saveProfile()">Save profile</button>
<button type="button" class="modal-btn btn-cancel" onclick="logoutAuth()">Log out</button>
</div>
<div id="adminPanel" class="admin-panel" style="display:none;">
<h4>Admin</h4>
<div class="admin-actions">
<button type="button" class="modal-btn btn-save" onclick="fetchPendingUsers()">Refresh pending</button>
<button type="button" class="modal-btn btn-save" onclick="downloadBackup()">Download backup</button>
<label class="modal-btn btn-save">
Restore backup
<input type="file" id="restoreInput" accept="application/json" style="display:none;" onchange="uploadRestore(event)">
</label>
</div>
<div id="pendingList" class="admin-list"></div>
</div>
</div>
</form>
</div>
</div>
<div class="pattern-overlay" id="patternOverlay">
<div class="pattern-sheet">
<div class="pattern-sheet-header">
@ -101,10 +158,23 @@
<div class="pattern-save-indicator" id="patternSaveIndicator">Saved</div>
<button class="pattern-close" onclick="closePatternComposer()">&times;</button>
</div>
<div class="pattern-toolbar">
<div class="pattern-toolbar-left">
<button class="toolbar-btn" onclick="savePatternDraft()" title="Save draft to basket"><i class="fa-solid fa-bookmark"></i> Save</button>
<button class="toolbar-btn" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i> PDF</button>
<button class="toolbar-btn" onclick="sharePattern()" title="Share link"><i class="fa-solid fa-link"></i> Share</button>
<button class="toolbar-btn" onclick="clearPatternOutput()" title="Clear draft"><i class="fa-solid fa-eraser"></i> Clear</button>
</div>
<div class="pattern-toolbar-right">
<span class="pattern-save-indicator small" id="patternSaveIndicatorMini">Saved</span>
</div>
</div>
<div class="pattern-body">
<div class="pattern-tabs">
<button class="pattern-tab" data-tab="info" onclick="showPatternTab('info')">Pattern Info</button>
<button class="pattern-tab" data-tab="steps" onclick="showPatternTab('steps')">Steps</button>
<button class="pattern-tab" data-tab="view" onclick="showPatternTab('view')">View</button>
<button class="pattern-tab" data-tab="library" onclick="showPatternTab('library')">My Basket</button>
</div>
<div class="pattern-section" data-section="info">
<label class="field-label" for="patternTitle">Title</label>
@ -140,10 +210,7 @@
<label class="field-label" for="patternNotes">Notes / finishing</label>
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
<div class="pattern-row-actions pattern-footer">
<button onclick="clearPatternOutput()">Clear pattern</button>
<button class="secondary" onclick="exportPatternJSON()">Export JSON</button>
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
<button class="primary" onclick="exportPatternPDF()">Export PDF</button>
</div>
</div>
<div class="pattern-section" data-section="steps">
@ -153,96 +220,15 @@
</div>
<div id="patternSteps"></div>
</div>
</div>
</div>
</div>
<div class="pattern-overlay" id="patternOverlay">
<div class="pattern-sheet">
<div class="pattern-sheet-header">
<div class="pattern-sheet-title">
<h2>Pattern Composer</h2>
<p class="pattern-sheet-subtitle">Draft rows plus materials, gauge, and abbreviations.</p>
</div>
<div class="pattern-modes">
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
</div>
<button class="pattern-close" onclick="closePatternComposer()">&times;</button>
</div>
<div class="pattern-body">
<div class="pattern-tabs">
<button class="pattern-tab" data-tab="meta" onclick="showPatternTab('meta')">Cover</button>
<button class="pattern-tab" data-tab="materials" onclick="showPatternTab('materials')">Materials</button>
<button class="pattern-tab" data-tab="gauge" onclick="showPatternTab('gauge')">Gauge/Size</button>
<button class="pattern-tab" data-tab="abbrev" onclick="showPatternTab('abbrev')">Abbrev</button>
<button class="pattern-tab" data-tab="stitches" onclick="showPatternTab('stitches')">Stitches</button>
<button class="pattern-tab" data-tab="steps" onclick="showPatternTab('steps')">Steps</button>
<button class="pattern-tab" data-tab="notes" onclick="showPatternTab('notes')">Notes</button>
</div>
<div class="pattern-section" data-section="meta">
<label class="field-label" for="patternTitle">Title</label>
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
<label class="field-label" for="patternDesigner">Designer / Credits</label>
<input id="patternDesigner" type="text" placeholder="Your name or shop">
</div>
<div class="pattern-section" data-section="materials">
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
<textarea id="patternMaterials" placeholder="Yarn (Color A) worsted\nYarn (Color B) accent\nHook 4.0 mm\nSafety eyes, stuffing, needle"></textarea>
</div>
<div class="pattern-section" data-section="gauge">
<label class="field-label" for="patternGauge">Gauge</label>
<textarea id="patternGauge" placeholder="e.g., 16 sc x 18 rows = 4”/10 cm with 4.0 mm hook"></textarea>
<label class="field-label" for="patternSize">Finished size</label>
<input id="patternSize" type="text" placeholder="Approx. 6 in / 15 cm tall">
</div>
<div class="pattern-section" data-section="abbrev">
<label class="field-label" for="patternAbbrev">Abbreviations</label>
<textarea id="patternAbbrev" placeholder="sc single crochet\ndc double crochet\ninc increase\nk knit\np purl"></textarea>
</div>
<div class="pattern-section" data-section="stitches">
<label class="field-label" for="patternStitches">Stitch guide / special stitches</label>
<textarea id="patternStitches" placeholder="Magic ring: ...&#10;Invisible decrease: ...&#10;Kfb: knit front and back ..."></textarea>
</div>
<div class="pattern-section" data-section="steps">
<div class="pattern-row-info">Row <span id="patternRowNumber">1</span></div>
<div class="pattern-buttons">
<button onclick="addPatternToken('sc')">sc</button>
<button onclick="addPatternToken('hdc')">hdc</button>
<button onclick="addPatternToken('dc')">dc</button>
<button onclick="addPatternToken('ch')">ch</button>
<button onclick="addPatternToken('sl st')">sl st</button>
<button onclick="addPatternToken('inc')">inc</button>
<button onclick="addPatternToken('dec')">dec</button>
<button onclick="addPatternToken('k')">k</button>
<button onclick="addPatternToken('p')">p</button>
<button onclick="addPatternToken('yo')">yo</button>
<button onclick="addPatternToken('k2tog')">k2tog</button>
<button onclick="addPatternToken('[ ] x')">[ ] x</button>
</div>
<div class="pattern-row-editor">
<textarea id="patternLine" placeholder="Build a row with the buttons above or type manually..."></textarea>
<div class="pattern-row-actions">
<button onclick="clearPatternLine()">Clear line</button>
<button class="primary" onclick="addPatternRow()">Add row</button>
</div>
</div>
<div class="pattern-section" data-section="library">
<div class="pattern-steps-head">
<h4>Steps</h4>
<button class="primary" onclick="addStep()">+ Step</button>
<h4>Saved Patterns</h4>
<button class="primary" onclick="savePatternDraft()">Save Draft to Basket</button>
</div>
<div id="patternSteps"></div>
</div>
<div class="pattern-section" data-section="notes">
<label class="field-label" for="patternNotes">Notes / finishing</label>
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
</div>
<div class="pattern-output">
<label for="patternOutput">Pattern draft (rows)</label>
<textarea id="patternOutput" placeholder="Rows will appear here as you add them..."></textarea>
<div class="pattern-row-actions">
<button onclick="clearPatternOutput()">Clear pattern</button>
<div id="patternLibrary" class="pattern-library"></div>
</div>
<div class="pattern-section" data-section="view">
<div id="patternView" class="pattern-view"></div>
</div>
</div>
</div>

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "toadstooltally",
"version": "1.0.0",
"description": "PWA-friendly, cottagecore row counter for crochet/knitting projects. Manage projects and parts, set max stitches, lock/finish, and enjoy a themed experience with install support and offline caching.",
"main": "sw.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

112
pattern-viewer.html Normal file
View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pattern Viewer</title>
<link rel="stylesheet" href="assets/style.css">
<style>
body { background: var(--bg); color: var(--text); padding: 16px; }
.viewer { max-width: 720px; margin: 0 auto; padding: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 10px 24px rgba(0,0,0,0.1); }
.viewer h1 { margin: 0 0 6px; }
.viewer h3 { margin: 12px 0 6px; }
.viewer pre { white-space: pre-wrap; background: var(--input-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); }
.step-list { display: grid; gap: 10px; margin: 12px 0; }
.step-card { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: var(--card-bg); box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.step-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.step-title input { margin-right: 8px; }
.step-rows { margin-top: 6px; }
.note-box { width: 100%; min-height: 80px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 8px; }
.meta { display: grid; gap: 6px; margin: 10px 0; font-size: 0.95rem; }
.pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: var(--input-bg); border: 1px solid var(--border); font-size: 0.85rem; margin-right: 6px; margin-bottom: 4px; }
.top-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.back-link { text-decoration: none; color: var(--text); font-weight: 700; }
.back-link:hover { color: var(--project-color); }
@media (max-width: 600px) { .viewer { padding: 12px; } }
</style>
</head>
<body>
<div class="viewer">
<div class="top-actions">
<a class="back-link" href="./">← Back to app</a>
<span id="viewerStatus" class="pill">Progress saved locally</span>
</div>
<h1 id="pvTitle">Pattern</h1>
<div id="pvDesigner" class="meta"></div>
<div id="pvMaterials"></div>
<h3>Steps</h3>
<div id="pvSteps" class="step-list"></div>
<h3>Notes</h3>
<textarea id="pvNotes" class="note-box" placeholder="Your notes..." aria-label="Notes"></textarea>
</div>
<script>
const params = new URLSearchParams(location.search);
const data = params.get('data');
let patternDraft = null;
try {
if (!data) throw new Error('Missing pattern data');
const json = decodeURIComponent(escape(atob(data)));
const parsed = JSON.parse(json);
if (!parsed.patternDraft) throw new Error('Invalid payload');
patternDraft = parsed.patternDraft;
} catch (e) {
document.body.innerHTML = `<div style="padding:20px;">Failed to load pattern: ${e.message}</div>`;
throw e;
}
const storageKey = `pattern-viewer-${patternDraft.meta?.title || 'pattern'}`;
let progress = JSON.parse(localStorage.getItem(storageKey) || '{}');
function saveProgress() {
localStorage.setItem(storageKey, JSON.stringify(progress));
}
function render() {
document.getElementById('pvTitle').textContent = patternDraft.meta?.title || 'Pattern';
document.getElementById('pvDesigner').textContent = patternDraft.meta?.designer || '';
const mats = patternDraft.materials || '';
document.getElementById('pvMaterials').innerHTML = mats ? `<h3>Materials</h3><pre>${mats}</pre>` : '';
const stepsEl = document.getElementById('pvSteps');
stepsEl.innerHTML = '';
(patternDraft.steps || []).forEach((step, idx) => {
const card = document.createElement('div');
card.className = 'step-card';
const rows = (step.rows || []).map((r,i) => `<div><input type="checkbox" data-step="${idx}" data-row="${i}" ${progress[idx]?.rows?.[i]?'checked':''}> Row ${i+1}: ${r}</div>`).join('');
card.innerHTML = `
<div class="step-title">
<div><strong>Step ${idx+1}</strong> ${step.title ? ' ' + step.title : ''}</div>
</div>
<div class="step-rows">${rows || '<em>No rows yet.</em>'}</div>
<div style="margin-top:6px;">
<label>Notes</label>
<textarea data-step="${idx}" class="note-box" placeholder="Notes for this step...">${progress[idx]?.note || ''}</textarea>
</div>
`;
stepsEl.appendChild(card);
});
const pvNotes = document.getElementById('pvNotes');
pvNotes.value = progress.globalNote || '';
}
document.addEventListener('change', (e) => {
if (e.target.matches('input[type="checkbox"][data-step]')) {
const s = Number(e.target.dataset.step);
const r = Number(e.target.dataset.row);
progress[s] = progress[s] || { rows: {} };
progress[s].rows = progress[s].rows || {};
progress[s].rows[r] = e.target.checked;
saveProgress();
}
});
document.addEventListener('input', (e) => {
if (e.target.matches('textarea[data-step]')) {
const s = Number(e.target.dataset.step);
progress[s] = progress[s] || { rows: {} };
progress[s].note = e.target.value;
saveProgress();
}
if (e.target.id === 'pvNotes') {
progress.globalNote = e.target.value;
saveProgress();
}
});
render();
</script>
</body>
</html>

4
server/.env.example Normal file
View File

@ -0,0 +1,4 @@
DATABASE_URL=postgres://user:pass@localhost:5432/toadstool
JWT_SECRET=change-me
PORT=4000
UPLOAD_DIR=./uploads

3138
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
server/package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "toadstooltally-backend",
"version": "0.1.0",
"description": "Backend API for Toadstool Cottage Counter (auth demo + sync endpoints).",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"lint": "eslint ."
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.3",
"pg": "^8.12.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"eslint": "^8.57.0",
"nodemon": "^3.0.2"
},
"engines": {
"node": ">=18"
}
}

309
server/src/db.js Normal file
View File

@ -0,0 +1,309 @@
const { Pool } = require('pg');
const crypto = require('crypto');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_URL?.includes('sslmode=require') ? { rejectUnauthorized: false } : false
});
async function initDb(retries = 10) {
let attempt = 0;
while (attempt < retries) {
try {
await pool.query(`
create table if not exists users (
id uuid primary key,
email text unique not null,
password_hash text not null,
display_name text default '',
note text default '',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
`);
await pool.query(`alter table users add column if not exists is_admin boolean not null default false`);
await pool.query(`alter table users add column if not exists status text not null default 'pending'`);
await pool.query(`
create table if not exists sessions (
token uuid primary key,
user_id uuid references users(id) on delete cascade,
created_at timestamptz not null default now()
);
`);
await pool.query(`
create table if not exists projects (
id text primary key,
user_id uuid references users(id) on delete cascade,
data jsonb not null,
updated_at timestamptz not null default now(),
deleted_at timestamptz
);
`);
await pool.query(`
create table if not exists patterns (
id text primary key,
user_id uuid references users(id) on delete cascade,
data jsonb not null,
updated_at timestamptz not null default now(),
deleted_at timestamptz
);
`);
// Migrate legacy UUID columns to text to allow non-UUID ids
await pool.query(`drop table if exists pattern_shares cascade`);
await pool.query(`alter table if exists projects alter column id type text using id::text`);
await pool.query(`alter table if exists patterns alter column id type text using id::text`);
await pool.query(`alter table patterns add column if not exists title text not null default ''`);
await pool.query(`alter table patterns add column if not exists slug text`);
await pool.query(`create unique index if not exists patterns_user_slug_idx on patterns(user_id, slug) where slug is not null`);
await pool.query(`
create table if not exists pattern_shares (
id uuid primary key,
pattern_id text,
token text unique not null,
is_public boolean default true,
expires_at timestamptz,
created_at timestamptz not null default now()
);
`);
await pool.query(`
create table if not exists password_resets (
token uuid primary key,
user_id uuid references users(id) on delete cascade,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
`);
// Backfill existing users to active and non-admin
await pool.query(`update users set status='active' where status is null or status=''`);
return;
} catch (err) {
attempt += 1;
if (attempt >= retries) throw err;
// eslint-disable-next-line no-console
console.log(`DB not ready, retrying (${attempt}/${retries})...`);
await new Promise(r => setTimeout(r, 1000));
}
}
}
async function withClient(fn) {
const client = await pool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
function hashPassword(pw) {
return crypto.createHash('sha256').update(pw).digest('hex');
}
async function createUser(email, password) {
const id = crypto.randomUUID();
const password_hash = hashPassword(password);
const countRes = await pool.query(`select count(*)::int as c from users`);
const isFirst = countRes.rows[0].c === 0;
await pool.query(
`insert into users (id, email, password_hash, status, is_admin) values ($1, $2, $3, $4, $5)
on conflict (email) do nothing`,
[id, email, password_hash, isFirst ? 'active' : 'pending', isFirst]
);
const row = await pool.query(`select id, email, display_name, note, is_admin, status from users where email=$1`, [email]);
return row.rows[0];
}
async function verifyUser(email, password) {
const row = await pool.query(`select * from users where email=$1`, [email]);
if (!row.rows.length) return null;
const user = row.rows[0];
if (user.password_hash !== hashPassword(password)) return null;
if (user.status !== 'active') {
return { pending: true, status: user.status };
}
return { id: user.id, email: user.email, display_name: user.display_name, note: user.note, is_admin: user.is_admin };
}
async function createSession(userId) {
const token = crypto.randomUUID();
await pool.query(`insert into sessions (token, user_id) values ($1, $2)`, [token, userId]);
return token;
}
async function getSession(token) {
const row = await pool.query(
`select s.token, u.id as user_id, u.email, u.is_admin, u.status from sessions s join users u on u.id=s.user_id where s.token=$1`,
[token]
);
return row.rows[0];
}
async function deleteSession(token) {
await pool.query(`delete from sessions where token=$1`, [token]);
}
async function upsertProfile(userId, displayName, note) {
await pool.query(
`update users set display_name=$2, note=$3, updated_at=now() where id=$1`,
[userId, displayName, note]
);
const row = await pool.query(`select email, display_name, note from users where id=$1`, [userId]);
return row.rows[0];
}
async function getProfile(userId) {
const row = await pool.query(`select email, display_name, note, is_admin, status from users where id=$1`, [userId]);
return row.rows[0];
}
async function upsertItems(table, userId, items = []) {
if (!items.length) return;
if (table === 'patterns') {
for (const item of items) {
const title = item.title || '';
const slug = item.slug || null;
const deleted = item.deleted_at || null;
await pool.query(
`insert into patterns (id, user_id, title, slug, data, updated_at, deleted_at)
values ($1, $2, $3, $4, $5, now(), $6)
on conflict (id) do update set title=excluded.title, slug=excluded.slug, data=excluded.data, updated_at=now(), deleted_at=excluded.deleted_at`,
[item.id, userId, title, slug, item.data || {}, deleted]
);
}
} else {
for (const item of items) {
const deleted = item.deleted_at || null;
await pool.query(
`insert into projects (id, user_id, data, updated_at, deleted_at)
values ($1, $2, $3, now(), $4)
on conflict (id) do update set data=excluded.data, updated_at=now(), deleted_at=excluded.deleted_at`,
[item.id, userId, item.data || {}, deleted]
);
}
}
}
async function fetchItemsSince(table, userId, since) {
if (table === 'patterns') {
const res = await pool.query(
`select id, title, slug, data, updated_at, deleted_at from patterns
where user_id=$1 and updated_at >= coalesce($2::timestamptz, 'epoch'::timestamptz)
order by updated_at asc`,
[userId, since || null]
);
return res.rows;
}
const res = await pool.query(
`select id, data, updated_at, deleted_at from projects
where user_id=$1 and updated_at >= coalesce($2::timestamptz, 'epoch'::timestamptz)
order by updated_at asc`,
[userId, since || null]
);
return res.rows;
}
async function createShare(patternId, token, isPublic = true, expiresAt = null) {
await pool.query(
`insert into pattern_shares (id, pattern_id, token, is_public, expires_at)
values ($1, $2, $3, $4, $5)`,
[crypto.randomUUID(), patternId, token, isPublic, expiresAt]
);
}
async function patternOwnedByUser(patternId, userId) {
const res = await pool.query(
`select id from patterns where id=$1 and user_id=$2 and deleted_at is null`,
[patternId, userId]
);
return res.rows.length > 0;
}
async function getSharedPattern(token) {
const res = await pool.query(
`select p.id, p.title, p.slug, p.data, p.updated_at
from pattern_shares s
join patterns p on p.id = s.pattern_id
where s.token=$1 and (s.expires_at is null or s.expires_at > now()) and s.is_public = true
limit 1`,
[token]
);
return res.rows[0] || null;
}
async function listPendingUsers() {
const res = await pool.query(`select id, email, display_name, note, created_at from users where status='pending' order by created_at asc`);
return res.rows;
}
async function setUserStatus(userId, status) {
await pool.query(`update users set status=$2, updated_at=now() where id=$1`, [userId, status]);
}
async function setUserAdmin(userId, isAdmin) {
await pool.query(`update users set is_admin=$2, updated_at=now() where id=$1`, [userId, isAdmin]);
}
async function bootstrapAdmin(email, password) {
if (!email || !password) return;
const hash = hashPassword(password);
const existing = await pool.query(`select id from users where email=$1`, [email]);
if (existing.rows.length) {
await pool.query(`update users set password_hash=$2, is_admin=true, status='active', updated_at=now() where email=$1`, [email, hash]);
} else {
const id = crypto.randomUUID();
await pool.query(
`insert into users (id, email, password_hash, is_admin, status) values ($1, $2, $3, true, 'active')`,
[id, email, hash]
);
}
}
async function listAllUsers() {
const res = await pool.query(`select id, email, display_name, status, is_admin, created_at from users order by created_at asc`);
return res.rows;
}
async function setPassword(userId, newPassword) {
const hash = hashPassword(newPassword);
await pool.query(`update users set password_hash=$2, updated_at=now() where id=$1`, [userId, hash]);
}
async function createResetToken(userId, ttlMinutes = 60) {
const token = crypto.randomUUID();
const expires = new Date(Date.now() + ttlMinutes * 60 * 1000);
await pool.query(`insert into password_resets (token, user_id, expires_at) values ($1, $2, $3)`, [token, userId, expires]);
return { token, expires };
}
async function consumeResetToken(token) {
const res = await pool.query(`select token, user_id, expires_at from password_resets where token=$1`, [token]);
if (!res.rows.length) return null;
const row = res.rows[0];
await pool.query(`delete from password_resets where token=$1`, [token]);
if (new Date(row.expires_at) < new Date()) return null;
return row.user_id;
}
module.exports = {
withClient,
initDb,
createUser,
verifyUser,
createSession,
getSession,
deleteSession,
upsertItems,
fetchItemsSince,
upsertProfile,
getProfile,
createShare,
getSharedPattern,
patternOwnedByUser,
listPendingUsers,
setUserStatus,
setUserAdmin,
bootstrapAdmin,
listAllUsers,
setPassword,
createResetToken,
consumeResetToken
};

371
server/src/index.js Normal file
View File

@ -0,0 +1,371 @@
const express = require('express');
const cors = require('cors');
const { v4: uuid } = require('uuid');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const sharp = require('sharp');
const {
initDb,
createUser,
verifyUser,
createSession,
getSession,
deleteSession,
upsertItems,
fetchItemsSince,
upsertProfile,
getProfile,
createShare,
getSharedPattern,
patternOwnedByUser,
listPendingUsers,
setUserStatus,
bootstrapAdmin,
listAllUsers,
setPassword,
createResetToken,
consumeResetToken,
setUserAdmin
} = require('./db');
const app = express();
const PORT = process.env.PORT || 4000;
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
app.use(cors());
app.use(express.json({ limit: '15mb' }));
// Init DB
initDb().catch((err) => {
// eslint-disable-next-line no-console
console.error('DB init failed', err);
process.exit(1);
}).then(() => {
if (process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
bootstrapAdmin(process.env.ADMIN_EMAIL, process.env.ADMIN_PASSWORD)
.then(() => console.log('Admin bootstrap ready'))
.catch((err) => console.error('Admin bootstrap failed', err));
}
});
const requireAuth = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
getSession(token)
.then(session => {
if (!session) return res.status(401).json({ error: 'Unauthorized' });
req.user = { id: session.user_id, email: session.email, token, is_admin: session.is_admin, status: session.status };
if (req.user.status !== 'active') return res.status(403).json({ error: 'Account not active', status: req.user.status });
return next();
})
.catch(() => res.status(401).json({ error: 'Unauthorized' }));
};
const requireAdmin = (req, res, next) => {
if (!req.user?.is_admin) return res.status(403).json({ error: 'Admin only' });
next();
};
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', version: '0.1.0', time: new Date().toISOString() });
});
// Serve static front-end from project root (../)
const clientDir = path.join(__dirname, '..', '..');
app.use(express.static(clientDir));
app.use('/uploads', express.static(UPLOAD_DIR));
app.get('/', (_req, res) => {
res.sendFile(path.join(clientDir, 'index.html'));
});
app.post('/api/signup', (req, res) => {
const { email, password, displayName = '' } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
createUser(email, password)
.then(async (user) => {
await upsertProfile(user.id, displayName, '');
const token = await createSession(user.id);
return res.json({ token, email: user.email });
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
return res.status(500).json({ error: 'Signup failed' });
});
});
app.post('/api/login', (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
verifyUser(email, password)
.then(async (user) => {
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.pending) return res.status(403).json({ error: 'Account pending approval', status: user.status });
const token = await createSession(user.id);
return res.json({ token, email: user.email, is_admin: user.is_admin });
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
return res.status(500).json({ error: 'Login failed' });
});
});
app.post('/api/logout', requireAuth, (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
deleteSession(token).finally(() => res.json({ ok: true }));
});
app.get('/api/sync', requireAuth, async (req, res) => {
const since = req.query.since;
try {
const projects = await fetchItemsSince('projects', req.user.id, since);
const patterns = await fetchItemsSince('patterns', req.user.id, since);
res.json({ projects, patterns, since: since || null, serverTime: new Date().toISOString() });
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Sync fetch failed' });
}
});
app.post('/api/sync', requireAuth, async (req, res) => {
const { projects = [], patterns = [] } = req.body || {};
try {
await upsertItems('projects', req.user.id, projects);
await upsertItems('patterns', req.user.id, patterns);
res.json({ ok: true });
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Sync failed' });
}
});
app.get('/api/me', requireAuth, (req, res) => {
getProfile(req.user.id)
.then(profile => res.json({ profile: profile || { email: req.user.email, displayName: '', note: '', is_admin: req.user.is_admin, status: req.user.status } }))
.catch(err => {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Profile fetch failed' });
});
});
app.post('/api/me', requireAuth, (req, res) => {
const { displayName = '', note = '' } = req.body || {};
upsertProfile(req.user.id, displayName, note)
.then(profile => res.json({ profile }))
.catch(err => {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Profile update failed' });
});
});
app.post('/api/patterns/:id/share', requireAuth, async (req, res) => {
const patternId = req.params.id;
const { isPublic = true, expiresAt = null } = req.body || {};
try {
const owns = await patternOwnedByUser(patternId, req.user.id);
if (!owns) return res.status(404).json({ error: 'Pattern not found' });
const token = uuid();
await createShare(patternId, token, isPublic, expiresAt);
res.json({ token, url: `/share/${token}` });
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Share failed' });
}
});
app.get('/share/:token', async (req, res) => {
try {
const shared = await getSharedPattern(req.params.token);
if (!shared) return res.status(404).json({ error: 'Not found' });
res.json({ pattern: shared });
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Share fetch failed' });
}
});
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'File required' });
const ext = (req.file.originalname.split('.').pop() || 'jpg').toLowerCase();
const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'].includes(ext) ? ext : 'jpg';
const filename = `${Date.now()}-${uuid()}.${safeExt}`;
const outPath = path.join(UPLOAD_DIR, filename);
const pipeline = sharp(req.file.buffer)
.rotate()
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true });
if (safeExt === 'png') {
pipeline.png({ quality: 80 });
} else if (safeExt === 'webp') {
pipeline.webp({ quality: 80 });
} else if (safeExt === 'avif') {
pipeline.avif({ quality: 70 });
} else {
pipeline.jpeg({ quality: 82, mozjpeg: true });
}
await pipeline.toFile(outPath);
const url = `/uploads/${filename}`;
res.json({ url });
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Upload failed' });
}
});
// Admin: list pending users
app.get('/api/admin/users/pending', requireAuth, requireAdmin, async (_req, res) => {
try {
const users = await listPendingUsers();
res.json({ users });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'List pending failed' });
}
});
// Admin: update user status
app.post('/api/admin/users/:id/status', requireAuth, requireAdmin, async (req, res) => {
const { status } = req.body || {};
if (!['pending', 'active', 'suspended'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
try {
await setUserStatus(req.params.id, status);
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Update status failed' });
}
});
// Admin: set/unset admin
app.post('/api/admin/users/:id/admin', requireAuth, requireAdmin, async (req, res) => {
const { is_admin } = req.body || {};
try {
await setUserAdmin(req.params.id, !!is_admin);
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Update admin failed' });
}
});
// Admin: list all users
app.get('/api/admin/users', requireAuth, requireAdmin, async (_req, res) => {
try {
const users = await listAllUsers();
res.json({ users });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'List users failed' });
}
});
// Password reset request (demo: returns token, logs it)
app.post('/api/password-reset/request', async (req, res) => {
const { email } = req.body || {};
if (!email) return res.status(400).json({ error: 'Email required' });
try {
const userRow = await listAllUsers().then(users => users.find(u => u.email === email));
if (!userRow) return res.json({ ok: true }); // don't leak
const reset = await createResetToken(userRow.id);
console.log(`Password reset token for ${email}: ${reset.token}`);
// TODO: send via mail backend
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Reset request failed' });
}
});
app.post('/api/password-reset/confirm', async (req, res) => {
const { token, password } = req.body || {};
if (!token || !password) return res.status(400).json({ error: 'Token and password required' });
try {
const userId = await consumeResetToken(token);
if (!userId) return res.status(400).json({ error: 'Invalid or expired token' });
await setPassword(userId, password);
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Reset failed' });
}
});
// Admin: backup (JSON export)
app.get('/api/admin/backup', requireAuth, requireAdmin, async (_req, res) => {
try {
const users = await listAllUsers();
const projects = await fetchItemsSince('projects', null, null);
const patterns = await fetchItemsSince('patterns', null, null);
res.json({ users, projects, patterns, exportedAt: new Date().toISOString() });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Backup failed' });
}
});
// Admin: restore (overwrite)
app.post('/api/admin/restore', requireAuth, requireAdmin, async (req, res) => {
const { users = [], projects = [], patterns = [] } = req.body || {};
try {
await withClient(async (client) => {
await client.query('begin');
await client.query('truncate table sessions cascade');
await client.query('truncate table users cascade');
await client.query('truncate table projects cascade');
await client.query('truncate table patterns cascade');
for (const u of users) {
await client.query(
`insert into users (id, email, password_hash, display_name, note, is_admin, status, created_at, updated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[u.id, u.email, u.password_hash || '', u.display_name || '', u.note || '', u.is_admin || false, u.status || 'active', u.created_at || new Date(), u.updated_at || new Date()]
);
}
for (const p of projects) {
await client.query(
`insert into projects (id, user_id, data, updated_at, deleted_at) values ($1,$2,$3,$4,$5)`,
[p.id, p.user_id, p.data || {}, p.updated_at || new Date(), p.deleted_at || null]
);
}
for (const p of patterns) {
await client.query(
`insert into patterns (id, user_id, title, slug, data, updated_at, deleted_at) values ($1,$2,$3,$4,$5,$6,$7)`,
[p.id, p.user_id, p.title || '', p.slug || null, p.data || {}, p.updated_at || new Date(), p.deleted_at || null]
);
}
await client.query('commit');
});
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Restore failed' });
}
});
app.use((err, _req, res, _next) => {
// Basic error guard
// eslint-disable-next-line no-console
console.error(err);
res.status(500).json({ error: 'Internal error' });
});
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`API listening on http://localhost:${PORT}`);
});