feat: Building the app in it's first version

This commit is contained in:
gauvainboiche
2026-09-15 23:12:57 +02:00
parent 34792e2782
commit 64a3d9b556
42 changed files with 2699 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Brumaire">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<circle cx="62" cy="46" r="12" fill="#e9dcb8" opacity="0.8"/>
<g fill="none" stroke="#8a9a94" stroke-width="3" stroke-linecap="round">
<path d="M28 56 Q38 50 48 56 T68 56 T92 56"/>
<path d="M24 68 Q34 62 44 68 T64 68 T96 68"/>
<path d="M30 80 Q40 74 50 80 T70 80 T90 80"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

+9
View File
@@ -0,0 +1,9 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Jours complémentaires">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<g fill="none" stroke="#7a9a5a" stroke-width="3" stroke-linecap="round">
<path d="M60 90 C40 84 32 66 38 46 C46 54 48 66 60 90 Z"/>
<path d="M60 90 C80 84 88 66 82 46 C74 54 72 66 60 90 Z"/>
</g>
<path d="M60 26 L64 38 L76 38 L66 46 L70 58 L60 50 L50 58 L54 46 L44 38 L56 38 Z"
fill="#e8b84b" stroke="#a97e1f" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Floréal">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<path d="M60 68 L60 92" stroke="#5c7a4a" stroke-width="3" stroke-linecap="round"/>
<path d="M60 82 C52 80 48 74 50 70 C58 70 62 76 60 82 Z" fill="#7a9a5a" stroke="#4f6b3d" stroke-width="1.2"/>
<g fill="#c76b8a" stroke="#8a3b58" stroke-width="1.2">
<ellipse cx="60" cy="44" rx="8" ry="12"/>
<ellipse cx="60" cy="44" rx="8" ry="12" transform="rotate(60 60 44)"/>
<ellipse cx="60" cy="44" rx="8" ry="12" transform="rotate(120 60 44)"/>
<ellipse cx="60" cy="44" rx="8" ry="12" transform="rotate(180 60 44)"/>
<ellipse cx="60" cy="44" rx="8" ry="12" transform="rotate(240 60 44)"/>
<ellipse cx="60" cy="44" rx="8" ry="12" transform="rotate(300 60 44)"/>
</g>
<circle cx="60" cy="44" r="6" fill="#e8b84b" stroke="#a97e1f" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 956 B

+13
View File
@@ -0,0 +1,13 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Frimaire">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<g stroke="#6f8794" stroke-width="2.5" stroke-linecap="round">
<line x1="60" y1="32" x2="60" y2="88"/>
<line x1="32" y1="60" x2="88" y2="60"/>
<line x1="40" y1="40" x2="80" y2="80"/>
<line x1="40" y1="80" x2="80" y2="40"/>
<path d="M60 32 L54 40 M60 32 L66 40"/>
<path d="M60 88 L54 80 M60 88 L66 80"/>
<path d="M32 60 L40 54 M32 60 L40 66"/>
<path d="M88 60 L80 54 M88 60 L80 66"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 611 B

+8
View File
@@ -0,0 +1,8 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Fructidor">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<circle cx="50" cy="66" r="14" fill="#8a3b2b" stroke="#5c2318" stroke-width="1.5"/>
<path d="M50 52 C48 46 52 44 54 46" fill="none" stroke="#5c7a4a" stroke-width="2" stroke-linecap="round"/>
<path d="M52 46 C58 42 62 46 58 50 C54 50 52 48 52 46 Z" fill="#7a9a5a" stroke="#4f6b3d" stroke-width="1"/>
<path d="M74 74 C66 74 62 66 66 58 C74 58 78 66 74 74 Z" fill="#c9973b" stroke="#8a6b28" stroke-width="1.5"/>
<path d="M70 58 C68 52 72 50 74 52" fill="none" stroke="#5c7a4a" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

+7
View File
@@ -0,0 +1,7 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Germinal">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<ellipse cx="60" cy="86" rx="7" ry="5" fill="#8a6b45" stroke="#5c4326" stroke-width="1.5"/>
<path d="M60 82 C58 68 60 56 60 44" fill="none" stroke="#5c7a4a" stroke-width="3" stroke-linecap="round"/>
<path d="M60 60 C50 56 46 48 48 40 C58 42 62 50 60 60 Z" fill="#7a9a5a" stroke="#4f6b3d" stroke-width="1.5"/>
<path d="M60 68 C70 64 74 56 72 48 C62 50 58 58 60 68 Z" fill="#7a9a5a" stroke="#4f6b3d" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

+16
View File
@@ -0,0 +1,16 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Messidor">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<g stroke="#c9973b" stroke-width="2.5" stroke-linecap="round" fill="none">
<path d="M60 88 L48 42"/>
<path d="M60 88 L54 40"/>
<path d="M60 88 L60 38"/>
<path d="M60 88 L66 40"/>
<path d="M60 88 L72 42"/>
<path d="M48 42 q-2 -6 4 -8"/>
<path d="M72 42 q2 -6 -4 -8"/>
<path d="M54 40 q-2 -6 4 -8"/>
<path d="M66 40 q2 -6 -4 -8"/>
<path d="M60 38 q0 -6 0 -8"/>
</g>
<path d="M46 82 Q60 74 74 82 L74 90 Q60 84 46 90 Z" fill="#8a6b45" stroke="#5c4326" stroke-width="1.2"/>
</svg>

After

Width:  |  Height:  |  Size: 702 B

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Nivôse">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<g stroke="#5b6b7a" stroke-width="2.5" stroke-linecap="round">
<line x1="60" y1="28" x2="60" y2="92"/>
<line x1="28" y1="60" x2="92" y2="60"/>
<line x1="38" y1="38" x2="82" y2="82"/>
<line x1="38" y1="82" x2="82" y2="38"/>
<path d="M60 38 L54 44 M60 38 L66 44"/>
<path d="M60 82 L54 76 M60 82 L66 76"/>
<path d="M38 60 L44 54 M38 60 L44 66"/>
<path d="M82 60 L76 54 M82 60 L76 66"/>
</g>
<circle cx="60" cy="60" r="4" fill="#5b6b7a"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pluviôse">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<path d="M40 56 a12 12 0 0 1 4 -23.6 a15 15 0 0 1 29 -4 a13 13 0 0 1 8 26.6 Z"
fill="#dfe6ea" stroke="#5b6b7a" stroke-width="2"/>
<g fill="#5b7a94">
<path d="M46 76 q3 8 0 12 q-3 -4 0 -12 Z"/>
<path d="M60 80 q3 8 0 12 q-3 -4 0 -12 Z"/>
<path d="M74 76 q3 8 0 12 q-3 -4 0 -12 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 500 B

+15
View File
@@ -0,0 +1,15 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prairial">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<g fill="none" stroke="#5c7a4a" stroke-width="3" stroke-linecap="round">
<path d="M36 90 C34 74 40 64 38 50"/>
<path d="M48 90 C48 72 54 64 50 48"/>
<path d="M60 90 C60 70 56 60 60 44"/>
<path d="M72 90 C72 72 66 64 70 48"/>
<path d="M84 90 C86 74 80 64 82 50"/>
</g>
<g fill="#c76b8a" stroke="#8a3b58" stroke-width="1">
<circle cx="60" cy="42" r="5"/>
<circle cx="70" cy="46" r="4"/>
<circle cx="50" cy="46" r="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 649 B

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Thermidor">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<circle cx="60" cy="60" r="16" fill="#e8b84b" stroke="#b5652b" stroke-width="2"/>
<g stroke="#b5652b" stroke-width="3" stroke-linecap="round">
<line x1="60" y1="28" x2="60" y2="38"/>
<line x1="60" y1="82" x2="60" y2="92"/>
<line x1="28" y1="60" x2="38" y2="60"/>
<line x1="82" y1="60" x2="92" y2="60"/>
<line x1="39" y1="39" x2="46" y2="46"/>
<line x1="74" y1="74" x2="81" y2="81"/>
<line x1="81" y1="39" x2="74" y2="46"/>
<line x1="46" y1="74" x2="39" y2="81"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 694 B

+16
View File
@@ -0,0 +1,16 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Vendémiaire">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<path d="M60 30 C64 36 64 42 60 46" fill="none" stroke="#5c7a4a" stroke-width="2.5" stroke-linecap="round"/>
<path d="M60 34 C68 30 74 34 72 40 C66 42 61 39 60 34 Z" fill="#7a9a5a" stroke="#4f6b3d" stroke-width="1.5"/>
<g fill="#7a4a6b" stroke="#452a3a" stroke-width="1.2">
<circle cx="52" cy="56" r="6.5"/>
<circle cx="68" cy="56" r="6.5"/>
<circle cx="60" cy="52" r="6.5"/>
<circle cx="46" cy="66" r="6.5"/>
<circle cx="60" cy="64" r="6.5"/>
<circle cx="74" cy="66" r="6.5"/>
<circle cx="53" cy="76" r="6.5"/>
<circle cx="67" cy="76" r="6.5"/>
<circle cx="60" cy="86" r="6.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 820 B

+8
View File
@@ -0,0 +1,8 @@
<svg viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ventôse">
<circle cx="60" cy="60" r="54" fill="#f6efe0" stroke="#8a6b45" stroke-width="2"/>
<g fill="none" stroke="#7d8a76" stroke-width="3" stroke-linecap="round">
<path d="M26 46 C50 36 62 46 78 40"/>
<path d="M22 62 C52 50 66 64 94 54"/>
<path d="M30 78 C54 70 64 80 84 72"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 395 B

+446
View File
@@ -0,0 +1,446 @@
:root {
--cream-bg: #faf3e6;
--cream-panel: #f6efe0;
--cream-panel-alt: #efe4cc;
--ink: #3a2e22;
--ink-soft: #6b5a45;
--accent: #b5652b;
--accent-soft: #d99a5b;
--brass: #8a6b45;
--border: #d8c9a8;
--shadow: 0 6px 20px rgba(90, 66, 34, 0.12);
color-scheme: light;
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html, body {
margin: 0;
padding: 0;
}
body {
background: var(--cream-bg);
background-image: radial-gradient(circle at 10% 10%, rgba(181, 101, 43, 0.05), transparent 40%),
radial-gradient(circle at 90% 20%, rgba(138, 107, 69, 0.06), transparent 45%);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', sans-serif;
line-height: 1.55;
min-height: 100vh;
display: flex;
flex-direction: column;
}
h1, h2, .brand-title, .republican-date, .republican-year {
font-family: 'Fraunces', 'Georgia', serif;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Header */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--cream-panel);
position: sticky;
top: 0;
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--ink);
}
.brand-mark {
font-family: 'Fraunces', serif;
font-style: italic;
font-size: 0.9rem;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 999px;
padding: 0.1rem 0.6rem;
}
.brand-title {
font-size: 1.15rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.lang-switch {
border: 1px solid var(--brass);
background: transparent;
color: var(--ink);
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.lang-switch:hover {
background: var(--cream-panel-alt);
}
.menu-toggle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 36px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--cream-bg);
cursor: pointer;
padding: 0 8px;
}
.burger-bar {
display: block;
height: 2px;
background: var(--ink);
border-radius: 2px;
}
/* Nav panel */
.main-nav {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: min(300px, 85vw);
background: var(--cream-panel);
border-left: 1px solid var(--border);
box-shadow: var(--shadow);
padding: calc(1.5rem + env(safe-area-inset-top, 0px)) 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 30;
}
.main-nav.open {
transform: translateX(0);
}
.nav-link {
color: var(--ink);
text-decoration: none;
padding: 0.6rem 0.5rem;
border-radius: 8px;
font-weight: 500;
}
.nav-link:hover, .nav-link.active {
background: var(--cream-panel-alt);
color: var(--accent);
}
.nav-section-label {
margin: 1rem 0 0.1rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-soft);
}
.nav-backdrop {
position: fixed;
inset: 0;
background: rgba(58, 46, 34, 0.35);
z-index: 25;
}
/* Main content */
main#app {
flex: 1;
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
.today-heading {
text-align: center;
font-weight: 400;
font-size: 1.4rem;
color: var(--ink-soft);
margin-bottom: 1.5rem;
}
.calendar-card {
display: grid;
grid-template-columns: minmax(180px, 220px) 1fr minmax(220px, 320px);
gap: 1.5rem;
align-items: stretch;
background: var(--cream-panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
box-shadow: var(--shadow);
}
.month-illustration {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
border-right: 1px dashed var(--border);
padding-right: 1.5rem;
}
.month-illustration img {
width: 100%;
max-width: 150px;
height: auto;
}
.month-period {
font-size: 0.85rem;
color: var(--ink-soft);
font-style: italic;
}
.date-display {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.3rem;
}
.decade-day {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
margin: 0;
}
.republican-date {
font-size: clamp(1.8rem, 4vw, 2.6rem);
margin: 0.1rem 0;
font-weight: 600;
}
.republican-year {
font-size: 1.1rem;
color: var(--ink-soft);
margin: 0;
}
.sextile-badge {
display: inline-block;
margin-top: 0.5rem;
background: var(--accent);
color: var(--cream-bg);
border-radius: 999px;
padding: 0.2rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.gregorian-equivalent {
margin-top: 0.75rem;
font-size: 0.85rem;
color: var(--ink-soft);
}
.description-panel {
border-left: 1px dashed var(--border);
padding-left: 1.5rem;
}
.month-name {
margin: 0 0 0.2rem;
font-size: 1.3rem;
}
.month-season-suffix {
font-size: 0.8rem;
color: var(--brass);
margin: 0 0 0.6rem;
}
.month-etymology {
font-style: italic;
color: var(--ink-soft);
margin: 0 0 0.6rem;
}
.month-description {
margin: 0;
}
/* Form */
.convert-form {
margin-top: 2rem;
background: var(--cream-panel-alt);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
}
.convert-form h2 {
margin-top: 0;
font-size: 1.1rem;
}
.convert-form label {
display: block;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--ink-soft);
}
.convert-form-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.convert-form-row input[type="date"] {
font-family: inherit;
font-size: 1rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--cream-bg);
color: var(--ink);
}
.convert-form-row button {
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
padding: 0.55rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
}
.convert-form-row button.secondary {
background: transparent;
color: var(--accent);
}
.convert-form-row button:hover {
filter: brightness(1.05);
}
.form-help {
margin: 0.75rem 0 0;
font-size: 0.8rem;
color: var(--ink-soft);
}
.form-error {
margin: 0.75rem 0 0;
font-size: 0.85rem;
color: #a13f2b;
font-weight: 600;
}
/* About view */
.about-view {
background: var(--cream-panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
box-shadow: var(--shadow);
}
.about-view h1 {
margin-top: 0;
}
.about-content h2 {
font-size: 1.15rem;
color: var(--accent);
margin: 1.5rem 0 0.5rem;
}
.about-content h2:first-child {
margin-top: 0;
}
.about-content p {
margin: 0 0 0.75rem;
}
/* Footer */
.site-footer {
text-align: center;
padding: 1.5rem 1rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
font-size: 0.8rem;
color: var(--ink-soft);
border-top: 1px solid var(--border);
}
/* Responsive */
@media (max-width: 820px) {
.calendar-card {
grid-template-columns: 1fr;
text-align: center;
}
.month-illustration,
.description-panel {
border: none;
padding: 0;
}
.month-illustration {
padding-bottom: 1rem;
border-bottom: 1px dashed var(--border);
}
.description-panel {
padding-top: 1rem;
border-top: 1px dashed var(--border);
}
}
@media (max-width: 480px) {
.site-header {
padding: 0.75rem 1rem;
}
main#app {
padding: 1.5rem 0.9rem 2rem;
}
.calendar-card, .convert-form, .about-view {
padding: 1.25rem;
}
}
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title data-i18n="site.title">Calendrier républicain</title>
<link rel="icon" href="data:,">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,500&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="#/">
<span class="brand-mark">an</span>
<span class="brand-title" data-i18n="site.title">Calendrier républicain</span>
</a>
<div class="header-actions">
<button id="lang-switch" class="lang-switch" type="button" aria-label="Switch language">FR</button>
<button id="menu-toggle" class="menu-toggle" type="button" aria-expanded="false" aria-controls="main-nav">
<span class="sr-only" data-i18n="nav.menuLabel">Menu</span>
<span class="burger-bar"></span>
<span class="burger-bar"></span>
<span class="burger-bar"></span>
</button>
</div>
</header>
<nav id="main-nav" class="main-nav" aria-hidden="true">
<a href="#/" class="nav-link" data-view="home" data-i18n="nav.home">Accueil</a>
<p class="nav-section-label" data-i18n="nav.about">À propos</p>
<a href="#/about/history" class="nav-link" data-view="history" data-i18n="nav.aboutHistory">Histoire du calendrier</a>
<a href="#/about/leap-years" class="nav-link" data-view="leap-years" data-i18n="nav.aboutLeapYears">Les années sextiles</a>
<a href="#/about/complementary-days" class="nav-link" data-view="complementary-days" data-i18n="nav.aboutComplementaryDays">Les jours complémentaires</a>
</nav>
<div id="nav-backdrop" class="nav-backdrop" hidden></div>
<main id="app">
<section id="view-home" class="view">
<h1 class="today-heading">
<span data-i18n="home.todayHeading">Nous sommes aujourd'hui le</span>
</h1>
<div class="calendar-card">
<div class="month-illustration">
<img id="month-image" src="assets/months/vendemiaire.svg" alt="" width="140" height="140">
<p id="month-period" class="month-period"></p>
</div>
<div class="date-display">
<p id="decade-day" class="decade-day"></p>
<p id="republican-date" class="republican-date"></p>
<p id="republican-year" class="republican-year"></p>
<span id="sextile-badge" class="sextile-badge" hidden data-i18n="home.sextileBadge">Année sextile</span>
<p class="gregorian-equivalent">
<span data-i18n="home.gregorianEquivalent">Équivalent grégorien</span> :
<span id="gregorian-equivalent-value"></span>
</p>
</div>
<div class="description-panel">
<h2 id="month-name" class="month-name"></h2>
<p id="month-season-suffix" class="month-season-suffix"></p>
<p id="month-etymology" class="month-etymology"></p>
<p id="month-description" class="month-description"></p>
</div>
</div>
<form id="convert-form" class="convert-form">
<h2 data-i18n="form.title">Convertir une autre date</h2>
<label for="date-input" data-i18n="form.dateLabel">Date grégorienne (JJ/MM/AAAA)</label>
<div class="convert-form-row">
<input type="date" id="date-input" name="date" min="1792-09-22" required>
<button type="submit" data-i18n="form.submit">Convertir</button>
<button type="button" id="today-button" class="secondary" data-i18n="form.todayButton">Revenir à aujourd'hui</button>
</div>
<p class="form-help" data-i18n="form.helpText">Sélectionnez un jour, un mois et une année : le formulaire vérifie automatiquement que la date existe.</p>
<p id="form-error" class="form-error" hidden></p>
</form>
</section>
<section id="view-about" class="view about-view" hidden>
<h1 id="about-title"></h1>
<div id="about-content" class="about-content"></div>
</section>
</main>
<footer class="site-footer">
<p data-i18n="footer.text">Dates calculées selon la règle arithmétique perpétuelle proposée par Gilbert Romme (19 floréal an III / 8 mai 1795).</p>
</footer>
<script src="js/i18n.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+230
View File
@@ -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();
})();
+93
View File
@@ -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,
};
})();