feat: thème WhatsApp + fix envoi rich/compact + nav shop + refactor
All checks were successful
Deploy XIP / deploy (push) Successful in 43s
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:
@@ -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="lime"<\/script>' : '<h1 style="color:#0ff">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>
|
||||
|
||||
Reference in New Issue
Block a user