Files
revfulop-calendar/public/index.html
T
2026-02-07 09:05:22 +01:00

593 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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; }
/* User bar */
.user-bar {
display: flex; align-items: center; justify-content: center; gap: 10px;
margin-bottom: 18px; padding: 8px 14px; border-radius: 10px;
background: #FFF; box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.user-bar-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.user-bar-name { font-size: 13px; font-weight: 600; color: #3A332D; }
.user-bar-label { font-size: 11px; color: #B0A89E; }
.btn-logout {
margin-left: auto; border: none; background: #F5F0EB; border-radius: 6px;
padding: 4px 10px; font-size: 11px; font-weight: 500; color: #8B7E74;
cursor: pointer; font-family: 'DM Sans', sans-serif;
}
.btn-logout:hover { background: #EAE3DB; color: #5C524A; }
/* 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-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: 400px; overflow-y: auto; }
.comment {
padding: 10px 14px; border-radius: 10px; background: #FDFBF9;
border-left: 3px solid #ccc; position: relative;
}
.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-delete {
border: none; background: none; cursor: pointer; font-size: 12px;
color: #D4756B; padding: 2px 4px; border-radius: 4px; margin-left: 8px;
opacity: 0.6;
}
.comment-delete:hover { opacity: 1; }
.comment-form { display: flex; gap: 8px; align-items: flex-end; }
.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; width: 280px; }
.login-box input, .login-box select {
padding: 10px 16px; border-radius: 8px; border: 1px solid #E8E0D8;
font-size: 14px; font-family: 'DM Sans', sans-serif; width: 100%;
margin-bottom: 10px; text-align: center; background: #FFF; color: #3A332D;
}
.login-box .btn-send { display: block; width: 100%; padding: 10px; }
.login-error { font-size: 12px; color: #D4756B; margin-top: 8px; }
.login-members { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 16px 0; }
.login-member-btn {
display: flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 10px; border: 2px solid #E8E0D8;
background: #FFF; cursor: pointer; font-family: 'DM Sans', sans-serif;
font-size: 13px; font-weight: 500; color: #5C524A; transition: all 0.15s ease;
}
.login-member-btn:hover { border-color: #C17F59; }
.login-member-btn.selected { border-color: #C17F59; background: #FDF6F0; font-weight: 600; }
.login-member-dot { width: 10px; height: 10px; border-radius: 50%; }
.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 8px;">Nyaraló Naptár</h1>
<div class="header-line" style="margin:0 auto 12px;"></div>
<div style="font-size:12px;color:#8B7E74;margin-bottom:4px;">Ki vagy?</div>
<div class="login-members" id="login-members"></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>
<!-- User bar -->
<div class="user-bar" id="user-bar">
<div class="user-bar-dot" id="user-bar-dot"></div>
<span class="user-bar-name" id="user-bar-name"></span>
<span class="user-bar-label">bejelentkezve</span>
<button class="btn-logout" onclick="doLogout()">Kijelentkezés</button>
</div>
<!-- New Booking Controls -->
<div class="card">
<div class="card-title">Új foglalás</div>
<div class="controls">
<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">
<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') || '';
let currentMemberId = localStorage.getItem('revfulop_member') || '';
let selectedLoginMember = '';
// 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 = '';
currentMemberId = '';
localStorage.removeItem('revfulop_token');
localStorage.removeItem('revfulop_member');
showLogin();
throw new Error('Unauthorized');
}
return res.json();
}
// Auth
async function checkAuth() {
// Always load members first (available without auth)
members = await fetch('/api/members').then(r => r.json());
buildLoginMembers();
const status = await fetch('/api/auth-status').then(r => r.json());
if (!status.authEnabled) {
showApp();
return;
}
if (authToken && currentMemberId) {
try {
await api('GET', '/me');
showApp();
} catch {
showLogin();
}
} else {
showLogin();
}
}
function buildLoginMembers() {
const container = document.getElementById('login-members');
container.innerHTML = members.map(m =>
`<button class="login-member-btn" data-id="${m.id}" onclick="selectLoginMember('${m.id}')">
<div class="login-member-dot" style="background:${m.color}"></div>
${m.name}
</button>`
).join('');
}
function selectLoginMember(id) {
selectedLoginMember = id;
document.querySelectorAll('.login-member-btn').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.id === id);
});
}
function showLogin() {
document.getElementById('login-screen').style.display = 'flex';
document.getElementById('app').style.display = 'none';
document.getElementById('login-error').textContent = '';
document.getElementById('login-password').value = '';
selectedLoginMember = '';
document.querySelectorAll('.login-member-btn').forEach(b => b.classList.remove('selected'));
}
function showApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
updateUserBar();
init();
}
function updateUserBar() {
const member = members.find(m => m.id === currentMemberId);
const bar = document.getElementById('user-bar');
if (!member) {
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
document.getElementById('user-bar-dot').style.background = member.color;
document.getElementById('user-bar-name').textContent = member.name;
}
async function doLogin() {
if (!selectedLoginMember) {
document.getElementById('login-error').textContent = 'Válassz egy családtagot!';
return;
}
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, memberId: selectedLoginMember }),
}).then(r => r.json());
if (res.success) {
authToken = res.token;
currentMemberId = res.memberId;
localStorage.setItem('revfulop_token', authToken);
localStorage.setItem('revfulop_member', currentMemberId);
showApp();
} else {
document.getElementById('login-error').textContent = res.error || 'Hibás jelszó';
}
} catch {
document.getElementById('login-error').textContent = 'Hiba történt';
}
}
function doLogout() {
authToken = '';
currentMemberId = '';
localStorage.removeItem('revfulop_token');
localStorage.removeItem('revfulop_member');
showLogin();
}
// Init
async function init() {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth();
await loadData();
// 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 status = document.getElementById('sel-status').value;
await api('POST', '/bookings', { 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 text = document.getElementById('comment-text').value.trim();
if (!text) return;
await api('POST', '/comments', { text });
document.getElementById('comment-text').value = '';
await loadData();
renderComments();
}
async function deleteComment(id) {
await api('DELETE', `/comments/${id}`);
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 + 'Z');
const dateStr = d.toLocaleDateString('hu-HU') + ' ' + d.toLocaleTimeString('hu-HU', { hour: '2-digit', minute: '2-digit' });
const isOwn = c.member_id === currentMemberId;
const deleteBtn = isOwn ? `<button class="comment-delete" onclick="deleteComment(${c.id})" title="Törlés">✕</button>` : '';
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}
${deleteBtn}
</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>