Revamp Pattern Composer UI, fix Backup/Restore, and improve Image Uploads

- Refactored Pattern Composer UI with new tabs (Specs, Draft, Read, Shelf).
- Added support for multiple Yarns and Hooks with integrated color pickers.
- Improved Step drafting UX: reordered list/editor, added inline actions.
- Fixed Database Backup/Restore: switched to SQL dump/restore for robustness.
- Improved Image Uploads: added WebP optimization (with fallback) and preview display.
- Updated local dev setup: added live-server proxy config and concurrently script.
This commit is contained in:
chris 2025-12-15 21:53:04 -05:00
parent d750cd88f4
commit 84909ff4e0
11 changed files with 4670 additions and 496 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ db_data/
uploads/
server/uploads/
*.log
server/.env

View File

@ -21,6 +21,40 @@ python3 -m http.server 8080
- Service worker requires an HTTP/HTTPS context; use a local server to test install/offline.
- Data is stored in `localStorage`; clear it to reset.
## Backend (optional sync)
Node + Docker demo backend lives in `server/`.
```bash
# Dev
cd server
npm install
npm run dev # http://localhost:4000
# Docker
docker build -t toadstool-api .
docker run -p 4000:4000 toadstool-api
```
### Docker Compose (app + Postgres)
```bash
docker compose up --build
# App/API: http://localhost:4000
# Postgres: localhost:5432 (user/pass/db: toadstool)
```
API (in-memory, demo):
- `POST /api/signup` `{ email, password, displayName? }` -> `{ token, email }`
- `POST /api/login` `{ email, password }` -> `{ token, email }`
- `POST /api/logout` (Bearer token)
- `GET /api/sync?since=...` (Bearer token) -> `{ projects, patterns }`
- `POST /api/sync` (Bearer token) `{ projects, patterns }`
- `GET /api/me`, `POST /api/me` (profile)
- `POST /api/patterns/:id/share` -> `{ token, url }`, `GET /share/:token` -> `{ pattern }`
- `POST /api/upload` (Bearer token, multipart `file`) -> `{ url }` (resizes/compresses to `/uploads`)
Replace with real auth/storage before production.
## PWA Notes
- Manifest: `assets/site.webmanifest`
- Service worker: `sw.js`

View File

@ -50,7 +50,7 @@ const crochetAbbrev = [
{ code: 'inc', desc: 'increase' },
{ code: 'lp', desc: 'loop' },
{ code: 'm', desc: 'marker' },
{ code: 'mc', desc: 'main color' },
{ code: 'mc', desc: 'main circle' },
{ code: 'pat', desc: 'pattern' },
{ code: 'pc', desc: 'popcorn stitch' },
{ code: 'pm', desc: 'place marker' },
@ -197,7 +197,8 @@ const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end'];
const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']);
function getActiveAbbrevLibrary() {
return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
const base = patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
return [...base, ...(patternDraft.customAbbrev || [])];
}
function getAbbrevByCode(code) {
@ -245,6 +246,17 @@ function getPatternButtonCodes() {
const merged = [...base, ...repeatTokens];
return Array.from(new Set(merged));
}
const yarnWeights = [
{ val: '0', label: 'Lace', desc: 'Fingering' },
{ val: '1', label: 'Super Fine', desc: 'Sock' },
{ val: '2', label: 'Fine', desc: 'Sport' },
{ val: '3', label: 'Light', desc: 'DK' },
{ val: '4', label: 'Medium', desc: 'Worsted' },
{ val: '5', label: 'Bulky', desc: 'Chunky' },
{ val: '6', label: 'Super Bulky', desc: 'Roving' },
{ val: '7', label: 'Jumbo', desc: 'Giant' }
];
function normalizePatternDraft(d = {}) {
const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' };
const base = {
@ -257,23 +269,39 @@ function normalizePatternDraft(d = {}) {
gauge: '',
gaugeSts: '',
gaugeRows: '',
gaugeHook: '',
size: '',
abbrev: '',
abbrevSelection: [],
customAbbrev: [],
stitches: '',
notes: '',
steps: []
steps: [],
palette: [],
yarns: [],
hooks: [],
previewSize: 'full'
};
const merged = { ...base, ...d };
merged.meta = { ...base.meta, ...(d.meta || {}) };
merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : [];
merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : [];
merged.palette = Array.isArray(merged.palette) ? merged.palette : [];
merged.yarns = Array.isArray(merged.yarns) ? merged.yarns : [];
merged.hooks = Array.isArray(merged.hooks) ? merged.hooks : [];
// Migration: Legacy yarnWeight
if (d.yarnWeight && merged.yarns.length === 0) {
merged.yarns.push({ weight: d.yarnWeight, note: 'Main' });
}
// Migration: Legacy gaugeHook
if (d.gaugeHook && merged.hooks.length === 0) {
merged.hooks.push({ size: d.gaugeHook, note: 'Main' });
}
merged.materials = merged.materials || '';
merged.gauge = merged.gauge || '';
merged.gaugeSts = merged.gaugeSts || '';
merged.gaugeRows = merged.gaugeRows || '';
merged.gaugeHook = merged.gaugeHook || '';
merged.size = merged.size || '';
merged.abbrev = merged.abbrev || '';
merged.stitches = merged.stitches || '';
@ -413,7 +441,6 @@ function syncPatternUI() {
const gaugeEl = document.getElementById('patternGauge');
const gaugeStsEl = document.getElementById('patternGaugeSts');
const gaugeRowsEl = document.getElementById('patternGaugeRows');
const gaugeHookEl = document.getElementById('patternGaugeHook');
const sizeEl = document.getElementById('patternSize');
const abbrevEl = document.getElementById('patternAbbrev');
const stitchesEl = document.getElementById('patternStitches');
@ -424,27 +451,151 @@ function syncPatternUI() {
if (gaugeEl) gaugeEl.value = patternDraft.gauge;
if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts;
if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows;
if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook;
if (sizeEl) sizeEl.value = patternDraft.size;
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
if (notesEl) notesEl.value = patternDraft.notes;
renderYarnList();
renderHookList();
renderSteps();
renderAbbrevChecklist();
renderAbbrevSummary();
}
function addPatternToken(tok) {
if (!patternLine) return;
patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok;
patternLine.value = patternDraft.line;
persistPatternDraft();
function renderYarnList() {
const el = document.getElementById('yarnList');
if (!el) return;
if (patternDraft.yarns.length === 0) {
el.innerHTML = `<button class="secondary small" onclick="addYarn()">+ Add Yarn</button>`;
return;
}
function clearPatternLine() {
if (!patternLine) return;
patternDraft.line = '';
patternLine.value = '';
el.innerHTML = patternDraft.yarns.map((y, idx) => `
<div class="yarn-item-card" style="border-left: 5px solid ${y.color || 'var(--border)'}">
<div class="yarn-header">
<span class="yarn-idx">Yarn ${String.fromCharCode(65 + idx)}</span>
<div style="display:flex; gap:8px;">
<label class="color-picker-label" style="background:${y.color || '#e0e0e0'}" title="Pick Color">
<input type="color" value="${y.color || '#e0e0e0'}" onchange="updateYarn(${idx}, 'color', this.value)">
</label>
<button class="btn-icon-small danger" onclick="removeYarn(${idx})">×</button>
</div>
</div>
<div class="yarn-weight-selector small">
${yarnWeights.map(w => `
<label class="yarn-pill small">
<input type="radio" name="yarnWeight_${idx}" value="${w.val}" ${y.weight === w.val ? 'checked' : ''} onchange="updateYarn(${idx}, 'weight', '${w.val}')">
<span class="yarn-content">
<span class="yarn-num">${w.val}</span>
<span class="yarn-label">${w.label}</span>
</span>
</label>
`).join('')}
</div>
<input type="text" class="yarn-note-input" value="${y.note || ''}" placeholder="Brand / Fiber / Note" oninput="updateYarn(${idx}, 'note', this.value)">
</div>
`).join('') + `<button class="secondary small" onclick="addYarn()" style="margin-top:8px;">+ Add Another Yarn</button>`;
}
function addYarn() {
patternDraft.yarns.push({ weight: '4', note: '', color: '#a17d63' });
persistPatternDraft();
renderYarnList();
}
function removeYarn(idx) {
patternDraft.yarns.splice(idx, 1);
persistPatternDraft();
renderYarnList();
}
function updateYarn(idx, field, val) {
if (patternDraft.yarns[idx]) {
patternDraft.yarns[idx][field] = val;
persistPatternDraft();
if (field === 'color') renderYarnList(); // Re-render to update border color
}
}
function renderHookList() {
const el = document.getElementById('hookList');
if (!el) return;
if (patternDraft.hooks.length === 0) {
el.innerHTML = `<button class="secondary small" onclick="addHook()">+ Add Hook</button>`;
return;
}
el.innerHTML = patternDraft.hooks.map((h, idx) => `
<div class="hook-item-row">
<input type="text" class="hook-size-input" value="${h.size || ''}" placeholder="Size (e.g. 4.0mm)" oninput="updateHook(${idx}, 'size', this.value)">
<input type="text" class="hook-note-input" value="${h.note || ''}" placeholder="Use (e.g. Body)" oninput="updateHook(${idx}, 'note', this.value)">
<button class="btn-icon-small danger" onclick="removeHook(${idx})">×</button>
</div>
`).join('') + `<button class="secondary small" onclick="addHook()" style="margin-top:6px;">+ Add Hook</button>`;
}
function addHook() {
patternDraft.hooks.push({ size: '', note: '' });
persistPatternDraft();
renderHookList();
}
function removeHook(idx) {
patternDraft.hooks.splice(idx, 1);
persistPatternDraft();
renderHookList();
}
function updateHook(idx, field, val) {
if (patternDraft.hooks[idx]) {
patternDraft.hooks[idx][field] = val;
persistPatternDraft();
}
}
function renderPatternPalette() {
const el = document.getElementById('patternPaletteList');
if (!el) return;
el.innerHTML = patternDraft.palette.map((c, i) => `
<div class="palette-swatch" style="background:${c}" onclick="removePatternColor(${i})" title="Remove color"></div>
`).join('');
}
function addPatternColor() {
// Open color picker but customized for pattern
// For simplicity, we'll reuse the existing color picker but hook it differently?
// Actually, let's just make a simple prompt or use the existing overlay with a special mode.
// Or just a native input.
const input = document.createElement('input');
input.type = 'color';
input.onchange = (e) => {
patternDraft.palette.push(e.target.value);
persistPatternDraft();
renderPatternPalette();
};
input.click();
}
function removePatternColor(idx) {
patternDraft.palette.splice(idx, 1);
persistPatternDraft();
renderPatternPalette();
}
function filterAbbrev() {
const term = document.getElementById('abbrevSearch').value.toLowerCase();
const items = document.querySelectorAll('.abbrev-pill');
items.forEach(el => {
const text = el.innerText.toLowerCase();
el.style.display = text.includes(term) ? 'flex' : 'none';
});
// Also hide empty groups
document.querySelectorAll('.abbrev-group').forEach(grp => {
const visible = grp.querySelectorAll('.abbrev-pill[style="display: flex;"]').length > 0;
grp.style.display = visible ? 'block' : 'none';
});
}
function addPatternRow() {
@ -579,8 +730,9 @@ function exportPatternPDF() {
<div class="section-title">Steps</div>
${patternDraft.steps.map((s, i) => {
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('<br>');
const note = s.note ? `<br>${s.note}` : '';
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows}${note}</div>`;
const note = s.note ? `<br><strong>Note:</strong> ${s.note}` : '';
const img = s.image ? `<br><img src="${s.image}" style="max-width:300px; max-height:300px; margin-top:10px; border-radius:8px;">` : '';
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows}${note}${img}</div>`;
}).join('<hr>')}
</div>
<div class="section">
@ -601,11 +753,27 @@ 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));
document.querySelectorAll('.nav-item').forEach(btn =>
btn.classList.toggle('active', btn.dataset.tab === tab)
);
// Remove split view logic for simplicity
document.querySelector('.pattern-body').classList.remove('split-view');
document.querySelectorAll('.pattern-section').forEach(sec => {
sec.classList.toggle('active', sec.dataset.section === tab);
});
try { localStorage.setItem('patternActiveTab', tab); } catch (e) {}
}
window.addEventListener('resize', () => {
const activeBtn = document.querySelector('.nav-item.active');
if (activeBtn && activeBtn.dataset.tab === 'steps') {
showPatternTab('steps');
}
});
function updateMetaField(field, value) {
patternDraft.meta[field] = value;
persistPatternDraft();
@ -620,88 +788,146 @@ function renderSteps() {
const container = document.getElementById('patternSteps');
if (!container) return;
container.innerHTML = '';
patternDraft.steps.forEach((step, idx) => {
const card = document.createElement('div');
card.className = 'pattern-step-card card-pop';
const rows = step.rows || [];
const rowList = rows.map((r, i) => `
<div class="row-item">
<label>Row ${i + 1}</label>
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}">
<button type="button" onclick="removeStepRow(${idx}, ${i})"></button>
<span class="row-num">${i + 1}.</span>
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}" placeholder="Row instructions...">
<button type="button" class="btn-icon-small" onclick="removeStepRow(${idx}, ${i})" title="Remove row"></button>
</div>
`).join('');
card.innerHTML = `
<div class="step-number">Step ${idx + 1}</div>
<label class="field-label">Title (optional)</label>
<input type="text" value="${step.title || ''}" data-idx="${idx}" data-field="title">
<div class="step-card-header">
<div class="step-title-group">
<span class="step-badge">Step ${idx + 1}</span>
<input type="text" class="step-title-input" value="${step.title || ''}" data-idx="${idx}" data-field="title" placeholder="Step Title (e.g., Head)">
</div>
<div class="step-actions-group">
<button class="btn-icon-small" onclick="moveStep(${idx}, -1)" title="Move Up"></button>
<button class="btn-icon-small" onclick="moveStep(${idx}, 1)" title="Move Down"></button>
<button class="btn-icon-small danger" onclick="deleteStep(${idx})" title="Delete Step"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<div class="pattern-row-list">${rowList}</div>
<div class="pattern-row-editor">
<div class="editor-bar">
<textarea data-idx="${idx}" data-field="rowDraft" rows="1" placeholder="Type next row instructions...">${step.rowDraft || ''}</textarea>
<button class="primary small" onclick="addStepRow(${idx})">Add Row</button>
</div>
<div class="pattern-buttons inline-buttons">
${getPatternButtonCodes().map(tok => `<button type="button" data-tok="${tok}" data-idx="${idx}">${tok}</button>`).join('')}
</div>
<div class="pattern-row-editor">
<textarea data-idx="${idx}" data-field="rowDraft" placeholder="Build a row with the buttons above or type manually...">${step.rowDraft || ''}</textarea>
<div class="pattern-row-actions">
<button class="secondary" onclick="clearStepRow(${idx})">Clear line</button>
<button class="primary" onclick="addStepRow(${idx})">Add row</button>
</div>
<div class="step-extras">
<label class="field-label-small" onclick="this.nextElementSibling.classList.toggle('hidden')">
<i class="fa-solid fa-caret-right"></i> Extra Info (Note / Image)
</label>
<div class="extras-row hidden">
<input type="text" value="${step.note || ''}" data-idx="${idx}" data-field="note" placeholder="Notes...">
<div class="image-input-group">
<input type="text" value="${step.image || ''}" data-idx="${idx}" data-field="image" placeholder="Image URL...">
${auth.token ? `<button class="btn-icon-small" onclick="uploadStepImage(${idx})" title="Upload/Change Image"><i class="fa-solid fa-upload"></i></button>` : ''}
${step.image ? `<button class="btn-icon-small danger" onclick="removeStepImage(${idx})" title="Remove Image"><i class="fa-solid fa-trash"></i></button>` : ''}
</div>
${step.image ? `<div class="step-img-preview"><img src="${step.image}" alt="Step Image"></div>` : ''}
</div>
<div class="pattern-row-list">${rowList}</div>
<label class="field-label">Note</label>
<textarea data-idx="${idx}" data-field="note" placeholder="Notes or assembly tips">${step.note || ''}</textarea>
<label class="field-label">Image URL (optional)</label>
<input type="text" value="${step.image || ''}" data-idx="${idx}" data-field="image" placeholder="https://...">
<div class="pattern-step-actions">
<button onclick="moveStep(${idx}, -1)"></button>
<button onclick="moveStep(${idx}, 1)"></button>
<button onclick="deleteStep(${idx})">Delete</button>
</div>
`;
// Event Listeners
card.querySelectorAll('input, textarea').forEach(el => {
el.addEventListener('input', (e) => {
const i = Number(e.target.dataset.idx);
const field = e.target.dataset.field;
if (!patternDraft.steps[i]) return;
// Handle row updates separately
if (e.target.hasAttribute('data-row')) {
const rIdx = Number(e.target.dataset.row);
patternDraft.steps[i].rows[rIdx] = e.target.value;
} else {
patternDraft.steps[i][field] = e.target.value;
}
persistPatternDraft();
});
});
card.querySelectorAll('.pattern-buttons button').forEach(btn => {
btn.addEventListener('click', () => {
const tok = btn.dataset.tok;
addPatternTokenToStep(idx, tok);
});
});
card.querySelectorAll('input[data-row]').forEach(inp => {
inp.addEventListener('input', (e) => {
const rowIdx = Number(e.target.dataset.row);
updateStepRow(idx, rowIdx, e.target.value);
addPatternTokenToStep(idx, btn.dataset.tok);
});
});
// Enter key handling for row adding
const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]');
if (rowTextarea) {
rowTextarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
addStepRow(idx);
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
addStep();
}
});
}
requestAnimationFrame(() => {
card.classList.remove('card-pop');
});
container.appendChild(card);
});
const addRow = document.createElement('div');
addRow.className = 'pattern-step-card add-step-row';
addRow.innerHTML = `
<button class="primary add-step-btn" onclick="addStep()">+ Add Step</button>
`;
addRow.className = 'add-step-row';
addRow.innerHTML = `<button class="primary add-step-btn" onclick="addStep()">+ New Step</button>`;
container.appendChild(addRow);
}
function removeStepImage(idx) {
if (patternDraft.steps[idx]) {
patternDraft.steps[idx].image = '';
persistPatternDraft();
renderSteps();
}
}
function uploadStepImage(idx) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${auth.token}` },
body: formData
});
const data = await resp.json();
if (resp.ok) {
patternDraft.steps[idx].image = data.url;
persistPatternDraft();
renderSteps();
showAlert({ title: 'Upload success', text: 'Image added to step.' });
} else {
showAlert({ title: 'Upload failed', text: data.error || 'Error' });
}
} catch (err) {
showAlert({ title: 'Error', text: err.message });
}
};
input.click();
}
function renderPatternView() {
const container = document.getElementById('patternView');
if (!container) return;
@ -711,23 +937,44 @@ function renderPatternView() {
const gauge = patternDraft.gauge || '';
const gaugeSts = patternDraft.gaugeSts || '';
const gaugeRows = patternDraft.gaugeRows || '';
const gaugeHook = patternDraft.gaugeHook || '';
const size = patternDraft.size || '';
// Construct displayedHooks from multiple hooks if available
const displayedHooks = patternDraft.hooks.map(h => h.size).filter(Boolean).join(' / ');
container.className = `pattern-view ${patternDraft.previewSize}`;
container.innerHTML = `
<div class="view-card">
<div class="view-card" style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<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>` : ''}
${(gauge || gaugeSts || gaugeRows || displayedHooks || size) ? `<h4>Gauge / Size</h4><pre>${[gaugeSts, gaugeRows, displayedHooks, size, gauge].filter(Boolean).join(' • ')}</pre>` : ''}
</div>
<button class="primary small" onclick="startProjectFromDraft()" title="Start a project from this pattern">
<i class="fa-solid fa-play"></i> Start Project
</button>
</div>
<div class="view-card">
<h4>Yarn & Tools</h4>
${patternDraft.yarns.length ? `<ul class="view-list">${patternDraft.yarns.map(y => `<li><strong>${y.note || 'Yarn'}:</strong> Weight ${y.weight} ${y.color ? `<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${y.color};border:1px solid #ccc;vertical-align:middle;margin-left:4px;"></span>` : ''}</li>`).join('')}</ul>` : ''}
${patternDraft.hooks.length ? `<p style="margin-top:8px;"><strong>Hooks:</strong> ${patternDraft.hooks.map(h => `${h.size} ${h.note ? `(${h.note})` : ''}`).join(', ')}</p>` : ''}
${!patternDraft.yarns.length && !patternDraft.hooks.length ? '<p class="muted">No yarn or tools listed.</p>' : ''}
</div>
<div class="view-card">
<h4>Key</h4>
${patternDraft.abbrevSelection.length ? `<div class="view-key-grid">${patternDraft.abbrevSelection.map(c => { const i = getAbbrevByCode(c); return `<div class="key-item"><span class="key-code">${c}</span> <span class="key-sep"></span> <span class="key-desc">${i ? i.desc : ''}</span></div>`; }).join('')}</div>` : '<p class="muted">No abbreviations selected.</p>'}
</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="view-step ${st.finished ? 'is-finished minimized' : ''}">
<header onclick="toggleViewStepMinimize(this)">
<div class="title">Step ${i + 1}${st.title ? ': ' + st.title : ''}</div>
<label class="view-check">
<label class="view-check" onclick="event.stopPropagation()">
<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>
@ -735,8 +982,12 @@ function renderPatternView() {
</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>
${st.image ? `<img src="${st.image}" class="view-step-img" alt="Step Image">` : ''}
<button class="note-toggle-btn" onclick="document.getElementById('view-note-${i}').classList.toggle('active')">
<i class="fa-regular fa-pen-to-square"></i> ${st.viewNote ? 'Edit Note' : 'Add Note'}
</button>
<div id="view-note-${i}" class="view-note-area ${st.viewNote ? 'active' : ''}">
<textarea data-view-note="${i}" placeholder="Notes for this step...">${st.viewNote || ''}</textarea>
</div>
</div>
@ -744,10 +995,6 @@ function renderPatternView() {
`).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) => {
@ -755,9 +1002,12 @@ function renderPatternView() {
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);
if (card) {
card.classList.toggle('is-finished', e.target.checked);
card.classList.toggle('minimized', e.target.checked);
}
});
});
container.querySelectorAll('textarea[data-view-note]').forEach(area => {
@ -768,13 +1018,29 @@ function renderPatternView() {
persistPatternDraft();
});
});
const globalNote = container.querySelector('textarea[data-view-global-note]');
if (globalNote) {
globalNote.addEventListener('input', (e) => {
patternDraft.viewGlobalNote = e.target.value;
persistPatternDraft();
});
}
function startProjectFromDraft() {
savePatternDraft(); // Ensure pattern is saved and has ID
closePatternComposer();
// Open project modal
openModal('addProject');
// Pre-select this pattern (need a slight delay to ensure modal logic runs first if async, but it's sync)
if (currentPatternId && patternSelect) {
patternSelect.value = currentPatternId;
// Optionally set default project name to pattern title
const name = patternDraft.meta.title;
if (name && modalInput) {
modalInput.value = name;
}
}
}
function toggleViewStepMinimize(header) {
const card = header.closest('.view-step');
if (card) card.classList.toggle('minimized');
}
function renderPatternLibrary() {
@ -936,24 +1202,128 @@ function updateStepRow(idx, rowIdx, value) {
persistPatternDraft();
}
function openAbbrevModal() {
const el = document.getElementById('abbrevModal');
if (el) {
el.classList.add('active');
renderAbbrevChecklist();
}
}
function closeAbbrevModal() {
const el = document.getElementById('abbrevModal');
if (el) el.classList.remove('active');
renderAbbrevSummary();
}
function renderAbbrevSummary() {
const el = document.getElementById('abbrevSummary');
if (!el) return;
if (patternDraft.abbrevSelection.length === 0) {
el.innerHTML = `<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>`;
return;
}
el.innerHTML = patternDraft.abbrevSelection.map(code => {
const item = getAbbrevByCode(code);
return `<span class="abbrev-pill is-selected" style="cursor: default;">
<span class="code">${code}</span>
<span class="desc" style="display:none;">${item ? item.desc : ''}</span>
</span>`;
}).join('');
}
function renderAbbrevChecklist() {
const listEl = document.getElementById('patternAbbrevList');
if (!listEl) return;
listEl.innerHTML = '';
const selected = new Set(patternDraft.abbrevSelection);
// Render Selected Group First
if (selected.size > 0) {
const selectedWrap = document.createElement('details');
selectedWrap.className = 'abbrev-group selected-group';
selectedWrap.open = true;
const selectedSummary = document.createElement('summary');
selectedSummary.innerHTML = `<strong>Selected (${selected.size})</strong>`;
selectedWrap.appendChild(selectedSummary);
const selectedGrid = document.createElement('div');
selectedGrid.className = 'abbrev-grid';
// Find item objects for selected codes
const library = getActiveAbbrevLibrary();
const selectedItems = library.filter(item => selected.has(item.code));
selectedItems.forEach(item => {
const pill = createAbbrevPill(item, true);
selectedGrid.appendChild(pill);
});
selectedWrap.appendChild(selectedGrid);
listEl.appendChild(selectedWrap);
}
// Render All Groups
getAbbrevGroups().forEach(bucket => {
const wrap = document.createElement('details');
wrap.className = 'abbrev-group';
wrap.open = true;
// Open if user is searching, otherwise close to save space? Keep open for now.
wrap.open = false;
const summary = document.createElement('summary');
summary.textContent = `${bucket.label} (${bucket.items.length})`;
wrap.appendChild(summary);
const grid = document.createElement('div');
grid.className = 'abbrev-grid';
bucket.items.forEach(item => {
const pill = createAbbrevPill(item, selected.has(item.code));
grid.appendChild(pill);
});
wrap.appendChild(grid);
listEl.appendChild(wrap);
});
// Add Custom Abbrev UI
const customWrap = document.createElement('div');
customWrap.className = 'abbrev-group custom-add-group';
customWrap.style.marginTop = '20px';
customWrap.innerHTML = `\n <div style="padding:10px 16px; font-weight:700; background:var(--input-bg); border-bottom:1px solid var(--border);">Add Custom Abbreviation</div>\n <div style="padding:10px; display:grid; gap:8px;">\n <div style="display:grid; grid-template-columns: 1fr 2fr; gap:8px;">\n <input id="newAbbrevCode" placeholder="Code (e.g. mc)" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--bg);">\n <input id="newAbbrevDesc" placeholder="Description (e.g. magic circle)" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--bg);">\n </div>\n <button class="primary small" onclick="addCustomAbbrev()" style="justify-self: end;">+ Add Custom</button>\n </div>\n `;
listEl.appendChild(customWrap);
// Re-apply filter if text exists
filterAbbrev();
}
function addCustomAbbrev() {
const codeInp = document.getElementById('newAbbrevCode');
const descInp = document.getElementById('newAbbrevDesc');
const code = codeInp.value.trim();
const desc = descInp.value.trim();
if (!code || !desc) {
alert('Please enter both a code and a description.');
return;
}
if (!patternDraft.customAbbrev) patternDraft.customAbbrev = [];
patternDraft.customAbbrev.push({ code, desc });
// Auto-select it
if (!patternDraft.abbrevSelection.includes(code)) {
patternDraft.abbrevSelection.push(code);
}
persistPatternDraft();
updateAbbrevFromSelection();
renderAbbrevChecklist();
// Clear inputs (though re-render clears them anyway, unless we want to preserve focus?)
// Re-render clears them.
}
function createAbbrevPill(item, isSelected) {
const pill = document.createElement('div');
pill.className = 'abbrev-pill';
if (selected.has(item.code)) pill.classList.add('is-selected');
if (isSelected) pill.classList.add('is-selected');
pill.dataset.code = item.code;
pill.innerHTML = `
<span class="code">${item.code}</span>
@ -961,21 +1331,17 @@ function renderAbbrevChecklist() {
`;
pill.addEventListener('click', () => {
const code = pill.dataset.code;
if (pill.classList.contains('is-selected')) {
pill.classList.remove('is-selected');
if (patternDraft.abbrevSelection.includes(code)) {
patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code);
pill.classList.remove('is-selected');
} else {
patternDraft.abbrevSelection.push(code);
pill.classList.add('is-selected');
if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code);
}
updateAbbrevFromSelection();
// Don't re-render whole list to keep scroll position
});
grid.appendChild(pill);
});
wrap.appendChild(grid);
listEl.appendChild(wrap);
});
renderSelectedAbbrev();
return pill;
}
function updateAbbrevFromSelection() {
@ -1102,9 +1468,11 @@ let easterEggCooling = false;
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 authProfile = document.querySelector('.auth-profile');
const authContent = document.querySelector('.auth-content');
const tabs = document.querySelector('.auth-tabs');
const adminTabBtn = document.getElementById('adminTabBtn');
const profileTabBtn = document.getElementById('profileTabBtn');
const displayNameInput = document.getElementById('authDisplayName');
const noteInput = document.getElementById('authNote');
@ -1116,19 +1484,79 @@ function updateAuthUI() {
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';
// Default container visibility logic based on state
if (signedIn) {
// Show Profile/Admin tabs, hide Login/Signup tabs
if (tabs) tabs.style.display = 'flex';
document.querySelectorAll('.auth-tab[data-mode="login"], .auth-tab[data-mode="signup"]').forEach(el => el.style.display = 'none');
if (profileTabBtn) profileTabBtn.style.display = 'block';
if (adminTabBtn) adminTabBtn.style.display = auth.isAdmin ? 'block' : 'none';
// If we are currently in a "logged out" mode (login/signup), switch to profile
if (auth.mode === 'login' || auth.mode === 'signup') {
setAuthMode('profile');
}
} else {
// Show Login/Signup tabs, hide Profile/Admin tabs
if (tabs) tabs.style.display = 'flex';
document.querySelectorAll('.auth-tab[data-mode="login"], .auth-tab[data-mode="signup"]').forEach(el => el.style.display = 'block');
if (profileTabBtn) profileTabBtn.style.display = 'none';
if (adminTabBtn) adminTabBtn.style.display = 'none';
if (authContent) authContent.style.display = 'block';
if (authProfile) authProfile.style.display = 'none';
}
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 setAuthMode(mode) {
auth.mode = mode;
// Update tab buttons state
document.querySelectorAll('.auth-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
// Handle Content Switching
const authContent = document.querySelector('.auth-content');
const authProfile = document.querySelector('.auth-profile');
// Hide all tab contents inside authContent first
document.querySelectorAll('.auth-tab-content').forEach(content => {
content.classList.remove('active');
});
if (mode === 'profile') {
if (authContent) authContent.style.display = 'none';
if (authProfile) authProfile.style.display = 'block';
} else if (mode === 'admin') {
if (authContent) authContent.style.display = 'block';
if (authProfile) authProfile.style.display = 'none';
const adminContent = document.getElementById('adminContent');
if (adminContent) adminContent.classList.add('active');
} else {
// Login or Signup
if (authContent) authContent.style.display = 'block';
if (authProfile) authProfile.style.display = 'none';
const target = document.getElementById(`${mode}Content`);
if (target) target.classList.add('active');
}
}
function openAuthModal() {
const overlay = document.getElementById('authOverlay');
if (!overlay) return;
overlay.classList.add('active');
// If signed in, ensure we start on profile (or last state if valid?)
// Defaulting to profile is safer.
if (auth.token) {
setAuthMode('profile');
} else {
setAuthMode('login');
}
updateAuthUI();
}
@ -1138,23 +1566,63 @@ function closeAuthModal() {
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 fetchAllUsers() {
if (!auth.token || !auth.isAdmin) return;
const list = document.getElementById('allUsersList');
if (!list) return;
list.innerHTML = '<div class="spinner">Loading...</div>';
try {
const resp = await fetch('/api/admin/users', { headers: { Authorization: `Bearer ${auth.token}` } });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to load users');
if (!data.users || !data.users.length) {
list.innerHTML = '<p class="muted">No users found.</p>';
return;
}
async function submitAuth(event) {
list.innerHTML = data.users.map(u => `
<div class="admin-item">
<div class="meta">
<strong>${u.email}</strong>
<span class="status-subtext">${u.display_name || 'No name'} ${u.status} ${u.is_admin ? '• Admin' : ''}</span>
</div>
<div class="actions">
${!u.is_admin ? `<button class="modal-btn btn-secondary" onclick="makeAdmin('${u.id}')">Make Admin</button>` : ''}
${u.status === 'active' ? `<button class="modal-btn btn-cancel" onclick="suspendUser('${u.id}')">Suspend</button>` : ''}
${u.status === 'suspended' ? `<button class="modal-btn btn-save" onclick="approveUser('${u.id}')">Activate</button>` : ''}
</div>
</div>
`).join('');
} catch (err) {
list.innerHTML = `<p class="danger-text">Error: ${err.message}</p>`;
}
}
window.fetchAllUsers = fetchAllUsers;
async function submitAuth(event, mode) {
if (event) event.preventDefault();
const email = (document.getElementById('authEmail') || {}).value || '';
const password = (document.getElementById('authPassword') || {}).value || '';
const email = document.getElementById(`${mode}Email`).value;
const password = document.getElementById(`${mode}Password`).value;
if (!email || !password) {
alert('Please enter email and password to continue.');
showAlert({ title: 'Missing Info', text: 'Please enter both email and password.' });
return false;
}
if (mode === 'signup') {
const confirmPassword = document.getElementById('signupConfirmPassword').value;
if (password !== confirmPassword) {
showAlert({ title: 'Passwords Do Not Match', text: 'Please re-enter your passwords.' });
return false;
}
}
try {
const endpoint = auth.mode === 'signup' ? '/api/signup' : '/api/login';
const endpoint = mode === 'signup' ? '/api/signup' : '/api/login';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -1180,7 +1648,8 @@ async function autoSync() {
await fetchProfile();
}
async function saveProfile() {
async function saveProfile(event) {
if (event) event.preventDefault();
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
const note = (document.getElementById('authNote') || {}).value || '';
try {
@ -1194,6 +1663,7 @@ async function saveProfile() {
} catch (err) {
showAlert({ title: 'Profile save failed', text: err.message });
}
return false;
}
async function fetchProfile() {
@ -2256,16 +2726,18 @@ async function makeAdmin(id) {
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' });
const err = await resp.json().catch(() => ({}));
showAlert({ title: 'Backup failed', text: err.error || 'Server error' });
return;
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `backup_${new Date().toISOString()}.json`;
a.download = `toadstool_dump_${new Date().toISOString().split('T')[0]}.sql`;
a.click();
URL.revokeObjectURL(url);
}
@ -2274,18 +2746,24 @@ 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', {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
const resp = await fetch('/api/admin/restore-sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
body: JSON.stringify(payload)
body: JSON.stringify({ sql: base64 })
});
const data = await resp.json();
if (!resp.ok) {
showAlert({ title: 'Restore failed', text: data.error || 'Error' });
showAlert({ title: 'Restore failed', text: data.error || data.message || 'Error' });
} else {
showAlert({ title: 'Restore complete', text: 'Data restored.' });
showAlert({ title: 'Restore complete', text: data.message || 'Database restored.' });
}
} catch (err) {
showAlert({ title: 'Restore failed', text: err.message });

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ services:
api:
build: .
restart: unless-stopped
# restart: unless-stopped
environment:
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
PORT: 4000
@ -21,11 +21,21 @@ services:
ADMIN_EMAIL: chris@chrisedwards.tech
ADMIN_PASSWORD: R4e3w2q1
volumes:
- ./server/uploads:/app/server/uploads
- .:/app
- /app/server/node_modules
- /app/node_modules
working_dir: /app
depends_on:
- db
ports:
- "4000:4000"
- "8080:8080"
command: >
sh -c "
npm install &&
cd server && npm install && cd .. &&
npm run dev
"
volumes:
db_data:

View File

@ -92,7 +92,7 @@
<div class="auth-modal">
<div class="auth-modal-head">
<div>
<h3 class="color-title">Sign in (optional)</h3>
<h3 class="color-title">Welcome Back!</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>
@ -102,43 +102,84 @@
<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 active" data-mode="login" onclick="setAuthMode('login')">Login</button>
<button class="auth-tab" data-mode="signup" onclick="setAuthMode('signup')">Sign up</button>
<button class="auth-tab" id="profileTabBtn" data-mode="profile" onclick="setAuthMode('profile')" style="display:none;">Profile</button>
<button class="auth-tab" id="adminTabBtn" data-mode="admin" onclick="setAuthMode('admin')" style="display:none;">Admin</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-content">
<div id="loginContent" class="auth-tab-content active">
<form class="auth-form" onsubmit="return submitAuth(event, 'login')">
<label class="field-label" for="loginEmail">Email</label>
<input id="loginEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
<label class="field-label" for="loginPassword">Password</label>
<input id="loginPassword" type="password" placeholder="••••••••" autocomplete="current-password" required>
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device.</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>
<button type="submit" class="modal-btn btn-save">Login</button>
</div>
</form>
</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 id="signupContent" class="auth-tab-content">
<form class="auth-form" onsubmit="return submitAuth(event, 'signup')">
<label class="field-label" for="signupEmail">Email</label>
<input id="signupEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
<label class="field-label" for="signupPassword">Password</label>
<input id="signupPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<label class="field-label" for="signupConfirmPassword">Confirm Password</label>
<input id="signupConfirmPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<p class="auth-hint">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">Sign Up</button>
</div>
<div id="adminPanel" class="admin-panel" style="display:none;">
<h4>Admin</h4>
</form>
</div>
<div id="adminContent" class="auth-tab-content">
<div id="adminPanel" class="admin-panel">
<h4>Pending Approvals</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>
<button type="button" class="modal-btn btn-secondary" onclick="fetchPendingUsers()">Refresh Pending</button>
</div>
<div id="pendingList" class="admin-list"></div>
<h4 style="margin-top:16px;">User Management</h4>
<div class="admin-actions">
<button type="button" class="modal-btn btn-secondary" onclick="fetchAllUsers()">Load All Users</button>
</div>
<div id="allUsersList" class="admin-list"></div>
<h4 style="margin-top:16px;">System Data</h4>
<div class="admin-actions">
<button type="button" class="modal-btn btn-secondary" onclick="downloadBackup()">Backup Data</button>
<label class="modal-btn btn-secondary">
Restore
<input type="file" id="restoreInput" accept="application/json,application/sql,.sql" style="display:none;" onchange="uploadRestore(event)">
</label>
</div>
</div>
</div>
</div>
<form class="auth-profile" onsubmit="return saveProfile(event)">
<h4 class="auth-section-title">Profile Settings</h4>
<div class="auth-profile-body">
<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>
<div class="auth-sync-section">
<p class="auth-hint">Sync your projects to the cloud to access them anywhere.</p>
<button type="button" class="modal-btn btn-secondary" onclick="autoSync()"><i class="fa-solid fa-rotate"></i> Sync Now</button>
</div>
<div class="auth-actions modal-footer">
<button type="button" class="modal-btn btn-cancel danger-text" onclick="logoutAuth()">Log Out</button>
<button type="submit" class="modal-btn btn-save">Save Changes</button>
</div>
</form>
</div>
@ -147,86 +188,114 @@
<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="header-main">
<h2 class="pattern-sheet-title">Pattern Composer</h2>
<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>
<div class="pattern-save-indicator" id="patternSaveIndicator">Saved</div>
</div>
<div class="header-actions">
<span class="pattern-save-indicator" id="patternSaveIndicator">Saved</span>
<button class="icon-action" onclick="savePatternDraft()" title="Save to Basket"><i class="fa-solid fa-bookmark"></i></button>
<button class="icon-action" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i></button>
<button class="icon-action" onclick="sharePattern()" title="Share Link"><i class="fa-solid fa-link"></i></button>
<button class="icon-action danger" onclick="clearPatternOutput()" title="Clear Draft"><i class="fa-solid fa-eraser"></i></button>
<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 class="pattern-nav">
<button class="nav-item active" data-tab="info" onclick="showPatternTab('info')">Specs</button>
<button class="nav-item" data-tab="steps" onclick="showPatternTab('steps')">Draft</button>
<button class="nav-item" data-tab="view" onclick="showPatternTab('view')">Read</button>
<button class="nav-item" data-tab="library" onclick="showPatternTab('library')">Shelf</button>
</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">
<div class="pattern-section active" data-section="info">
<div class="card-grid">
<div class="input-card">
<h4 class="card-head">Basic Details</h4>
<div class="form-row">
<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>
</div>
<div class="form-row">
<label class="field-label" for="patternDesigner">Designer</label>
<input id="patternDesigner" type="text" placeholder="Your name or shop">
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
<textarea id="patternMaterials" placeholder="Yarn (Color A) worsted&#10;Yarn (Color B) accent&#10;Hook 4.0 mm&#10;Safety eyes, stuffing, needle"></textarea>
<div class="field-group-inline">
</div>
</div>
<div class="input-card">
<h4 class="card-head">Yarn & Tools</h4>
<label class="field-label">Yarns</label>
<div id="yarnList" class="yarn-list-container">
<!-- Populated by JS -->
</div>
<label class="field-label" style="margin-top: 10px;">Hooks / Needles</label>
<div id="hookList" class="hook-list-container">
<!-- Populated by JS -->
</div>
<div class="field-group-inline" style="margin-top: 10px;">
<div>
<label class="field-label" for="patternGaugeSts">Stitches / 4in (10cm)</label>
<label class="field-label" for="patternGaugeSts">Sts / 4in</label>
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
</div>
<div>
<label class="field-label" for="patternGaugeRows">Rows / 4in (10cm)</label>
<label class="field-label" for="patternGaugeRows">Rows / 4in</label>
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
</div>
</div>
<label class="field-label" for="patternGaugeHook">Hook / Needles</label>
<input id="patternGaugeHook" type="text" placeholder="e.g., 4.0 mm hook">
<label class="field-label" for="patternSize">Finished size</label>
<input id="patternSize" type="text" placeholder="Approx. 6 in / 15 cm tall">
<label class="field-label" for="patternGauge">Gauge (extra notes)</label>
<textarea id="patternGauge" placeholder="Magic ring start; or any extra gauge notes"></textarea>
<div class="abbrev-head">
<label class="field-label" for="patternAbbrev">Abbreviations</label>
<button class="secondary" onclick="loadDefaultAbbrev()">Load defaults</button>
<label class="field-label" for="patternGauge">Gauge Notes</label>
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
</div>
<div id="patternAbbrevList" class="abbrev-grid"></div>
<textarea id="patternAbbrev" placeholder="sc single crochet&#10;dc double crochet&#10;inc increase&#10;k knit&#10;p purl"></textarea>
<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>
<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">
<div class="input-card full-width">
<h4 class="card-head">Materials & Notes</h4>
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color)&#10;• 4.0mm Hook&#10;• Tapestry Needle&#10;• Polyester Fiberfill"></textarea>
<label class="field-label" for="patternNotes">Notes / Finishing</label>
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
</div>
<div class="input-card full-width">
<div class="abbrev-head">
<h4 class="card-head" style="margin:0;">Abbreviations</h4>
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
</div>
<div id="abbrevSummary" class="selected-abbrev">
<!-- Selected pills will appear here -->
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
</div>
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ...&#10;Invisible decrease: ..."></textarea>
</div>
</div>
<div class="pattern-footer-actions">
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
</div>
</div>
<div class="pattern-section" data-section="steps">
<div class="pattern-steps-head">
<h4>Steps</h4>
<button class="primary" onclick="addStep()">+ Step</button>
<h4>Pattern Steps</h4>
</div>
<div id="patternSteps"></div>
</div>
<div class="pattern-section" data-section="library">
<div class="pattern-steps-head">
<h4>Saved Patterns</h4>
<button class="primary" onclick="savePatternDraft()">Save Draft to Basket</button>
<button class="primary" onclick="savePatternDraft()">Save Current Draft</button>
</div>
<div id="patternLibrary" class="pattern-library"></div>
</div>
<div class="pattern-section" data-section="view">
<div id="patternView" class="pattern-view"></div>
</div>
@ -234,6 +303,20 @@
</div>
</div>
<div class="modal-overlay" id="abbrevModal">
<div class="modal-content" style="width: min(800px, 94vw); max-height: 85vh; display: flex; flex-direction: column;">
<h3 class="modal-title">Select Abbreviations</h3>
<div class="abbrev-controls" style="margin-bottom: 12px;">
<input type="text" id="abbrevSearch" placeholder="Search stitches..." oninput="filterAbbrev()">
<button class="secondary small" onclick="loadDefaultAbbrev()">Reset to Defaults</button>
</div>
<div id="patternAbbrevList" class="abbrev-grid" style="overflow-y: auto; flex: 1;"></div>
<div class="modal-actions" style="margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
<button class="modal-btn btn-save" onclick="closeAbbrevModal()">Done</button>
</div>
</div>
</div>
<script src="assets/app.js"></script>
<footer class="footer-bg" aria-hidden="true"></footer>
</body>

15
live-server.js Normal file
View File

@ -0,0 +1,15 @@
var liveServer = require("live-server");
var params = {
port: 8080,
host: "0.0.0.0",
ignore: 'db_data,server/uploads',
file: "index.html",
wait: 500,
proxy: [
['/api', 'http://127.0.0.1:4000/api'],
['/uploads', 'http://127.0.0.1:4000/uploads']
]
};
liveServer.start(params);

2726
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,15 @@
"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"
"test": "echo \"Error: no test specified\" && exit 1",
"dev-frontend": "node live-server.js",
"dev": "cd server && npm install && cd .. && concurrently \"npm:dev-frontend\" \"cd server && npm run dev\""
},
"keywords": [],
"author": "",
"license": "ISC"
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1",
"live-server": "^1.2.2"
}
}

138
server/package-lock.json generated
View File

@ -11,6 +11,7 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0",
"sharp": "^0.33.3",
"uuid": "^9.0.1"
},
@ -2287,6 +2288,95 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
"pg-protocol": "^1.10.3",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.7"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@ -2300,6 +2390,45 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -2854,6 +2983,15 @@
"node": ">=10"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@ -7,6 +7,7 @@ const multer = require('multer');
const sharp = require('sharp');
const {
initDb,
withClient,
createUser,
verifyUser,
createSession,
@ -36,7 +37,7 @@ 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' }));
app.use(express.json({ limit: '50mb' }));
// Init DB
initDb().catch((err) => {
@ -195,37 +196,41 @@ app.get('/share/:token', async (req, res) => {
}
});
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
// Upload route: save to /uploads, return URL (Optimized with sharp, fallback to raw)
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); // Bumped limit slightly
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'File required' });
let filename;
let outPath;
try {
// Attempt optimization
filename = `${Date.now()}-${uuid()}.webp`;
outPath = path.join(UPLOAD_DIR, filename);
await sharp(req.file.buffer)
.rotate() // Auto-rotate based on EXIF
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(outPath);
} catch (sharpErr) {
console.warn('Image optimization failed, falling back to raw save:', sharpErr.message);
// Fallback: Save original
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 });
filename = `${Date.now()}-${uuid()}.${safeExt}`;
outPath = path.join(UPLOAD_DIR, filename);
await fs.promises.writeFile(outPath, req.file.buffer);
}
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' });
res.status(500).json({ error: err.message, stack: err.stack });
}
});
@ -307,16 +312,57 @@ app.post('/api/password-reset/confirm', async (req, res) => {
}
});
// Admin: backup (JSON export)
// Admin: backup (SQL Dump)
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() });
// Let's use direct DB queries to get everything.
const allUsers = await withClient(c => c.query('select * from users order by created_at'));
const allProjects = await withClient(c => c.query('select * from projects order by updated_at'));
const allPatterns = await withClient(c => c.query('select * from patterns order by updated_at'));
let sql = `-- Toadstool Tally Database Dump\n-- Exported: ${new Date().toISOString()}\n\n`;
const escape = (val) => {
if (val === null || val === undefined) return 'NULL';
if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE';
if (typeof val === 'number') return val;
if (val instanceof Date) return `'${val.toISOString()}'`;
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`; // JSON columns
return `'${String(val).replace(/'/g, "''")}'`;
};
if (allUsers.rows.length) {
sql += `-- Users\n`;
for (const row of allUsers.rows) {
sql += `INSERT INTO users (id, email, password_hash, display_name, note, created_at, updated_at, is_admin, status) VALUES (${escape(row.id)}, ${escape(row.email)}, ${escape(row.password_hash)}, ${escape(row.display_name)}, ${escape(row.note)}, ${escape(row.created_at)}, ${escape(row.updated_at)}, ${escape(row.is_admin)}, ${escape(row.status)}) ON CONFLICT (id) DO NOTHING;\n`;
}
sql += `\n`;
}
if (allProjects.rows.length) {
sql += `-- Projects\n`;
for (const row of allProjects.rows) {
sql += `INSERT INTO projects (id, user_id, data, updated_at, deleted_at) VALUES (${escape(row.id)}, ${escape(row.user_id)}, ${escape(row.data)}, ${escape(row.updated_at)}, ${escape(row.deleted_at)}) ON CONFLICT (id) DO NOTHING;\n`;
}
sql += `\n`;
}
if (allPatterns.rows.length) {
sql += `-- Patterns\n`;
for (const row of allPatterns.rows) {
sql += `INSERT INTO patterns (id, user_id, data, updated_at, deleted_at, title, slug) VALUES (${escape(row.id)}, ${escape(row.user_id)}, ${escape(row.data)}, ${escape(row.updated_at)}, ${escape(row.deleted_at)}, ${escape(row.title)}, ${escape(row.slug)}) ON CONFLICT (id) DO NOTHING;\n`;
}
sql += `\n`;
}
res.setHeader('Content-Disposition', `attachment; filename="toadstool_backup_${new Date().toISOString().split('T')[0]}.sql"`);
res.setHeader('Content-Type', 'application/sql; charset=utf-8');
// res.setHeader('Content-Length', Buffer.byteLength(sql)); // Let Express handle this
res.end(sql);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Backup failed' });
res.status(500).json({ error: err.message, stack: err.stack });
}
});
@ -358,6 +404,54 @@ app.post('/api/admin/restore', requireAuth, requireAdmin, async (req, res) => {
}
});
// Admin: restore from SQL dump (Base64 JSON)
app.post('/api/admin/restore-sql', requireAuth, requireAdmin, async (req, res) => {
try {
const { sql } = req.body || {};
if (!sql || typeof sql !== 'string') {
return res.status(400).json({
error: 'SQL dump (base64) required',
debug: {
keys: Object.keys(req.body || {}),
contentType: req.headers['content-type'],
bodyType: typeof req.body
}
});
}
const sqlDump = Buffer.from(sql, 'base64').toString('utf-8');
if (!sqlDump || sqlDump.trim().length === 0) {
return res.status(400).json({ error: 'Decoded SQL is empty' });
}
await withClient(async (client) => {
await client.query('BEGIN'); // Start transaction
// Clear existing data before restoring
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');
await client.query('TRUNCATE table pattern_shares CASCADE'); // Assuming pattern_shares also needs to be cleared
await client.query('TRUNCATE table password_resets CASCADE'); // Assuming password_resets also needs to be cleared
// Execute the SQL dump. This is inherently risky with untrusted input.
// For this project context, it's assumed admin is trusted.
await client.query(sqlDump);
await client.query('COMMIT'); // Commit transaction
});
res.json({ ok: true, message: 'Database restored successfully from SQL dump.' });
} catch (err) {
console.error(err);
// Rollback on error
await withClient(async (client) => {
await client.query('ROLLBACK');
});
res.status(500).json({ error: err.message, stack: err.stack, message: 'SQL Restore failed, transaction rolled back.' });
}
});
app.use((err, _req, res, _next) => {
// Basic error guard
// eslint-disable-next-line no-console