445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
'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 <input type="date">
|
|
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();
|
|
})();
|