@@ -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 : 100 vh ;
padding : 20 px 16 px ;
}
. container { max-width : 520 px ; margin : 0 auto ; }
/* Header */
. header { text-align : center ; margin-bottom : 28 px ; }
. header-sub { font-size : 11 px ; letter-spacing : 0.2 em ; color : #B0A89E ; font-weight : 500 ; text-transform : uppercase ; margin-bottom : 4 px ; }
. header h1 { font-family : 'Playfair Display' , serif ; font-size : 28 px ; font-weight : 700 ; color : #3A332D ; line-height : 1.2 ; }
. header-line { width : 40 px ; height : 2 px ; background : #C17F59 ; margin : 10 px auto 0 ; border-radius : 1 px ; }
/* Cards */
. card { background : #FFF ; border-radius : 14 px ; padding : 16 px ; margin-bottom : 14 px ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.04 ) ; }
. card-title { font-size : 11 px ; font-weight : 600 ; color : #8B7E74 ; margin-bottom : 10 px ; text-transform : uppercase ; letter-spacing : 0.08 em ; }
/* Controls */
. controls { display : flex ; gap : 8 px ; flex-wrap : wrap ; align-items : center ; }
. controls select , . controls input {
padding : 7 px 10 px ; border-radius : 8 px ; border : 1 px solid #E8E0D8 ;
background : #FDFBF9 ; font-size : 13 px ; font-family : 'DM Sans' , sans-serif ;
color : #3A332D ; flex : 1 ; min-width : 100 px ;
}
. hint { font-size : 11 px ; color : #B0A89E ; margin-top : 8 px ; }
/* Calendar */
. cal-nav { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 12 px ; }
. cal-nav button {
border : none ; background : #F5F0EB ; border-radius : 8 px ; width : 32 px ; height : 32 px ;
cursor : pointer ; font-size : 16 px ; color : #5C524A ; display : flex ; align-items : center ; justify-content : center ;
}
. cal-nav button : hover { background : #EAE3DB ; }
. cal-month { font-size : 16 px ; font-weight : 600 ; color : #3A332D ; }
. cal-grid { display : grid ; grid-template-columns : repeat ( 7 , 1 fr ) ; gap : 2 px ; }
. cal-day-header { text-align : center ; padding : 6 px 0 ; font-size : 11 px ; font-weight : 600 ; color : #8B7E74 ; letter-spacing : 0.05 em ; }
. cal-day {
position : relative ; min-height : 48 px ; padding : 3 px ; border-radius : 6 px ;
background : #FDFBF9 ; cursor : pointer ; border : 1 px solid transparent ;
transition : all 0.15 s ease ;
}
. cal-day : hover { background : #F0EAE3 ; }
. cal-day . empty { background : transparent ; cursor : default ; }
. cal-day . today { border : 2 px solid #C17F59 ; }
. cal-day . in-selection { background : #D4C5B9 ; }
. cal-day-num { font-size : 12 px ; font-weight : 400 ; color : #5C524A ; text-align : right ; padding-right : 2 px ; display : block ; }
. cal-day . today . cal-day-num { font-weight : 700 ; color : #C17F59 ; }
. cal-day-dots { display : flex ; flex-wrap : wrap ; gap : 2 px ; margin-top : 2 px ; }
. dot {
width : 8 px ; height : 8 px ; border-radius : 50 % ; flex-shrink : 0 ;
}
. dot . planned { opacity : 0.5 ; border : 1.5 px dashed ; background : transparent !important ; }
/* Legend */
. legend { display : flex ; flex-wrap : wrap ; gap : 10 px ; justify-content : center ; margin-bottom : 14 px ; padding : 8 px 0 ; }
. legend-item { display : flex ; align-items : center ; gap : 5 px ; font-size : 11 px ; color : #5C524A ; }
. legend-dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; }
. legend-row { width : 100 % ; display : flex ; justify-content : center ; gap : 16 px ; margin-top : 2 px ; }
. legend-row span { font-size : 10 px ; color : #B0A89E ; }
/* Booking list */
. booking-item {
display : flex ; align-items : center ; gap : 10 px ; padding : 8 px 12 px ;
border-radius : 8 px ; background : #FDFBF9 ; margin-bottom : 6 px ;
}
. booking-color { width : 3 px ; height : 100 % ; min-height : 36 px ; border-radius : 2 px ; flex-shrink : 0 ; }
. booking-dot { width : 10 px ; height : 10 px ; border-radius : 50 % ; flex-shrink : 0 ; }
. booking-info { flex : 1 ; min-width : 0 ; }
. booking-name { font-size : 13 px ; font-weight : 600 ; color : #3A332D ; }
. booking-badge {
display : inline-block ; margin-left : 8 px ; font-size : 10 px ; font-weight : 500 ;
padding : 1 px 6 px ; border-radius : 10 px ;
}
. booking-badge . confirmed { background : #81B29A 22 ; color : #5A9A78 ; }
. booking-badge . planned { background : #F2CC8F 33 ; color : #C49A4A ; }
. booking-dates { font-size : 11 px ; color : #8B7E74 ; margin-top : 2 px ; }
. booking-actions button {
border : none ; background : none ; cursor : pointer ; font-size : 14 px ; padding : 4 px ; border-radius : 4 px ;
}
. booking-actions . toggle { color : #B0A89E ; }
. booking-actions . delete { color : #D4756B ; }
. empty-state { text-align : center ; padding : 30 px ; color : #B0A89E ; font-style : italic ; }
/* Comments */
. comments-list { display : flex ; flex-direction : column ; gap : 8 px ; margin-bottom : 16 px ; max-height : 300 px ; overflow-y : auto ; }
. comment {
padding : 10 px 14 px ; border-radius : 10 px ; background : #FDFBF9 ;
border-left : 3 px solid #ccc ;
}
. comment-header { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 4 px ; }
. comment-author { font-size : 12 px ; font-weight : 600 ; }
. comment-time { font-size : 10 px ; color : #B0A89E ; }
. comment-text { font-size : 13 px ; color : #5C524A ; line-height : 1.5 ; }
. comment-form { display : flex ; gap : 8 px ; align-items : flex-end ; }
. comment-form select { width : 100 px ; }
. comment-form input {
flex : 1 ; padding : 8 px 12 px ; border-radius : 8 px ; border : 1 px solid #E8E0D8 ;
background : #FFF ; font-size : 13 px ; font-family : 'DM Sans' , sans-serif ;
color : #3A332D ; outline : none ;
}
. comment-form input : focus { border-color : #C17F59 ; }
. btn-send {
padding : 8 px 16 px ; border-radius : 8 px ; border : none ;
background : #C17F59 ; color : #FFF ; font-size : 12 px ; 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 : 10 px 16 px ; border-radius : 8 px ; border : 1 px solid #E8E0D8 ;
font-size : 14 px ; font-family : 'DM Sans' , sans-serif ; width : 220 px ;
margin : 12 px 0 ; text-align : center ;
}
. login-box . btn-send { display : block ; width : 220 px ; margin : 0 auto ; padding : 10 px ; }
. login-error { font-size : 12 px ; color : #D4756B ; margin-top : 8 px ; }
. footer { text-align : center ; font-size : 10 px ; color : #C8BFB5 ; padding : 0 0 20 px ; }
< / 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 >