Urlaub 2026: Vergleich, Karte, 3 Empfehlungen, Sterne-Voting (Node+Voting-API)
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// Urlaubsvergleich 2026 – Familie Heidrich
|
||||
// Zero-dependency Node server: serves static page + voting API with file persistence.
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
const PUBLIC = path.join(__dirname, 'public');
|
||||
|
||||
// Persistence: prefer mounted /data volume, fall back to local ./data
|
||||
function pickDataDir() {
|
||||
const candidates = [process.env.DATA_DIR, '/data', path.join(__dirname, 'data')].filter(Boolean);
|
||||
for (const dir of candidates) {
|
||||
try { fs.mkdirSync(dir, { recursive: true }); fs.accessSync(dir, fs.constants.W_OK); return dir; }
|
||||
catch (e) { /* try next */ }
|
||||
}
|
||||
return __dirname;
|
||||
}
|
||||
const DATA_DIR = pickDataDir();
|
||||
const DATA_FILE = path.join(DATA_DIR, 'votes.json');
|
||||
|
||||
const VOTERS = ['till', 'lea', 'astrid'];
|
||||
|
||||
function loadState() {
|
||||
try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
|
||||
catch (e) { return { votes: {} }; }
|
||||
}
|
||||
function saveState(state) {
|
||||
try { fs.writeFileSync(DATA_FILE, JSON.stringify(state)); } catch (e) { console.error('save failed', e); }
|
||||
}
|
||||
let state = loadState();
|
||||
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml',
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json' };
|
||||
|
||||
function sendJSON(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let b = ''; req.on('data', c => { b += c; if (b.length > 1e5) req.destroy(); });
|
||||
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (e) { resolve({}); } });
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const p = url.pathname;
|
||||
|
||||
// ---- API ----
|
||||
if (p === '/api/state' && req.method === 'GET') {
|
||||
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
|
||||
}
|
||||
if (p === '/api/vote' && req.method === 'POST') {
|
||||
const { voter, option, stars } = await readBody(req);
|
||||
if (!VOTERS.includes(voter) || !option || typeof stars !== 'number' || stars < 0 || stars > 5) {
|
||||
return sendJSON(res, 400, { error: 'invalid' });
|
||||
}
|
||||
state.votes[voter] = state.votes[voter] || {};
|
||||
if (stars === 0) { delete state.votes[voter][option]; }
|
||||
else { state.votes[voter][option] = Math.round(stars); }
|
||||
saveState(state);
|
||||
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
|
||||
}
|
||||
if (p === '/api/reset' && req.method === 'POST') {
|
||||
state = { votes: {} }; saveState(state);
|
||||
return sendJSON(res, 200, { votes: state.votes, voters: VOTERS });
|
||||
}
|
||||
if (p === '/health') { return sendJSON(res, 200, { ok: true }); }
|
||||
|
||||
// ---- Static ----
|
||||
let file = p === '/' ? '/index.html' : decodeURIComponent(p);
|
||||
const full = path.join(PUBLIC, path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
|
||||
if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end('forbidden'); }
|
||||
fs.readFile(full, (err, data) => {
|
||||
if (err) {
|
||||
// SPA-ish fallback to index
|
||||
return fs.readFile(path.join(PUBLIC, 'index.html'), (e2, d2) => {
|
||||
if (e2) { res.writeHead(404); return res.end('not found'); }
|
||||
res.writeHead(200, { 'Content-Type': MIME['.html'] }); res.end(d2);
|
||||
});
|
||||
}
|
||||
const ext = path.extname(full).toLowerCase();
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log('Reise-Vergleich läuft auf Port ' + PORT + ' | Daten: ' + DATA_FILE));
|
||||
Reference in New Issue
Block a user