Init Git Prod Mars 2026

This commit is contained in:
Oleg Barykin committed 2026-03-05 10:25:39 +01:00
commit d81efe93b1
362 files changed
+86627

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
// /messages/messages.js
(function () {
const cfg = window.MESSAGING_CFG || {};
const app = document.querySelector('.msg-app');
const convList = document.getElementById('conv-list');
const threadPanel = document.getElementById('msg-thread-panel');
const titleEl = document.getElementById('thread-title');
const msgsEl = document.getElementById('messages');
const btnBack = document.getElementById('btn-back');
const form = document.getElementById('send-form');
const input = document.getElementById('msg-input');
const fileInput = document.getElementById('msg-file');
const hint = document.getElementById('send-hint');
const btnArchive = document.getElementById('btn-archive');
const btnClose = document.getElementById('btn-close');
// État
let currentConv = Number(threadPanel?.dataset?.convId || cfg.initialConv || 0) || 0;
let lastMsgId = 0;
let pollTimer = null;
let isArchived = false;
let isClosed = false;
// Utils
function qs(obj) { return new URLSearchParams(obj).toString(); }
function h(s) { const d = document.createElement('div'); d.textContent = (s ?? ''); return d.innerHTML; }
function atBottom(el) { return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 6; }
function scrollToBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
function setShowThread(on) { if (on) app.classList.add('show-thread'); else app.classList.remove('show-thread'); }
// ===== API helpers =====
async function apiListConversations() {
const r = await fetch('/messages/api/list_conversations.php', { credentials: 'same-origin' });
const j = await r.json();
if (!r.ok) throw new Error('list_failed');
return j.items || [];
}
async function apiFetchMessages(onlyNew) {
if (!currentConv) return [];
const url = new URL('/messages/api/fetch_messages.php', location.origin);
url.searchParams.set('conversation_id', String(currentConv));
if (onlyNew && lastMsgId) url.searchParams.set('since_id', String(lastMsgId));
const r = await fetch(url.toString(), { credentials: 'same-origin' });
const j = await r.json();
if (!r.ok) throw new Error('fetch_failed');
return j.items || [];
}
async function apiSendMessage(fd) {
const r = await fetch('/messages/api/send_message.php', { method: 'POST', body: fd, credentials: 'same-origin' });
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) throw new Error(j.error || 'send_failed');
return j;
}
async function apiMarkRead() {
if (!currentConv) return;
try {
const res = await fetch('/messages/api/mark_read.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: qs({ conversation_id: String(currentConv) })
});
const data = await res.json();
if (data.ok && window.applyBadges) {
window.applyBadges(data);
}
} catch (e) {
// silencieux volontairement
}
}
async function apiDeleteMessage(id) {
const fd = new FormData();
fd.set('_csrf', cfg.csrf);
fd.set('message_id', String(id));
const r = await fetch('/messages/api/delete_message.php', { method: 'POST', body: fd, credentials: 'same-origin' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || 'delete_failed');
return j;
}
async function apiArchiveConversation(convId, archive) {
const fd = new FormData();
fd.set('_csrf', cfg.csrf);
fd.set('conversation_id', String(convId));
fd.set('archive', String(archive ? 1 : 0));
const r = await fetch('/messages/api/archive_conversation.php', { method: 'POST', body: fd, credentials: 'same-origin' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || 'archive_failed');
return j;
}
async function apiCloseConversation(convId, close) {
const fd = new FormData();
fd.set('_csrf', cfg.csrf);
fd.set('conversation_id', String(convId));
fd.set('close', String(close ? 1 : 0));
const r = await fetch('/messages/api/close_conversation.php', { method: 'POST', body: fd, credentials: 'same-origin' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || 'close_failed');
return j;
}
async function apiGetConversation(convId) {
const url = new URL('/messages/api/get_conversation.php', location.origin);
url.searchParams.set('conversation_id', String(convId));
const r = await fetch(url.toString(), { credentials: 'same-origin' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || 'meta_failed');
return j; // {status, archived_for_me, ...}
}
// ===== Rendu UI =====
async function loadConversations() {
try {
const items = await apiListConversations();
renderConversations(items);
} catch (e) {
convList.innerHTML = `<div class="conv-item"><div class="conv-title">API indisponible</div></div>`;
}
}
function renderConversations(items){
convList.innerHTML = '';
if (!items.length){
const el = document.createElement('div');
el.className = 'conv-item';
el.innerHTML = `
<div style="flex:1; text-align:left; overflow:hidden">
<div class="conv-title">${h(cfg.i18n?.noConversations || 'Sélectionner une conversation')}</div>
<div class="conv-last"></div>
</div>
`;
convList.appendChild(el);
return;
}
items.forEach(it => {
const btn = document.createElement('div'); // div pour pouvoir avoir 2 zones cliquables
btn.className = 'conv-item';
btn.innerHTML = `
<div class="conv-main" style="flex:1; text-align:left; overflow:hidden; cursor:pointer;">
<div class="conv-title">${h(it.peer_name || ('#'+it.id))}</div>
<div class="conv-last">${h(it.last_body || '')}</div>
</div>
${Number(it.unread||0) ? `<span class="conv-badge">${it.unread}</span>` : ''}
<button type="button" class="conv-delete" title="${h(cfg.i18n?.deleteConv || 'Supprimer')}">🗑️</button>
`;
// ouvrir la conversation si on clique sur le bloc texte
btn.querySelector('.conv-main').onclick = () => openConversation(Number(it.id));
// suppression via la corbeille
btn.querySelector('.conv-delete').onclick = async (e) => {
e.stopPropagation();
if (!confirm(cfg.i18n?.deleteConvConfirm || 'Supprimer cette conversation ?')) return;
try{
await apiDeleteConversation(it.id);
// Si on est en train de regarder cette conv, on ferme le thread
if (Number(threadPanel.dataset.convId||0) === Number(it.id)) {
threadPanel.dataset.convId = '0';
titleEl.textContent = cfg.i18n?.noConversations || 'Sélectionner une conversation';
msgsEl.innerHTML = '';
setShowThread(false);
const url = new URL(window.location.href);
url.searchParams.delete('conv');
window.history.replaceState(null, '', url.toString());
}
await loadConversations();
}catch(err){
alert(cfg.i18n?.deleteConvFail || 'Suppression impossible.');
}
};
convList.appendChild(btn);
});
}
async function apiDeleteConversation(convId){
const fd = new FormData();
fd.set('_csrf', cfg.csrf);
fd.set('conversation_id', String(convId));
const r = await fetch('/messages/api/delete_conversation.php', { method:'POST', body: fd, credentials: 'same-origin' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || 'delete_conv_failed');
return j;
}
async function fetchAndRender(onlyNew) {
if (!currentConv) return;
try {
const items = await apiFetchMessages(onlyNew);
renderMessages(items, !!onlyNew);
} catch (e) {
// silencieux
}
}
function renderMessages(items, append) {
if (!append) msgsEl.innerHTML = '';
const stick = atBottom(msgsEl);
items.forEach(m => {
const mine = (m.sender_role === cfg.role && Number(m.sender_id) === Number(cfg.meId));
const wrap = document.createElement('div');
wrap.className = 'msg' + (mine ? ' me' : '');
wrap.dataset.id = String(m.id);
const parts = [];
if (m.body) parts.push(`<div>${h(m.body)}</div>`);
if (m.attachment_path) {
const name = m.attachment_name || 'fichier';
parts.push(`<div><a href="/messages/api/download_attachment.php?message_id=${Number(m.id)}" rel="noopener">${h(name)}</a></div>`);
}
const actions = `
<div class="msg-actions">
<button type="button" data-action="delete-for-me">🗑️ ${h(cfg.i18n?.hide || 'Masquer')}</button>
</div>
`;
wrap.innerHTML = `
<div class="bubble">${parts.join('')}</div>
<div class="meta">${h(m.created_at || '')}</div>
${actions}
`;
msgsEl.appendChild(wrap);
lastMsgId = Math.max(lastMsgId, Number(m.id || 0));
});
if (stick) scrollToBottom();
}
function applyClosedUI(on) {
isClosed = !!on;
if (btnClose) btnClose.textContent = on ? (cfg.i18n?.reopen || 'Rouvrir') : (cfg.i18n?.close || 'Clôturer');
if (input) input.disabled = on;
if (fileInput) fileInput.disabled = on;
const sendBtn = form?.querySelector('.send-btn');
if (sendBtn) sendBtn.disabled = on;
// bannière info
let banner = document.getElementById('thread-closed-banner');
if (on) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'thread-closed-banner';
banner.className = 'thread-banner';
banner.textContent = cfg.i18n?.closedBanner || 'Cette conversation est clôturée.';
const body = threadPanel.querySelector('.thread-body');
threadPanel.insertBefore(banner, body || threadPanel.firstChild);
}
} else if (banner) {
banner.remove();
}
}
async function openConversation(id) {
currentConv = Number(id) || 0;
threadPanel.dataset.convId = String(currentConv);
titleEl.textContent = `…`;
setShowThread(true);
lastMsgId = 0;
// labels par défaut
if (btnArchive) btnArchive.textContent = cfg.i18n?.archive || 'Archiver';
if (btnClose) btnClose.textContent = cfg.i18n?.close || 'Clôturer';
applyClosedUI(false);
isArchived = false;
// synchro état réel
try {
const meta = await apiGetConversation(currentConv);
titleEl.textContent = meta.peer_name || `${(cfg.i18n?.conversation || 'Conversation')} #${currentConv}`;
applyClosedUI(meta.status === 'closed');
isArchived = !!meta.archived_for_me;
if (btnArchive) btnArchive.textContent = isArchived
? (cfg.i18n?.unarchive || cfg.i18n?.desarchiver || 'Désarchiver')
: (cfg.i18n?.archive || 'Archiver');
} catch (e) { /* ok si indispo */ }
await fetchAndRender(false);
await fetchAndRender(false);
// force descendre tout en bas à l’ouverture
msgsEl.scrollTop = msgsEl.scrollHeight;
await apiMarkRead();
startPolling();
const url = new URL(window.location.href);
url.searchParams.set('conv', String(currentConv));
window.history.replaceState(null, '', url.toString());
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async () => {
if (!currentConv) return;
await fetchAndRender(true);
// Optionnel: rafraîchir la liste pour les badges
loadConversations();
}, 3000);
}
// ===== Handlers =====
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentConv) return;
const fd = new FormData(form);
fd.set('conversation_id', String(currentConv));
fd.set('_csrf', cfg.csrf);
const btn = form.querySelector('.send-btn');
if (btn) btn.disabled = true;
hint.textContent = '';
try {
await apiSendMessage(fd);
form.reset();
await fetchAndRender(true);
await apiMarkRead();
loadConversations();
input?.focus();
} catch (e2) {
hint.textContent = cfg.i18n?.sendError || 'Erreur lors de l’envoi.';
} finally {
if (btn) btn.disabled = false;
}
});
// Délégation: suppression “pour moi”
msgsEl.addEventListener('click', async (e) => {
const delBtn = e.target.closest?.('[data-action="delete-for-me"]');
if (!delBtn) return;
const wrap = delBtn.closest('.msg');
const id = Number(wrap?.dataset.id || 0);
if (!id) return;
if (!confirm(cfg.i18n?.hideConfirm || 'Masquer ce message pour vous ?')) return;
try {
await apiDeleteMessage(id);
wrap.remove();
hint.textContent = cfg.i18n?.hideOk || 'Message masqué.';
setTimeout(() => { hint.textContent = ''; }, 2000);
} catch (err) {
alert(cfg.i18n?.hideFail || 'Suppression impossible pour le moment.');
}
});
// Archiver / Désarchiver (toggle)
btnArchive?.addEventListener('click', async () => {
if (!currentConv) return;
try {
btnArchive.disabled = true;
const target = isArchived ? 0 : 1;
await apiArchiveConversation(currentConv, target);
isArchived = !!target;
// Label
btnArchive.textContent = isArchived
? (cfg.i18n?.unarchive || cfg.i18n?.desarchiver || 'Désarchiv­er')
: (cfg.i18n?.archive || 'Archiver');
await loadConversations();
if (isArchived) {
// fermer le fil, nettoyer l’URL
currentConv = 0;
threadPanel.dataset.convId = '0';
titleEl.textContent = cfg.i18n?.noConversations || 'Sélectionner une conversation';
msgsEl.innerHTML = '';
const url = new URL(window.location.href);
url.searchParams.delete('conv');
window.history.replaceState(null, '', url.toString());
setShowThread(false);
}
} catch (e) {
alert('Archive: erreur.');
} finally {
btnArchive.disabled = false;
}
});
// Clôturer / Rouvrir (toggle)
btnClose?.addEventListener('click', async () => {
if (!currentConv) return;
try {
btnClose.disabled = true;
if (!isClosed) {
await apiCloseConversation(currentConv, 1);
applyClosedUI(true);
} else {
await apiCloseConversation(currentConv, 0);
applyClosedUI(false);
}
} catch (e) {
alert('Clôture: ' + (e.message || 'erreur.'));
} finally {
btnClose.disabled = false;
}
});
// Retour mobile
btnBack?.addEventListener('click', () => {
setShowThread(false);
const url = new URL(window.location.href);
url.searchParams.delete('conv');
window.history.replaceState(null, '', url.toString());
});
// ===== INIT =====
loadConversations();
if (currentConv) { setShowThread(true); openConversation(currentConv); }
else { setShowThread(false); }
})();