templates/base.html.twig line 1

Open in your IDE?
  1. <!DOCTYPE html>
  2. <html lang="fr">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <meta name="description" content="Journal Officiel de l'Union des Comores - Portail officiel de publication des textes juridiques et administratifs">
  7. <meta name="author" content="Union des Comores">
  8. <meta name="theme-color" content="#006633">
  9. <title>{% block title %}Journal Officiel - Union des Comores{% endblock %}</title>
  10. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
  11. <script src="https://cdn.tailwindcss.com"></script>
  12. <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
  13. <style>
  14. * { font-family: 'Inter', sans-serif; }
  15. body { background: linear-gradient(135deg, #f5f7fa 0%, #eef2f7 100%); }
  16. ::-webkit-scrollbar { width: 10px; height: 10px; }
  17. ::-webkit-scrollbar-track { background: #f1f1f1; }
  18. ::-webkit-scrollbar-thumb { background: #006633; border-radius: 5px; }
  19. ::-webkit-scrollbar-thumb:hover { background: #004d26; }
  20. @keyframes fadeInUp {
  21. from { opacity: 0; transform: translateY(30px); }
  22. to { opacity: 1; transform: translateY(0); }
  23. }
  24. .animate-fadeInUp { animation: fadeInUp 0.6s ease-out; }
  25. .card-hover { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
  26. .card-hover:hover {
  27. transform: translateY(-4px);
  28. box-shadow: 0 20px 25px -5px rgba(0,0,0,.1),
  29. 0 10px 10px -5px rgba(0,0,0,.02);
  30. }
  31. .gradient-text {
  32. background: linear-gradient(135deg, #006633 0%, #008844 100%);
  33. -webkit-background-clip: text;
  34. -webkit-text-fill-color: transparent;
  35. background-clip: text;
  36. }
  37. .chatbot-container { position: fixed; bottom: 2rem; right: 2rem; z-index: 1000; }
  38. .chatbot-toggle {
  39. width: 64px; height: 64px;
  40. background: linear-gradient(135deg, #006633, #008844);
  41. border-radius: 50%;
  42. display: flex; align-items: center; justify-content: center;
  43. cursor: pointer;
  44. box-shadow: 0 10px 25px -5px rgba(0,0,0,.2);
  45. transition: all 0.3s ease;
  46. }
  47. .chatbot-toggle:hover {
  48. transform: scale(1.05);
  49. box-shadow: 0 20px 25px -5px rgba(0,0,0,.3);
  50. }
  51. .chatbot-window {
  52. position: absolute; bottom: 80px; right: 0;
  53. width: 400px; height: 560px;
  54. background: white; border-radius: 1rem;
  55. box-shadow: 0 25px 50px -12px rgba(0,0,0,.25);
  56. display: none; flex-direction: column; overflow: hidden;
  57. }
  58. .chatbot-window.open { display: flex; }
  59. @media (max-width: 640px) {
  60. .chatbot-window { width: calc(100vw - 2rem); right: 0; bottom: 80px; }
  61. }
  62. .loading-spinner {
  63. border: 3px solid rgba(0,102,51,.2);
  64. border-radius: 50%;
  65. border-top: 3px solid #006633;
  66. width: 24px; height: 24px;
  67. animation: spin 1s linear infinite;
  68. }
  69. @keyframes spin {
  70. 0% { transform: rotate(0deg); }
  71. 100% { transform: rotate(360deg); }
  72. }
  73. /* Barre de progression traduction */
  74. @keyframes tlProgress {
  75. 0% { width: 0%; }
  76. 50% { width: 70%; }
  77. 100% { width: 100%; }
  78. }
  79. </style>
  80. {% block stylesheets %}{% endblock %}
  81. </head>
  82. <body>
  83. {% include 'components/_header.html.twig' %}
  84. <main class="min-h-screen">
  85. {% block body %}{% endblock %}
  86. </main>
  87. {% include 'components/_footer.html.twig' %}
  88. {% include 'components/_chatbot.html.twig' %}
  89. <!-- Alpine.js -->
  90. <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
  91. <script>
  92. window.App = { chatbot: null };
  93. document.addEventListener('alpine:init', () => {
  94. Alpine.data('chatbot', () => ({
  95. isOpen: false,
  96. messages: [],
  97. currentMessage: '',
  98. isLoading: false,
  99. init() {
  100. this.addMessage(
  101. "Bonjour et bienvenue sur le Journal Officiel de l'Union des Comores. " +
  102. "Je suis Ibunas.IA, votre assistant juridique. Comment puis-je vous aider ?",
  103. 'bot'
  104. );
  105. },
  106. toggle() {
  107. this.isOpen = !this.isOpen;
  108. if (this.isOpen) setTimeout(() => this.scrollToBottom(), 100);
  109. },
  110. async send() {
  111. if (!this.currentMessage.trim() || this.isLoading) return;
  112. const message = this.currentMessage;
  113. this.addMessage(message, 'user');
  114. this.currentMessage = '';
  115. this.isLoading = true;
  116. this.scrollToBottom();
  117. try {
  118. const response = await fetch('{{ path('app_chatbot_send') }}', {
  119. method: 'POST',
  120. headers: {
  121. 'Content-Type': 'application/json',
  122. 'X-Requested-With': 'XMLHttpRequest'
  123. },
  124. body: JSON.stringify({ message })
  125. });
  126. const data = await response.json();
  127. this.addMessage(data.response, 'bot');
  128. } catch (e) {
  129. this.addMessage('Désolé, une erreur technique est survenue.', 'bot');
  130. } finally {
  131. this.isLoading = false;
  132. this.scrollToBottom();
  133. }
  134. },
  135. addMessage(text, sender) {
  136. this.messages.push({
  137. id: Date.now(), text, sender, timestamp: new Date()
  138. });
  139. },
  140. scrollToBottom() {
  141. const c = document.getElementById('chatbot-messages');
  142. if (c) c.scrollTop = c.scrollHeight;
  143. }
  144. }));
  145. });
  146. </script>
  147. <!-- ── Moteur de traduction LibreTranslate ── -->
  148. <script>
  149. const Translator = {
  150. cache: {},
  151. loading: false,
  152. getNodes() {
  153. return [...document.querySelectorAll(
  154. 'h1, h2, h3, h4, p, a, span, button, label, td, th, li'
  155. )].filter(el =>
  156. [...el.childNodes].some(
  157. n => n.nodeType === 3 && n.textContent.trim().length > 2
  158. ) &&
  159. !el.closest('script, style, [data-no-translate]')
  160. );
  161. },
  162. // MyMemory — gratuit, CORS autorisé, pas de clé requise
  163. async translateText(text) {
  164. const url = 'https://api.mymemory.translated.net/get?'
  165. + new URLSearchParams({
  166. q: text,
  167. langpair: 'fr|en'
  168. });
  169. const res = await fetch(url, { method: 'GET' });
  170. const data = await res.json();
  171. if (data.responseStatus === 200) {
  172. return data.responseData.translatedText;
  173. }
  174. throw new Error('MyMemory error: ' + data.responseStatus);
  175. },
  176. async translate(lang) {
  177. if (this.loading) return;
  178. this.loading = true;
  179. this.showBar();
  180. const nodes = this.getNodes();
  181. const texts = nodes.map(el => el.innerText.trim());
  182. // Retourne le cache si déjà traduit
  183. if (this.cache[lang]) {
  184. this.apply(nodes, this.cache[lang]);
  185. this.hideBar();
  186. this.loading = false;
  187. return;
  188. }
  189. try {
  190. // MyMemory a une limite de 500 chars par requête
  191. // On traduit par petits groupes
  192. const translations = [];
  193. const chunkSize = 10; // 10 textes par lot
  194. for (let i = 0; i < texts.length; i += chunkSize) {
  195. const chunk = texts.slice(i, i + chunkSize);
  196. const sep = ' [|||] ';
  197. const combined = chunk.join(sep);
  198. // MyMemory limite à 500 chars — on tronque si nécessaire
  199. const toTranslate = combined.length > 500
  200. ? combined.substring(0, 500)
  201. : combined;
  202. const result = await this.translateText(toTranslate);
  203. const parts = result.split(sep).map(t => t.trim());
  204. // Complète si la réponse a moins d'éléments que l'envoi
  205. while (parts.length < chunk.length) {
  206. parts.push(chunk[parts.length]);
  207. }
  208. translations.push(...parts);
  209. // Pause entre les lots pour éviter le rate limit
  210. if (i + chunkSize < texts.length) {
  211. await new Promise(r => setTimeout(r, 300));
  212. }
  213. }
  214. this.cache[lang] = translations;
  215. this.apply(nodes, translations);
  216. } catch (e) {
  217. console.error('Erreur traduction:', e);
  218. this.showToast('⚠ Traduction indisponible');
  219. } finally {
  220. this.hideBar();
  221. this.loading = false;
  222. }
  223. },
  224. apply(nodes, translations) {
  225. nodes.forEach((el, i) => {
  226. if (!translations[i]) return;
  227. [...el.childNodes].forEach(n => {
  228. if (n.nodeType === 3 && n.textContent.trim().length > 2) {
  229. n.textContent = translations[i];
  230. }
  231. });
  232. });
  233. },
  234. showBar() {
  235. let bar = document.getElementById('tl-bar');
  236. if (!bar) {
  237. bar = document.createElement('div');
  238. bar.id = 'tl-bar';
  239. bar.innerHTML = `
  240. <div style="position:fixed;top:0;left:0;right:0;height:3px;
  241. background:#e5e7eb;z-index:9999;pointer-events:none">
  242. <div id="tl-prog"
  243. style="height:100%;width:0%;background:#006633;
  244. transition:width 1.5s ease;
  245. border-radius:0 2px 2px 0">
  246. </div>
  247. </div>`;
  248. document.body.appendChild(bar);
  249. }
  250. bar.style.display = 'block';
  251. setTimeout(() => {
  252. const p = document.getElementById('tl-prog');
  253. if (p) p.style.width = '80%';
  254. }, 50);
  255. },
  256. hideBar() {
  257. const p = document.getElementById('tl-prog');
  258. if (p) {
  259. p.style.width = '100%';
  260. setTimeout(() => {
  261. const bar = document.getElementById('tl-bar');
  262. if (bar) bar.style.display = 'none';
  263. p.style.width = '0%';
  264. }, 400);
  265. }
  266. },
  267. showToast(msg) {
  268. const t = document.createElement('div');
  269. t.textContent = msg;
  270. t.style.cssText = `
  271. position: fixed; bottom: 1.5rem; left: 50%;
  272. transform: translateX(-50%);
  273. background: #fee2e2; color: #991b1b;
  274. border: 1px solid #fca5a5;
  275. padding: .6rem 1.2rem; border-radius: .75rem;
  276. font-size: .8rem; font-weight: 600; z-index: 9999;`;
  277. document.body.appendChild(t);
  278. setTimeout(() => t.remove(), 3000);
  279. }
  280. };
  281. // ── Fonctions globales du sélecteur de langue ──
  282. function toggleLangMenu() {
  283. const menu = document.getElementById('lang-menu');
  284. const chevron = document.getElementById('lang-chevron');
  285. const isOpen = !menu.classList.contains('hidden');
  286. menu.classList.toggle('hidden', isOpen);
  287. chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)';
  288. }
  289. function switchLanguage(lang) {
  290. // Ferme le dropdown
  291. document.getElementById('lang-menu')?.classList.add('hidden');
  292. const chevron = document.getElementById('lang-chevron');
  293. if (chevron) chevron.style.transform = 'rotate(0deg)';
  294. // Met à jour le bouton
  295. const flag = document.getElementById('lang-flag');
  296. const label = document.getElementById('lang-label');
  297. if (flag) flag.src = lang === 'en'
  298. ? 'https://flagcdn.com/w20/gb.png'
  299. : 'https://flagcdn.com/w20/fr.png';
  300. if (label) label.textContent = lang === 'en' ? 'English' : 'Français';
  301. // Coches actives / inactives
  302. const optFr = document.getElementById('lang-opt-fr');
  303. const optEn = document.getElementById('lang-opt-en');
  304. const chkFr = document.querySelector('.lang-check-fr');
  305. const chkEn = document.querySelector('.lang-check-en');
  306. if (lang === 'en') {
  307. optEn?.classList.add('text-[#006633]', 'font-semibold', 'bg-emerald-50');
  308. optEn?.classList.remove('text-gray-700');
  309. optFr?.classList.remove('text-[#006633]', 'font-semibold', 'bg-emerald-50');
  310. optFr?.classList.add('text-gray-700');
  311. chkEn?.classList.remove('hidden');
  312. chkFr?.classList.add('hidden');
  313. } else {
  314. optFr?.classList.add('text-[#006633]', 'font-semibold', 'bg-emerald-50');
  315. optFr?.classList.remove('text-gray-700');
  316. optEn?.classList.remove('text-[#006633]', 'font-semibold', 'bg-emerald-50');
  317. optEn?.classList.add('text-gray-700');
  318. chkFr?.classList.remove('hidden');
  319. chkEn?.classList.add('hidden');
  320. }
  321. // Sauvegarde le choix
  322. localStorage.setItem('jo_lang', lang);
  323. // Lance la traduction ou recharge pour le français
  324. if (lang === 'en') {
  325. Translator.translate('en');
  326. } else {
  327. window.location.reload();
  328. }
  329. }
  330. // Ferme le dropdown si clic en dehors
  331. document.addEventListener('click', function (e) {
  332. const switcher = document.getElementById('lang-switcher');
  333. if (switcher && !switcher.contains(e.target)) {
  334. document.getElementById('lang-menu')?.classList.add('hidden');
  335. const c = document.getElementById('lang-chevron');
  336. if (c) c.style.transform = 'rotate(0deg)';
  337. }
  338. });
  339. // Restaure la langue sauvegardée au chargement
  340. document.addEventListener('DOMContentLoaded', function () {
  341. if (localStorage.getItem('jo_lang') === 'en') {
  342. const flag = document.getElementById('lang-flag');
  343. const label = document.getElementById('lang-label');
  344. if (flag) flag.src = 'https://flagcdn.com/w20/gb.png';
  345. if (label) label.textContent = 'English';
  346. const optFr = document.getElementById('lang-opt-fr');
  347. const optEn = document.getElementById('lang-opt-en');
  348. const chkFr = document.querySelector('.lang-check-fr');
  349. const chkEn = document.querySelector('.lang-check-en');
  350. optEn?.classList.add('text-[#006633]', 'font-semibold', 'bg-emerald-50');
  351. optEn?.classList.remove('text-gray-700');
  352. optFr?.classList.remove('text-[#006633]', 'font-semibold', 'bg-emerald-50');
  353. optFr?.classList.add('text-gray-700');
  354. chkEn?.classList.remove('hidden');
  355. chkFr?.classList.add('hidden');
  356. Translator.translate('en');
  357. }
  358. });
  359. </script>
  360. {% block javascripts %}{% endblock %}
  361. </body>
  362. </html>