Add calendar next to newsletter — month view fed by schedule webhook
- Calendar card beside newsletter signup (2-column grid, stacks on mobile)
- Month navigation (prev/next), Monday-first week, today highlighted
- Event dots on days with entries + event list below the grid
- Normalizes schedule webhook response ({data:[]} or [{data:[]}])
- Bilingual DE/EN month names + weekday headers, re-renders on language toggle
This commit is contained in:
+124
@@ -1387,6 +1387,130 @@ TELEGRAM BANNER
|
|||||||
.newsletter-icon { font-size: 2.5rem; margin-bottom: 16px; }
|
.newsletter-icon { font-size: 2.5rem; margin-bottom: 16px; }
|
||||||
.newsletter-hint { color: #65676b; margin-bottom: 16px; font-size: 15px; }
|
.newsletter-hint { color: #65676b; margin-bottom: 16px; font-size: 15px; }
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
NEWSLETTER + CALENDAR LAYOUT
|
||||||
|
======================================== */
|
||||||
|
.newsletter-calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: start;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.newsletter-calendar-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar card */
|
||||||
|
.calendar-card {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e4e6eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.calendar-card.visible {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: scale(1) !important;
|
||||||
|
animation: scaleEntry 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.calendar-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1c1e21;
|
||||||
|
}
|
||||||
|
.calendar-nav {
|
||||||
|
background: #f0f2f5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #1c1e21;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.calendar-nav:hover { background: #e4e6eb; }
|
||||||
|
.calendar-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #65676b;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.calendar-day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #1c1e21;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.calendar-day.empty { visibility: hidden; }
|
||||||
|
.calendar-day.today {
|
||||||
|
background: #e7f3ff;
|
||||||
|
color: #1877f2;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.calendar-day.has-event { font-weight: 700; }
|
||||||
|
.calendar-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
}
|
||||||
|
.calendar-dots i {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1877f2;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.calendar-events { margin-top: 12px; border-top: 1px solid #e4e6eb; padding-top: 10px; }
|
||||||
|
.calendar-events-hint { color: #65676b; font-size: 0.85rem; text-align: center; margin: 0; }
|
||||||
|
.calendar-event-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.calendar-event-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.calendar-event-date {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1877f2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.calendar-event-headline { flex: 1; color: #1c1e21; }
|
||||||
|
.calendar-event-time { color: #65676b; white-space: nowrap; }
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
FOOTER
|
FOOTER
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|||||||
+23
@@ -395,6 +395,7 @@
|
|||||||
<!-- Newsletter Section (nur logged-in) -->
|
<!-- Newsletter Section (nur logged-in) -->
|
||||||
<section class="section section-alt newsletter-section" id="newsletter-section" style="display:none;">
|
<section class="section section-alt newsletter-section" id="newsletter-section" style="display:none;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="newsletter-calendar-grid">
|
||||||
<div class="newsletter-card">
|
<div class="newsletter-card">
|
||||||
<img src="https://images.unsplash.com/photo-1596526131083-e8c633c948d2?w=120&h=120&fit=crop&q=80" alt="Newsletter" class="newsletter-image" />
|
<img src="https://images.unsplash.com/photo-1596526131083-e8c633c948d2?w=120&h=120&fit=crop&q=80" alt="Newsletter" class="newsletter-image" />
|
||||||
<h2>
|
<h2>
|
||||||
@@ -421,6 +422,28 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="calendar-card">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<button type="button" class="calendar-nav" id="calendar-prev" aria-label="Monat zurück">‹</button>
|
||||||
|
<div class="calendar-title" id="calendar-title">
|
||||||
|
<span class="hide-de">Kalender</span>
|
||||||
|
<span class="hide-en">Calendar</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="calendar-nav" id="calendar-next" aria-label="Monat vor">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-weekdays">
|
||||||
|
<span class="hide-de">Mo</span><span class="hide-de">Di</span><span class="hide-de">Mi</span><span class="hide-de">Do</span><span class="hide-de">Fr</span><span class="hide-de">Sa</span><span class="hide-de">So</span>
|
||||||
|
<span class="hide-en">Mo</span><span class="hide-en">Tu</span><span class="hide-en">We</span><span class="hide-en">Th</span><span class="hide-en">Fr</span><span class="hide-en">Sa</span><span class="hide-en">Su</span>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-grid" id="calendar-grid"></div>
|
||||||
|
<div class="calendar-events" id="calendar-events">
|
||||||
|
<p class="calendar-events-hint">
|
||||||
|
<span class="hide-de">Termine werden geladen...</span>
|
||||||
|
<span class="hide-en">Loading events...</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+129
-3
@@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
currentLang = document.body.dataset.lang || 'de';
|
currentLang = document.body.dataset.lang || 'de';
|
||||||
checkSession();
|
checkSession();
|
||||||
loadCommunityStats();
|
loadCommunityStats();
|
||||||
|
initCalendar();
|
||||||
|
|
||||||
// Smooth scroll nav links
|
// Smooth scroll nav links
|
||||||
document.querySelectorAll('.nav-link').forEach(link => {
|
document.querySelectorAll('.nav-link').forEach(link => {
|
||||||
@@ -179,6 +180,7 @@ function initScrollReveal() {
|
|||||||
'.member-card',
|
'.member-card',
|
||||||
'.date-card',
|
'.date-card',
|
||||||
'.newsletter-card',
|
'.newsletter-card',
|
||||||
|
'.calendar-card',
|
||||||
'.btn-cta',
|
'.btn-cta',
|
||||||
'.hero-title',
|
'.hero-title',
|
||||||
'.hero-subtitle'
|
'.hero-subtitle'
|
||||||
@@ -218,7 +220,7 @@ function initScrollReveal() {
|
|||||||
// initScrollReveal() ran, so they were never observed -> stuck at opacity 0.
|
// initScrollReveal() ran, so they were never observed -> stuck at opacity 0.
|
||||||
// ========================================
|
// ========================================
|
||||||
function observeRevealCards(container) {
|
function observeRevealCards(container) {
|
||||||
const els = (container || document).querySelectorAll('.wiki-card-new, .member-card, .date-card, .newsletter-card');
|
const els = (container || document).querySelectorAll('.wiki-card-new, .member-card, .date-card, .newsletter-card, .calendar-card');
|
||||||
if (!revealObserver) {
|
if (!revealObserver) {
|
||||||
// Reduced-motion (observer never created) or init missed: show immediately
|
// Reduced-motion (observer never created) or init missed: show immediately
|
||||||
els.forEach(el => el.classList.add('visible'));
|
els.forEach(el => el.classList.add('visible'));
|
||||||
@@ -267,6 +269,7 @@ function setLanguage(lang) {
|
|||||||
document.querySelectorAll('.lang-btn').forEach(b => {
|
document.querySelectorAll('.lang-btn').forEach(b => {
|
||||||
b.classList.toggle('active', b.dataset.lang === lang);
|
b.classList.toggle('active', b.dataset.lang === lang);
|
||||||
});
|
});
|
||||||
|
renderCalendar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDE() {
|
function isDE() {
|
||||||
@@ -906,21 +909,39 @@ function escapeHtml(text) {
|
|||||||
// ========================================
|
// ========================================
|
||||||
// DATES / EVENTS
|
// DATES / EVENTS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
let scheduleData = [];
|
||||||
|
|
||||||
|
// Schedule webhook may return { data: [...] } or [{ data: [...] }] — normalize
|
||||||
|
function normalizeScheduleResult(result) {
|
||||||
|
if (!result) return [];
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
for (const item of result) {
|
||||||
|
if (item && Array.isArray(item.data)) return item.data;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(result.data)) return result.data;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDates() {
|
async function loadDates() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(SCHEDULE_URL);
|
const response = await fetch(SCHEDULE_URL);
|
||||||
if (!response.ok) throw new Error('Failed to fetch');
|
if (!response.ok) throw new Error('Failed to fetch');
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
scheduleData = normalizeScheduleResult(result);
|
||||||
|
|
||||||
if (result.data && Array.isArray(result.data) && result.data.length > 0) {
|
if (scheduleData.length > 0) {
|
||||||
renderDates(result.data);
|
renderDates(scheduleData);
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('dates-list').innerHTML =
|
document.getElementById('dates-list').innerHTML =
|
||||||
'<p class="no-events">' + (isDE() ? 'Keine Termine geplant' : 'No events scheduled') + '</p>';
|
'<p class="no-events">' + (isDE() ? 'Keine Termine geplant' : 'No events scheduled') + '</p>';
|
||||||
}
|
}
|
||||||
|
renderCalendar();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById('dates-list').innerHTML =
|
document.getElementById('dates-list').innerHTML =
|
||||||
'<p class="no-events">' + (isDE() ? 'Fehler beim Laden' : 'Error loading events') + '</p>';
|
'<p class="no-events">' + (isDE() ? 'Fehler beim Laden' : 'Error loading events') + '</p>';
|
||||||
|
renderCalendar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,6 +994,111 @@ function renderDates(events) {
|
|||||||
observeRevealCards(container);
|
observeRevealCards(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// CALENDAR (next to newsletter) — month view from schedule webhook
|
||||||
|
// ========================================
|
||||||
|
const MONTH_NAMES_DE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
|
const MONTH_NAMES_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
let calendarDate = new Date();
|
||||||
|
|
||||||
|
function isoDay(d) {
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsByDay() {
|
||||||
|
const map = {};
|
||||||
|
scheduleData.forEach(ev => {
|
||||||
|
if (!ev || !ev.start) return;
|
||||||
|
const key = isoDay(new Date(ev.start));
|
||||||
|
(map[key] = map[key] || []).push(ev);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCalendar() {
|
||||||
|
const prev = document.getElementById('calendar-prev');
|
||||||
|
const next = document.getElementById('calendar-next');
|
||||||
|
if (!prev || !next) return;
|
||||||
|
prev.addEventListener('click', () => {
|
||||||
|
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1);
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
next.addEventListener('click', () => {
|
||||||
|
calendarDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1);
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
const grid = document.getElementById('calendar-grid');
|
||||||
|
const title = document.getElementById('calendar-title');
|
||||||
|
if (!grid || !title) return;
|
||||||
|
|
||||||
|
const lang = isDE() ? MONTH_NAMES_DE : MONTH_NAMES_EN;
|
||||||
|
const year = calendarDate.getFullYear();
|
||||||
|
const month = calendarDate.getMonth();
|
||||||
|
title.innerHTML = '<span>' + escapeHtml(lang[month]) + ' ' + year + '</span>';
|
||||||
|
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
// Monday as first weekday: JS getDay() -> 0=Sun..6=Sat
|
||||||
|
let startOffset = (firstDay.getDay() + 6) % 7;
|
||||||
|
|
||||||
|
const todayKey = isoDay(new Date());
|
||||||
|
const eventMap = eventsByDay();
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < startOffset; i++) {
|
||||||
|
html += '<div class="calendar-day empty"></div>';
|
||||||
|
}
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const key = isoDay(new Date(year, month, day));
|
||||||
|
const dayEvents = eventMap[key] || [];
|
||||||
|
const classes = ['calendar-day'];
|
||||||
|
if (key === todayKey) classes.push('today');
|
||||||
|
if (dayEvents.length > 0) classes.push('has-event');
|
||||||
|
let cell = '<div class="' + classes.join(' ') + '"' +
|
||||||
|
(dayEvents.length ? ' title="' + escapeHtml(dayEvents.map(e => e.headline || 'Event').join(', ')) + '"' : '') + '>';
|
||||||
|
cell += '<span class="calendar-day-number">' + day + '</span>';
|
||||||
|
if (dayEvents.length > 0) {
|
||||||
|
cell += '<span class="calendar-dots">' + '<i></i>'.repeat(Math.min(dayEvents.length, 3)) + '</span>';
|
||||||
|
}
|
||||||
|
cell += '</div>';
|
||||||
|
html += cell;
|
||||||
|
}
|
||||||
|
grid.innerHTML = html;
|
||||||
|
|
||||||
|
// Event list below: upcoming events from this month (or hint)
|
||||||
|
const eventsEl = document.getElementById('calendar-events');
|
||||||
|
if (!eventsEl) return;
|
||||||
|
const monthEvents = scheduleData
|
||||||
|
.filter(ev => {
|
||||||
|
const d = new Date(ev.start);
|
||||||
|
return d.getFullYear() === year && d.getMonth() === month;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(a.start) - new Date(b.start));
|
||||||
|
|
||||||
|
if (monthEvents.length === 0) {
|
||||||
|
eventsEl.innerHTML = '<p class="calendar-events-hint">' +
|
||||||
|
(isDE() ? 'Keine Termine in diesem Monat.' : 'No events this month.') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let list = '<ul class="calendar-event-list">';
|
||||||
|
monthEvents.forEach(ev => {
|
||||||
|
const sd = new Date(ev.start);
|
||||||
|
const ed = ev.end ? new Date(ev.end) : null;
|
||||||
|
const timeStr = String(sd.getHours()).padStart(2, '0') + ':' + String(sd.getMinutes()).padStart(2, '0') +
|
||||||
|
(ed ? '–' + String(ed.getHours()).padStart(2, '0') + ':' + String(ed.getMinutes()).padStart(2, '0') : '');
|
||||||
|
list += '<li><span class="calendar-event-date">' + String(sd.getDate()).padStart(2, '0') + '. ' + escapeHtml(lang[month].slice(0, 2)) + '</span>' +
|
||||||
|
'<span class="calendar-event-headline">' + escapeHtml(ev.headline || 'Event') + '</span>' +
|
||||||
|
'<span class="calendar-event-time">' + timeStr + '</span></li>';
|
||||||
|
});
|
||||||
|
list += '</ul>';
|
||||||
|
eventsEl.innerHTML = list;
|
||||||
|
}
|
||||||
|
|
||||||
function setCookie(name, value, days) {
|
function setCookie(name, value, days) {
|
||||||
const expires = new Date();
|
const expires = new Date();
|
||||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
|||||||
Reference in New Issue
Block a user