Add backend admin/auth wiring and pattern viewer
This commit is contained in:
parent
0d6f4a9df3
commit
d750cd88f4
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.npmrc
|
||||
.DS_Store
|
||||
server/node_modules
|
||||
server/.env
|
||||
db_data
|
||||
server/uploads
|
||||
coverage
|
||||
dist
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,2 +1,10 @@
|
||||
# macOS metadata
|
||||
.DS_Store
|
||||
|
||||
# Dependencies and build artifacts
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
db_data/
|
||||
uploads/
|
||||
server/uploads/
|
||||
*.log
|
||||
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Build deps for sharp on alpine
|
||||
RUN apk add --no-cache python3 make g++ libc6-compat vips-dev
|
||||
|
||||
# Install dependencies first (better layer caching)
|
||||
COPY server/package*.json ./server/
|
||||
WORKDIR /app/server
|
||||
RUN npm install --production
|
||||
|
||||
# Copy the rest of the app (frontend + backend)
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
WORKDIR /app/server
|
||||
|
||||
EXPOSE 4000
|
||||
CMD ["npm", "start"]
|
||||
556
assets/app.js
556
assets/app.js
@ -2,6 +2,14 @@
|
||||
let projects = JSON.parse(localStorage.getItem('crochetCounters')) || [];
|
||||
let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || [];
|
||||
if (!Array.isArray(patterns)) patterns = [];
|
||||
let auth = {
|
||||
token: localStorage.getItem('authToken') || '',
|
||||
email: '',
|
||||
isAdmin: false,
|
||||
status: 'unknown',
|
||||
mode: 'login'
|
||||
};
|
||||
let pendingUsers = [];
|
||||
let saveFlashTimer = null;
|
||||
const crochetAbbrev = [
|
||||
{ code: 'alt', desc: 'alternate' },
|
||||
@ -278,6 +286,7 @@ function normalizePatternDraft(d = {}) {
|
||||
}
|
||||
|
||||
let patternDraft = normalizePatternDraft(JSON.parse(localStorage.getItem('crochetPatternDraft')) || {});
|
||||
let currentPatternId = null;
|
||||
// New Earthy/Woodland Palette extracted from image vibes
|
||||
const colors = [
|
||||
'#a17d63', // Soft oak
|
||||
@ -467,6 +476,7 @@ function clearPatternOutput() {
|
||||
patternDraft.output = '';
|
||||
patternDraft.currentRow = 1;
|
||||
patternDraft.line = '';
|
||||
currentPatternId = null;
|
||||
patternDraft.meta = { title: '', designer: '' };
|
||||
patternDraft.materials = '';
|
||||
patternDraft.gauge = '';
|
||||
@ -478,6 +488,7 @@ function clearPatternOutput() {
|
||||
patternDraft.stitches = '';
|
||||
patternDraft.notes = '';
|
||||
patternDraft.steps = [];
|
||||
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
|
||||
if (patternOutput) patternOutput.value = '';
|
||||
persistPatternDraft();
|
||||
syncPatternUI();
|
||||
@ -592,6 +603,7 @@ function exportPatternPDF() {
|
||||
function showPatternTab(tab) {
|
||||
document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab));
|
||||
document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab));
|
||||
try { localStorage.setItem('patternActiveTab', tab); } catch (e) {}
|
||||
}
|
||||
|
||||
function updateMetaField(field, value) {
|
||||
@ -690,6 +702,196 @@ function renderSteps() {
|
||||
container.appendChild(addRow);
|
||||
}
|
||||
|
||||
function renderPatternView() {
|
||||
const container = document.getElementById('patternView');
|
||||
if (!container) return;
|
||||
const steps = patternDraft.steps || [];
|
||||
const meta = patternDraft.meta || {};
|
||||
const mats = patternDraft.materials || '';
|
||||
const gauge = patternDraft.gauge || '';
|
||||
const gaugeSts = patternDraft.gaugeSts || '';
|
||||
const gaugeRows = patternDraft.gaugeRows || '';
|
||||
const gaugeHook = patternDraft.gaugeHook || '';
|
||||
const size = patternDraft.size || '';
|
||||
container.innerHTML = `
|
||||
<div class="view-card">
|
||||
<h3 class="view-title">${meta.title || 'Pattern'}</h3>
|
||||
<p class="view-sub">${meta.designer || ''}</p>
|
||||
${mats ? `<h4>Materials</h4><pre>${mats}</pre>` : ''}
|
||||
${(gauge || gaugeSts || gaugeRows || gaugeHook || size) ? `<h4>Gauge / Size</h4><pre>${[gaugeSts, gaugeRows, gaugeHook, size, gauge].filter(Boolean).join(' • ')}</pre>` : ''}
|
||||
</div>
|
||||
<div class="view-card">
|
||||
<h4>Steps</h4>
|
||||
<div class="view-steps">
|
||||
${steps.map((st, i) => `
|
||||
<div class="view-step ${st.finished ? 'is-finished' : ''}">
|
||||
<header>
|
||||
<div class="title">Step ${i + 1}${st.title ? ': ' + st.title : ''}</div>
|
||||
<label class="view-check">
|
||||
<input type="checkbox" data-view-step="${i}" ${st.finished ? 'checked' : ''}>
|
||||
<i class="fa-regular fa-square"></i>
|
||||
<i class="fa-solid fa-square-check"></i>
|
||||
</label>
|
||||
</header>
|
||||
<div class="view-step-body">
|
||||
<ul class="rows">${(st.rows || []).map((r, idx) => `<li>Row ${idx + 1}: ${r}</li>`).join('') || '<li class="muted">No rows yet.</li>'}</ul>
|
||||
<div style="margin-top:6px;">
|
||||
<label>Notes</label>
|
||||
<textarea data-view-note="${i}" placeholder="Notes for this step...">${st.viewNote || ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-card">
|
||||
<h4>Notes</h4>
|
||||
<textarea data-view-global-note placeholder="Notes or reminders...">${patternDraft.viewGlobalNote || ''}</textarea>
|
||||
</div>
|
||||
`;
|
||||
container.querySelectorAll('input[type="checkbox"][data-view-step]').forEach(cb => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const idx = Number(e.target.dataset.viewStep);
|
||||
if (!patternDraft.steps[idx]) return;
|
||||
patternDraft.steps[idx].finished = e.target.checked;
|
||||
persistPatternDraft();
|
||||
save();
|
||||
const card = e.target.closest('.view-step');
|
||||
if (card) card.classList.toggle('is-finished', e.target.checked);
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('textarea[data-view-note]').forEach(area => {
|
||||
area.addEventListener('input', (e) => {
|
||||
const idx = Number(e.target.dataset.viewNote);
|
||||
if (!patternDraft.steps[idx]) return;
|
||||
patternDraft.steps[idx].viewNote = e.target.value;
|
||||
persistPatternDraft();
|
||||
});
|
||||
});
|
||||
const globalNote = container.querySelector('textarea[data-view-global-note]');
|
||||
if (globalNote) {
|
||||
globalNote.addEventListener('input', (e) => {
|
||||
patternDraft.viewGlobalNote = e.target.value;
|
||||
persistPatternDraft();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatternLibrary() {
|
||||
const lib = document.getElementById('patternLibrary');
|
||||
if (!lib) return;
|
||||
if (!patterns.length) {
|
||||
lib.innerHTML = `<p style="color: var(--text-muted);">No saved patterns yet. Save your draft to add it here.</p>`;
|
||||
return;
|
||||
}
|
||||
lib.innerHTML = patterns.map(p => {
|
||||
const subtitle = p.draft?.meta?.designer || '';
|
||||
return `
|
||||
<div class="pattern-library-item">
|
||||
<div>
|
||||
<div class="pattern-lib-title">${p.name || 'Pattern'}</div>
|
||||
<div class="pattern-lib-subtitle">${subtitle}</div>
|
||||
</div>
|
||||
<div class="pattern-lib-actions">
|
||||
<button class="secondary" onclick="loadPatternFromLibrary('${p.id}')">Load</button>
|
||||
<button class="secondary danger" onclick="deletePatternFromLibrary('${p.id}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function savePatternDraft() {
|
||||
const name = patternDraft.meta.title?.trim() || 'Pattern';
|
||||
const draftCopy = JSON.parse(JSON.stringify(patternDraft));
|
||||
if (!Array.isArray(patterns)) patterns = [];
|
||||
const stepsForProjects = draftCopy.steps || [];
|
||||
if (currentPatternId) {
|
||||
const idx = patterns.findIndex(p => p.id === currentPatternId);
|
||||
if (idx >= 0) {
|
||||
patterns[idx] = { id: currentPatternId, name, draft: draftCopy };
|
||||
} else {
|
||||
const id = currentPatternId;
|
||||
patterns.push({ id, name, draft: draftCopy });
|
||||
}
|
||||
} else {
|
||||
const id = `pat-${Date.now()}`;
|
||||
currentPatternId = id;
|
||||
patterns.push({ id, name, draft: draftCopy });
|
||||
}
|
||||
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
|
||||
// Propagate instructions to linked projects
|
||||
let changed = false;
|
||||
projects.forEach(project => {
|
||||
if (project.patternId === currentPatternId && Array.isArray(project.parts)) {
|
||||
project.parts.forEach((part, idx) => {
|
||||
const st = stepsForProjects[idx];
|
||||
if (!st) return;
|
||||
part.instructions = { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] };
|
||||
if (part.instructionsCollapsed === undefined) part.instructionsCollapsed = true;
|
||||
changed = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (changed) save();
|
||||
renderPatternLibrary();
|
||||
showAlert({ title: 'Saved', text: `"${name}" added to your basket.` });
|
||||
}
|
||||
|
||||
function loadPatternFromLibrary(id) {
|
||||
if (!Array.isArray(patterns)) patterns = [];
|
||||
const found = patterns.find(p => p.id === id);
|
||||
if (!found) {
|
||||
showAlert({ title: 'Not found', text: 'That saved pattern was not found.' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const draftCopy = JSON.parse(JSON.stringify(found.draft || {}));
|
||||
patternDraft = normalizePatternDraft(draftCopy);
|
||||
currentPatternId = found.id;
|
||||
// Recompute row counters based on existing output
|
||||
const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== '');
|
||||
patternDraft.currentRow = Math.max(1, lines.length + 1);
|
||||
// Update any linked projects to use latest instructions
|
||||
let changed = false;
|
||||
projects.forEach(project => {
|
||||
if (project.patternId === currentPatternId && Array.isArray(project.parts)) {
|
||||
(patternDraft.steps || []).forEach((st, idx) => {
|
||||
if (!project.parts[idx]) return;
|
||||
project.parts[idx].instructions = { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] };
|
||||
project.parts[idx].instructionsCollapsed = project.parts[idx].instructionsCollapsed ?? true;
|
||||
changed = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (changed) save();
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Load failed', text: 'Saved pattern data is invalid.' });
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft));
|
||||
syncPatternUI();
|
||||
renderPatternLibrary();
|
||||
showPatternTab('steps');
|
||||
showAlert({ title: 'Loaded', text: `"${found.name}" loaded into the composer.` });
|
||||
}
|
||||
|
||||
function deletePatternFromLibrary(id) {
|
||||
patterns = patterns.filter(p => p.id !== id);
|
||||
if (currentPatternId === id) currentPatternId = null;
|
||||
localStorage.setItem('crochetPatterns', JSON.stringify(patterns));
|
||||
renderPatternLibrary();
|
||||
}
|
||||
|
||||
function togglePartInstructions(pId, partId) {
|
||||
const project = projects.find(p => p.id === pId);
|
||||
if (!project) return;
|
||||
const part = project.parts.find(pt => pt.id === partId);
|
||||
if (!part || !part.instructions) return;
|
||||
part.instructionsCollapsed = !part.instructionsCollapsed;
|
||||
save();
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
patternDraft.steps.push({ title: '', rows: [], rowDraft: '', note: '', image: '' });
|
||||
persistPatternDraft();
|
||||
@ -875,7 +1077,10 @@ function openPatternComposer() {
|
||||
if (!patternOverlay) return;
|
||||
patternOverlay.classList.add('active');
|
||||
syncPatternUI();
|
||||
showPatternTab('steps');
|
||||
renderPatternLibrary();
|
||||
renderPatternView();
|
||||
const savedTab = localStorage.getItem('patternActiveTab') || 'steps';
|
||||
showPatternTab(savedTab);
|
||||
}
|
||||
|
||||
function closePatternComposer() {
|
||||
@ -892,6 +1097,130 @@ let fireflyTimer = null;
|
||||
let fireflyActive = false;
|
||||
let titleClicks = [];
|
||||
let easterEggCooling = false;
|
||||
// --- Auth State (backend) ---
|
||||
|
||||
function updateAuthUI() {
|
||||
const badge = document.getElementById('authStatusBadge');
|
||||
const lastSync = document.getElementById('authLastSync');
|
||||
const whenIn = document.querySelector('.auth-when-in');
|
||||
const whenOut = document.querySelector('.auth-when-out');
|
||||
const tabs = document.querySelector('.auth-tabs');
|
||||
const displayNameInput = document.getElementById('authDisplayName');
|
||||
const noteInput = document.getElementById('authNote');
|
||||
|
||||
const signedIn = !!auth.token;
|
||||
if (badge) {
|
||||
badge.textContent = signedIn ? `Signed in: ${auth.email || 'Account'}` : 'Signed out';
|
||||
badge.classList.toggle('is-on', signedIn);
|
||||
}
|
||||
if (lastSync) {
|
||||
lastSync.textContent = `Status: ${auth.status || 'unknown'}`;
|
||||
}
|
||||
if (whenIn) whenIn.style.display = signedIn ? 'block' : 'none';
|
||||
if (whenOut) whenOut.style.display = signedIn ? 'none' : 'block';
|
||||
if (tabs) tabs.style.display = signedIn ? 'none' : 'flex';
|
||||
if (displayNameInput && auth.profile) displayNameInput.value = auth.profile.display_name || '';
|
||||
if (noteInput && auth.profile) noteInput.value = auth.profile.note || '';
|
||||
const adminPanel = document.getElementById('adminPanel');
|
||||
if (adminPanel) adminPanel.style.display = signedIn && auth.isAdmin ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function openAuthModal() {
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.add('active');
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
function closeAuthModal() {
|
||||
const overlay = document.getElementById('authOverlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
|
||||
function setAuthMode(mode) {
|
||||
document.querySelectorAll('.auth-tab').forEach(btn => {
|
||||
btn.classList.toggle('is-active', btn.dataset.mode === mode);
|
||||
});
|
||||
auth.mode = mode;
|
||||
}
|
||||
|
||||
async function submitAuth(event) {
|
||||
if (event) event.preventDefault();
|
||||
const email = (document.getElementById('authEmail') || {}).value || '';
|
||||
const password = (document.getElementById('authPassword') || {}).value || '';
|
||||
if (!email || !password) {
|
||||
alert('Please enter email and password to continue.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const endpoint = auth.mode === 'signup' ? '/api/signup' : '/api/login';
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Auth failed', text: data.error || 'Invalid credentials' });
|
||||
return false;
|
||||
}
|
||||
auth = { token: data.token, email: data.email, isAdmin: !!data.is_admin, status: data.status || 'active', mode: 'login' };
|
||||
localStorage.setItem('authToken', auth.token);
|
||||
updateAuthUI();
|
||||
closeAuthModal();
|
||||
await fetchProfile();
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Auth failed', text: err.message });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function autoSync() {
|
||||
await fetchProfile();
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
const displayName = (document.getElementById('authDisplayName') || {}).value || '';
|
||||
const note = (document.getElementById('authNote') || {}).value || '';
|
||||
try {
|
||||
await fetch('/api/me', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ displayName, note })
|
||||
});
|
||||
await fetchProfile();
|
||||
showAlert({ title: 'Profile saved' });
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Profile save failed', text: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
if (!auth.token) return;
|
||||
const resp = await fetch('/api/me', { headers: { Authorization: `Bearer ${auth.token}` } });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Profile fetch failed', text: data.error || 'Error' });
|
||||
return;
|
||||
}
|
||||
auth.profile = data.profile;
|
||||
auth.isAdmin = !!data.profile.is_admin;
|
||||
auth.status = data.profile.status || 'active';
|
||||
document.getElementById('authDisplayName').value = data.profile.display_name || '';
|
||||
document.getElementById('authNote').value = data.profile.note || '';
|
||||
updateAuthUI();
|
||||
}
|
||||
|
||||
async function logoutAuth() {
|
||||
try {
|
||||
await fetch('/api/logout', { method: 'POST', headers: { Authorization: `Bearer ${auth.token}` } });
|
||||
} catch (e) {}
|
||||
auth = { token: '', email: '', isAdmin: false, status: 'unknown' };
|
||||
localStorage.removeItem('authToken');
|
||||
updateAuthUI();
|
||||
closeAuthModal();
|
||||
}
|
||||
|
||||
// --- Service Worker ---
|
||||
if ('serviceWorker' in navigator) {
|
||||
@ -938,6 +1267,29 @@ if (installBtn) {
|
||||
|
||||
if (isStandalone()) hideInstall();
|
||||
|
||||
// Initialize auth UI
|
||||
updateAuthUI();
|
||||
// expose auth helpers
|
||||
window.openAuthModal = openAuthModal;
|
||||
window.closeAuthModal = closeAuthModal;
|
||||
window.setAuthMode = setAuthMode;
|
||||
window.submitAuth = submitAuth;
|
||||
window.autoSync = autoSync;
|
||||
window.saveProfile = saveProfile;
|
||||
window.logoutAuth = logoutAuth;
|
||||
window.savePatternDraft = savePatternDraft;
|
||||
window.loadPatternFromLibrary = loadPatternFromLibrary;
|
||||
window.deletePatternFromLibrary = deletePatternFromLibrary;
|
||||
window.sharePattern = sharePattern;
|
||||
window.togglePartInstructions = togglePartInstructions;
|
||||
window.fetchPendingUsers = fetchPendingUsers;
|
||||
window.downloadBackup = downloadBackup;
|
||||
window.uploadRestore = uploadRestore;
|
||||
window.setAuthMode = setAuthMode;
|
||||
window.approveUser = approveUser;
|
||||
window.suspendUser = suspendUser;
|
||||
window.makeAdmin = makeAdmin;
|
||||
|
||||
// --- Sweet-ish Alerts ---
|
||||
function removeSwal() {
|
||||
const existing = document.querySelector('.swal-overlay');
|
||||
@ -1492,7 +1844,54 @@ async function resetCount(pId, partId) {
|
||||
function saveProjectAsPattern(pId) {
|
||||
const project = projects.find(p => p.id === pId);
|
||||
if (!project) return;
|
||||
openModal('savePattern', pId);
|
||||
const patternName = project ? `${project.name}` : 'Pattern';
|
||||
// If a different pattern is loaded, warn before overwriting the active draft
|
||||
if (currentPatternId) {
|
||||
showConfirm({
|
||||
title: 'Overwrite current draft?',
|
||||
text: 'Creating a pattern from this project will replace the draft currently in the composer.',
|
||||
confirmText: 'Replace draft',
|
||||
cancelText: 'Cancel',
|
||||
danger: false
|
||||
}).then(ok => {
|
||||
if (ok) buildPatternFromProject(project, patternName);
|
||||
});
|
||||
return;
|
||||
}
|
||||
buildPatternFromProject(project, patternName);
|
||||
}
|
||||
|
||||
function buildPatternFromProject(project, patternName) {
|
||||
// build patternDraft from project parts
|
||||
const steps = (project.parts || []).map((part, idx) => ({
|
||||
title: part.name || `Part ${idx + 1}`,
|
||||
rows: [],
|
||||
rowDraft: '',
|
||||
note: part.note || '',
|
||||
image: ''
|
||||
}));
|
||||
const newDraft = normalizePatternDraft({
|
||||
mode: 'crochet',
|
||||
meta: { title: patternName, designer: '' },
|
||||
materials: '',
|
||||
gauge: '',
|
||||
gaugeSts: '',
|
||||
gaugeRows: '',
|
||||
gaugeHook: '',
|
||||
size: '',
|
||||
abbrev: patternDraft.abbrev,
|
||||
abbrevSelection: patternDraft.abbrevSelection || [],
|
||||
stitches: '',
|
||||
notes: project.note || '',
|
||||
steps
|
||||
});
|
||||
patternDraft = newDraft;
|
||||
currentPatternId = null;
|
||||
persistPatternDraft();
|
||||
syncPatternUI();
|
||||
renderPatternLibrary();
|
||||
showPatternTab('steps');
|
||||
showAlert({ title: 'Pattern created', text: `"${patternName}" is ready in the composer.` });
|
||||
}
|
||||
|
||||
// --- Modal Logic ---
|
||||
@ -1552,31 +1951,34 @@ function closeModal() {
|
||||
|
||||
if (modalState.type === 'addProject') {
|
||||
const nextColor = colors[projects.length % colors.length];
|
||||
const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [] };
|
||||
const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [], patternId: null };
|
||||
const selectedPatternId = patternSelect ? patternSelect.value : '';
|
||||
const chosenPattern = selectedPatternId ? patterns.find(p => String(p.id) === selectedPatternId) : null;
|
||||
if (chosenPattern && Array.isArray(chosenPattern.parts) && chosenPattern.parts.length) {
|
||||
chosenPattern.parts.forEach((pt, idx) => {
|
||||
if (chosenPattern && chosenPattern.draft && Array.isArray(chosenPattern.draft.steps) && chosenPattern.draft.steps.length) {
|
||||
newProject.patternId = chosenPattern.id;
|
||||
chosenPattern.draft.steps.forEach((st, idx) => {
|
||||
newProject.parts.push({
|
||||
id: Date.now() + idx + 1,
|
||||
name: pt.name || `Part ${idx + 1}`,
|
||||
name: st.title || `Step ${idx + 1}`,
|
||||
count: 0,
|
||||
locked: false,
|
||||
finished: false,
|
||||
minimized: false,
|
||||
max: pt.max ?? null,
|
||||
color: pt.color || newProject.color,
|
||||
note: ''
|
||||
max: null,
|
||||
color: nextColor,
|
||||
note: '',
|
||||
instructionsCollapsed: true,
|
||||
instructions: { title: st.title || `Step ${idx + 1}`, rows: st.rows || [] }
|
||||
});
|
||||
});
|
||||
} else {
|
||||
newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '' });
|
||||
newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '', instructionsCollapsed: true, instructions: null });
|
||||
}
|
||||
projects.push(newProject);
|
||||
}
|
||||
else if (modalState.type === 'addPart') {
|
||||
const project = projects.find(p => p.id === modalState.pId);
|
||||
project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color, note: '' });
|
||||
project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color, note: '', instructionsCollapsed: true, instructions: null });
|
||||
project.collapsed = false;
|
||||
}
|
||||
else if (modalState.type === 'renamePart') {
|
||||
@ -1710,6 +2112,19 @@ function render() {
|
||||
<button class="action-btn ${lockBtnClass}" onclick="togglePartLock(${project.id}, ${part.id})">${lockIcon}</button>
|
||||
<button class="action-btn btn-plus ${controlsDimmed}" onclick="updateCount(${project.id}, ${part.id}, 1)">+</button>
|
||||
</div>
|
||||
${part.instructions ? `
|
||||
<div class="instructions-block ${part.instructionsCollapsed ? 'collapsed' : ''}">
|
||||
<div class="instructions-head">
|
||||
<span>Pattern</span>
|
||||
<button class="note-toggle" onclick="togglePartInstructions(${project.id}, ${part.id})">${part.instructionsCollapsed ? 'Show' : 'Hide'}</button>
|
||||
</div>
|
||||
<div class="instructions-body">
|
||||
<div class="instruction-title">${part.instructions.title || ''}</div>
|
||||
<ul class="instruction-rows">
|
||||
${(part.instructions.rows || []).map((r,i)=>`<li>Row ${i+1}: ${r}</li>`).join('') || '<li class="muted">No rows</li>'}
|
||||
</ul>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<div class="note-area" id="${partNoteId}">
|
||||
<textarea placeholder="Notes for this part..." oninput="updatePartNote(event, ${project.id}, ${part.id})">${part.note || ''}</textarea>
|
||||
</div>
|
||||
@ -1730,7 +2145,13 @@ function render() {
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn-add-part" onclick="openModal('addPart', ${project.id})">+ Part</button>
|
||||
${project.patternId ? `
|
||||
<span class="project-link-icon" title="Linked to pattern">
|
||||
<i class="fa-solid fa-link"></i>
|
||||
</span>
|
||||
` : `
|
||||
<button class="btn-save-pattern" onclick="saveProjectAsPattern(${project.id})" title="Save as pattern"><i class="fa-solid fa-swatchbook"></i></button>
|
||||
`}
|
||||
<button class="btn-delete-project" onclick="deleteProject(${project.id})">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1749,13 +2170,126 @@ function render() {
|
||||
}
|
||||
|
||||
render();
|
||||
if (auth.token) {
|
||||
fetchProfile().catch(()=>{});
|
||||
}
|
||||
function flashSave() {
|
||||
const el = document.getElementById('patternSaveIndicator');
|
||||
const mini = document.getElementById('patternSaveIndicatorMini');
|
||||
if (!el) return;
|
||||
el.textContent = 'Saved';
|
||||
el.style.opacity = '1';
|
||||
if (mini) mini.textContent = 'Saved';
|
||||
if (saveFlashTimer) clearTimeout(saveFlashTimer);
|
||||
saveFlashTimer = setTimeout(() => {
|
||||
el.style.opacity = '0.6';
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function sharePattern() {
|
||||
try {
|
||||
const payload = { patternDraft };
|
||||
const json = JSON.stringify(payload);
|
||||
const b64 = btoa(unescape(encodeURIComponent(json)));
|
||||
const base = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}`;
|
||||
const url = `${base}pattern-viewer.html?data=${encodeURIComponent(b64)}`;
|
||||
navigator.clipboard?.writeText(url);
|
||||
showAlert({ title: 'Share link ready', text: 'Link copied to clipboard. Open on any device to view checklist.' });
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Share failed', text: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPendingUsers() {
|
||||
if (!auth.token || !auth.isAdmin) return;
|
||||
const resp = await fetch('/api/admin/users/pending', { headers: { Authorization: `Bearer ${auth.token}` } });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Failed', text: data.error || 'Could not load pending users' });
|
||||
return;
|
||||
}
|
||||
pendingUsers = data.users || [];
|
||||
const list = document.getElementById('pendingList');
|
||||
if (list) {
|
||||
list.innerHTML = pendingUsers.map(u => `
|
||||
<div class="admin-item">
|
||||
<div class="meta">
|
||||
<strong>${u.email}</strong>
|
||||
<span>${u.display_name || ''}</span>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<button class="modal-btn btn-save" onclick="approveUser('${u.id}')">Approve</button>
|
||||
<button class="modal-btn btn-cancel" onclick="suspendUser('${u.id}')">Suspend</button>
|
||||
<button class="modal-btn btn-save" onclick="makeAdmin('${u.id}')">Make admin</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<p class="auth-hint">No pending users.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserStatus(id, status) {
|
||||
const resp = await fetch(`/api/admin/users/${id}/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Failed', text: data.error || 'Update failed' });
|
||||
} else {
|
||||
fetchPendingUsers();
|
||||
}
|
||||
}
|
||||
async function approveUser(id) { return updateUserStatus(id, 'active'); }
|
||||
async function suspendUser(id) { return updateUserStatus(id, 'suspended'); }
|
||||
|
||||
async function makeAdmin(id) {
|
||||
const resp = await fetch(`/api/admin/users/${id}/admin`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ is_admin: true })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) showAlert({ title: 'Failed', text: data.error || 'Admin update failed' });
|
||||
else fetchPendingUsers();
|
||||
}
|
||||
|
||||
async function downloadBackup() {
|
||||
const resp = await fetch('/api/admin/backup', { headers: { Authorization: `Bearer ${auth.token}` } });
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Backup failed', text: data.error || 'Error' });
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `backup_${new Date().toISOString()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function uploadRestore(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const payload = JSON.parse(text);
|
||||
const resp = await fetch('/api/admin/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showAlert({ title: 'Restore failed', text: data.error || 'Error' });
|
||||
} else {
|
||||
showAlert({ title: 'Restore complete', text: 'Data restored.' });
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert({ title: 'Restore failed', text: err.message });
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
163
assets/style.css
163
assets/style.css
@ -184,6 +184,33 @@ h1 {
|
||||
.pattern-mode { border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 6px 10px; border-radius: 10px; cursor: pointer; }
|
||||
.pattern-mode.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
|
||||
.pattern-save-indicator { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.pattern-save-indicator.small { font-size: 0.85rem; }
|
||||
.pattern-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--input-bg);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pattern-toolbar-left { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.pattern-toolbar-right { display: flex; align-items: center; }
|
||||
.toolbar-btn {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar-btn i { color: var(--project-color); }
|
||||
.toolbar-btn:hover { border-color: var(--project-color); }
|
||||
.pattern-body { display: grid; gap: 12px; }
|
||||
.pattern-row-info { color: var(--text-muted); font-size: 0.95rem; }
|
||||
.pattern-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
@ -193,14 +220,45 @@ h1 {
|
||||
.pattern-tab {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.pattern-tab.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
|
||||
.pattern-section { display: none; border: 1px dashed var(--border); padding: 12px; border-radius: 12px; background: var(--card-bg); }
|
||||
.pattern-section { display: none; border: 1px solid var(--border); padding: 14px; border-radius: 14px; background: var(--card-bg); box-shadow: 0 10px 22px rgba(0,0,0,0.08); }
|
||||
.pattern-section.is-active { display: block; }
|
||||
|
||||
.pattern-view { display: grid; gap: 10px; }
|
||||
.view-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 6px 14px rgba(0,0,0,0.08);
|
||||
}
|
||||
.view-title { margin: 0 0 4px; font-size: 1.1rem; }
|
||||
.view-sub { margin: 0; color: var(--text-muted); font-size: 0.95rem; }
|
||||
.view-steps { display: grid; gap: 8px; }
|
||||
.view-step {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: var(--input-bg);
|
||||
}
|
||||
.view-step header { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
||||
.view-step header .title { font-weight: 800; }
|
||||
.view-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.view-check input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.view-check .fa-square { color: var(--text-muted); }
|
||||
.view-check .fa-square-check { color: var(--project-color); display: none; }
|
||||
.view-check input:checked + .fa-square { display: none; }
|
||||
.view-check input:checked ~ .fa-square-check { display: inline; }
|
||||
.view-step .rows { margin: 0; padding-left: 16px; color: var(--text); }
|
||||
.view-step textarea { width: 100%; min-height: 60px; border-radius: 10px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); padding: 8px; }
|
||||
.view-step.is-finished .view-step-body { display: none; }
|
||||
.view-step.is-finished { opacity: 0.8; }
|
||||
.field-label { display: block; margin: 8px 0 4px; color: var(--text-muted); font-size: 0.9rem; }
|
||||
.pattern-section input, .pattern-section textarea {
|
||||
width: 100%;
|
||||
@ -257,6 +315,37 @@ h1 {
|
||||
.add-step-row .add-step-btn { padding: 10px 16px; border-radius: 12px; border: none; background: var(--project-color); color: var(--card-bg); cursor: pointer; font-weight: 700; }
|
||||
.add-step-row .add-step-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.12); }
|
||||
.add-step-row .add-step-btn:active { transform: translateY(0); box-shadow: none; }
|
||||
|
||||
.pattern-library { display: grid; gap: 10px; max-height: 320px; overflow-y: auto; }
|
||||
.pattern-library-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pattern-lib-title { font-weight: 800; color: var(--text); }
|
||||
.pattern-lib-subtitle { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.pattern-lib-actions { display: flex; gap: 8px; }
|
||||
|
||||
.instructions-block {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
padding: 8px 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.instructions-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.instructions-body { margin-top: 6px; display: ${'' /* fallback */}; }
|
||||
.instructions-block.collapsed .instructions-body { display: none; }
|
||||
.instruction-title { font-weight: 700; margin-bottom: 4px; }
|
||||
.instruction-rows { margin: 0; padding-left: 16px; }
|
||||
.instruction-rows li { margin: 2px 0; color: var(--text); }
|
||||
.instruction-rows .muted { color: var(--text-muted); }
|
||||
|
||||
.step-number {
|
||||
font-weight: 800;
|
||||
color: var(--project-color);
|
||||
@ -416,6 +505,65 @@ h1 {
|
||||
.save-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.icon-woodland { width: 22px; height: 22px; }
|
||||
|
||||
/* Auth Modal */
|
||||
.auth-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(44, 35, 25, 0.6);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 220;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.auth-overlay.active { display: flex; }
|
||||
.auth-modal {
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
border-radius: 16px;
|
||||
padding: 18px 18px 14px;
|
||||
width: min(440px, 92vw);
|
||||
box-shadow: 0 14px 32px rgba(0,0,0,0.22);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.auth-modal-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }
|
||||
.auth-subtext { margin: 4px 0 10px; color: var(--text-muted); font-size: 0.92rem; }
|
||||
.auth-status { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.status-pill { padding: 6px 10px; border-radius: 999px; background: var(--input-bg); border: 1px solid var(--border); font-weight: 700; color: var(--text); }
|
||||
.status-pill.is-on { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
|
||||
.status-subtext { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.auth-tabs { display: flex; gap: 8px; margin: 6px 0 12px; }
|
||||
.auth-tab {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.auth-tab.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); }
|
||||
.auth-form { display: grid; gap: 8px; }
|
||||
.auth-hint { margin: 4px 0; color: var(--text-muted); font-size: 0.9rem; }
|
||||
.auth-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
.auth-actions-stack { flex-wrap: wrap; justify-content: flex-start; }
|
||||
.auth-when-in { display: none; }
|
||||
.auth-when-out { display: block; }
|
||||
.admin-panel { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: grid; gap: 8px; }
|
||||
.admin-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.admin-list { display: grid; gap: 8px; max-height: 200px; overflow-y: auto; }
|
||||
.admin-item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); }
|
||||
.admin-item .meta { display: grid; gap: 2px; }
|
||||
.admin-item .modal-btn { padding: 6px 10px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.pattern-sheet { padding: 12px; }
|
||||
.pattern-toolbar { flex-direction: column; align-items: flex-start; }
|
||||
.pattern-tabs { justify-content: center; }
|
||||
.pattern-tab { flex: 1 1 120px; text-align: center; }
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
@ -485,6 +633,17 @@ h1 {
|
||||
border-radius: 14px; padding: 6px 10px; font-size: 0.9rem; cursor: pointer;
|
||||
}
|
||||
.btn-save-pattern:hover { color: var(--project-color); border-color: var(--project-color); }
|
||||
.project-link-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-delete-project {
|
||||
background: none; border: none; color: var(--text-muted); font-size: 1.2rem; cursor: pointer; padding: 5px;
|
||||
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@ -0,0 +1,31 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: toadstool
|
||||
POSTGRES_PASSWORD: toadstool
|
||||
POSTGRES_DB: toadstool
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5440:5432"
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
|
||||
PORT: 4000
|
||||
UPLOAD_DIR: /app/server/uploads
|
||||
ADMIN_EMAIL: chris@chrisedwards.tech
|
||||
ADMIN_PASSWORD: R4e3w2q1
|
||||
volumes:
|
||||
- ./server/uploads:/app/server/uploads
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
166
index.html
166
index.html
@ -28,6 +28,7 @@
|
||||
<button class="header-btn" id="themeBtn" onclick="toggleTheme()" title="Toggle Dark Mode"><i class="fa-solid fa-moon"></i></button>
|
||||
<button class="header-btn" id="focusBtn" onclick="toggleFocusMode()" title="Focus Mode (Keeps Screen On)"><i class="fa-solid fa-eye"></i></button>
|
||||
<button class="header-btn" id="saveLoadBtn" onclick="openSaveModal()" title="Save/Load"><i class="fa-solid fa-floppy-disk"></i></button>
|
||||
<button class="header-btn" id="authBtn" onclick="openAuthModal()" title="Sign in to sync"><i class="fa-solid fa-user"></i></button>
|
||||
</div>
|
||||
</header>
|
||||
<input type="file" id="importFile" accept="application/json" class="hidden-input" />
|
||||
@ -87,6 +88,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-modal">
|
||||
<div class="auth-modal-head">
|
||||
<div>
|
||||
<h3 class="color-title">Sign in (optional)</h3>
|
||||
<p class="auth-subtext">Stay free forever. Sign in only if you want cloud backups and device switching.</p>
|
||||
</div>
|
||||
<button class="pattern-close" onclick="closeAuthModal()">×</button>
|
||||
</div>
|
||||
<div class="auth-status">
|
||||
<span id="authStatusBadge" class="status-pill">Signed out</span>
|
||||
<span id="authLastSync" class="status-subtext">Last sync: never</span>
|
||||
</div>
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab" data-mode="login" onclick="setAuthMode('login')">Login</button>
|
||||
<button class="auth-tab" data-mode="signup" onclick="setAuthMode('signup')">Sign up</button>
|
||||
</div>
|
||||
<form class="auth-form" onsubmit="return submitAuth(event)">
|
||||
<div class="auth-when-out">
|
||||
<label class="field-label" for="authEmail">Email</label>
|
||||
<input id="authEmail" type="email" placeholder="you@example.com" autocomplete="email">
|
||||
<label class="field-label" for="authPassword">Password</label>
|
||||
<input id="authPassword" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device. When enabled, 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">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-when-in">
|
||||
<label class="field-label" for="authDisplayName">Display name</label>
|
||||
<input id="authDisplayName" type="text" placeholder="Your name">
|
||||
<label class="field-label" for="authNote">Profile note</label>
|
||||
<textarea id="authNote" rows="2" placeholder="Add a note for your patterns..."></textarea>
|
||||
<div class="auth-actions auth-actions-stack">
|
||||
<button type="button" class="modal-btn btn-save" onclick="autoSync()">Sync now</button>
|
||||
<button type="button" class="modal-btn btn-save" onclick="saveProfile()">Save profile</button>
|
||||
<button type="button" class="modal-btn btn-cancel" onclick="logoutAuth()">Log out</button>
|
||||
</div>
|
||||
<div id="adminPanel" class="admin-panel" style="display:none;">
|
||||
<h4>Admin</h4>
|
||||
<div class="admin-actions">
|
||||
<button type="button" class="modal-btn btn-save" onclick="fetchPendingUsers()">Refresh pending</button>
|
||||
<button type="button" class="modal-btn btn-save" onclick="downloadBackup()">Download backup</button>
|
||||
<label class="modal-btn btn-save">
|
||||
Restore backup
|
||||
<input type="file" id="restoreInput" accept="application/json" style="display:none;" onchange="uploadRestore(event)">
|
||||
</label>
|
||||
</div>
|
||||
<div id="pendingList" class="admin-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-overlay" id="patternOverlay">
|
||||
<div class="pattern-sheet">
|
||||
<div class="pattern-sheet-header">
|
||||
@ -101,10 +158,23 @@
|
||||
<div class="pattern-save-indicator" id="patternSaveIndicator">Saved</div>
|
||||
<button class="pattern-close" onclick="closePatternComposer()">×</button>
|
||||
</div>
|
||||
<div class="pattern-toolbar">
|
||||
<div class="pattern-toolbar-left">
|
||||
<button class="toolbar-btn" onclick="savePatternDraft()" title="Save draft to basket"><i class="fa-solid fa-bookmark"></i> Save</button>
|
||||
<button class="toolbar-btn" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i> PDF</button>
|
||||
<button class="toolbar-btn" onclick="sharePattern()" title="Share link"><i class="fa-solid fa-link"></i> Share</button>
|
||||
<button class="toolbar-btn" onclick="clearPatternOutput()" title="Clear draft"><i class="fa-solid fa-eraser"></i> Clear</button>
|
||||
</div>
|
||||
<div class="pattern-toolbar-right">
|
||||
<span class="pattern-save-indicator small" id="patternSaveIndicatorMini">Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pattern-body">
|
||||
<div class="pattern-tabs">
|
||||
<button class="pattern-tab" data-tab="info" onclick="showPatternTab('info')">Pattern Info</button>
|
||||
<button class="pattern-tab" data-tab="steps" onclick="showPatternTab('steps')">Steps</button>
|
||||
<button class="pattern-tab" data-tab="view" onclick="showPatternTab('view')">View</button>
|
||||
<button class="pattern-tab" data-tab="library" onclick="showPatternTab('library')">My Basket</button>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="info">
|
||||
<label class="field-label" for="patternTitle">Title</label>
|
||||
@ -140,10 +210,7 @@
|
||||
<label class="field-label" for="patternNotes">Notes / finishing</label>
|
||||
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
<div class="pattern-row-actions pattern-footer">
|
||||
<button onclick="clearPatternOutput()">Clear pattern</button>
|
||||
<button class="secondary" onclick="exportPatternJSON()">Export JSON</button>
|
||||
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
|
||||
<button class="primary" onclick="exportPatternPDF()">Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="steps">
|
||||
@ -153,96 +220,15 @@
|
||||
</div>
|
||||
<div id="patternSteps"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-overlay" id="patternOverlay">
|
||||
<div class="pattern-sheet">
|
||||
<div class="pattern-sheet-header">
|
||||
<div class="pattern-sheet-title">
|
||||
<h2>Pattern Composer</h2>
|
||||
<p class="pattern-sheet-subtitle">Draft rows plus materials, gauge, and abbreviations.</p>
|
||||
</div>
|
||||
<div class="pattern-modes">
|
||||
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
|
||||
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
|
||||
</div>
|
||||
<button class="pattern-close" onclick="closePatternComposer()">×</button>
|
||||
</div>
|
||||
<div class="pattern-body">
|
||||
<div class="pattern-tabs">
|
||||
<button class="pattern-tab" data-tab="meta" onclick="showPatternTab('meta')">Cover</button>
|
||||
<button class="pattern-tab" data-tab="materials" onclick="showPatternTab('materials')">Materials</button>
|
||||
<button class="pattern-tab" data-tab="gauge" onclick="showPatternTab('gauge')">Gauge/Size</button>
|
||||
<button class="pattern-tab" data-tab="abbrev" onclick="showPatternTab('abbrev')">Abbrev</button>
|
||||
<button class="pattern-tab" data-tab="stitches" onclick="showPatternTab('stitches')">Stitches</button>
|
||||
<button class="pattern-tab" data-tab="steps" onclick="showPatternTab('steps')">Steps</button>
|
||||
<button class="pattern-tab" data-tab="notes" onclick="showPatternTab('notes')">Notes</button>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="meta">
|
||||
<label class="field-label" for="patternTitle">Title</label>
|
||||
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
|
||||
<label class="field-label" for="patternDesigner">Designer / Credits</label>
|
||||
<input id="patternDesigner" type="text" placeholder="Your name or shop">
|
||||
</div>
|
||||
<div class="pattern-section" data-section="materials">
|
||||
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
|
||||
<textarea id="patternMaterials" placeholder="Yarn (Color A) – worsted\nYarn (Color B) – accent\nHook – 4.0 mm\nSafety eyes, stuffing, needle"></textarea>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="gauge">
|
||||
<label class="field-label" for="patternGauge">Gauge</label>
|
||||
<textarea id="patternGauge" placeholder="e.g., 16 sc x 18 rows = 4”/10 cm with 4.0 mm hook"></textarea>
|
||||
<label class="field-label" for="patternSize">Finished size</label>
|
||||
<input id="patternSize" type="text" placeholder="Approx. 6 in / 15 cm tall">
|
||||
</div>
|
||||
<div class="pattern-section" data-section="abbrev">
|
||||
<label class="field-label" for="patternAbbrev">Abbreviations</label>
|
||||
<textarea id="patternAbbrev" placeholder="sc – single crochet\ndc – double crochet\ninc – increase\nk – knit\np – purl"></textarea>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="stitches">
|
||||
<label class="field-label" for="patternStitches">Stitch guide / special stitches</label>
|
||||
<textarea id="patternStitches" placeholder="Magic ring: ... Invisible decrease: ... Kfb: knit front and back ..."></textarea>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="steps">
|
||||
<div class="pattern-row-info">Row <span id="patternRowNumber">1</span></div>
|
||||
<div class="pattern-buttons">
|
||||
<button onclick="addPatternToken('sc')">sc</button>
|
||||
<button onclick="addPatternToken('hdc')">hdc</button>
|
||||
<button onclick="addPatternToken('dc')">dc</button>
|
||||
<button onclick="addPatternToken('ch')">ch</button>
|
||||
<button onclick="addPatternToken('sl st')">sl st</button>
|
||||
<button onclick="addPatternToken('inc')">inc</button>
|
||||
<button onclick="addPatternToken('dec')">dec</button>
|
||||
<button onclick="addPatternToken('k')">k</button>
|
||||
<button onclick="addPatternToken('p')">p</button>
|
||||
<button onclick="addPatternToken('yo')">yo</button>
|
||||
<button onclick="addPatternToken('k2tog')">k2tog</button>
|
||||
<button onclick="addPatternToken('[ ] x')">[ ] x</button>
|
||||
</div>
|
||||
<div class="pattern-row-editor">
|
||||
<textarea id="patternLine" placeholder="Build a row with the buttons above or type manually..."></textarea>
|
||||
<div class="pattern-row-actions">
|
||||
<button onclick="clearPatternLine()">Clear line</button>
|
||||
<button class="primary" onclick="addPatternRow()">Add row</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="library">
|
||||
<div class="pattern-steps-head">
|
||||
<h4>Steps</h4>
|
||||
<button class="primary" onclick="addStep()">+ Step</button>
|
||||
<h4>Saved Patterns</h4>
|
||||
<button class="primary" onclick="savePatternDraft()">Save Draft to Basket</button>
|
||||
</div>
|
||||
<div id="patternSteps"></div>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="notes">
|
||||
<label class="field-label" for="patternNotes">Notes / finishing</label>
|
||||
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
</div>
|
||||
<div class="pattern-output">
|
||||
<label for="patternOutput">Pattern draft (rows)</label>
|
||||
<textarea id="patternOutput" placeholder="Rows will appear here as you add them..."></textarea>
|
||||
<div class="pattern-row-actions">
|
||||
<button onclick="clearPatternOutput()">Clear pattern</button>
|
||||
<div id="patternLibrary" class="pattern-library"></div>
|
||||
</div>
|
||||
<div class="pattern-section" data-section="view">
|
||||
<div id="patternView" class="pattern-view"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "toadstooltally",
|
||||
"version": "1.0.0",
|
||||
"description": "PWA-friendly, cottagecore row counter for crochet/knitting projects. Manage projects and parts, set max stitches, lock/finish, and enjoy a themed experience with install support and offline caching.",
|
||||
"main": "sw.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
112
pattern-viewer.html
Normal file
112
pattern-viewer.html
Normal file
@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pattern Viewer</title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
<style>
|
||||
body { background: var(--bg); color: var(--text); padding: 16px; }
|
||||
.viewer { max-width: 720px; margin: 0 auto; padding: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 10px 24px rgba(0,0,0,0.1); }
|
||||
.viewer h1 { margin: 0 0 6px; }
|
||||
.viewer h3 { margin: 12px 0 6px; }
|
||||
.viewer pre { white-space: pre-wrap; background: var(--input-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); }
|
||||
.step-list { display: grid; gap: 10px; margin: 12px 0; }
|
||||
.step-card { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: var(--card-bg); box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
|
||||
.step-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.step-title input { margin-right: 8px; }
|
||||
.step-rows { margin-top: 6px; }
|
||||
.note-box { width: 100%; min-height: 80px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 8px; }
|
||||
.meta { display: grid; gap: 6px; margin: 10px 0; font-size: 0.95rem; }
|
||||
.pill { display: inline-block; padding: 4px 8px; border-radius: 999px; background: var(--input-bg); border: 1px solid var(--border); font-size: 0.85rem; margin-right: 6px; margin-bottom: 4px; }
|
||||
.top-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.back-link { text-decoration: none; color: var(--text); font-weight: 700; }
|
||||
.back-link:hover { color: var(--project-color); }
|
||||
@media (max-width: 600px) { .viewer { padding: 12px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="viewer">
|
||||
<div class="top-actions">
|
||||
<a class="back-link" href="./">← Back to app</a>
|
||||
<span id="viewerStatus" class="pill">Progress saved locally</span>
|
||||
</div>
|
||||
<h1 id="pvTitle">Pattern</h1>
|
||||
<div id="pvDesigner" class="meta"></div>
|
||||
<div id="pvMaterials"></div>
|
||||
<h3>Steps</h3>
|
||||
<div id="pvSteps" class="step-list"></div>
|
||||
<h3>Notes</h3>
|
||||
<textarea id="pvNotes" class="note-box" placeholder="Your notes..." aria-label="Notes"></textarea>
|
||||
</div>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const data = params.get('data');
|
||||
let patternDraft = null;
|
||||
try {
|
||||
if (!data) throw new Error('Missing pattern data');
|
||||
const json = decodeURIComponent(escape(atob(data)));
|
||||
const parsed = JSON.parse(json);
|
||||
if (!parsed.patternDraft) throw new Error('Invalid payload');
|
||||
patternDraft = parsed.patternDraft;
|
||||
} catch (e) {
|
||||
document.body.innerHTML = `<div style="padding:20px;">Failed to load pattern: ${e.message}</div>`;
|
||||
throw e;
|
||||
}
|
||||
const storageKey = `pattern-viewer-${patternDraft.meta?.title || 'pattern'}`;
|
||||
let progress = JSON.parse(localStorage.getItem(storageKey) || '{}');
|
||||
function saveProgress() {
|
||||
localStorage.setItem(storageKey, JSON.stringify(progress));
|
||||
}
|
||||
function render() {
|
||||
document.getElementById('pvTitle').textContent = patternDraft.meta?.title || 'Pattern';
|
||||
document.getElementById('pvDesigner').textContent = patternDraft.meta?.designer || '';
|
||||
const mats = patternDraft.materials || '';
|
||||
document.getElementById('pvMaterials').innerHTML = mats ? `<h3>Materials</h3><pre>${mats}</pre>` : '';
|
||||
const stepsEl = document.getElementById('pvSteps');
|
||||
stepsEl.innerHTML = '';
|
||||
(patternDraft.steps || []).forEach((step, idx) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'step-card';
|
||||
const rows = (step.rows || []).map((r,i) => `<div><input type="checkbox" data-step="${idx}" data-row="${i}" ${progress[idx]?.rows?.[i]?'checked':''}> Row ${i+1}: ${r}</div>`).join('');
|
||||
card.innerHTML = `
|
||||
<div class="step-title">
|
||||
<div><strong>Step ${idx+1}</strong> ${step.title ? '– ' + step.title : ''}</div>
|
||||
</div>
|
||||
<div class="step-rows">${rows || '<em>No rows yet.</em>'}</div>
|
||||
<div style="margin-top:6px;">
|
||||
<label>Notes</label>
|
||||
<textarea data-step="${idx}" class="note-box" placeholder="Notes for this step...">${progress[idx]?.note || ''}</textarea>
|
||||
</div>
|
||||
`;
|
||||
stepsEl.appendChild(card);
|
||||
});
|
||||
const pvNotes = document.getElementById('pvNotes');
|
||||
pvNotes.value = progress.globalNote || '';
|
||||
}
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.matches('input[type="checkbox"][data-step]')) {
|
||||
const s = Number(e.target.dataset.step);
|
||||
const r = Number(e.target.dataset.row);
|
||||
progress[s] = progress[s] || { rows: {} };
|
||||
progress[s].rows = progress[s].rows || {};
|
||||
progress[s].rows[r] = e.target.checked;
|
||||
saveProgress();
|
||||
}
|
||||
});
|
||||
document.addEventListener('input', (e) => {
|
||||
if (e.target.matches('textarea[data-step]')) {
|
||||
const s = Number(e.target.dataset.step);
|
||||
progress[s] = progress[s] || { rows: {} };
|
||||
progress[s].note = e.target.value;
|
||||
saveProgress();
|
||||
}
|
||||
if (e.target.id === 'pvNotes') {
|
||||
progress.globalNote = e.target.value;
|
||||
saveProgress();
|
||||
}
|
||||
});
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4
server/.env.example
Normal file
4
server/.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
DATABASE_URL=postgres://user:pass@localhost:5432/toadstool
|
||||
JWT_SECRET=change-me
|
||||
PORT=4000
|
||||
UPLOAD_DIR=./uploads
|
||||
3138
server/package-lock.json
generated
Normal file
3138
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
server/package.json
Normal file
26
server/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "toadstooltally-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Backend API for Toadstool Cottage Counter (auth demo + sync endpoints).",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.33.3",
|
||||
"pg": "^8.12.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
309
server/src/db.js
Normal file
309
server/src/db.js
Normal file
@ -0,0 +1,309 @@
|
||||
const { Pool } = require('pg');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require') ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
async function initDb(retries = 10) {
|
||||
let attempt = 0;
|
||||
while (attempt < retries) {
|
||||
try {
|
||||
await pool.query(`
|
||||
create table if not exists users (
|
||||
id uuid primary key,
|
||||
email text unique not null,
|
||||
password_hash text not null,
|
||||
display_name text default '',
|
||||
note text default '',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`alter table users add column if not exists is_admin boolean not null default false`);
|
||||
await pool.query(`alter table users add column if not exists status text not null default 'pending'`);
|
||||
await pool.query(`
|
||||
create table if not exists sessions (
|
||||
token uuid primary key,
|
||||
user_id uuid references users(id) on delete cascade,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`
|
||||
create table if not exists projects (
|
||||
id text primary key,
|
||||
user_id uuid references users(id) on delete cascade,
|
||||
data jsonb not null,
|
||||
updated_at timestamptz not null default now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await pool.query(`
|
||||
create table if not exists patterns (
|
||||
id text primary key,
|
||||
user_id uuid references users(id) on delete cascade,
|
||||
data jsonb not null,
|
||||
updated_at timestamptz not null default now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
// Migrate legacy UUID columns to text to allow non-UUID ids
|
||||
await pool.query(`drop table if exists pattern_shares cascade`);
|
||||
await pool.query(`alter table if exists projects alter column id type text using id::text`);
|
||||
await pool.query(`alter table if exists patterns alter column id type text using id::text`);
|
||||
await pool.query(`alter table patterns add column if not exists title text not null default ''`);
|
||||
await pool.query(`alter table patterns add column if not exists slug text`);
|
||||
await pool.query(`create unique index if not exists patterns_user_slug_idx on patterns(user_id, slug) where slug is not null`);
|
||||
await pool.query(`
|
||||
create table if not exists pattern_shares (
|
||||
id uuid primary key,
|
||||
pattern_id text,
|
||||
token text unique not null,
|
||||
is_public boolean default true,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
await pool.query(`
|
||||
create table if not exists password_resets (
|
||||
token uuid primary key,
|
||||
user_id uuid references users(id) on delete cascade,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
`);
|
||||
// Backfill existing users to active and non-admin
|
||||
await pool.query(`update users set status='active' where status is null or status=''`);
|
||||
return;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt >= retries) throw err;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`DB not ready, retrying (${attempt}/${retries})...`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function hashPassword(pw) {
|
||||
return crypto.createHash('sha256').update(pw).digest('hex');
|
||||
}
|
||||
|
||||
async function createUser(email, password) {
|
||||
const id = crypto.randomUUID();
|
||||
const password_hash = hashPassword(password);
|
||||
const countRes = await pool.query(`select count(*)::int as c from users`);
|
||||
const isFirst = countRes.rows[0].c === 0;
|
||||
await pool.query(
|
||||
`insert into users (id, email, password_hash, status, is_admin) values ($1, $2, $3, $4, $5)
|
||||
on conflict (email) do nothing`,
|
||||
[id, email, password_hash, isFirst ? 'active' : 'pending', isFirst]
|
||||
);
|
||||
const row = await pool.query(`select id, email, display_name, note, is_admin, status from users where email=$1`, [email]);
|
||||
return row.rows[0];
|
||||
}
|
||||
|
||||
async function verifyUser(email, password) {
|
||||
const row = await pool.query(`select * from users where email=$1`, [email]);
|
||||
if (!row.rows.length) return null;
|
||||
const user = row.rows[0];
|
||||
if (user.password_hash !== hashPassword(password)) return null;
|
||||
if (user.status !== 'active') {
|
||||
return { pending: true, status: user.status };
|
||||
}
|
||||
return { id: user.id, email: user.email, display_name: user.display_name, note: user.note, is_admin: user.is_admin };
|
||||
}
|
||||
|
||||
async function createSession(userId) {
|
||||
const token = crypto.randomUUID();
|
||||
await pool.query(`insert into sessions (token, user_id) values ($1, $2)`, [token, userId]);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function getSession(token) {
|
||||
const row = await pool.query(
|
||||
`select s.token, u.id as user_id, u.email, u.is_admin, u.status from sessions s join users u on u.id=s.user_id where s.token=$1`,
|
||||
[token]
|
||||
);
|
||||
return row.rows[0];
|
||||
}
|
||||
|
||||
async function deleteSession(token) {
|
||||
await pool.query(`delete from sessions where token=$1`, [token]);
|
||||
}
|
||||
|
||||
async function upsertProfile(userId, displayName, note) {
|
||||
await pool.query(
|
||||
`update users set display_name=$2, note=$3, updated_at=now() where id=$1`,
|
||||
[userId, displayName, note]
|
||||
);
|
||||
const row = await pool.query(`select email, display_name, note from users where id=$1`, [userId]);
|
||||
return row.rows[0];
|
||||
}
|
||||
|
||||
async function getProfile(userId) {
|
||||
const row = await pool.query(`select email, display_name, note, is_admin, status from users where id=$1`, [userId]);
|
||||
return row.rows[0];
|
||||
}
|
||||
|
||||
async function upsertItems(table, userId, items = []) {
|
||||
if (!items.length) return;
|
||||
if (table === 'patterns') {
|
||||
for (const item of items) {
|
||||
const title = item.title || '';
|
||||
const slug = item.slug || null;
|
||||
const deleted = item.deleted_at || null;
|
||||
await pool.query(
|
||||
`insert into patterns (id, user_id, title, slug, data, updated_at, deleted_at)
|
||||
values ($1, $2, $3, $4, $5, now(), $6)
|
||||
on conflict (id) do update set title=excluded.title, slug=excluded.slug, data=excluded.data, updated_at=now(), deleted_at=excluded.deleted_at`,
|
||||
[item.id, userId, title, slug, item.data || {}, deleted]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const item of items) {
|
||||
const deleted = item.deleted_at || null;
|
||||
await pool.query(
|
||||
`insert into projects (id, user_id, data, updated_at, deleted_at)
|
||||
values ($1, $2, $3, now(), $4)
|
||||
on conflict (id) do update set data=excluded.data, updated_at=now(), deleted_at=excluded.deleted_at`,
|
||||
[item.id, userId, item.data || {}, deleted]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchItemsSince(table, userId, since) {
|
||||
if (table === 'patterns') {
|
||||
const res = await pool.query(
|
||||
`select id, title, slug, data, updated_at, deleted_at from patterns
|
||||
where user_id=$1 and updated_at >= coalesce($2::timestamptz, 'epoch'::timestamptz)
|
||||
order by updated_at asc`,
|
||||
[userId, since || null]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
const res = await pool.query(
|
||||
`select id, data, updated_at, deleted_at from projects
|
||||
where user_id=$1 and updated_at >= coalesce($2::timestamptz, 'epoch'::timestamptz)
|
||||
order by updated_at asc`,
|
||||
[userId, since || null]
|
||||
);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function createShare(patternId, token, isPublic = true, expiresAt = null) {
|
||||
await pool.query(
|
||||
`insert into pattern_shares (id, pattern_id, token, is_public, expires_at)
|
||||
values ($1, $2, $3, $4, $5)`,
|
||||
[crypto.randomUUID(), patternId, token, isPublic, expiresAt]
|
||||
);
|
||||
}
|
||||
|
||||
async function patternOwnedByUser(patternId, userId) {
|
||||
const res = await pool.query(
|
||||
`select id from patterns where id=$1 and user_id=$2 and deleted_at is null`,
|
||||
[patternId, userId]
|
||||
);
|
||||
return res.rows.length > 0;
|
||||
}
|
||||
|
||||
async function getSharedPattern(token) {
|
||||
const res = await pool.query(
|
||||
`select p.id, p.title, p.slug, p.data, p.updated_at
|
||||
from pattern_shares s
|
||||
join patterns p on p.id = s.pattern_id
|
||||
where s.token=$1 and (s.expires_at is null or s.expires_at > now()) and s.is_public = true
|
||||
limit 1`,
|
||||
[token]
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async function listPendingUsers() {
|
||||
const res = await pool.query(`select id, email, display_name, note, created_at from users where status='pending' order by created_at asc`);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function setUserStatus(userId, status) {
|
||||
await pool.query(`update users set status=$2, updated_at=now() where id=$1`, [userId, status]);
|
||||
}
|
||||
async function setUserAdmin(userId, isAdmin) {
|
||||
await pool.query(`update users set is_admin=$2, updated_at=now() where id=$1`, [userId, isAdmin]);
|
||||
}
|
||||
|
||||
async function bootstrapAdmin(email, password) {
|
||||
if (!email || !password) return;
|
||||
const hash = hashPassword(password);
|
||||
const existing = await pool.query(`select id from users where email=$1`, [email]);
|
||||
if (existing.rows.length) {
|
||||
await pool.query(`update users set password_hash=$2, is_admin=true, status='active', updated_at=now() where email=$1`, [email, hash]);
|
||||
} else {
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
`insert into users (id, email, password_hash, is_admin, status) values ($1, $2, $3, true, 'active')`,
|
||||
[id, email, hash]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllUsers() {
|
||||
const res = await pool.query(`select id, email, display_name, status, is_admin, created_at from users order by created_at asc`);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
async function setPassword(userId, newPassword) {
|
||||
const hash = hashPassword(newPassword);
|
||||
await pool.query(`update users set password_hash=$2, updated_at=now() where id=$1`, [userId, hash]);
|
||||
}
|
||||
|
||||
async function createResetToken(userId, ttlMinutes = 60) {
|
||||
const token = crypto.randomUUID();
|
||||
const expires = new Date(Date.now() + ttlMinutes * 60 * 1000);
|
||||
await pool.query(`insert into password_resets (token, user_id, expires_at) values ($1, $2, $3)`, [token, userId, expires]);
|
||||
return { token, expires };
|
||||
}
|
||||
|
||||
async function consumeResetToken(token) {
|
||||
const res = await pool.query(`select token, user_id, expires_at from password_resets where token=$1`, [token]);
|
||||
if (!res.rows.length) return null;
|
||||
const row = res.rows[0];
|
||||
await pool.query(`delete from password_resets where token=$1`, [token]);
|
||||
if (new Date(row.expires_at) < new Date()) return null;
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
withClient,
|
||||
initDb,
|
||||
createUser,
|
||||
verifyUser,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession,
|
||||
upsertItems,
|
||||
fetchItemsSince,
|
||||
upsertProfile,
|
||||
getProfile,
|
||||
createShare,
|
||||
getSharedPattern,
|
||||
patternOwnedByUser,
|
||||
listPendingUsers,
|
||||
setUserStatus,
|
||||
setUserAdmin,
|
||||
bootstrapAdmin,
|
||||
listAllUsers,
|
||||
setPassword,
|
||||
createResetToken,
|
||||
consumeResetToken
|
||||
};
|
||||
371
server/src/index.js
Normal file
371
server/src/index.js
Normal file
@ -0,0 +1,371 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
const {
|
||||
initDb,
|
||||
createUser,
|
||||
verifyUser,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession,
|
||||
upsertItems,
|
||||
fetchItemsSince,
|
||||
upsertProfile,
|
||||
getProfile,
|
||||
createShare,
|
||||
getSharedPattern,
|
||||
patternOwnedByUser,
|
||||
listPendingUsers,
|
||||
setUserStatus,
|
||||
bootstrapAdmin,
|
||||
listAllUsers,
|
||||
setPassword,
|
||||
createResetToken,
|
||||
consumeResetToken,
|
||||
setUserAdmin
|
||||
} = require('./db');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
||||
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
|
||||
// Init DB
|
||||
initDb().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('DB init failed', err);
|
||||
process.exit(1);
|
||||
}).then(() => {
|
||||
if (process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
|
||||
bootstrapAdmin(process.env.ADMIN_EMAIL, process.env.ADMIN_PASSWORD)
|
||||
.then(() => console.log('Admin bootstrap ready'))
|
||||
.catch((err) => console.error('Admin bootstrap failed', err));
|
||||
}
|
||||
});
|
||||
|
||||
const requireAuth = (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||
getSession(token)
|
||||
.then(session => {
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
req.user = { id: session.user_id, email: session.email, token, is_admin: session.is_admin, status: session.status };
|
||||
if (req.user.status !== 'active') return res.status(403).json({ error: 'Account not active', status: req.user.status });
|
||||
return next();
|
||||
})
|
||||
.catch(() => res.status(401).json({ error: 'Unauthorized' }));
|
||||
};
|
||||
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (!req.user?.is_admin) return res.status(403).json({ error: 'Admin only' });
|
||||
next();
|
||||
};
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '0.1.0', time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Serve static front-end from project root (../)
|
||||
const clientDir = path.join(__dirname, '..', '..');
|
||||
app.use(express.static(clientDir));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.sendFile(path.join(clientDir, 'index.html'));
|
||||
});
|
||||
|
||||
app.post('/api/signup', (req, res) => {
|
||||
const { email, password, displayName = '' } = req.body || {};
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
createUser(email, password)
|
||||
.then(async (user) => {
|
||||
await upsertProfile(user.id, displayName, '');
|
||||
const token = await createSession(user.id);
|
||||
return res.json({ token, email: user.email });
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Signup failed' });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
verifyUser(email, password)
|
||||
.then(async (user) => {
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
if (user.pending) return res.status(403).json({ error: 'Account pending approval', status: user.status });
|
||||
const token = await createSession(user.id);
|
||||
return res.json({ token, email: user.email, is_admin: user.is_admin });
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
return res.status(500).json({ error: 'Login failed' });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/logout', requireAuth, (req, res) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
deleteSession(token).finally(() => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
app.get('/api/sync', requireAuth, async (req, res) => {
|
||||
const since = req.query.since;
|
||||
try {
|
||||
const projects = await fetchItemsSince('projects', req.user.id, since);
|
||||
const patterns = await fetchItemsSince('patterns', req.user.id, since);
|
||||
res.json({ projects, patterns, since: since || null, serverTime: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Sync fetch failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/sync', requireAuth, async (req, res) => {
|
||||
const { projects = [], patterns = [] } = req.body || {};
|
||||
try {
|
||||
await upsertItems('projects', req.user.id, projects);
|
||||
await upsertItems('patterns', req.user.id, patterns);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Sync failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/me', requireAuth, (req, res) => {
|
||||
getProfile(req.user.id)
|
||||
.then(profile => res.json({ profile: profile || { email: req.user.email, displayName: '', note: '', is_admin: req.user.is_admin, status: req.user.status } }))
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Profile fetch failed' });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/me', requireAuth, (req, res) => {
|
||||
const { displayName = '', note = '' } = req.body || {};
|
||||
upsertProfile(req.user.id, displayName, note)
|
||||
.then(profile => res.json({ profile }))
|
||||
.catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Profile update failed' });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/patterns/:id/share', requireAuth, async (req, res) => {
|
||||
const patternId = req.params.id;
|
||||
const { isPublic = true, expiresAt = null } = req.body || {};
|
||||
try {
|
||||
const owns = await patternOwnedByUser(patternId, req.user.id);
|
||||
if (!owns) return res.status(404).json({ error: 'Pattern not found' });
|
||||
const token = uuid();
|
||||
await createShare(patternId, token, isPublic, expiresAt);
|
||||
res.json({ token, url: `/share/${token}` });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Share failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/share/:token', async (req, res) => {
|
||||
try {
|
||||
const shared = await getSharedPattern(req.params.token);
|
||||
if (!shared) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ pattern: shared });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Share fetch failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'File required' });
|
||||
const ext = (req.file.originalname.split('.').pop() || 'jpg').toLowerCase();
|
||||
const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'].includes(ext) ? ext : 'jpg';
|
||||
const filename = `${Date.now()}-${uuid()}.${safeExt}`;
|
||||
const outPath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
const pipeline = sharp(req.file.buffer)
|
||||
.rotate()
|
||||
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true });
|
||||
|
||||
if (safeExt === 'png') {
|
||||
pipeline.png({ quality: 80 });
|
||||
} else if (safeExt === 'webp') {
|
||||
pipeline.webp({ quality: 80 });
|
||||
} else if (safeExt === 'avif') {
|
||||
pipeline.avif({ quality: 70 });
|
||||
} else {
|
||||
pipeline.jpeg({ quality: 82, mozjpeg: true });
|
||||
}
|
||||
|
||||
await pipeline.toFile(outPath);
|
||||
const url = `/uploads/${filename}`;
|
||||
res.json({ url });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Upload failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: list pending users
|
||||
app.get('/api/admin/users/pending', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const users = await listPendingUsers();
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'List pending failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: update user status
|
||||
app.post('/api/admin/users/:id/status', requireAuth, requireAdmin, async (req, res) => {
|
||||
const { status } = req.body || {};
|
||||
if (!['pending', 'active', 'suspended'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
||||
try {
|
||||
await setUserStatus(req.params.id, status);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Update status failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: set/unset admin
|
||||
app.post('/api/admin/users/:id/admin', requireAuth, requireAdmin, async (req, res) => {
|
||||
const { is_admin } = req.body || {};
|
||||
try {
|
||||
await setUserAdmin(req.params.id, !!is_admin);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Update admin failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: list all users
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const users = await listAllUsers();
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'List users failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Password reset request (demo: returns token, logs it)
|
||||
app.post('/api/password-reset/request', async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
if (!email) return res.status(400).json({ error: 'Email required' });
|
||||
try {
|
||||
const userRow = await listAllUsers().then(users => users.find(u => u.email === email));
|
||||
if (!userRow) return res.json({ ok: true }); // don't leak
|
||||
const reset = await createResetToken(userRow.id);
|
||||
console.log(`Password reset token for ${email}: ${reset.token}`);
|
||||
// TODO: send via mail backend
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Reset request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/password-reset/confirm', async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password) return res.status(400).json({ error: 'Token and password required' });
|
||||
try {
|
||||
const userId = await consumeResetToken(token);
|
||||
if (!userId) return res.status(400).json({ error: 'Invalid or expired token' });
|
||||
await setPassword(userId, password);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Reset failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: backup (JSON export)
|
||||
app.get('/api/admin/backup', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const users = await listAllUsers();
|
||||
const projects = await fetchItemsSince('projects', null, null);
|
||||
const patterns = await fetchItemsSince('patterns', null, null);
|
||||
res.json({ users, projects, patterns, exportedAt: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Backup failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: restore (overwrite)
|
||||
app.post('/api/admin/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
const { users = [], projects = [], patterns = [] } = req.body || {};
|
||||
try {
|
||||
await withClient(async (client) => {
|
||||
await client.query('begin');
|
||||
await client.query('truncate table sessions cascade');
|
||||
await client.query('truncate table users cascade');
|
||||
await client.query('truncate table projects cascade');
|
||||
await client.query('truncate table patterns cascade');
|
||||
for (const u of users) {
|
||||
await client.query(
|
||||
`insert into users (id, email, password_hash, display_name, note, is_admin, status, created_at, updated_at)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[u.id, u.email, u.password_hash || '', u.display_name || '', u.note || '', u.is_admin || false, u.status || 'active', u.created_at || new Date(), u.updated_at || new Date()]
|
||||
);
|
||||
}
|
||||
for (const p of projects) {
|
||||
await client.query(
|
||||
`insert into projects (id, user_id, data, updated_at, deleted_at) values ($1,$2,$3,$4,$5)`,
|
||||
[p.id, p.user_id, p.data || {}, p.updated_at || new Date(), p.deleted_at || null]
|
||||
);
|
||||
}
|
||||
for (const p of patterns) {
|
||||
await client.query(
|
||||
`insert into patterns (id, user_id, title, slug, data, updated_at, deleted_at) values ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[p.id, p.user_id, p.title || '', p.slug || null, p.data || {}, p.updated_at || new Date(), p.deleted_at || null]
|
||||
);
|
||||
}
|
||||
await client.query('commit');
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Restore failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
// Basic error guard
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API listening on http://localhost:${PORT}`);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user