feat: thème WhatsApp + fix envoi rich/compact + nav shop + refactor
All checks were successful
Deploy XIP / deploy (push) Successful in 43s

Theming
- Thème global piloté par variables CSS (:root + [data-theme]) appliqué via un
  attribut data-theme sur la racine app. Ajout du thème "WhatsApp" (bulles +
  palette verte, bulle sortante #005c4b) sans nouveau composant message.
- useTheme: type Theme étendu + THEME_LAYOUT (whatsapp = layout bulles).
- MessageList: sélection du composant par layout avec garde de repli
  (fini le <component :is="undefined">).
- Fix du thème "compact" cassé : nouveau MessageItemCompact.vue (variante dense).
- Surfaces migrées en variables : fond app/chat, header, bouton d'envoi, bulles.

Corrections
- Bug envoi rich/fichier : le backend exigeait un content texte non vide même
  en mode HTML/CSS/JS. Validation par présence (texte OU rich OU piece jointe) ;
  le front n'envoie plus d'espace bidon. Plus besoin de faux texte.
- Shop : suppression de "Tout voir", navigation forcee par categorie
  (defaut: Publicite).

Refactor (lisibilite)
- Parite perks backend (ip-colors, audio-alert, send-skin-*) ; /api/shop/me
  renvoie myPerks precalcule ; le front consomme directement (suppression de la
  derivation dupliquee + nettoyage d'un artefact de merge dans useMessages).
- Coherence composable-singleton : myPerks lu via useMyPerks() partout.
- Extraction du composer de HomePage vers ChatComposer.vue (HomePage = layout).
- Helper type parseMeta<T>() pour les metaJson (moins de any).
- vue-tsc --noEmit : 0 erreur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 19:51:24 +02:00
parent c0b82222bd
commit aca608e520
17 changed files with 524 additions and 303 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="xip-app">
<div class="xip-app" :data-theme="theme">
<!-- Bandeau de stats temps réel, toujours visible -->
<StatsTicker :stats="stats" :connected="connected" />
@@ -17,69 +17,8 @@
<button class="reply-cancel" @click="cancelReply" type="button"></button>
</div>
<!-- Composer riche (HTML/CSS ou JS) -->
<div v-if="richMode !== 'none'" class="rich-composer">
<div class="rich-head">
<span class="rich-badge" :class="`rich-badge--${richMode}`">
{{ richMode === 'js' ? '⚡ JavaScript' : '🎨 HTML / CSS' }}
</span>
<button class="rich-close" @click="richMode = 'none'" type="button"> texte simple</button>
</div>
<textarea
v-model="richDraft"
class="rich-textarea"
:placeholder="richMode === 'js' ? '<script>document.body.style.background=&quot;lime&quot;<\/script>' : '<h1 style=&quot;color:#0ff&quot;>Salut</h1>'"
rows="4"
/>
</div>
<!-- Barre de saisie -->
<div class="input-bar">
<!-- Bouton mode riche (si débloqué) -->
<button
v-if="myPerks.richHtmlcss || myPerks.richJs"
class="icon-btn"
:title="richMenuTitle"
@click="cycleRichMode"
type="button"
>{{ richMode === 'none' ? '🎨' : richMode === 'htmlcss' ? '🎨' : '⚡' }}</button>
<!-- Bouton pièce jointe -->
<button class="icon-btn" title="Joindre un fichier" @click="pickFile" type="button">📎</button>
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
<!-- Bouton alerte audio (si débloqué) -->
<button
v-if="myPerks.audioAlert"
class="icon-btn icon-btn--alert"
:title="alertMsg || 'Déclencher l\'alerte audio générale'"
@click="triggerAlert"
type="button"
>🔊</button>
<div v-show="richMode === 'none'" class="field-wrap">
<input
v-model="draft"
class="input-field"
type="text"
placeholder="Entrez un message..."
:maxlength="267"
@input="onInput"
@keydown.enter.exact.prevent="submit"
/>
<span class="char-counter" :class="{ warn: draft.length > 240 }">{{ draft.length }}/267</span>
</div>
<SendButton :disabled="!canSend || sending" @send="submit" />
</div>
<!-- Pièces jointes en attente -->
<div v-if="pendingFiles.length" class="pending-files">
<span v-for="f in pendingFiles" :key="f.id" class="pending-chip">
📎 {{ f.filename }} ({{ kb(f.size) }})
<button @click="removePending(f.id)" type="button"></button>
</span>
</div>
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
<!-- Composer (texte / riche / pièces jointes / envoi) -->
<ChatComposer :replying-to="replyingTo" @clear-reply="cancelReply" />
</div>
</div>
</div>
@@ -87,21 +26,18 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import ChatHeader from '@/components/ChatHeader.vue';
import MessageList from '@/components/MessageList.vue';
import SendButton from '@/components/SendButton.vue';
import StatsTicker from '@/components/StatsTicker.vue';
import { useMessages } from '@/composables/useMessages';
import ChatHeader from '@/components/ChatHeader.vue';
import MessageList from '@/components/MessageList.vue';
import ChatComposer from '@/components/ChatComposer.vue';
import StatsTicker from '@/components/StatsTicker.vue';
import { useMessages, useMyPerks } from '@/composables/useMessages';
import { provideTheme } from '@/composables/useTheme';
provideTheme();
import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert';
import { useCustomStyles } from '@/composables/useCustomStyles';
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks, myIp } = useMessages();
const { uploadFile, kb } = useAttachments();
const { fireAlert } = useAlert();
const { theme } = provideTheme();
const { messages, stats, connected, myIp } = useMessages();
const { myPerks } = useMyPerks();
const { prefs: stylePrefs } = useCustomStyles();
const chatBgStyle = computed(() => {
@@ -114,17 +50,7 @@ const chatBgStyle = computed(() => {
};
});
const draft = ref('');
// ── Alerte audio ──
const alertMsg = ref('');
async function triggerAlert(): Promise<void> {
const res = await fireAlert();
alertMsg.value = res.ok ? '' : res.error || '';
if (alertMsg.value) setTimeout(() => { alertMsg.value = ''; }, 3000);
}
// ── Réponse ──
// ── Réponse (la bannière vit ici ; le composer envoie avec parentId) ──
const replyingTo = ref<{ id: string; authorIp: string } | null>(null);
function startReply(payload: { id: string; authorIp: string }): void {
replyingTo.value = payload;
@@ -132,76 +58,6 @@ function startReply(payload: { id: string; authorIp: string }): void {
function cancelReply(): void {
replyingTo.value = null;
}
// ── Mode riche ──
const richMode = ref<'none' | 'htmlcss' | 'js'>('none');
const richDraft = ref('');
const richMenuTitle = computed(() =>
myPerks.value.richJs ? 'Message riche : texte / HTML-CSS / JS' : 'Message riche : texte / HTML-CSS'
);
function cycleRichMode(): void {
// Cycle through the tiers the user owns.
if (richMode.value === 'none') richMode.value = myPerks.value.richHtmlcss ? 'htmlcss' : 'js';
else if (richMode.value === 'htmlcss') richMode.value = myPerks.value.richJs ? 'js' : 'none';
else richMode.value = 'none';
}
// ── Pièces jointes ──
const fileInput = ref<HTMLInputElement | null>(null);
const pendingFiles = ref<{ id: string; filename: string; size: number }[]>([]);
const uploadError = ref<string | null>(null);
function pickFile(): void {
uploadError.value = null;
fileInput.value?.click();
}
async function onFileSelected(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const res = await uploadFile(file);
if (res.ok) {
pendingFiles.value.push({ id: res.attachment.id, filename: res.attachment.filename, size: res.attachment.size });
} else {
uploadError.value = res.error;
}
}
function removePending(id: string): void {
pendingFiles.value = pendingFiles.value.filter((f) => f.id !== id);
}
// ── Frappe (stats) ──
let prevLen = 0;
function onInput(): void {
const len = draft.value.length;
const delta = len - prevLen;
prevLen = len;
sendTyping(delta > 0 ? delta : 0);
}
// ── Envoi ──
const canSend = computed(() =>
!!draft.value.trim() || (richMode.value !== 'none' && !!richDraft.value.trim()) || pendingFiles.value.length > 0
);
async function submit(): Promise<void> {
if (!canSend.value) return;
const ok = await postMessage(draft.value, {
parentId: replyingTo.value?.id,
richMode: richMode.value !== 'none' && richDraft.value.trim() ? richMode.value : undefined,
richContent: richMode.value !== 'none' && richDraft.value.trim() ? richDraft.value : undefined,
attachmentIds: pendingFiles.value.map((f) => f.id),
});
if (ok) {
draft.value = '';
richDraft.value = '';
richMode.value = 'none';
pendingFiles.value = [];
replyingTo.value = null;
uploadError.value = null;
prevLen = 0;
}
}
</script>
<style scoped>
@@ -210,7 +66,7 @@ async function submit(): Promise<void> {
flex-direction: column;
width: 100vw;
height: 100dvh;
background: #080808;
background: var(--xip-app-bg);
overflow: hidden;
}
@@ -226,7 +82,7 @@ async function submit(): Promise<void> {
min-width: 0;
display: flex;
flex-direction: column;
background: #090910;
background: var(--xip-bg);
overflow: hidden;
}
@@ -244,76 +100,4 @@ async function submit(): Promise<void> {
.reply-ip { font-family: 'Courier New', monospace; color: #00ccff; font-weight: bold; }
.reply-cancel { background: none; border: none; color: #557; cursor: pointer; font-size: 13px; }
.reply-cancel:hover { color: #aac; }
/* ── Composer riche ── */
.rich-composer {
flex-shrink: 0;
background: #0c0c16;
border-top: 1px solid #1a1a26;
padding: 8px 20px;
}
.rich-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.rich-badge { font-size: 11px; font-weight: bold; padding: 2px 8px; border-radius: 8px; }
.rich-badge--htmlcss { color: #00ddaa; background: #062019; }
.rich-badge--js { color: #ffcc44; background: #201a06; }
.rich-close { background: none; border: none; color: #557; cursor: pointer; font-size: 11px; }
.rich-close:hover { color: #aac; }
.rich-textarea {
width: 100%; box-sizing: border-box; resize: vertical;
background: #141420; border: 1px solid #222234; border-radius: 8px;
color: #aaccbb; font-family: 'Courier New', monospace; font-size: 12px; padding: 8px 10px; outline: none;
}
/* ── Barre de saisie ── */
.input-bar {
min-height: 70px;
flex-shrink: 0;
background: #0e0e16;
border-top: 1px solid #1a1a26;
display: flex;
align-items: center;
padding: 0 20px;
gap: 10px;
}
.icon-btn {
flex-shrink: 0;
width: 36px; height: 36px;
background: #141420; border: 1px solid #222234; border-radius: 50%;
font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.icon-btn:hover { background: #1c1c2e; }
.icon-btn--alert { border-color: #aa3344; }
.icon-btn--alert:hover { background: #1e1218; }
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
.input-field {
flex: 1;
background: #141420;
border: 1px solid #222234;
border-radius: 23px;
padding: 12px 60px 12px 22px;
color: #aaaacc;
font-family: Arial, sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.input-field::placeholder { color: #2a2a44; }
.input-field:focus { border-color: #333355; }
.char-counter {
position: absolute; right: 16px;
font-family: 'Courier New', monospace; font-size: 10px; color: #33334d; pointer-events: none;
}
.char-counter.warn { color: #ff8844; }
/* ── Pièces jointes en attente ── */
.pending-files { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px; padding: 0 20px 10px; }
.pending-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #141420; border: 1px solid #222234; border-radius: 12px;
padding: 4px 10px; font-size: 11px; color: #aaccbb; font-family: Arial, sans-serif;
}
.pending-chip button { background: none; border: none; color: #66f; cursor: pointer; }
.upload-error { flex-shrink: 0; padding: 0 20px 10px; color: #ff7788; font-size: 11px; font-family: Arial, sans-serif; }
</style>