Revamp Pattern Composer UI, fix Backup/Restore, and improve Image Uploads
- Refactored Pattern Composer UI with new tabs (Specs, Draft, Read, Shelf). - Added support for multiple Yarns and Hooks with integrated color pickers. - Improved Step drafting UX: reordered list/editor, added inline actions. - Fixed Database Backup/Restore: switched to SQL dump/restore for robustness. - Improved Image Uploads: added WebP optimization (with fallback) and preview display. - Updated local dev setup: added live-server proxy config and concurrently script.
This commit is contained in:
parent
d750cd88f4
commit
84909ff4e0
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ db_data/
|
||||
uploads/
|
||||
server/uploads/
|
||||
*.log
|
||||
server/.env
|
||||
|
||||
34
README.md
34
README.md
@ -21,6 +21,40 @@ python3 -m http.server 8080
|
||||
- Service worker requires an HTTP/HTTPS context; use a local server to test install/offline.
|
||||
- Data is stored in `localStorage`; clear it to reset.
|
||||
|
||||
## Backend (optional sync)
|
||||
|
||||
Node + Docker demo backend lives in `server/`.
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # http://localhost:4000
|
||||
|
||||
# Docker
|
||||
docker build -t toadstool-api .
|
||||
docker run -p 4000:4000 toadstool-api
|
||||
```
|
||||
|
||||
### Docker Compose (app + Postgres)
|
||||
```bash
|
||||
docker compose up --build
|
||||
# App/API: http://localhost:4000
|
||||
# Postgres: localhost:5432 (user/pass/db: toadstool)
|
||||
```
|
||||
|
||||
API (in-memory, demo):
|
||||
- `POST /api/signup` `{ email, password, displayName? }` -> `{ token, email }`
|
||||
- `POST /api/login` `{ email, password }` -> `{ token, email }`
|
||||
- `POST /api/logout` (Bearer token)
|
||||
- `GET /api/sync?since=...` (Bearer token) -> `{ projects, patterns }`
|
||||
- `POST /api/sync` (Bearer token) `{ projects, patterns }`
|
||||
- `GET /api/me`, `POST /api/me` (profile)
|
||||
- `POST /api/patterns/:id/share` -> `{ token, url }`, `GET /share/:token` -> `{ pattern }`
|
||||
- `POST /api/upload` (Bearer token, multipart `file`) -> `{ url }` (resizes/compresses to `/uploads`)
|
||||
|
||||
Replace with real auth/storage before production.
|
||||
|
||||
## PWA Notes
|
||||
- Manifest: `assets/site.webmanifest`
|
||||
- Service worker: `sw.js`
|
||||
|
||||
748
assets/app.js
748
assets/app.js
File diff suppressed because it is too large
Load Diff
1065
assets/style.css
1065
assets/style.css
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ services:
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
# restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://toadstool:toadstool@db:5432/toadstool
|
||||
PORT: 4000
|
||||
@ -21,11 +21,21 @@ services:
|
||||
ADMIN_EMAIL: chris@chrisedwards.tech
|
||||
ADMIN_PASSWORD: R4e3w2q1
|
||||
volumes:
|
||||
- ./server/uploads:/app/server/uploads
|
||||
- .:/app
|
||||
- /app/server/node_modules
|
||||
- /app/node_modules
|
||||
working_dir: /app
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "8080:8080"
|
||||
command: >
|
||||
sh -c "
|
||||
npm install &&
|
||||
cd server && npm install && cd .. &&
|
||||
npm run dev
|
||||
"
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
263
index.html
263
index.html
@ -92,7 +92,7 @@
|
||||
<div class="auth-modal">
|
||||
<div class="auth-modal-head">
|
||||
<div>
|
||||
<h3 class="color-title">Sign in (optional)</h3>
|
||||
<h3 class="color-title">Welcome Back!</h3>
|
||||
<p class="auth-subtext">Stay free forever. Sign in only if you want cloud backups and device switching.</p>
|
||||
</div>
|
||||
<button class="pattern-close" onclick="closeAuthModal()">×</button>
|
||||
@ -102,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, 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 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, we’ll sync projects/patterns securely.</p>
|
||||
<div class="auth-actions">
|
||||
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
|
||||
<button type="submit" class="modal-btn btn-save">Sign Up</button>
|
||||
</div>
|
||||
</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()">×</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()">×</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 Yarn (Color B) – accent Hook – 4.0 mm 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) • 4.0mm Hook • Tapestry Needle • Polyester Fiberfill"></textarea>
|
||||
|
||||
<label class="field-label" for="patternNotes">Notes / Finishing</label>
|
||||
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-card full-width">
|
||||
<div class="abbrev-head">
|
||||
<h4 class="card-head" style="margin:0;">Abbreviations</h4>
|
||||
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
|
||||
</div>
|
||||
<div id="abbrevSummary" class="selected-abbrev">
|
||||
<!-- Selected pills will appear here -->
|
||||
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
|
||||
</div>
|
||||
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
|
||||
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
|
||||
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ... Invisible decrease: ..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<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 dc – double crochet inc – increase k – knit p – purl"></textarea>
|
||||
<label class="field-label" for="patternStitches">Stitch guide / special stitches</label>
|
||||
<textarea id="patternStitches" placeholder="Magic ring: ... Invisible decrease: ... Kfb: knit front and back ..."></textarea>
|
||||
<label class="field-label" for="patternNotes">Notes / finishing</label>
|
||||
<textarea id="patternNotes" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
|
||||
<div class="pattern-row-actions pattern-footer">
|
||||
<div class="pattern-footer-actions">
|
||||
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="steps">
|
||||
<div class="pattern-steps-head">
|
||||
<h4>Steps</h4>
|
||||
<button class="primary" onclick="addStep()">+ Step</button>
|
||||
<h4>Pattern Steps</h4>
|
||||
</div>
|
||||
<div id="patternSteps"></div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="library">
|
||||
<div class="pattern-steps-head">
|
||||
<h4>Saved Patterns</h4>
|
||||
<button class="primary" onclick="savePatternDraft()">Save Draft to Basket</button>
|
||||
<button class="primary" onclick="savePatternDraft()">Save Current Draft</button>
|
||||
</div>
|
||||
<div id="patternLibrary" class="pattern-library"></div>
|
||||
</div>
|
||||
|
||||
<div class="pattern-section" data-section="view">
|
||||
<div id="patternView" class="pattern-view"></div>
|
||||
</div>
|
||||
@ -234,6 +303,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="abbrevModal">
|
||||
<div class="modal-content" style="width: min(800px, 94vw); max-height: 85vh; display: flex; flex-direction: column;">
|
||||
<h3 class="modal-title">Select Abbreviations</h3>
|
||||
<div class="abbrev-controls" style="margin-bottom: 12px;">
|
||||
<input type="text" id="abbrevSearch" placeholder="Search stitches..." oninput="filterAbbrev()">
|
||||
<button class="secondary small" onclick="loadDefaultAbbrev()">Reset to Defaults</button>
|
||||
</div>
|
||||
<div id="patternAbbrevList" class="abbrev-grid" style="overflow-y: auto; flex: 1;"></div>
|
||||
<div class="modal-actions" style="margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
|
||||
<button class="modal-btn btn-save" onclick="closeAbbrevModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/app.js"></script>
|
||||
<footer class="footer-bg" aria-hidden="true"></footer>
|
||||
</body>
|
||||
|
||||
15
live-server.js
Normal file
15
live-server.js
Normal file
@ -0,0 +1,15 @@
|
||||
var liveServer = require("live-server");
|
||||
|
||||
var params = {
|
||||
port: 8080,
|
||||
host: "0.0.0.0",
|
||||
ignore: 'db_data,server/uploads',
|
||||
file: "index.html",
|
||||
wait: 500,
|
||||
proxy: [
|
||||
['/api', 'http://127.0.0.1:4000/api'],
|
||||
['/uploads', 'http://127.0.0.1:4000/uploads']
|
||||
]
|
||||
};
|
||||
|
||||
liveServer.start(params);
|
||||
2726
package-lock.json
generated
Normal file
2726
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -4,9 +4,15 @@
|
||||
"description": "PWA-friendly, cottagecore row counter for crochet/knitting projects. Manage projects and parts, set max stitches, lock/finish, and enjoy a themed experience with install support and offline caching.",
|
||||
"main": "sw.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev-frontend": "node live-server.js",
|
||||
"dev": "cd server && npm install && cd .. && concurrently \"npm:dev-frontend\" \"cd server && npm run dev\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"live-server": "^1.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
138
server/package-lock.json
generated
138
server/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.12.0",
|
||||
"sharp": "^0.33.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
@ -2287,6 +2288,95 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@ -2300,6 +2390,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@ -2854,6 +2983,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
||||
@ -7,6 +7,7 @@ const multer = require('multer');
|
||||
const sharp = require('sharp');
|
||||
const {
|
||||
initDb,
|
||||
withClient,
|
||||
createUser,
|
||||
verifyUser,
|
||||
createSession,
|
||||
@ -36,7 +37,7 @@ const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '15mb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Init DB
|
||||
initDb().catch((err) => {
|
||||
@ -195,37 +196,41 @@ app.get('/share/:token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
// Upload route: save to /uploads, return URL (Optimized with sharp, fallback to raw)
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); // Bumped limit slightly
|
||||
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'File required' });
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user