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:
parent
d750cd88f4
commit
84909ff4e0
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ db_data/
|
|||||||
uploads/
|
uploads/
|
||||||
server/uploads/
|
server/uploads/
|
||||||
*.log
|
*.log
|
||||||
|
server/.env
|
||||||
|
|||||||
34
README.md
34
README.md
@ -21,6 +21,40 @@ python3 -m http.server 8080
|
|||||||
- Service worker requires an HTTP/HTTPS context; use a local server to test install/offline.
|
- Service worker requires an HTTP/HTTPS context; use a local server to test install/offline.
|
||||||
- Data is stored in `localStorage`; clear it to reset.
|
- 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
|
## PWA Notes
|
||||||
- Manifest: `assets/site.webmanifest`
|
- Manifest: `assets/site.webmanifest`
|
||||||
- Service worker: `sw.js`
|
- Service worker: `sw.js`
|
||||||
|
|||||||
716
assets/app.js
716
assets/app.js
@ -50,7 +50,7 @@ const crochetAbbrev = [
|
|||||||
{ code: 'inc', desc: 'increase' },
|
{ code: 'inc', desc: 'increase' },
|
||||||
{ code: 'lp', desc: 'loop' },
|
{ code: 'lp', desc: 'loop' },
|
||||||
{ code: 'm', desc: 'marker' },
|
{ code: 'm', desc: 'marker' },
|
||||||
{ code: 'mc', desc: 'main color' },
|
{ code: 'mc', desc: 'main circle' },
|
||||||
{ code: 'pat', desc: 'pattern' },
|
{ code: 'pat', desc: 'pattern' },
|
||||||
{ code: 'pc', desc: 'popcorn stitch' },
|
{ code: 'pc', desc: 'popcorn stitch' },
|
||||||
{ code: 'pm', desc: 'place marker' },
|
{ code: 'pm', desc: 'place marker' },
|
||||||
@ -197,7 +197,8 @@ const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end'];
|
|||||||
const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']);
|
const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']);
|
||||||
|
|
||||||
function getActiveAbbrevLibrary() {
|
function getActiveAbbrevLibrary() {
|
||||||
return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
|
const base = patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
|
||||||
|
return [...base, ...(patternDraft.customAbbrev || [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAbbrevByCode(code) {
|
function getAbbrevByCode(code) {
|
||||||
@ -245,6 +246,17 @@ function getPatternButtonCodes() {
|
|||||||
const merged = [...base, ...repeatTokens];
|
const merged = [...base, ...repeatTokens];
|
||||||
return Array.from(new Set(merged));
|
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 = {}) {
|
function normalizePatternDraft(d = {}) {
|
||||||
const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' };
|
const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' };
|
||||||
const base = {
|
const base = {
|
||||||
@ -257,23 +269,39 @@ function normalizePatternDraft(d = {}) {
|
|||||||
gauge: '',
|
gauge: '',
|
||||||
gaugeSts: '',
|
gaugeSts: '',
|
||||||
gaugeRows: '',
|
gaugeRows: '',
|
||||||
gaugeHook: '',
|
|
||||||
size: '',
|
size: '',
|
||||||
abbrev: '',
|
abbrev: '',
|
||||||
abbrevSelection: [],
|
abbrevSelection: [],
|
||||||
|
customAbbrev: [],
|
||||||
stitches: '',
|
stitches: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
steps: []
|
steps: [],
|
||||||
|
palette: [],
|
||||||
|
yarns: [],
|
||||||
|
hooks: [],
|
||||||
|
previewSize: 'full'
|
||||||
};
|
};
|
||||||
const merged = { ...base, ...d };
|
const merged = { ...base, ...d };
|
||||||
merged.meta = { ...base.meta, ...(d.meta || {}) };
|
merged.meta = { ...base.meta, ...(d.meta || {}) };
|
||||||
merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : [];
|
merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : [];
|
||||||
merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : [];
|
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.materials = merged.materials || '';
|
||||||
merged.gauge = merged.gauge || '';
|
merged.gauge = merged.gauge || '';
|
||||||
merged.gaugeSts = merged.gaugeSts || '';
|
merged.gaugeSts = merged.gaugeSts || '';
|
||||||
merged.gaugeRows = merged.gaugeRows || '';
|
merged.gaugeRows = merged.gaugeRows || '';
|
||||||
merged.gaugeHook = merged.gaugeHook || '';
|
|
||||||
merged.size = merged.size || '';
|
merged.size = merged.size || '';
|
||||||
merged.abbrev = merged.abbrev || '';
|
merged.abbrev = merged.abbrev || '';
|
||||||
merged.stitches = merged.stitches || '';
|
merged.stitches = merged.stitches || '';
|
||||||
@ -413,7 +441,6 @@ function syncPatternUI() {
|
|||||||
const gaugeEl = document.getElementById('patternGauge');
|
const gaugeEl = document.getElementById('patternGauge');
|
||||||
const gaugeStsEl = document.getElementById('patternGaugeSts');
|
const gaugeStsEl = document.getElementById('patternGaugeSts');
|
||||||
const gaugeRowsEl = document.getElementById('patternGaugeRows');
|
const gaugeRowsEl = document.getElementById('patternGaugeRows');
|
||||||
const gaugeHookEl = document.getElementById('patternGaugeHook');
|
|
||||||
const sizeEl = document.getElementById('patternSize');
|
const sizeEl = document.getElementById('patternSize');
|
||||||
const abbrevEl = document.getElementById('patternAbbrev');
|
const abbrevEl = document.getElementById('patternAbbrev');
|
||||||
const stitchesEl = document.getElementById('patternStitches');
|
const stitchesEl = document.getElementById('patternStitches');
|
||||||
@ -424,27 +451,151 @@ function syncPatternUI() {
|
|||||||
if (gaugeEl) gaugeEl.value = patternDraft.gauge;
|
if (gaugeEl) gaugeEl.value = patternDraft.gauge;
|
||||||
if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts;
|
if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts;
|
||||||
if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows;
|
if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows;
|
||||||
if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook;
|
|
||||||
if (sizeEl) sizeEl.value = patternDraft.size;
|
if (sizeEl) sizeEl.value = patternDraft.size;
|
||||||
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
|
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
|
||||||
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
|
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
|
||||||
if (notesEl) notesEl.value = patternDraft.notes;
|
if (notesEl) notesEl.value = patternDraft.notes;
|
||||||
|
|
||||||
|
renderYarnList();
|
||||||
|
renderHookList();
|
||||||
renderSteps();
|
renderSteps();
|
||||||
renderAbbrevChecklist();
|
renderAbbrevSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPatternToken(tok) {
|
function renderYarnList() {
|
||||||
if (!patternLine) return;
|
const el = document.getElementById('yarnList');
|
||||||
patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok;
|
if (!el) return;
|
||||||
patternLine.value = patternDraft.line;
|
|
||||||
persistPatternDraft();
|
if (patternDraft.yarns.length === 0) {
|
||||||
|
el.innerHTML = `<button class="secondary small" onclick="addYarn()">+ Add Yarn</button>`;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPatternLine() {
|
el.innerHTML = patternDraft.yarns.map((y, idx) => `
|
||||||
if (!patternLine) return;
|
<div class="yarn-item-card" style="border-left: 5px solid ${y.color || 'var(--border)'}">
|
||||||
patternDraft.line = '';
|
<div class="yarn-header">
|
||||||
patternLine.value = '';
|
<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();
|
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() {
|
function addPatternRow() {
|
||||||
@ -579,8 +730,9 @@ function exportPatternPDF() {
|
|||||||
<div class="section-title">Steps</div>
|
<div class="section-title">Steps</div>
|
||||||
${patternDraft.steps.map((s, i) => {
|
${patternDraft.steps.map((s, i) => {
|
||||||
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('<br>');
|
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('<br>');
|
||||||
const note = s.note ? `<br>${s.note}` : '';
|
const note = s.note ? `<br><strong>Note:</strong> ${s.note}` : '';
|
||||||
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows}${note}</div>`;
|
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>')}
|
}).join('<hr>')}
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
@ -601,11 +753,27 @@ function exportPatternPDF() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showPatternTab(tab) {
|
function showPatternTab(tab) {
|
||||||
document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab));
|
document.querySelectorAll('.nav-item').forEach(btn =>
|
||||||
document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab));
|
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) {}
|
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) {
|
function updateMetaField(field, value) {
|
||||||
patternDraft.meta[field] = value;
|
patternDraft.meta[field] = value;
|
||||||
persistPatternDraft();
|
persistPatternDraft();
|
||||||
@ -620,88 +788,146 @@ function renderSteps() {
|
|||||||
const container = document.getElementById('patternSteps');
|
const container = document.getElementById('patternSteps');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
patternDraft.steps.forEach((step, idx) => {
|
patternDraft.steps.forEach((step, idx) => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'pattern-step-card card-pop';
|
card.className = 'pattern-step-card card-pop';
|
||||||
|
|
||||||
const rows = step.rows || [];
|
const rows = step.rows || [];
|
||||||
const rowList = rows.map((r, i) => `
|
const rowList = rows.map((r, i) => `
|
||||||
<div class="row-item">
|
<div class="row-item">
|
||||||
<label>Row ${i + 1}</label>
|
<span class="row-num">${i + 1}.</span>
|
||||||
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}">
|
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}" placeholder="Row instructions...">
|
||||||
<button type="button" onclick="removeStepRow(${idx}, ${i})">✕</button>
|
<button type="button" class="btn-icon-small" onclick="removeStepRow(${idx}, ${i})" title="Remove row">✕</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="step-number">Step ${idx + 1}</div>
|
<div class="step-card-header">
|
||||||
<label class="field-label">Title (optional)</label>
|
<div class="step-title-group">
|
||||||
<input type="text" value="${step.title || ''}" data-idx="${idx}" data-field="title">
|
<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">
|
<div class="pattern-buttons inline-buttons">
|
||||||
${getPatternButtonCodes().map(tok => `<button type="button" data-tok="${tok}" data-idx="${idx}">${tok}</button>`).join('')}
|
${getPatternButtonCodes().map(tok => `<button type="button" data-tok="${tok}" data-idx="${idx}">${tok}</button>`).join('')}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
card.querySelectorAll('input, textarea').forEach(el => {
|
card.querySelectorAll('input, textarea').forEach(el => {
|
||||||
el.addEventListener('input', (e) => {
|
el.addEventListener('input', (e) => {
|
||||||
const i = Number(e.target.dataset.idx);
|
const i = Number(e.target.dataset.idx);
|
||||||
const field = e.target.dataset.field;
|
const field = e.target.dataset.field;
|
||||||
if (!patternDraft.steps[i]) return;
|
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;
|
patternDraft.steps[i][field] = e.target.value;
|
||||||
|
}
|
||||||
persistPatternDraft();
|
persistPatternDraft();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
card.querySelectorAll('.pattern-buttons button').forEach(btn => {
|
card.querySelectorAll('.pattern-buttons button').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const tok = btn.dataset.tok;
|
addPatternTokenToStep(idx, 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enter key handling for row adding
|
||||||
const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]');
|
const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]');
|
||||||
if (rowTextarea) {
|
if (rowTextarea) {
|
||||||
rowTextarea.addEventListener('keydown', (e) => {
|
rowTextarea.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addStepRow(idx);
|
addStepRow(idx);
|
||||||
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
addStep();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => {
|
|
||||||
card.classList.remove('card-pop');
|
|
||||||
});
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
const addRow = document.createElement('div');
|
const addRow = document.createElement('div');
|
||||||
addRow.className = 'pattern-step-card add-step-row';
|
addRow.className = 'add-step-row';
|
||||||
addRow.innerHTML = `
|
addRow.innerHTML = `<button class="primary add-step-btn" onclick="addStep()">+ New Step</button>`;
|
||||||
<button class="primary add-step-btn" onclick="addStep()">+ Add Step</button>
|
|
||||||
`;
|
|
||||||
container.appendChild(addRow);
|
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() {
|
function renderPatternView() {
|
||||||
const container = document.getElementById('patternView');
|
const container = document.getElementById('patternView');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@ -711,23 +937,44 @@ function renderPatternView() {
|
|||||||
const gauge = patternDraft.gauge || '';
|
const gauge = patternDraft.gauge || '';
|
||||||
const gaugeSts = patternDraft.gaugeSts || '';
|
const gaugeSts = patternDraft.gaugeSts || '';
|
||||||
const gaugeRows = patternDraft.gaugeRows || '';
|
const gaugeRows = patternDraft.gaugeRows || '';
|
||||||
const gaugeHook = patternDraft.gaugeHook || '';
|
|
||||||
const size = patternDraft.size || '';
|
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 = `
|
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>
|
<h3 class="view-title">${meta.title || 'Pattern'}</h3>
|
||||||
<p class="view-sub">${meta.designer || ''}</p>
|
<p class="view-sub">${meta.designer || ''}</p>
|
||||||
${mats ? `<h4>Materials</h4><pre>${mats}</pre>` : ''}
|
${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>
|
</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">
|
<div class="view-card">
|
||||||
<h4>Steps</h4>
|
<h4>Steps</h4>
|
||||||
<div class="view-steps">
|
<div class="view-steps">
|
||||||
${steps.map((st, i) => `
|
${steps.map((st, i) => `
|
||||||
<div class="view-step ${st.finished ? 'is-finished' : ''}">
|
<div class="view-step ${st.finished ? 'is-finished minimized' : ''}">
|
||||||
<header>
|
<header onclick="toggleViewStepMinimize(this)">
|
||||||
<div class="title">Step ${i + 1}${st.title ? ': ' + st.title : ''}</div>
|
<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' : ''}>
|
<input type="checkbox" data-view-step="${i}" ${st.finished ? 'checked' : ''}>
|
||||||
<i class="fa-regular fa-square"></i>
|
<i class="fa-regular fa-square"></i>
|
||||||
<i class="fa-solid fa-square-check"></i>
|
<i class="fa-solid fa-square-check"></i>
|
||||||
@ -735,8 +982,12 @@ function renderPatternView() {
|
|||||||
</header>
|
</header>
|
||||||
<div class="view-step-body">
|
<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>
|
<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;">
|
${st.image ? `<img src="${st.image}" class="view-step-img" alt="Step Image">` : ''}
|
||||||
<label>Notes</label>
|
|
||||||
|
<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>
|
<textarea data-view-note="${i}" placeholder="Notes for this step...">${st.viewNote || ''}</textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -744,10 +995,6 @@ function renderPatternView() {
|
|||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</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 => {
|
container.querySelectorAll('input[type="checkbox"][data-view-step]').forEach(cb => {
|
||||||
cb.addEventListener('change', (e) => {
|
cb.addEventListener('change', (e) => {
|
||||||
@ -755,9 +1002,12 @@ function renderPatternView() {
|
|||||||
if (!patternDraft.steps[idx]) return;
|
if (!patternDraft.steps[idx]) return;
|
||||||
patternDraft.steps[idx].finished = e.target.checked;
|
patternDraft.steps[idx].finished = e.target.checked;
|
||||||
persistPatternDraft();
|
persistPatternDraft();
|
||||||
save();
|
|
||||||
const card = e.target.closest('.view-step');
|
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 => {
|
container.querySelectorAll('textarea[data-view-note]').forEach(area => {
|
||||||
@ -768,13 +1018,29 @@ function renderPatternView() {
|
|||||||
persistPatternDraft();
|
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() {
|
function renderPatternLibrary() {
|
||||||
@ -936,24 +1202,128 @@ function updateStepRow(idx, rowIdx, value) {
|
|||||||
persistPatternDraft();
|
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() {
|
function renderAbbrevChecklist() {
|
||||||
const listEl = document.getElementById('patternAbbrevList');
|
const listEl = document.getElementById('patternAbbrevList');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
const selected = new Set(patternDraft.abbrevSelection);
|
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 => {
|
getAbbrevGroups().forEach(bucket => {
|
||||||
const wrap = document.createElement('details');
|
const wrap = document.createElement('details');
|
||||||
wrap.className = 'abbrev-group';
|
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');
|
const summary = document.createElement('summary');
|
||||||
summary.textContent = `${bucket.label} (${bucket.items.length})`;
|
summary.textContent = `${bucket.label} (${bucket.items.length})`;
|
||||||
wrap.appendChild(summary);
|
wrap.appendChild(summary);
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'abbrev-grid';
|
grid.className = 'abbrev-grid';
|
||||||
bucket.items.forEach(item => {
|
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');
|
const pill = document.createElement('div');
|
||||||
pill.className = 'abbrev-pill';
|
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.dataset.code = item.code;
|
||||||
pill.innerHTML = `
|
pill.innerHTML = `
|
||||||
<span class="code">${item.code}</span>
|
<span class="code">${item.code}</span>
|
||||||
@ -961,21 +1331,17 @@ function renderAbbrevChecklist() {
|
|||||||
`;
|
`;
|
||||||
pill.addEventListener('click', () => {
|
pill.addEventListener('click', () => {
|
||||||
const code = pill.dataset.code;
|
const code = pill.dataset.code;
|
||||||
if (pill.classList.contains('is-selected')) {
|
if (patternDraft.abbrevSelection.includes(code)) {
|
||||||
pill.classList.remove('is-selected');
|
|
||||||
patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code);
|
patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code);
|
||||||
|
pill.classList.remove('is-selected');
|
||||||
} else {
|
} else {
|
||||||
|
patternDraft.abbrevSelection.push(code);
|
||||||
pill.classList.add('is-selected');
|
pill.classList.add('is-selected');
|
||||||
if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code);
|
|
||||||
}
|
}
|
||||||
updateAbbrevFromSelection();
|
updateAbbrevFromSelection();
|
||||||
|
// Don't re-render whole list to keep scroll position
|
||||||
});
|
});
|
||||||
grid.appendChild(pill);
|
return pill;
|
||||||
});
|
|
||||||
wrap.appendChild(grid);
|
|
||||||
listEl.appendChild(wrap);
|
|
||||||
});
|
|
||||||
renderSelectedAbbrev();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAbbrevFromSelection() {
|
function updateAbbrevFromSelection() {
|
||||||
@ -1102,9 +1468,11 @@ let easterEggCooling = false;
|
|||||||
function updateAuthUI() {
|
function updateAuthUI() {
|
||||||
const badge = document.getElementById('authStatusBadge');
|
const badge = document.getElementById('authStatusBadge');
|
||||||
const lastSync = document.getElementById('authLastSync');
|
const lastSync = document.getElementById('authLastSync');
|
||||||
const whenIn = document.querySelector('.auth-when-in');
|
const authProfile = document.querySelector('.auth-profile');
|
||||||
const whenOut = document.querySelector('.auth-when-out');
|
const authContent = document.querySelector('.auth-content');
|
||||||
const tabs = document.querySelector('.auth-tabs');
|
const tabs = document.querySelector('.auth-tabs');
|
||||||
|
const adminTabBtn = document.getElementById('adminTabBtn');
|
||||||
|
const profileTabBtn = document.getElementById('profileTabBtn');
|
||||||
const displayNameInput = document.getElementById('authDisplayName');
|
const displayNameInput = document.getElementById('authDisplayName');
|
||||||
const noteInput = document.getElementById('authNote');
|
const noteInput = document.getElementById('authNote');
|
||||||
|
|
||||||
@ -1116,19 +1484,79 @@ function updateAuthUI() {
|
|||||||
if (lastSync) {
|
if (lastSync) {
|
||||||
lastSync.textContent = `Status: ${auth.status || 'unknown'}`;
|
lastSync.textContent = `Status: ${auth.status || 'unknown'}`;
|
||||||
}
|
}
|
||||||
if (whenIn) whenIn.style.display = signedIn ? 'block' : 'none';
|
|
||||||
if (whenOut) whenOut.style.display = signedIn ? 'none' : 'block';
|
// Default container visibility logic based on state
|
||||||
if (tabs) tabs.style.display = signedIn ? 'none' : 'flex';
|
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 (displayNameInput && auth.profile) displayNameInput.value = auth.profile.display_name || '';
|
||||||
if (noteInput && auth.profile) noteInput.value = auth.profile.note || '';
|
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() {
|
function openAuthModal() {
|
||||||
const overlay = document.getElementById('authOverlay');
|
const overlay = document.getElementById('authOverlay');
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
overlay.classList.add('active');
|
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();
|
updateAuthUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1138,23 +1566,63 @@ function closeAuthModal() {
|
|||||||
overlay.classList.remove('active');
|
overlay.classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthMode(mode) {
|
async function fetchAllUsers() {
|
||||||
document.querySelectorAll('.auth-tab').forEach(btn => {
|
if (!auth.token || !auth.isAdmin) return;
|
||||||
btn.classList.toggle('is-active', btn.dataset.mode === mode);
|
const list = document.getElementById('allUsersList');
|
||||||
});
|
if (!list) return;
|
||||||
auth.mode = mode;
|
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();
|
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) {
|
if (!email || !password) {
|
||||||
alert('Please enter email and password to continue.');
|
showAlert({ title: 'Missing Info', text: 'Please enter both email and password.' });
|
||||||
return false;
|
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 {
|
try {
|
||||||
const endpoint = auth.mode === 'signup' ? '/api/signup' : '/api/login';
|
const endpoint = mode === 'signup' ? '/api/signup' : '/api/login';
|
||||||
const resp = await fetch(endpoint, {
|
const resp = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -1180,7 +1648,8 @@ async function autoSync() {
|
|||||||
await fetchProfile();
|
await fetchProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile(event) {
|
||||||
|
if (event) event.preventDefault();
|
||||||
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
|
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
|
||||||
const note = (document.getElementById('authNote') || {}).value || '';
|
const note = (document.getElementById('authNote') || {}).value || '';
|
||||||
try {
|
try {
|
||||||
@ -1194,6 +1663,7 @@ async function saveProfile() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert({ title: 'Profile save failed', text: err.message });
|
showAlert({ title: 'Profile save failed', text: err.message });
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
@ -2256,16 +2726,18 @@ async function makeAdmin(id) {
|
|||||||
|
|
||||||
async function downloadBackup() {
|
async function downloadBackup() {
|
||||||
const resp = await fetch('/api/admin/backup', { headers: { Authorization: `Bearer ${auth.token}` } });
|
const resp = await fetch('/api/admin/backup', { headers: { Authorization: `Bearer ${auth.token}` } });
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
||||||
|
const blob = await resp.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `backup_${new Date().toISOString()}.json`;
|
a.download = `toadstool_dump_${new Date().toISOString().split('T')[0]}.sql`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@ -2274,18 +2746,24 @@ async function uploadRestore(event) {
|
|||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const buffer = await file.arrayBuffer();
|
||||||
const payload = JSON.parse(text);
|
const bytes = new Uint8Array(buffer);
|
||||||
const resp = await fetch('/api/admin/restore', {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify({ sql: base64 })
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
showAlert({ title: 'Restore failed', text: data.error || 'Error' });
|
showAlert({ title: 'Restore failed', text: data.error || data.message || 'Error' });
|
||||||
} else {
|
} else {
|
||||||
showAlert({ title: 'Restore complete', text: 'Data restored.' });
|
showAlert({ title: 'Restore complete', text: data.message || 'Database restored.' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert({ title: 'Restore failed', text: err.message });
|
showAlert({ title: 'Restore failed', text: err.message });
|
||||||
|
|||||||
1065
assets/style.css
1065
assets/style.css
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ services:
|
|||||||
|
|
||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
|
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
|
||||||
PORT: 4000
|
PORT: 4000
|
||||||
@ -21,11 +21,21 @@ services:
|
|||||||
ADMIN_EMAIL: chris@chrisedwards.tech
|
ADMIN_EMAIL: chris@chrisedwards.tech
|
||||||
ADMIN_PASSWORD: R4e3w2q1
|
ADMIN_PASSWORD: R4e3w2q1
|
||||||
volumes:
|
volumes:
|
||||||
- ./server/uploads:/app/server/uploads
|
- .:/app
|
||||||
|
- /app/server/node_modules
|
||||||
|
- /app/node_modules
|
||||||
|
working_dir: /app
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
|
- "8080:8080"
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
npm install &&
|
||||||
|
cd server && npm install && cd .. &&
|
||||||
|
npm run dev
|
||||||
|
"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
|||||||
229
index.html
229
index.html
@ -92,7 +92,7 @@
|
|||||||
<div class="auth-modal">
|
<div class="auth-modal">
|
||||||
<div class="auth-modal-head">
|
<div class="auth-modal-head">
|
||||||
<div>
|
<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>
|
<p class="auth-subtext">Stay free forever. Sign in only if you want cloud backups and device switching.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="pattern-close" onclick="closeAuthModal()">×</button>
|
<button class="pattern-close" onclick="closeAuthModal()">×</button>
|
||||||
@ -102,43 +102,84 @@
|
|||||||
<span id="authLastSync" class="status-subtext">Last sync: never</span>
|
<span id="authLastSync" class="status-subtext">Last sync: never</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-tabs">
|
<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" 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>
|
</div>
|
||||||
<form class="auth-form" onsubmit="return submitAuth(event)">
|
<div class="auth-content">
|
||||||
<div class="auth-when-out">
|
<div id="loginContent" class="auth-tab-content active">
|
||||||
<label class="field-label" for="authEmail">Email</label>
|
<form class="auth-form" onsubmit="return submitAuth(event, 'login')">
|
||||||
<input id="authEmail" type="email" placeholder="you@example.com" autocomplete="email">
|
<label class="field-label" for="loginEmail">Email</label>
|
||||||
<label class="field-label" for="authPassword">Password</label>
|
<input id="loginEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
|
||||||
<input id="authPassword" type="password" placeholder="••••••••" autocomplete="current-password">
|
<label class="field-label" for="loginPassword">Password</label>
|
||||||
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device. When enabled, we’ll sync projects/patterns securely.</p>
|
<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">
|
<div class="auth-actions">
|
||||||
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
|
<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>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-when-in">
|
<div id="signupContent" class="auth-tab-content">
|
||||||
<label class="field-label" for="authDisplayName">Display name</label>
|
<form class="auth-form" onsubmit="return submitAuth(event, 'signup')">
|
||||||
<input id="authDisplayName" type="text" placeholder="Your name">
|
<label class="field-label" for="signupEmail">Email</label>
|
||||||
<label class="field-label" for="authNote">Profile note</label>
|
<input id="signupEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
|
||||||
<textarea id="authNote" rows="2" placeholder="Add a note for your patterns..."></textarea>
|
<label class="field-label" for="signupPassword">Password</label>
|
||||||
<div class="auth-actions auth-actions-stack">
|
<input id="signupPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
|
||||||
<button type="button" class="modal-btn btn-save" onclick="autoSync()">Sync now</button>
|
<label class="field-label" for="signupConfirmPassword">Confirm Password</label>
|
||||||
<button type="button" class="modal-btn btn-save" onclick="saveProfile()">Save profile</button>
|
<input id="signupConfirmPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
|
||||||
<button type="button" class="modal-btn btn-cancel" onclick="logoutAuth()">Log out</button>
|
<p class="auth-hint">When enabled, we’ll 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>
|
||||||
<div id="adminPanel" class="admin-panel" style="display:none;">
|
</form>
|
||||||
<h4>Admin</h4>
|
</div>
|
||||||
|
<div id="adminContent" class="auth-tab-content">
|
||||||
|
<div id="adminPanel" class="admin-panel">
|
||||||
|
<h4>Pending Approvals</h4>
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
<button type="button" class="modal-btn btn-save" onclick="fetchPendingUsers()">Refresh pending</button>
|
<button type="button" class="modal-btn btn-secondary" 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>
|
||||||
<div id="pendingList" class="admin-list"></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>
|
||||||
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -147,86 +188,114 @@
|
|||||||
<div class="pattern-overlay" id="patternOverlay">
|
<div class="pattern-overlay" id="patternOverlay">
|
||||||
<div class="pattern-sheet">
|
<div class="pattern-sheet">
|
||||||
<div class="pattern-sheet-header">
|
<div class="pattern-sheet-header">
|
||||||
<div class="pattern-sheet-title">
|
<div class="header-main">
|
||||||
<h2>Pattern Composer</h2>
|
<h2 class="pattern-sheet-title">Pattern Composer</h2>
|
||||||
<p class="pattern-sheet-subtitle">Draft rows plus materials, gauge, and abbreviations.</p>
|
|
||||||
</div>
|
|
||||||
<div class="pattern-modes">
|
<div class="pattern-modes">
|
||||||
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
|
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
|
||||||
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
|
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
|
||||||
</div>
|
</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()">×</button>
|
<button class="pattern-close" onclick="closePatternComposer()">×</button>
|
||||||
</div>
|
</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-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>
|
||||||
|
|
||||||
<div class="pattern-body">
|
<div class="pattern-body">
|
||||||
<div class="pattern-tabs">
|
<div class="pattern-section active" data-section="info">
|
||||||
<button class="pattern-tab" data-tab="info" onclick="showPatternTab('info')">Pattern Info</button>
|
<div class="card-grid">
|
||||||
<button class="pattern-tab" data-tab="steps" onclick="showPatternTab('steps')">Steps</button>
|
<div class="input-card">
|
||||||
<button class="pattern-tab" data-tab="view" onclick="showPatternTab('view')">View</button>
|
<h4 class="card-head">Basic Details</h4>
|
||||||
<button class="pattern-tab" data-tab="library" onclick="showPatternTab('library')">My Basket</button>
|
<div class="form-row">
|
||||||
</div>
|
|
||||||
<div class="pattern-section" data-section="info">
|
|
||||||
<label class="field-label" for="patternTitle">Title</label>
|
<label class="field-label" for="patternTitle">Title</label>
|
||||||
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
|
<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">
|
<input id="patternDesigner" type="text" placeholder="Your name or shop">
|
||||||
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
</div>
|
||||||
<textarea id="patternMaterials" placeholder="Yarn (Color A) – worsted Yarn (Color B) – accent Hook – 4.0 mm Safety eyes, stuffing, needle"></textarea>
|
</div>
|
||||||
<div class="field-group-inline">
|
|
||||||
|
<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>
|
<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">
|
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="field-label" for="patternGaugeHook">Hook / Needles</label>
|
<label class="field-label" for="patternGauge">Gauge Notes</label>
|
||||||
<input id="patternGaugeHook" type="text" placeholder="e.g., 4.0 mm hook">
|
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="patternAbbrevList" class="abbrev-grid"></div>
|
|
||||||
<textarea id="patternAbbrev" placeholder="sc – single crochet dc – double crochet inc – increase k – knit p – purl"></textarea>
|
<div class="input-card full-width">
|
||||||
<label class="field-label" for="patternStitches">Stitch guide / special stitches</label>
|
<h4 class="card-head">Materials & Notes</h4>
|
||||||
<textarea id="patternStitches" placeholder="Magic ring: ... Invisible decrease: ... Kfb: knit front and back ..."></textarea>
|
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
||||||
<label class="field-label" for="patternNotes">Notes / finishing</label>
|
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color) • 4.0mm Hook • Tapestry Needle • Polyester Fiberfill"></textarea>
|
||||||
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
|
||||||
<div class="pattern-row-actions pattern-footer">
|
<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: ... Invisible decrease: ..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pattern-footer-actions">
|
||||||
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
|
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pattern-section" data-section="steps">
|
<div class="pattern-section" data-section="steps">
|
||||||
<div class="pattern-steps-head">
|
<div class="pattern-steps-head">
|
||||||
<h4>Steps</h4>
|
<h4>Pattern Steps</h4>
|
||||||
<button class="primary" onclick="addStep()">+ Step</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="patternSteps"></div>
|
<div id="patternSteps"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pattern-section" data-section="library">
|
<div class="pattern-section" data-section="library">
|
||||||
<div class="pattern-steps-head">
|
<div class="pattern-steps-head">
|
||||||
<h4>Saved Patterns</h4>
|
<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>
|
||||||
<div id="patternLibrary" class="pattern-library"></div>
|
<div id="patternLibrary" class="pattern-library"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pattern-section" data-section="view">
|
<div class="pattern-section" data-section="view">
|
||||||
<div id="patternView" class="pattern-view"></div>
|
<div id="patternView" class="pattern-view"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -234,6 +303,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script src="assets/app.js"></script>
|
||||||
<footer class="footer-bg" aria-hidden="true"></footer>
|
<footer class="footer-bg" aria-hidden="true"></footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
15
live-server.js
Normal file
15
live-server.js
Normal 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
2726
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -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.",
|
"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",
|
"main": "sw.js",
|
||||||
"scripts": {
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"live-server": "^1.2.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
server/package-lock.json
generated
138
server/package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pg": "^8.12.0",
|
||||||
"sharp": "^0.33.3",
|
"sharp": "^0.33.3",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
@ -2287,6 +2288,95 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
@ -2300,6 +2390,45 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -2854,6 +2983,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const multer = require('multer');
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const {
|
const {
|
||||||
initDb,
|
initDb,
|
||||||
|
withClient,
|
||||||
createUser,
|
createUser,
|
||||||
verifyUser,
|
verifyUser,
|
||||||
createSession,
|
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 });
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '15mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
// Init DB
|
// Init DB
|
||||||
initDb().catch((err) => {
|
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.
|
// Upload route: save to /uploads, return URL (Optimized with sharp, fallback to raw)
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
|
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) => {
|
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) return res.status(400).json({ error: 'File required' });
|
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 ext = (req.file.originalname.split('.').pop() || 'jpg').toLowerCase();
|
||||||
const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'].includes(ext) ? ext : 'jpg';
|
const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'].includes(ext) ? ext : 'jpg';
|
||||||
const filename = `${Date.now()}-${uuid()}.${safeExt}`;
|
filename = `${Date.now()}-${uuid()}.${safeExt}`;
|
||||||
const outPath = path.join(UPLOAD_DIR, filename);
|
outPath = path.join(UPLOAD_DIR, filename);
|
||||||
|
await fs.promises.writeFile(outPath, req.file.buffer);
|
||||||
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}`;
|
const url = `/uploads/${filename}`;
|
||||||
res.json({ url });
|
res.json({ url });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(err);
|
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) => {
|
app.get('/api/admin/backup', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await listAllUsers();
|
// Let's use direct DB queries to get everything.
|
||||||
const projects = await fetchItemsSince('projects', null, null);
|
const allUsers = await withClient(c => c.query('select * from users order by created_at'));
|
||||||
const patterns = await fetchItemsSince('patterns', null, null);
|
const allProjects = await withClient(c => c.query('select * from projects order by updated_at'));
|
||||||
res.json({ users, projects, patterns, exportedAt: new Date().toISOString() });
|
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) {
|
} catch (err) {
|
||||||
console.error(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) => {
|
app.use((err, _req, res, _next) => {
|
||||||
// Basic error guard
|
// Basic error guard
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user