mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 21:48:47 +02:00
Refonte de l'interface : direction « Sober Tech »
Reprise du design d'après la maquette fournie, sans changer d'architecture : l'UI reste du HTML/CSS/JS assemblé dans le binaire (voir plus bas). Charte - Palette ardoise + sauge en remplacement des bruns « Terre ». Deux variantes : claire par défaut (celle de la maquette) et « Deep Dark » (fond #0F172A, cartes #1E293B), à un clic depuis l'en-tête. Une variable --on-accent porte ce qui s'écrit sur les aplats de sauge, pour que le contraste tienne dans les deux variantes. - Typographie Inter (interface) + JetBrains Mono (code, chiffres, chemins), en sous-ensemble latin, embarquées comme l'étaient Bricolage et IBM Plex Mono — toujours aucune requête vers un service de polices. - Boutons sans contour, fond au survol, léger enfoncement au clic. - Coque à plat : plus de cartes flottantes séparées par une gouttière, la barre latérale est collée au bord et le fil occupe tout le reste. Coque - Barre d'en-tête : titre de la discussion + sélecteur de modèle. Changer de preset demandait d'ouvrir la barre latérale et de descendre jusqu'aux presets ; c'est désormais une liste déroulante, toujours visible. - Barre latérale en trois zones — marque, contenu défilant, moniteur machine — et escamotable d'un clic sur grand écran. Les discussions y passent au premier plan, avec une recherche textuelle (filtrage côté client : la liste est déjà chargée, sans les messages) ; les réglages descendent sous un repli unique, d'où openDetails(), qui déplie aussi les parents. - Moniteur machine en pied de colonne : une jauge par ressource (charge GPU, puis VRAM et température en détail, mémoire vive), sauge jusqu'à 75 %, ambre puis rouille quand la machine sature. Il remplace la section « Machine », qui disait la même chose en plus verbeux et en plus loin. - L'option « barre latérale escamotable » disparaît d'Apparence : elle faisait de la barre un tiroir posé SUR la conversation, là où le bouton d'en-tête l'escamote pour de bon. Deux mécanismes concurrents pour la même intention. Fil et saisie - La réponse de l'IA redevient une carte, la question un aplat de sauge : sur un fond de fil coloré, du texte nu flottait sans ancrage. - Saisie en pilule, ses outils (joindre, fichiers, envoyer) dans la barre elle-même. L'envoi est une pastille ronde à flèche ; le mot « Envoyer » reste porté par aria-label et l'infobulle. Corrections croisées - L'heure d'une discussion prenait la moitié de la ligne (flex:1 hérité de `.preset>span`), le titre était tronqué au premier mot. - Une liste déroulante posée directement dans une ligne de modale débordait sous son intitulé quand sa valeur était longue (chemin de destination). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
This commit is contained in:
16 files changed
+1160
-255
No files matched your search
@@ -24,11 +24,14 @@ function initTheme(){
|
||||
// Quatre interrupteurs indépendants. Chacun pose un attribut sur <html> ; le CSS
|
||||
// fait le reste, sauf « replier les bulles » qui est un comportement (voir
|
||||
// 08-chat-render).
|
||||
//
|
||||
// « barre latérale escamotable » a disparu d'ici : la refonte l'escamote pour de
|
||||
// bon (bouton de l'en-tête, data-side — voir 18-shell.js), au lieu de la
|
||||
// transformer en tiroir qui recouvre la conversation.
|
||||
const VIEW_OPTS=[
|
||||
{id:'hide-reasoning', label:'masquer le raisonnement'},
|
||||
{id:'hide-tools', label:"masquer les appels d'outils"},
|
||||
{id:'fold-tools', label:'garder les bulles repliées'},
|
||||
{id:'hide-side', label:'barre latérale escamotable'},
|
||||
{id:'hide-stats', label:'masquer la vitesse de génération'},
|
||||
];
|
||||
function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; }
|
||||
@@ -36,12 +39,6 @@ function applyView(id, on){
|
||||
document.documentElement.setAttribute('data-'+id, on?'1':'0');
|
||||
try{ localStorage.setItem('loki-'+id, on?'1':'0'); }catch(e){}
|
||||
const box=document.getElementById('view-'+id); if(box) box.checked=!!on;
|
||||
// La barre latérale peut rester ouverte quand on repasse en mode fixe.
|
||||
if(id==='hide-side' && !on){
|
||||
document.getElementById('side').classList.remove('open');
|
||||
document.getElementById('backdrop').classList.remove('open');
|
||||
document.body.classList.remove('drawer-open');
|
||||
}
|
||||
}
|
||||
function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); }
|
||||
function initView(){
|
||||
|
||||
@@ -42,7 +42,9 @@ async function loadStatus(){
|
||||
function toggleSvcLog(){
|
||||
const box=document.getElementById('svc-log-box');
|
||||
if(!box) return;
|
||||
box.open = !box.open;
|
||||
// openDetails déplie aussi « Réglages » : sans lui, la section s'ouvrait à
|
||||
// l'intérieur d'un repli fermé — donc nulle part, du point de vue de l'écran.
|
||||
if(box.open) box.open = false; else openDetails(box);
|
||||
}
|
||||
async function loadSvcLog(){
|
||||
const el=document.getElementById('svc-log');
|
||||
@@ -127,30 +129,63 @@ function updateCtxMeter(){
|
||||
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
|
||||
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
|
||||
}
|
||||
// ─── Moniteur machine (pied de barre latérale) ───────────────────────────────
|
||||
// Une jauge par ressource : nom, pourcentage, barre, puis le détail chiffré en
|
||||
// dessous. C'est la hiérarchie de la maquette — on lit la charge d'un coup
|
||||
// d'œil, et le détail (Gio, température) reste là pour qui le cherche.
|
||||
//
|
||||
// Construit EN DOM et non en innerHTML : les noms de GPU viennent du pilote,
|
||||
// donc d'ailleurs. Même règle que pour les noms de fichiers et les titres de
|
||||
// discussions.
|
||||
function perfRow(label, pct, sub, title){
|
||||
pct = Math.max(0, Math.min(100, Math.round(pct||0)));
|
||||
const row=document.createElement('div'); row.className='perf-row';
|
||||
if(title) row.title=title;
|
||||
const l=document.createElement('div'); l.className='perf-l';
|
||||
const b=document.createElement('b'); b.textContent=label;
|
||||
const v=document.createElement('span'); v.className='perf-pct'; v.textContent=pct+'%';
|
||||
l.append(b,v);
|
||||
const track=document.createElement('div'); track.className='perf-track';
|
||||
const fill=document.createElement('div');
|
||||
// Sauge jusqu'à 75 %, ambre au-delà, rouille à 90 : la couleur ne sert à rien
|
||||
// tant que la machine respire, elle doit sauter aux yeux quand elle sature.
|
||||
fill.className='perf-fill'+(pct>=90?' max':pct>=75?' hot':'');
|
||||
fill.style.width=pct+'%';
|
||||
track.appendChild(fill);
|
||||
row.append(l,track);
|
||||
if(sub){ const s=document.createElement('div'); s.className='perf-sub'; s.textContent=sub; row.appendChild(s); }
|
||||
return row;
|
||||
}
|
||||
async function loadVram(){
|
||||
const gpus=await jget('/api/vram');
|
||||
// Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous.
|
||||
// Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant
|
||||
// collait aux bords de la carte.
|
||||
document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{
|
||||
const pct=Math.round(g.used*100/g.total);
|
||||
return '<div class="stat"><div class="stat-h"><span class="stat-n">'+g.name+'</span>'+
|
||||
'<span class="stat-v">'+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB</span></div>'+
|
||||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||||
'<div class="stat-s">GPU '+g.util+' % · '+g.temp+' °C</div></div>';
|
||||
}).join('') || '<div class="stat"><span class="stat-s">(pas de GPU)</span></div>';
|
||||
const box=document.getElementById('vram');
|
||||
if(!box) return;
|
||||
box.textContent='';
|
||||
if(!gpus || !gpus.length){
|
||||
const d=document.createElement('div'); d.className='perf-sub'; d.textContent='(pas de GPU détecté)';
|
||||
box.appendChild(d);
|
||||
return;
|
||||
}
|
||||
gpus.forEach((g,i)=>{
|
||||
// Le pourcentage affiché est la CHARGE du processeur graphique (ce que
|
||||
// montre la maquette) ; la VRAM, elle, passe en détail sous la barre — deux
|
||||
// mesures distinctes qu'une seule barre ne peut pas porter.
|
||||
const vram=(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' Gio';
|
||||
box.appendChild(perfRow(
|
||||
'GPU '+i+(gpus.length>1?'':'')+' — charge', g.util,
|
||||
vram+' · '+g.temp+' °C', g.name));
|
||||
});
|
||||
}
|
||||
async function loadRam(){
|
||||
const m=await jget('/api/ram');
|
||||
const box=document.getElementById('ram-details');
|
||||
if(!m || !m.total){ if(box) box.style.display='none'; return; }
|
||||
if(box) box.style.display='';
|
||||
const pct=Math.round(m.used*100/m.total);
|
||||
document.getElementById('ram').innerHTML =
|
||||
'<div class="stat"><div class="stat-h"><span class="stat-n">Mémoire vive</span>'+
|
||||
'<span class="stat-v">'+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB</span></div>'+
|
||||
'<div class="bar"><div style="width:'+pct+'%"></div></div>'+
|
||||
'<div class="stat-s">'+pct+' % utilisée</div></div>';
|
||||
if(!box) return;
|
||||
if(!m || !m.total){ box.style.display='none'; return; }
|
||||
box.style.display='';
|
||||
const host=document.getElementById('ram');
|
||||
host.textContent='';
|
||||
host.appendChild(perfRow('Mémoire vive', m.used*100/m.total,
|
||||
(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' Gio'));
|
||||
}
|
||||
async function loadCfg(){
|
||||
// /api/llamacpp en parallèle : il indique si le BIN de la config correspond au
|
||||
|
||||
@@ -44,6 +44,8 @@ async function loadPresets(){
|
||||
const sp = document.getElementById('status-preset');
|
||||
sp.textContent = act ? act.name : '';
|
||||
sp.title = act ? 'preset actif' : '';
|
||||
// Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
|
||||
if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
|
||||
const cont=document.getElementById('presets');
|
||||
cont.innerHTML='';
|
||||
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
|
||||
|
||||
@@ -28,13 +28,13 @@ async function loadLlamacpp(){
|
||||
|
||||
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
|
||||
if(s.job && s.job.exists && s.job.running && !lcPoll){
|
||||
document.getElementById('lc-details').open = true;
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
|
||||
// typiquement quand le service a redémarré) : on l'affiche sans polling.
|
||||
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
|
||||
document.getElementById('lc-details').open = true;
|
||||
openDetails('lc-details');
|
||||
document.getElementById('lc-job').style.display = '';
|
||||
lcLogNext = 0;
|
||||
document.getElementById('lc-log').textContent = '';
|
||||
@@ -73,7 +73,7 @@ function lcChipOpen(){
|
||||
const side = document.getElementById('side');
|
||||
if(!side.classList.contains('open')) toggleSide();
|
||||
const det = document.getElementById('lc-details');
|
||||
det.open = true;
|
||||
openDetails(det); // déplie aussi « Réglages », qui contient la section
|
||||
det.scrollIntoView({block:'center'});
|
||||
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
|
||||
lcSeenEnd = true;
|
||||
@@ -235,7 +235,7 @@ async function lcInstallCustom(){
|
||||
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
closeCustomBackends();
|
||||
document.getElementById('lc-details').open = true;
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,31 +8,55 @@
|
||||
// Les titres viennent du premier message de l'utilisateur : ils sont construits
|
||||
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
|
||||
// balisage ni les gestionnaires d'événement.
|
||||
//
|
||||
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
|
||||
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||
CONVS = r.conversations || [];
|
||||
CONV_ACTIVE = r.active || '';
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
function renderConversations(){
|
||||
const cont = document.getElementById('conv-list');
|
||||
if(!cont) return;
|
||||
cont.innerHTML = '';
|
||||
const list = r.conversations || [];
|
||||
if(!list.length){ cont.innerHTML = '<span class="muted">(aucune)</span>'; return; }
|
||||
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
|
||||
cont.textContent = '';
|
||||
const list = q
|
||||
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
|
||||
: CONVS;
|
||||
if(!list.length){
|
||||
const e = document.createElement('div');
|
||||
e.className = 'conv-empty';
|
||||
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
|
||||
cont.appendChild(e);
|
||||
syncTopbarTitle();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === r.active ? ' active' : '');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
row.onclick = () => convSwitch(c.id);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'preset-info';
|
||||
const nm = document.createElement('div'); nm.className = 'preset-name';
|
||||
if(c.id === r.active){ const d = document.createElement('i'); d.className = 'preset-dot'; nm.appendChild(d); }
|
||||
const title = c.title || 'Nouvelle discussion';
|
||||
nm.appendChild(document.createTextNode(title));
|
||||
nm.title = title;
|
||||
const meta = document.createElement('div'); meta.className = 'preset-meta';
|
||||
const when = document.createElement('span'); when.className = 'muted';
|
||||
when.textContent = convWhen(c.updated) + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||||
meta.appendChild(when);
|
||||
info.appendChild(nm); info.appendChild(meta);
|
||||
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
|
||||
info.appendChild(nm);
|
||||
|
||||
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
|
||||
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
|
||||
// donc jamais et rien ne se chevauche.
|
||||
const when = document.createElement('span');
|
||||
when.className = 'conv-when';
|
||||
when.textContent = convWhen(c.updated);
|
||||
|
||||
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
|
||||
const acts = document.createElement('div'); acts.className = 'conv-acts';
|
||||
@@ -42,9 +66,21 @@ async function loadConversations(){
|
||||
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
|
||||
acts.appendChild(ren); acts.appendChild(del);
|
||||
|
||||
row.appendChild(info); row.appendChild(acts);
|
||||
row.append(info, when, acts);
|
||||
cont.appendChild(row);
|
||||
}
|
||||
syncTopbarTitle();
|
||||
}
|
||||
|
||||
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
|
||||
// quand la barre latérale est escamotée — c'est-à-dire quand on en a besoin.
|
||||
function syncTopbarTitle(){
|
||||
const el = document.getElementById('topbar-title');
|
||||
if(!el) return;
|
||||
const cur = CONVS.find(c => c.id === CONV_ACTIVE);
|
||||
const t = (cur && cur.title) || 'Nouvelle discussion';
|
||||
el.textContent = t;
|
||||
el.title = t;
|
||||
}
|
||||
|
||||
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
|
||||
@@ -62,6 +98,8 @@ async function convNew(){
|
||||
try{
|
||||
await jpost('/api/conversations/new', {});
|
||||
toast('nouvelle discussion');
|
||||
const s = document.getElementById('conv-search');
|
||||
if(s) s.value = ''; // sinon la discussion toute neuve est masquée par le filtre
|
||||
loadConversations();
|
||||
}catch(_){ toast('erreur réseau'); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// ─── Coque de l'interface ────────────────────────────────────────────────────
|
||||
// Les commandes de la refonte : escamoter la barre latérale, replier le
|
||||
// moniteur machine, basculer le thème depuis l'en-tête, changer de modèle sans
|
||||
// ouvrir les réglages.
|
||||
//
|
||||
// Chaque état vit sur <html> (data-side, data-perf, data-theme) et dans
|
||||
// localStorage, et il est REPOSÉ AVANT LE RENDU par le script du <head> : sans
|
||||
// ça, la barre latérale s'afficherait puis se refermerait sous les yeux à chaque
|
||||
// chargement de page.
|
||||
|
||||
// Escamotage de la barre latérale (grand écran). Sur téléphone elle est déjà un
|
||||
// tiroir — c'est toggleSide() qui s'en charge, et le bouton ☰ qui l'ouvre.
|
||||
function toggleSideCollapse(){
|
||||
if(window.innerWidth <= 720){ toggleSide(); return; }
|
||||
const on = document.documentElement.getAttribute('data-side') !== '0';
|
||||
document.documentElement.setAttribute('data-side', on ? '0' : '1');
|
||||
try{ localStorage.setItem('loki-side', on ? '0' : '1'); }catch(_){}
|
||||
// La largeur du fil change sans qu'aucun `resize` ne soit émis : la gouttière
|
||||
// de barre de défilement mesurée dans --sbw resterait celle d'avant, et la
|
||||
// carte de saisie serait décalée du fil.
|
||||
if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
|
||||
}
|
||||
|
||||
// Moniteur machine replié : le pied de barre latérale ne garde que son titre.
|
||||
function togglePerf(){
|
||||
const on = document.documentElement.getAttribute('data-perf') !== '0';
|
||||
document.documentElement.setAttribute('data-perf', on ? '0' : '1');
|
||||
try{ localStorage.setItem('loki-perf', on ? '0' : '1'); }catch(_){}
|
||||
const b = document.querySelector('#side-perf .perf-toggle');
|
||||
if(b){
|
||||
b.textContent = on ? '+' : '–';
|
||||
b.title = on ? 'déplier le moniteur' : 'replier le moniteur';
|
||||
}
|
||||
}
|
||||
|
||||
// Bascule clair/sombre depuis l'en-tête. Elle passe par applyTheme + savePrefs
|
||||
// (01-theme.js) pour rester alignée avec l'interrupteur des réglages et avec le
|
||||
// serveur — deux chemins vers le même réglage, une seule source de vérité.
|
||||
function toggleThemeQuick(){
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
applyTheme(dark ? 'light' : 'dark');
|
||||
if(typeof savePrefs === 'function') savePrefs();
|
||||
}
|
||||
|
||||
// Déplie un <details> ET tous ceux qui le contiennent. Depuis que les réglages
|
||||
// vivent sous un repli unique, ouvrir une section par programme (journal du
|
||||
// moteur, installation llama.cpp) ne suffit plus : elle s'ouvrait à l'intérieur
|
||||
// d'un parent fermé, donc invisible.
|
||||
function openDetails(el){
|
||||
if(typeof el === 'string') el = document.getElementById(el);
|
||||
while(el){
|
||||
if(el.tagName === 'DETAILS') el.open = true;
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
|
||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||
// confirmation puis relance le moteur).
|
||||
function onModelSwitch(sel){
|
||||
const n = parseInt(sel.value, 10);
|
||||
if(!n) return;
|
||||
const name = sel.options[sel.selectedIndex].textContent;
|
||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
|
||||
// pendant les quelques secondes du redémarrage.
|
||||
switchTo(n, name);
|
||||
}
|
||||
|
||||
// Remplit le sélecteur à partir de la liste de presets déjà chargée par
|
||||
// loadPresets() — aucun appel réseau supplémentaire.
|
||||
function renderModelSwitch(presets, active){
|
||||
const sel = document.getElementById('model-switch');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
if(!presets || !presets.length){
|
||||
const o = document.createElement('option');
|
||||
o.textContent = 'aucun preset'; o.value = '';
|
||||
sel.appendChild(o); sel.disabled = true;
|
||||
return;
|
||||
}
|
||||
sel.disabled = false;
|
||||
presets.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.textContent = p.name;
|
||||
if(active && p.id === active.id) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
// Aucun preset actif (configuration modifiée à la main) : on le dit, plutôt
|
||||
// que de laisser le premier de la liste passer pour le modèle chargé.
|
||||
if(!active){
|
||||
const o = document.createElement('option');
|
||||
o.value = ''; o.textContent = 'configuration hors preset'; o.selected = true;
|
||||
sel.insertBefore(o, sel.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// État initial des libellés (le CSS, lui, est déjà appliqué par le <head>).
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if(document.documentElement.getAttribute('data-perf') === '0'){
|
||||
const b = document.querySelector('#side-perf .perf-toggle');
|
||||
if(b){ b.textContent = '+'; b.title = 'déplier le moniteur'; }
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user