initial commit (2)

This commit is contained in:
2026-02-07 08:21:46 +01:00
parent a587ed69e6
commit dd6664c070
4 changed files with 509 additions and 8 deletions
-17
View File
@@ -1,17 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm ci --production
COPY server.js ./
COPY public/ ./public/
RUN mkdir -p /data
EXPOSE 3000
USER node
CMD ["node", "server.js"]
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Révfülöp · Nyaraló Naptár</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: #F5F0EB;
color: #3A332D;
min-height: 100vh;
padding: 20px 16px;
}
.container { max-width: 520px; margin: 0 auto; }
/* Header */
.header { text-align: center; margin-bottom: 28px; }
.header-sub { font-size: 11px; letter-spacing: 0.2em; color: #B0A89E; font-weight: 500; text-transform: uppercase; margin-bottom: 4px; }
.header h1 { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; color: #3A332D; line-height: 1.2; }
.header-line { width: 40px; height: 2px; background: #C17F59; margin: 10px auto 0; border-radius: 1px; }
/* Cards */
.card { background: #FFF; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
.card-title { font-size: 11px; font-weight: 600; color: #8B7E74; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.08em; }
/* Controls */
.controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.controls select, .controls input {
padding: 7px 10px; border-radius: 8px; border: 1px solid #E8E0D8;
background: #FDFBF9; font-size: 13px; font-family: 'DM Sans', sans-serif;
color: #3A332D; flex: 1; min-width: 100px;
}
.hint { font-size: 11px; color: #B0A89E; margin-top: 8px; }
/* Calendar */
.cal-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.cal-nav button {
border: none; background: #F5F0EB; border-radius: 8px; width: 32px; height: 32px;
cursor: pointer; font-size: 16px; color: #5C524A; display: flex; align-items: center; justify-content: center;
}
.cal-nav button:hover { background: #EAE3DB; }
.cal-month { font-size: 16px; font-weight: 600; color: #3A332D; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-day-header { text-align: center; padding: 6px 0; font-size: 11px; font-weight: 600; color: #8B7E74; letter-spacing: 0.05em; }
.cal-day {
position: relative; min-height: 48px; padding: 3px; border-radius: 6px;
background: #FDFBF9; cursor: pointer; border: 1px solid transparent;
transition: all 0.15s ease;
}
.cal-day:hover { background: #F0EAE3; }
.cal-day.empty { background: transparent; cursor: default; }
.cal-day.today { border: 2px solid #C17F59; }
.cal-day.in-selection { background: #D4C5B9; }
.cal-day-num { font-size: 12px; font-weight: 400; color: #5C524A; text-align: right; padding-right: 2px; display: block; }
.cal-day.today .cal-day-num { font-weight: 700; color: #C17F59; }
.cal-day-dots { display: flex; flex-wrap: wrap; gap: 2px; margin-top: 2px; }
.dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.dot.planned { opacity: 0.5; border: 1.5px dashed; background: transparent !important; }
/* Legend */
.legend { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 14px; padding: 8px 0; }
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: #5C524A; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-row { width: 100%; display: flex; justify-content: center; gap: 16px; margin-top: 2px; }
.legend-row span { font-size: 10px; color: #B0A89E; }
/* Booking list */
.booking-item {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
border-radius: 8px; background: #FDFBF9; margin-bottom: 6px;
}
.booking-color { width: 3px; height: 100%; min-height: 36px; border-radius: 2px; flex-shrink: 0; }
.booking-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.booking-info { flex: 1; min-width: 0; }
.booking-name { font-size: 13px; font-weight: 600; color: #3A332D; }
.booking-badge {
display: inline-block; margin-left: 8px; font-size: 10px; font-weight: 500;
padding: 1px 6px; border-radius: 10px;
}
.booking-badge.confirmed { background: #81B29A22; color: #5A9A78; }
.booking-badge.planned { background: #F2CC8F33; color: #C49A4A; }
.booking-dates { font-size: 11px; color: #8B7E74; margin-top: 2px; }
.booking-actions button {
border: none; background: none; cursor: pointer; font-size: 14px; padding: 4px; border-radius: 4px;
}
.booking-actions .toggle { color: #B0A89E; }
.booking-actions .delete { color: #D4756B; }
.empty-state { text-align: center; padding: 30px; color: #B0A89E; font-style: italic; }
/* Comments */
.comments-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; max-height: 300px; overflow-y: auto; }
.comment {
padding: 10px 14px; border-radius: 10px; background: #FDFBF9;
border-left: 3px solid #ccc;
}
.comment-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.comment-author { font-size: 12px; font-weight: 600; }
.comment-time { font-size: 10px; color: #B0A89E; }
.comment-text { font-size: 13px; color: #5C524A; line-height: 1.5; }
.comment-form { display: flex; gap: 8px; align-items: flex-end; }
.comment-form select { width: 100px; }
.comment-form input {
flex: 1; padding: 8px 12px; border-radius: 8px; border: 1px solid #E8E0D8;
background: #FFF; font-size: 13px; font-family: 'DM Sans', sans-serif;
color: #3A332D; outline: none;
}
.comment-form input:focus { border-color: #C17F59; }
.btn-send {
padding: 8px 16px; border-radius: 8px; border: none;
background: #C17F59; color: #FFF; font-size: 12px; font-weight: 600;
cursor: pointer; font-family: 'DM Sans', sans-serif; white-space: nowrap;
}
.btn-send:hover { background: #A96A4A; }
/* Login */
.login-overlay {
position: fixed; inset: 0; background: #F5F0EB;
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.login-box { text-align: center; }
.login-box input {
padding: 10px 16px; border-radius: 8px; border: 1px solid #E8E0D8;
font-size: 14px; font-family: 'DM Sans', sans-serif; width: 220px;
margin: 12px 0; text-align: center;
}
.login-box .btn-send { display: block; width: 220px; margin: 0 auto; padding: 10px; }
.login-error { font-size: 12px; color: #D4756B; margin-top: 8px; }
.footer { text-align: center; font-size: 10px; color: #C8BFB5; padding: 0 0 20px; }
</style>
</head>
<body>
<div id="login-screen" class="login-overlay" style="display:none;">
<div class="login-box">
<div class="header-sub">Révfülöp · Balaton</div>
<h1 style="font-family:'Playfair Display',serif;font-size:28px;font-weight:700;color:#3A332D;margin:4px 0 16px;">Nyaraló Naptár</h1>
<div class="header-line" style="margin:0 auto 20px;"></div>
<input type="password" id="login-password" placeholder="Jelszó" onkeydown="if(event.key==='Enter')doLogin()">
<button class="btn-send" onclick="doLogin()">Belépés</button>
<div id="login-error" class="login-error"></div>
</div>
</div>
<div id="app" class="container" style="display:none;">
<div class="header">
<div class="header-sub">Révfülöp · Balaton</div>
<h1>Nyaraló Naptár</h1>
<div class="header-line"></div>
</div>
<!-- New Booking Controls -->
<div class="card">
<div class="card-title">Új foglalás</div>
<div class="controls">
<select id="sel-member"></select>
<select id="sel-status">
<option value="planned">○ Tervezett</option>
<option value="confirmed">✓ Megerősített</option>
</select>
</div>
<div class="hint" id="sel-hint">Kattints a kezdő dátumra a naptárban ↓</div>
</div>
<!-- Calendar -->
<div class="card">
<div class="cal-nav">
<button onclick="prevMonth()"></button>
<div class="cal-month" id="cal-month-label"></div>
<button onclick="nextMonth()"></button>
</div>
<div class="cal-grid" id="cal-grid"></div>
</div>
<!-- Legend -->
<div class="legend" id="legend"></div>
<!-- Bookings List -->
<div class="card">
<div class="card-title">Foglalások</div>
<div id="bookings-list"></div>
</div>
<!-- Comments -->
<div class="card">
<div class="card-title">Hozzászólások</div>
<div class="comments-list" id="comments-list"></div>
<div class="comment-form">
<select id="comment-member" style="padding:7px 10px;border-radius:8px;border:1px solid #E8E0D8;background:#FDFBF9;font-size:12px;font-family:'DM Sans',sans-serif;color:#3A332D;"></select>
<input type="text" id="comment-text" placeholder="Hozzászólás írása..." onkeydown="if(event.key==='Enter')addComment()">
<button class="btn-send" onclick="addComment()">Küldés</button>
</div>
</div>
<div class="footer">revfulop.dooplex.hu</div>
</div>
<script>
const MONTHS_HU = ["Január","Február","Március","Április","Május","Június","Július","Augusztus","Szeptember","Október","November","December"];
const DAYS_HU = ["H","K","Sze","Cs","P","Szo","V"];
let members = [];
let bookings = [];
let comments = [];
let currentYear, currentMonth;
let selectionStart = null;
let isSelecting = false;
let authToken = localStorage.getItem('revfulop_token') || '';
// API helpers
async function api(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (authToken) opts.headers['X-Auth-Token'] = authToken;
if (body) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
if (res.status === 401) {
authToken = '';
localStorage.removeItem('revfulop_token');
showLogin();
throw new Error('Unauthorized');
}
return res.json();
}
// Auth
async function checkAuth() {
const status = await fetch('/api/auth-status').then(r => r.json());
if (!status.authEnabled) {
showApp();
return;
}
if (authToken) {
try {
await api('GET', '/members');
showApp();
} catch {
showLogin();
}
} else {
showLogin();
}
}
function showLogin() {
document.getElementById('login-screen').style.display = 'flex';
document.getElementById('app').style.display = 'none';
}
function showApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
init();
}
async function doLogin() {
const pw = document.getElementById('login-password').value;
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
}).then(r => r.json());
if (res.success) {
authToken = res.token;
localStorage.setItem('revfulop_token', authToken);
showApp();
} else {
document.getElementById('login-error').textContent = 'Hibás jelszó';
}
} catch {
document.getElementById('login-error').textContent = 'Hiba történt';
}
}
// Init
async function init() {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth();
members = await api('GET', '/members');
await loadData();
// Populate selects
const selMember = document.getElementById('sel-member');
const commentMember = document.getElementById('comment-member');
selMember.innerHTML = '';
commentMember.innerHTML = '';
members.forEach(m => {
selMember.innerHTML += `<option value="${m.id}">${m.name}</option>`;
commentMember.innerHTML += `<option value="${m.id}">${m.name}</option>`;
});
// Legend
const legend = document.getElementById('legend');
legend.innerHTML = members.map(m =>
`<div class="legend-item"><div class="legend-dot" style="background:${m.color}"></div>${m.name}</div>`
).join('') + `
<div class="legend-row">
<div class="legend-item"><div class="legend-dot" style="background:#999;opacity:0.5;border:1.5px dashed #999;box-sizing:border-box;"></div><span>tervezett</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#999"></div><span>megerősített</span></div>
</div>`;
render();
}
async function loadData() {
bookings = await api('GET', '/bookings');
comments = await api('GET', '/comments');
}
function render() {
renderCalendar();
renderBookings();
renderComments();
}
// Calendar
function prevMonth() { if (currentMonth === 0) { currentMonth = 11; currentYear--; } else currentMonth--; renderCalendar(); }
function nextMonth() { if (currentMonth === 11) { currentMonth = 0; currentYear++; } else currentMonth++; renderCalendar(); }
function dateKey(y, m, d) {
return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
}
function inRange(dk, start, end) { return dk >= start && dk <= end; }
function renderCalendar() {
document.getElementById('cal-month-label').textContent = `${MONTHS_HU[currentMonth]} ${currentYear}`;
const firstDay = new Date(currentYear, currentMonth, 1);
const lastDay = new Date(currentYear, currentMonth + 1, 0);
const startOffset = (firstDay.getDay() + 6) % 7;
const today = new Date(); today.setHours(0,0,0,0);
const todayKey = dateKey(today.getFullYear(), today.getMonth(), today.getDate());
let html = DAYS_HU.map(d => `<div class="cal-day-header">${d}</div>`).join('');
for (let i = 0; i < startOffset; i++) html += '<div class="cal-day empty"></div>';
for (let d = 1; d <= lastDay.getDate(); d++) {
const dk = dateKey(currentYear, currentMonth, d);
const isToday = dk === todayKey;
const dayBookings = bookings.filter(b => inRange(dk, b.start_date, b.end_date));
let classes = 'cal-day';
if (isToday) classes += ' today';
const dots = dayBookings.map(b => {
const m = members.find(x => x.id === b.member_id);
const color = m ? m.color : '#999';
if (b.status === 'planned') {
return `<div class="dot planned" style="border-color:${color}" title="${m?.name || ''} (tervezett)"></div>`;
}
return `<div class="dot" style="background:${color}" title="${m?.name || ''} (megerősített)"></div>`;
}).join('');
html += `<div class="${classes}" onclick="dayClick('${dk}')" onmouseenter="dayHover('${dk}')">
<span class="cal-day-num">${d}</span>
<div class="cal-day-dots">${dots}</div>
</div>`;
}
document.getElementById('cal-grid').innerHTML = html;
}
function dayClick(dk) {
if (!isSelecting) {
selectionStart = dk;
isSelecting = true;
document.getElementById('sel-hint').textContent = '📍 Kattints a befejező dátumra';
highlightSelection(dk, dk);
} else {
const start = selectionStart < dk ? selectionStart : dk;
const end = selectionStart < dk ? dk : selectionStart;
isSelecting = false;
selectionStart = null;
document.getElementById('sel-hint').textContent = 'Kattints a kezdő dátumra a naptárban ↓';
addBooking(start, end);
}
}
function dayHover(dk) {
if (isSelecting && selectionStart) {
highlightSelection(selectionStart, dk);
}
}
function highlightSelection(s, e) {
const start = s < e ? s : e;
const end = s < e ? e : s;
document.querySelectorAll('.cal-day').forEach(el => {
el.classList.remove('in-selection');
const numEl = el.querySelector('.cal-day-num');
if (!numEl) return;
const d = parseInt(numEl.textContent);
const dk = dateKey(currentYear, currentMonth, d);
if (inRange(dk, start, end)) el.classList.add('in-selection');
});
}
// Bookings
async function addBooking(start, end) {
const memberId = document.getElementById('sel-member').value;
const status = document.getElementById('sel-status').value;
await api('POST', '/bookings', { member_id: memberId, start_date: start, end_date: end, status });
await loadData();
render();
}
async function toggleBooking(id) {
const b = bookings.find(x => x.id === id);
if (!b) return;
await api('PUT', `/bookings/${id}`, { status: b.status === 'planned' ? 'confirmed' : 'planned' });
await loadData();
render();
}
async function deleteBooking(id) {
await api('DELETE', `/bookings/${id}`);
await loadData();
render();
}
function renderBookings() {
const list = document.getElementById('bookings-list');
if (bookings.length === 0) {
list.innerHTML = '<div class="empty-state">Még nincs foglalás. Kattints a naptárra egy új hozzáadásához!</div>';
return;
}
list.innerHTML = bookings.map(b => {
const m = members.find(x => x.id === b.member_id);
const color = m ? m.color : '#999';
const start = new Date(b.start_date);
const end = new Date(b.end_date);
const nights = Math.round((end - start) / 86400000);
return `<div class="booking-item" style="border-left:3px solid ${color}">
<div class="booking-dot" style="background:${color};opacity:${b.status==='planned'?0.5:1}"></div>
<div class="booking-info">
<div class="booking-name">${m?.name || b.member_id}
<span class="booking-badge ${b.status}">${b.status === 'confirmed' ? '✓ megerősített' : '○ tervezett'}</span>
</div>
<div class="booking-dates">${b.start_date}${b.end_date} (${nights} éj)</div>
</div>
<div class="booking-actions">
<button class="toggle" onclick="toggleBooking(${b.id})" title="Státusz váltás">↻</button>
<button class="delete" onclick="deleteBooking(${b.id})" title="Törlés">✕</button>
</div>
</div>`;
}).join('');
}
// Comments
async function addComment() {
const memberId = document.getElementById('comment-member').value;
const text = document.getElementById('comment-text').value.trim();
if (!text) return;
await api('POST', '/comments', { member_id: memberId, text });
document.getElementById('comment-text').value = '';
await loadData();
renderComments();
}
function renderComments() {
const list = document.getElementById('comments-list');
if (comments.length === 0) {
list.innerHTML = '<div class="empty-state">Még nincsenek hozzászólások.</div>';
return;
}
list.innerHTML = comments.map(c => {
const m = members.find(x => x.id === c.member_id);
const d = new Date(c.created_at);
const dateStr = d.toLocaleDateString('hu-HU') + ' ' + d.toLocaleTimeString('hu-HU', { hour: '2-digit', minute: '2-digit' });
return `<div class="comment" style="border-left-color:${m?.color || '#ccc'}">
<div class="comment-header">
<span class="comment-author" style="color:${m?.color || '#666'}">${m?.name || c.member_id}</span>
<span class="comment-time">${dateStr}</span>
</div>
<div class="comment-text">${escapeHtml(c.text)}</div>
</div>`;
}).join('');
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
// Start
checkAuth();
</script>
</body>
</html>
-13
View File
@@ -1,13 +0,0 @@
{
"name": "revfulop-calendar",
"version": "1.0.0",
"description": "Family vacation house booking calendar",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"express": "^4.21.0"
}
}
-160
View File
@@ -1,160 +0,0 @@
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const crypto = require('crypto');
const app = express();
const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.DB_PATH || '/data/revfulop.db';
// Simple auth config (set SIMPLE_AUTH_PASSWORD env var to enable)
const AUTH_PASSWORD = process.env.SIMPLE_AUTH_PASSWORD || '';
const AUTH_ENABLED = AUTH_PASSWORD.length > 0;
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
// Family members config (can be overridden via FAMILY_MEMBERS env var as JSON)
const DEFAULT_MEMBERS = [
{ id: "orsi", name: "Orsi", color: "#E07A5F" },
{ id: "papa", name: "Papa", color: "#3D405B" },
{ id: "mama", name: "Mama", color: "#81B29A" },
{ id: "tesa", name: "Tesa", color: "#F2CC8F" },
{ id: "balint", name: "Bálint", color: "#7B9EA8" },
];
let FAMILY_MEMBERS;
try {
FAMILY_MEMBERS = process.env.FAMILY_MEMBERS ? JSON.parse(process.env.FAMILY_MEMBERS) : DEFAULT_MEMBERS;
} catch {
FAMILY_MEMBERS = DEFAULT_MEMBERS;
}
// Init DB
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'planned',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
`);
app.use(express.json());
// Session tokens (in-memory, simple approach)
const sessions = new Map();
// Simple auth middleware
function authMiddleware(req, res, next) {
if (!AUTH_ENABLED) return next();
// Skip auth for login endpoint and static assets
if (req.path === '/api/login' || req.path === '/api/auth-status') return next();
const token = req.headers['x-auth-token'];
if (token && sessions.has(token)) {
return next();
}
// For API routes, return 401
if (req.path.startsWith('/api/')) {
return res.status(401).json({ error: 'Unauthorized' });
}
next(); // Let static files through (frontend handles auth UI)
}
app.use(authMiddleware);
// Auth endpoints
app.get('/api/auth-status', (req, res) => {
res.json({ authEnabled: AUTH_ENABLED });
});
app.post('/api/login', (req, res) => {
if (!AUTH_ENABLED) return res.json({ success: true, token: 'none' });
const { password } = req.body;
if (password === AUTH_PASSWORD) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, { created: Date.now() });
// Clean old sessions (>24h)
for (const [t, s] of sessions) {
if (Date.now() - s.created > 86400000) sessions.delete(t);
}
res.json({ success: true, token });
} else {
res.status(401).json({ success: false, error: 'Hibás jelszó' });
}
});
// Members endpoint
app.get('/api/members', (req, res) => {
res.json(FAMILY_MEMBERS);
});
// Bookings CRUD
app.get('/api/bookings', (req, res) => {
const bookings = db.prepare('SELECT * FROM bookings ORDER BY start_date').all();
res.json(bookings);
});
app.post('/api/bookings', (req, res) => {
const { member_id, start_date, end_date, status } = req.body;
if (!member_id || !start_date || !end_date) {
return res.status(400).json({ error: 'Missing fields' });
}
const result = db.prepare(
'INSERT INTO bookings (member_id, start_date, end_date, status) VALUES (?, ?, ?, ?)'
).run(member_id, start_date, end_date, status || 'planned');
res.json({ id: result.lastInsertRowid });
});
app.put('/api/bookings/:id', (req, res) => {
const { status } = req.body;
db.prepare('UPDATE bookings SET status = ? WHERE id = ?').run(status, req.params.id);
res.json({ success: true });
});
app.delete('/api/bookings/:id', (req, res) => {
db.prepare('DELETE FROM bookings WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Comments CRUD
app.get('/api/comments', (req, res) => {
const comments = db.prepare('SELECT * FROM comments ORDER BY created_at DESC').all();
res.json(comments);
});
app.post('/api/comments', (req, res) => {
const { member_id, text } = req.body;
if (!member_id || !text) {
return res.status(400).json({ error: 'Missing fields' });
}
const result = db.prepare(
'INSERT INTO comments (member_id, text) VALUES (?, ?)'
).run(member_id, text);
res.json({ id: result.lastInsertRowid });
});
// Serve static frontend
app.use(express.static(path.join(__dirname, 'public')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Révfülöp Calendar running on port ${PORT}`);
console.log(`Auth: ${AUTH_ENABLED ? 'ENABLED (simple password)' : 'DISABLED (public access)'}`);
console.log(`Members: ${FAMILY_MEMBERS.map(m => m.name).join(', ')}`);
});