feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
This commit is contained in:
+20
-2
@@ -1072,6 +1072,13 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<!-- #191 : rangée de canaux (Général + utilisateurs) -->
|
||||
<div
|
||||
id="sidebar-panel-chat-channels"
|
||||
class="chat-channels"
|
||||
role="tablist"
|
||||
aria-label="Canaux de discussion"
|
||||
></div>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
@@ -1090,7 +1097,7 @@
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 16px; height: 16px"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
<input
|
||||
@@ -1102,7 +1109,18 @@
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
/>
|
||||
<button type="submit" class="file-chat-send">Envoyer</button>
|
||||
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
|
||||
<button
|
||||
type="submit"
|
||||
class="file-chat-send file-chat-send-icon"
|
||||
title="Envoyer"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<i
|
||||
data-lucide="send"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
+216
-45
@@ -12,20 +12,23 @@
|
||||
import { api, AuthManager } from "./auth.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./ui.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { escapeHtml, safeCreateIcons } from "./utils.js";
|
||||
|
||||
const PANEL_ID = "file-chat-panel";
|
||||
const GLOBAL_VAULT = "__global__";
|
||||
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts}
|
||||
const DM_VAULT = "__dm__";
|
||||
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
|
||||
|
||||
let _vault = null;
|
||||
let _path = null;
|
||||
let _title = "";
|
||||
let _sending = false;
|
||||
|
||||
// État du chat général (onglet sidebar)
|
||||
let _generalMessages = [];
|
||||
let _generalLoaded = false;
|
||||
// État du chat sidebar (#190 B + #191 : canaux général / privé)
|
||||
let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
|
||||
let _channelMessages = [];
|
||||
let _channelLoaded = false;
|
||||
let _usersCache = null; // liste des destinataires DM (une seule requête)
|
||||
let _pendingAttach = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -90,7 +93,65 @@ function _attachEl(att) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function _messageEl(m, currentUser) {
|
||||
// #191 — suppression : auteur ou admin, avec confirmation native.
|
||||
function _canDelete(m, currentUser) {
|
||||
const u = AuthManager.getUser();
|
||||
if (!u || !u.username) return false;
|
||||
return u.role === "admin" || m.user === u.username;
|
||||
}
|
||||
|
||||
async function _deleteMessage(delUrl, id, wrap) {
|
||||
if (!window.confirm(t("chat.delete_confirm"))) return;
|
||||
try {
|
||||
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
wrap.remove();
|
||||
} catch (_) {
|
||||
showToast(t("chat.delete_error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
// #191 — carte de lien : site, titre, description, vignette.
|
||||
function _previewEl(p) {
|
||||
const card = document.createElement("a");
|
||||
card.className = "file-chat-preview";
|
||||
card.href = p.url;
|
||||
card.target = "_blank";
|
||||
card.rel = "noopener noreferrer";
|
||||
if (p.image) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "file-chat-preview-img";
|
||||
img.src = p.image;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
card.appendChild(img);
|
||||
}
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-preview-meta";
|
||||
const site = document.createElement("div");
|
||||
site.className = "file-chat-preview-site";
|
||||
site.textContent = p.site || "";
|
||||
const title = document.createElement("div");
|
||||
title.className = "file-chat-preview-title";
|
||||
title.textContent = p.title || p.url;
|
||||
meta.appendChild(site);
|
||||
meta.appendChild(title);
|
||||
if (p.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "file-chat-preview-desc";
|
||||
desc.textContent = p.description;
|
||||
meta.appendChild(desc);
|
||||
}
|
||||
card.appendChild(meta);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one message.
|
||||
* *delUrl* (#191) is the DELETE endpoint base for this conversation; when
|
||||
* provided the author/admin sees a trash button on their own messages.
|
||||
*/
|
||||
export function _messageEl(m, currentUser, delUrl) {
|
||||
const mine = currentUser && m.user === currentUser;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
|
||||
@@ -100,6 +161,16 @@ export function _messageEl(m, currentUser) {
|
||||
meta.innerHTML =
|
||||
`<span class="file-chat-user">${_esc(m.user)}</span>` +
|
||||
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
|
||||
if (delUrl && _canDelete(m, currentUser)) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "file-chat-del";
|
||||
del.title = t("chat.delete");
|
||||
del.setAttribute("aria-label", t("chat.delete"));
|
||||
del.textContent = "🗑";
|
||||
del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap));
|
||||
meta.appendChild(del);
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-chat-body";
|
||||
_fillBody(body, m.text);
|
||||
@@ -107,10 +178,11 @@ export function _messageEl(m, currentUser) {
|
||||
if (m.text) wrap.appendChild(body);
|
||||
const att = _attachEl(m.attachment);
|
||||
if (att) wrap.appendChild(att);
|
||||
if (m.preview) wrap.appendChild(_previewEl(m.preview));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderChatMessages(container, messages, currentUser) {
|
||||
export function renderChatMessages(container, messages, currentUser, delUrl) {
|
||||
container.innerHTML = "";
|
||||
if (!messages || messages.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
@@ -119,16 +191,16 @@ export function renderChatMessages(container, messages, currentUser) {
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser)));
|
||||
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl)));
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function _appendMessage(list, m) {
|
||||
function _appendMessage(list, m, delUrl) {
|
||||
const empty = list.querySelector(".file-chat-empty");
|
||||
if (empty) empty.remove();
|
||||
const id = m.id;
|
||||
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
|
||||
list.appendChild(_messageEl(m, _currentUser()));
|
||||
list.appendChild(_messageEl(m, _currentUser(), delUrl));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return true;
|
||||
}
|
||||
@@ -203,8 +275,11 @@ function _renderShell(panel) {
|
||||
input.setAttribute("aria-label", t("chat.placeholder"));
|
||||
const send = document.createElement("button");
|
||||
send.type = "submit";
|
||||
send.className = "file-chat-send";
|
||||
send.textContent = t("chat.send");
|
||||
send.className = "file-chat-send file-chat-send-icon";
|
||||
send.title = t("chat.send");
|
||||
send.setAttribute("aria-label", t("chat.send"));
|
||||
// #191 : icône compacte — la saisie garde toute la largeur
|
||||
send.innerHTML = '<i data-lucide="send" style="width:15px;height:15px"></i>';
|
||||
form.appendChild(input);
|
||||
form.appendChild(send);
|
||||
form.addEventListener("submit", (e) => {
|
||||
@@ -215,6 +290,7 @@ function _renderShell(panel) {
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(list);
|
||||
panel.appendChild(form);
|
||||
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
|
||||
}
|
||||
|
||||
export async function openFileChat(vault, path, title) {
|
||||
@@ -272,21 +348,76 @@ async function _send(input) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Chat général, onglet sidebar (#190 B)
|
||||
// 2. Chat sidebar (#190 B) + canaux privé #191
|
||||
// ---------------------------------------------------------------------------
|
||||
function _sidebarList() {
|
||||
return document.getElementById("sidebar-panel-chat-list");
|
||||
}
|
||||
|
||||
/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
|
||||
/** Clé de la conversation courante (unread + sélection). */
|
||||
function _channelKey() {
|
||||
return _channel.type === "dm" ? `dm:${_channel.user}` : "general";
|
||||
}
|
||||
|
||||
/** URL d'API de base du canal courant. */
|
||||
function _channelUrl() {
|
||||
return _channel.type === "dm"
|
||||
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
||||
: "/api/chat";
|
||||
}
|
||||
|
||||
/** URL de base de suppression du canal courant. */
|
||||
function _channelDelUrl() {
|
||||
return _channel.type === "dm"
|
||||
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
||||
: "/api/chat";
|
||||
}
|
||||
|
||||
/** Rend la rangée de canaux : Général + un bouton par utilisateur. */
|
||||
function _renderChannels(users) {
|
||||
const holder = document.getElementById("sidebar-panel-chat-channels");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
const mk = (label, active, onClick, title) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "chat-channel" + (active ? " active" : "");
|
||||
b.textContent = label;
|
||||
if (title) b.title = title;
|
||||
b.addEventListener("click", onClick);
|
||||
holder.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null));
|
||||
(users || []).forEach((u) => {
|
||||
mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username,
|
||||
() => switchChannel(u.username), `@${u.username}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */
|
||||
async function _loadUsers() {
|
||||
if (_usersCache) return _usersCache;
|
||||
try {
|
||||
const data = await api("/api/chat/users");
|
||||
_usersCache = Array.isArray(data) ? data : [];
|
||||
} catch (_) {
|
||||
_usersCache = [];
|
||||
}
|
||||
return _usersCache;
|
||||
}
|
||||
|
||||
/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */
|
||||
export async function openSidebarChat() {
|
||||
const list = _sidebarList();
|
||||
if (!list) return;
|
||||
const users = await _loadUsers();
|
||||
_renderChannels(users);
|
||||
try {
|
||||
const data = await api("/api/chat");
|
||||
_generalMessages = (data.messages || []).slice();
|
||||
_generalLoaded = true;
|
||||
renderChatMessages(list, _generalMessages, _currentUser());
|
||||
const data = await api(_channelUrl());
|
||||
_channelMessages = (data.messages || []).slice();
|
||||
_channelLoaded = true;
|
||||
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl());
|
||||
_clearUnread();
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
@@ -297,17 +428,27 @@ export async function openSidebarChat() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
|
||||
/** Change de canal (#191) : null = général, sinon l'username du pair. */
|
||||
export function switchChannel(user) {
|
||||
_channel = user ? { type: "dm", user } : { type: "general" };
|
||||
const input = _sendInput();
|
||||
if (input) {
|
||||
input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder");
|
||||
}
|
||||
openSidebarChat();
|
||||
}
|
||||
|
||||
/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */
|
||||
export function filterChatMessages(query) {
|
||||
const list = _sidebarList();
|
||||
if (!list || !_generalLoaded) return;
|
||||
if (!list || !_channelLoaded) return;
|
||||
const q = String(query || "").toLowerCase().trim();
|
||||
const shown = q
|
||||
? _generalMessages.filter((m) =>
|
||||
? _channelMessages.filter((m) =>
|
||||
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
|
||||
)
|
||||
: _generalMessages;
|
||||
renderChatMessages(list, shown, _currentUser());
|
||||
: _channelMessages;
|
||||
renderChatMessages(list, shown, _currentUser(), _channelDelUrl());
|
||||
}
|
||||
|
||||
function _sendInput() {
|
||||
@@ -344,14 +485,14 @@ export async function sendGeneralMessage() {
|
||||
const body = { text };
|
||||
if (_pendingAttach) body.attachment = _pendingAttach;
|
||||
try {
|
||||
const data = await api("/api/chat", { method: "POST", body: JSON.stringify(body) });
|
||||
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
|
||||
input.value = "";
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
const list = _sidebarList();
|
||||
if (list) {
|
||||
_generalMessages.push(data.message);
|
||||
_appendMessage(list, data.message);
|
||||
_channelMessages.push(data.message);
|
||||
_appendMessage(list, data.message, _channelDelUrl());
|
||||
}
|
||||
input.focus();
|
||||
} catch (_) {
|
||||
@@ -387,7 +528,9 @@ export async function pickAttachment() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages non lus (#190 B) --------------------------------------------
|
||||
// --- Messages non lus (#190 B / #191) --------------------------------------
|
||||
// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du
|
||||
// canal → remise à zéro de sa clé.
|
||||
function _readUnread() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
|
||||
@@ -396,13 +539,23 @@ function _readUnread() {
|
||||
}
|
||||
}
|
||||
|
||||
function _clearUnread() {
|
||||
const u = _readUnread();
|
||||
u.general = Math.floor(Date.now() / 1000);
|
||||
function _writeUnread(u) {
|
||||
try {
|
||||
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
|
||||
} catch (_) { /* quota */ }
|
||||
_renderBadge(0);
|
||||
}
|
||||
|
||||
function _clearUnread() {
|
||||
const u = _readUnread();
|
||||
u[_channelKey()] = 0;
|
||||
_writeUnread(u);
|
||||
_renderBadge(_totalUnread());
|
||||
_renderChannels(_usersCache);
|
||||
}
|
||||
|
||||
function _totalUnread() {
|
||||
const u = _readUnread();
|
||||
return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
|
||||
}
|
||||
|
||||
function _renderBadge(count) {
|
||||
@@ -421,13 +574,9 @@ function _renderBadge(count) {
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
}
|
||||
|
||||
/** Compte les messages reçus depuis la dernière visite. */
|
||||
export function refreshUnreadBadge(messages) {
|
||||
const list = messages || _generalMessages;
|
||||
if (!list.length) return _renderBadge(0);
|
||||
const seen = _readUnread().general || 0;
|
||||
const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length;
|
||||
_renderBadge(count);
|
||||
/** Affiche le total des messages non lus (tous canaux confondus). */
|
||||
export function refreshUnreadBadge() {
|
||||
_renderBadge(_totalUnread());
|
||||
}
|
||||
|
||||
/** Initialisation : formulaire + raccourcis clavier. */
|
||||
@@ -456,18 +605,20 @@ export function initSidebarChat() {
|
||||
// ---------------------------------------------------------------------------
|
||||
export function onChatMessage(data) {
|
||||
if (!data || !data.message) return;
|
||||
const isGeneral = data.vault === GLOBAL_VAULT;
|
||||
|
||||
if (isGeneral) {
|
||||
_generalMessages.push(data.message);
|
||||
const list = _sidebarList();
|
||||
// --- Canaux sidebar : général (#190) ou privé (#191) ---
|
||||
if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
|
||||
const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
|
||||
const isActive = key === _channelKey();
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
const visible = panel && panel.classList.contains("active");
|
||||
if (list && visible) {
|
||||
_appendMessage(list, data.message);
|
||||
if (isActive) _channelMessages.push(data.message);
|
||||
if (visible && isActive) {
|
||||
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
|
||||
_clearUnread();
|
||||
} else if (data.message.user !== _currentUser()) {
|
||||
refreshUnreadBadge(_generalMessages);
|
||||
_bumpUnread(key);
|
||||
refreshUnreadBadge();
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
}
|
||||
return;
|
||||
@@ -486,3 +637,23 @@ export function onChatMessage(data) {
|
||||
if (data.message.user === _currentUser()) return; // son propre message
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
}
|
||||
|
||||
/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */
|
||||
function _pairOf(path) {
|
||||
const me = _currentUser();
|
||||
const parts = String(path || "").split("|");
|
||||
return parts[0] === me ? parts[1] : parts[0];
|
||||
}
|
||||
|
||||
function _bumpUnread(key) {
|
||||
const u = _readUnread();
|
||||
u[key] = (u[key] || 0) + 1;
|
||||
_writeUnread(u);
|
||||
}
|
||||
|
||||
/** Suppression d'un post (#191) — relayée par sync.js. */
|
||||
export function onChatDeleted(data) {
|
||||
if (!data || !data.id) return;
|
||||
const sel = `[data-id="${CSS.escape(data.id)}"]`;
|
||||
document.querySelectorAll(sel).forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
+11
-1
@@ -120,7 +120,7 @@ export const IndexUpdateManager = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
// #169 — Chat par fichier : relais du broadcast SSE vers le panneau
|
||||
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
|
||||
eventSource.addEventListener("chat_message", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
@@ -130,6 +130,16 @@ export const IndexUpdateManager = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
// #191 — suppression d'un post
|
||||
eventSource.addEventListener("chat_deleted", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
|
||||
@@ -320,6 +320,10 @@
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Cancel",
|
||||
"chat.attach_error": "Attachment upload failed",
|
||||
"chat.channel_general": "General",
|
||||
"chat.delete": "Delete this message",
|
||||
"chat.delete_confirm": "Delete this message?",
|
||||
"chat.delete_error": "Delete failed",
|
||||
"chat.attach_remove": "Remove attachment",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuss this file",
|
||||
@@ -328,6 +332,7 @@
|
||||
"chat.load_error": "Could not load the discussion",
|
||||
"chat.new_message": "New message from {user}",
|
||||
"chat.placeholder": "Write a message…",
|
||||
"chat.placeholder_dm": "Private message to {user}…",
|
||||
"chat.send": "Send",
|
||||
"chat.send_error": "Failed to send the message",
|
||||
"chat.tab": "General chat",
|
||||
|
||||
@@ -320,6 +320,10 @@
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Annuler",
|
||||
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
|
||||
"chat.channel_general": "Général",
|
||||
"chat.delete": "Supprimer ce message",
|
||||
"chat.delete_confirm": "Supprimer ce message ?",
|
||||
"chat.delete_error": "Échec de la suppression",
|
||||
"chat.attach_remove": "Retirer la pièce jointe",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuter de ce fichier",
|
||||
@@ -328,6 +332,7 @@
|
||||
"chat.load_error": "Impossible de charger la discussion",
|
||||
"chat.new_message": "Nouveau message de {user}",
|
||||
"chat.placeholder": "Écrire un message…",
|
||||
"chat.placeholder_dm": "Message privé à {user}…",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.send_error": "Échec de l'envoi du message",
|
||||
"chat.tab": "Chat général",
|
||||
|
||||
@@ -13887,6 +13887,116 @@ body.reading-mode .file-actions {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#191 — Chat : canaux, suppression, bouton compact, link preview
|
||||
--------------------------------------------------------------------------- */
|
||||
.chat-channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
.chat-channel {
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
color: var(--text-muted, #8b949e);
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-channel:hover {
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.chat-channel.active {
|
||||
background: var(--accent, #58a6ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
}
|
||||
/* Bouton d'envoi compact : une icône, la saisie garde la largeur */
|
||||
.file-chat-send-icon {
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Suppression d'un post */
|
||||
.file-chat-del {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.file-chat-msg:hover .file-chat-del {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.file-chat-del:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
/* Link preview (#191) */
|
||||
.file-chat-preview {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-left: 3px solid var(--accent, #58a6ff);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
.file-chat-preview:hover {
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-preview-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.file-chat-preview-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.file-chat-preview-site {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-preview-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.file-chat-preview-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user