Wie besturen Cycle Team Wijchen
Wie besturen Cycle Team Wijchen
Meedoen ritten
CTW Ride
Afgelopen ritten
:root{
--paper:#EEF1EC;
--paper-2:#E4E8DF;
--ink:#1E2A22;
--muted:#5C6259;
--line:#CBC6B4;
--red:#F21B06;
--red-dark:#C11504;
--red-tint:#FDE4DF;
--green:#3F6C4B;
--green-light:#E4ECE3;
--card:#F8F7F2;
--radius:3px;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:var(--paper);
color:var(--ink);
font-family:'Karla',sans-serif;
font-size:16px;
line-height:1.5;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3,.stage-num,.btn,.eyebrow{font-family:'Oswald',sans-serif; text-transform:none;}
a{color:inherit;}
button{font-family:'Karla',sans-serif; cursor:pointer;}
.wrap{max-width:760px; margin:0 auto; padding:0 20px 80px;}
/* ---------- HERO ---------- */
.hero{
position:relative;
padding:36px 20px 28px;
overflow:hidden;
}
.hero-inner{max-width:760px; margin:0 auto; position:relative; z-index:2;}
.club-row{display:flex; justify-content:space-between; align-items:flex-start; gap:12px;}
.club-name{
font-family:'Oswald',sans-serif;
font-weight:700;
font-size:clamp(28px,6vw,40px);
letter-spacing:0.5px;
margin:0;
line-height:1.05;
}
.club-sub{color:var(--muted); margin:4px 0 0; font-size:15px;}
.header-chips{display:flex; flex-direction:column; align-items:flex-end; gap:6px;}
.member-chip{
flex:0 0 auto;
background:var(--card);
border:1px solid var(--line);
border-radius:20px;
padding:6px 14px 6px 10px;
font-size:14px;
display:flex;
align-items:center;
gap:8px;
white-space:nowrap;
}
.member-dot{width:8px; height:8px; border-radius:50%; background:var(--green); flex:0 0 auto;}
.member-chip button{background:none; border:none; padding:0; color:var(--muted); text-decoration:underline; font-size:13px;}
.admin-badge{
display:flex; align-items:center; gap:8px; font-size:13px; color:var(--red-dark);
background:var(--red-tint); border-radius:20px; padding:5px 12px 5px 10px; white-space:nowrap;
}
.admin-badge button{background:none; border:none; padding:0; color:var(--red-dark); text-decoration:underline; font-size:12px;}
.admin-login-link{background:none; border:none; padding:0; color:var(--muted); text-decoration:underline; font-size:13px; white-space:nowrap;}
/* ---------- SECTION HEADER ---------- */
.section-head{
display:flex; align-items:center; justify-content:space-between;
margin:40px 0 18px; gap:12px; flex-wrap:wrap;
}
.section-head h2{font-size:20px; font-weight:600; margin:0;}
.btn{
display:inline-flex; align-items:center; gap:6px;
background:var(--red); color:#fff; border:none;
padding:9px 16px; border-radius:var(--radius); font-size:15px; font-weight:500;
letter-spacing:0.2px;
}
.btn:hover{background:var(--red-dark);}
.btn.secondary{background:transparent; color:var(--ink); border:1px solid var(--line);}
.btn.secondary:hover{border-color:var(--ink);}
.btn.ghost{background:transparent; color:var(--muted); padding:6px 10px; font-size:13px; text-decoration:underline;}
.btn.small{padding:6px 12px; font-size:13px;}
.btn:disabled{opacity:0.5; cursor:default;}
/* ---------- TIMELINE ---------- */
.timeline{position:relative; padding-left:34px;}
.timeline::before{
content:""; position:absolute; left:13px; top:6px; bottom:6px; width:2px; background:var(--line);
}
.ride{position:relative; margin-bottom:22px;}
.ride:last-child{margin-bottom:0;}
.stage-num{
position:absolute; left:-34px; top:2px; width:28px; height:28px; border-radius:50%;
background:var(--red); border:2px solid var(--red); color:#fff;
display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600;
}
.ride.past .stage-num{border-color:var(--line); color:var(--muted); background:var(--paper);}
.ride-card{
background:var(--card); border:1px solid var(--line); border-left:4px solid var(--red);
border-radius:var(--radius); padding:14px 16px;
}
.ride.past .ride-card{border-left-color:var(--line);}
.ride-top{display:flex; justify-content:space-between; align-items:flex-start; gap:10px; cursor:pointer;}
.ride-date{font-size:13px; color:var(--muted); margin:0 0 3px; font-weight:500;}
.ride-title{font-size:18px; margin:0; font-weight:600;}
.ride-chevron{color:var(--muted); font-size:20px; line-height:1; transition:transform .15s ease;}
.ride.open .ride-chevron{transform:rotate(90deg);}
.ride-quickmeta{margin:8px 0 0; font-size:14px; color:var(--muted);}
.ride-quickmeta span:not(:last-child)::after{content:"·"; margin:0 6px; color:var(--line);}
.ride-foot{display:flex; align-items:center; justify-content:space-between; margin-top:12px; gap:10px; flex-wrap:wrap;}
.attendee-count{font-size:13px; color:var(--muted);}
.attendee-count b{color:var(--ink);}
.group-tally{color:var(--muted);}
.group-select{display:flex; gap:6px; flex-wrap:wrap;}
.group-btn{
background:var(--card); border:1px solid var(--line); border-radius:16px;
padding:6px 13px; font-size:13px; color:var(--ink); font-weight:500;
}
.group-btn:hover{border-color:var(--ink);}
.group-btn.active{background:var(--green); color:#fff; border-color:var(--green);}
.ride-detail{max-height:0; overflow:hidden; transition:max-height .2s ease;}
.ride.open .ride-detail{max-height:1100px;}
.ride-detail-inner{padding-top:14px; margin-top:14px; border-top:1px solid var(--line);}
.detail-grid{display:grid; grid-template-columns:1fr 1fr; gap:10px 20px; font-size:14px; margin-bottom:12px;}
.detail-grid .lbl{color:var(--muted); font-size:12px; display:block; margin-bottom:2px;}
.map-embed{border-radius:var(--radius); overflow:hidden; border:1px solid var(--line); margin-bottom:6px;}
.map-embed iframe{display:block; width:100%; height:200px; border:0;}
.map-link{margin:8px 0 14px; font-size:13px;}
.map-link a{color:var(--red-dark); font-weight:600; text-decoration:underline;}
.ride-desc{font-size:14px; margin:0 0 14px; white-space:pre-wrap;}
.attendee-list{margin:0 0 14px;}
.attendee-list .lbl{color:var(--muted); font-size:12px; display:block; margin-bottom:6px;}
.group-block{margin-bottom:12px;}
.group-block:last-child{margin-bottom:0;}
.pill-row{display:flex; flex-wrap:wrap; gap:6px;}
.pill{background:var(--green-light); color:var(--green); border-radius:14px; padding:3px 10px; font-size:13px;}
.no-attendees{color:var(--muted); font-size:13px; font-style:italic;}
.ride-actions{display:flex; gap:14px; font-size:13px;}
.ride-actions button{background:none; border:none; color:var(--muted); text-decoration:underline; padding:0;}
.ride-actions button:hover{color:var(--red);}
.empty{
border:1px dashed var(--line); border-radius:var(--radius); padding:26px 20px; text-align:center; color:var(--muted);
}
.empty p{margin:0 0 12px;}
details.past-wrap{margin-top:44px;}
details.past-wrap>summary{
cursor:pointer; font-family:'Oswald',sans-serif; font-weight:600; font-size:17px;
list-style:none; display:flex; align-items:center; gap:8px; padding-bottom:14px; border-bottom:1px solid var(--line);
}
details.past-wrap>summary::-webkit-details-marker{display:none;}
details.past-wrap>summary::before{content:"›"; display:inline-block; transition:transform .15s ease; font-size:20px;}
details.past-wrap[open]>summary::before{transform:rotate(90deg);}
details.past-wrap .timeline{margin-top:18px;}
/* ---------- MODAL ---------- */
.modal-backdrop{
position:fixed; inset:0; background:rgba(30,42,34,0.55); display:flex; align-items:flex-end;
justify-content:center; z-index:50; padding:0;
}
@media (min-width:640px){ .modal-backdrop{align-items:center; padding:20px;} }
.modal{
background:var(--paper); width:100%; max-width:520px; max-height:92vh; overflow-y:auto;
border-radius:10px 10px 0 0; padding:24px 20px 28px;
}
@media (min-width:640px){ .modal{border-radius:6px; padding:28px 30px 30px;} }
.modal h3{margin:0 0 4px; font-size:22px; font-weight:600;}
.modal .modal-sub{color:var(--muted); font-size:14px; margin:0 0 20px;}
.field{margin-bottom:14px;}
.field label{display:block; font-size:13px; color:var(--muted); margin-bottom:5px;}
.field input, .field select, .field textarea{
width:100%; padding:10px 11px; border:1px solid var(--line); border-radius:var(--radius);
font-family:'Karla',sans-serif; font-size:15px; background:#fff; color:var(--ink);
}
.field input:focus, .field select:focus, .field textarea:focus{outline:2px solid var(--ink); outline-offset:1px;}
.field-row{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
.field textarea{resize:vertical; min-height:70px;}
.opt-tag{color:var(--muted); font-weight:400;}
.field-help{font-size:12px; color:var(--muted); margin:5px 0 0;}
.field-group-label{
font-family:'Oswald',sans-serif; font-weight:600; font-size:15px; letter-spacing:0.2px;
margin:22px 0 4px; padding-top:16px; border-top:1px solid var(--line);
}
.field input[type="file"]{
padding:8px; font-size:14px; background:#fff;
}
.gpx-download-btn{
background:none; border:none; padding:0; color:var(--red-dark); font-weight:600;
text-decoration:underline; font-size:14px;
}
.modal-actions{display:flex; justify-content:flex-end; gap:10px; margin-top:22px;}
.error-text{color:var(--red); font-size:13px; margin:-6px 0 14px;}
.loading-row{color:var(--muted); font-size:14px; padding:20px 0;}
footer{text-align:center; color:var(--muted); font-size:13px; margin-top:60px;}
.app-notice{
position:fixed; top:14px; left:50%; transform:translateX(-50%); max-width:90vw;
background:var(--ink); color:#fff; padding:10px 18px; border-radius:20px; font-size:14px;
z-index:100; box-shadow:0 4px 14px rgba(0,0,0,0.25); display:none; text-align:center;
}
.app-notice.error{background:var(--red-dark);}
@media (prefers-reduced-motion: reduce){
*{transition:none !important;}
}
:focus-visible{outline:2px solid var(--ink); outline-offset:2px;}
CTW Ride
Clubritten van Cycle Team Wijchen
Aankomende ritten
Ritten laden…
(function(){
"use strict";
var RIDE_TYPES = ["Racefiets","Gravel/CX/Cross","Mtb"];
var WEEKDAYS = ["zo","ma","di","wo","do","vr","za"];
var MONTHS = ["jan","feb","mrt","apr","mei","jun","jul","aug","sep","okt","nov","dec"];
var state = {
memberName: null,
rides: [],
rsvps: {}, // { rideId: { name: 'A'|'B'|'C' } }
locations: [], // opgeslagen startlocaties: { id, street, city }
openRideId: null,
loaded: false,
loadError: null,
adminPin: null, // huidige beheerderscode (null = nog niet ingesteld)
isAdmin: false // is dit apparaat ingelogd als beheerder
};
// ---------- helpers ----------
function uid(){ return 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2,7); }
function fmtDate(dateStr){
var d = parseLocalDate(dateStr);
if(!d) return dateStr;
return WEEKDAYS[d.getDay()] + ' ' + d.getDate() + ' ' + MONTHS[d.getMonth()];
}
function parseLocalDate(dateStr){
if(!dateStr) return null;
var parts = dateStr.split('-');
if(parts.length !== 3) return null;
return new Date(parseInt(parts[0],10), parseInt(parts[1],10)-1, parseInt(parts[2],10));
}
function rideDateTime(ride){
var d = parseLocalDate(ride.date);
if(!d) return new Date(8640000000000000);
if(ride.time){
var t = ride.time.split(':');
d.setHours(parseInt(t[0],10)||0, parseInt(t[1],10)||0);
}
return d;
}
function isPast(ride){
var end = parseLocalDate(ride.date);
if(!end) return false;
if(ride.time){
var t = ride.time.split(':');
end.setHours(parseInt(t[0],10)||0, parseInt(t[1],10)||0);
end.setHours(end.getHours()+3); // ride counts as "upcoming" until a few hours after start
}else{
end.setHours(23,59,59); // no time given: counts as upcoming the whole day
}
return end.getTime() < Date.now();
}
function escapeHtml(s){
return String(s==null?'':s).replace(/[&<>"']/g, function(c){
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
});
}
function showNotice(msg, isError){
var el = document.getElementById('appNotice');
if(!el){
el = document.createElement('div');
el.id = 'appNotice';
el.className = 'app-notice';
document.body.appendChild(el);
}
el.className = 'app-notice' + (isError ? ' error' : '');
el.textContent = msg;
el.style.display = 'block';
clearTimeout(el._hideTimer);
el._hideTimer = setTimeout(function(){ el.style.display = 'none'; }, 4500);
}
function openConfirmModal(opts){
var root = document.getElementById('modalRoot');
root.innerHTML =
'
' +
'
' +
'
' + escapeHtml(opts.title || 'Weet je het zeker?') + '
' + '
' + escapeHtml(opts.message || '') + '
' + '
' +
'' +
'' +
'
' + '
' + '
'; document.getElementById('confirmCancel').onclick = closeModal;
document.getElementById('confirmOk').onclick = function(){
closeModal();
opts.onConfirm();
};
}
var GROUPS = ['A','B','C'];
var GROUP_LABELS = { A:'A-groep', B:'B-groep', C:'C-groep' };
function attendeesByGroup(rideId){
var map = state.rsvps[rideId] || {};
var groups = { A:[], B:[], C:[] };
for(var name in map){
var g = map[name];
if(groups[g]) groups[g].push(name);
}
GROUPS.forEach(function(g){ groups[g].sort(function(a,b){ return a.localeCompare(b,'nl'); }); });
return groups;
}
function myStatus(rideId){
var map = state.rsvps[rideId] || {};
return map[state.memberName] || null;
}
// ---------- GPX ----------
function haversineKm(lat1, lon1, lat2, lon2){
var R = 6371;
var dLat = (lat2 - lat1) * Math.PI / 180;
var dLon = (lon2 - lon1) * Math.PI / 180;
var a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI/180) * Math.cos(lat2 * Math.PI/180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function parseGpx(text){
try{
var doc = new DOMParser().parseFromString(text, 'application/xml');
if(doc.querySelector('parsererror')) return { distanceKm:null, elevationM:null };
var pts = doc.getElementsByTagName('trkpt');
if(!pts.length) pts = doc.getElementsByTagName('rtept');
if(!pts.length) return { distanceKm:null, elevationM:null };
var distance = 0, gain = 0;
var prevLat, prevLon, prevEle;
for(var i=0; i
var pt = pts[i];
var lat = parseFloat(pt.getAttribute('lat'));
var lon = parseFloat(pt.getAttribute('lon'));
var eleNode = pt.getElementsByTagName('ele')[0];
var ele = eleNode ? parseFloat(eleNode.textContent) : null;
if(isFinite(prevLat) && isFinite(lat) && isFinite(lon)){
distance += haversineKm(prevLat, prevLon, lat, lon);
if(ele !== null && prevEle !== null && isFinite(ele) && isFinite(prevEle) && ele > prevEle){
gain += (ele - prevEle);
}
}
if(isFinite(lat)) prevLat = lat;
if(isFinite(lon)) prevLon = lon;
prevEle = (ele !== null && isFinite(ele)) ? ele : prevEle;
}
return {
distanceKm: Math.round(distance * 10) / 10,
elevationM: Math.round(gain)
};
}catch(err){
console.error('GPX parsen mislukt', err);
return { distanceKm:null, elevationM:null };
}
}
// ---------- storage ----------
async function loadAll(){
try{
try{
var nameRes = await window.storage.get('ctw_member_name', false);
state.memberName = nameRes ? nameRes.value : null;
}catch(e){ state.memberName = null; }
try{
var ridesRes = await window.storage.get('ctw_rides', true);
state.rides = ridesRes ? JSON.parse(ridesRes.value) : [];
}catch(e){ state.rides = []; }
try{
var rsvpRes = await window.storage.get('ctw_rsvps', true);
state.rsvps = rsvpRes ? JSON.parse(rsvpRes.value) : {};
}catch(e){ state.rsvps = {}; }
try{
var locRes = await window.storage.get('ctw_locations', true);
state.locations = locRes ? JSON.parse(locRes.value) : [];
}catch(e){ state.locations = []; }
try{
var pinRes = await window.storage.get('ctw_admin_pin', true);
state.adminPin = pinRes ? pinRes.value : null;
}catch(e){ state.adminPin = null; }
try{
var adminRes = await window.storage.get('ctw_is_admin', false);
state.isAdmin = adminRes ? (adminRes.value === state.adminPin && !!state.adminPin) : false;
}catch(e){ state.isAdmin = false; }
state.loaded = true;
state.loadError = null;
}catch(err){
console.error('Laden mislukt', err);
state.loadError = 'Gegevens laden is niet gelukt. Ververs de pagina om het opnieuw te proberen.';
}
render();
}
async function saveRides(){
try{
var res = await window.storage.set('ctw_rides', JSON.stringify(state.rides), true);
if(!res) throw new Error('no result');
return true;
}catch(err){
console.error('Opslaan van ritten mislukt', err);
showNotice('Opslaan is niet gelukt. Controleer je verbinding en probeer het opnieuw.', true);
return false;
}
}
async function saveRsvps(){
try{
var res = await window.storage.set('ctw_rsvps', JSON.stringify(state.rsvps), true);
if(!res) throw new Error('no result');
return true;
}catch(err){
console.error('Opslaan van aanmeldingen mislukt', err);
showNotice('Aanmelding opslaan is niet gelukt. Probeer het opnieuw.', true);
return false;
}
}
async function saveLocations(){
try{
var res = await window.storage.set('ctw_locations', JSON.stringify(state.locations), true);
if(!res) throw new Error('no result');
return true;
}catch(err){
console.error('Locatie opslaan mislukt', err);
return false;
}
}
function upsertLocation(street, city){
var key = (street + '|' + city).toLowerCase();
var exists = state.locations.some(function(l){ return (l.street + '|' + l.city).toLowerCase() === key; });
if(exists) return false;
state.locations.push({ id: uid(), street: street, city: city });
return true;
}
async function saveName(name){
try{
await window.storage.set('ctw_member_name', name, false);
return true;
}catch(err){
console.error('Naam opslaan mislukt', err);
return false;
}
}
async function saveGpx(rideId, content){
try{
var res = await window.storage.set('ctw_gpx_' + rideId, content, true);
if(!res) throw new Error('no result');
return true;
}catch(err){
console.error('GPX opslaan mislukt', err);
showNotice('De ritgegevens zijn opgeslagen, maar het GPX-bestand kon niet worden bewaard. Probeer het opnieuw te uploaden via "Bewerken".', true);
return false;
}
}
async function deleteGpx(rideId){
try{
await window.storage.delete('ctw_gpx_' + rideId, true);
}catch(err){ /* geen bestand opgeslagen, prima */ }
}
async function handleDownloadGpx(rideId, fileName){
try{
var res = await window.storage.get('ctw_gpx_' + rideId, true);
if(!res) throw new Error('not found');
var blob = new Blob([res.value], { type:'application/gpx+xml' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = fileName || 'route.gpx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(url); }, 1000);
}catch(err){
console.error('GPX downloaden mislukt', err);
showNotice('Het GPX-bestand kon niet worden opgehaald.', true);
}
}
async function setAdminPin(pin){
try{
await window.storage.set('ctw_admin_pin', pin, true);
state.adminPin = pin;
return true;
}catch(err){
console.error('Beheerderscode opslaan mislukt', err);
showNotice('Instellen van de beheerderscode is niet gelukt. Probeer het opnieuw.', true);
return false;
}
}
async function markThisDeviceAsAdmin(pin){
state.isAdmin = true;
try{
await window.storage.set('ctw_is_admin', pin, false);
}catch(err){
console.error('Beheerderstatus onthouden mislukt', err);
}
}
async function logoutAdmin(){
state.isAdmin = false;
render();
try{
await window.storage.delete('ctw_is_admin', false);
}catch(err){ /* niets ingesteld, prima */ }
}
// ---------- actions ----------
async function handleRsvp(rideId, group){
if(!state.memberName){ openNameModal(); return; }
if(!state.rsvps[rideId]) state.rsvps[rideId] = {};
var current = state.rsvps[rideId][state.memberName];
if(current === group){
delete state.rsvps[rideId][state.memberName]; // nogmaals klikken meldt af
}else{
state.rsvps[rideId][state.memberName] = group;
}
render();
await saveRsvps();
}
function handleDeleteRide(rideId){
openConfirmModal({
title: 'Rit verwijderen',
message: 'Deze rit verwijderen? Dit kan niet ongedaan gemaakt worden.',
confirmLabel: 'Verwijderen',
onConfirm: async function(){
state.rides = state.rides.filter(function(r){ return r.id !== rideId; });
delete state.rsvps[rideId];
render();
await saveRides();
await saveRsvps();
await deleteGpx(rideId);
}
});
}
function toggleOpen(rideId){
state.openRideId = state.openRideId === rideId ? null : rideId;
render();
}
// ---------- rendering ----------
function render(){
renderMemberChip();
renderAdminChip();
renderRideLists();
}
function renderAdminChip(){
var el = document.getElementById('adminChip');
if(state.isAdmin){
el.innerHTML = '
Beheerder
'; document.getElementById('adminLogoutBtn').onclick = logoutAdmin;
}else{
el.innerHTML = '';
document.getElementById('adminLoginLink').onclick = openAdminModal;
}
}
function renderMemberChip(){
var el = document.getElementById('memberChip');
if(state.memberName){
el.innerHTML =
'
' +
escapeHtml(state.memberName) +
'
'; document.getElementById('changeNameBtn').onclick = openNameModal;
}else{
el.innerHTML = '';
document.getElementById('setNameBtn').onclick = openNameModal;
}
}
function renderRideLists(){
var upcomingBox = document.getElementById('upcomingBox');
var pastBox = document.getElementById('pastBox');
var pastWrap = document.getElementById('pastWrap');
var addBtnBox = document.getElementById('addRideBtnBox');
addBtnBox.innerHTML = state.isAdmin ? '' : '';
if(state.isAdmin) document.getElementById('addRideBtn').onclick = function(){ openRideModal(null); };
if(state.loadError){
upcomingBox.innerHTML = '';
' + escapeHtml(state.loadError) + '
pastWrap.style.display = 'none';
return;
}
if(!state.loaded){
upcomingBox.innerHTML = '
Ritten laden…
'; pastWrap.style.display = 'none';
return;
}
var upcoming = state.rides.filter(function(r){ return !isPast(r); })
.sort(function(a,b){ return rideDateTime(a) - rideDateTime(b); });
var past = state.rides.filter(isPast)
.sort(function(a,b){ return rideDateTime(b) - rideDateTime(a); });
if(!upcoming.length){
upcomingBox.innerHTML = state.isAdmin
? '
Er staat nog geen rit gepland.
' + '
' : '';
Er staat nog geen rit gepland. Vraag de beheerder om er één aan te maken.
if(state.isAdmin) document.getElementById('emptyAddBtn').onclick = function(){ openRideModal(null); };
}else{
upcomingBox.innerHTML = '
' + upcoming.map(function(r,i){ return rideCardHtml(r, i+1, false); }).join('') + '
'; wireRideCards(upcoming);
}
pastWrap.style.display = past.length ? '' : 'none';
if(past.length){
pastBox.innerHTML = '
' + past.map(function(r,i){ return rideCardHtml(r, i+1, true); }).join('') + '
'; wireRideCards(past);
}
}
function rideCardHtml(ride, num, past){
var open = state.openRideId === ride.id;
var groups = attendeesByGroup(ride.id);
var totalGoing = groups.A.length + groups.B.length + groups.C.length;
var myGroup = myStatus(ride.id);
var location = [ride.street, ride.city].filter(Boolean).join(', ');
var quick = [];
if(ride.time) quick.push(ride.time);
if(ride.type) quick.push(escapeHtml(ride.type));
if(ride.distanceKm != null) quick.push(ride.distanceKm + ' km');
if(ride.elevationM != null) quick.push('↑ ' + ride.elevationM + ' m');
if(ride.city) quick.push(escapeHtml(ride.city));
return (
'
' +
'
' + num + '
' + '
' +
'
' +
'
' +
'
' + fmtDate(ride.date) + '
' + '
' + escapeHtml(ride.title) + '
' + (quick.length ? '' : '') +
'
' + '›' +
'
' + '
' +
'' + totalGoing + ' ' + (totalGoing===1?'gaat mee':'gaan mee') +
(totalGoing ? ' (A ' + groups.A.length + ' · B ' + groups.B.length + ' · C ' + groups.C.length + ')' : '') +
'' +
(past ? '' :
'
' +
GROUPS.map(function(g){
return '
(myGroup===g ? '✓ ' : '') + g + '-groep' +
'';
}).join('') +
'
' ) +
'
' + '' +
' +
'
' +
(ride.type ? '
Type' + escapeHtml(ride.type) + '
' : '') + (ride.distanceKm != null ? '
Afstand' + ride.distanceKm + ' km
' : '') + (ride.elevationM != null ? '
Hoogtemeters' + ride.elevationM + ' m
' : '') + (location ? '
Startlocatie' + escapeHtml(location) + '
' : '') + '
' + (location && open ?
'' +
: '') +
(ride.description ? '
' + escapeHtml(ride.description) + '
' : '') + (ride.hasGpx ?
''
: '') +
'
' +
GROUPS.map(function(g){
return '
' + GROUP_LABELS[g] + ' (' + groups[g].length + ')' +
(groups[g].length
? '
' + groups[g].map(function(n){ return '' + escapeHtml(n) + ''; }).join('') + '
' : 'Nog niemand aangemeld') +
'
'; }).join('') +
'
' + (state.isAdmin ?
'
' +
'' +
'' +
'
' : '') +
'
'
' + '
' );
}
function wireRideCards(rides){
rides.forEach(function(ride){
var el = document.querySelector('[data-ride="' + ride.id + '"]');
if(!el) return;
var toggle = el.querySelector('[data-toggle]');
if(toggle) toggle.onclick = function(){ toggleOpen(ride.id); };
var rsvpBtns = el.querySelectorAll('[data-rsvp]');
rsvpBtns.forEach(function(btn){
btn.onclick = function(e){ e.stopPropagation(); handleRsvp(ride.id, btn.getAttribute('data-group')); };
});
var editBtn = el.querySelector('[data-edit]');
if(editBtn) editBtn.onclick = function(e){ e.stopPropagation(); openRideModal(ride); };
var delBtn = el.querySelector('[data-delete]');
if(delBtn) delBtn.onclick = function(e){ e.stopPropagation(); handleDeleteRide(ride.id); };
var gpxBtn = el.querySelector('[data-gpx]');
if(gpxBtn) gpxBtn.onclick = function(e){ e.stopPropagation(); handleDownloadGpx(ride.id, gpxBtn.getAttribute('data-gpxname')); };
});
}
// ---------- name modal ----------
function openNameModal(){
var root = document.getElementById('modalRoot');
root.innerHTML =
'
' +
'
' +
'
Wie ben jij?
' + '
Je naam wordt gebruikt om je aan te melden voor ritten. Andere leden zien deze naam.
' + '
' +
'' +
'' +
'
' + '' +
'
' +
(state.memberName ? '' : '') +
'' +
'
' + '
' + '
'; var input = document.getElementById('nameInput');
input.focus();
input.select();
var cancelBtn = document.getElementById('nameCancel');
if(cancelBtn) cancelBtn.onclick = closeModal;
document.getElementById('nameSave').onclick = async function(){
var val = input.value.trim();
if(!val){ document.getElementById('nameError').style.display = 'block'; return; }
state.memberName = val;
closeModal();
render();
await saveName(val);
};
input.onkeydown = function(e){ if(e.key === 'Enter') document.getElementById('nameSave').click(); };
}
// ---------- admin modal ----------
function openAdminModal(){
var root = document.getElementById('modalRoot');
var isFirstTime = !state.adminPin;
root.innerHTML =
'
' +
'
' +
'
' + (isFirstTime ? 'Beheerderscode instellen' : 'Beheerder inloggen') + '
' + '
' +
(isFirstTime
? 'Er is nog geen beheerderscode. Stel er hier één in — deel deze alleen met de mensen die ritten mogen aanmaken.'
: 'Vul de beheerderscode van de club in om ritten te kunnen aanmaken, bewerken of verwijderen.') +
'' +
'
' +
'' +
'' +
'
' + '' +
'
' +
'' +
'' +
'
' + '
' + '
'; var input = document.getElementById('adminPinInput');
input.focus();
document.getElementById('adminCancel').onclick = closeModal;
document.getElementById('adminSubmit').onclick = async function(){
var val = input.value.trim();
var errEl = document.getElementById('adminError');
if(isFirstTime){
if(val.length < 4){
errEl.textContent = 'Kies een code van minimaal 4 tekens.';
errEl.style.display = 'block';
return;
}
var ok = await setAdminPin(val);
if(!ok) return;
await markThisDeviceAsAdmin(val);
closeModal();
render();
}else{
if(val !== state.adminPin){
errEl.textContent = 'Deze code klopt niet.';
errEl.style.display = 'block';
return;
}
await markThisDeviceAsAdmin(val);
closeModal();
render();
}
};
input.onkeydown = function(e){ if(e.key === 'Enter') document.getElementById('adminSubmit').click(); };
}
// ---------- ride modal (add/edit) ----------
function openRideModal(existing){
var isEdit = !!existing;
var pendingGpx = null; // { fileName, content, distanceKm, elevationM } — pas gezet als er een nieuw bestand gekozen wordt
var root = document.getElementById('modalRoot');
var typeOptions = RIDE_TYPES.map(function(t){
return '';
}).join('');
var existingGpxNote = (isEdit && existing.hasGpx)
? 'Huidig bestand: ' + escapeHtml(existing.gpxFileName || 'route.gpx') + '. Upload een nieuw bestand om dit te vervangen.'
: '';
var matchingLocId = null;
if(existing){
var m = state.locations.find(function(l){ return l.street === existing.street && l.city === existing.city; });
if(m) matchingLocId = m.id;
}
var savedLocationOptions = state.locations.map(function(l){
return '';
}).join('');
root.innerHTML =
'
' +
'
' +
'
' + (isEdit ? 'Rit bewerken' : 'Nieuwe rit') + '
' + '
' + (isEdit ? 'Pas de gegevens van deze rit aan.' : 'Plan een rit zodat leden zich kunnen aanmelden.') + '
' + '
' +
'
' + '
' +
'
' + '
' +
'' +
'' +
'
' + '
' +
'
' + '
' +
'
Voeg een GPX-bestand toe aan je rit
' + '' +
'
' + existingGpxNote + '
' + '
' + '
' +
'' +
'' +
'
' + '
Startlocatie
' + (state.locations.length ?
'
' +
'
' : '') +
'
' +
'
' + '
' +
'
' + '' +
'
' +
'' +
'' +
'
' + '
' + '
'; if(existing && existing.type) document.getElementById('f_type').value = existing.type;
document.getElementById('f_title').focus();
document.getElementById('rideCancel').onclick = closeModal;
var savedLocationSelect = document.getElementById('f_saved_location');
if(savedLocationSelect){
savedLocationSelect.onchange = function(){
var loc = state.locations.find(function(l){ return l.id === savedLocationSelect.value; });
if(loc){
document.getElementById('f_street').value = loc.street;
document.getElementById('f_city').value = loc.city;
}
};
}
document.getElementById('f_gpx').onchange = function(e){
var file = e.target.files && e.target.files[0];
var statusEl = document.getElementById('gpxStatus');
if(!file){ pendingGpx = null; statusEl.textContent = existingGpxNote; return; }
if(file.size > 4.5 * 1024 * 1024){
showNotice('Dit GPX-bestand is te groot (max ca. 4,5 MB).', true);
e.target.value = '';
pendingGpx = null;
return;
}
var reader = new FileReader();
reader.onload = function(){
var text = String(reader.result);
var stats = parseGpx(text);
pendingGpx = { fileName: file.name, content: text, distanceKm: stats.distanceKm, elevationM: stats.elevationM };
if(stats.distanceKm != null){
document.getElementById('f_distance').value = stats.distanceKm;
document.getElementById('f_elevation').value = stats.elevationM;
statusEl.textContent = 'Bestand geladen: ' + file.name + ' — afstand en hoogtemeters automatisch ingevuld, pas ze aan indien nodig.';
}else{
statusEl.textContent = 'Bestand geladen: ' + file.name + ' — kon er geen route uit lezen, vul afstand en hoogtemeters handmatig in.';
}
};
reader.onerror = function(){
showNotice('Het GPX-bestand kon niet worden gelezen.', true);
pendingGpx = null;
};
reader.readAsText(file);
};
document.getElementById('rideSave').onclick = async function(){
var title = document.getElementById('f_title').value.trim();
var date = document.getElementById('f_date').value;
var time = document.getElementById('f_time').value;
var type = document.getElementById('f_type').value;
var distanceVal = document.getElementById('f_distance').value;
var elevationVal = document.getElementById('f_elevation').value;
var street = document.getElementById('f_street').value.trim();
var city = document.getElementById('f_city').value.trim();
var hasGpxNow = !!pendingGpx || (isEdit && existing.hasGpx);
var errEl = document.getElementById('rideError');
var missing = !title || !date || !time || !type || distanceVal === '' || elevationVal === '' || !street || !city || !hasGpxNow;
if(missing){
errEl.textContent = !hasGpxNow
? 'Vul alle verplichte velden in, inclusief een GPX-bestand.'
: 'Vul alle verplichte velden in.';
errEl.style.display = 'block';
return;
}
var rideId = existing ? existing.id : uid();
var rideData = {
id: rideId,
title: title,
description: document.getElementById('f_desc').value.trim(),
date: date,
time: time,
type: type,
distanceKm: parseFloat(distanceVal),
elevationM: parseInt(elevationVal, 10),
street: street,
city: city,
gpxFileName: pendingGpx ? pendingGpx.fileName : (existing ? existing.gpxFileName : ''),
hasGpx: hasGpxNow,
createdBy: existing ? existing.createdBy : (state.memberName || 'Onbekend')
};
if(isEdit){
state.rides = state.rides.map(function(r){ return r.id === rideData.id ? rideData : r; });
}else{
state.rides.push(rideData);
}
var locationIsNew = upsertLocation(street, city);
closeModal();
render();
await saveRides();
if(pendingGpx){
await saveGpx(rideId, pendingGpx.content);
}
if(locationIsNew){
await saveLocations();
}
};
}
function closeModal(){
document.getElementById('modalRoot').innerHTML = '';
}
document.addEventListener('keydown', function(e){
if(e.key === 'Escape') closeModal();
});
loadAll();
})();