'use strict'; (() => { const monthImage = document.getElementById('month-image'); const monthPeriod = document.getElementById('month-period'); const decadeDay = document.getElementById('decade-day'); const republicanDate = document.getElementById('republican-date'); const republicanYear = document.getElementById('republican-year'); const sextileBadge = document.getElementById('sextile-badge'); const gregorianEquivalentValue = document.getElementById('gregorian-equivalent-value'); const monthName = document.getElementById('month-name'); const monthSeasonSuffix = document.getElementById('month-season-suffix'); const monthEtymology = document.getElementById('month-etymology'); const monthDescription = document.getElementById('month-description'); const dateInput = document.getElementById('date-input'); const convertForm = document.getElementById('convert-form'); const todayButton = document.getElementById('today-button'); const formError = document.getElementById('form-error'); const viewHome = document.getElementById('view-home'); const viewCalendar = document.getElementById('view-calendar'); const viewAbout = document.getElementById('view-about'); const aboutTitle = document.getElementById('about-title'); const aboutContent = document.getElementById('about-content'); const posterGrid = document.getElementById('poster-grid'); const posterFeasts = document.getElementById('poster-feasts'); const monthDialog = document.getElementById('month-dialog'); const dialogImage = document.getElementById('dialog-image'); const dialogName = document.getElementById('dialog-name'); const dialogPeriod = document.getElementById('dialog-period'); const dialogEtymology = document.getElementById('dialog-etymology'); const dialogDescription = document.getElementById('dialog-description'); const menuToggle = document.getElementById('menu-toggle'); const mainNav = document.getElementById('main-nav'); const navBackdrop = document.getElementById('nav-backdrop'); const langSwitch = document.getElementById('lang-switch'); const ABOUT_ROUTES = { 'about/history': 'history', 'about/leap-years': 'leapYears', 'about/complementary-days': 'complementaryDays', }; // Season columns of the full calendar; `image` is the month plate shown in the arch, // `focus` its vertical crop position so that the face stays in the arch. const SEASONS = [ { key: 'autumn', image: 'vendemiaire', focus: '23%', months: ['vendemiaire', 'brumaire', 'frimaire'] }, { key: 'winter', image: 'nivose', focus: '18%', months: ['nivose', 'pluviose', 'ventose'] }, { key: 'spring', image: 'floreal', focus: '18%', months: ['germinal', 'floreal', 'prairial'] }, { key: 'summer', image: 'messidor', focus: '18%', months: ['messidor', 'thermidor', 'fructidor'] }, ]; const DECADE_DAY_KEYS = [ 'primidi', 'duodi', 'tridi', 'quartidi', 'quintidi', 'sextidi', 'septidi', 'octidi', 'nonidi', 'decadi', ]; const COMPLEMENTARY_DAY_KEYS = ['virtue', 'genius', 'labour', 'opinion', 'rewards', 'revolution']; let lastRepublicanResult = null; let dayNames = {}; function pad(value) { return String(value).padStart(2, '0'); } function toIsoDate({ year, month, day }) { return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`; } function formatGregorian({ year, month, day }, weekdayKey) { const weekdayLabel = I18N.t(`weekdays.${weekdayKey}`, ''); return `${weekdayLabel} ${pad(day)}/${pad(month)}/${year}`.trim(); } function createElement(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; } function closeMenu() { mainNav.classList.remove('open'); mainNav.setAttribute('aria-hidden', 'true'); navBackdrop.hidden = true; menuToggle.setAttribute('aria-expanded', 'false'); } function openMenu() { mainNav.classList.add('open'); mainNav.setAttribute('aria-hidden', 'false'); navBackdrop.hidden = false; menuToggle.setAttribute('aria-expanded', 'true'); } function renderRepublicanResult(result) { lastRepublicanResult = result; const months = I18N.months.months || {}; const decadeDays = I18N.months.decadeDays || {}; const complementary = I18N.months.complementaryDays || { days: {} }; sextileBadge.hidden = !result.isSextile; gregorianEquivalentValue.textContent = formatGregorian(result.gregorianDate, result.gregorianWeekdayKey); republicanYear.textContent = `${I18N.t('home.yearLabel')} ${result.republicanYearRoman} (${result.republicanYear})`; if (result.isComplementaryDay) { const day = complementary.days[result.complementaryDayKey] || {}; decadeDay.textContent = complementary.title || ''; republicanDate.textContent = day.name || ''; monthImage.src = 'assets/complementary.svg'; monthImage.alt = day.name || ''; monthPeriod.textContent = ''; monthName.textContent = day.name || ''; monthSeasonSuffix.textContent = ''; monthEtymology.textContent = ''; monthDescription.textContent = day.description || ''; } else { const month = months[result.monthKey] || {}; const seasonSuffix = (I18N.months.seasonSuffix || {})[month.season] || ''; decadeDay.textContent = decadeDays[result.decadeDayKey] || ''; republicanDate.textContent = `${result.dayOfMonth} ${month.name || ''}`.trim(); monthImage.src = `assets/months/${result.monthKey}.jpg`; monthImage.alt = month.name || ''; monthPeriod.textContent = month.period || ''; monthName.textContent = month.name || ''; monthSeasonSuffix.textContent = seasonSuffix; monthEtymology.textContent = month.etymology || ''; monthDescription.textContent = month.description || ''; } markToday(); } /** Highlights the displayed republican date inside the full calendar. */ function markToday() { posterGrid.querySelectorAll('.today, .today-row, .is-current').forEach((element) => { element.classList.remove('today', 'today-row', 'is-current'); }); posterFeasts.querySelectorAll('.today').forEach((element) => element.classList.remove('today')); const result = lastRepublicanResult; if (!result) return; if (result.isComplementaryDay) { const feast = posterFeasts.querySelector(`[data-feast="${result.complementaryDayKey}"]`); if (feast) feast.classList.add('today'); return; } const cell = posterGrid.querySelector( `td[data-month="${result.monthKey}"][data-day="${result.dayOfMonth}"]` ); if (cell) { cell.classList.add('today'); cell.parentElement.firstElementChild.classList.add('today-row'); } const monthButton = posterGrid.querySelector(`.month-button[data-month="${result.monthKey}"]`); if (monthButton) monthButton.classList.add('is-current'); } function buildSeasonTable(season) { const months = I18N.months.months || {}; const decadeDays = I18N.months.decadeDays || {}; const decades = I18N.t('calendar.decades', []); const table = createElement('table', 'season-table'); const colgroup = table.appendChild(document.createElement('colgroup')); colgroup.appendChild(createElement('col', 'col-day')); season.months.forEach(() => colgroup.appendChild(document.createElement('col'))); const headRow = table.createTHead().insertRow(); headRow.appendChild(createElement('th', 'day-head', I18N.t('calendar.dayColumn'))); season.months.forEach((monthKey) => { const th = createElement('th'); th.scope = 'col'; const button = createElement('button', 'month-button', (months[monthKey] || {}).name || monthKey); button.type = 'button'; button.dataset.month = monthKey; th.appendChild(button); headRow.appendChild(th); }); const body = table.createTBody(); for (let decade = 0; decade < 3; decade += 1) { const decadeRow = body.insertRow(); decadeRow.className = 'decade-row'; const decadeHead = createElement('th', '', decades[decade] || ''); decadeHead.colSpan = season.months.length + 1; decadeRow.appendChild(decadeHead); for (let index = 0; index < 10; index += 1) { const day = decade * 10 + index + 1; const row = body.insertRow(); if (index === 9) row.className = 'decade-end'; const dayHead = createElement('th'); dayHead.scope = 'row'; dayHead.appendChild(createElement('span', 'day-number', String(day))); const decadeDayName = decadeDays[DECADE_DAY_KEYS[index]] || ''; const abbreviation = createElement('abbr', '', `${decadeDayName.slice(0, 3)}.`); abbreviation.title = decadeDayName; dayHead.appendChild(abbreviation); row.appendChild(dayHead); season.months.forEach((monthKey) => { const cell = createElement('td', '', (dayNames[monthKey] || [])[day - 1] || ''); cell.dataset.month = monthKey; cell.dataset.day = String(day); row.appendChild(cell); }); } } return table; } function buildSeason(season) { const section = createElement('section', 'season'); const arch = createElement('div', 'arch'); const image = createElement('img'); image.src = `assets/months/${season.image}.jpg`; image.alt = ''; image.loading = 'lazy'; image.style.objectPosition = `50% ${season.focus}`; arch.appendChild(image); section.appendChild(arch); section.appendChild(createElement('h2', 'season-title', I18N.t(`calendar.seasons.${season.key}`))); section.appendChild(buildSeasonTable(season)); return section; } function buildPosterCenter() { const center = createElement('aside', 'poster-center'); const medallion = createElement('div', 'medallion'); const inner = createElement('div', 'medallion-inner'); const fasces = createElement('img'); fasces.src = 'assets/fasces.svg'; fasces.alt = ''; inner.appendChild(fasces); medallion.appendChild(inner); center.appendChild(medallion); const explanation = createElement('div', 'explanation'); explanation.appendChild(createElement('h2', '', I18N.t('calendar.explanationTitle'))); I18N.t('calendar.explanation', []).forEach((paragraph) => { explanation.appendChild(createElement('p', '', paragraph)); }); center.appendChild(explanation); return center; } function buildFeasts() { const complementary = I18N.months.complementaryDays || { days: {} }; const decadeDays = I18N.months.decadeDays || {}; posterFeasts.replaceChildren(); COMPLEMENTARY_DAY_KEYS.forEach((key, index) => { const feast = createElement('p', 'feast'); feast.dataset.feast = key; feast.appendChild(createElement('span', 'feast-day', decadeDays[DECADE_DAY_KEYS[index]] || '')); feast.appendChild(createElement('span', 'feast-name', (complementary.days[key] || {}).name || '')); if (key === 'revolution') { feast.appendChild(createElement('span', 'feast-note', I18N.t('calendar.sextileOnly'))); } posterFeasts.appendChild(feast); }); } function renderCalendarView() { posterGrid.replaceChildren(...SEASONS.map(buildSeason), buildPosterCenter()); buildFeasts(); markToday(); } function openMonthDialog(monthKey) { const month = (I18N.months.months || {})[monthKey]; if (!month) return; dialogImage.src = `assets/months/${monthKey}.jpg`; dialogImage.alt = month.name; dialogName.textContent = month.name; dialogPeriod.textContent = month.period || ''; dialogEtymology.textContent = month.etymology || ''; dialogDescription.textContent = month.description || ''; monthDialog.showModal(); } async function loadDayNames() { try { const response = await fetch('data/day-names.json'); dayNames = await response.json(); } catch (error) { dayNames = {}; } } function showFormError(messageKey) { formError.textContent = I18N.t(messageKey); formError.hidden = false; } function hideFormError() { formError.hidden = true; formError.textContent = ''; } async function fetchAndRender(url) { hideFormError(); const response = await fetch(url); const payload = await response.json(); if (!payload.ok) { if (payload.error === 'BEFORE_EPOCH') { showFormError('form.errorBeforeEpoch'); } else if (payload.error === 'INVALID_DATE') { showFormError('form.errorInvalid'); } else { showFormError('form.errorGeneric'); } return; } renderRepublicanResult(payload.republicanDate); dateInput.value = toIsoDate(payload.republicanDate.gregorianDate); } async function loadToday() { try { await fetchAndRender('/api/today'); } catch (error) { showFormError('form.errorGeneric'); } } async function handleConvertSubmit(event) { event.preventDefault(); const value = dateInput.value; // yyyy-mm-dd from if (!value) return; const [year, month, day] = value.split('-').map((part) => Number.parseInt(part, 10)); try { await fetchAndRender(`/api/convert?year=${year}&month=${month}&day=${day}`); } catch (error) { showFormError('form.errorGeneric'); } } function renderAboutView(sectionKey) { const section = I18N.about[sectionKey]; if (!section) return; aboutTitle.textContent = section.title; aboutContent.innerHTML = ''; (section.sections || []).forEach((block) => { const heading = document.createElement('h2'); heading.textContent = block.heading; aboutContent.appendChild(heading); (block.paragraphs || []).forEach((paragraph) => { const p = document.createElement('p'); p.textContent = paragraph; aboutContent.appendChild(p); }); }); } function route() { const hash = window.location.hash.replace(/^#\/?/, ''); closeMenu(); document.querySelectorAll('.nav-link').forEach((link) => link.classList.remove('active')); viewHome.hidden = true; viewCalendar.hidden = true; viewAbout.hidden = true; let activeHref = '#/'; if (hash === 'calendar') { viewCalendar.hidden = false; activeHref = '#/calendar'; } else if (hash in ABOUT_ROUTES) { viewAbout.hidden = false; renderAboutView(ABOUT_ROUTES[hash]); activeHref = `#/${hash}`; } else { viewHome.hidden = false; } const activeLink = document.querySelector(`.nav-link[href="${activeHref}"]`); if (activeLink) activeLink.classList.add('active'); } function bindEvents() { menuToggle.addEventListener('click', () => { if (mainNav.classList.contains('open')) { closeMenu(); } else { openMenu(); } }); navBackdrop.addEventListener('click', closeMenu); window.addEventListener('hashchange', route); posterGrid.addEventListener('click', (event) => { const button = event.target.closest('.month-button'); if (button) openMonthDialog(button.dataset.month); }); // A click on the ::backdrop targets the dialog itself, outside its box. monthDialog.addEventListener('click', (event) => { const box = monthDialog.getBoundingClientRect(); const inside = event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom; if (!inside) monthDialog.close(); }); convertForm.addEventListener('submit', handleConvertSubmit); todayButton.addEventListener('click', () => { loadToday(); }); langSwitch.addEventListener('click', async () => { const nextLang = I18N.currentLang === 'fr' ? 'en' : 'fr'; await I18N.setLang(nextLang); }); I18N.onChange((lang) => { langSwitch.textContent = lang.toUpperCase(); renderCalendarView(); if (lastRepublicanResult) { renderRepublicanResult(lastRepublicanResult); } if (!viewAbout.hidden) { const hash = window.location.hash.replace(/^#\/?/, ''); if (hash in ABOUT_ROUTES) renderAboutView(ABOUT_ROUTES[hash]); } }); } async function init() { bindEvents(); const initialLang = I18N.detectInitialLang(); await loadDayNames(); await I18N.load(initialLang); langSwitch.textContent = initialLang.toUpperCase(); route(); await loadToday(); } init(); })();