Initial commit — site configs
- vault.hidrago.click: Vaultwarden (password manager) - git.hidrago.click: Gitea (git service) - All static sites (suma, weather, combien-jour, tokyo-trip, etc.) - Nginx configs for all subdomains - Systemd user service for Vaultwarden
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# Ignore Docker data volumes (large, machine-specific)
|
||||||
|
vault.hidrago.click/vw-data/
|
||||||
|
git.hidrago.click/gitea-data/
|
||||||
|
git.hidrago.click/postgres-data/
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
server_name combien-jour.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/combien-jour.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/combien-jour.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/combien-jour.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = combien-jour.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name combien-jour.hidrago.click;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a static HTML page showing Louis and Maël's age in days and weeks (FR/EN/JA)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
CHILDREN = [
|
||||||
|
{"name": "Louis", "birthdate": date(2018, 10, 14), "emoji": "👦", "color": "#667eea"},
|
||||||
|
{"name": "Maël", "birthdate": date(2020, 6, 24), "emoji": "🧒", "color": "#764ba2"},
|
||||||
|
]
|
||||||
|
|
||||||
|
TODAY = date.today()
|
||||||
|
|
||||||
|
# UI translations
|
||||||
|
I18N = {
|
||||||
|
"fr": {
|
||||||
|
"title": "Combien de jours ? — Louis & Maël",
|
||||||
|
"heading": "📅 Combien de jours ?",
|
||||||
|
"born": "né le {date}",
|
||||||
|
"days": "jours",
|
||||||
|
"weeks": "semaines",
|
||||||
|
"years": "ans",
|
||||||
|
"months": "mois",
|
||||||
|
"extra_days": "+ {n} j",
|
||||||
|
"updated": "Mis à jour le",
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"title": "How Many Days? — Louis & Maël",
|
||||||
|
"heading": "📅 How Many Days?",
|
||||||
|
"born": "born on {date}",
|
||||||
|
"days": "days",
|
||||||
|
"weeks": "weeks",
|
||||||
|
"years": "years",
|
||||||
|
"months": "months",
|
||||||
|
"extra_days": "+ {n} d",
|
||||||
|
"updated": "Updated on",
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"title": "何日経った? — Louis & Maël",
|
||||||
|
"heading": "📅 何日経った?",
|
||||||
|
"born": "{date} 生まれ",
|
||||||
|
"days": "日",
|
||||||
|
"weeks": "週間",
|
||||||
|
"years": "歳",
|
||||||
|
"months": "ヶ月",
|
||||||
|
"extra_days": "+ {n} 日",
|
||||||
|
"updated": "更新日時",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Month names for date formatting
|
||||||
|
MONTHS = {
|
||||||
|
"fr": ["janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre"],
|
||||||
|
"en": ["January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"],
|
||||||
|
"ja": ["1月", "2月", "3月", "4月", "5月", "6月",
|
||||||
|
"7月", "8月", "9月", "10月", "11月", "12月"],
|
||||||
|
}
|
||||||
|
|
||||||
|
I18N_JSON = json.dumps(I18N, ensure_ascii=False)
|
||||||
|
MONTHS_JSON = json.dumps(MONTHS, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_age(birthdate):
|
||||||
|
delta = TODAY - birthdate
|
||||||
|
total_days = delta.days
|
||||||
|
weeks = total_days // 7
|
||||||
|
extra_days = total_days % 7
|
||||||
|
years = TODAY.year - birthdate.year
|
||||||
|
months = TODAY.month - birthdate.month
|
||||||
|
if TODAY.day < birthdate.day:
|
||||||
|
months -= 1
|
||||||
|
if months < 0:
|
||||||
|
years -= 1
|
||||||
|
months += 12
|
||||||
|
return {
|
||||||
|
"total_days": total_days,
|
||||||
|
"weeks": weeks,
|
||||||
|
"extra_days": extra_days,
|
||||||
|
"years": years,
|
||||||
|
"months": months,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cards_html = []
|
||||||
|
|
||||||
|
for child in CHILDREN:
|
||||||
|
age = calculate_age(child["birthdate"])
|
||||||
|
birth_y = child["birthdate"].year
|
||||||
|
birth_m = child["birthdate"].month
|
||||||
|
birth_d = child["birthdate"].day
|
||||||
|
|
||||||
|
card = f"""
|
||||||
|
<div class="card" style="border-top: 5px solid {child['color']};">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>{child['emoji']} {child['name']}</h2>
|
||||||
|
<span class="birthdate" data-i18n="born" data-y="{birth_y}" data-m="{birth_m}" data-d="{birth_d}"></span>
|
||||||
|
</div>
|
||||||
|
<div class="age-grid">
|
||||||
|
<div class="age-item big">
|
||||||
|
<span class="number">{age['total_days']:,}</span>
|
||||||
|
<span class="unit" data-i18n="days">jours</span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item big">
|
||||||
|
<span class="number">{age['weeks']:,}</span>
|
||||||
|
<span class="unit" data-i18n="weeks">semaines</span>
|
||||||
|
<span class="sub" data-i18n="extra_days" data-n="{age['extra_days']}"></span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item small">
|
||||||
|
<span class="number">{age['years']}</span>
|
||||||
|
<span class="unit" data-i18n="years">ans</span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item small">
|
||||||
|
<span class="number">{age['months']}</span>
|
||||||
|
<span class="unit" data-i18n="months">mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
cards_html.append(card)
|
||||||
|
|
||||||
|
cards = "\n".join(cards_html)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Combien de jours ? — Louis & Maël</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}}
|
||||||
|
.lang-switch {{
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
.lang-btn {{
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.lang-btn:hover {{ background: rgba(255,255,255,0.3); }}
|
||||||
|
.lang-btn.active {{
|
||||||
|
background: #fff;
|
||||||
|
color: #764ba2;
|
||||||
|
border-color: #fff;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}}
|
||||||
|
.subtitle {{
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 360px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}}
|
||||||
|
.card:hover {{ transform: translateY(-4px); }}
|
||||||
|
.card-header {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
.card-header h2 {{ font-size: 1.5rem; color: #333; }}
|
||||||
|
.birthdate {{ font-size: 0.8rem; color: #999; }}
|
||||||
|
.age-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}}
|
||||||
|
.age-item {{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}}
|
||||||
|
.age-item.big {{ grid-column: span 2; }}
|
||||||
|
.age-item.small {{ }}
|
||||||
|
.number {{
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
}}
|
||||||
|
.age-item.small .number {{ font-size: 1.8rem; }}
|
||||||
|
.unit {{
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}}
|
||||||
|
.sub {{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}}
|
||||||
|
footer {{
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||||
|
</div>
|
||||||
|
<h1 id="heading">📅 Combien de jours ?</h1>
|
||||||
|
<p class="subtitle" id="subtitle"></p>
|
||||||
|
<div class="container">
|
||||||
|
{cards}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span data-i18n="updated">Mis à jour le</span> {now.strftime('%d/%m/%Y à %H:%M UTC')}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const I18N = {I18N_JSON};
|
||||||
|
const MONTHS = {MONTHS_JSON};
|
||||||
|
let currentLang = 'fr';
|
||||||
|
|
||||||
|
function formatDate(y, m, d, lang) {{
|
||||||
|
if (lang === 'fr') return d + ' ' + MONTHS[lang][m-1] + ' ' + y;
|
||||||
|
if (lang === 'en') return MONTHS[lang][m-1] + ' ' + d + ', ' + y;
|
||||||
|
if (lang === 'ja') return y + '年' + MONTHS[lang][m-1] + d + '日';
|
||||||
|
}}
|
||||||
|
|
||||||
|
function applyLang(lang) {{
|
||||||
|
currentLang = lang;
|
||||||
|
const t = I18N[lang];
|
||||||
|
document.title = t.title;
|
||||||
|
document.getElementById('heading').textContent = t.heading;
|
||||||
|
|
||||||
|
// Today's date as subtitle
|
||||||
|
const today = new Date();
|
||||||
|
document.getElementById('subtitle').textContent = formatDate(today.getFullYear(), today.getMonth()+1, today.getDate(), lang);
|
||||||
|
|
||||||
|
// Generic i18n labels
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {{
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
if (!t[key]) return;
|
||||||
|
let text = t[key];
|
||||||
|
// Substitute date for born, n for extra_days
|
||||||
|
if (key === 'born') {{
|
||||||
|
const y = parseInt(el.getAttribute('data-y'));
|
||||||
|
const m = parseInt(el.getAttribute('data-m'));
|
||||||
|
const d = parseInt(el.getAttribute('data-d'));
|
||||||
|
text = text.replace('{{date}}', formatDate(y, m, d, lang));
|
||||||
|
}}
|
||||||
|
if (key === 'extra_days') {{
|
||||||
|
const n = el.getAttribute('data-n');
|
||||||
|
text = text.replace('{{n}}', n);
|
||||||
|
}}
|
||||||
|
el.textContent = text;
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||||
|
}});
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.addEventListener('click', () => {{
|
||||||
|
applyLang(btn.getAttribute('data-lang'));
|
||||||
|
localStorage.setItem('age-lang', btn.getAttribute('data-lang'));
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Auto-detect
|
||||||
|
const saved = localStorage.getItem('age-lang');
|
||||||
|
if (saved && I18N[saved]) {{
|
||||||
|
applyLang(saved);
|
||||||
|
}} else {{
|
||||||
|
const browserLang = navigator.language.slice(0, 2);
|
||||||
|
if (I18N[browserLang]) applyLang(browserLang);
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
html = generate_html()
|
||||||
|
output_path = "/home/jerome/hermes-sites/combien-jour.hidrago.click/index.html"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"Age page generated at {output_path}")
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Combien de jours ? — Louis & Maël</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.lang-btn {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.lang-btn:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
.lang-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #764ba2;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 360px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-4px); }
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.card-header h2 { font-size: 1.5rem; color: #333; }
|
||||||
|
.birthdate { font-size: 0.8rem; color: #999; }
|
||||||
|
.age-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.age-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.age-item.big { grid-column: span 2; }
|
||||||
|
.age-item.small { }
|
||||||
|
.number {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.age-item.small .number { font-size: 1.8rem; }
|
||||||
|
.unit {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||||
|
</div>
|
||||||
|
<h1 id="heading">📅 Combien de jours ?</h1>
|
||||||
|
<p class="subtitle" id="subtitle"></p>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="card" style="border-top: 5px solid #667eea;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>👦 Louis</h2>
|
||||||
|
<span class="birthdate" data-i18n="born" data-y="2018" data-m="10" data-d="14"></span>
|
||||||
|
</div>
|
||||||
|
<div class="age-grid">
|
||||||
|
<div class="age-item big">
|
||||||
|
<span class="number">2,847</span>
|
||||||
|
<span class="unit" data-i18n="days">jours</span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item big">
|
||||||
|
<span class="number">406</span>
|
||||||
|
<span class="unit" data-i18n="weeks">semaines</span>
|
||||||
|
<span class="sub" data-i18n="extra_days" data-n="5"></span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item small">
|
||||||
|
<span class="number">7</span>
|
||||||
|
<span class="unit" data-i18n="years">ans</span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item small">
|
||||||
|
<span class="number">9</span>
|
||||||
|
<span class="unit" data-i18n="months">mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="border-top: 5px solid #764ba2;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>🧒 Maël</h2>
|
||||||
|
<span class="birthdate" data-i18n="born" data-y="2020" data-m="6" data-d="24"></span>
|
||||||
|
</div>
|
||||||
|
<div class="age-grid">
|
||||||
|
<div class="age-item big">
|
||||||
|
<span class="number">2,228</span>
|
||||||
|
<span class="unit" data-i18n="days">jours</span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item big">
|
||||||
|
<span class="number">318</span>
|
||||||
|
<span class="unit" data-i18n="weeks">semaines</span>
|
||||||
|
<span class="sub" data-i18n="extra_days" data-n="2"></span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item small">
|
||||||
|
<span class="number">6</span>
|
||||||
|
<span class="unit" data-i18n="years">ans</span>
|
||||||
|
</div>
|
||||||
|
<div class="age-item small">
|
||||||
|
<span class="number">1</span>
|
||||||
|
<span class="unit" data-i18n="months">mois</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span data-i18n="updated">Mis à jour le</span> 30/07/2026 à 23:00 UTC
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const I18N = {"fr": {"title": "Combien de jours ? — Louis & Maël", "heading": "📅 Combien de jours ?", "born": "né le {date}", "days": "jours", "weeks": "semaines", "years": "ans", "months": "mois", "extra_days": "+ {n} j", "updated": "Mis à jour le"}, "en": {"title": "How Many Days? — Louis & Maël", "heading": "📅 How Many Days?", "born": "born on {date}", "days": "days", "weeks": "weeks", "years": "years", "months": "months", "extra_days": "+ {n} d", "updated": "Updated on"}, "ja": {"title": "何日経った? — Louis & Maël", "heading": "📅 何日経った?", "born": "{date} 生まれ", "days": "日", "weeks": "週間", "years": "歳", "months": "ヶ月", "extra_days": "+ {n} 日", "updated": "更新日時"}};
|
||||||
|
const MONTHS = {"fr": ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"], "en": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], "ja": ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]};
|
||||||
|
let currentLang = 'fr';
|
||||||
|
|
||||||
|
function formatDate(y, m, d, lang) {
|
||||||
|
if (lang === 'fr') return d + ' ' + MONTHS[lang][m-1] + ' ' + y;
|
||||||
|
if (lang === 'en') return MONTHS[lang][m-1] + ' ' + d + ', ' + y;
|
||||||
|
if (lang === 'ja') return y + '年' + MONTHS[lang][m-1] + d + '日';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLang(lang) {
|
||||||
|
currentLang = lang;
|
||||||
|
const t = I18N[lang];
|
||||||
|
document.title = t.title;
|
||||||
|
document.getElementById('heading').textContent = t.heading;
|
||||||
|
|
||||||
|
// Today's date as subtitle
|
||||||
|
const today = new Date();
|
||||||
|
document.getElementById('subtitle').textContent = formatDate(today.getFullYear(), today.getMonth()+1, today.getDate(), lang);
|
||||||
|
|
||||||
|
// Generic i18n labels
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
if (!t[key]) return;
|
||||||
|
let text = t[key];
|
||||||
|
// Substitute date for born, n for extra_days
|
||||||
|
if (key === 'born') {
|
||||||
|
const y = parseInt(el.getAttribute('data-y'));
|
||||||
|
const m = parseInt(el.getAttribute('data-m'));
|
||||||
|
const d = parseInt(el.getAttribute('data-d'));
|
||||||
|
text = text.replace('{date}', formatDate(y, m, d, lang));
|
||||||
|
}
|
||||||
|
if (key === 'extra_days') {
|
||||||
|
const n = el.getAttribute('data-n');
|
||||||
|
text = text.replace('{n}', n);
|
||||||
|
}
|
||||||
|
el.textContent = text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||||
|
});
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
applyLang(btn.getAttribute('data-lang'));
|
||||||
|
localStorage.setItem('age-lang', btn.getAttribute('data-lang'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-detect
|
||||||
|
const saved = localStorage.getItem('age-lang');
|
||||||
|
if (saved && I18N[saved]) {
|
||||||
|
applyLang(saved);
|
||||||
|
} else {
|
||||||
|
const browserLang = navigator.language.slice(0, 2);
|
||||||
|
if (I18N[browserLang]) applyLang(browserLang);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
server {
|
||||||
|
server_name espene-example.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/espene-example.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/espene-example.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/espene-example.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}server {
|
||||||
|
if ($host = espene-example.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name espene-example.hidrago.click;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Espène Collaborative WebSocket Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=jerome
|
||||||
|
WorkingDirectory=/home/jerome/hermes-sites/espene-example.hidrago.click
|
||||||
|
ExecStart=/home/linuxbrew/.linuxbrew/bin/node /home/jerome/hermes-sites/espene-example.hidrago.click/server.mjs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
Environment=ESPENE_PORT=8080
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
File diff suppressed because it is too large
Load Diff
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "espene-example.hidrago.click",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "espene-example.hidrago.click",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.21.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "espene-example.hidrago.click",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.21.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* Espène — WebSocket collaborative server v2
|
||||||
|
* Manages sessions with multiple pages, broadcasts strokes.
|
||||||
|
*
|
||||||
|
* Client → Server:
|
||||||
|
* { type: "create_session", name?: string }
|
||||||
|
* { type: "join", sessionId: string }
|
||||||
|
* { type: "leave" }
|
||||||
|
* { type: "list_sessions" }
|
||||||
|
* { type: "draw", action, x, y, color?, size?, isEraser? }
|
||||||
|
* { type: "undo" }
|
||||||
|
* { type: "clear" }
|
||||||
|
* { type: "switch_page", page: N }
|
||||||
|
* { type: "add_page" }
|
||||||
|
* { type: "delete_page", page: N }
|
||||||
|
* { type: "rename_page", page: N, name: string }
|
||||||
|
* { type: "pages_info" }
|
||||||
|
*
|
||||||
|
* Server → Client:
|
||||||
|
* { type: "sessions", sessions: [...] }
|
||||||
|
* { type: "session_joined", sessionId, clientId, pages: [...], currentPage }
|
||||||
|
* { type: "draw", ... } ← broadcast to other clients
|
||||||
|
* { type: "undo" } ← broadcast
|
||||||
|
* { type: "clear" } ← broadcast
|
||||||
|
* { type: "clients", count: N }
|
||||||
|
* { type: "page_switched", page, strokes, pageCount }
|
||||||
|
* { type: "page_added", page, pageCount }
|
||||||
|
* { type: "page_deleted", page, pageCount, currentPage }
|
||||||
|
* { type: "pages_info", pages: [...] }
|
||||||
|
* { type: "error", message }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.ESPENE_PORT || '8080', 10);
|
||||||
|
const MAX_STROKES_PER_PAGE = 2000;
|
||||||
|
|
||||||
|
// ── Session store ────────────────────────────────────────────────
|
||||||
|
// Map<sessionId, Session>
|
||||||
|
// Session = { id, name, pages: Page[], clientIds: Set<string> }
|
||||||
|
// Page = { name: string, strokes: Stroke[] }
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(name) {
|
||||||
|
const id = generateId();
|
||||||
|
sessions.set(id, {
|
||||||
|
id,
|
||||||
|
name: name || `Session ${id}`,
|
||||||
|
pages: [{ name: 'Page 1', strokes: [] }],
|
||||||
|
clientIds: new Set(),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSocket server ─────────────────────────────────────────────
|
||||||
|
const wss = new WebSocketServer({ port: PORT });
|
||||||
|
console.log(`[Espène WS v2] Server listening on port ${PORT}`);
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
const clientId = generateId();
|
||||||
|
let currentSessionId = null;
|
||||||
|
let currentPage = 0; // per-client page
|
||||||
|
|
||||||
|
function broadcastToSession(sessionId, message, exclude = null) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const raw = JSON.stringify(message);
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === 1 && client._sessionId === sessionId && client !== exclude) {
|
||||||
|
client.send(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastToAll(message) {
|
||||||
|
const raw = JSON.stringify(message);
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === 1) client.send(raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionList() {
|
||||||
|
return Array.from(sessions.entries())
|
||||||
|
.filter(([_, s]) => s.clientIds.size > 0 || s.pages.some(p => p.strokes.length > 0))
|
||||||
|
.map(([id, s]) => ({
|
||||||
|
id, name: s.name,
|
||||||
|
clientCount: s.clientIds.size,
|
||||||
|
pageCount: s.pages.length,
|
||||||
|
strokeCount: s.pages.reduce((sum, p) => sum + p.strokes.length, 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastSessionList() {
|
||||||
|
broadcastToAll({ type: 'sessions', sessions: getSessionList() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastClientCount(sessionId) {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
broadcastToSession(sessionId, { type: 'clients', count: session.clientIds.size, clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPageStrokes(session) {
|
||||||
|
if (!session || !session.pages[currentPage]) return [];
|
||||||
|
return session.pages[currentPage].strokes;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = currentSessionId ? sessions.get(currentSessionId) : null;
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
|
||||||
|
// ── List sessions ─────────────────────────────────────────
|
||||||
|
case 'list_sessions': {
|
||||||
|
ws.send(JSON.stringify({ type: 'sessions', sessions: getSessionList() }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create session ─────────────────────────────────────────
|
||||||
|
case 'create_session': {
|
||||||
|
const sid = createSession(msg.name || null);
|
||||||
|
const s = sessions.get(sid);
|
||||||
|
ws.send(JSON.stringify({ type: 'session_created', sessionId: sid, name: s.name }));
|
||||||
|
broadcastSessionList();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join session ───────────────────────────────────────────
|
||||||
|
case 'join': {
|
||||||
|
const sid = msg.sessionId;
|
||||||
|
if (!sid || !sessions.has(sid)) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: `Session "${sid}" not found` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = sessions.get(sid);
|
||||||
|
if (currentSessionId) {
|
||||||
|
const old = sessions.get(currentSessionId);
|
||||||
|
if (old) old.clientIds.delete(clientId);
|
||||||
|
broadcastClientCount(currentSessionId);
|
||||||
|
}
|
||||||
|
currentSessionId = sid;
|
||||||
|
currentPage = 0;
|
||||||
|
ws._sessionId = sid;
|
||||||
|
s.clientIds.add(clientId);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'session_joined',
|
||||||
|
sessionId: sid,
|
||||||
|
clientId,
|
||||||
|
name: s.name,
|
||||||
|
pages: s.pages.map((p, i) => ({ index: i, name: p.name, strokeCount: p.strokes.length })),
|
||||||
|
currentPage: 0,
|
||||||
|
strokes: s.pages[0].strokes,
|
||||||
|
}));
|
||||||
|
broadcastClientCount(sid);
|
||||||
|
broadcastSessionList();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leave session ──────────────────────────────────────────
|
||||||
|
case 'leave': {
|
||||||
|
if (currentSessionId) {
|
||||||
|
const s = sessions.get(currentSessionId);
|
||||||
|
if (s) s.clientIds.delete(clientId);
|
||||||
|
broadcastClientCount(currentSessionId);
|
||||||
|
broadcastSessionList();
|
||||||
|
ws._sessionId = null;
|
||||||
|
currentSessionId = null;
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ type: 'left' }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw stroke ────────────────────────────────────────────
|
||||||
|
case 'draw': {
|
||||||
|
if (!session) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Not in a session' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = session.pages[currentPage];
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
if (msg.action === 'start') {
|
||||||
|
session._pendingStroke = {
|
||||||
|
points: [{ x: msg.x, y: msg.y }],
|
||||||
|
color: msg.color || '#e94560',
|
||||||
|
size: msg.size || 3,
|
||||||
|
isEraser: msg.isEraser || false,
|
||||||
|
clientId,
|
||||||
|
id: generateId(),
|
||||||
|
page: currentPage,
|
||||||
|
};
|
||||||
|
} else if (msg.action === 'move' && session._pendingStroke) {
|
||||||
|
session._pendingStroke.points.push({ x: msg.x, y: msg.y });
|
||||||
|
} else if (msg.action === 'end' && session._pendingStroke) {
|
||||||
|
session._pendingStroke.points.push({ x: msg.x, y: msg.y });
|
||||||
|
page.strokes.push(session._pendingStroke);
|
||||||
|
if (page.strokes.length > MAX_STROKES_PER_PAGE) {
|
||||||
|
page.strokes.splice(0, page.strokes.length - MAX_STROKES_PER_PAGE);
|
||||||
|
}
|
||||||
|
delete session._pendingStroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToSession(currentSessionId, {
|
||||||
|
type: 'draw',
|
||||||
|
action: msg.action,
|
||||||
|
x: msg.x, y: msg.y,
|
||||||
|
color: msg.color,
|
||||||
|
size: msg.size,
|
||||||
|
isEraser: msg.isEraser,
|
||||||
|
clientId,
|
||||||
|
page: currentPage,
|
||||||
|
}, ws);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Undo ───────────────────────────────────────────────────
|
||||||
|
case 'undo': {
|
||||||
|
if (!session) return;
|
||||||
|
const page = session.pages[currentPage];
|
||||||
|
if (!page || page.strokes.length === 0) return;
|
||||||
|
page.strokes.pop();
|
||||||
|
broadcastToSession(currentSessionId, { type: 'undo', clientId, page: currentPage }, ws);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clear ──────────────────────────────────────────────────
|
||||||
|
case 'clear': {
|
||||||
|
if (!session) return;
|
||||||
|
const page = session.pages[currentPage];
|
||||||
|
if (!page) return;
|
||||||
|
page.strokes = [];
|
||||||
|
broadcastToSession(currentSessionId, { type: 'clear', clientId, page: currentPage }, ws);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch page ────────────────────────────────────────────
|
||||||
|
case 'switch_page': {
|
||||||
|
if (!session) return;
|
||||||
|
const newPage = parseInt(msg.page, 10);
|
||||||
|
if (isNaN(newPage) || newPage < 0 || newPage >= session.pages.length) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: `Invalid page: ${newPage}` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentPage = newPage;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'page_switched',
|
||||||
|
page: currentPage,
|
||||||
|
pageCount: session.pages.length,
|
||||||
|
strokes: session.pages[currentPage].strokes,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add page ───────────────────────────────────────────────
|
||||||
|
case 'add_page': {
|
||||||
|
if (!session) return;
|
||||||
|
const idx = session.pages.length;
|
||||||
|
const name = `Page ${idx + 1}`;
|
||||||
|
session.pages.push({ name, strokes: [] });
|
||||||
|
// Broadcast to all clients
|
||||||
|
broadcastToSession(currentSessionId, {
|
||||||
|
type: 'page_added',
|
||||||
|
page: idx,
|
||||||
|
name,
|
||||||
|
pageCount: session.pages.length,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete page ────────────────────────────────────────────
|
||||||
|
case 'delete_page': {
|
||||||
|
if (!session) return;
|
||||||
|
const delPage = parseInt(msg.page, 10);
|
||||||
|
if (isNaN(delPage) || delPage < 0 || delPage >= session.pages.length) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: `Invalid page: ${delPage}` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session.pages.length <= 1) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Cannot delete the last page' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.pages.splice(delPage, 1);
|
||||||
|
const newCurrent = Math.min(currentPage, session.pages.length - 1);
|
||||||
|
currentPage = newCurrent;
|
||||||
|
broadcastToSession(currentSessionId, {
|
||||||
|
type: 'page_deleted',
|
||||||
|
page: delPage,
|
||||||
|
pageCount: session.pages.length,
|
||||||
|
currentPage: newCurrent,
|
||||||
|
strokes: session.pages[newCurrent].strokes,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages info ─────────────────────────────────────────────
|
||||||
|
case 'pages_info': {
|
||||||
|
if (!session) return;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'pages_info',
|
||||||
|
currentPage,
|
||||||
|
pages: session.pages.map((p, i) => ({ index: i, name: p.name, strokeCount: p.strokes.length })),
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch strokes (offline sync) ──────────────────────────
|
||||||
|
case 'batch_strokes': {
|
||||||
|
if (!session) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Not in a session' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!msg.strokes || !Array.isArray(msg.strokes)) return;
|
||||||
|
const stored = [];
|
||||||
|
for (const stroke of msg.strokes) {
|
||||||
|
const pIdx = stroke.page !== undefined ? Math.min(stroke.page, session.pages.length - 1) : currentPage;
|
||||||
|
const page = session.pages[pIdx];
|
||||||
|
if (!page) continue;
|
||||||
|
const full = {
|
||||||
|
points: stroke.points || [],
|
||||||
|
color: stroke.color || '#e94560',
|
||||||
|
size: stroke.size || 3,
|
||||||
|
isEraser: stroke.isEraser || false,
|
||||||
|
clientId,
|
||||||
|
id: generateId(),
|
||||||
|
page: pIdx,
|
||||||
|
};
|
||||||
|
page.strokes.push(full);
|
||||||
|
if (page.strokes.length > MAX_STROKES_PER_PAGE) {
|
||||||
|
page.strokes.splice(0, page.strokes.length - MAX_STROKES_PER_PAGE);
|
||||||
|
}
|
||||||
|
stored.push(full);
|
||||||
|
}
|
||||||
|
if (stored.length > 0) {
|
||||||
|
broadcastToSession(currentSessionId, { type: 'batch_strokes', strokes: stored }, ws);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: `Unknown message type: ${msg.type}` }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (currentSessionId) {
|
||||||
|
const s = sessions.get(currentSessionId);
|
||||||
|
if (s) {
|
||||||
|
s.clientIds.delete(clientId);
|
||||||
|
broadcastClientCount(currentSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up empty sessions
|
||||||
|
let changed = false;
|
||||||
|
for (const [sid, s] of sessions) {
|
||||||
|
if (s.clientIds.size === 0 && s.pages.every(p => p.strokes.length === 0)) {
|
||||||
|
sessions.delete(sid);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed || currentSessionId) broadcastSessionList();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
server_name git.hidrago.click;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3001;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Gitea — self-hosted Git service
|
||||||
|
# https://git.hidrago.click
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
container_name: gitea
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__database__DB_TYPE=postgres
|
||||||
|
- GITEA__database__HOST=db:5432
|
||||||
|
- GITEA__database__NAME=gitea
|
||||||
|
- GITEA__database__USER=gitea
|
||||||
|
- GITEA__database__PASSWD=gitea
|
||||||
|
- GITEA__server__DOMAIN=git.hidrago.click
|
||||||
|
- GITEA__server__ROOT_URL=https://git.hidrago.click
|
||||||
|
- GITEA__server__HTTP_PORT=3000
|
||||||
|
- GITEA__server__SSH_PORT=2222
|
||||||
|
- GITEA__server__DISABLE_SSH=false
|
||||||
|
- GITEA__server__START_SSH_SERVER=true
|
||||||
|
- GITEA__server__SSH_DOMAIN=git.hidrago.click
|
||||||
|
- GITEA__server__LFS_START_SERVER=true
|
||||||
|
- GITEA__server__SSH_LISTEN_PORT=2222
|
||||||
|
- GITEA__actions__ENABLED=true
|
||||||
|
- GITEA__admin__NAME=jerome
|
||||||
|
- GITEA__admin__PASSWD=test1234
|
||||||
|
- GITEA__admin__EMAIL=hayakawa.guiard@gmail.com
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./gitea-data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3000"
|
||||||
|
- "2222:22"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- gitea
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: gitea-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=gitea
|
||||||
|
- POSTGRES_PASSWORD=gitea
|
||||||
|
- POSTGRES_DB=gitea
|
||||||
|
volumes:
|
||||||
|
- ./postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U gitea"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- gitea
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gitea:
|
||||||
|
name: gitea
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
server_name suma.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/suma.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/suma.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/suma.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
if ($host = suma.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
server_name suma.hidrago.click;
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scrape sumo results from sumodb.sumogames.de and generate the Suma website."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
JST = timezone(timedelta(hours=9))
|
||||||
|
PARIS = timezone(timedelta(hours=2)) # CEST (summer)
|
||||||
|
|
||||||
|
BASHO_CODES = {
|
||||||
|
"01": ("Hatsu", "初場所", "Janvier"),
|
||||||
|
"03": ("Haru", "春場所", "Mars"),
|
||||||
|
"05": ("Natsu", "夏場所", "Mai"),
|
||||||
|
"07": ("Nagoya", "名古屋場所", "Juillet"),
|
||||||
|
"09": ("Aki", "秋場所", "Septembre"),
|
||||||
|
"11": ("Kyushu", "九州場所", "Novembre"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Scraping ───
|
||||||
|
|
||||||
|
def fetch_results(basho, day):
|
||||||
|
"""Fetch and parse results for a given basho code (e.g. 202607) and day (1-15)."""
|
||||||
|
url = f"https://sumodb.sumogames.de/Results.aspx?b={basho}&d={day}"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (SumaBot)"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
html = resp.read().decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# Header info
|
||||||
|
h1 = re.search(r"<h1>([^<]+)</h1>", html)
|
||||||
|
h2 = re.search(r"<h2>([^<]+)</h2>", html)
|
||||||
|
title = h1.group(1).strip() if h1 else f"Basho {basho} Day {day}"
|
||||||
|
date_str = h2.group(1).strip() if h2 else ""
|
||||||
|
|
||||||
|
# Parse tables by division
|
||||||
|
tables = re.findall(r'<table class="tk_table".*?</table>', html, re.DOTALL)
|
||||||
|
divisions = {}
|
||||||
|
division_names = ["Makuuchi", "Juryo", "Makushita", "Sandanme", "Jonidan", "Jonokuchi"]
|
||||||
|
|
||||||
|
for i, table in enumerate(tables):
|
||||||
|
if i >= len(division_names):
|
||||||
|
break
|
||||||
|
div_name = division_names[i]
|
||||||
|
matches = re.findall(
|
||||||
|
r'<td class="tk_kekka">(.*?)</td><td class="tk_east">(.*?)</td>'
|
||||||
|
r'<td class="tk_kim">(.*?)</td><td class="tk_west">(.*?)</td>'
|
||||||
|
r'<td class="tk_kekka">(.*?)</td>',
|
||||||
|
table, re.DOTALL)
|
||||||
|
parsed = []
|
||||||
|
for m in matches:
|
||||||
|
east_result_img = m[0]
|
||||||
|
east_cell = m[1]
|
||||||
|
kim_cell = m[2]
|
||||||
|
west_cell = m[3]
|
||||||
|
west_result_img = m[4]
|
||||||
|
|
||||||
|
# Parse wrestler info
|
||||||
|
east = parse_wrestler(east_cell)
|
||||||
|
west = parse_wrestler(west_cell)
|
||||||
|
technique = parse_technique(kim_cell)
|
||||||
|
|
||||||
|
# Determine winner
|
||||||
|
east_won = "shiro" in east_result_img
|
||||||
|
west_won = "shiro" in west_result_img
|
||||||
|
east_lost = "kuro" in east_result_img
|
||||||
|
west_lost = "kuro" in west_result_img
|
||||||
|
fusen = "fusen" in east_result_img or "fusen" in west_result_img
|
||||||
|
|
||||||
|
if east_won:
|
||||||
|
winner = "east"
|
||||||
|
elif west_won:
|
||||||
|
winner = "west"
|
||||||
|
elif fusen:
|
||||||
|
# Check who got the fusensho (win by default)
|
||||||
|
winner = "east" if "fusensho" in east_result_img else "west"
|
||||||
|
else:
|
||||||
|
winner = None
|
||||||
|
|
||||||
|
parsed.append({
|
||||||
|
"east": east,
|
||||||
|
"west": west,
|
||||||
|
"technique": technique,
|
||||||
|
"winner": winner,
|
||||||
|
"fusen": fusen,
|
||||||
|
})
|
||||||
|
divisions[div_name] = parsed
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"date_str": date_str,
|
||||||
|
"basho": basho,
|
||||||
|
"day": day,
|
||||||
|
"divisions": divisions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wrestler(cell):
|
||||||
|
"""Extract wrestler name, rank, record, and shikona from a cell."""
|
||||||
|
rank = ""
|
||||||
|
rank_m = re.search(r'<font size="1">(.*?)</font>', cell)
|
||||||
|
if rank_m:
|
||||||
|
rank = unescape(rank_m.group(1).strip())
|
||||||
|
|
||||||
|
# Japanese shikona from title
|
||||||
|
shikona_ja = ""
|
||||||
|
title_m = re.search(r"title='([^']+)'", cell)
|
||||||
|
if title_m:
|
||||||
|
parts = title_m.group(1).split(",")
|
||||||
|
if parts:
|
||||||
|
shikona_ja = parts[0].strip()
|
||||||
|
|
||||||
|
# English shikona from link text
|
||||||
|
name = ""
|
||||||
|
link_m = re.search(r"href='Rikishi\.aspx\?r=\d+'>([^<]+)</a>", cell)
|
||||||
|
if link_m:
|
||||||
|
name = link_m.group(1).strip()
|
||||||
|
|
||||||
|
# Record
|
||||||
|
record = ""
|
||||||
|
rec_m = re.search(r'<font color="#000000" size="1">([^<]+)</font>', cell)
|
||||||
|
if rec_m:
|
||||||
|
record = rec_m.group(1).strip()
|
||||||
|
|
||||||
|
return {"rank": rank, "name": name, "shikona_ja": shikona_ja, "record": record}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_technique(cell):
|
||||||
|
"""Extract winning technique from kimari cell."""
|
||||||
|
# Remove HTML tags
|
||||||
|
text = re.sub(r"<[^>]+>", "", cell)
|
||||||
|
text = unescape(text).strip()
|
||||||
|
# The technique is on its own line (after the first <br />)
|
||||||
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||||
|
# Filter out H2H records (contain digits)
|
||||||
|
tech = ""
|
||||||
|
for line in lines:
|
||||||
|
if not re.search(r"\d", line) and line not in ("", " "):
|
||||||
|
tech = line
|
||||||
|
break
|
||||||
|
return tech
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTML generation ───
|
||||||
|
|
||||||
|
def generate_html(data):
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
now_jst = now_utc.astimezone(JST)
|
||||||
|
now_paris = now_utc.astimezone(PARIS)
|
||||||
|
|
||||||
|
basho_code = data["basho"]
|
||||||
|
month_code = basho_code[4:6]
|
||||||
|
year = basho_code[:4]
|
||||||
|
basho_en, basho_ja, basho_fr = BASHO_CODES.get(month_code, ("Unknown", "不明", "Inconnu"))
|
||||||
|
|
||||||
|
# Build division sections
|
||||||
|
divisions_html = []
|
||||||
|
for div_name in ["Makuuchi", "Juryo", "Makushita", "Sandanme", "Jonidan", "Jonokuchi"]:
|
||||||
|
matches = data["divisions"].get(div_name, [])
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
matches_html = []
|
||||||
|
for m in matches:
|
||||||
|
east = m["east"]
|
||||||
|
west = m["west"]
|
||||||
|
winner = m["winner"]
|
||||||
|
|
||||||
|
# Winner/loser styling
|
||||||
|
east_class = "winner" if winner == "east" else ("loser" if winner == "west" else "")
|
||||||
|
west_class = "winner" if winner == "west" else ("loser" if winner == "east" else "")
|
||||||
|
|
||||||
|
tech_display = m["technique"] if m["technique"] and not m["fusen"] else ""
|
||||||
|
if m["fusen"]:
|
||||||
|
tech_display = "不戦勝" if winner == "east" else "不戦勝"
|
||||||
|
|
||||||
|
match_html = f""" <div class="match">
|
||||||
|
<div class="wrestler {east_class}">
|
||||||
|
<span class="rank">{east['rank']}</span>
|
||||||
|
<span class="name">{east['name']}</span>
|
||||||
|
<span class="shikona-jp">{east['shikona_ja']}</span>
|
||||||
|
<span class="record">{east['record']}</span>
|
||||||
|
</div>
|
||||||
|
<div class="technique">{tech_display}</div>
|
||||||
|
<div class="wrestler {west_class}">
|
||||||
|
<span class="rank">{west['rank']}</span>
|
||||||
|
<span class="name">{west['name']}</span>
|
||||||
|
<span class="shikona-jp">{west['shikona_ja']}</span>
|
||||||
|
<span class="record">{west['record']}</span>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
matches_html.append(match_html)
|
||||||
|
|
||||||
|
matches_str = "\n".join(matches_html)
|
||||||
|
div_html = f""" <div class="division">
|
||||||
|
<h2 class="division-title">{div_name}</h2>
|
||||||
|
{matches_str}
|
||||||
|
</div>"""
|
||||||
|
divisions_html.append(div_html)
|
||||||
|
|
||||||
|
divisions_str = "\n".join(divisions_html)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Suma — 相撲結果</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans JP', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
color: #eee;
|
||||||
|
}}
|
||||||
|
header {{
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #e94560;
|
||||||
|
text-shadow: 0 2px 8px rgba(233,69,96,0.3);
|
||||||
|
}}
|
||||||
|
h1 .jp {{
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #f5f5f5;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}}
|
||||||
|
.basho-info {{
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #a8b4c5;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}}
|
||||||
|
.day-info {{
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e94560;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}}
|
||||||
|
.date-info {{
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #7a8a9e;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 680px;
|
||||||
|
width: 100%;
|
||||||
|
}}
|
||||||
|
.division {{
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}}
|
||||||
|
.division-title {{
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #e94560;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 2px solid rgba(233,69,96,0.3);
|
||||||
|
}}
|
||||||
|
.match {{
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}}
|
||||||
|
.match:hover {{ transform: scale(1.01); }}
|
||||||
|
.wrestler {{
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}}
|
||||||
|
.wrestler.east {{ border-right: 1px solid rgba(255,255,255,0.08); }}
|
||||||
|
.wrestler.winner {{
|
||||||
|
background: rgba(76,175,80,0.12);
|
||||||
|
}}
|
||||||
|
.wrestler.loser {{
|
||||||
|
opacity: 0.6;
|
||||||
|
}}
|
||||||
|
.wrestler .rank {{
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #7a8a9e;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
.wrestler .name {{
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}}
|
||||||
|
.wrestler .shikona-jp {{
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a8b4c5;
|
||||||
|
}}
|
||||||
|
.wrestler .record {{
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #8899aa;
|
||||||
|
}}
|
||||||
|
.wrestler.winner .record {{
|
||||||
|
color: #66bb6a;
|
||||||
|
font-weight: 600;
|
||||||
|
}}
|
||||||
|
.technique {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #e94560;
|
||||||
|
font-style: italic;
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(233,69,96,0.06);
|
||||||
|
border-left: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-right: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}}
|
||||||
|
.east {{ text-align: left; }}
|
||||||
|
.west {{ text-align: right; align-items: flex-end; }}
|
||||||
|
.winner-mark {{
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
margin-right: 6px;
|
||||||
|
}}
|
||||||
|
.loser-mark {{
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e94560;
|
||||||
|
margin-right: 6px;
|
||||||
|
}}
|
||||||
|
.auto-refresh {{
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #7a8a9e;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}}
|
||||||
|
.auto-refresh .pulse {{
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf50;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
margin-right: 5px;
|
||||||
|
}}
|
||||||
|
@keyframes pulse {{
|
||||||
|
0% {{ opacity: 1; }}
|
||||||
|
50% {{ opacity: 0.3; }}
|
||||||
|
100% {{ opacity: 1; }}
|
||||||
|
}}
|
||||||
|
footer {{
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #556677;
|
||||||
|
}}
|
||||||
|
a {{ color: #e94560; text-decoration: none; }}
|
||||||
|
a:hover {{ text-decoration: underline; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🤼 Suma <span class="jp">相撲</span></h1>
|
||||||
|
<div class="basho-info">{year} {basho_fr} · {basho_ja}</div>
|
||||||
|
<div class="day-info">Jour {data['day']}</div>
|
||||||
|
<div class="date-info">{data['date_str']}</div>
|
||||||
|
</header>
|
||||||
|
<div class="auto-refresh">
|
||||||
|
<span class="pulse"></span>
|
||||||
|
Mise à jour automatique toutes les 10 minutes
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
{divisions_str}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
Données : <a href="https://sumodb.sumogames.de">sumodb.sumogames.de</a> ·
|
||||||
|
Dernière mise à jour : {now_paris.strftime('%d/%m/%Y %H:%M')} (Paris) / {now_jst.strftime('%H:%M')} (JST)
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auto-detect current basho and day ───
|
||||||
|
|
||||||
|
def detect_current_basho_day():
|
||||||
|
"""Determine the current basho code and day based on the date."""
|
||||||
|
now_jst = datetime.now(JST)
|
||||||
|
year = now_jst.year
|
||||||
|
month = now_jst.month
|
||||||
|
|
||||||
|
# Basho months: 1, 3, 5, 7, 9, 11
|
||||||
|
basho_months = {1: "01", 3: "03", 5: "05", 7: "07", 9: "09", 11: "11"}
|
||||||
|
|
||||||
|
# Find the most recent basho
|
||||||
|
basho_code = None
|
||||||
|
for m in [1, 3, 5, 7, 9, 11]:
|
||||||
|
if month >= m:
|
||||||
|
basho_code = f"{year}{basho_months[m]}"
|
||||||
|
elif month < 3:
|
||||||
|
basho_code = f"{year-1}11"
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no basho yet this year, use last November
|
||||||
|
if basho_code is None:
|
||||||
|
basho_code = f"{year-1}11"
|
||||||
|
|
||||||
|
# Basho runs for 15 days starting on the 2nd Sunday of the month
|
||||||
|
# For simplicity, try days 1-15 and use the latest one with data
|
||||||
|
# First try day 15, then go down
|
||||||
|
for day in range(15, 0, -1):
|
||||||
|
try:
|
||||||
|
data = fetch_results(basho_code, day)
|
||||||
|
if data["divisions"]:
|
||||||
|
return basho_code, day, data
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return basho_code, 1, None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) >= 3:
|
||||||
|
basho = sys.argv[1]
|
||||||
|
day = int(sys.argv[2])
|
||||||
|
else:
|
||||||
|
basho, day, pre_data = detect_current_basho_day()
|
||||||
|
if pre_data:
|
||||||
|
data = pre_data
|
||||||
|
else:
|
||||||
|
data = fetch_results(basho, day)
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
# Auto mode
|
||||||
|
if 'data' not in dir():
|
||||||
|
data = fetch_results(basho, day)
|
||||||
|
else:
|
||||||
|
data = fetch_results(basho, day)
|
||||||
|
|
||||||
|
html = generate_html(data)
|
||||||
|
output_path = "/home/jerome/hermes-sites/suma.hidrago.click/index.html"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"Suma page generated: basho={data['basho']} day={data['day']} -> {output_path}")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
server_name summer-2026.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/summer-2026.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/summer-2026.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/summer-2026.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
if ($host = summer-2026.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
server_name summer-2026.hidrago.click;
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,955 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Summer 2026 — Éclipse & Campings</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0f0c29 0%, #1a1a3e 40%, #162447 100%);
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem 2rem;
|
||||||
|
background: radial-gradient(ellipse at center, rgba(99,102,241,0.15) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #f97316, #ef4444);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
header .subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eclipse banner */
|
||||||
|
.eclipse-banner {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(147,51,234,0.1));
|
||||||
|
border: 1px solid rgba(99,102,241,0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.eclipse-banner .date {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.eclipse-banner .info {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.eclipse-banner .totality {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
background: rgba(251,191,36,0.15);
|
||||||
|
border: 1px solid rgba(251,191,36,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.records-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
}
|
||||||
|
.record-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(30,41,59,0.6);
|
||||||
|
border: 1px solid rgba(99,102,241,0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.record-card .number {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
.record-card .label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map section */
|
||||||
|
.map-section {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.map-section h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.eclipse-map {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.totality-cities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.city-tag {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
background: rgba(99,102,241,0.15);
|
||||||
|
border: 1px solid rgba(99,102,241,0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
.city-tag .time { color: #fbbf24; font-weight: 600; margin-left: 0.3rem; }
|
||||||
|
.city-tag .duration { color: #4ade80; font-size: 0.75rem; margin-left: 0.3rem; }
|
||||||
|
|
||||||
|
/* Compare table */
|
||||||
|
.compare-section {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.compare-section h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.compare-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
background: rgba(30,41,59,0.4);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.compare-table th {
|
||||||
|
background: rgba(99,102,241,0.2);
|
||||||
|
color: #a5b4fc;
|
||||||
|
padding: 0.7rem 0.6rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.compare-table td {
|
||||||
|
padding: 0.6rem;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid rgba(99,102,241,0.1);
|
||||||
|
}
|
||||||
|
.compare-table tr:last-child td { border-bottom: none; }
|
||||||
|
.compare-table .best-row { background: rgba(251,191,36,0.08); }
|
||||||
|
.compare-table .best-duration { color: #4ade80; font-weight: 700; }
|
||||||
|
.compare-table .rank { font-size: 0.75rem; color: #64748b; }
|
||||||
|
.compare-table .location-cell { text-align: left; color: #e2e8f0; }
|
||||||
|
|
||||||
|
/* Camping cards */
|
||||||
|
.campings {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.campings h2 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.campings > .subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.section-divider {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2.5rem 0 1.5rem;
|
||||||
|
padding: 1rem 0 0.5rem;
|
||||||
|
border-top: 1px dashed rgba(99,102,241,0.2);
|
||||||
|
}
|
||||||
|
.section-divider h3 {
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
.section-divider p {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(135deg, rgba(30,41,59,0.8), rgba(15,23,42,0.8));
|
||||||
|
border: 1px solid rgba(99,102,241,0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: border-color 0.3s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: rgba(99,102,241,0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card-header h3 { font-size: 1.3rem; color: #f1f5f9; }
|
||||||
|
.card-header .chain {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.chain-capfun { background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3); }
|
||||||
|
.chain-es { background: rgba(168,85,247,0.15); color: #c084fc; border: 1px solid rgba(168,85,247,0.3); }
|
||||||
|
.chain-ms { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.3); }
|
||||||
|
.chain-indie { background: rgba(236,72,153,0.15); color: #f472b6; border: 1px solid rgba(236,72,153,0.3); }
|
||||||
|
|
||||||
|
.card-location { font-size: 0.9rem; color: #94a3b8; margin-bottom: 0.8rem; }
|
||||||
|
.card-features {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.feature {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.feature.dog { color: #4ade80; }
|
||||||
|
.feature.slide { color: #38bdf8; }
|
||||||
|
.feature.kids { color: #fbbf24; }
|
||||||
|
.feature.totality { color: #f97316; }
|
||||||
|
.feature.pro { color: #a78bfa; }
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #b0b8c8;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.card-eclipse {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
background: rgba(251,191,36,0.08);
|
||||||
|
border: 1px solid rgba(251,191,36,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.card-price {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.card-price strong { color: #4ade80; font-size: 1.1rem; }
|
||||||
|
.card-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding: 0.5rem 1.2rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.card-link:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
/* Tips section */
|
||||||
|
.tips {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
.tips h2 {
|
||||||
|
color: #f59e0b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.tips ul { list-style: none; padding: 0; }
|
||||||
|
.tips li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.tips li::before {
|
||||||
|
content: '☀';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
header h1 { font-size: 1.8rem; }
|
||||||
|
.eclipse-banner .date { font-size: 1.4rem; }
|
||||||
|
.card-header h3 { font-size: 1.1rem; }
|
||||||
|
.compare-table { font-size: 0.75rem; }
|
||||||
|
.compare-table th, .compare-table td { padding: 0.4rem 0.3rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>🌞 Éclipse Solaire du 12 Août 2026</h1>
|
||||||
|
<p class="subtitle">Campings & clubs familiaux avec toboggans, activités enfants et chiens admis — du Pays Basque aux Asturies, et dans toute l'Espagne du Nord pour la totalité</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Eclipse info banner -->
|
||||||
|
<div class="eclipse-banner">
|
||||||
|
<div class="date">⏱ Mercredi 12 août 2026 — 20h27 ~ 20h32 (heure locale espagnole)</div>
|
||||||
|
<div class="info">
|
||||||
|
<strong>Totalité</strong>: l'éclipse traverse le nord de l'Espagne d'ouest en est, de la Galice aux Baléares.<br>
|
||||||
|
Visible depuis <strong>A Coruña · Gijón · Oviedo · Santander · Bilbao · Vitoria · Burgos · Valladolid · Palencia · Zaragoza · Valence · Palma (Majorque)</strong> et bien d'autres villes.<br>
|
||||||
|
<strong>Madrid et Barcelone</strong> sont juste en dehors de la bande de totalité — mais y verront une éclipse partielle à ~90%.
|
||||||
|
</div>
|
||||||
|
<div class="totality">🌑 La dernière éclipse totale visible en Espagne continentale datait de 1905 — et la prochaine arrive dès 2027 !</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key records -->
|
||||||
|
<div class="records-banner">
|
||||||
|
<div class="record-card">
|
||||||
|
<div class="number">2m 18s</div>
|
||||||
|
<div class="label">Durée max mondiale (Islande)</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-card">
|
||||||
|
<div class="number">1m 49s</div>
|
||||||
|
<div class="label">Durée max Espagne (Oviedo)</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-card">
|
||||||
|
<div class="number">1.0386</div>
|
||||||
|
<div class="label">Magnitude maximale</div>
|
||||||
|
</div>
|
||||||
|
<div class="record-card">
|
||||||
|
<div class="number">2027</div>
|
||||||
|
<div class="label">Prochaine totale en Espagne</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compare Table -->
|
||||||
|
<div class="compare-section">
|
||||||
|
<h2>⏱️ Durée de totalité par ville</h2>
|
||||||
|
<p style="text-align:center; color:#94a3b8; margin-bottom:1rem; font-size:0.9rem;">
|
||||||
|
Classement par durée de totalité — pour choisir votre spot idéal
|
||||||
|
</p>
|
||||||
|
<table class="compare-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th style="text-align:left">Ville</th>
|
||||||
|
<th>Début totalité</th>
|
||||||
|
<th>Fin totalité</th>
|
||||||
|
<th>Durée</th>
|
||||||
|
<th>Fin éclipse</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="best-row">
|
||||||
|
<td class="rank">🥇</td>
|
||||||
|
<td class="location-cell">Oviedo ⭐</td>
|
||||||
|
<td>20:27:05</td>
|
||||||
|
<td>20:28:54</td>
|
||||||
|
<td class="best-duration">1m 49s</td>
|
||||||
|
<td>21:21</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="best-row">
|
||||||
|
<td class="rank">🥇</td>
|
||||||
|
<td class="location-cell">Gijón ⭐</td>
|
||||||
|
<td>20:26:48</td>
|
||||||
|
<td>20:28:33</td>
|
||||||
|
<td class="best-duration">1m 45s</td>
|
||||||
|
<td>21:20</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="best-row">
|
||||||
|
<td class="rank">🥇</td>
|
||||||
|
<td class="location-cell">Burgos ⭐</td>
|
||||||
|
<td>20:28:24</td>
|
||||||
|
<td>20:30:08</td>
|
||||||
|
<td class="best-duration">1m 44s</td>
|
||||||
|
<td>21:20 (couché)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">4</td>
|
||||||
|
<td class="location-cell">Palencia</td>
|
||||||
|
<td>20:29:08</td>
|
||||||
|
<td>20:30:51</td>
|
||||||
|
<td>1m 43s</td>
|
||||||
|
<td>21:22</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">5</td>
|
||||||
|
<td class="location-cell">Palma (Majorque)</td>
|
||||||
|
<td>20:31:05</td>
|
||||||
|
<td>20:32:41</td>
|
||||||
|
<td>1m 36s</td>
|
||||||
|
<td>20:49 (couché)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">6</td>
|
||||||
|
<td class="location-cell">Teruel</td>
|
||||||
|
<td>20:31:04</td>
|
||||||
|
<td>20:32:38</td>
|
||||||
|
<td>1m 34s</td>
|
||||||
|
<td>21:05</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">7</td>
|
||||||
|
<td class="location-cell">Alcúdia (Majorque)</td>
|
||||||
|
<td>20:30:34</td>
|
||||||
|
<td>20:32:03</td>
|
||||||
|
<td>1m 29s</td>
|
||||||
|
<td>20:48 (couché)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">8</td>
|
||||||
|
<td class="location-cell">Valladolid</td>
|
||||||
|
<td>20:29:53</td>
|
||||||
|
<td>20:31:20</td>
|
||||||
|
<td>1m 27s</td>
|
||||||
|
<td>21:22</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">9</td>
|
||||||
|
<td class="location-cell">Ponferrada</td>
|
||||||
|
<td>20:28:44</td>
|
||||||
|
<td>20:30:11</td>
|
||||||
|
<td>1m 27s</td>
|
||||||
|
<td>21:22</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">10</td>
|
||||||
|
<td class="location-cell">Zaragoza</td>
|
||||||
|
<td>20:29:02</td>
|
||||||
|
<td>20:30:27</td>
|
||||||
|
<td>1m 25s</td>
|
||||||
|
<td>21:07 (couché)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">11</td>
|
||||||
|
<td class="location-cell">Logroño</td>
|
||||||
|
<td>20:28:09</td>
|
||||||
|
<td>20:29:31</td>
|
||||||
|
<td>1m 22s</td>
|
||||||
|
<td>21:15 (couché)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">12</td>
|
||||||
|
<td class="location-cell">A Coruña</td>
|
||||||
|
<td>20:27:40</td>
|
||||||
|
<td>20:28:57</td>
|
||||||
|
<td>1m 17s</td>
|
||||||
|
<td>21:22</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">13</td>
|
||||||
|
<td class="location-cell">Santander</td>
|
||||||
|
<td>20:26:57</td>
|
||||||
|
<td>20:28:01</td>
|
||||||
|
<td>1m 04s</td>
|
||||||
|
<td>21:20</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="rank">14</td>
|
||||||
|
<td class="location-cell">Vitoria-Gasteiz</td>
|
||||||
|
<td>20:27:43</td>
|
||||||
|
<td>20:28:47</td>
|
||||||
|
<td>1m 04s</td>
|
||||||
|
<td>21:17 (couché)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="text-align:center; color:#64748b; font-size:0.8rem; margin-top:0.8rem;">
|
||||||
|
⏰ Heures locales espagnoles (UTC+2) · Source: Wikipedia · Données NASA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map section -->
|
||||||
|
<div class="map-section">
|
||||||
|
<h2>🗺️ Carte du trajet de la totalité</h2>
|
||||||
|
<img class="eclipse-map" src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/SE2026Aug12T.png/640px-SE2026Aug12T.png" alt="Carte de l'éclipse du 12 août 2026" loading="lazy">
|
||||||
|
<div class="totality-cities">
|
||||||
|
<span class="city-tag">A Coruña <span class="time">20:28</span> <span class="duration">1m17</span></span>
|
||||||
|
<span class="city-tag">Gijón <span class="time">20:27</span> <span class="duration">1m45</span></span>
|
||||||
|
<span class="city-tag">Oviedo <span class="time">20:28</span> <span class="duration">1m49</span></span>
|
||||||
|
<span class="city-tag">Santander <span class="time">20:27</span> <span class="duration">1m04</span></span>
|
||||||
|
<span class="city-tag">Bilbao <span class="time">20:27</span> <span class="duration">0m31</span></span>
|
||||||
|
<span class="city-tag">Vitoria <span class="time">20:28</span> <span class="duration">1m04</span></span>
|
||||||
|
<span class="city-tag">Burgos <span class="time">20:29</span> <span class="duration">1m44</span></span>
|
||||||
|
<span class="city-tag">Logroño <span class="time">20:28</span> <span class="duration">1m22</span></span>
|
||||||
|
<span class="city-tag">Valladolid <span class="time">20:30</span> <span class="duration">1m27</span></span>
|
||||||
|
<span class="city-tag">Zaragoza <span class="time">20:30</span> <span class="duration">1m25</span></span>
|
||||||
|
<span class="city-tag">Valence <span class="time">20:30</span></span>
|
||||||
|
<span class="city-tag">Palma <span class="time">20:31</span> <span class="duration">1m36</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campings -->
|
||||||
|
<div class="campings">
|
||||||
|
<h2>🏕️ Nos propositions de campings</h2>
|
||||||
|
<p class="subtitle">Tous acceptent les chiens, ont des toboggans aquatiques et des activités encadrées pour Louis (CE1) et Maël (GS)</p>
|
||||||
|
|
||||||
|
<!-- Section 1: Côte cantabrique / Asturies — MEILLEURE TOTALITÉ -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<h3>🥇 Asturies & Cantabrie — Totalité maximale</h3>
|
||||||
|
<p>Jusqu'à 1m49s de totalité ! La côte cantabrique offre les plus longues durées d'Espagne.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEW: Camping Deva-Gijón -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 Camping Deva ⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-es">Asturies</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Deva, Gijón (Asturies, Espagne) — DANS la zone de totalité</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Piscine</span>
|
||||||
|
<span class="feature kids">🎨 Aire de jeux enfants</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🌊 Plage à 300m</span>
|
||||||
|
<span class="feature tot">🌑 Totalité 1m45s</span>
|
||||||
|
<span class="feature">🏖️ 5 plages à proximité</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
<strong>⭐ LE MEILLEUR SPOT POUR LA TOTALITÉ !</strong> Camping familial à 5 min de Gijón et 15 min d'Oviedo. Sur la côte cantabrique, avec le plus longue durée de totalité de toutes nos options (1m45s). Plage de Deva à 300m, proche du centre de Gijón. Note <strong>8,2/10</strong> — excellent rapport qualité-prix pour être au cœur de l'événement.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Totalité sur place !</strong> Gijón à 5min (1m45s) · Oviedo à 15min (1m49s)
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de ~40€/nuit en mobil-home</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camping Playa de Vega (Asturies) — updated -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🇪🇸 Camping Playa de Vega ⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-es">Espagne - Asturies</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Ribadesella, Asturies (Espagne) — DANS la zone de totalité, côte cantabrique</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Piscine & toboggans</span>
|
||||||
|
<span class="feature kids">🎨 Aire de jeux & mini-club</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🏖️ Plage à 200m</span>
|
||||||
|
<span class="feature">🧗 Grottes de Tito Bustillo (UNESCO)</span>
|
||||||
|
<span class="feature tot">🌑 Totalité 1m45s+</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
⭐ <strong>DANS LA ZONE DE TOTALITÉ!</strong> Camping familial sur la côte des Asturies, à proximité de Ribadesella. Plage de Vega à 200m. Piscine avec toboggans, aire de jeux, mini-club en saison. Les grottes préhistoriques de Tito Bustillo (UNESCO) sont à 5 min. Note 4,3/5 sur Google (1200+ avis). Durée de totalité estimée ~1m45 (entre Oviedo et Gijón).
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Totalité sur place!</strong> — ~20:27, durée ~1min 45s
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de ~50€/nuit en mobil-home</div>
|
||||||
|
<a class="card-link" href="https://www.campingplayavega.com/" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camping Playa Liencres (Cantabrie) — updated -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🇪🇸 Camping Playa Liencres ⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-es">Espagne - Cantabrie</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Liencres, Cantabrie (Espagne) — à 15km à l'ouest de Santander, DANS la bande de totalité</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Piscine & accès plage</span>
|
||||||
|
<span class="feature kids">🎨 Aire de jeux enfants</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🏖️ Plage à 800m</span>
|
||||||
|
<span class="feature">🌲 Parc naturel de Liencres</span>
|
||||||
|
<span class="feature tot">🌑 Totalité 1m04s</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
⭐ <strong>DANS LA ZONE DE TOTALITÉ!</strong> Camping situé à 15km de Santander, sur la côte cantabre. Vous pourrez voir l'éclipse totale sans aucune route à faire! Parc naturel de Liencres, plages sauvages et dunaires. Idéal pour combiner vacances en famille et éclipse. Durée: 1m04s.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Totalité sur place!</strong> — 20:27, durée ~1m04s
|
||||||
|
</div>
|
||||||
|
<div class="card-price">Tarifs à confirmer sur le site</div>
|
||||||
|
<a class="card-link" href="https://www.campingliencres.com/" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 2: Pays Basque / Landes — Bon équilibre voyage+totalité -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<h3>🥈 Pays Basque & Landes — Option équilibrée</h3>
|
||||||
|
<p>Moins de route depuis la France, trajet de 1h30-2h vers Bilbao ou Vitoria le jour J.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 1: Sud Land -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🏖️ Camping Sud Land ⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-capfun">Capfun</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Labenne-Océan (40), Landes — à 1,4km des plages et 15km de Bayonne</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Toboggans Crazy Cone & Double Tube</span>
|
||||||
|
<span class="feature">Piscine couverte & extérieure chauffées</span>
|
||||||
|
<span class="feature kids">🎨 Club Clapou (4-6 ans) + Club Politicator (7-10 ans), 4j/semaine</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis (35€/semaine BS, 42€ HS)</span>
|
||||||
|
<span class="feature">🚴 Location de vélos</span>
|
||||||
|
<span class="feature">Commerces à pied</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
L'idéal pour découvrir à la fois les Landes et le Pays Basque. Plage et commerces à proximité, pistes cyclables de la Vélodyssée au pied du camping. Ambiance familiale, soirées mousse, château gonflable. Note <strong>9,1/10</strong> sur 1430 avis — 94% de clients veulent repartir.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h · Vitoria (1m04) à ~2h30
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de <strong>350€</strong> / semaine (Habana Top Presta 4pers)</div>
|
||||||
|
<a class="card-link" href="https://www.capfun.com/camping-france-aquitaine-sud_land-FR.html" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Clown Océan -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🎪 Camping Clown Océan ⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-capfun">Capfun</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Tosse (40), Landes — à 10km des plages landaises</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Toboggans géants & Spacebowl</span>
|
||||||
|
<span class="feature">Piscine couverte avec toboggans</span>
|
||||||
|
<span class="feature kids">🎨 Club Clapou (4-6 ans) + Club Politicator (7-10 ans)</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis (35€/semaine basse saison, 42€ haute saison)</span>
|
||||||
|
<span class="feature">🔥 Fun Dose Rouge (animations max)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Camping original sur 4 hectares de couleurs, à 10km des plages. Animations maximum (Fun Dose Rouge) avec soirées mousse, spectacles et mascottes. Clubs enfants encadrés 4 jours/semaine. Note <strong>9,2/10</strong> — excellent rapport qualité-prix!
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~1h45 · Vitoria (1m04) à ~2h
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de <strong>315€</strong> / semaine (Habana Top Presta 4pers) — le moins cher!</div>
|
||||||
|
<a class="card-link" href="https://www.capfun.com/camping-france-aquitaine-clown_ocean-FR.html" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: La Mer (closest to Spain in Capfun) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌅 Camping La Mer ⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-capfun">Capfun</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Labenne (40), Landes — entre Biarritz et Hossegor, sur le littoral</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Spacebowl & toboggans à sensation</span>
|
||||||
|
<span class="feature">Piscine couverte avec toboggans</span>
|
||||||
|
<span class="feature kids">🎨 Club Clapou (4-6 ans) + Club Politicator (7-10 ans), 4j/semaine</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis (35€/semaine BS, 42€ HS)</span>
|
||||||
|
<span class="feature">🏖️ 700m de la plage</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Situé à Labenne, entre Biarritz et Hossegor, à seulement 700m de la plage. <strong>Le Capfun le plus proche du Pays Basque et de l'Espagne.</strong> Spacebowl (toboggan à sensation), clubs enfants encadrés 4 jours/semaine. Note <strong>9,1/10</strong>.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h · Vitoria (1m04) à ~2h
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de <strong>399€</strong> / semaine (Habana Top Presta 4pers)</div>
|
||||||
|
<a class="card-link" href="https://www.capfun.com/camping-france-aquitaine-la_mer-FR.html" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Lou Puntaou -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌲 Camping Lou Puntaou ⭐⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-capfun">Capfun</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Léon (40), Landes — bordure du lac de Léon, réserve naturelle du Huchet</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Toboggans aquatiques & parc aquatique</span>
|
||||||
|
<span class="feature">Piscine chauffée</span>
|
||||||
|
<span class="feature kids">🎨 Club enfants & animations</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis (laisse, vaccinés)</span>
|
||||||
|
<span class="feature">🛶 Lac & pédalos</span>
|
||||||
|
<span class="feature">🌲 Réserve naturelle</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Ambiance calme et familiale en bordure du magnifique lac de Léon et de ses plages aménagées. Contact direct avec la nature dans la réserve du Huchet. Note <strong>9,2/10</strong> — l'un des mieux notés de Capfun!
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h · Vitoria (1m04) à ~2h30
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de <strong>343€</strong> / semaine (Habana Top Presta 4pers)</div>
|
||||||
|
<a class="card-link" href="https://www.capfun.com/camping-france-aquitaine-lou_puntaou-FR.html" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: La Paillotte -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 Camping La Paillotte ⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-capfun">Capfun</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Azur (40), Landes — bordure du lac de Soustons, à 7km de l'océan</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Toboggans aquatiques & parc aquatique</span>
|
||||||
|
<span class="feature">Piscine chauffée</span>
|
||||||
|
<span class="feature kids">🎨 Club enfants & animations</span>
|
||||||
|
<span class="feature" style="color:#f87171">🚫 Chiens NON admis</span>
|
||||||
|
<span class="feature">🚣 Lac pour activités nautiques</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Au cœur des Landes, bordé par le lac de Soustons. Concept unique avec cadre naturel et détente. Idéal pour les familles cherchant un cadre paisible avec un grand parc aquatique. Note <strong>8,9/10</strong>. ⚠️ <strong>Attention: chiens non acceptés</strong>.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h15 · Santander (1m04) à ~3h15
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de <strong>350€</strong> / semaine (Habana Top Presta 4pers)</div>
|
||||||
|
<a class="card-link" href="https://www.capfun.com/camping-france-aquitaine-paillotte-FR.html" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Col d'Ibardin (Homair, closest to Spain) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>⛰️ Camping Col d'Ibardin ⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-indie">Homair</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Urrugne (64), Pyrénées-Atlantiques — Pays Basque, au pied du col d'Ibardin, frontière espagnole</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Piscine</span>
|
||||||
|
<span class="feature kids">🎨 Club enfants en saison</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🌊 5km des plages</span>
|
||||||
|
<span class="feature">🏔️ Vue Pyrénées</span>
|
||||||
|
<span class="feature pro">🚗 1h30 de Bilbao</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Le seul camping Homair directement au Pays Basque! Au col d'Ibardin, entre Saint-Jean-de-Luz et l'Espagne. <strong>Le camping français le plus proche de la zone de totalité</strong> — Bilbao à 1h30. Note <strong>4,4/5</strong> — le mieux noté des Pyrénées-Atlantiques sur Homair.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~1h30 · Vitoria (1m04) à ~1h45
|
||||||
|
</div>
|
||||||
|
<div class="card-price">Tarifs sur Homair</div>
|
||||||
|
<a class="card-link" href="https://www.homair.com/camping-pyrenees-atlantiques" target="_blank">Voir sur Homair →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Camping Landa Hiria (Pays Basque espagnol) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🇪🇸 Camping Landa Hiria ⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-es">Espagne - Pays Basque</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Hondarribia, Guipúzcoa (Espagne) — à la frontière franco-espagnole, proche de Hendaye</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Piscine & toboggans</span>
|
||||||
|
<span class="feature kids">🎨 Mini-club & animations</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🏖️ Plage à 1km</span>
|
||||||
|
<span class="feature">🏛️ Hondarribia (village médiéval)</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Camping familial au Pays Basque espagnol, à 5min de la frontière française. Situation idéale: vous êtes à ~1h45 de Bilbao et de sa zone de totalité. Piscine avec toboggans, mini-club en saison, animations. Proche du charmant village médiéval d'Hondarribia. Note 4,2/5 sur Google.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~1h45 · Vitoria (1m04) à ~1h30
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de ~45€/nuit</div>
|
||||||
|
<a class="card-link" href="https://www.landahiria.eus/" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 3: Intérieur (Burgos/Valladolid) — Meilleure météo -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<h3>🥉 Intérieur castillan (Burgos, Valladolid) — Meilleure météo</h3>
|
||||||
|
<p>La côte cantabrique peut être nuageuse en août. L'intérieur (Burgos, Valladolid, Palencia) offre plus de chances de ciel dégagé.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Camping El Tilo Burgos -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌳 Camping El Tilo ⭐⭐</h3>
|
||||||
|
<span class="chain chain-indie">Indépendant</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Burgos (Castille-et-León, Espagne) — DANS la zone de totalité</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature">🏊 Piscine extérieure</span>
|
||||||
|
<span class="feature kids">🎨 Aire de jeux</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🌲 Cadre naturel</span>
|
||||||
|
<span class="feature tot">🌑 Totalité 1m44s</span>
|
||||||
|
<span class="feature pro">☀️ Meilleure météo</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
⭐ <strong>DANS LA ZONE DE TOTALITÉ — 1m44s!</strong> Camping simple et familial à Burgos. L'intérieur des terres (<strong>Burgos, Palencia, Valladolid</strong>) a statistiquement <strong>moins de nuages</strong> que la côte cantabrique en août. Piscine, aire de jeux, calme. Note 7,8/10.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Totalité sur place!</strong> — 20:29, durée 1m44s — ciel plus dégagé qu'en bord de mer
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de ~30€/nuit</div>
|
||||||
|
<a class="card-link" href="https://www.campingeltilo.com/" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 4: Baléares — Option soleil assuré -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<h3>🏝️ Baléares (Majorque) — Soleil garanti</h3>
|
||||||
|
<p>La Méditerranée offre le meilleur climat pour l'éclipse. Palma et Alcúdia sont dans la bande de totalité !</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Camping Son Moll -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🇪🇸 Camping Son Moll ⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-es">Majorque - Cala Ratjada</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Cala Ratjada, Majorque (Baléares, Espagne) — DANS la bande de totalité</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Piscine & toboggans</span>
|
||||||
|
<span class="feature kids">🎨 Mini-club & animations</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🏖️ Plage à 10min à pied</span>
|
||||||
|
<span class="feature">☀️ Climat méditerranéen</span>
|
||||||
|
<span class="feature tot">🌑 Totalité 1m30s+</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
⭐ <strong>DANS LA ZONE DE TOTALITÉ!</strong> Camping familial sur la côte est de Majorque. <strong>Le meilleur climat d'Espagne pour l'éclipse</strong> — la Méditerranée a statistiquement le plus de jours dégagés en août. Piscine avec toboggans, mini-club, animations. Note 4/5. Durée de totalité estimée ~1m30.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Totalité sur place!</strong> — 20:31, durée ~1m30 — mais le Soleil se couche peu après (20:49)
|
||||||
|
</div>
|
||||||
|
<div class="card-price">À partir de ~35€/nuit</div>
|
||||||
|
<a class="card-link" href="https://www.campingsonmoll.com/" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 5: Nouveautés & alternatives -->
|
||||||
|
<div class="section-divider">
|
||||||
|
<h3>🔄 Alternatives supplémentaires</h3>
|
||||||
|
<p>Si les options ci-dessus sont complètes, voici des plans B et C solides</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Les Tourterelles -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🐦 Camping Club Les Tourterelles ⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-ms">MS Vacances</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Saint-Girons Plage (40), Landes — bord de plage Atlantique</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Toboggans aquatiques</span>
|
||||||
|
<span class="feature">Piscine</span>
|
||||||
|
<span class="feature kids">🎨 Club enfants & animations</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🏖️ Accès direct à la plage</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Camping Club 4* MS Vacances avec accès direct à la plage de Saint-Girons. Le style "club" que vous cherchez: animations, soirées, club enfants, restauration. Idéalement situé dans le sud des Landes, proche du Pays Basque et de la frontière espagnole.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h30 · Vitoria (1m04) à ~2h
|
||||||
|
</div>
|
||||||
|
<div class="card-price">Tarifs à confirmer sur le site MS Vacances</div>
|
||||||
|
<a class="card-link" href="https://www.ms-vacances.com/camping-les-tourterelles/" target="_blank">Voir & réserver →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mayotte Vacances -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌴 Camping Mayotte Vacances ⭐⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-indie">Homair</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Biscarrosse (40), Landes — proche du lac et de l'océan</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Parc aquatique avec toboggans</span>
|
||||||
|
<span class="feature">Piscine couverte & extérieure</span>
|
||||||
|
<span class="feature kids">🎨 Club enfants & animations</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
<span class="feature">🏖️ Plage du lac</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Camping 5★ à Biscarrosse, l'un des mieux notés Homair dans les Landes (<strong>4,2/5</strong>). Grand parc aquatique, animations, club enfants.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h45 · Vitoria (1m04) à ~3h
|
||||||
|
</div>
|
||||||
|
<div class="card-price">Tarifs sur Homair</div>
|
||||||
|
<a class="card-link" href="https://www.homair.com/camping-landes" target="_blank">Voir sur Homair →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lou Pignada -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>🌊 Camping Lou Pignada ⭐⭐⭐⭐⭐</h3>
|
||||||
|
<span class="chain chain-indie">Homair</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-location">📍 Sud des Landes / Pays Basque — frontière 64/40</div>
|
||||||
|
<div class="card-features">
|
||||||
|
<span class="feature slide">🏊 Parc aquatique avec toboggans</span>
|
||||||
|
<span class="feature">Piscine</span>
|
||||||
|
<span class="feature kids">🎨 Club enfants</span>
|
||||||
|
<span class="feature dog">🐕 Chiens admis</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-description">
|
||||||
|
Camping 5★ dans le sud des Landes / Pays Basque (<strong>4,1/5</strong>). Parc aquatique avec toboggans, club enfants. Bien situé pour l'Espagne.
|
||||||
|
</div>
|
||||||
|
<div class="card-eclipse">
|
||||||
|
🌑 <strong>Trajet totalité</strong>: Bilbao (31s) à ~2h · Vitoria (1m04) à ~2h
|
||||||
|
</div>
|
||||||
|
<div class="card-price">Tarifs sur Homair</div>
|
||||||
|
<a class="card-link" href="https://www.homair.com/camping-landes" target="_blank">Voir sur Homair →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Strategy section -->
|
||||||
|
<div class="tips" style="border-top:1px dashed rgba(99,102,241,0.2); padding-top:3rem;">
|
||||||
|
<h2>💡 Analyse & stratégie</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>🥇 Option A — Tout sur place (Asturies)</strong>: Séjourner directement à Gijón ou Oviedo dans un camping local. Totalité maximale (1m45-1m49s), pas de route le jour J. L'inconvénient: la côte cantabrique peut être nuageuse en août.</li>
|
||||||
|
<li><strong>🥈 Option B — Landes/Pays Basque + road-trip</strong>: Séjourner dans un Capfun/Homair du sud des Landes, partir tôt le 12 août vers Burgos ou Vitoria. 1h30-2h de route pour 1m44s de totalité à Burgos.</li>
|
||||||
|
<li><strong>🥉 Option C — Intérieur espagnol (Burgos, Valladolid)</strong>: Meilleure probabilité de ciel dégagé. Moins de choix de campings avec piscines/toboggans, mais totalité longue (1m44s) et ciel plus fiable.</li>
|
||||||
|
<li><strong>🏝️ Option D — Majorque (Baléares)</strong>: Climat méditerranéen quasi garanti, campings familiaux, totalité 1m30+. Le soleil se couche vite après (20:49) mais la vue sera magnifique.</li>
|
||||||
|
<li><strong>🔭 Observation</strong>: La totalité dure 1 à 2 minutes. Pendant ce laps de temps, <strong>vous pouvez retirer les lunettes</strong> — c'est l'unique moment où la couronne solaire est visible à l'œil nu. AVANT et APRÈS, lunettes ISO 12312-2 obligatoires.</li>
|
||||||
|
<li><strong>✨ Pendant la totalité en Espagne</strong>: Vénus sera brillante dans le sud-ouest, avec Spica à côté. Arcturus haute dans le sud, le Triangle d'Été bien haut dans l'est. Mercure et Jupiter très bas sous le Soleil.</li>
|
||||||
|
<li><strong>🐕 Chiens</strong>: Passeport européen obligatoire pour entrer en Espagne. Carnet de vaccination à jour. Dans tous les campings listés, les chiens sont admis en laisse.</li>
|
||||||
|
<li><strong>🗓️ Réservez tôt !</strong> L'éclipse totale du 12 août 2026 est l'événement astronomique de la décennie en Europe. Les campings près de la ligne de totalité seront pris d'assaut.</li>
|
||||||
|
<li><strong>☀️ Prochaine éclipse en Espagne</strong>: Le 2 août 2027 ! Une éclipse totale traversera le sud de l'Espagne (Andalousie, Gibraltar). Moins d'un an après — de quoi faire un rattrapage si la météo est mauvaise en 2026.</li>
|
||||||
|
<li><strong>👨👩👧👦 Pour Louis (CE1) et Maël (GS)</strong>: Tous les campings proposés ont toboggans aquatiques et clubs enfants. La totalité en fin de journée (20h30) est parfaite — les enfants seront encore éveillés, et l'ambiance "nuit en plein jour" sera magique pour eux !</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Éclipse solaire totale du 12 août 2026 — Données: NASA / Wikipedia · Campings: Capfun / Homair / MS Vacances / Indépendants</p>
|
||||||
|
<p>🤖 Généré par Botty — <a href="http://summer-2026.hidrago.click" style="color:#6366f1;">summer-2026.hidrago.click</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
server_name tokyo-trip.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/tokyo-trip.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/tokyo-trip.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/tokyo-trip.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
if ($host = tokyo-trip.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name tokyo-trip.hidrago.click;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the Tokyo Trip itinerary page (FR/EN/JA)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# ─── Trip data (easily editable) ───
|
||||||
|
TRIP = {
|
||||||
|
"title": {"fr": "Tokyo Trip", "en": "Tokyo Trip", "ja": "東京旅行"},
|
||||||
|
"date_label": {"fr": "Date du voyage", "en": "Trip date", "ja": "旅行日"},
|
||||||
|
"date": "", # à remplir plus tard
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"time": "07:30",
|
||||||
|
"city": "Kuwana",
|
||||||
|
"station": {"fr": "Gare de Kuwana", "en": "Kuwana Station", "ja": "桑名駅"},
|
||||||
|
"action": {"fr": "Départ", "en": "Departure", "ja": "出発"},
|
||||||
|
"icon": "🚉",
|
||||||
|
"note": {"fr": "Train depuis Kuwana", "en": "Train from Kuwana", "ja": "桑名から電車で出発"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "08:35",
|
||||||
|
"city": "Nagoya",
|
||||||
|
"station": {"fr": "Gare de Nagoya", "en": "Nagoya Station", "ja": "名古屋駅"},
|
||||||
|
"action": {"fr": "Arrivée à Nagoya", "en": "Arrival at Nagoya", "ja": "名古屋到着"},
|
||||||
|
"icon": "🛬",
|
||||||
|
"note": {"fr": "22 minutes avant le Shinkansen", "en": "22 minutes before Shinkansen", "ja": "新幹線まで22分"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "08:57",
|
||||||
|
"city": "Nagoya",
|
||||||
|
"station": {"fr": "Gare de Nagoya", "en": "Nagoya Station", "ja": "名古屋駅"},
|
||||||
|
"action": {"fr": "Shinkansen pour Tokyo", "en": "Shinkansen to Tokyo", "ja": "新幹線で東京へ"},
|
||||||
|
"icon": "🚄",
|
||||||
|
"note": {"fr": "Départ en Shinkansen", "en": "Shinkansen departure", "ja": "新幹線出発"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": "—",
|
||||||
|
"city": "Tokyo",
|
||||||
|
"station": {"fr": "Gare de Tokyo", "en": "Tokyo Station", "ja": "東京駅"},
|
||||||
|
"action": {"fr": "Arrivée à Tokyo", "en": "Arrival at Tokyo", "ja": "東京到着"},
|
||||||
|
"icon": "🏁",
|
||||||
|
"note": {"fr": "", "en": "", "ja": ""},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── UI translations ───
|
||||||
|
I18N = {
|
||||||
|
"fr": {
|
||||||
|
"title": "Tokyo Trip — Itinéraire",
|
||||||
|
"heading": "🚄 Tokyo Trip",
|
||||||
|
"subtitle": "Itinéraire de voyage",
|
||||||
|
"step": "Étape",
|
||||||
|
"time": "Heure",
|
||||||
|
"station": "Gare",
|
||||||
|
"action": "Action",
|
||||||
|
"note": "Note",
|
||||||
|
"no_date": "Date à définir",
|
||||||
|
"updated": "Mis à jour le",
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"title": "Tokyo Trip — Itinerary",
|
||||||
|
"heading": "🚄 Tokyo Trip",
|
||||||
|
"subtitle": "Travel itinerary",
|
||||||
|
"step": "Step",
|
||||||
|
"time": "Time",
|
||||||
|
"station": "Station",
|
||||||
|
"action": "Action",
|
||||||
|
"note": "Note",
|
||||||
|
"no_date": "Date TBD",
|
||||||
|
"updated": "Updated on",
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"title": "東京旅行 — 旅程",
|
||||||
|
"heading": "🚄 東京旅行",
|
||||||
|
"subtitle": "旅程表",
|
||||||
|
"step": "ステップ",
|
||||||
|
"time": "時刻",
|
||||||
|
"station": "駅",
|
||||||
|
"action": "行動",
|
||||||
|
"note": "メモ",
|
||||||
|
"no_date": "日付未定",
|
||||||
|
"updated": "更新日時",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
REMINDER = {
|
||||||
|
"fr": "📅 Rappel : le 30 juillet, départ pour Saitama ! Chichi-bouh ! 🎉",
|
||||||
|
"en": "📅 Reminder: July 30th, departure for Saitama! Chichi-bouh! 🎉",
|
||||||
|
"ja": "📅 リマインダー:7月30日、埼玉へ出発!ちちぶー!🎉",
|
||||||
|
}
|
||||||
|
|
||||||
|
I18N_JSON = json.dumps(I18N, ensure_ascii=False)
|
||||||
|
TRIP_JSON = json.dumps(TRIP, ensure_ascii=False)
|
||||||
|
REMINDER_JSON = json.dumps(REMINDER, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Build step cards server-side (time, city, icon are language-independent)
|
||||||
|
steps_html = []
|
||||||
|
for i, step in enumerate(TRIP["steps"]):
|
||||||
|
note_html = (
|
||||||
|
f'<div class="step-note" data-step-note="{i}"></div>'
|
||||||
|
if step["note"].get("fr")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
steps_html.append(f"""
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-time">{step['time']}</div>
|
||||||
|
<div class="step-icon">{step['icon']}</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<div class="step-city">{step['city']}</div>
|
||||||
|
<div class="step-station" data-step-station="{i}"></div>
|
||||||
|
<div class="step-action" data-step-action="{i}"></div>
|
||||||
|
{note_html}
|
||||||
|
</div>
|
||||||
|
</div>""")
|
||||||
|
|
||||||
|
steps = "\n".join(steps_html)
|
||||||
|
# Connector arrows between steps
|
||||||
|
n_steps = len(TRIP["steps"])
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tokyo Trip — Itinéraire</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}}
|
||||||
|
.lang-switch {{
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
.lang-btn {{
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.lang-btn:hover {{ background: rgba(255,255,255,0.3); }}
|
||||||
|
.lang-btn.active {{
|
||||||
|
background: #fff;
|
||||||
|
color: #764ba2;
|
||||||
|
border-color: #fff;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}}
|
||||||
|
.subtitle {{
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}}
|
||||||
|
.trip-date {{
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}}
|
||||||
|
.reminder {{
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
color: #e8893b;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
max-width: 580px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||||
|
border: 2px solid #e8893b;
|
||||||
|
}}
|
||||||
|
.timeline {{
|
||||||
|
max-width: 580px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}}
|
||||||
|
.step-card {{
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}}
|
||||||
|
.step-card:hover {{ transform: translateX(4px); }}
|
||||||
|
.step-time {{
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #764ba2;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.step-icon {{
|
||||||
|
font-size: 2rem;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.step-body {{
|
||||||
|
flex: 1;
|
||||||
|
}}
|
||||||
|
.step-city {{
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}}
|
||||||
|
.step-station {{
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}}
|
||||||
|
.step-action {{
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}}
|
||||||
|
.step-note {{
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e8893b;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-style: italic;
|
||||||
|
}}
|
||||||
|
.connector {{
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
line-height: 1;
|
||||||
|
}}
|
||||||
|
footer {{
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||||
|
</div>
|
||||||
|
<h1 id="heading">🚄 Tokyo Trip</h1>
|
||||||
|
<p class="subtitle" id="subtitle">Itinéraire de voyage</p>
|
||||||
|
<p class="trip-date" id="trip-date"></p>
|
||||||
|
<div class="reminder" id="reminder"></div>
|
||||||
|
<div class="timeline">
|
||||||
|
{steps}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span data-i18n="updated">Mis à jour le</span> {now.strftime('%d/%m/%Y à %H:%M UTC')}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const I18N = {I18N_JSON};
|
||||||
|
const TRIP = {TRIP_JSON};
|
||||||
|
const REMINDER = {REMINDER_JSON};
|
||||||
|
let currentLang = 'fr';
|
||||||
|
|
||||||
|
function applyLang(lang) {{
|
||||||
|
currentLang = lang;
|
||||||
|
const t = I18N[lang];
|
||||||
|
document.title = t.title;
|
||||||
|
document.getElementById('heading').textContent = t.heading;
|
||||||
|
document.getElementById('subtitle').textContent = t.subtitle;
|
||||||
|
|
||||||
|
// Reminder banner
|
||||||
|
const remEl = document.getElementById('reminder');
|
||||||
|
if (remEl && REMINDER[lang]) remEl.textContent = REMINDER[lang];
|
||||||
|
|
||||||
|
// Trip date
|
||||||
|
const dateEl = document.getElementById('trip-date');
|
||||||
|
if (TRIP.date) {{
|
||||||
|
dateEl.textContent = t.date_label + ' : ' + TRIP.date;
|
||||||
|
}} else {{
|
||||||
|
dateEl.textContent = t.no_date;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Step stations, actions, notes
|
||||||
|
TRIP.steps.forEach((step, i) => {{
|
||||||
|
const stEl = document.querySelector('[data-step-station="' + i + '"]');
|
||||||
|
if (stEl) stEl.textContent = step.station[lang];
|
||||||
|
const actEl = document.querySelector('[data-step-action="' + i + '"]');
|
||||||
|
if (actEl) actEl.textContent = step.action[lang];
|
||||||
|
const noteEl = document.querySelector('[data-step-note="' + i + '"]');
|
||||||
|
if (noteEl && step.note[lang]) noteEl.textContent = step.note[lang];
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Generic i18n labels
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {{
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
if (t[key]) el.textContent = t[key];
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Active button
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||||
|
}});
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.addEventListener('click', () => {{
|
||||||
|
applyLang(btn.getAttribute('data-lang'));
|
||||||
|
localStorage.setItem('trip-lang', btn.getAttribute('data-lang'));
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('trip-lang');
|
||||||
|
if (saved && I18N[saved]) {{
|
||||||
|
applyLang(saved);
|
||||||
|
}} else {{
|
||||||
|
const browserLang = navigator.language.slice(0, 2);
|
||||||
|
if (I18N[browserLang]) applyLang(browserLang);
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
html = generate_html()
|
||||||
|
output_path = "/home/jerome/hermes-sites/tokyo-trip.hidrago.click/index.html"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"Tokyo Trip page generated at {output_path}")
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tokyo Trip — Itinéraire</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.lang-btn {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.lang-btn:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
.lang-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #764ba2;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.trip-date {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.reminder {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
color: #e8893b;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
max-width: 580px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||||
|
border: 2px solid #e8893b;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
max-width: 580px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.step-card {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.step-card:hover { transform: translateX(4px); }
|
||||||
|
.step-time {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #764ba2;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.step-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.step-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.step-city {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.step-station {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.step-action {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.step-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #e8893b;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.connector {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||||
|
</div>
|
||||||
|
<h1 id="heading">🚄 Tokyo Trip</h1>
|
||||||
|
<p class="subtitle" id="subtitle">Itinéraire de voyage</p>
|
||||||
|
<p class="trip-date" id="trip-date"></p>
|
||||||
|
<div class="reminder" id="reminder"></div>
|
||||||
|
<div class="timeline">
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-time">07:30</div>
|
||||||
|
<div class="step-icon">🚉</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<div class="step-city">Kuwana</div>
|
||||||
|
<div class="step-station" data-step-station="0"></div>
|
||||||
|
<div class="step-action" data-step-action="0"></div>
|
||||||
|
<div class="step-note" data-step-note="0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-time">08:35</div>
|
||||||
|
<div class="step-icon">🛬</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<div class="step-city">Nagoya</div>
|
||||||
|
<div class="step-station" data-step-station="1"></div>
|
||||||
|
<div class="step-action" data-step-action="1"></div>
|
||||||
|
<div class="step-note" data-step-note="1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-time">08:57</div>
|
||||||
|
<div class="step-icon">🚄</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<div class="step-city">Nagoya</div>
|
||||||
|
<div class="step-station" data-step-station="2"></div>
|
||||||
|
<div class="step-action" data-step-action="2"></div>
|
||||||
|
<div class="step-note" data-step-note="2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-card">
|
||||||
|
<div class="step-time">—</div>
|
||||||
|
<div class="step-icon">🏁</div>
|
||||||
|
<div class="step-body">
|
||||||
|
<div class="step-city">Tokyo</div>
|
||||||
|
<div class="step-station" data-step-station="3"></div>
|
||||||
|
<div class="step-action" data-step-action="3"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span data-i18n="updated">Mis à jour le</span> 25/07/2026 à 08:39 UTC
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const I18N = {"fr": {"title": "Tokyo Trip — Itinéraire", "heading": "🚄 Tokyo Trip", "subtitle": "Itinéraire de voyage", "step": "Étape", "time": "Heure", "station": "Gare", "action": "Action", "note": "Note", "no_date": "Date à définir", "updated": "Mis à jour le"}, "en": {"title": "Tokyo Trip — Itinerary", "heading": "🚄 Tokyo Trip", "subtitle": "Travel itinerary", "step": "Step", "time": "Time", "station": "Station", "action": "Action", "note": "Note", "no_date": "Date TBD", "updated": "Updated on"}, "ja": {"title": "東京旅行 — 旅程", "heading": "🚄 東京旅行", "subtitle": "旅程表", "step": "ステップ", "time": "時刻", "station": "駅", "action": "行動", "note": "メモ", "no_date": "日付未定", "updated": "更新日時"}};
|
||||||
|
const TRIP = {"title": {"fr": "Tokyo Trip", "en": "Tokyo Trip", "ja": "東京旅行"}, "date_label": {"fr": "Date du voyage", "en": "Trip date", "ja": "旅行日"}, "date": "", "steps": [{"time": "07:30", "city": "Kuwana", "station": {"fr": "Gare de Kuwana", "en": "Kuwana Station", "ja": "桑名駅"}, "action": {"fr": "Départ", "en": "Departure", "ja": "出発"}, "icon": "🚉", "note": {"fr": "Train depuis Kuwana", "en": "Train from Kuwana", "ja": "桑名から電車で出発"}}, {"time": "08:35", "city": "Nagoya", "station": {"fr": "Gare de Nagoya", "en": "Nagoya Station", "ja": "名古屋駅"}, "action": {"fr": "Arrivée à Nagoya", "en": "Arrival at Nagoya", "ja": "名古屋到着"}, "icon": "🛬", "note": {"fr": "22 minutes avant le Shinkansen", "en": "22 minutes before Shinkansen", "ja": "新幹線まで22分"}}, {"time": "08:57", "city": "Nagoya", "station": {"fr": "Gare de Nagoya", "en": "Nagoya Station", "ja": "名古屋駅"}, "action": {"fr": "Shinkansen pour Tokyo", "en": "Shinkansen to Tokyo", "ja": "新幹線で東京へ"}, "icon": "🚄", "note": {"fr": "Départ en Shinkansen", "en": "Shinkansen departure", "ja": "新幹線出発"}}, {"time": "—", "city": "Tokyo", "station": {"fr": "Gare de Tokyo", "en": "Tokyo Station", "ja": "東京駅"}, "action": {"fr": "Arrivée à Tokyo", "en": "Arrival at Tokyo", "ja": "東京到着"}, "icon": "🏁", "note": {"fr": "", "en": "", "ja": ""}}]};
|
||||||
|
const REMINDER = {"fr": "📅 Rappel : le 30 juillet, départ pour Saitama ! Chichi-bouh ! 🎉", "en": "📅 Reminder: July 30th, departure for Saitama! Chichi-bouh! 🎉", "ja": "📅 リマインダー:7月30日、埼玉へ出発!ちちぶー!🎉"};
|
||||||
|
let currentLang = 'fr';
|
||||||
|
|
||||||
|
function applyLang(lang) {
|
||||||
|
currentLang = lang;
|
||||||
|
const t = I18N[lang];
|
||||||
|
document.title = t.title;
|
||||||
|
document.getElementById('heading').textContent = t.heading;
|
||||||
|
document.getElementById('subtitle').textContent = t.subtitle;
|
||||||
|
|
||||||
|
// Reminder banner
|
||||||
|
const remEl = document.getElementById('reminder');
|
||||||
|
if (remEl && REMINDER[lang]) remEl.textContent = REMINDER[lang];
|
||||||
|
|
||||||
|
// Trip date
|
||||||
|
const dateEl = document.getElementById('trip-date');
|
||||||
|
if (TRIP.date) {
|
||||||
|
dateEl.textContent = t.date_label + ' : ' + TRIP.date;
|
||||||
|
} else {
|
||||||
|
dateEl.textContent = t.no_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step stations, actions, notes
|
||||||
|
TRIP.steps.forEach((step, i) => {
|
||||||
|
const stEl = document.querySelector('[data-step-station="' + i + '"]');
|
||||||
|
if (stEl) stEl.textContent = step.station[lang];
|
||||||
|
const actEl = document.querySelector('[data-step-action="' + i + '"]');
|
||||||
|
if (actEl) actEl.textContent = step.action[lang];
|
||||||
|
const noteEl = document.querySelector('[data-step-note="' + i + '"]');
|
||||||
|
if (noteEl && step.note[lang]) noteEl.textContent = step.note[lang];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic i18n labels
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
if (t[key]) el.textContent = t[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active button
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||||
|
});
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
applyLang(btn.getAttribute('data-lang'));
|
||||||
|
localStorage.setItem('trip-lang', btn.getAttribute('data-lang'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('trip-lang');
|
||||||
|
if (saved && I18N[saved]) {
|
||||||
|
applyLang(saved);
|
||||||
|
} else {
|
||||||
|
const browserLang = navigator.language.slice(0, 2);
|
||||||
|
if (I18N[browserLang]) applyLang(browserLang);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
server {
|
||||||
|
server_name vault.hidrago.click;
|
||||||
|
|
||||||
|
client_max_body_size 128M;
|
||||||
|
|
||||||
|
location /notifications/hub {
|
||||||
|
proxy_pass http://127.0.0.1:3012;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notifications/hub/negotiate {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/vault.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/vault.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = vault.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name vault.hidrago.click;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Vaultwarden — self-hosted password manager
|
||||||
|
# https://vault.hidrago.click
|
||||||
|
services:
|
||||||
|
vaultwarden:
|
||||||
|
image: vaultwarden/server:latest
|
||||||
|
container_name: vaultwarden
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DOMAIN: "https://vault.hidrago.click"
|
||||||
|
WEBSOCKET_ENABLED: "true"
|
||||||
|
SIGNUPS_ALLOWED: "false"
|
||||||
|
# Generated via: docker run --rm -it vaultwarden/server /vaultwarden hash
|
||||||
|
ADMIN_TOKEN: "$$argon2id$$v=19$$m=65540,t=3,p=4$$ufU3+T57S9CCQLaaRKU4Y/7cZWwTWGE88IwK04lJeus$$E8AdcFA2Rltos1yTKLGn3hIU9iUHhIKLwYcqfLljSm4"
|
||||||
|
volumes:
|
||||||
|
- ./vw-data:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8081:80"
|
||||||
|
- "127.0.0.1:3012:3012"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Vaultwarden - self-hosted Bitwarden-compatible password manager
|
||||||
|
Documentation=https://github.com/dani-garcia/vaultwarden
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
WorkingDirectory=/home/jerome/hermes-sites/vault.hidrago.click
|
||||||
|
ExecStart=/usr/bin/docker compose up -d
|
||||||
|
ExecStop=/usr/bin/docker compose down
|
||||||
|
ExecReload=/usr/bin/docker compose restart
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
TimeoutStartSec=120
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
server_name weather-example.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/weather-example.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
ssl_certificate /etc/letsencrypt/live/weather-example.hidrago.click/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/weather-example.hidrago.click/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
}
|
||||||
|
server {
|
||||||
|
if ($host = weather-example.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name weather-example.hidrago.click;
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch daily weather for Chambourcy, Kuwana, Tokyo and generate a static HTML page (FR/EN/JA)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# City configs
|
||||||
|
CITIES = [
|
||||||
|
{"name": "Chambourcy", "lat": 48.892, "lon": 2.049, "tz": "Europe/Paris"},
|
||||||
|
{"name": "Kuwana", "lat": 35.064, "lon": 136.686, "tz": "Asia/Tokyo"},
|
||||||
|
{"name": "Tokyo", "lat": 35.6762, "lon": 139.6503, "tz": "Asia/Tokyo"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# WMO weather codes -> {lang: description}
|
||||||
|
WMO_CODES = {
|
||||||
|
0: {"fr": "Ciel dégagé", "en": "Clear sky", "ja": "快晴", "emoji": "☀️"},
|
||||||
|
1: {"fr": "Peu nuageux", "en": "Mainly clear", "ja": "概ね晴れ", "emoji": "🌤️"},
|
||||||
|
2: {"fr": "Partiellement nuageux", "en": "Partly cloudy", "ja": "所により曇り", "emoji": "⛅"},
|
||||||
|
3: {"fr": "Couvert", "en": "Overcast", "ja": "曇り", "emoji": "☁️"},
|
||||||
|
45: {"fr": "Brouillard", "en": "Fog", "ja": "霧", "emoji": "🌫️"},
|
||||||
|
48: {"fr": "Brouillard givrant", "en": "Rime fog", "ja": "着氷霧", "emoji": "🌫️"},
|
||||||
|
51: {"fr": "Bruine légère", "en": "Light drizzle", "ja": "軽い霧雨", "emoji": "🌦️"},
|
||||||
|
53: {"fr": "Bruine modérée", "en": "Moderate drizzle", "ja": "霧雨", "emoji": "🌦️"},
|
||||||
|
55: {"fr": "Bruine dense", "en": "Dense drizzle", "ja": "濃い霧雨", "emoji": "🌧️"},
|
||||||
|
56: {"fr": "Bruine verglaçante", "en": "Freezing drizzle", "ja": "着氷霧雨", "emoji": "🌧️"},
|
||||||
|
57: {"fr": "Bruine verglaçante dense", "en": "Dense freezing drizzle", "ja": "濃い着氷霧雨", "emoji": "🌧️"},
|
||||||
|
61: {"fr": "Pluie légère", "en": "Slight rain", "ja": "小雨", "emoji": "🌧️"},
|
||||||
|
63: {"fr": "Pluie modérée", "en": "Moderate rain", "ja": "雨", "emoji": "🌧️"},
|
||||||
|
65: {"fr": "Pluie forte", "en": "Heavy rain", "ja": "大雨", "emoji": "🌧️"},
|
||||||
|
66: {"fr": "Pluie verglaçante", "en": "Freezing rain", "ja": "着氷雨", "emoji": "🌧️"},
|
||||||
|
67: {"fr": "Pluie verglaçante forte", "en": "Heavy freezing rain", "ja": "着氷大雨", "emoji": "🌧️"},
|
||||||
|
71: {"fr": "Neige légère", "en": "Slight snow", "ja": "小雪", "emoji": "🌨️"},
|
||||||
|
73: {"fr": "Neige modérée", "en": "Moderate snow", "ja": "雪", "emoji": "🌨️"},
|
||||||
|
75: {"fr": "Neige forte", "en": "Heavy snow", "ja": "大雪", "emoji": "❄️"},
|
||||||
|
77: {"fr": "Grains de neige", "en": "Snow grains", "ja": "細かい雪", "emoji": "🌨️"},
|
||||||
|
80: {"fr": "Averses légères", "en": "Light showers", "ja": "軽いにわか雨", "emoji": "🌦️"},
|
||||||
|
81: {"fr": "Averses modérées", "en": "Moderate showers", "ja": "にわか雨", "emoji": "🌧️"},
|
||||||
|
82: {"fr": "Averses violentes", "en": "Violent showers", "ja": "激しいにわか雨", "emoji": "⛈️"},
|
||||||
|
85: {"fr": "Averses de neige légères", "en": "Light snow showers", "ja": "軽いにわか雪", "emoji": "🌨️"},
|
||||||
|
86: {"fr": "Averses de neige fortes", "en": "Heavy snow showers", "ja": "激しいにわか雪", "emoji": "❄️"},
|
||||||
|
95: {"fr": "Orage", "en": "Thunderstorm", "ja": "雷雨", "emoji": "⛈️"},
|
||||||
|
96: {"fr": "Orage grêle léger", "en": "Thunderstorm slight hail", "ja": "雷雨・小さな雹", "emoji": "⛈️"},
|
||||||
|
99: {"fr": "Orage grêle fort", "en": "Thunderstorm heavy hail", "ja": "雷雨・大きな雹", "emoji": "⛈️"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# UI translations
|
||||||
|
I18N = {
|
||||||
|
"fr": {
|
||||||
|
"title": "Météo — Chambourcy · Kuwana · Tokyo",
|
||||||
|
"heading": "🌤️ Météo du jour",
|
||||||
|
"subtitle": "Chambourcy · Kuwana · Tokyo",
|
||||||
|
"feels": "Ressenti",
|
||||||
|
"humidity": "Humidité",
|
||||||
|
"wind": "Vent",
|
||||||
|
"maxmin": "Max / Min",
|
||||||
|
"precip": "Précip.",
|
||||||
|
"sunrise": "Lever",
|
||||||
|
"sunset": "Coucher",
|
||||||
|
"data_source": "Données",
|
||||||
|
"updated": "Mis à jour le",
|
||||||
|
"error": "Erreur",
|
||||||
|
"unknown": "Inconnu",
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"title": "Weather — Chambourcy · Kuwana · Tokyo",
|
||||||
|
"heading": "🌤️ Today's Weather",
|
||||||
|
"subtitle": "Chambourcy · Kuwana · Tokyo",
|
||||||
|
"feels": "Feels like",
|
||||||
|
"humidity": "Humidity",
|
||||||
|
"wind": "Wind",
|
||||||
|
"maxmin": "High / Low",
|
||||||
|
"precip": "Rain",
|
||||||
|
"sunrise": "Sunrise",
|
||||||
|
"sunset": "Sunset",
|
||||||
|
"data_source": "Data",
|
||||||
|
"updated": "Updated on",
|
||||||
|
"error": "Error",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"title": "天気 — Chambourcy · Kuwana · Tokyo",
|
||||||
|
"heading": "🌤️ 今日の天気",
|
||||||
|
"subtitle": "Chambourcy · Kuwana · Tokyo",
|
||||||
|
"feels": "体感温度",
|
||||||
|
"humidity": "湿度",
|
||||||
|
"wind": "風速",
|
||||||
|
"maxmin": "最高 / 最低",
|
||||||
|
"precip": "降水量",
|
||||||
|
"sunrise": "日の出",
|
||||||
|
"sunset": "日の入り",
|
||||||
|
"data_source": "データ元",
|
||||||
|
"updated": "更新日時",
|
||||||
|
"error": "エラー",
|
||||||
|
"unknown": "不明",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Country names per lang
|
||||||
|
COUNTRY = {
|
||||||
|
"Chambourcy": {"fr": "France", "en": "France", "ja": "フランス"},
|
||||||
|
"Kuwana": {"fr": "Japon", "en": "Japan", "ja": "日本"},
|
||||||
|
"Tokyo": {"fr": "Japon", "en": "Japan", "ja": "日本"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Export i18n + country maps as JSON for client-side JS
|
||||||
|
I18N_JSON = json.dumps(I18N, ensure_ascii=False)
|
||||||
|
COUNTRY_JSON = json.dumps(COUNTRY, ensure_ascii=False)
|
||||||
|
WMO_JSON = json.dumps(WMO_CODES, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_weather(city):
|
||||||
|
lat, lon = city["lat"], city["lon"]
|
||||||
|
url = (
|
||||||
|
f"https://api.open-meteo.com/v1/forecast"
|
||||||
|
f"?latitude={lat}&longitude={lon}"
|
||||||
|
f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m"
|
||||||
|
f"&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_sum"
|
||||||
|
f"&timezone={city['tz']}"
|
||||||
|
f"&forecast_days=1"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "weather-example/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def generate_html():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cards_html = []
|
||||||
|
|
||||||
|
for city in CITIES:
|
||||||
|
try:
|
||||||
|
data = fetch_weather(city)
|
||||||
|
except Exception as e:
|
||||||
|
cards_html.append(
|
||||||
|
f'<div class="card error"><h2>{city["name"]}</h2>'
|
||||||
|
f'<p data-i18n="error">Erreur: {e}</p></div>'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = data.get("current", {})
|
||||||
|
daily = data.get("daily", {})
|
||||||
|
|
||||||
|
temp = current.get("temperature_2m", "N/A")
|
||||||
|
feels = current.get("apparent_temperature", "N/A")
|
||||||
|
humidity = current.get("relative_humidity_2m", "N/A")
|
||||||
|
wind = current.get("wind_speed_10m", "N/A")
|
||||||
|
code = current.get("weather_code", 0)
|
||||||
|
|
||||||
|
t_max = daily.get("temperature_2m_max", [None])
|
||||||
|
t_min = daily.get("temperature_2m_min", [None])
|
||||||
|
precip = daily.get("precipitation_sum", [None])
|
||||||
|
sunrise = daily.get("sunrise", [None])
|
||||||
|
sunset = daily.get("sunset", [None])
|
||||||
|
|
||||||
|
t_max_val = t_max[0] if t_max else "N/A"
|
||||||
|
t_min_val = t_min[0] if t_min else "N/A"
|
||||||
|
precip_val = precip[0] if precip else "N/A"
|
||||||
|
sunrise_val = sunrise[0] if sunrise else "N/A"
|
||||||
|
sunset_val = sunset[0] if sunset else "N/A"
|
||||||
|
|
||||||
|
card = f"""
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><span class="wmo-emoji" data-code="{code}"></span> {city['name']}</h2>
|
||||||
|
<span class="country" data-city="{city['name']}"></span>
|
||||||
|
</div>
|
||||||
|
<div class="temp-main">{temp}°C</div>
|
||||||
|
<div class="desc" data-wmo="{code}"></div>
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="feels">Ressenti</span><span class="value">{feels}°C</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="humidity">Humidité</span><span class="value">{humidity}%</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="wind">Vent</span><span class="value">{wind} km/h</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="maxmin">Max / Min</span><span class="value">{t_max_val}° / {t_min_val}°</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="precip">Précip.</span><span class="value">{precip_val} mm</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunrise">Lever</span><span class="value">{sunrise_val}</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunset">Coucher</span><span class="value">{sunset_val}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
cards_html.append(card)
|
||||||
|
|
||||||
|
cards = "\n".join(cards_html)
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Météo — Chambourcy · Kuwana · Tokyo</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}}
|
||||||
|
.lang-switch {{
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
.lang-btn {{
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.lang-btn:hover {{ background: rgba(255,255,255,0.3); }}
|
||||||
|
.lang-btn.active {{
|
||||||
|
background: #fff;
|
||||||
|
color: #764ba2;
|
||||||
|
border-color: #fff;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}}
|
||||||
|
.subtitle {{
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 1100px;
|
||||||
|
width: 100%;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 320px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}}
|
||||||
|
.card:hover {{ transform: translateY(-4px); }}
|
||||||
|
.card.error {{ border: 2px solid #e74c3c; }}
|
||||||
|
.card-header {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}}
|
||||||
|
.card-header h2 {{ font-size: 1.4rem; color: #333; }}
|
||||||
|
.country {{ font-size: 0.8rem; color: #999; }}
|
||||||
|
.temp-main {{
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}}
|
||||||
|
.desc {{
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}}
|
||||||
|
.details {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}}
|
||||||
|
.detail-item {{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}}
|
||||||
|
.label {{ font-size: 0.75rem; color: #999; }}
|
||||||
|
.value {{ font-size: 1rem; color: #333; font-weight: 600; }}
|
||||||
|
footer {{
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
footer a {{ color: rgba(255,255,255,0.8); }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||||
|
</div>
|
||||||
|
<h1 id="heading">🌤️ Météo du jour</h1>
|
||||||
|
<p class="subtitle" id="subtitle">Chambourcy · Kuwana · Tokyo</p>
|
||||||
|
<div class="container">
|
||||||
|
{cards}
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span data-i18n="data_source">Données</span>: <a href="https://open-meteo.com/">Open-Meteo</a> · <span data-i18n="updated">Mis à jour le</span> {now.strftime('%d/%m/%Y à %H:%M UTC')}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const I18N = {I18N_JSON};
|
||||||
|
const COUNTRIES = {COUNTRY_JSON};
|
||||||
|
const WMO = {WMO_JSON};
|
||||||
|
let currentLang = 'fr';
|
||||||
|
|
||||||
|
function applyLang(lang) {{
|
||||||
|
currentLang = lang;
|
||||||
|
const t = I18N[lang];
|
||||||
|
document.title = t.title;
|
||||||
|
document.getElementById('heading').textContent = t.heading;
|
||||||
|
document.getElementById('subtitle').textContent = t.subtitle;
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {{
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
if (t[key]) el.textContent = t[key];
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Country names
|
||||||
|
document.querySelectorAll('[data-city]').forEach(el => {{
|
||||||
|
const city = el.getAttribute('data-city');
|
||||||
|
if (COUNTRIES[city]) el.textContent = COUNTRIES[city][lang];
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Weather descriptions + emojis
|
||||||
|
document.querySelectorAll('[data-wmo]').forEach(el => {{
|
||||||
|
const code = parseInt(el.getAttribute('data-wmo'));
|
||||||
|
const w = WMO[code];
|
||||||
|
if (w) el.textContent = w[lang];
|
||||||
|
}});
|
||||||
|
document.querySelectorAll('.wmo-emoji').forEach(el => {{
|
||||||
|
const code = parseInt(el.getAttribute('data-code'));
|
||||||
|
const w = WMO[code];
|
||||||
|
if (w) el.textContent = w.emoji;
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Update <html lang>
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.addEventListener('click', () => applyLang(btn.getAttribute('data-lang')));
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Auto-detect from browser or saved preference
|
||||||
|
const saved = localStorage.getItem('weather-lang');
|
||||||
|
if (saved && I18N[saved]) {{
|
||||||
|
applyLang(saved);
|
||||||
|
}} else {{
|
||||||
|
const browserLang = navigator.language.slice(0, 2);
|
||||||
|
if (I18N[browserLang]) applyLang(browserLang);
|
||||||
|
}}
|
||||||
|
// Save preference on change
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||||
|
btn.addEventListener('click', () => localStorage.setItem('weather-lang', btn.getAttribute('data-lang')));
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
html = generate_html()
|
||||||
|
output_path = "/home/jerome/hermes-sites/weather-example.hidrago.click/index.html"
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"Weather page generated at {output_path}")
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Météo — Chambourcy · Kuwana · Tokyo</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.lang-btn {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.lang-btn:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
.lang-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #764ba2;
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 1100px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 320px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-4px); }
|
||||||
|
.card.error { border: 2px solid #e74c3c; }
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card-header h2 { font-size: 1.4rem; color: #333; }
|
||||||
|
.country { font-size: 0.8rem; color: #999; }
|
||||||
|
.temp-main {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
.label { font-size: 0.75rem; color: #999; }
|
||||||
|
.value { font-size: 1rem; color: #333; font-weight: 600; }
|
||||||
|
footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
footer a { color: rgba(255,255,255,0.8); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||||
|
<button class="lang-btn" data-lang="en">EN</button>
|
||||||
|
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||||
|
</div>
|
||||||
|
<h1 id="heading">🌤️ Météo du jour</h1>
|
||||||
|
<p class="subtitle" id="subtitle">Chambourcy · Kuwana · Tokyo</p>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><span class="wmo-emoji" data-code="3"></span> Chambourcy</h2>
|
||||||
|
<span class="country" data-city="Chambourcy"></span>
|
||||||
|
</div>
|
||||||
|
<div class="temp-main">17.2°C</div>
|
||||||
|
<div class="desc" data-wmo="3"></div>
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="feels">Ressenti</span><span class="value">18.0°C</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="humidity">Humidité</span><span class="value">86%</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="wind">Vent</span><span class="value">5.4 km/h</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="maxmin">Max / Min</span><span class="value">28.3° / 17.2°</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="precip">Précip.</span><span class="value">0.0 mm</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunrise">Lever</span><span class="value">2026-07-31T06:23</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunset">Coucher</span><span class="value">2026-07-31T21:32</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><span class="wmo-emoji" data-code="3"></span> Kuwana</h2>
|
||||||
|
<span class="country" data-city="Kuwana"></span>
|
||||||
|
</div>
|
||||||
|
<div class="temp-main">35.3°C</div>
|
||||||
|
<div class="desc" data-wmo="3"></div>
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="feels">Ressenti</span><span class="value">41.8°C</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="humidity">Humidité</span><span class="value">56%</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="wind">Vent</span><span class="value">9.5 km/h</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="maxmin">Max / Min</span><span class="value">35.3° / 26.5°</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="precip">Précip.</span><span class="value">0.0 mm</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunrise">Lever</span><span class="value">2026-07-31T05:01</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunset">Coucher</span><span class="value">2026-07-31T18:57</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2><span class="wmo-emoji" data-code="1"></span> Tokyo</h2>
|
||||||
|
<span class="country" data-city="Tokyo"></span>
|
||||||
|
</div>
|
||||||
|
<div class="temp-main">32.4°C</div>
|
||||||
|
<div class="desc" data-wmo="1"></div>
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="feels">Ressenti</span><span class="value">37.8°C</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="humidity">Humidité</span><span class="value">58%</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="wind">Vent</span><span class="value">1.4 km/h</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="maxmin">Max / Min</span><span class="value">32.7° / 24.5°</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="precip">Précip.</span><span class="value">0.0 mm</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunrise">Lever</span><span class="value">2026-07-31T04:48</span></div>
|
||||||
|
<div class="detail-item"><span class="label" data-i18n="sunset">Coucher</span><span class="value">2026-07-31T18:47</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<span data-i18n="data_source">Données</span>: <a href="https://open-meteo.com/">Open-Meteo</a> · <span data-i18n="updated">Mis à jour le</span> 31/07/2026 à 05:00 UTC
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const I18N = {"fr": {"title": "Météo — Chambourcy · Kuwana · Tokyo", "heading": "🌤️ Météo du jour", "subtitle": "Chambourcy · Kuwana · Tokyo", "feels": "Ressenti", "humidity": "Humidité", "wind": "Vent", "maxmin": "Max / Min", "precip": "Précip.", "sunrise": "Lever", "sunset": "Coucher", "data_source": "Données", "updated": "Mis à jour le", "error": "Erreur", "unknown": "Inconnu"}, "en": {"title": "Weather — Chambourcy · Kuwana · Tokyo", "heading": "🌤️ Today's Weather", "subtitle": "Chambourcy · Kuwana · Tokyo", "feels": "Feels like", "humidity": "Humidity", "wind": "Wind", "maxmin": "High / Low", "precip": "Rain", "sunrise": "Sunrise", "sunset": "Sunset", "data_source": "Data", "updated": "Updated on", "error": "Error", "unknown": "Unknown"}, "ja": {"title": "天気 — Chambourcy · Kuwana · Tokyo", "heading": "🌤️ 今日の天気", "subtitle": "Chambourcy · Kuwana · Tokyo", "feels": "体感温度", "humidity": "湿度", "wind": "風速", "maxmin": "最高 / 最低", "precip": "降水量", "sunrise": "日の出", "sunset": "日の入り", "data_source": "データ元", "updated": "更新日時", "error": "エラー", "unknown": "不明"}};
|
||||||
|
const COUNTRIES = {"Chambourcy": {"fr": "France", "en": "France", "ja": "フランス"}, "Kuwana": {"fr": "Japon", "en": "Japan", "ja": "日本"}, "Tokyo": {"fr": "Japon", "en": "Japan", "ja": "日本"}};
|
||||||
|
const WMO = {"0": {"fr": "Ciel dégagé", "en": "Clear sky", "ja": "快晴", "emoji": "☀️"}, "1": {"fr": "Peu nuageux", "en": "Mainly clear", "ja": "概ね晴れ", "emoji": "🌤️"}, "2": {"fr": "Partiellement nuageux", "en": "Partly cloudy", "ja": "所により曇り", "emoji": "⛅"}, "3": {"fr": "Couvert", "en": "Overcast", "ja": "曇り", "emoji": "☁️"}, "45": {"fr": "Brouillard", "en": "Fog", "ja": "霧", "emoji": "🌫️"}, "48": {"fr": "Brouillard givrant", "en": "Rime fog", "ja": "着氷霧", "emoji": "🌫️"}, "51": {"fr": "Bruine légère", "en": "Light drizzle", "ja": "軽い霧雨", "emoji": "🌦️"}, "53": {"fr": "Bruine modérée", "en": "Moderate drizzle", "ja": "霧雨", "emoji": "🌦️"}, "55": {"fr": "Bruine dense", "en": "Dense drizzle", "ja": "濃い霧雨", "emoji": "🌧️"}, "56": {"fr": "Bruine verglaçante", "en": "Freezing drizzle", "ja": "着氷霧雨", "emoji": "🌧️"}, "57": {"fr": "Bruine verglaçante dense", "en": "Dense freezing drizzle", "ja": "濃い着氷霧雨", "emoji": "🌧️"}, "61": {"fr": "Pluie légère", "en": "Slight rain", "ja": "小雨", "emoji": "🌧️"}, "63": {"fr": "Pluie modérée", "en": "Moderate rain", "ja": "雨", "emoji": "🌧️"}, "65": {"fr": "Pluie forte", "en": "Heavy rain", "ja": "大雨", "emoji": "🌧️"}, "66": {"fr": "Pluie verglaçante", "en": "Freezing rain", "ja": "着氷雨", "emoji": "🌧️"}, "67": {"fr": "Pluie verglaçante forte", "en": "Heavy freezing rain", "ja": "着氷大雨", "emoji": "🌧️"}, "71": {"fr": "Neige légère", "en": "Slight snow", "ja": "小雪", "emoji": "🌨️"}, "73": {"fr": "Neige modérée", "en": "Moderate snow", "ja": "雪", "emoji": "🌨️"}, "75": {"fr": "Neige forte", "en": "Heavy snow", "ja": "大雪", "emoji": "❄️"}, "77": {"fr": "Grains de neige", "en": "Snow grains", "ja": "細かい雪", "emoji": "🌨️"}, "80": {"fr": "Averses légères", "en": "Light showers", "ja": "軽いにわか雨", "emoji": "🌦️"}, "81": {"fr": "Averses modérées", "en": "Moderate showers", "ja": "にわか雨", "emoji": "🌧️"}, "82": {"fr": "Averses violentes", "en": "Violent showers", "ja": "激しいにわか雨", "emoji": "⛈️"}, "85": {"fr": "Averses de neige légères", "en": "Light snow showers", "ja": "軽いにわか雪", "emoji": "🌨️"}, "86": {"fr": "Averses de neige fortes", "en": "Heavy snow showers", "ja": "激しいにわか雪", "emoji": "❄️"}, "95": {"fr": "Orage", "en": "Thunderstorm", "ja": "雷雨", "emoji": "⛈️"}, "96": {"fr": "Orage grêle léger", "en": "Thunderstorm slight hail", "ja": "雷雨・小さな雹", "emoji": "⛈️"}, "99": {"fr": "Orage grêle fort", "en": "Thunderstorm heavy hail", "ja": "雷雨・大きな雹", "emoji": "⛈️"}};
|
||||||
|
let currentLang = 'fr';
|
||||||
|
|
||||||
|
function applyLang(lang) {
|
||||||
|
currentLang = lang;
|
||||||
|
const t = I18N[lang];
|
||||||
|
document.title = t.title;
|
||||||
|
document.getElementById('heading').textContent = t.heading;
|
||||||
|
document.getElementById('subtitle').textContent = t.subtitle;
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
if (t[key]) el.textContent = t[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Country names
|
||||||
|
document.querySelectorAll('[data-city]').forEach(el => {
|
||||||
|
const city = el.getAttribute('data-city');
|
||||||
|
if (COUNTRIES[city]) el.textContent = COUNTRIES[city][lang];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Weather descriptions + emojis
|
||||||
|
document.querySelectorAll('[data-wmo]').forEach(el => {
|
||||||
|
const code = parseInt(el.getAttribute('data-wmo'));
|
||||||
|
const w = WMO[code];
|
||||||
|
if (w) el.textContent = w[lang];
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.wmo-emoji').forEach(el => {
|
||||||
|
const code = parseInt(el.getAttribute('data-code'));
|
||||||
|
const w = WMO[code];
|
||||||
|
if (w) el.textContent = w.emoji;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update active button
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update <html lang>
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => applyLang(btn.getAttribute('data-lang')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-detect from browser or saved preference
|
||||||
|
const saved = localStorage.getItem('weather-lang');
|
||||||
|
if (saved && I18N[saved]) {
|
||||||
|
applyLang(saved);
|
||||||
|
} else {
|
||||||
|
const browserLang = navigator.language.slice(0, 2);
|
||||||
|
if (I18N[browserLang]) applyLang(browserLang);
|
||||||
|
}
|
||||||
|
// Save preference on change
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => localStorage.setItem('weather-lang', btn.getAttribute('data-lang')));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
server_name yada-land.hidrago.click;
|
||||||
|
|
||||||
|
root /home/jerome/hermes-sites/yada-land.hidrago.click;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 443 ssl;
|
||||||
|
ssl_certificate /etc/letsencrypt/live/hidrago.click/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/hidrago.click/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = yada-land.hidrago.click) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
server_name yada-land.hidrago.click;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
@@ -0,0 +1,945 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Yada Land — 桑名市 矢田の土地価格</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Hiragino Sans', 'Noto Sans JP', sans-serif;
|
||||||
|
background: #f0f4f8;
|
||||||
|
color: #1a202c;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.container { max-width: 960px; margin: 0 auto; }
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
header .subtitle {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
header .subtitle-jp {
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
header .location {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ebf8ff;
|
||||||
|
color: #2b6cb0;
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.lang-btn {
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
border: 2px solid #cbd5e0;
|
||||||
|
background: #fff;
|
||||||
|
color: #4a5568;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.lang-btn.active {
|
||||||
|
background: #2b6cb0;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2b6cb0;
|
||||||
|
}
|
||||||
|
.lang-btn:hover { border-color: #2b6cb0; }
|
||||||
|
.bilingual { display: none; }
|
||||||
|
.bilingual.active { display: block; }
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.card .h2-jp {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.card h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #4a5568;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.card p { line-height: 1.6; margin-bottom: 0.5rem; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #edf2f7;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
th.num { text-align: right; }
|
||||||
|
td {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
td.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
tr:hover td { background: #f7fafc; }
|
||||||
|
.highlight-row td {
|
||||||
|
background: #ebf8ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-box {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.summary-box h2 { color: #fff; border-bottom-color: rgba(255,255,255,0.2); }
|
||||||
|
.summary-box .h2-jp { color: rgba(255,255,255,0.7); }
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.summary-item {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.summary-item .label { font-size: 0.8rem; opacity: 0.8; }
|
||||||
|
.summary-item .value { font-size: 1.4rem; font-weight: 700; margin-top: 0.3rem; }
|
||||||
|
.note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #718096;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.trend-up { color: #48bb78; }
|
||||||
|
.trend-down { color: #e53e3e; }
|
||||||
|
.trend-flat { color: #718096; }
|
||||||
|
/* Price chart */
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
height: 220px;
|
||||||
|
padding: 1rem 0.5rem 2rem;
|
||||||
|
border-bottom: 2px solid #cbd5e0;
|
||||||
|
border-left: 2px solid #cbd5e0;
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
.chart-bar {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-bar .bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 50px;
|
||||||
|
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-bar:hover .bar { opacity: 0.8; }
|
||||||
|
.chart-bar .bar-value {
|
||||||
|
position: absolute;
|
||||||
|
top: -1.2rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #4a5568;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chart-bar .bar-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.chart-yaxis {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 220px;
|
||||||
|
padding: 1rem 0 2rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.chart-wrapper {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.chart-legend .dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* Road project section */
|
||||||
|
.road-card {
|
||||||
|
border-left: 4px solid #e53e3e;
|
||||||
|
}
|
||||||
|
.road-card h2 { color: #c53030; }
|
||||||
|
.road-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.road-info-item {
|
||||||
|
background: #fff5f5;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fed7d7;
|
||||||
|
}
|
||||||
|
.road-info-item .label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #c53030;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.road-info-item .value {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #2d3748;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.road-impact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.road-impact-box {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.road-impact-box.negative {
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #feb2b2;
|
||||||
|
}
|
||||||
|
.road-impact-box.positive {
|
||||||
|
background: #f0fff4;
|
||||||
|
border: 1px solid #9ae6b4;
|
||||||
|
}
|
||||||
|
.road-impact-box h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.road-impact-box.negative h4 { color: #c53030; }
|
||||||
|
.road-impact-box.positive h4 { color: #276749; }
|
||||||
|
.road-impact-box ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.road-impact-box li {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.road-impact-box li::before {
|
||||||
|
content: '•';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.road-impact-box.positive li::before { color: #48bb78; }
|
||||||
|
.road-impact-box.negative li::before { color: #e53e3e; }
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
a { color: #2b6cb0; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
header h1 { font-size: 1.6rem; }
|
||||||
|
table { font-size: 0.8rem; }
|
||||||
|
th, td { padding: 0.4rem 0.5rem; }
|
||||||
|
.road-info, .road-impact { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🏠 Yada Land</h1>
|
||||||
|
<div class="subtitle">Prix des terrains à construire — Kuwana, préfecture de Mie</div>
|
||||||
|
<div class="subtitle-jp">桑名市 矢田 の土地価格情報</div>
|
||||||
|
<div class="location">📍 147-2 Yada, Kuwana, Mie 🇯🇵</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="lang-switch">
|
||||||
|
<button class="lang-btn active" onclick="switchLang('fr')">🇫🇷 Français</button>
|
||||||
|
<button class="lang-btn" onclick="switchLang('jp')">🇯🇵 日本語</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SUMMARY ===== -->
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>📊 Résumé — 桑名市 (données 2026)</h2>
|
||||||
|
<div class="h2-jp">桑名市の土地価格まとめ(2026年)</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>📊 桑名市の土地価格まとめ(2026年)</h2>
|
||||||
|
<div class="h2-jp">Summary — Kuwana City land prices (2026 data)</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="label">Prix moyen / 平均地価<br>FR: tous types · JP: 全用途</div>
|
||||||
|
<div class="value">57,356 ¥/m²</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="label">Terrain résidentiel / 住宅地<br>FR: moyen · JP: 平均</div>
|
||||||
|
<div class="value">54,378 ¥/m²</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="label">Prix au tsubo / 坪単価</div>
|
||||||
|
<div class="value">~190,000 ¥/坪</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="label">Tendance / 変動率</div>
|
||||||
|
<div class="value trend-up">+0.79%/an ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== PRICE BY ZONE TYPE ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2> Prix par type de zone</h2>
|
||||||
|
<div class="h2-jp">用途地域別の土地価格</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>用途地域別の土地価格</h2>
|
||||||
|
<div class="h2-jp">Price by zone type</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>FR / JP</th>
|
||||||
|
<th class="num">¥/m²</th>
|
||||||
|
<th class="num">¥/坪</th>
|
||||||
|
<th class="num">FR: Tendance · JP: 変動率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>住宅地 · Résidentiel</td>
|
||||||
|
<td class="num">54,378</td>
|
||||||
|
<td class="num">179,765</td>
|
||||||
|
<td class="num trend-up">+0.72%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>商業地 · Commercial</td>
|
||||||
|
<td class="num">87,771</td>
|
||||||
|
<td class="num">290,153</td>
|
||||||
|
<td class="num trend-up">+0.99%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>工業地 · Industriel</td>
|
||||||
|
<td class="num">28,650</td>
|
||||||
|
<td class="num">94,710</td>
|
||||||
|
<td class="num trend-up">+1.40%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">Source: 公示地価 2026 (tochidai.info) · MLIT 公示地価</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== PRICE BY STATION ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>🚉 Prix par gare (autour de Yada)</h2>
|
||||||
|
<div class="h2-jp">矢田周辺の駅別地価</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>🚉 矢田周辺の駅別地価</h2>
|
||||||
|
<div class="h2-jp">Price by station (around Yada)</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>FR: Gare · JP: 駅名</th>
|
||||||
|
<th class="num">¥/m²</th>
|
||||||
|
<th class="num">¥/坪</th>
|
||||||
|
<th class="num">変動率</th>
|
||||||
|
<th>FR: Distance Yada · JP: 矢田からの距離</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>馬道 (Umamichi)</td>
|
||||||
|
<td class="num">100,000</td>
|
||||||
|
<td class="num">330,578</td>
|
||||||
|
<td class="num trend-up">+2.88%</td>
|
||||||
|
<td>~1 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="highlight-row">
|
||||||
|
<td>桑名 (Kuwana)</td>
|
||||||
|
<td class="num">89,515</td>
|
||||||
|
<td class="num">295,918</td>
|
||||||
|
<td class="num trend-up">+1.87%</td>
|
||||||
|
<td>~700 m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>益生 (Masuo)</td>
|
||||||
|
<td class="num">60,787</td>
|
||||||
|
<td class="num">200,950</td>
|
||||||
|
<td class="num trend-up">+0.85%</td>
|
||||||
|
<td>~1.5 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>星川 (Hoshikawa)</td>
|
||||||
|
<td class="num">59,720</td>
|
||||||
|
<td class="num">197,421</td>
|
||||||
|
<td class="num trend-up">+0.79%</td>
|
||||||
|
<td>~2 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>下深谷 (Shimofukaya)</td>
|
||||||
|
<td class="num">59,100</td>
|
||||||
|
<td class="num">195,371</td>
|
||||||
|
<td class="num trend-up">+1.03%</td>
|
||||||
|
<td>~3 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>播磨 (Harima)</td>
|
||||||
|
<td class="num">53,800</td>
|
||||||
|
<td class="num">177,851</td>
|
||||||
|
<td class="num trend-up">+0.84%</td>
|
||||||
|
<td>~3 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>伊勢朝日 (Ise-Asahi)</td>
|
||||||
|
<td class="num">47,650</td>
|
||||||
|
<td class="num">157,520</td>
|
||||||
|
<td class="num trend-up">+0.57%</td>
|
||||||
|
<td>~4 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>蓮花寺 (Rengeji)</td>
|
||||||
|
<td class="num">39,883</td>
|
||||||
|
<td class="num">131,845</td>
|
||||||
|
<td class="num trend-up">+0.72%</td>
|
||||||
|
<td>~5 km</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>長島 (Nagashima)</td>
|
||||||
|
<td class="num">30,550</td>
|
||||||
|
<td class="num">100,991</td>
|
||||||
|
<td class="num trend-down">-0.87%</td>
|
||||||
|
<td>~8 km</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">Source: 公示地価 2026 — 駅地価ランキング</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== YADA NEIGHBORHOOD ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2> Ton quartier : 矢田 (Yada)</h2>
|
||||||
|
<div class="h2-jp">矢田(やだ)の地価情報</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>矢田(やだ)の地価情報</h2>
|
||||||
|
<div class="h2-jp">Your neighborhood: Yada</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<p>Le point de référence officiel le plus proche de chez toi :</p>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<p>最寄りの公示地価地点:</p>
|
||||||
|
</div>
|
||||||
|
<h3>大字矢田字大塚90番2外</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Prix officiel · JP: 公示地価</td>
|
||||||
|
<td class="num"><strong>102,000 ¥/m²</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Prix au tsubo · JP: 坪単価</td>
|
||||||
|
<td class="num"><strong>337,190 ¥/坪</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Tendance · JP: 変動率</td>
|
||||||
|
<td class="num trend-up">+3.98% ↑ <span class="bilingual fr active" style="display:inline">(en forte hausse)</span><span class="bilingual jp" style="display:none">(大幅上昇)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Distance gare Kuwana · JP: 桑名駅からの距離</td>
|
||||||
|
<td class="num">660 m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Zone · JP: 用途地域</td>
|
||||||
|
<td>第一種住居地域 · Residential zone</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Emprise au sol · JP: 建蔽率</td>
|
||||||
|
<td class="num">60%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Coefficient d'occupation · JP: 容積率</td>
|
||||||
|
<td class="num">200%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Superficie de référence · JP: 地積</td>
|
||||||
|
<td class="num">184 m² (55 坪)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">Source: 公示地価 2026 — 地点ランキング 4位/桑名市</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 10 YEAR PRICE EVOLUTION ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>📈 Évolution des prix sur 10 ans (2016–2026)</h2>
|
||||||
|
<div class="h2-jp">過去10年間の地価推移(2016〜2026年)</div>
|
||||||
|
<p>Évolution de la moyenne des prix officiels (公示地価) à 桑名市, tous types confondus :</p>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>📈 過去10年間の地価推移(2016〜2026年)</h2>
|
||||||
|
<div class="h2-jp">10-year price evolution (2016–2026)</div>
|
||||||
|
<p>桑名市の公示地価平均(全用途)の推移:</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<div class="chart-wrapper">
|
||||||
|
<div class="chart-yaxis">
|
||||||
|
<span>65,000</span>
|
||||||
|
<span>60,000</span>
|
||||||
|
<span>55,000</span>
|
||||||
|
<span>50,000</span>
|
||||||
|
<span>45,000</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart" id="priceChart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-legend">
|
||||||
|
<span><span class="dot" style="background:linear-gradient(180deg,#667eea,#764ba2)"></span>FR: Prix moyen (¥/m²) · JP: 平均地価(円/m²)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>FR: Année · JP: 年</th>
|
||||||
|
<th class="num">FR: ¥/m² · JP: 円/m²</th>
|
||||||
|
<th class="num">FR: ¥/坪 · JP: 円/坪</th>
|
||||||
|
<th class="num">FR: Variation · JP: 変動率</th>
|
||||||
|
<th class="num">FR: vs 2016 · JP: 2016年比</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>2016</td><td class="num">54,300</td><td class="num">179,504</td><td class="num trend-down">-0.61%</td><td class="num">—</td></tr>
|
||||||
|
<tr><td>2017</td><td class="num">53,404</td><td class="num">176,542</td><td class="num trend-down">-0.54%</td><td class="num trend-down">-1.65%</td></tr>
|
||||||
|
<tr><td>2018</td><td class="num">53,371</td><td class="num">176,434</td><td class="num trend-down">-0.37%</td><td class="num trend-down">-1.71%</td></tr>
|
||||||
|
<tr><td>2019</td><td class="num">54,002</td><td class="num">178,519</td><td class="num trend-down">-0.26%</td><td class="num trend-down">-0.55%</td></tr>
|
||||||
|
<tr><td>2020</td><td class="num">54,602</td><td class="num">180,502</td><td class="num trend-down">-0.03%</td><td class="num trend-up">+0.56%</td></tr>
|
||||||
|
<tr><td>2021</td><td class="num">54,381</td><td class="num">179,773</td><td class="num trend-down">-0.59%</td><td class="num trend-up">+0.15%</td></tr>
|
||||||
|
<tr><td>2022</td><td class="num">54,324</td><td class="num">179,585</td><td class="num trend-down">-0.31%</td><td class="num trend-up">+0.04%</td></tr>
|
||||||
|
<tr><td>2023</td><td class="num">55,428</td><td class="num">183,234</td><td class="num trend-up">+0.14%</td><td class="num trend-up">+2.08%</td></tr>
|
||||||
|
<tr><td>2024</td><td class="num">55,820</td><td class="num">184,530</td><td class="num trend-up">+0.62%</td><td class="num trend-up">+2.80%</td></tr>
|
||||||
|
<tr><td>2025</td><td class="num">56,635</td><td class="num">187,224</td><td class="num trend-up">+0.85%</td><td class="num trend-up">+4.30%</td></tr>
|
||||||
|
<tr class="highlight-row"><td>2026</td><td class="num">57,356</td><td class="num">189,607</td><td class="num trend-up">+0.79%</td><td class="num trend-up">+5.63%</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<p style="margin-top:1rem"><strong>📊 Analyse / 分析:</strong></p>
|
||||||
|
<ul style="padding-left:1.5rem; line-height:1.8; font-size:0.9rem">
|
||||||
|
<li><strong>2016–2022</strong> : Léger déclin (~-1.7%), tendance baissière héritée du post-bulle</li>
|
||||||
|
<li><strong>2020</strong> : Point bas, stabilisation autour de 54,300 ¥/m²</li>
|
||||||
|
<li><strong>2023–2026</strong> : Retournement à la hausse, +5.6% cumulé depuis le point bas</li>
|
||||||
|
<li><strong>Yada (矢田)</strong> spécifiquement : +3.98%/an — bien supérieur à la moyenne de la ville</li>
|
||||||
|
<li>La hausse récente coïncide avec l'avancée des travaux de la Route 1 (国道1号桑名東部拡幅)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<p style="margin-top:1rem"><strong>📊 分析:</strong></p>
|
||||||
|
<ul style="padding-left:1.5rem; line-height:1.8; font-size:0.9rem">
|
||||||
|
<li><strong>2016〜2022年</strong>:微減(約-1.7%)、バブル後の低下傾向の継続</li>
|
||||||
|
<li><strong>2020年</strong>:底打ち、54,300円/m²前後で安定</li>
|
||||||
|
<li><strong>2023〜2026年</strong>:上昇に転じ、底値から累計+5.6%</li>
|
||||||
|
<li><strong>矢田</strong>の地価上昇率は+3.98%/年と市平均を大幅に上回る</li>
|
||||||
|
<li>近年の地価上昇は国道1号桑名東部拡幅事業の進展と時期が一致</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="note">Source: 公示地価 2016–2026 (tochidai.info) · 国土交通省 MLIT</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== ROAD PROJECT ===== -->
|
||||||
|
<div class="card road-card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>🚧 Projet : Élargissement Route 1 — 国道1号桑名東部拡幅事業</h2>
|
||||||
|
<div class="h2-jp">国道1号桑名東部拡幅事業について</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>🚧 国道1号桑名東部拡幅事業</h2>
|
||||||
|
<div class="h2-jp">Road Project: Route 1 Kuwana Eastern Widening</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="road-info">
|
||||||
|
<div class="road-info-item">
|
||||||
|
<div class="label">FR: Tracé · JP: 事業区間</div>
|
||||||
|
<div class="value">長島町又木 ~ 北浜町</div>
|
||||||
|
</div>
|
||||||
|
<div class="road-info-item">
|
||||||
|
<div class="label">FR: Longueur · JP: 延長</div>
|
||||||
|
<div class="value">3.9 km</div>
|
||||||
|
</div>
|
||||||
|
<div class="road-info-item">
|
||||||
|
<div class="label">FR: Objectif · JP: 目的</div>
|
||||||
|
<div class="value">4車線化 + 伊勢大橋架け替え</div>
|
||||||
|
</div>
|
||||||
|
<div class="road-info-item">
|
||||||
|
<div class="label">FR: Début travaux · JP: 着工</div>
|
||||||
|
<div class="value">2015年9月 (平成27年)</div>
|
||||||
|
</div>
|
||||||
|
<div class="road-info-item">
|
||||||
|
<div class="label">FR: Maître d'ouvrage · JP: 主体</div>
|
||||||
|
<div class="value">国土交通省 北勢国道事務所</div>
|
||||||
|
</div>
|
||||||
|
<div class="road-info-item">
|
||||||
|
<div class="label">FR: Statut · JP: 状況</div>
|
||||||
|
<div class="value">🚧 工事進行中 (15橋脚中11完成)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h3>📌 Pourquoi ce projet ?</h3>
|
||||||
|
<p><strong>1. Pont vieillissant</strong> : L'伊勢大橋 (Ise-ōhashi) a été construit en 1934 (90+ ans). Détérioré par la pluie et le sel, il est vulnérable aux tsunami (南海トラフ巨大地震).</p>
|
||||||
|
<p><strong>2. Embouteillages</strong> : C'est le seul secteur de Kuwana classé "congestion majeure", avec 3 carrefours problématiques. Le temps perdu par km est <strong>6,4× la moyenne préfectorale</strong> de Mie.</p>
|
||||||
|
<p><strong>3. Sécurité</strong> : Ajout de voies de virage à droite, élargissement à 4 voies, nouvelles normes antisismiques pour le pont.</p>
|
||||||
|
|
||||||
|
<h3>📍 Impact sur Yada (矢田)</h3>
|
||||||
|
<p>La Route 1 passe par le centre de Kuwana, tout près de la gare et de ton quartier 矢田. Le point de référence officiel à 大字矢田字大塚 est à ~660m de la gare, directement sur l'axe du projet.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h3>📌 事業の目的</h3>
|
||||||
|
<p><strong>1. 老朽橋の架け替え</strong>:伊勢大橋は昭和9年(1934年)竣工(築90年以上)。雨水・塩害による劣化が進み、南海トラフ巨大地震の津波に対して脆弱。</p>
|
||||||
|
<p><strong>2. 渋滞緩和</strong>:桑名市内で唯一の主要渋滞区間。交差点3箇所が主要渋滞箇所に選定。kmあたりの渋滞損失時間は三重県内国道平均の<strong>約6.4倍</strong>。</p>
|
||||||
|
<p><strong>3. 安全性向上</strong>:右折レーン新設、4車線化、最新の耐震基準に基づく橋梁設計。</p>
|
||||||
|
|
||||||
|
<h3>📍 矢田への影響</h3>
|
||||||
|
<p>国道1号は桑名駅近くの中心市街地を通過しており、矢田地域に隣接。大字矢田字大塚の公示地価地点は桑名駅から約660mで、事業区間のすぐ近く。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="road-impact">
|
||||||
|
<div class="road-impact-box negative">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h4>⚠️ Pendant les travaux / 工事中</h4>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h4>⚠️ 工事中の影響</h4>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li><span class="bilingual fr active">Déviations et circulation réduite</span><span class="bilingual jp" style="display:none">通行制限・迂回</span></li>
|
||||||
|
<li><span class="bilingual fr active">Bruit, poussière, vibrations</span><span class="bilingual jp" style="display:none">騒音・粉塵・振動</span></li>
|
||||||
|
<li><span class="bilingual fr active">Acquisitions foncières (移転補償)</span><span class="bilingual jp" style="display:none">用地買収・移転補償</span></li>
|
||||||
|
<li><span class="bilingual fr active">Gêne de circulation pendant 10+ ans</span><span class="bilingual jp" style="display:none">10年以上の交通障害</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="road-impact-box positive">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h4>✅ Après achèvement / 完成後</h4>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h4>✅ 完成後の効果</h4>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li><span class="bilingual fr active">4 voies → trafic fluide</span><span class="bilingual jp" style="display:none">4車線化で交通渋滞緩和</span></li>
|
||||||
|
<li><span class="bilingual fr active">Nouveau pont antisismique</span><span class="bilingual jp" style="display:none">耐震性の高い新橋</span></li>
|
||||||
|
<li><span class="bilingual fr active">Voie de fuite en cas de tsunami</span><span class="bilingual jp" style="display:none">津波時の避難経路確保</span></li>
|
||||||
|
<li><span class="bilingual fr active">Valorisation des terrains proches</span><span class="bilingual jp" style="display:none">周辺地価の上昇期待</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<p style="margin-top:1rem; font-size:0.85rem"><strong>💡 Impact sur les prix</strong> : La hausse de +3.98%/an à 矢田 (vs +0.79% pour la moyenne de 桑名市) suggère que le marché anticipe déjà les bénéfices de l'élargissement. Les terrains proches de la Route 1 pourraient continuer à augmenter à mesure que les travaux avancent.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<p style="margin-top:1rem; font-size:0.85rem"><strong>💡 地価への影響</strong>:矢田の地価上昇率(+3.98%/年)は桑名市平均(+0.79%)を大幅に上回っており、拡幅事業の効果が既に織り込まれていると考えられる。国道1号周辺の土地は工事の進捗に伴いさらに上昇する可能性がある。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">Source: 国土交通省 中部地方整備局 北勢国道事務所 · 桑名市公式サイト</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== BUDGET ESTIMATION ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>💰 Estimation budget pour construire</h2>
|
||||||
|
<div class="h2-jp">建築用土地の予算目安</div>
|
||||||
|
<p>Budget indicatif pour acheter un terrain constructible autour de Yada :</p>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>💰 建築用土地の予算目安</h2>
|
||||||
|
<div class="h2-jp">Budget estimation for building land</div>
|
||||||
|
<p>矢田周辺の建築用土地の予算目安:</p>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>FR: Surface · JP: 面積</th>
|
||||||
|
<th class="num">FR: Zone Yada/Gare · JP: 矢田・駅周辺</th>
|
||||||
|
<th class="num">FR: Zone éloignée · JP: 郊外</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>30 坪 (~100 m²)</td>
|
||||||
|
<td class="num">7.5M – 10.5M ¥</td>
|
||||||
|
<td class="num">4.5M – 6M ¥</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>50 坪 (~165 m²)</td>
|
||||||
|
<td class="num">12.5M – 17.5M ¥</td>
|
||||||
|
<td class="num">7.5M – 10M ¥</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>80 坪 (~265 m²)</td>
|
||||||
|
<td class="num">20M – 28M ¥</td>
|
||||||
|
<td class="num">12M – 16M ¥</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>100 坪 (~330 m²)</td>
|
||||||
|
<td class="num">25M – 35M ¥</td>
|
||||||
|
<td class="num">15M – 20M ¥</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">FR: Estimations basées sur les prix officiels et transactions réelles · JP: 公示地価・実取引価格に基づく概算</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== REAL TRANSACTIONS ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>📋 Transactions réelles (2023)</h2>
|
||||||
|
<div class="h2-jp">実際の取引価格(2023年)</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>📋 実際の取引価格(2023年)</h2>
|
||||||
|
<div class="h2-jp">Real transaction prices (2023)</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Nombre de transactions · JP: 取引件数</td>
|
||||||
|
<td class="num">183件</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Prix moyen réel · JP: 平均取引価格</td>
|
||||||
|
<td class="num">44,682 ¥/m²</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: Soit au tsubo · JP: 坪単価</td>
|
||||||
|
<td class="num">~147,580 ¥/坪</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>FR: vs prix officiel · JP: 公示地価との差</td>
|
||||||
|
<td class="num">~18% en dessous / 約18%低い</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">Source: 国土交通省 API — 不動産取引価格 (land-cost.work)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== TOP 5 ===== -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="bilingual fr active">
|
||||||
|
<h2>🏆 Top 5 des points les plus chers (桑名市)</h2>
|
||||||
|
<div class="h2-jp">桑名市の地価上位5地点</div>
|
||||||
|
</div>
|
||||||
|
<div class="bilingual jp">
|
||||||
|
<h2>🏆 桑名市の地価上位5地点</h2>
|
||||||
|
<div class="h2-jp">Top 5 most expensive points (Kuwana)</div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>FR: Adresse · JP: 住所</th>
|
||||||
|
<th class="num">¥/m²</th>
|
||||||
|
<th class="num">¥/坪</th>
|
||||||
|
<th class="num">変動率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>寿町2丁目 (Kuwana gare)</td>
|
||||||
|
<td class="num">278,000</td>
|
||||||
|
<td class="num">919,008</td>
|
||||||
|
<td class="num trend-up">+3.73%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>末広町58番外</td>
|
||||||
|
<td class="num">159,000</td>
|
||||||
|
<td class="num">525,619</td>
|
||||||
|
<td class="num trend-up">+2.58%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>中央町4丁目</td>
|
||||||
|
<td class="num">132,000</td>
|
||||||
|
<td class="num">436,363</td>
|
||||||
|
<td class="num trend-up">+2.33%</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="highlight-row">
|
||||||
|
<td>4</td>
|
||||||
|
<td>大字矢田字大塚 (Yada 📍)</td>
|
||||||
|
<td class="num">102,000</td>
|
||||||
|
<td class="num">337,190</td>
|
||||||
|
<td class="num trend-up">+3.98%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>5</td>
|
||||||
|
<td>松並町2丁目</td>
|
||||||
|
<td class="num">102,000</td>
|
||||||
|
<td class="num">337,190</td>
|
||||||
|
<td class="num trend-up">+3.13%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="note">Source: 公示地価 2026 & 基準地価 2025 — 地点ランキング</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>FR: Données: tochidai.info · land-cost.work · SUUMO · 国土交通省 (MLIT) · 桑名市 — Juillet 2026</p>
|
||||||
|
<p>JP: データ出典:tochidai.info · land-cost.work · SUUMO · 国土交通省 · 桑名市 — 2026年7月</p>
|
||||||
|
<p>🏠 Generated by Botty for Yada, Kuwana — 矢田のための土地情報</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Language switcher
|
||||||
|
function switchLang(lang) {
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.querySelectorAll('.bilingual').forEach(el => el.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.bilingual.' + lang).forEach(el => el.classList.add('active'));
|
||||||
|
|
||||||
|
// Also switch inline bilingual spans
|
||||||
|
document.querySelectorAll('.bilingual.fr').forEach(el => {
|
||||||
|
if (lang === 'fr') {
|
||||||
|
el.style.display = el.tagName === 'SPAN' ? 'inline' : 'block';
|
||||||
|
el.classList.add('active');
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.bilingual.jp').forEach(el => {
|
||||||
|
if (lang === 'jp') {
|
||||||
|
el.style.display = el.tagName === 'SPAN' ? 'inline' : 'block';
|
||||||
|
el.classList.add('active');
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price chart data
|
||||||
|
const priceData = [
|
||||||
|
{ year: '2016', value: 54300 },
|
||||||
|
{ year: '2017', value: 53404 },
|
||||||
|
{ year: '2018', value: 53371 },
|
||||||
|
{ year: '2019', value: 54002 },
|
||||||
|
{ year: '2020', value: 54602 },
|
||||||
|
{ year: '2021', value: 54381 },
|
||||||
|
{ year: '2022', value: 54324 },
|
||||||
|
{ year: '2023', value: 55428 },
|
||||||
|
{ year: '2024', value: 55820 },
|
||||||
|
{ year: '2025', value: 56635 },
|
||||||
|
{ year: '2026', value: 57356 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxVal = 65000;
|
||||||
|
const minVal = 45000;
|
||||||
|
|
||||||
|
const chartEl = document.getElementById('priceChart');
|
||||||
|
priceData.forEach((d, i) => {
|
||||||
|
const barWrap = document.createElement('div');
|
||||||
|
barWrap.className = 'chart-bar';
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'bar';
|
||||||
|
const heightPct = ((d.value - minVal) / (maxVal - minVal)) * 100;
|
||||||
|
bar.style.height = heightPct + '%';
|
||||||
|
|
||||||
|
const valLabel = document.createElement('span');
|
||||||
|
valLabel.className = 'bar-value';
|
||||||
|
valLabel.textContent = (d.value / 10000).toFixed(1) + '万';
|
||||||
|
|
||||||
|
const yearLabel = document.createElement('span');
|
||||||
|
yearLabel.className = 'bar-label';
|
||||||
|
yearLabel.textContent = d.year;
|
||||||
|
|
||||||
|
bar.appendChild(valLabel);
|
||||||
|
barWrap.appendChild(bar);
|
||||||
|
barWrap.appendChild(yearLabel);
|
||||||
|
chartEl.appendChild(barWrap);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user