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

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

1
.gitignore vendored
View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -92,7 +92,7 @@
<div class="auth-modal">
<div class="auth-modal-head">
<div>
<h3 class="color-title">Sign in (optional)</h3>
<h3 class="color-title">Welcome Back!</h3>
<p class="auth-subtext">Stay free forever. Sign in only if you want cloud backups and device switching.</p>
</div>
<button class="pattern-close" onclick="closeAuthModal()">&times;</button>
@ -102,44 +102,85 @@
<span id="authLastSync" class="status-subtext">Last sync: never</span>
</div>
<div class="auth-tabs">
<button class="auth-tab" data-mode="login" onclick="setAuthMode('login')">Login</button>
<button class="auth-tab active" data-mode="login" onclick="setAuthMode('login')">Login</button>
<button class="auth-tab" data-mode="signup" onclick="setAuthMode('signup')">Sign up</button>
<button class="auth-tab" id="profileTabBtn" data-mode="profile" onclick="setAuthMode('profile')" style="display:none;">Profile</button>
<button class="auth-tab" id="adminTabBtn" data-mode="admin" onclick="setAuthMode('admin')" style="display:none;">Admin</button>
</div>
<form class="auth-form" onsubmit="return submitAuth(event)">
<div class="auth-when-out">
<label class="field-label" for="authEmail">Email</label>
<input id="authEmail" type="email" placeholder="you@example.com" autocomplete="email">
<label class="field-label" for="authPassword">Password</label>
<input id="authPassword" type="password" placeholder="••••••••" autocomplete="current-password">
<p class="auth-hint">Cloud sync is not required. Offline data stays on this device. When enabled, well sync projects/patterns securely.</p>
<div class="auth-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 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">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>
<div id="adminPanel" class="admin-panel" style="display:none;">
<h4>Admin</h4>
<div id="signupContent" class="auth-tab-content">
<form class="auth-form" onsubmit="return submitAuth(event, 'signup')">
<label class="field-label" for="signupEmail">Email</label>
<input id="signupEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
<label class="field-label" for="signupPassword">Password</label>
<input id="signupPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<label class="field-label" for="signupConfirmPassword">Confirm Password</label>
<input id="signupConfirmPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<p class="auth-hint">When enabled, well sync projects/patterns securely.</p>
<div class="auth-actions">
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
<button type="submit" class="modal-btn btn-save">Sign Up</button>
</div>
</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>
</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 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>
<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>
<button class="pattern-close" onclick="closePatternComposer()">&times;</button>
</div>
<div class="pattern-toolbar">
<div class="pattern-toolbar-left">
<button class="toolbar-btn" onclick="savePatternDraft()" title="Save draft to basket"><i class="fa-solid fa-bookmark"></i> Save</button>
<button class="toolbar-btn" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i> PDF</button>
<button class="toolbar-btn" onclick="sharePattern()" title="Share link"><i class="fa-solid fa-link"></i> Share</button>
<button class="toolbar-btn" onclick="clearPatternOutput()" title="Clear draft"><i class="fa-solid fa-eraser"></i> Clear</button>
</div>
<div class="pattern-toolbar-right">
<span class="pattern-save-indicator small" id="patternSaveIndicatorMini">Saved</span>
<div class="header-actions">
<span class="pattern-save-indicator" id="patternSaveIndicator">Saved</span>
<button class="icon-action" onclick="savePatternDraft()" title="Save to Basket"><i class="fa-solid fa-bookmark"></i></button>
<button class="icon-action" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i></button>
<button class="icon-action" onclick="sharePattern()" title="Share Link"><i class="fa-solid fa-link"></i></button>
<button class="icon-action danger" onclick="clearPatternOutput()" title="Clear Draft"><i class="fa-solid fa-eraser"></i></button>
<button class="pattern-close" onclick="closePatternComposer()">&times;</button>
</div>
</div>
<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">
<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">
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
<textarea id="patternMaterials" placeholder="Yarn (Color A) worsted&#10;Yarn (Color B) accent&#10;Hook 4.0 mm&#10;Safety eyes, stuffing, needle"></textarea>
<div class="field-group-inline">
<div>
<label class="field-label" for="patternGaugeSts">Stitches / 4in (10cm)</label>
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
<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">
</div>
<div class="form-row">
<label class="field-label" for="patternDesigner">Designer</label>
<input id="patternDesigner" type="text" placeholder="Your name or shop">
</div>
</div>
<div>
<label class="field-label" for="patternGaugeRows">Rows / 4in (10cm)</label>
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
<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">Sts / 4in</label>
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
</div>
<div>
<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="patternGauge">Gauge Notes</label>
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
</div>
<div class="input-card full-width">
<h4 class="card-head">Materials & Notes</h4>
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color)&#10;• 4.0mm Hook&#10;• Tapestry Needle&#10;• Polyester Fiberfill"></textarea>
<label class="field-label" for="patternNotes">Notes / Finishing</label>
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
</div>
<div class="input-card full-width">
<div class="abbrev-head">
<h4 class="card-head" style="margin:0;">Abbreviations</h4>
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
</div>
<div id="abbrevSummary" class="selected-abbrev">
<!-- Selected pills will appear here -->
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
</div>
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ...&#10;Invisible decrease: ..."></textarea>
</div>
</div>
<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>
</div>
<div id="patternAbbrevList" class="abbrev-grid"></div>
<textarea id="patternAbbrev" placeholder="sc single crochet&#10;dc double crochet&#10;inc increase&#10;k knit&#10;p purl"></textarea>
<label class="field-label" for="patternStitches">Stitch guide / special stitches</label>
<textarea id="patternStitches" placeholder="Magic ring: ...&#10;Invisible decrease: ...&#10;Kfb: knit front and back ..."></textarea>
<label class="field-label" for="patternNotes">Notes / finishing</label>
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
<div class="pattern-row-actions pattern-footer">
<div class="pattern-footer-actions">
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
</div>
</div>
<div class="pattern-section" data-section="steps">
<div class="pattern-steps-head">
<h4>Steps</h4>
<button class="primary" onclick="addStep()">+ Step</button>
<h4>Pattern Steps</h4>
</div>
<div id="patternSteps"></div>
</div>
<div class="pattern-section" data-section="library">
<div class="pattern-steps-head">
<h4>Saved Patterns</h4>
<button class="primary" onclick="savePatternDraft()">Save Draft to Basket</button>
<button class="primary" onclick="savePatternDraft()">Save Current Draft</button>
</div>
<div id="patternLibrary" class="pattern-library"></div>
</div>
<div class="pattern-section" data-section="view">
<div id="patternView" class="pattern-view"></div>
</div>
@ -234,6 +303,20 @@
</div>
</div>
<div class="modal-overlay" id="abbrevModal">
<div class="modal-content" style="width: min(800px, 94vw); max-height: 85vh; display: flex; flex-direction: column;">
<h3 class="modal-title">Select Abbreviations</h3>
<div class="abbrev-controls" style="margin-bottom: 12px;">
<input type="text" id="abbrevSearch" placeholder="Search stitches..." oninput="filterAbbrev()">
<button class="secondary small" onclick="loadDefaultAbbrev()">Reset to Defaults</button>
</div>
<div id="patternAbbrevList" class="abbrev-grid" style="overflow-y: auto; flex: 1;"></div>
<div class="modal-actions" style="margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
<button class="modal-btn btn-save" onclick="closeAbbrevModal()">Done</button>
</div>
</div>
</div>
<script src="assets/app.js"></script>
<footer class="footer-bg" aria-hidden="true"></footer>
</body>

15
live-server.js Normal file
View File

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

2726
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,15 @@
"description": "PWA-friendly, cottagecore row counter for crochet/knitting projects. Manage projects and parts, set max stitches, lock/finish, and enjoy a themed experience with install support and offline caching.",
"main": "sw.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"dev-frontend": "node live-server.js",
"dev": "cd server && npm install && cd .. && concurrently \"npm:dev-frontend\" \"cd server && npm run dev\""
},
"keywords": [],
"author": "",
"license": "ISC"
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1",
"live-server": "^1.2.2"
}
}

138
server/package-lock.json generated
View File

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

View File

@ -7,6 +7,7 @@ const multer = require('multer');
const sharp = require('sharp');
const {
initDb,
withClient,
createUser,
verifyUser,
createSession,
@ -36,7 +37,7 @@ const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
app.use(cors());
app.use(express.json({ limit: '15mb' }));
app.use(express.json({ limit: '50mb' }));
// Init DB
initDb().catch((err) => {
@ -195,37 +196,41 @@ app.get('/share/:token', async (req, res) => {
}
});
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
// Upload route: save to /uploads, return URL (Optimized with sharp, fallback to raw)
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); // Bumped limit slightly
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'File required' });
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);
let filename;
let outPath;
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 });
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';
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