feat: Building the app in it's first version
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
'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 viewAbout = document.getElementById('view-about');
|
||||
const aboutTitle = document.getElementById('about-title');
|
||||
const aboutContent = document.getElementById('about-content');
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
let lastRepublicanResult = null;
|
||||
|
||||
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 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/months/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}.svg`;
|
||||
monthImage.alt = month.name || '';
|
||||
monthPeriod.textContent = month.period || '';
|
||||
monthName.textContent = month.name || '';
|
||||
monthSeasonSuffix.textContent = seasonSuffix;
|
||||
monthEtymology.textContent = month.etymology || '';
|
||||
monthDescription.textContent = month.description || '';
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
|
||||
if (hash in ABOUT_ROUTES) {
|
||||
viewHome.hidden = true;
|
||||
viewAbout.hidden = false;
|
||||
renderAboutView(ABOUT_ROUTES[hash]);
|
||||
const activeLink = document.querySelector(`.nav-link[href="#/${hash}"]`);
|
||||
if (activeLink) activeLink.classList.add('active');
|
||||
} else {
|
||||
viewHome.hidden = false;
|
||||
viewAbout.hidden = true;
|
||||
const activeLink = document.querySelector('.nav-link[href="#/"]');
|
||||
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);
|
||||
|
||||
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();
|
||||
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 I18N.load(initialLang);
|
||||
langSwitch.textContent = initialLang.toUpperCase();
|
||||
route();
|
||||
await loadToday();
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const I18N = (() => {
|
||||
const SUPPORTED_LANGS = ['fr', 'en'];
|
||||
const DEFAULT_LANG = 'fr';
|
||||
const STORAGE_KEY = 'republican-calendar-lang';
|
||||
|
||||
let currentLang = DEFAULT_LANG;
|
||||
let common = {};
|
||||
let months = {};
|
||||
let about = {};
|
||||
const changeListeners = [];
|
||||
|
||||
function detectInitialLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
|
||||
} catch (error) {
|
||||
// localStorage unavailable (private mode, etc.): fall through to default.
|
||||
}
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
function getByPath(object, path) {
|
||||
return path.split('.').reduce((node, key) => (node && node[key] !== undefined ? node[key] : undefined), object);
|
||||
}
|
||||
|
||||
function t(path, fallback) {
|
||||
const value = getByPath(common, path);
|
||||
return value !== undefined ? value : (fallback !== undefined ? fallback : path);
|
||||
}
|
||||
|
||||
function applyStaticTranslations() {
|
||||
document.documentElement.lang = currentLang;
|
||||
document.querySelectorAll('[data-i18n]').forEach((element) => {
|
||||
const key = element.getAttribute('data-i18n');
|
||||
const value = getByPath(common, key);
|
||||
if (value !== undefined) {
|
||||
element.textContent = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${url}: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function load(lang) {
|
||||
const targetLang = SUPPORTED_LANGS.includes(lang) ? lang : DEFAULT_LANG;
|
||||
const [commonData, monthsData, aboutData] = await Promise.all([
|
||||
fetchJson(`locales/${targetLang}/common.json`),
|
||||
fetchJson(`locales/${targetLang}/months.json`),
|
||||
fetchJson(`locales/${targetLang}/about.json`),
|
||||
]);
|
||||
currentLang = targetLang;
|
||||
common = commonData;
|
||||
months = monthsData;
|
||||
about = aboutData;
|
||||
applyStaticTranslations();
|
||||
changeListeners.forEach((listener) => listener(currentLang));
|
||||
}
|
||||
|
||||
async function setLang(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang) || lang === currentLang) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch (error) {
|
||||
// Ignore storage failures; language just won't persist across reloads.
|
||||
}
|
||||
await load(lang);
|
||||
}
|
||||
|
||||
function onChange(listener) {
|
||||
changeListeners.push(listener);
|
||||
}
|
||||
|
||||
return {
|
||||
SUPPORTED_LANGS,
|
||||
get currentLang() { return currentLang; },
|
||||
get common() { return common; },
|
||||
get months() { return months; },
|
||||
get about() { return about; },
|
||||
detectInitialLang,
|
||||
load,
|
||||
setLang,
|
||||
onChange,
|
||||
t,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user