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/
|
||||
server/uploads/
|
||||
*.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.
|
||||
- Data is stored in `localStorage`; clear it to reset.
|
||||
|
||||
## Backend (optional sync)
|
||||
|
||||
Node + Docker demo backend lives in `server/`.
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # http://localhost:4000
|
||||
|
||||
# Docker
|
||||
docker build -t toadstool-api .
|
||||
docker run -p 4000:4000 toadstool-api
|
||||
```
|
||||
|
||||
### Docker Compose (app + Postgres)
|
||||
```bash
|
||||
docker compose up --build
|
||||
# App/API: http://localhost:4000
|
||||
# Postgres: localhost:5432 (user/pass/db: toadstool)
|
||||
```
|
||||
|
||||
API (in-memory, demo):
|
||||
- `POST /api/signup` `{ email, password, displayName? }` -> `{ token, email }`
|
||||
- `POST /api/login` `{ email, password }` -> `{ token, email }`
|
||||
- `POST /api/logout` (Bearer token)
|
||||
- `GET /api/sync?since=...` (Bearer token) -> `{ projects, patterns }`
|
||||
- `POST /api/sync` (Bearer token) `{ projects, patterns }`
|
||||
- `GET /api/me`, `POST /api/me` (profile)
|
||||
- `POST /api/patterns/:id/share` -> `{ token, url }`, `GET /share/:token` -> `{ pattern }`
|
||||
- `POST /api/upload` (Bearer token, multipart `file`) -> `{ url }` (resizes/compresses to `/uploads`)
|
||||
|
||||
Replace with real auth/storage before production.
|
||||
|
||||
## PWA Notes
|
||||
- Manifest: `assets/site.webmanifest`
|
||||
- Service worker: `sw.js`
|
||||
|
||||
716
assets/app.js
716
assets/app.js
@ -50,7 +50,7 @@ const crochetAbbrev = [
|
||||
{ code: 'inc', desc: 'increase' },
|
||||
{ code: 'lp', desc: 'loop' },
|
||||
{ code: 'm', desc: 'marker' },
|
||||
{ code: 'mc', desc: 'main color' },
|
||||
{ code: 'mc', desc: 'main circle' },
|
||||
{ code: 'pat', desc: 'pattern' },
|
||||
{ code: 'pc', desc: 'popcorn stitch' },
|
||||
{ code: 'pm', desc: 'place marker' },
|
||||
@ -197,7 +197,8 @@ const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end'];
|
||||
const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']);
|
||||
|
||||
function getActiveAbbrevLibrary() {
|
||||
return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
|
||||
const base = patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev;
|
||||
return [...base, ...(patternDraft.customAbbrev || [])];
|
||||
}
|
||||
|
||||
function getAbbrevByCode(code) {
|
||||
@ -245,6 +246,17 @@ function getPatternButtonCodes() {
|
||||
const merged = [...base, ...repeatTokens];
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
const yarnWeights = [
|
||||
{ val: '0', label: 'Lace', desc: 'Fingering' },
|
||||
{ val: '1', label: 'Super Fine', desc: 'Sock' },
|
||||
{ val: '2', label: 'Fine', desc: 'Sport' },
|
||||
{ val: '3', label: 'Light', desc: 'DK' },
|
||||
{ val: '4', label: 'Medium', desc: 'Worsted' },
|
||||
{ val: '5', label: 'Bulky', desc: 'Chunky' },
|
||||
{ val: '6', label: 'Super Bulky', desc: 'Roving' },
|
||||
{ val: '7', label: 'Jumbo', desc: 'Giant' }
|
||||
];
|
||||
|
||||
function normalizePatternDraft(d = {}) {
|
||||
const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' };
|
||||
const base = {
|
||||
@ -257,23 +269,39 @@ function normalizePatternDraft(d = {}) {
|
||||
gauge: '',
|
||||
gaugeSts: '',
|
||||
gaugeRows: '',
|
||||
gaugeHook: '',
|
||||
size: '',
|
||||
abbrev: '',
|
||||
abbrevSelection: [],
|
||||
customAbbrev: [],
|
||||
stitches: '',
|
||||
notes: '',
|
||||
steps: []
|
||||
steps: [],
|
||||
palette: [],
|
||||
yarns: [],
|
||||
hooks: [],
|
||||
previewSize: 'full'
|
||||
};
|
||||
const merged = { ...base, ...d };
|
||||
merged.meta = { ...base.meta, ...(d.meta || {}) };
|
||||
merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : [];
|
||||
merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : [];
|
||||
merged.palette = Array.isArray(merged.palette) ? merged.palette : [];
|
||||
merged.yarns = Array.isArray(merged.yarns) ? merged.yarns : [];
|
||||
merged.hooks = Array.isArray(merged.hooks) ? merged.hooks : [];
|
||||
|
||||
// Migration: Legacy yarnWeight
|
||||
if (d.yarnWeight && merged.yarns.length === 0) {
|
||||
merged.yarns.push({ weight: d.yarnWeight, note: 'Main' });
|
||||
}
|
||||
// Migration: Legacy gaugeHook
|
||||
if (d.gaugeHook && merged.hooks.length === 0) {
|
||||
merged.hooks.push({ size: d.gaugeHook, note: 'Main' });
|
||||
}
|
||||
|
||||
merged.materials = merged.materials || '';
|
||||
merged.gauge = merged.gauge || '';
|
||||
merged.gaugeSts = merged.gaugeSts || '';
|
||||
merged.gaugeRows = merged.gaugeRows || '';
|
||||
merged.gaugeHook = merged.gaugeHook || '';
|
||||
merged.size = merged.size || '';
|
||||
merged.abbrev = merged.abbrev || '';
|
||||
merged.stitches = merged.stitches || '';
|
||||
@ -413,7 +441,6 @@ function syncPatternUI() {
|
||||
const gaugeEl = document.getElementById('patternGauge');
|
||||
const gaugeStsEl = document.getElementById('patternGaugeSts');
|
||||
const gaugeRowsEl = document.getElementById('patternGaugeRows');
|
||||
const gaugeHookEl = document.getElementById('patternGaugeHook');
|
||||
const sizeEl = document.getElementById('patternSize');
|
||||
const abbrevEl = document.getElementById('patternAbbrev');
|
||||
const stitchesEl = document.getElementById('patternStitches');
|
||||
@ -424,27 +451,151 @@ function syncPatternUI() {
|
||||
if (gaugeEl) gaugeEl.value = patternDraft.gauge;
|
||||
if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts;
|
||||
if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows;
|
||||
if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook;
|
||||
if (sizeEl) sizeEl.value = patternDraft.size;
|
||||
if (abbrevEl) abbrevEl.value = patternDraft.abbrev;
|
||||
if (stitchesEl) stitchesEl.value = patternDraft.stitches;
|
||||
if (notesEl) notesEl.value = patternDraft.notes;
|
||||
|
||||
renderYarnList();
|
||||
renderHookList();
|
||||
renderSteps();
|
||||
renderAbbrevChecklist();
|
||||
renderAbbrevSummary();
|
||||
}
|
||||
|
||||
function addPatternToken(tok) {
|
||||
if (!patternLine) return;
|
||||
patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok;
|
||||
patternLine.value = patternDraft.line;
|
||||
persistPatternDraft();
|
||||
function renderYarnList() {
|
||||
const el = document.getElementById('yarnList');
|
||||
if (!el) return;
|
||||
|
||||
if (patternDraft.yarns.length === 0) {
|
||||
el.innerHTML = `<button class="secondary small" onclick="addYarn()">+ Add Yarn</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
function clearPatternLine() {
|
||||
if (!patternLine) return;
|
||||
patternDraft.line = '';
|
||||
patternLine.value = '';
|
||||
el.innerHTML = patternDraft.yarns.map((y, idx) => `
|
||||
<div class="yarn-item-card" style="border-left: 5px solid ${y.color || 'var(--border)'}">
|
||||
<div class="yarn-header">
|
||||
<span class="yarn-idx">Yarn ${String.fromCharCode(65 + idx)}</span>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<label class="color-picker-label" style="background:${y.color || '#e0e0e0'}" title="Pick Color">
|
||||
<input type="color" value="${y.color || '#e0e0e0'}" onchange="updateYarn(${idx}, 'color', this.value)">
|
||||
</label>
|
||||
<button class="btn-icon-small danger" onclick="removeYarn(${idx})">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="yarn-weight-selector small">
|
||||
${yarnWeights.map(w => `
|
||||
<label class="yarn-pill small">
|
||||
<input type="radio" name="yarnWeight_${idx}" value="${w.val}" ${y.weight === w.val ? 'checked' : ''} onchange="updateYarn(${idx}, 'weight', '${w.val}')">
|
||||
<span class="yarn-content">
|
||||
<span class="yarn-num">${w.val}</span>
|
||||
<span class="yarn-label">${w.label}</span>
|
||||
</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
<input type="text" class="yarn-note-input" value="${y.note || ''}" placeholder="Brand / Fiber / Note" oninput="updateYarn(${idx}, 'note', this.value)">
|
||||
</div>
|
||||
`).join('') + `<button class="secondary small" onclick="addYarn()" style="margin-top:8px;">+ Add Another Yarn</button>`;
|
||||
}
|
||||
|
||||
function addYarn() {
|
||||
patternDraft.yarns.push({ weight: '4', note: '', color: '#a17d63' });
|
||||
persistPatternDraft();
|
||||
renderYarnList();
|
||||
}
|
||||
|
||||
function removeYarn(idx) {
|
||||
patternDraft.yarns.splice(idx, 1);
|
||||
persistPatternDraft();
|
||||
renderYarnList();
|
||||
}
|
||||
|
||||
function updateYarn(idx, field, val) {
|
||||
if (patternDraft.yarns[idx]) {
|
||||
patternDraft.yarns[idx][field] = val;
|
||||
persistPatternDraft();
|
||||
if (field === 'color') renderYarnList(); // Re-render to update border color
|
||||
}
|
||||
}
|
||||
|
||||
function renderHookList() {
|
||||
const el = document.getElementById('hookList');
|
||||
if (!el) return;
|
||||
|
||||
if (patternDraft.hooks.length === 0) {
|
||||
el.innerHTML = `<button class="secondary small" onclick="addHook()">+ Add Hook</button>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = patternDraft.hooks.map((h, idx) => `
|
||||
<div class="hook-item-row">
|
||||
<input type="text" class="hook-size-input" value="${h.size || ''}" placeholder="Size (e.g. 4.0mm)" oninput="updateHook(${idx}, 'size', this.value)">
|
||||
<input type="text" class="hook-note-input" value="${h.note || ''}" placeholder="Use (e.g. Body)" oninput="updateHook(${idx}, 'note', this.value)">
|
||||
<button class="btn-icon-small danger" onclick="removeHook(${idx})">×</button>
|
||||
</div>
|
||||
`).join('') + `<button class="secondary small" onclick="addHook()" style="margin-top:6px;">+ Add Hook</button>`;
|
||||
}
|
||||
|
||||
function addHook() {
|
||||
patternDraft.hooks.push({ size: '', note: '' });
|
||||
persistPatternDraft();
|
||||
renderHookList();
|
||||
}
|
||||
|
||||
function removeHook(idx) {
|
||||
patternDraft.hooks.splice(idx, 1);
|
||||
persistPatternDraft();
|
||||
renderHookList();
|
||||
}
|
||||
|
||||
function updateHook(idx, field, val) {
|
||||
if (patternDraft.hooks[idx]) {
|
||||
patternDraft.hooks[idx][field] = val;
|
||||
persistPatternDraft();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatternPalette() {
|
||||
const el = document.getElementById('patternPaletteList');
|
||||
if (!el) return;
|
||||
el.innerHTML = patternDraft.palette.map((c, i) => `
|
||||
<div class="palette-swatch" style="background:${c}" onclick="removePatternColor(${i})" title="Remove color"></div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function addPatternColor() {
|
||||
// Open color picker but customized for pattern
|
||||
// For simplicity, we'll reuse the existing color picker but hook it differently?
|
||||
// Actually, let's just make a simple prompt or use the existing overlay with a special mode.
|
||||
// Or just a native input.
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.onchange = (e) => {
|
||||
patternDraft.palette.push(e.target.value);
|
||||
persistPatternDraft();
|
||||
renderPatternPalette();
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function removePatternColor(idx) {
|
||||
patternDraft.palette.splice(idx, 1);
|
||||
persistPatternDraft();
|
||||
renderPatternPalette();
|
||||
}
|
||||
|
||||
function filterAbbrev() {
|
||||
const term = document.getElementById('abbrevSearch').value.toLowerCase();
|
||||
const items = document.querySelectorAll('.abbrev-pill');
|
||||
items.forEach(el => {
|
||||
const text = el.innerText.toLowerCase();
|
||||
el.style.display = text.includes(term) ? 'flex' : 'none';
|
||||
});
|
||||
// Also hide empty groups
|
||||
document.querySelectorAll('.abbrev-group').forEach(grp => {
|
||||
const visible = grp.querySelectorAll('.abbrev-pill[style="display: flex;"]').length > 0;
|
||||
grp.style.display = visible ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function addPatternRow() {
|
||||
@ -579,8 +730,9 @@ function exportPatternPDF() {
|
||||
<div class="section-title">Steps</div>
|
||||
${patternDraft.steps.map((s, i) => {
|
||||
const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('<br>');
|
||||
const note = s.note ? `<br>${s.note}` : '';
|
||||
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows}${note}</div>`;
|
||||
const note = s.note ? `<br><strong>Note:</strong> ${s.note}` : '';
|
||||
const img = s.image ? `<br><img src="${s.image}" style="max-width:300px; max-height:300px; margin-top:10px; border-radius:8px;">` : '';
|
||||
return `<div><strong>Step ${i + 1}${s.title ? ': ' + s.title : ''}</strong><br>${rows}${note}${img}</div>`;
|
||||
}).join('<hr>')}
|
||||
</div>
|
||||
<div class="section">
|
||||
@ -601,11 +753,27 @@ function exportPatternPDF() {
|
||||
}
|
||||
|
||||
function showPatternTab(tab) {
|
||||
document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab));
|
||||
document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab));
|
||||
document.querySelectorAll('.nav-item').forEach(btn =>
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab)
|
||||
);
|
||||
|
||||
// Remove split view logic for simplicity
|
||||
document.querySelector('.pattern-body').classList.remove('split-view');
|
||||
|
||||
document.querySelectorAll('.pattern-section').forEach(sec => {
|
||||
sec.classList.toggle('active', sec.dataset.section === tab);
|
||||
});
|
||||
|
||||
try { localStorage.setItem('patternActiveTab', tab); } catch (e) {}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const activeBtn = document.querySelector('.nav-item.active');
|
||||
if (activeBtn && activeBtn.dataset.tab === 'steps') {
|
||||
showPatternTab('steps');
|
||||
}
|
||||
});
|
||||
|
||||
function updateMetaField(field, value) {
|
||||
patternDraft.meta[field] = value;
|
||||
persistPatternDraft();
|
||||
@ -620,88 +788,146 @@ function renderSteps() {
|
||||
const container = document.getElementById('patternSteps');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
patternDraft.steps.forEach((step, idx) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pattern-step-card card-pop';
|
||||
|
||||
const rows = step.rows || [];
|
||||
const rowList = rows.map((r, i) => `
|
||||
<div class="row-item">
|
||||
<label>Row ${i + 1}</label>
|
||||
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}">
|
||||
<button type="button" onclick="removeStepRow(${idx}, ${i})">✕</button>
|
||||
<span class="row-num">${i + 1}.</span>
|
||||
<input type="text" value="${r}" data-idx="${idx}" data-row="${i}" placeholder="Row instructions...">
|
||||
<button type="button" class="btn-icon-small" onclick="removeStepRow(${idx}, ${i})" title="Remove row">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="step-number">Step ${idx + 1}</div>
|
||||
<label class="field-label">Title (optional)</label>
|
||||
<input type="text" value="${step.title || ''}" data-idx="${idx}" data-field="title">
|
||||
<div class="step-card-header">
|
||||
<div class="step-title-group">
|
||||
<span class="step-badge">Step ${idx + 1}</span>
|
||||
<input type="text" class="step-title-input" value="${step.title || ''}" data-idx="${idx}" data-field="title" placeholder="Step Title (e.g., Head)">
|
||||
</div>
|
||||
<div class="step-actions-group">
|
||||
<button class="btn-icon-small" onclick="moveStep(${idx}, -1)" title="Move Up">↑</button>
|
||||
<button class="btn-icon-small" onclick="moveStep(${idx}, 1)" title="Move Down">↓</button>
|
||||
<button class="btn-icon-small danger" onclick="deleteStep(${idx})" title="Delete Step"><i class="fa-solid fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-row-list">${rowList}</div>
|
||||
|
||||
<div class="pattern-row-editor">
|
||||
<div class="editor-bar">
|
||||
<textarea data-idx="${idx}" data-field="rowDraft" rows="1" placeholder="Type next row instructions...">${step.rowDraft || ''}</textarea>
|
||||
<button class="primary small" onclick="addStepRow(${idx})">Add Row</button>
|
||||
</div>
|
||||
<div class="pattern-buttons inline-buttons">
|
||||
${getPatternButtonCodes().map(tok => `<button type="button" data-tok="${tok}" data-idx="${idx}">${tok}</button>`).join('')}
|
||||
</div>
|
||||
<div class="pattern-row-editor">
|
||||
<textarea data-idx="${idx}" data-field="rowDraft" placeholder="Build a row with the buttons above or type manually...">${step.rowDraft || ''}</textarea>
|
||||
<div class="pattern-row-actions">
|
||||
<button class="secondary" onclick="clearStepRow(${idx})">Clear line</button>
|
||||
<button class="primary" onclick="addStepRow(${idx})">Add row</button>
|
||||
</div>
|
||||
|
||||
<div class="step-extras">
|
||||
<label class="field-label-small" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||
<i class="fa-solid fa-caret-right"></i> Extra Info (Note / Image)
|
||||
</label>
|
||||
<div class="extras-row hidden">
|
||||
<input type="text" value="${step.note || ''}" data-idx="${idx}" data-field="note" placeholder="Notes...">
|
||||
<div class="image-input-group">
|
||||
<input type="text" value="${step.image || ''}" data-idx="${idx}" data-field="image" placeholder="Image URL...">
|
||||
${auth.token ? `<button class="btn-icon-small" onclick="uploadStepImage(${idx})" title="Upload/Change Image"><i class="fa-solid fa-upload"></i></button>` : ''}
|
||||
${step.image ? `<button class="btn-icon-small danger" onclick="removeStepImage(${idx})" title="Remove Image"><i class="fa-solid fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
${step.image ? `<div class="step-img-preview"><img src="${step.image}" alt="Step Image"></div>` : ''}
|
||||
</div>
|
||||
<div class="pattern-row-list">${rowList}</div>
|
||||
<label class="field-label">Note</label>
|
||||
<textarea data-idx="${idx}" data-field="note" placeholder="Notes or assembly tips">${step.note || ''}</textarea>
|
||||
<label class="field-label">Image URL (optional)</label>
|
||||
<input type="text" value="${step.image || ''}" data-idx="${idx}" data-field="image" placeholder="https://...">
|
||||
<div class="pattern-step-actions">
|
||||
<button onclick="moveStep(${idx}, -1)">↑</button>
|
||||
<button onclick="moveStep(${idx}, 1)">↓</button>
|
||||
<button onclick="deleteStep(${idx})">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event Listeners
|
||||
card.querySelectorAll('input, textarea').forEach(el => {
|
||||
el.addEventListener('input', (e) => {
|
||||
const i = Number(e.target.dataset.idx);
|
||||
const field = e.target.dataset.field;
|
||||
if (!patternDraft.steps[i]) return;
|
||||
|
||||
// Handle row updates separately
|
||||
if (e.target.hasAttribute('data-row')) {
|
||||
const rIdx = Number(e.target.dataset.row);
|
||||
patternDraft.steps[i].rows[rIdx] = e.target.value;
|
||||
} else {
|
||||
patternDraft.steps[i][field] = e.target.value;
|
||||
}
|
||||
persistPatternDraft();
|
||||
});
|
||||
});
|
||||
|
||||
card.querySelectorAll('.pattern-buttons button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tok = btn.dataset.tok;
|
||||
addPatternTokenToStep(idx, tok);
|
||||
});
|
||||
});
|
||||
card.querySelectorAll('input[data-row]').forEach(inp => {
|
||||
inp.addEventListener('input', (e) => {
|
||||
const rowIdx = Number(e.target.dataset.row);
|
||||
updateStepRow(idx, rowIdx, e.target.value);
|
||||
addPatternTokenToStep(idx, btn.dataset.tok);
|
||||
});
|
||||
});
|
||||
|
||||
// Enter key handling for row adding
|
||||
const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]');
|
||||
if (rowTextarea) {
|
||||
rowTextarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addStepRow(idx);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addStep();
|
||||
}
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
card.classList.remove('card-pop');
|
||||
});
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
const addRow = document.createElement('div');
|
||||
addRow.className = 'pattern-step-card add-step-row';
|
||||
addRow.innerHTML = `
|
||||
<button class="primary add-step-btn" onclick="addStep()">+ Add Step</button>
|
||||
`;
|
||||
addRow.className = 'add-step-row';
|
||||
addRow.innerHTML = `<button class="primary add-step-btn" onclick="addStep()">+ New Step</button>`;
|
||||
container.appendChild(addRow);
|
||||
}
|
||||
|
||||
function removeStepImage(idx) {
|
||||
if (patternDraft.steps[idx]) {
|
||||
patternDraft.steps[idx].image = '';
|
||||
persistPatternDraft();
|
||||
renderSteps();
|
||||
}
|
||||
}
|
||||
|
||||
function uploadStepImage(idx) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
patternDraft.steps[idx].image = data.url;
|
||||
persistPatternDraft();
|
||||
renderSteps();
|
||||
showAlert({ title: 'Upload success', text: 'Image added to step.' });
|
||||
} else {
|
||||
showAlert({ title: 'Upload failed', text: data.error || 'Error' });
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Error', text: err.message });
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function renderPatternView() {
|
||||
const container = document.getElementById('patternView');
|
||||
if (!container) return;
|
||||
@ -711,23 +937,44 @@ function renderPatternView() {
|
||||
const gauge = patternDraft.gauge || '';
|
||||
const gaugeSts = patternDraft.gaugeSts || '';
|
||||
const gaugeRows = patternDraft.gaugeRows || '';
|
||||
const gaugeHook = patternDraft.gaugeHook || '';
|
||||
const size = patternDraft.size || '';
|
||||
// Construct displayedHooks from multiple hooks if available
|
||||
const displayedHooks = patternDraft.hooks.map(h => h.size).filter(Boolean).join(' / ');
|
||||
|
||||
container.className = `pattern-view ${patternDraft.previewSize}`;
|
||||
container.innerHTML = `
|
||||
<div class="view-card">
|
||||
<div class="view-card" style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||
<div>
|
||||
<h3 class="view-title">${meta.title || 'Pattern'}</h3>
|
||||
<p class="view-sub">${meta.designer || ''}</p>
|
||||
${mats ? `<h4>Materials</h4><pre>${mats}</pre>` : ''}
|
||||
${(gauge || gaugeSts || gaugeRows || gaugeHook || size) ? `<h4>Gauge / Size</h4><pre>${[gaugeSts, gaugeRows, gaugeHook, size, gauge].filter(Boolean).join(' • ')}</pre>` : ''}
|
||||
${(gauge || gaugeSts || gaugeRows || displayedHooks || size) ? `<h4>Gauge / Size</h4><pre>${[gaugeSts, gaugeRows, displayedHooks, size, gauge].filter(Boolean).join(' • ')}</pre>` : ''}
|
||||
</div>
|
||||
<button class="primary small" onclick="startProjectFromDraft()" title="Start a project from this pattern">
|
||||
<i class="fa-solid fa-play"></i> Start Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="view-card">
|
||||
<h4>Yarn & Tools</h4>
|
||||
${patternDraft.yarns.length ? `<ul class="view-list">${patternDraft.yarns.map(y => `<li><strong>${y.note || 'Yarn'}:</strong> Weight ${y.weight} ${y.color ? `<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:${y.color};border:1px solid #ccc;vertical-align:middle;margin-left:4px;"></span>` : ''}</li>`).join('')}</ul>` : ''}
|
||||
${patternDraft.hooks.length ? `<p style="margin-top:8px;"><strong>Hooks:</strong> ${patternDraft.hooks.map(h => `${h.size} ${h.note ? `(${h.note})` : ''}`).join(', ')}</p>` : ''}
|
||||
${!patternDraft.yarns.length && !patternDraft.hooks.length ? '<p class="muted">No yarn or tools listed.</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="view-card">
|
||||
<h4>Key</h4>
|
||||
${patternDraft.abbrevSelection.length ? `<div class="view-key-grid">${patternDraft.abbrevSelection.map(c => { const i = getAbbrevByCode(c); return `<div class="key-item"><span class="key-code">${c}</span> <span class="key-sep">–</span> <span class="key-desc">${i ? i.desc : ''}</span></div>`; }).join('')}</div>` : '<p class="muted">No abbreviations selected.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="view-card">
|
||||
<h4>Steps</h4>
|
||||
<div class="view-steps">
|
||||
${steps.map((st, i) => `
|
||||
<div class="view-step ${st.finished ? 'is-finished' : ''}">
|
||||
<header>
|
||||
<div class="view-step ${st.finished ? 'is-finished minimized' : ''}">
|
||||
<header onclick="toggleViewStepMinimize(this)">
|
||||
<div class="title">Step ${i + 1}${st.title ? ': ' + st.title : ''}</div>
|
||||
<label class="view-check">
|
||||
<label class="view-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" data-view-step="${i}" ${st.finished ? 'checked' : ''}>
|
||||
<i class="fa-regular fa-square"></i>
|
||||
<i class="fa-solid fa-square-check"></i>
|
||||
@ -735,8 +982,12 @@ function renderPatternView() {
|
||||
</header>
|
||||
<div class="view-step-body">
|
||||
<ul class="rows">${(st.rows || []).map((r, idx) => `<li>Row ${idx + 1}: ${r}</li>`).join('') || '<li class="muted">No rows yet.</li>'}</ul>
|
||||
<div style="margin-top:6px;">
|
||||
<label>Notes</label>
|
||||
${st.image ? `<img src="${st.image}" class="view-step-img" alt="Step Image">` : ''}
|
||||
|
||||
<button class="note-toggle-btn" onclick="document.getElementById('view-note-${i}').classList.toggle('active')">
|
||||
<i class="fa-regular fa-pen-to-square"></i> ${st.viewNote ? 'Edit Note' : 'Add Note'}
|
||||
</button>
|
||||
<div id="view-note-${i}" class="view-note-area ${st.viewNote ? 'active' : ''}">
|
||||
<textarea data-view-note="${i}" placeholder="Notes for this step...">${st.viewNote || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
@ -744,10 +995,6 @@ function renderPatternView() {
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-card">
|
||||
<h4>Notes</h4>
|
||||
<textarea data-view-global-note placeholder="Notes or reminders...">${patternDraft.viewGlobalNote || ''}</textarea>
|
||||
</div>
|
||||
`;
|
||||
container.querySelectorAll('input[type="checkbox"][data-view-step]').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
@ -755,9 +1002,12 @@ function renderPatternView() {
|
||||
if (!patternDraft.steps[idx]) return;
|
||||
patternDraft.steps[idx].finished = e.target.checked;
|
||||
persistPatternDraft();
|
||||
save();
|
||||
|
||||
const card = e.target.closest('.view-step');
|
||||
if (card) card.classList.toggle('is-finished', e.target.checked);
|
||||
if (card) {
|
||||
card.classList.toggle('is-finished', e.target.checked);
|
||||
card.classList.toggle('minimized', e.target.checked);
|
||||
}
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('textarea[data-view-note]').forEach(area => {
|
||||
@ -768,13 +1018,29 @@ function renderPatternView() {
|
||||
persistPatternDraft();
|
||||
});
|
||||
});
|
||||
const globalNote = container.querySelector('textarea[data-view-global-note]');
|
||||
if (globalNote) {
|
||||
globalNote.addEventListener('input', (e) => {
|
||||
patternDraft.viewGlobalNote = e.target.value;
|
||||
persistPatternDraft();
|
||||
});
|
||||
}
|
||||
|
||||
function startProjectFromDraft() {
|
||||
savePatternDraft(); // Ensure pattern is saved and has ID
|
||||
closePatternComposer();
|
||||
|
||||
// Open project modal
|
||||
openModal('addProject');
|
||||
|
||||
// Pre-select this pattern (need a slight delay to ensure modal logic runs first if async, but it's sync)
|
||||
if (currentPatternId && patternSelect) {
|
||||
patternSelect.value = currentPatternId;
|
||||
// Optionally set default project name to pattern title
|
||||
const name = patternDraft.meta.title;
|
||||
if (name && modalInput) {
|
||||
modalInput.value = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleViewStepMinimize(header) {
|
||||
const card = header.closest('.view-step');
|
||||
if (card) card.classList.toggle('minimized');
|
||||
}
|
||||
|
||||
function renderPatternLibrary() {
|
||||
@ -936,24 +1202,128 @@ function updateStepRow(idx, rowIdx, value) {
|
||||
persistPatternDraft();
|
||||
}
|
||||
|
||||
function openAbbrevModal() {
|
||||
const el = document.getElementById('abbrevModal');
|
||||
if (el) {
|
||||
el.classList.add('active');
|
||||
renderAbbrevChecklist();
|
||||
}
|
||||
}
|
||||
|
||||
function closeAbbrevModal() {
|
||||
const el = document.getElementById('abbrevModal');
|
||||
if (el) el.classList.remove('active');
|
||||
renderAbbrevSummary();
|
||||
}
|
||||
|
||||
function renderAbbrevSummary() {
|
||||
const el = document.getElementById('abbrevSummary');
|
||||
if (!el) return;
|
||||
|
||||
if (patternDraft.abbrevSelection.length === 0) {
|
||||
el.innerHTML = `<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = patternDraft.abbrevSelection.map(code => {
|
||||
const item = getAbbrevByCode(code);
|
||||
return `<span class="abbrev-pill is-selected" style="cursor: default;">
|
||||
<span class="code">${code}</span>
|
||||
<span class="desc" style="display:none;">${item ? item.desc : ''}</span>
|
||||
</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAbbrevChecklist() {
|
||||
const listEl = document.getElementById('patternAbbrevList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '';
|
||||
const selected = new Set(patternDraft.abbrevSelection);
|
||||
|
||||
// Render Selected Group First
|
||||
if (selected.size > 0) {
|
||||
const selectedWrap = document.createElement('details');
|
||||
selectedWrap.className = 'abbrev-group selected-group';
|
||||
selectedWrap.open = true;
|
||||
const selectedSummary = document.createElement('summary');
|
||||
selectedSummary.innerHTML = `<strong>Selected (${selected.size})</strong>`;
|
||||
selectedWrap.appendChild(selectedSummary);
|
||||
const selectedGrid = document.createElement('div');
|
||||
selectedGrid.className = 'abbrev-grid';
|
||||
|
||||
// Find item objects for selected codes
|
||||
const library = getActiveAbbrevLibrary();
|
||||
const selectedItems = library.filter(item => selected.has(item.code));
|
||||
|
||||
selectedItems.forEach(item => {
|
||||
const pill = createAbbrevPill(item, true);
|
||||
selectedGrid.appendChild(pill);
|
||||
});
|
||||
selectedWrap.appendChild(selectedGrid);
|
||||
listEl.appendChild(selectedWrap);
|
||||
}
|
||||
|
||||
// Render All Groups
|
||||
getAbbrevGroups().forEach(bucket => {
|
||||
const wrap = document.createElement('details');
|
||||
wrap.className = 'abbrev-group';
|
||||
wrap.open = true;
|
||||
// Open if user is searching, otherwise close to save space? Keep open for now.
|
||||
wrap.open = false;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = `${bucket.label} (${bucket.items.length})`;
|
||||
wrap.appendChild(summary);
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'abbrev-grid';
|
||||
bucket.items.forEach(item => {
|
||||
const pill = createAbbrevPill(item, selected.has(item.code));
|
||||
grid.appendChild(pill);
|
||||
});
|
||||
wrap.appendChild(grid);
|
||||
listEl.appendChild(wrap);
|
||||
});
|
||||
|
||||
// Add Custom Abbrev UI
|
||||
const customWrap = document.createElement('div');
|
||||
customWrap.className = 'abbrev-group custom-add-group';
|
||||
customWrap.style.marginTop = '20px';
|
||||
customWrap.innerHTML = `\n <div style="padding:10px 16px; font-weight:700; background:var(--input-bg); border-bottom:1px solid var(--border);">Add Custom Abbreviation</div>\n <div style="padding:10px; display:grid; gap:8px;">\n <div style="display:grid; grid-template-columns: 1fr 2fr; gap:8px;">\n <input id="newAbbrevCode" placeholder="Code (e.g. mc)" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--bg);">\n <input id="newAbbrevDesc" placeholder="Description (e.g. magic circle)" style="width:100%; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--bg);">\n </div>\n <button class="primary small" onclick="addCustomAbbrev()" style="justify-self: end;">+ Add Custom</button>\n </div>\n `;
|
||||
listEl.appendChild(customWrap);
|
||||
|
||||
// Re-apply filter if text exists
|
||||
filterAbbrev();
|
||||
}
|
||||
|
||||
function addCustomAbbrev() {
|
||||
const codeInp = document.getElementById('newAbbrevCode');
|
||||
const descInp = document.getElementById('newAbbrevDesc');
|
||||
const code = codeInp.value.trim();
|
||||
const desc = descInp.value.trim();
|
||||
|
||||
if (!code || !desc) {
|
||||
alert('Please enter both a code and a description.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!patternDraft.customAbbrev) patternDraft.customAbbrev = [];
|
||||
patternDraft.customAbbrev.push({ code, desc });
|
||||
|
||||
// Auto-select it
|
||||
if (!patternDraft.abbrevSelection.includes(code)) {
|
||||
patternDraft.abbrevSelection.push(code);
|
||||
}
|
||||
|
||||
persistPatternDraft();
|
||||
updateAbbrevFromSelection();
|
||||
renderAbbrevChecklist();
|
||||
|
||||
// Clear inputs (though re-render clears them anyway, unless we want to preserve focus?)
|
||||
// Re-render clears them.
|
||||
}
|
||||
|
||||
function createAbbrevPill(item, isSelected) {
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'abbrev-pill';
|
||||
if (selected.has(item.code)) pill.classList.add('is-selected');
|
||||
if (isSelected) pill.classList.add('is-selected');
|
||||
pill.dataset.code = item.code;
|
||||
pill.innerHTML = `
|
||||
<span class="code">${item.code}</span>
|
||||
@ -961,21 +1331,17 @@ function renderAbbrevChecklist() {
|
||||
`;
|
||||
pill.addEventListener('click', () => {
|
||||
const code = pill.dataset.code;
|
||||
if (pill.classList.contains('is-selected')) {
|
||||
pill.classList.remove('is-selected');
|
||||
if (patternDraft.abbrevSelection.includes(code)) {
|
||||
patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code);
|
||||
pill.classList.remove('is-selected');
|
||||
} else {
|
||||
patternDraft.abbrevSelection.push(code);
|
||||
pill.classList.add('is-selected');
|
||||
if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code);
|
||||
}
|
||||
updateAbbrevFromSelection();
|
||||
// Don't re-render whole list to keep scroll position
|
||||
});
|
||||
grid.appendChild(pill);
|
||||
});
|
||||
wrap.appendChild(grid);
|
||||
listEl.appendChild(wrap);
|
||||
});
|
||||
renderSelectedAbbrev();
|
||||
return pill;
|
||||
}
|
||||
|
||||
function updateAbbrevFromSelection() {
|
||||
@ -1102,9 +1468,11 @@ let easterEggCooling = false;
|
||||
function updateAuthUI() {
|
||||
const badge = document.getElementById('authStatusBadge');
|
||||
const lastSync = document.getElementById('authLastSync');
|
||||
const whenIn = document.querySelector('.auth-when-in');
|
||||
const whenOut = document.querySelector('.auth-when-out');
|
||||
const authProfile = document.querySelector('.auth-profile');
|
||||
const authContent = document.querySelector('.auth-content');
|
||||
const tabs = document.querySelector('.auth-tabs');
|
||||
const adminTabBtn = document.getElementById('adminTabBtn');
|
||||
const profileTabBtn = document.getElementById('profileTabBtn');
|
||||
const displayNameInput = document.getElementById('authDisplayName');
|
||||
const noteInput = document.getElementById('authNote');
|
||||
|
||||
@ -1116,19 +1484,79 @@ function updateAuthUI() {
|
||||
if (lastSync) {
|
||||
lastSync.textContent = `Status: ${auth.status || 'unknown'}`;
|
||||
}
|
||||
if (whenIn) whenIn.style.display = signedIn ? 'block' : 'none';
|
||||
if (whenOut) whenOut.style.display = signedIn ? 'none' : 'block';
|
||||
if (tabs) tabs.style.display = signedIn ? 'none' : 'flex';
|
||||
|
||||
// Default container visibility logic based on state
|
||||
if (signedIn) {
|
||||
// Show Profile/Admin tabs, hide Login/Signup tabs
|
||||
if (tabs) tabs.style.display = 'flex';
|
||||
document.querySelectorAll('.auth-tab[data-mode="login"], .auth-tab[data-mode="signup"]').forEach(el => el.style.display = 'none');
|
||||
if (profileTabBtn) profileTabBtn.style.display = 'block';
|
||||
if (adminTabBtn) adminTabBtn.style.display = auth.isAdmin ? 'block' : 'none';
|
||||
|
||||
// If we are currently in a "logged out" mode (login/signup), switch to profile
|
||||
if (auth.mode === 'login' || auth.mode === 'signup') {
|
||||
setAuthMode('profile');
|
||||
}
|
||||
} else {
|
||||
// Show Login/Signup tabs, hide Profile/Admin tabs
|
||||
if (tabs) tabs.style.display = 'flex';
|
||||
document.querySelectorAll('.auth-tab[data-mode="login"], .auth-tab[data-mode="signup"]').forEach(el => el.style.display = 'block');
|
||||
if (profileTabBtn) profileTabBtn.style.display = 'none';
|
||||
if (adminTabBtn) adminTabBtn.style.display = 'none';
|
||||
|
||||
if (authContent) authContent.style.display = 'block';
|
||||
if (authProfile) authProfile.style.display = 'none';
|
||||
}
|
||||
|
||||
if (displayNameInput && auth.profile) displayNameInput.value = auth.profile.display_name || '';
|
||||
if (noteInput && auth.profile) noteInput.value = auth.profile.note || '';
|
||||
const adminPanel = document.getElementById('adminPanel');
|
||||
if (adminPanel) adminPanel.style.display = signedIn && auth.isAdmin ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function setAuthMode(mode) {
|
||||
auth.mode = mode;
|
||||
// Update tab buttons state
|
||||
document.querySelectorAll('.auth-tab').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
});
|
||||
|
||||
// Handle Content Switching
|
||||
const authContent = document.querySelector('.auth-content');
|
||||
const authProfile = document.querySelector('.auth-profile');
|
||||
|
||||
// Hide all tab contents inside authContent first
|
||||
document.querySelectorAll('.auth-tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
if (mode === 'profile') {
|
||||
if (authContent) authContent.style.display = 'none';
|
||||
if (authProfile) authProfile.style.display = 'block';
|
||||
} else if (mode === 'admin') {
|
||||
if (authContent) authContent.style.display = 'block';
|
||||
if (authProfile) authProfile.style.display = 'none';
|
||||
const adminContent = document.getElementById('adminContent');
|
||||
if (adminContent) adminContent.classList.add('active');
|
||||
} else {
|
||||
// Login or Signup
|
||||
if (authContent) authContent.style.display = 'block';
|
||||
if (authProfile) authProfile.style.display = 'none';
|
||||
const target = document.getElementById(`${mode}Content`);
|
||||
if (target) target.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function openAuthModal() {
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.add('active');
|
||||
|
||||
// If signed in, ensure we start on profile (or last state if valid?)
|
||||
// Defaulting to profile is safer.
|
||||
if (auth.token) {
|
||||
setAuthMode('profile');
|
||||
} else {
|
||||
setAuthMode('login');
|
||||
}
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
@ -1138,23 +1566,63 @@ function closeAuthModal() {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
|
||||
function setAuthMode(mode) {
|
||||
document.querySelectorAll('.auth-tab').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.dataset.mode === mode);
|
||||
});
|
||||
auth.mode = mode;
|
||||
async function fetchAllUsers() {
|
||||
if (!auth.token || !auth.isAdmin) return;
|
||||
const list = document.getElementById('allUsersList');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="spinner">Loading...</div>';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/users', { headers: { Authorization: `Bearer ${auth.token}` } });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to load users');
|
||||
|
||||
if (!data.users || !data.users.length) {
|
||||
list.innerHTML = '<p class="muted">No users found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
async function submitAuth(event) {
|
||||
list.innerHTML = data.users.map(u => `
|
||||
<div class="admin-item">
|
||||
<div class="meta">
|
||||
<strong>${u.email}</strong>
|
||||
<span class="status-subtext">${u.display_name || 'No name'} • ${u.status} ${u.is_admin ? '• Admin' : ''}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${!u.is_admin ? `<button class="modal-btn btn-secondary" onclick="makeAdmin('${u.id}')">Make Admin</button>` : ''}
|
||||
${u.status === 'active' ? `<button class="modal-btn btn-cancel" onclick="suspendUser('${u.id}')">Suspend</button>` : ''}
|
||||
${u.status === 'suspended' ? `<button class="modal-btn btn-save" onclick="approveUser('${u.id}')">Activate</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
list.innerHTML = `<p class="danger-text">Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.fetchAllUsers = fetchAllUsers;
|
||||
|
||||
async function submitAuth(event, mode) {
|
||||
if (event) event.preventDefault();
|
||||
const email = (document.getElementById('authEmail') || {}).value || '';
|
||||
const password = (document.getElementById('authPassword') || {}).value || '';
|
||||
|
||||
const email = document.getElementById(`${mode}Email`).value;
|
||||
const password = document.getElementById(`${mode}Password`).value;
|
||||
|
||||
if (!email || !password) {
|
||||
alert('Please enter email and password to continue.');
|
||||
showAlert({ title: 'Missing Info', text: 'Please enter both email and password.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === 'signup') {
|
||||
const confirmPassword = document.getElementById('signupConfirmPassword').value;
|
||||
if (password !== confirmPassword) {
|
||||
showAlert({ title: 'Passwords Do Not Match', text: 'Please re-enter your passwords.' });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = auth.mode === 'signup' ? '/api/signup' : '/api/login';
|
||||
const endpoint = mode === 'signup' ? '/api/signup' : '/api/login';
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -1180,7 +1648,8 @@ async function autoSync() {
|
||||
await fetchProfile();
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
async function saveProfile(event) {
|
||||
if (event) event.preventDefault();
|
||||
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
|
||||
const note = (document.getElementById('authNote') || {}).value || '';
|
||||
try {
|
||||
@ -1194,6 +1663,7 @@ async function saveProfile() {
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Profile save failed', text: err.message });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
@ -2256,16 +2726,18 @@ async function makeAdmin(id) {
|
||||
|
||||
async function downloadBackup() {
|
||||
const resp = await fetch('/api/admin/backup', { headers: { Authorization: `Bearer ${auth.token}` } });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Backup failed', text: data.error || 'Error' });
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showAlert({ title: 'Backup failed', text: err.error || 'Server error' });
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `backup_${new Date().toISOString()}.json`;
|
||||
a.download = `toadstool_dump_${new Date().toISOString().split('T')[0]}.sql`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@ -2274,18 +2746,24 @@ async function uploadRestore(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = JSON.parse(text);
|
||||
const resp = await fetch('/api/admin/restore', {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
|
||||
const resp = await fetch('/api/admin/restore-sql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify({ sql: base64 })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Restore failed', text: data.error || 'Error' });
|
||||
showAlert({ title: 'Restore failed', text: data.error || data.message || 'Error' });
|
||||
} else {
|
||||
showAlert({ title: 'Restore complete', text: 'Data restored.' });
|
||||
showAlert({ title: 'Restore complete', text: data.message || 'Database restored.' });
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Restore failed', text: err.message });
|
||||
|
||||
1065
assets/style.css
1065
assets/style.css
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ services:
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
# restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
|
||||
PORT: 4000
|
||||
@ -21,11 +21,21 @@ services:
|
||||
ADMIN_EMAIL: chris@chrisedwards.tech
|
||||
ADMIN_PASSWORD: R4e3w2q1
|
||||
volumes:
|
||||
- ./server/uploads:/app/server/uploads
|
||||
- .:/app
|
||||
- /app/server/node_modules
|
||||
- /app/node_modules
|
||||
working_dir: /app
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "8080:8080"
|
||||
command: >
|
||||
sh -c "
|
||||
npm install &&
|
||||
cd server && npm install && cd .. &&
|
||||
npm run dev
|
||||
"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
229
index.html
229
index.html
@ -92,7 +92,7 @@
|
||||
<div class="auth-modal">
|
||||
<div class="auth-modal-head">
|
||||
<div>
|
||||
<h3 class="color-title">Sign in (optional)</h3>
|
||||
<h3 class="color-title">Welcome Back!</h3>
|
||||
<p class="auth-subtext">Stay free forever. Sign in only if you want cloud backups and device switching.</p>
|
||||
</div>
|
||||
<button class="pattern-close" onclick="closeAuthModal()">×</button>
|
||||
@ -102,43 +102,84 @@
|
||||
<span id="authLastSync" class="status-subtext">Last sync: never</span>
|
||||
</div>
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab" data-mode="login" onclick="setAuthMode('login')">Login</button>
|
||||
<button class="auth-tab active" data-mode="login" onclick="setAuthMode('login')">Login</button>
|
||||
<button class="auth-tab" data-mode="signup" onclick="setAuthMode('signup')">Sign up</button>
|
||||
<button class="auth-tab" id="profileTabBtn" data-mode="profile" onclick="setAuthMode('profile')" style="display:none;">Profile</button>
|
||||
<button class="auth-tab" id="adminTabBtn" data-mode="admin" onclick="setAuthMode('admin')" style="display:none;">Admin</button>
|
||||
</div>
|
||||
<form class="auth-form" onsubmit="return submitAuth(event)">
|
||||
<div class="auth-when-out">
|
||||
<label class="field-label" for="authEmail">Email</label>
|
||||
<input id="authEmail" type="email" placeholder="you@example.com" autocomplete="email">
|
||||
<label class="field-label" for="authPassword">Password</label>
|
||||
<input id="authPassword" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device. When enabled, we’ll sync projects/patterns securely.</p>
|
||||
<div class="auth-content">
|
||||
<div id="loginContent" class="auth-tab-content active">
|
||||
<form class="auth-form" onsubmit="return submitAuth(event, 'login')">
|
||||
<label class="field-label" for="loginEmail">Email</label>
|
||||
<input id="loginEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
|
||||
<label class="field-label" for="loginPassword">Password</label>
|
||||
<input id="loginPassword" type="password" placeholder="••••••••" autocomplete="current-password" required>
|
||||
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device.</p>
|
||||
<div class="auth-actions">
|
||||
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
|
||||
<button type="submit" class="modal-btn btn-save">Continue</button>
|
||||
<button type="submit" class="modal-btn btn-save">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="auth-when-in">
|
||||
<label class="field-label" for="authDisplayName">Display name</label>
|
||||
<input id="authDisplayName" type="text" placeholder="Your name">
|
||||
<label class="field-label" for="authNote">Profile note</label>
|
||||
<textarea id="authNote" rows="2" placeholder="Add a note for your patterns..."></textarea>
|
||||
<div class="auth-actions auth-actions-stack">
|
||||
<button type="button" class="modal-btn btn-save" onclick="autoSync()">Sync now</button>
|
||||
<button type="button" class="modal-btn btn-save" onclick="saveProfile()">Save profile</button>
|
||||
<button type="button" class="modal-btn btn-cancel" onclick="logoutAuth()">Log out</button>
|
||||
<div id="signupContent" class="auth-tab-content">
|
||||
<form class="auth-form" onsubmit="return submitAuth(event, 'signup')">
|
||||
<label class="field-label" for="signupEmail">Email</label>
|
||||
<input id="signupEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
|
||||
<label class="field-label" for="signupPassword">Password</label>
|
||||
<input id="signupPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
|
||||
<label class="field-label" for="signupConfirmPassword">Confirm Password</label>
|
||||
<input id="signupConfirmPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
|
||||
<p class="auth-hint">When enabled, 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 id="adminPanel" class="admin-panel" style="display:none;">
|
||||
<h4>Admin</h4>
|
||||
</form>
|
||||
</div>
|
||||
<div id="adminContent" class="auth-tab-content">
|
||||
<div id="adminPanel" class="admin-panel">
|
||||
<h4>Pending Approvals</h4>
|
||||
<div class="admin-actions">
|
||||
<button type="button" class="modal-btn btn-save" onclick="fetchPendingUsers()">Refresh pending</button>
|
||||
<button type="button" class="modal-btn btn-save" onclick="downloadBackup()">Download backup</button>
|
||||
<label class="modal-btn btn-save">
|
||||
Restore backup
|
||||
<input type="file" id="restoreInput" accept="application/json" style="display:none;" onchange="uploadRestore(event)">
|
||||
</label>
|
||||
<button type="button" class="modal-btn btn-secondary" onclick="fetchPendingUsers()">Refresh Pending</button>
|
||||
</div>
|
||||
<div id="pendingList" class="admin-list"></div>
|
||||
|
||||
<h4 style="margin-top:16px;">User Management</h4>
|
||||
<div class="admin-actions">
|
||||
<button type="button" class="modal-btn btn-secondary" onclick="fetchAllUsers()">Load All Users</button>
|
||||
</div>
|
||||
<div id="allUsersList" class="admin-list"></div>
|
||||
|
||||
<h4 style="margin-top:16px;">System Data</h4>
|
||||
<div class="admin-actions">
|
||||
<button type="button" class="modal-btn btn-secondary" onclick="downloadBackup()">Backup Data</button>
|
||||
<label class="modal-btn btn-secondary">
|
||||
Restore
|
||||
<input type="file" id="restoreInput" accept="application/json,application/sql,.sql" style="display:none;" onchange="uploadRestore(event)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form class="auth-profile" onsubmit="return saveProfile(event)">
|
||||
<h4 class="auth-section-title">Profile Settings</h4>
|
||||
|
||||
<div class="auth-profile-body">
|
||||
<label class="field-label" for="authDisplayName">Display Name</label>
|
||||
<input id="authDisplayName" type="text" placeholder="Your name">
|
||||
|
||||
<label class="field-label" for="authNote">Profile Note</label>
|
||||
<textarea id="authNote" rows="2" placeholder="Add a note for your patterns..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="auth-sync-section">
|
||||
<p class="auth-hint">Sync your projects to the cloud to access them anywhere.</p>
|
||||
<button type="button" class="modal-btn btn-secondary" onclick="autoSync()"><i class="fa-solid fa-rotate"></i> Sync Now</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-actions modal-footer">
|
||||
<button type="button" class="modal-btn btn-cancel danger-text" onclick="logoutAuth()">Log Out</button>
|
||||
<button type="submit" class="modal-btn btn-save">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -147,86 +188,114 @@
|
||||
<div class="pattern-overlay" id="patternOverlay">
|
||||
<div class="pattern-sheet">
|
||||
<div class="pattern-sheet-header">
|
||||
<div class="pattern-sheet-title">
|
||||
<h2>Pattern Composer</h2>
|
||||
<p class="pattern-sheet-subtitle">Draft rows plus materials, gauge, and abbreviations.</p>
|
||||
</div>
|
||||
<div class="header-main">
|
||||
<h2 class="pattern-sheet-title">Pattern Composer</h2>
|
||||
<div class="pattern-modes">
|
||||
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
|
||||
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
|
||||
</div>
|
||||
<div class="pattern-save-indicator" id="patternSaveIndicator">Saved</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<span class="pattern-save-indicator" id="patternSaveIndicator">Saved</span>
|
||||
<button class="icon-action" onclick="savePatternDraft()" title="Save to Basket"><i class="fa-solid fa-bookmark"></i></button>
|
||||
<button class="icon-action" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i></button>
|
||||
<button class="icon-action" onclick="sharePattern()" title="Share Link"><i class="fa-solid fa-link"></i></button>
|
||||
<button class="icon-action danger" onclick="clearPatternOutput()" title="Clear Draft"><i class="fa-solid fa-eraser"></i></button>
|
||||
<button class="pattern-close" onclick="closePatternComposer()">×</button>
|
||||
</div>
|
||||
<div class="pattern-toolbar">
|
||||
<div class="pattern-toolbar-left">
|
||||
<button class="toolbar-btn" onclick="savePatternDraft()" title="Save draft to basket"><i class="fa-solid fa-bookmark"></i> Save</button>
|
||||
<button class="toolbar-btn" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i> PDF</button>
|
||||
<button class="toolbar-btn" onclick="sharePattern()" title="Share link"><i class="fa-solid fa-link"></i> Share</button>
|
||||
<button class="toolbar-btn" onclick="clearPatternOutput()" title="Clear draft"><i class="fa-solid fa-eraser"></i> Clear</button>
|
||||
</div>
|
||||
<div class="pattern-toolbar-right">
|
||||
<span class="pattern-save-indicator small" id="patternSaveIndicatorMini">Saved</span>
|
||||
</div>
|
||||
|
||||
<div class="pattern-nav">
|
||||
<button class="nav-item active" data-tab="info" onclick="showPatternTab('info')">Specs</button>
|
||||
<button class="nav-item" data-tab="steps" onclick="showPatternTab('steps')">Draft</button>
|
||||
<button class="nav-item" data-tab="view" onclick="showPatternTab('view')">Read</button>
|
||||
<button class="nav-item" data-tab="library" onclick="showPatternTab('library')">Shelf</button>
|
||||
</div>
|
||||
|
||||
<div class="pattern-body">
|
||||
<div class="pattern-tabs">
|
||||
<button class="pattern-tab" data-tab="info" onclick="showPatternTab('info')">Pattern Info</button>
|
||||
<button class="pattern-tab" data-tab="steps" onclick="showPatternTab('steps')">Steps</button>
|
||||
<button class="pattern-tab" data-tab="view" onclick="showPatternTab('view')">View</button>
|
||||
<button class="pattern-tab" data-tab="library" onclick="showPatternTab('library')">My Basket</button>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="info">
|
||||
<div class="pattern-section active" data-section="info">
|
||||
<div class="card-grid">
|
||||
<div class="input-card">
|
||||
<h4 class="card-head">Basic Details</h4>
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternTitle">Title</label>
|
||||
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
|
||||
<label class="field-label" for="patternDesigner">Designer / Credits</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="field-label" for="patternDesigner">Designer</label>
|
||||
<input id="patternDesigner" type="text" placeholder="Your name or shop">
|
||||
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
||||
<textarea id="patternMaterials" placeholder="Yarn (Color A) – worsted Yarn (Color B) – accent Hook – 4.0 mm Safety eyes, stuffing, needle"></textarea>
|
||||
<div class="field-group-inline">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-card">
|
||||
<h4 class="card-head">Yarn & Tools</h4>
|
||||
<label class="field-label">Yarns</label>
|
||||
<div id="yarnList" class="yarn-list-container">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<label class="field-label" style="margin-top: 10px;">Hooks / Needles</label>
|
||||
<div id="hookList" class="hook-list-container">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
|
||||
<div class="field-group-inline" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label class="field-label" for="patternGaugeSts">Stitches / 4in (10cm)</label>
|
||||
<label class="field-label" for="patternGaugeSts">Sts / 4in</label>
|
||||
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="patternGaugeRows">Rows / 4in (10cm)</label>
|
||||
<label class="field-label" for="patternGaugeRows">Rows / 4in</label>
|
||||
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
|
||||
</div>
|
||||
</div>
|
||||
<label class="field-label" for="patternGaugeHook">Hook / Needles</label>
|
||||
<input id="patternGaugeHook" type="text" placeholder="e.g., 4.0 mm hook">
|
||||
<label class="field-label" for="patternSize">Finished size</label>
|
||||
<input id="patternSize" type="text" placeholder="Approx. 6 in / 15 cm tall">
|
||||
<label class="field-label" for="patternGauge">Gauge (extra notes)</label>
|
||||
<textarea id="patternGauge" placeholder="Magic ring start; or any extra gauge notes"></textarea>
|
||||
<div class="abbrev-head">
|
||||
<label class="field-label" for="patternAbbrev">Abbreviations</label>
|
||||
<button class="secondary" onclick="loadDefaultAbbrev()">Load defaults</button>
|
||||
<label class="field-label" for="patternGauge">Gauge Notes</label>
|
||||
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
|
||||
</div>
|
||||
<div id="patternAbbrevList" class="abbrev-grid"></div>
|
||||
<textarea id="patternAbbrev" placeholder="sc – single crochet dc – double crochet inc – increase k – knit p – purl"></textarea>
|
||||
<label class="field-label" for="patternStitches">Stitch guide / special stitches</label>
|
||||
<textarea id="patternStitches" placeholder="Magic ring: ... Invisible decrease: ... Kfb: knit front and back ..."></textarea>
|
||||
<label class="field-label" for="patternNotes">Notes / finishing</label>
|
||||
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
<div class="pattern-row-actions pattern-footer">
|
||||
|
||||
<div class="input-card full-width">
|
||||
<h4 class="card-head">Materials & Notes</h4>
|
||||
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
||||
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color) • 4.0mm Hook • Tapestry Needle • Polyester Fiberfill"></textarea>
|
||||
|
||||
<label class="field-label" for="patternNotes">Notes / Finishing</label>
|
||||
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-card full-width">
|
||||
<div class="abbrev-head">
|
||||
<h4 class="card-head" style="margin:0;">Abbreviations</h4>
|
||||
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
|
||||
</div>
|
||||
<div id="abbrevSummary" class="selected-abbrev">
|
||||
<!-- Selected pills will appear here -->
|
||||
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
|
||||
</div>
|
||||
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
|
||||
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
|
||||
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ... Invisible decrease: ..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pattern-footer-actions">
|
||||
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="steps">
|
||||
<div class="pattern-steps-head">
|
||||
<h4>Steps</h4>
|
||||
<button class="primary" onclick="addStep()">+ Step</button>
|
||||
<h4>Pattern Steps</h4>
|
||||
</div>
|
||||
<div id="patternSteps"></div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="library">
|
||||
<div class="pattern-steps-head">
|
||||
<h4>Saved Patterns</h4>
|
||||
<button class="primary" onclick="savePatternDraft()">Save Draft to Basket</button>
|
||||
<button class="primary" onclick="savePatternDraft()">Save Current Draft</button>
|
||||
</div>
|
||||
<div id="patternLibrary" class="pattern-library"></div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="view">
|
||||
<div id="patternView" class="pattern-view"></div>
|
||||
</div>
|
||||
@ -234,6 +303,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="abbrevModal">
|
||||
<div class="modal-content" style="width: min(800px, 94vw); max-height: 85vh; display: flex; flex-direction: column;">
|
||||
<h3 class="modal-title">Select Abbreviations</h3>
|
||||
<div class="abbrev-controls" style="margin-bottom: 12px;">
|
||||
<input type="text" id="abbrevSearch" placeholder="Search stitches..." oninput="filterAbbrev()">
|
||||
<button class="secondary small" onclick="loadDefaultAbbrev()">Reset to Defaults</button>
|
||||
</div>
|
||||
<div id="patternAbbrevList" class="abbrev-grid" style="overflow-y: auto; flex: 1;"></div>
|
||||
<div class="modal-actions" style="margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
|
||||
<button class="modal-btn btn-save" onclick="closeAbbrevModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/app.js"></script>
|
||||
<footer class="footer-bg" aria-hidden="true"></footer>
|
||||
</body>
|
||||
|
||||
15
live-server.js
Normal file
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.",
|
||||
"main": "sw.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev-frontend": "node live-server.js",
|
||||
"dev": "cd server && npm install && cd .. && concurrently \"npm:dev-frontend\" \"cd server && npm run dev\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"live-server": "^1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
138
server/package-lock.json
generated
138
server/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.12.0",
|
||||
"sharp": "^0.33.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
@ -2287,6 +2288,95 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@ -2300,6 +2390,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@ -2854,6 +2983,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
||||
@ -7,6 +7,7 @@ const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
const {
|
||||
initDb,
|
||||
withClient,
|
||||
createUser,
|
||||
verifyUser,
|
||||
createSession,
|
||||
@ -36,7 +37,7 @@ const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Init DB
|
||||
initDb().catch((err) => {
|
||||
@ -195,37 +196,41 @@ app.get('/share/:token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
// Upload route: save to /uploads, return URL (Optimized with sharp, fallback to raw)
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); // Bumped limit slightly
|
||||
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'File required' });
|
||||
|
||||
let filename;
|
||||
let outPath;
|
||||
|
||||
try {
|
||||
// Attempt optimization
|
||||
filename = `${Date.now()}-${uuid()}.webp`;
|
||||
outPath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
await sharp(req.file.buffer)
|
||||
.rotate() // Auto-rotate based on EXIF
|
||||
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 80 })
|
||||
.toFile(outPath);
|
||||
|
||||
} catch (sharpErr) {
|
||||
console.warn('Image optimization failed, falling back to raw save:', sharpErr.message);
|
||||
// Fallback: Save original
|
||||
const ext = (req.file.originalname.split('.').pop() || 'jpg').toLowerCase();
|
||||
const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'].includes(ext) ? ext : 'jpg';
|
||||
const filename = `${Date.now()}-${uuid()}.${safeExt}`;
|
||||
const outPath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
const pipeline = sharp(req.file.buffer)
|
||||
.rotate()
|
||||
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true });
|
||||
|
||||
if (safeExt === 'png') {
|
||||
pipeline.png({ quality: 80 });
|
||||
} else if (safeExt === 'webp') {
|
||||
pipeline.webp({ quality: 80 });
|
||||
} else if (safeExt === 'avif') {
|
||||
pipeline.avif({ quality: 70 });
|
||||
} else {
|
||||
pipeline.jpeg({ quality: 82, mozjpeg: true });
|
||||
filename = `${Date.now()}-${uuid()}.${safeExt}`;
|
||||
outPath = path.join(UPLOAD_DIR, filename);
|
||||
await fs.promises.writeFile(outPath, req.file.buffer);
|
||||
}
|
||||
|
||||
await pipeline.toFile(outPath);
|
||||
const url = `/uploads/${filename}`;
|
||||
res.json({ url });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Upload failed' });
|
||||
res.status(500).json({ error: err.message, stack: err.stack });
|
||||
}
|
||||
});
|
||||
|
||||
@ -307,16 +312,57 @@ app.post('/api/password-reset/confirm', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: backup (JSON export)
|
||||
// Admin: backup (SQL Dump)
|
||||
app.get('/api/admin/backup', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const users = await listAllUsers();
|
||||
const projects = await fetchItemsSince('projects', null, null);
|
||||
const patterns = await fetchItemsSince('patterns', null, null);
|
||||
res.json({ users, projects, patterns, exportedAt: new Date().toISOString() });
|
||||
// Let's use direct DB queries to get everything.
|
||||
const allUsers = await withClient(c => c.query('select * from users order by created_at'));
|
||||
const allProjects = await withClient(c => c.query('select * from projects order by updated_at'));
|
||||
const allPatterns = await withClient(c => c.query('select * from patterns order by updated_at'));
|
||||
|
||||
let sql = `-- Toadstool Tally Database Dump\n-- Exported: ${new Date().toISOString()}\n\n`;
|
||||
|
||||
const escape = (val) => {
|
||||
if (val === null || val === undefined) return 'NULL';
|
||||
if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE';
|
||||
if (typeof val === 'number') return val;
|
||||
if (val instanceof Date) return `'${val.toISOString()}'`;
|
||||
if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'`; // JSON columns
|
||||
return `'${String(val).replace(/'/g, "''")}'`;
|
||||
};
|
||||
|
||||
if (allUsers.rows.length) {
|
||||
sql += `-- Users\n`;
|
||||
for (const row of allUsers.rows) {
|
||||
sql += `INSERT INTO users (id, email, password_hash, display_name, note, created_at, updated_at, is_admin, status) VALUES (${escape(row.id)}, ${escape(row.email)}, ${escape(row.password_hash)}, ${escape(row.display_name)}, ${escape(row.note)}, ${escape(row.created_at)}, ${escape(row.updated_at)}, ${escape(row.is_admin)}, ${escape(row.status)}) ON CONFLICT (id) DO NOTHING;\n`;
|
||||
}
|
||||
sql += `\n`;
|
||||
}
|
||||
|
||||
if (allProjects.rows.length) {
|
||||
sql += `-- Projects\n`;
|
||||
for (const row of allProjects.rows) {
|
||||
sql += `INSERT INTO projects (id, user_id, data, updated_at, deleted_at) VALUES (${escape(row.id)}, ${escape(row.user_id)}, ${escape(row.data)}, ${escape(row.updated_at)}, ${escape(row.deleted_at)}) ON CONFLICT (id) DO NOTHING;\n`;
|
||||
}
|
||||
sql += `\n`;
|
||||
}
|
||||
|
||||
if (allPatterns.rows.length) {
|
||||
sql += `-- Patterns\n`;
|
||||
for (const row of allPatterns.rows) {
|
||||
sql += `INSERT INTO patterns (id, user_id, data, updated_at, deleted_at, title, slug) VALUES (${escape(row.id)}, ${escape(row.user_id)}, ${escape(row.data)}, ${escape(row.updated_at)}, ${escape(row.deleted_at)}, ${escape(row.title)}, ${escape(row.slug)}) ON CONFLICT (id) DO NOTHING;\n`;
|
||||
}
|
||||
sql += `\n`;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="toadstool_backup_${new Date().toISOString().split('T')[0]}.sql"`);
|
||||
res.setHeader('Content-Type', 'application/sql; charset=utf-8');
|
||||
// res.setHeader('Content-Length', Buffer.byteLength(sql)); // Let Express handle this
|
||||
res.end(sql);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Backup failed' });
|
||||
res.status(500).json({ error: err.message, stack: err.stack });
|
||||
}
|
||||
});
|
||||
|
||||
@ -358,6 +404,54 @@ app.post('/api/admin/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: restore from SQL dump (Base64 JSON)
|
||||
app.post('/api/admin/restore-sql', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { sql } = req.body || {};
|
||||
if (!sql || typeof sql !== 'string') {
|
||||
return res.status(400).json({
|
||||
error: 'SQL dump (base64) required',
|
||||
debug: {
|
||||
keys: Object.keys(req.body || {}),
|
||||
contentType: req.headers['content-type'],
|
||||
bodyType: typeof req.body
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sqlDump = Buffer.from(sql, 'base64').toString('utf-8');
|
||||
|
||||
if (!sqlDump || sqlDump.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Decoded SQL is empty' });
|
||||
}
|
||||
|
||||
await withClient(async (client) => {
|
||||
await client.query('BEGIN'); // Start transaction
|
||||
// Clear existing data before restoring
|
||||
await client.query('TRUNCATE table sessions CASCADE');
|
||||
await client.query('TRUNCATE table users CASCADE');
|
||||
await client.query('TRUNCATE table projects CASCADE');
|
||||
await client.query('TRUNCATE table patterns CASCADE');
|
||||
await client.query('TRUNCATE table pattern_shares CASCADE'); // Assuming pattern_shares also needs to be cleared
|
||||
await client.query('TRUNCATE table password_resets CASCADE'); // Assuming password_resets also needs to be cleared
|
||||
|
||||
// Execute the SQL dump. This is inherently risky with untrusted input.
|
||||
// For this project context, it's assumed admin is trusted.
|
||||
await client.query(sqlDump);
|
||||
await client.query('COMMIT'); // Commit transaction
|
||||
});
|
||||
|
||||
res.json({ ok: true, message: 'Database restored successfully from SQL dump.' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// Rollback on error
|
||||
await withClient(async (client) => {
|
||||
await client.query('ROLLBACK');
|
||||
});
|
||||
res.status(500).json({ error: err.message, stack: err.stack, message: 'SQL Restore failed, transaction rolled back.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Basic error guard
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user