feat: add web UI for managing products
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
This commit is contained in:
+43
@@ -0,0 +1,43 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const basicAuth = require('express-basic-auth');
|
||||||
|
const { renderIndex } = require('./views');
|
||||||
|
|
||||||
|
function createApp(deps) {
|
||||||
|
const {
|
||||||
|
listProducts, addProduct, deleteProduct,
|
||||||
|
checkAllProducts, authUser, authPass,
|
||||||
|
} = deps;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(basicAuth({ users: { [authUser]: authPass }, challenge: true }));
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
const products = listProducts();
|
||||||
|
const message = req.query.checked ? 'Controle uitgevoerd.' : undefined;
|
||||||
|
res.send(renderIndex({ products, message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/products', (req, res) => {
|
||||||
|
const { name, url } = req.body;
|
||||||
|
if (name && url) {
|
||||||
|
addProduct({ name, url });
|
||||||
|
}
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/products/:id/delete', (req, res) => {
|
||||||
|
deleteProduct(Number(req.params.id));
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/check-now', async (req, res) => {
|
||||||
|
const products = listProducts();
|
||||||
|
await checkAllProducts(products);
|
||||||
|
res.redirect('/?checked=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createApp };
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price) {
|
||||||
|
return price == null ? '—' : `€${Number(price).toFixed(2).replace('.', ',')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProductRow(product) {
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(product.name)}</td>
|
||||||
|
<td><a href="${escapeHtml(product.url)}" target="_blank" rel="noopener">bekijken</a></td>
|
||||||
|
<td>${formatPrice(product.last_price)}</td>
|
||||||
|
<td>${product.last_checked_at ? escapeHtml(product.last_checked_at) : '—'}</td>
|
||||||
|
<td>${escapeHtml(product.last_check_status || '—')}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/products/${product.id}/delete" onsubmit="return confirm('Product verwijderen?');">
|
||||||
|
<button type="submit">Verwijder</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIndex({ products, message }) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Korting — prijstracker</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #ddd; }
|
||||||
|
.message { background: #eef; padding: 0.5rem 1rem; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Korting — prijstracker</h1>
|
||||||
|
${message ? `<p class="message">${escapeHtml(message)}</p>` : ''}
|
||||||
|
<form method="post" action="/products">
|
||||||
|
<input name="name" placeholder="Naam" required>
|
||||||
|
<input name="url" type="url" placeholder="https://..." required>
|
||||||
|
<button type="submit">Toevoegen</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/check-now">
|
||||||
|
<button type="submit">Nu controleren</button>
|
||||||
|
</form>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Naam</th><th>URL</th><th>Prijs</th><th>Laatst gecontroleerd</th><th>Status</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${products.map(renderProductRow).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderIndex, escapeHtml, formatPrice };
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { createApp } = require('../src/app');
|
||||||
|
|
||||||
|
function makeDeps(overrides = {}) {
|
||||||
|
let products = [];
|
||||||
|
let nextId = 1;
|
||||||
|
return {
|
||||||
|
listProducts: () => products,
|
||||||
|
addProduct: ({ name, url }) => {
|
||||||
|
const product = { id: nextId++, name, url, last_price: null, last_checked_at: null, last_check_status: null };
|
||||||
|
products.push(product);
|
||||||
|
return product;
|
||||||
|
},
|
||||||
|
deleteProduct: (id) => { products = products.filter((p) => p.id !== id); },
|
||||||
|
checkAllProducts: async () => [],
|
||||||
|
authUser: 'admin',
|
||||||
|
authPass: 'secret',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejects requests without basic auth credentials', async () => {
|
||||||
|
const app = createApp(makeDeps());
|
||||||
|
const res = await request(app).get('/');
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists products for authenticated requests', async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
deps.addProduct({ name: 'Voorbeeld', url: 'https://example.com' });
|
||||||
|
const app = createApp(deps);
|
||||||
|
const res = await request(app).get('/').auth('admin', 'secret');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(res.text, /Voorbeeld/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding a product redirects to the list', async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const app = createApp(deps);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/products')
|
||||||
|
.auth('admin', 'secret')
|
||||||
|
.type('form')
|
||||||
|
.send({ name: 'Nieuw product', url: 'https://shop.example/p' });
|
||||||
|
assert.equal(res.status, 302);
|
||||||
|
assert.equal(res.headers.location, '/');
|
||||||
|
const list = await request(app).get('/').auth('admin', 'secret');
|
||||||
|
assert.match(list.text, /Nieuw product/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a product removes it from the list', async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const product = deps.addProduct({ name: 'Weg ermee', url: 'https://shop.example/p2' });
|
||||||
|
const app = createApp(deps);
|
||||||
|
const res = await request(app).post(`/products/${product.id}/delete`).auth('admin', 'secret');
|
||||||
|
assert.equal(res.status, 302);
|
||||||
|
const list = await request(app).get('/').auth('admin', 'secret');
|
||||||
|
assert.doesNotMatch(list.text, /Weg ermee/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check-now triggers checkAllProducts and redirects with confirmation', async () => {
|
||||||
|
let called = false;
|
||||||
|
const deps = makeDeps({ checkAllProducts: async () => { called = true; return []; } });
|
||||||
|
const app = createApp(deps);
|
||||||
|
const res = await request(app).post('/check-now').auth('admin', 'secret');
|
||||||
|
assert.equal(res.status, 302);
|
||||||
|
assert.equal(res.headers.location, '/?checked=1');
|
||||||
|
assert.equal(called, true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user