feat: Building the app in it's first version
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||