Files
calendrier_republicain/public/js/app.js
T

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();
})();