<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Journal Officiel de l'Union des Comores - Portail officiel de publication des textes juridiques et administratifs">
<meta name="author" content="Union des Comores">
<meta name="theme-color" content="#006633">
<title>{% block title %}Journal Officiel - Union des Comores{% endblock %}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { font-family: 'Inter', sans-serif; }
body { background: linear-gradient(135deg, #f5f7fa 0%, #eef2f7 100%); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #006633; border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #004d26; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeInUp { animation: fadeInUp 0.6s ease-out; }
.card-hover { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgba(0,0,0,.1),
0 10px 10px -5px rgba(0,0,0,.02);
}
.gradient-text {
background: linear-gradient(135deg, #006633 0%, #008844 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.chatbot-container { position: fixed; bottom: 2rem; right: 2rem; z-index: 1000; }
.chatbot-toggle {
width: 64px; height: 64px;
background: linear-gradient(135deg, #006633, #008844);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
box-shadow: 0 10px 25px -5px rgba(0,0,0,.2);
transition: all 0.3s ease;
}
.chatbot-toggle:hover {
transform: scale(1.05);
box-shadow: 0 20px 25px -5px rgba(0,0,0,.3);
}
.chatbot-window {
position: absolute; bottom: 80px; right: 0;
width: 400px; height: 560px;
background: white; border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,.25);
display: none; flex-direction: column; overflow: hidden;
}
.chatbot-window.open { display: flex; }
@media (max-width: 640px) {
.chatbot-window { width: calc(100vw - 2rem); right: 0; bottom: 80px; }
}
.loading-spinner {
border: 3px solid rgba(0,102,51,.2);
border-radius: 50%;
border-top: 3px solid #006633;
width: 24px; height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Barre de progression traduction */
@keyframes tlProgress {
0% { width: 0%; }
50% { width: 70%; }
100% { width: 100%; }
}
</style>
{% block stylesheets %}{% endblock %}
</head>
<body>
{% include 'components/_header.html.twig' %}
<main class="min-h-screen">
{% block body %}{% endblock %}
</main>
{% include 'components/_footer.html.twig' %}
{% include 'components/_chatbot.html.twig' %}
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script>
window.App = { chatbot: null };
document.addEventListener('alpine:init', () => {
Alpine.data('chatbot', () => ({
isOpen: false,
messages: [],
currentMessage: '',
isLoading: false,
init() {
this.addMessage(
"Bonjour et bienvenue sur le Journal Officiel de l'Union des Comores. " +
"Je suis Ibunas.IA, votre assistant juridique. Comment puis-je vous aider ?",
'bot'
);
},
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) setTimeout(() => this.scrollToBottom(), 100);
},
async send() {
if (!this.currentMessage.trim() || this.isLoading) return;
const message = this.currentMessage;
this.addMessage(message, 'user');
this.currentMessage = '';
this.isLoading = true;
this.scrollToBottom();
try {
const response = await fetch('{{ path('app_chatbot_send') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ message })
});
const data = await response.json();
this.addMessage(data.response, 'bot');
} catch (e) {
this.addMessage('Désolé, une erreur technique est survenue.', 'bot');
} finally {
this.isLoading = false;
this.scrollToBottom();
}
},
addMessage(text, sender) {
this.messages.push({
id: Date.now(), text, sender, timestamp: new Date()
});
},
scrollToBottom() {
const c = document.getElementById('chatbot-messages');
if (c) c.scrollTop = c.scrollHeight;
}
}));
});
</script>
<!-- ── Moteur de traduction LibreTranslate ── -->
<script>
const Translator = {
cache: {},
loading: false,
getNodes() {
return [...document.querySelectorAll(
'h1, h2, h3, h4, p, a, span, button, label, td, th, li'
)].filter(el =>
[...el.childNodes].some(
n => n.nodeType === 3 && n.textContent.trim().length > 2
) &&
!el.closest('script, style, [data-no-translate]')
);
},
// MyMemory — gratuit, CORS autorisé, pas de clé requise
async translateText(text) {
const url = 'https://api.mymemory.translated.net/get?'
+ new URLSearchParams({
q: text,
langpair: 'fr|en'
});
const res = await fetch(url, { method: 'GET' });
const data = await res.json();
if (data.responseStatus === 200) {
return data.responseData.translatedText;
}
throw new Error('MyMemory error: ' + data.responseStatus);
},
async translate(lang) {
if (this.loading) return;
this.loading = true;
this.showBar();
const nodes = this.getNodes();
const texts = nodes.map(el => el.innerText.trim());
// Retourne le cache si déjà traduit
if (this.cache[lang]) {
this.apply(nodes, this.cache[lang]);
this.hideBar();
this.loading = false;
return;
}
try {
// MyMemory a une limite de 500 chars par requête
// On traduit par petits groupes
const translations = [];
const chunkSize = 10; // 10 textes par lot
for (let i = 0; i < texts.length; i += chunkSize) {
const chunk = texts.slice(i, i + chunkSize);
const sep = ' [|||] ';
const combined = chunk.join(sep);
// MyMemory limite à 500 chars — on tronque si nécessaire
const toTranslate = combined.length > 500
? combined.substring(0, 500)
: combined;
const result = await this.translateText(toTranslate);
const parts = result.split(sep).map(t => t.trim());
// Complète si la réponse a moins d'éléments que l'envoi
while (parts.length < chunk.length) {
parts.push(chunk[parts.length]);
}
translations.push(...parts);
// Pause entre les lots pour éviter le rate limit
if (i + chunkSize < texts.length) {
await new Promise(r => setTimeout(r, 300));
}
}
this.cache[lang] = translations;
this.apply(nodes, translations);
} catch (e) {
console.error('Erreur traduction:', e);
this.showToast('⚠ Traduction indisponible');
} finally {
this.hideBar();
this.loading = false;
}
},
apply(nodes, translations) {
nodes.forEach((el, i) => {
if (!translations[i]) return;
[...el.childNodes].forEach(n => {
if (n.nodeType === 3 && n.textContent.trim().length > 2) {
n.textContent = translations[i];
}
});
});
},
showBar() {
let bar = document.getElementById('tl-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'tl-bar';
bar.innerHTML = `
<div style="position:fixed;top:0;left:0;right:0;height:3px;
background:#e5e7eb;z-index:9999;pointer-events:none">
<div id="tl-prog"
style="height:100%;width:0%;background:#006633;
transition:width 1.5s ease;
border-radius:0 2px 2px 0">
</div>
</div>`;
document.body.appendChild(bar);
}
bar.style.display = 'block';
setTimeout(() => {
const p = document.getElementById('tl-prog');
if (p) p.style.width = '80%';
}, 50);
},
hideBar() {
const p = document.getElementById('tl-prog');
if (p) {
p.style.width = '100%';
setTimeout(() => {
const bar = document.getElementById('tl-bar');
if (bar) bar.style.display = 'none';
p.style.width = '0%';
}, 400);
}
},
showToast(msg) {
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = `
position: fixed; bottom: 1.5rem; left: 50%;
transform: translateX(-50%);
background: #fee2e2; color: #991b1b;
border: 1px solid #fca5a5;
padding: .6rem 1.2rem; border-radius: .75rem;
font-size: .8rem; font-weight: 600; z-index: 9999;`;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
};
// ── Fonctions globales du sélecteur de langue ──
function toggleLangMenu() {
const menu = document.getElementById('lang-menu');
const chevron = document.getElementById('lang-chevron');
const isOpen = !menu.classList.contains('hidden');
menu.classList.toggle('hidden', isOpen);
chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)';
}
function switchLanguage(lang) {
// Ferme le dropdown
document.getElementById('lang-menu')?.classList.add('hidden');
const chevron = document.getElementById('lang-chevron');
if (chevron) chevron.style.transform = 'rotate(0deg)';
// Met à jour le bouton
const flag = document.getElementById('lang-flag');
const label = document.getElementById('lang-label');
if (flag) flag.src = lang === 'en'
? 'https://flagcdn.com/w20/gb.png'
: 'https://flagcdn.com/w20/fr.png';
if (label) label.textContent = lang === 'en' ? 'English' : 'Français';
// Coches actives / inactives
const optFr = document.getElementById('lang-opt-fr');
const optEn = document.getElementById('lang-opt-en');
const chkFr = document.querySelector('.lang-check-fr');
const chkEn = document.querySelector('.lang-check-en');
if (lang === 'en') {
optEn?.classList.add('text-[#006633]', 'font-semibold', 'bg-emerald-50');
optEn?.classList.remove('text-gray-700');
optFr?.classList.remove('text-[#006633]', 'font-semibold', 'bg-emerald-50');
optFr?.classList.add('text-gray-700');
chkEn?.classList.remove('hidden');
chkFr?.classList.add('hidden');
} else {
optFr?.classList.add('text-[#006633]', 'font-semibold', 'bg-emerald-50');
optFr?.classList.remove('text-gray-700');
optEn?.classList.remove('text-[#006633]', 'font-semibold', 'bg-emerald-50');
optEn?.classList.add('text-gray-700');
chkFr?.classList.remove('hidden');
chkEn?.classList.add('hidden');
}
// Sauvegarde le choix
localStorage.setItem('jo_lang', lang);
// Lance la traduction ou recharge pour le français
if (lang === 'en') {
Translator.translate('en');
} else {
window.location.reload();
}
}
// Ferme le dropdown si clic en dehors
document.addEventListener('click', function (e) {
const switcher = document.getElementById('lang-switcher');
if (switcher && !switcher.contains(e.target)) {
document.getElementById('lang-menu')?.classList.add('hidden');
const c = document.getElementById('lang-chevron');
if (c) c.style.transform = 'rotate(0deg)';
}
});
// Restaure la langue sauvegardée au chargement
document.addEventListener('DOMContentLoaded', function () {
if (localStorage.getItem('jo_lang') === 'en') {
const flag = document.getElementById('lang-flag');
const label = document.getElementById('lang-label');
if (flag) flag.src = 'https://flagcdn.com/w20/gb.png';
if (label) label.textContent = 'English';
const optFr = document.getElementById('lang-opt-fr');
const optEn = document.getElementById('lang-opt-en');
const chkFr = document.querySelector('.lang-check-fr');
const chkEn = document.querySelector('.lang-check-en');
optEn?.classList.add('text-[#006633]', 'font-semibold', 'bg-emerald-50');
optEn?.classList.remove('text-gray-700');
optFr?.classList.remove('text-[#006633]', 'font-semibold', 'bg-emerald-50');
optFr?.classList.add('text-gray-700');
chkEn?.classList.remove('hidden');
chkFr?.classList.add('hidden');
Translator.translate('en');
}
});
</script>
{% block javascripts %}{% endblock %}
</body>
</html>