feat: rebuild Catalog with collapsible tree + product table + photo upload

- Left panel: collapsible tree (Country → City → District → Category → Subcategory)
  - Quick-add buttons: + City, + District, + Category, + Subcategory
  - Delete buttons with confirmation on all nodes
  - Product count badges on each node
  - Click node to filter right panel
- Right panel: Product table with Photo, Name, Category, Subcategory, Price, Stock
  - Edit (✎) and Delete (✕) buttons per row
  - Add Product modal with all fields
- Product edit form: name, price, stock, description, category, subcategory (JS filtered),
  photo_url/hidden_photo_url (URL or file upload), hidden_coordinates, hidden_description, private_data
- Multer file upload for photos stored in /uploads/
- Routes: add-city, add-district, product CRUD with photo upload
- Product JSON API for modal editing
- Responsive grid: tree (320px) + table (1fr)
This commit is contained in:
NW
2026-06-22 21:42:56 +01:00
parent c7bf3f132c
commit 935c6df1dc
9 changed files with 374 additions and 316 deletions

View File

@@ -14,6 +14,7 @@ services:
- .env
volumes:
- ./db:/app/db/ # Синхронизация базы данных (persistence)
- ./uploads:/app/uploads/ # Uploaded product photos
- ./wg/start.sh:/app/start.sh # Монтируем start.sh (генерирует wg0.conf из env)
cap_add: # Минимальные привилегии, необходимые только для WireGuard
- NET_ADMIN

86
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"ethereumjs-util": "^7.1.5",
"express": "^4.21.0",
"hdkey": "^2.1.0",
"multer": "^2.2.0",
"node-telegram-bot-api": "^0.64.0",
"pino": "^8.21.0",
"pino-pretty": "^13.1.3",
@@ -275,6 +276,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -887,11 +894,28 @@
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
"integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buffer-xor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
"integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1096,6 +1120,35 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -3179,6 +3232,25 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/napi-build-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
@@ -4500,6 +4572,14 @@
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/streamx": {
"version": "2.20.2",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.2.tgz",
@@ -4898,6 +4978,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typeforce": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz",

View File

@@ -10,16 +10,17 @@
"archiver": "^7.0.1",
"axios": "^1.7.7",
"better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.6",
"express": "^4.21.0",
"bip39": "^3.1.0",
"bitcoinjs-lib": "^6.1.6",
"cookie-parser": "^1.4.6",
"csv-writer": "^1.6.0",
"decompress": "^4.2.1",
"dotenv": "^16.3.1",
"ecpair": "^2.1.0",
"ethereumjs-util": "^7.1.5",
"express": "^4.21.0",
"hdkey": "^2.1.0",
"multer": "^2.2.0",
"node-telegram-bot-api": "^0.64.0",
"pino": "^8.21.0",
"pino-pretty": "^13.1.3",

View File

@@ -230,23 +230,48 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
.seed-card h2 { margin-bottom: 0.5rem; }
.seed-card p { margin-bottom: 1rem; color: var(--muted); }
.catalog-grid {
.catalog-layout {
display: grid;
grid-template-columns: 1fr 360px;
grid-template-columns: 320px 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.catalog-grid { grid-template-columns: 1fr; }
.catalog-layout { grid-template-columns: 1fr; }
}
.catalog-tree h2 { margin-bottom: 0.75rem; }
.catalog-forms { position: sticky; top: 1rem; }
.catalog-main { min-width: 0; }
.tree-node { margin-left: 0; }
.tree-location > .tree-children { margin-left: 0; }
.tree-toggle {
cursor: pointer;
user-select: none;
padding: 0.4rem 0;
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
.tree-toggle:hover { background: #f0f4ff; }
.tree-toggle .arrow {
display: inline-block;
font-size: 0.7rem;
transition: transform 0.15s;
width: 12px;
text-align: center;
}
.tree-toggle .arrow.open { transform: rotate(90deg); }
.tree-children { display: none; }
.tree-children.open { display: block; }
.tree-children {
margin-left: 1.5rem;
@@ -254,60 +279,40 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
padding-left: 0.75rem;
}
.tree-label {
.tree-count {
font-size: 0.75rem;
color: #888;
margin-left: 0.3rem;
}
.tree-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0;
flex-wrap: wrap;
gap: 0.25rem;
}
.tree-label strong { font-size: 0.95rem; }
.tree-icon { font-size: 1rem; }
.tree-meta {
font-size: 0.78rem;
color: var(--muted);
margin-left: 0.25rem;
}
.tree-empty {
font-size: 0.85rem;
color: var(--muted);
margin: 0.3rem 0;
padding-left: 1.5rem;
}
.tree-product {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0;
margin-left: 1.5rem;
border-left: none;
}
.tree-product .tree-name { font-weight: 500; font-size: 0.9rem; }
.tree-add {
margin: 0.3rem 0 0.3rem 1.5rem;
}
.tree-location {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.tree-location > .tree-children { margin-left: 0; border-left: none; padding-left: 0; }
.tree-node:not(.tree-location) .tree-children {
margin-left: 1rem;
border-left: 2px solid var(--border);
padding-left: 0.5rem;
.modal-content {
background: var(--card);
border-radius: var(--radius);
padding: 1.5rem;
max-width: 560px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.form-row {
@@ -323,5 +328,5 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
.stats-grid { grid-template-columns: 1fr 1fr; }
table { font-size: 0.8rem; }
th, td { padding: 0.4rem; }
.catalog-grid { grid-template-columns: 1fr; }
.catalog-layout { grid-template-columns: 1fr; }
}

View File

@@ -5,109 +5,91 @@ import { renderCatalog } from '../views/catalog.js';
const router = Router();
router.get('/', async (req, res) => {
const msg = req.query.msg || '';
const msgType = req.query.msg_type || 'info';
const [locations, categories, subcategories, products] = await Promise.all([
db.allAsync(`SELECT l.*,
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
FROM locations l ORDER BY l.country, l.city, l.district`),
db.allAsync(`SELECT c.*, l.country, l.city, l.district
FROM categories c LEFT JOIN locations l ON c.location_id = l.id ORDER BY c.id`),
db.allAsync(`SELECT s.*, c.name as category_name
FROM subcategories s LEFT JOIN categories c ON s.category_id = c.id ORDER BY s.id`),
db.allAsync(`SELECT p.*, c.name as category_name, s.name as subcategory_name, l.city
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN subcategories s ON p.subcategory_id = s.id
LEFT JOIN locations l ON p.location_id = l.id
ORDER BY p.id DESC LIMIT 200`),
const { loc, cat, sub, msg, msg_type } = req.query;
const [locations, categories, subcategories] = await Promise.all([
db.allAsync('SELECT * FROM locations ORDER BY country, city, district'),
db.allAsync(`SELECT c.*, (SELECT COUNT(*) FROM products WHERE category_id=c.id) as pc,
(SELECT COUNT(*) FROM subcategories WHERE category_id=c.id) as sc FROM categories c`),
db.allAsync(`SELECT s.*, (SELECT COUNT(*) FROM products WHERE subcategory_id=s.id) as pc
FROM subcategories s`)
]);
res.send(renderCatalog(locations, categories, subcategories, products, msg, msgType));
let psql = `SELECT p.*, c.name as cn, s.name as sn FROM products p
LEFT JOIN categories c ON p.category_id=c.id LEFT JOIN subcategories s ON p.subcategory_id=s.id WHERE 1=1`;
const params = [];
if (loc) { psql += ' AND p.location_id=?'; params.push(loc); }
if (cat) { psql += ' AND p.category_id=?'; params.push(cat); }
if (sub) { psql += ' AND p.subcategory_id=?'; params.push(sub); }
const products = await db.allAsync(psql + ' ORDER BY p.id DESC LIMIT 200', params);
const cl = {}; for (const c of categories) (cl[c.location_id]??=[]).push(c);
const sc = {}; for (const s of subcategories) (sc[s.category_id]??=[]).push(s);
const tree = {};
for (const l of locations) {
(tree[l.country]??={cities:{}}).cities[l.city]??={districts:{}};
tree[l.country].cities[l.city].districts[l.district] = {
id: l.id, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]}))
};
}
res.send(renderCatalog(tree, products, { loc, cat, sub }, categories, subcategories, msg||'', msg_type||'info'));
});
router.post('/locations', async (req, res) => {
const { country, city, district } = req.body;
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
try {
await db.runAsync('INSERT INTO locations (country, city, district) VALUES (?, ?, ?)',
[country.trim(), city.trim(), (district || '').trim()]);
res.redirect('/catalog?msg=Location+added&msg_type=success');
} catch (e) {
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
}
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), (district||'').trim()]);
res.redirect('/catalog?msg=Location+added&msg_type=success');
});
router.post('/locations/:id/delete', async (req, res) => {
const cnt = await db.getAsync('SELECT COUNT(*) as c FROM categories WHERE location_id = ?', [req.params.id]);
if (cnt && cnt.c > 0) return res.redirect('/catalog?msg=Has+categories&msg_type=error');
await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error');
await db.runAsync('DELETE FROM locations WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Location+deleted&msg_type=success');
});
router.post('/locations/add-city', async (req, res) => {
const { country, city } = req.body;
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), '']);
res.redirect('/catalog?msg=City+added&msg_type=success');
});
router.post('/locations/add-district', async (req, res) => {
const { country, city, district } = req.body;
if (!country || !city || !district) return res.redirect('/catalog?msg=All+fields+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), district.trim()]);
res.redirect('/catalog?msg=District+added&msg_type=success');
});
router.post('/categories', async (req, res) => {
const { name, location_id } = req.body;
if (!name) return res.redirect('/catalog?msg=Name+required&msg_type=error');
try {
await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name.trim(), location_id || null]);
res.redirect('/catalog?msg=Category+added&msg_type=success');
} catch (e) {
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
}
});
router.post('/categories/:id/update', async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?',
[name.trim(), location_id || null, req.params.id]);
res.redirect('/catalog?msg=Category+updated&msg_type=success');
if (!name || !location_id) return res.redirect('/catalog?msg=Name+and+location+required&msg_type=error');
await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
res.redirect('/catalog?msg=Category+added&msg_type=success');
});
router.post('/categories/:id/delete', async (req, res) => {
const cnt = await db.getAsync('SELECT COUNT(*) as c FROM products WHERE category_id = ?', [req.params.id]);
if (cnt && cnt.c > 0) return res.redirect('/catalog?msg=Has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE category_id = ?', [req.params.id]);
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE category_id=?', [req.params.id]);
await db.runAsync('DELETE FROM categories WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Category+deleted&msg_type=success');
});
router.post('/categories/:id/subcategories', async (req, res) => {
const { name } = req.body;
if (!name) return res.redirect('/catalog?msg=Name+required&msg_type=error');
try {
await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)', [req.params.id, name.trim()]);
res.redirect('/catalog?msg=Subcategory+added&msg_type=success');
} catch (e) {
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
}
await db.runAsync('INSERT INTO subcategories (category_id,name) VALUES (?,?)', [req.params.id, name.trim()]);
res.redirect('/catalog?msg=Subcategory+added&msg_type=success');
});
router.post('/subcategories/:id/delete', async (req, res) => {
const cnt = await db.getAsync('SELECT COUNT(*) as c FROM products WHERE subcategory_id = ?', [req.params.id]);
if (cnt && cnt.c > 0) return res.redirect('/catalog?msg=Has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Subcategory+deleted&msg_type=success');
});
router.post('/products', async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
try {
const locRow = await db.getAsync('SELECT location_id FROM categories WHERE id = ?', [category_id]);
await db.runAsync(
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock) || 0,
description || '', photo_url || '', category_id, subcategory_id || null, locRow?.location_id || null]);
res.redirect('/catalog?msg=Product+added&msg_type=success');
} catch (e) {
res.redirect('/catalog?msg=' + encodeURIComponent(e.message) + '&msg_type=error');
}
});
router.post('/products/:id/delete', async (req, res) => {
await db.runAsync('DELETE FROM products WHERE id = ?', [req.params.id]);
res.redirect('/catalog?msg=Product+deleted&msg_type=success');
});
export default router;
export default router;

View File

@@ -0,0 +1,56 @@
import { Router } from 'express';
import multer from 'multer';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import db from '../../config/database.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const uploadsDir = join(__dirname, '..', '..', '..', 'uploads');
const storage = multer.diskStorage({
destination: uploadsDir,
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
});
const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
const router = Router();
router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locRow = await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]);
await db.runAsync(`INSERT INTO products (name,price,quantity_in_stock,description,photo_url,hidden_photo_url,
hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock)||0, description||'', pu, hu,
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locRow?.location_id||null]);
res.redirect('/catalog?msg=Product+added&msg_type=success');
});
router.post('/products/:id/edit', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locRow = await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]);
await db.runAsync(`UPDATE products SET name=?,price=?,quantity_in_stock=?,description=?,photo_url=?,hidden_photo_url=?,
hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=? WHERE id=?`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock)||0, description||'', pu, hu,
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locRow?.location_id||null, req.params.id]);
res.redirect('/catalog?msg=Product+updated&msg_type=success');
});
router.post('/products/:id/delete', async (req, res) => {
await db.runAsync('DELETE FROM products WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Product+deleted&msg_type=success');
});
router.get('/products/:id/json', async (req, res) => {
const p = await db.getAsync('SELECT * FROM products WHERE id=?', [req.params.id]);
if (!p) return res.status(404).json({ error: 'Not found' });
res.json(p);
});
export default router;

View File

@@ -6,6 +6,7 @@ import logger from '../utils/logger.js';
import { requireAuth, handleLogin, handleLogout, renderLogin } from './auth.js';
import dashboardRouter from './routes/dashboard.js';
import catalogRouter from './routes/catalog.js';
import catalogProductsRouter from './routes/catalogProducts.js';
import usersRouter from './routes/users.js';
import productsRouter from './routes/products.js';
import walletsRouter from './routes/wallets.js';
@@ -23,6 +24,7 @@ const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use('/admin/style.css', express.static(join(__dirname, 'public', 'style.css')));
app.use('/uploads', express.static(join(__dirname, '..', '..', 'uploads')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
@@ -39,6 +41,7 @@ app.use(requireAuth);
app.use('/', dashboardRouter);
app.use('/catalog', catalogRouter);
app.use('/catalog', catalogProductsRouter);
app.use('/users', usersRouter);
app.use('/products', productsRouter);
app.use('/wallets', walletsRouter);

View File

@@ -1,200 +1,96 @@
import { layout, flash } from './layout.js';
import { renderProductEditForm } from './catalogProduct.js';
export function renderCatalog(locations, categories, subcategories, products, msg, msgType) {
const subByCat = {};
for (const s of subcategories) {
if (!subByCat[s.category_id]) subByCat[s.category_id] = [];
subByCat[s.category_id].push(s);
}
const prodsBySubcat = {};
const prodsByCat = {};
for (const p of products) {
const key = p.subcategory_id || p.category_id;
if (p.subcategory_id) {
if (!prodsBySubcat[p.subcategory_id]) prodsBySubcat[p.subcategory_id] = [];
prodsBySubcat[p.subcategory_id].push(p);
}
if (!prodsByCat[p.category_id]) prodsByCat[p.category_id] = [];
prodsByCat[p.category_id].push(p);
}
export function renderCatalog(tree, products, filter, categories, subcategories, msg, msgType) {
const { loc, cat, sub } = filter;
const catOptions = categories.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
const subcatJson = JSON.stringify(subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id })));
const addFormHtml = renderProductEditForm('/catalog/products', catOptions, subcatJson)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
const editFormHtml = renderProductEditForm('/catalog/products/__ID__/edit', catOptions, subcatJson)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
const catsByLoc = {};
for (const c of categories) {
if (!catsByLoc[c.location_id]) catsByLoc[c.location_id] = [];
catsByLoc[c.location_id].push(c);
}
const locOptions = locations.map(l =>
`<option value="${l.id}">${esc(l.country)} / ${esc(l.city)}${l.district ? ' / ' + esc(l.district) : ''}</option>`
).join('');
const catOptions = categories.map(c =>
`<option value="${c.id}">${esc(c.name)} (${esc(c.city || 'No location')})</option>`
).join('');
let treeHtml = '';
if (locations.length === 0) {
treeHtml = '<p class="muted">No locations yet. Add one above.</p>';
}
for (const loc of locations) {
const locCats = catsByLoc[loc.id] || [];
const catCount = locCats.length;
const prodCount = loc.product_count || 0;
let catHtml = '';
if (catCount === 0) {
catHtml = '<p class="muted tree-empty">No categories</p>';
}
for (const cat of locCats) {
const subs = subByCat[cat.id] || [];
const catProds = prodsByCat[cat.id] || [];
let subHtml = '';
for (const sub of subs) {
const subProds = prodsBySubcat[sub.id] || [];
let prodHtml = '';
for (const p of subProds) {
prodHtml += `<div class="tree-item tree-product">
<span class="tree-name">${esc(p.name)}</span>
<span class="tree-meta">$${(p.price || 0).toFixed(2)} · ${p.quantity_in_stock || 0} in stock</span>
<form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete product?')">
<button class="btn-sm btn-danger">✕</button>
</form>
</div>`;
let treeHtml = '<div class="tree-node"><div class="tree-toggle" data-all="1"><span class="arrow">▶</span> <strong>All Products</strong><span class="tree-count">(' + products.length + ')</span></div></div>';
for (const [country, cdata] of Object.entries(tree)) {
let countryCount = 0, cityHtml = '';
for (const [city, ddata] of Object.entries(cdata.cities)) {
let cityCount = 0, districtHtml = '';
for (const [district, ldata] of Object.entries(ddata.districts)) {
let districtCount = 0, catHtml = '';
for (const c of ldata.cats) {
const catCount = (c.pc||0) + (c.subs||[]).reduce((a,s)=>a+(s.pc||0),0);
districtCount += catCount;
let subHtml = '';
for (const s of (c.subs||[])) {
subHtml += `<div class="tree-node"><div class="tree-toggle" data-sub="${s.id}"><span class="arrow">▶</span> ${esc(s.name)}<span class="tree-count">(${s.pc||0})</span><span class="tree-actions"><form method="POST" action="/catalog/subcategories/${s.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children"><form method="POST" action="/catalog/categories/${c.id}/subcategories" class="inline-form tree-add"><input name="name" placeholder="+ Subcategory" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
}
catHtml += `<div class="tree-node"><div class="tree-toggle" data-cat="${c.id}"><span class="arrow">▶</span> ${esc(c.name)}<span class="tree-count">(${catCount})</span><span class="tree-actions"><form method="POST" action="/catalog/categories/${c.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children">${subHtml}<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="inline-form tree-add"><input name="name" placeholder="+ Subcategory" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
}
subHtml += `<div class="tree-node">
<div class="tree-label">
<span class="tree-icon">📁</span>
<span>${esc(sub.name)}</span>
<span class="tree-meta">${subProds.length} products</span>
<form method="POST" action="/catalog/subcategories/${sub.id}/delete" style="display:inline" onsubmit="return confirm('Delete subcategory?')">
<button class="btn-sm btn-danger">✕</button>
</form>
</div>
${prodHtml}
</div>`;
districtHtml += `<div class="tree-node"><div class="tree-toggle" data-loc="${ldata.id}"><span class="arrow">▶</span> ${esc(district)}<span class="tree-count">(${districtCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/${ldata.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children">${catHtml}<form method="POST" action="/catalog/categories" class="inline-form tree-add"><input type="hidden" name="location_id" value="${ldata.id}"><input name="name" placeholder="+ Category" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
cityCount += districtCount;
}
let directProds = '';
const directP = catProds.filter(p => !p.subcategory_id);
for (const p of directP) {
directProds += `<div class="tree-item tree-product">
<span class="tree-name">${esc(p.name)}</span>
<span class="tree-meta">$${(p.price || 0).toFixed(2)} · ${p.quantity_in_stock || 0} in stock</span>
<form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete product?')">
<button class="btn-sm btn-danger">✕</button>
</form>
</div>`;
}
catHtml += `<div class="tree-node">
<div class="tree-label">
<span class="tree-icon">📂</span>
<span>${esc(cat.name)}</span>
<span class="tree-meta">${catProds.length} products · ${subs.length} subcats</span>
<form method="POST" action="/catalog/categories/${cat.id}/delete" style="display:inline" onsubmit="return confirm('Delete category and all subcategories?')">
<button class="btn-sm btn-danger">✕</button>
</form>
</div>
<div class="tree-children">
${subHtml}
${directProds}
<form method="POST" action="/catalog/categories/${cat.id}/subcategories" class="inline-form tree-add">
<input name="name" placeholder="+ Subcategory" required size="12">
<button class="btn-sm">Add</button>
</form>
</div>
</div>`;
cityHtml += `<div class="tree-node"><div class="tree-toggle" data-city="${city}" data-country="${esc(country)}"><span class="arrow">▶</span> ${esc(city)}<span class="tree-count">(${cityCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/add-district" class="inline-form tree-add"><input type="hidden" name="country" value="${esc(country)}"><input type="hidden" name="city" value="${esc(city)}"><input name="district" placeholder="+ District" required size="12"><button class="btn-sm">Add</button></form></span></div><div class="tree-children">${districtHtml}</div></div>`;
countryCount += cityCount;
}
treeHtml += `<div class="tree-node"><div class="tree-toggle" data-country="${esc(country)}"><span class="arrow">▶</span> <strong>${esc(country)}</strong><span class="tree-count">(${countryCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/add-city" class="inline-form tree-add"><input type="hidden" name="country" value="${esc(country)}"><input name="city" placeholder="+ City" required size="10"><button class="btn-sm">Add</button></form></span></div><div class="tree-children">${cityHtml}</div></div>`;
}
treeHtml += `<div class="tree-node tree-location">
<div class="tree-label">
<span class="tree-icon">🌍</span>
<span><strong>${esc(loc.country)}</strong> · ${esc(loc.city)}${loc.district ? ' · ' + esc(loc.district) : ''}</span>
<span class="tree-meta">${catCount} categories · ${prodCount} products</span>
<form method="POST" action="/catalog/locations/${loc.id}/delete" style="display:inline" onsubmit="return confirm('Delete location?')">
<button class="btn-sm btn-danger">✕</button>
</form>
</div>
<div class="tree-children">${catHtml}</div>
</div>`;
let tableHtml = '<p class="muted">No products found.</p>';
if (products.length) {
tableHtml = '<table><thead><tr><th>ID</th><th>Photo</th><th>Name</th><th>Category</th><th>Subcategory</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead><tbody>';
for (const p of products) {
const img = p.photo_url ? `<img src="${esc(p.photo_url)}" width="40" height="40" style="object-fit:cover;border-radius:4px">` : '<span class="muted">—</span>';
tableHtml += `<tr><td>${p.id}</td><td>${img}</td><td>${esc(p.name)}</td><td>${esc(p.cn||'')}</td><td>${esc(p.sn||'')}</td><td>$${(p.price||0).toFixed(2)}</td><td>${p.quantity_in_stock||0}</td><td><button class="btn-sm" onclick="openEdit(${p.id})">✎</button><form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></td></tr>`;
}
tableHtml += '</tbody></table>';
}
const content = `${flash(msg, msgType)}
<div class="catalog-grid">
<div class="catalog-tree">
<h2>Catalog Tree</h2>
${treeHtml}
</div>
<div class="catalog-forms">
<details class="form-section" open>
<summary>+ Add Location</summary>
<form method="POST" action="/catalog/locations" class="inline-form">
<input name="country" placeholder="Country" required>
<input name="city" placeholder="City" required>
<input name="district" placeholder="District">
<button type="submit" class="btn btn-sm">Add</button>
</form>
</details>
<details class="form-section">
<summary>+ Add Category</summary>
<form method="POST" action="/catalog/categories" class="inline-form">
<input name="name" placeholder="Category name" required>
<select name="location_id" required>
<option value="">-- Location --</option>
${locOptions}
</select>
<button type="submit" class="btn btn-sm">Add</button>
</form>
</details>
<details class="form-section">
<summary>+ Add Product</summary>
<form method="POST" action="/catalog/products" class="form">
<input name="name" placeholder="Product name" required>
<div class="form-row">
<input name="price" type="number" step="0.01" placeholder="Price" required>
<input name="quantity_in_stock" type="number" placeholder="Stock" value="0">
</div>
<input name="description" placeholder="Description">
<input name="photo_url" placeholder="Photo URL">
<select name="category_id" required id="cat-select-catalog">
<option value="">-- Category --</option>
${catOptions}
</select>
<select name="subcategory_id" id="subcat-select-catalog">
<option value="">-- Subcategory (optional) --</option>
</select>
<button type="submit" class="btn">Add Product</button>
</form>
</details>
</div>
</div>`;
const subcatData = JSON.stringify(
subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id }))
);
return layout('Catalog', content, 'catalog') + `
<div class="catalog-layout">
<div class="catalog-tree">
<h2>Catalog</h2>
<details class="form-section"><summary>+ Add Location</summary>
<form method="POST" action="/catalog/locations" class="inline-form">
<input name="country" placeholder="Country" required><input name="city" placeholder="City" required><input name="district" placeholder="District"><button class="btn-sm">Add</button>
</form>
</details>
${treeHtml}
</div>
<div class="catalog-main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h2>Products</h2>
<button class="btn" onclick="openAdd()">+ Add Product</button>
</div>
${tableHtml}
</div>
</div>
<div id="product-modal" class="modal" style="display:none"><div class="modal-content" id="modal-body"></div></div>
<script>
const allSubcats = ${subcatData};
const catSel = document.getElementById('cat-select-catalog');
const subSel = document.getElementById('subcat-select-catalog');
if (catSel && subSel) {
catSel.addEventListener('change', () => {
const catId = catSel.value;
subSel.innerHTML = '<option value="">-- Subcategory (optional) --</option>';
allSubcats.forEach(s => {
if (s.category_id == catId) {
const o = document.createElement('option');
o.value = s.id; o.textContent = s.name;
subSel.appendChild(o);
}
});
});
}
const subcats = ${subcatJson};
const addFormTpl = \`${addFormHtml}\`;
const editFormTpl = \`${editFormHtml}\`;
document.querySelectorAll('.tree-toggle').forEach(el=>{el.addEventListener('click',()=>{
const ch=el.nextElementSibling; if(!ch||!ch.classList.contains('tree-children')) return;
ch.classList.toggle('open'); el.querySelector('.arrow').classList.toggle('open');
const loc=el.dataset.loc, cat=el.dataset.cat, sub=el.dataset.sub, all=el.dataset.all;
if(loc||cat||sub||all){ let u='/catalog?'; if(loc) u+='loc='+loc; if(cat) u+='cat='+cat; if(sub) u+='sub='+sub; location.href=u; }
})});
function openAdd(){ document.getElementById('modal-body').innerHTML=addFormTpl; document.getElementById('product-modal').style.display='flex'; }
async function openEdit(id){ const r=await fetch('/catalog/products/'+id+'/json'); const p=await r.json();
document.getElementById('modal-body').innerHTML=editFormTpl.replace('/__ID__/','/'+p.id+'/'); fillEditForm(p); document.getElementById('product-modal').style.display='flex'; }
function fillEditForm(p){ const f=document.getElementById('product-modal').querySelector('form'); if(!f)return;
f.querySelector('[name=name]').value=p.name||''; f.querySelector('[name=price]').value=p.price||'';
f.querySelector('[name=quantity_in_stock]').value=p.quantity_in_stock||''; f.querySelector('[name=description]').value=p.description||'';
f.querySelector('[name=photo_url]').value=p.photo_url||''; f.querySelector('[name=hidden_photo_url]').value=p.hidden_photo_url||'';
f.querySelector('[name=hidden_coordinates]').value=p.hidden_coordinates||''; f.querySelector('[name=hidden_description]').value=p.hidden_description||'';
f.querySelector('[name=private_data]').value=p.private_data||''; f.querySelector('[name=category_id]').value=p.category_id||'';
updateSubcats(p.category_id,p.subcategory_id); }
function updateSubcats(catId,selSub){ const ss=document.getElementById('product-modal').querySelector('[name=subcategory_id]'); if(!ss)return;
ss.innerHTML='<option value="">-- Subcategory --</option>'; subcats.forEach(s=>{if(s.category_id==catId){const o=document.createElement('option');o.value=s.id;o.textContent=s.name;if(s.id==selSub)o.selected=true;ss.appendChild(o)}}); }
document.getElementById('product-modal').addEventListener('click',e=>{if(e.target===document.getElementById('product-modal'))document.getElementById('product-modal').style.display='none'});
document.addEventListener('change',e=>{if(e.target.name==='category_id'&&e.target.closest('#product-modal'))updateSubcats(e.target.value);});
</script>`;
return layout('Catalog', content, 'catalog');
}
function esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function esc(str) { return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

View File

@@ -0,0 +1,28 @@
export function renderProductEditForm(action, catOptions, subcatJson) {
const isEdit = action.includes('/edit');
const title = isEdit ? 'Edit Product' : 'Add Product';
return `<h2>${title}</h2>
<form method="POST" action="${action}" enctype="multipart/form-data" class="form">
<label>Name <input name="name" required></label>
<div class="form-row">
<label>Price <input name="price" type="number" step="0.01" required></label>
<label>Stock <input name="quantity_in_stock" type="number" value="0"></label>
</div>
<label>Description <textarea name="description"></textarea></label>
<label>Category <select name="category_id" required onchange="updateSubcats(this.value)">
<option value="">-- Select --</option>${catOptions}
</select></label>
<label>Subcategory <select name="subcategory_id"><option value="">-- Subcategory --</option></select></label>
<label>Photo URL <input name="photo_url" placeholder="https://... or upload below"></label>
<label>Photo File <input type="file" name="photo_file" accept="image/*"></label>
<label>Hidden Photo URL <input name="hidden_photo_url" placeholder="https://... or upload below"></label>
<label>Hidden Photo File <input type="file" name="hidden_photo_file" accept="image/*"></label>
<label>Hidden Coordinates <input name="hidden_coordinates" placeholder="lat,lng or address"></label>
<label>Hidden Description <textarea name="hidden_description"></textarea></label>
<label>Private Data <textarea name="private_data"></textarea></label>
<div style="display:flex;gap:0.5rem">
<button type="submit" class="btn">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('product-modal').style.display='none'">Cancel</button>
</div>
</form>`;
}