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:
@@ -25,6 +25,9 @@ export interface Perks {
|
|||||||
richHtmlcss?: boolean;
|
richHtmlcss?: boolean;
|
||||||
richJs?: boolean;
|
richJs?: boolean;
|
||||||
noFileLimit?: boolean;
|
noFileLimit?: boolean;
|
||||||
|
ipColors?: boolean;
|
||||||
|
audioAlert?: boolean;
|
||||||
|
sendSkins?: { id: string; char: string; label?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const perksKey = (ip: string) => `xip:perks:${ip}`;
|
const perksKey = (ip: string) => `xip:perks:${ip}`;
|
||||||
@@ -88,6 +91,22 @@ export async function getPerksForIp(ip: string): Promise<Perks> {
|
|||||||
case "no-file-limit":
|
case "no-file-limit":
|
||||||
perks.noFileLimit = true;
|
perks.noFileLimit = true;
|
||||||
break;
|
break;
|
||||||
|
case "ip-colors":
|
||||||
|
perks.ipColors = true;
|
||||||
|
break;
|
||||||
|
case "audio-alert":
|
||||||
|
perks.audioAlert = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send-button skins use a prefixed kind (send-skin-rocket, …), so they
|
||||||
|
// can't be matched by the switch above.
|
||||||
|
if (e.kind.startsWith("send-skin-")) {
|
||||||
|
(perks.sendSkins ??= []).push({
|
||||||
|
id: e.kind,
|
||||||
|
char: meta.char ?? "?",
|
||||||
|
label: meta.label,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pets.length) perks.pets = pets.slice(0, 3);
|
if (pets.length) perks.pets = pets.slice(0, 3);
|
||||||
|
|||||||
@@ -66,17 +66,25 @@ messages.post("/", async (c) => {
|
|||||||
const ip = getClientIp(c);
|
const ip = getClientIp(c);
|
||||||
|
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
content: string;
|
content?: string;
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
richMode?: "htmlcss" | "js";
|
richMode?: "htmlcss" | "js";
|
||||||
richContent?: string;
|
richContent?: string;
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
if (!body.content || body.content.trim().length === 0) {
|
// A message is valid if it has ANY of: plain text, rich content, or attachments.
|
||||||
return c.json({ error: "Content is required" }, 400);
|
// (Rich-only and file-only messages are legitimate — no need for placeholder text.)
|
||||||
|
const hasContent = typeof body.content === "string" && body.content.trim().length > 0;
|
||||||
|
const hasRich =
|
||||||
|
!!body.richMode && !!body.richContent && body.richContent.trim().length > 0;
|
||||||
|
const hasAttachments =
|
||||||
|
Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0;
|
||||||
|
|
||||||
|
if (!hasContent && !hasRich && !hasAttachments) {
|
||||||
|
return c.json({ error: "Message vide" }, 400);
|
||||||
}
|
}
|
||||||
if (body.content.length > 267) {
|
if (hasContent && body.content!.trim().length > 267) {
|
||||||
return c.json({ error: "Content exceeds 267 characters" }, 400);
|
return c.json({ error: "Content exceeds 267 characters" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +105,7 @@ messages.post("/", async (c) => {
|
|||||||
richContent = body.richContent;
|
richContent = body.richContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = body.content.trim();
|
const content = (body.content ?? "").trim();
|
||||||
const parentId = body.parentId ?? null;
|
const parentId = body.parentId ?? null;
|
||||||
|
|
||||||
const message = await prisma.message.create({
|
const message = await prisma.message.create({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type PurchaseOptions,
|
type PurchaseOptions,
|
||||||
} from "../lib/catalog";
|
} from "../lib/catalog";
|
||||||
import { broadcast, broadcastToIp } from "../realtime";
|
import { broadcast, broadcastToIp } from "../realtime";
|
||||||
|
import { getPerksForIp } from "../lib/perks";
|
||||||
|
|
||||||
const shop = new Hono();
|
const shop = new Hono();
|
||||||
|
|
||||||
@@ -30,11 +31,12 @@ shop.get("/products/:id", async (c) => {
|
|||||||
// GET /api/shop/me — my balance + owned entitlements
|
// GET /api/shop/me — my balance + owned entitlements
|
||||||
shop.get("/me", async (c) => {
|
shop.get("/me", async (c) => {
|
||||||
const ip = getClientIp(c);
|
const ip = getClientIp(c);
|
||||||
const [wallet, entitlements] = await Promise.all([
|
const [wallet, entitlements, myPerks] = await Promise.all([
|
||||||
getWallet(ip),
|
getWallet(ip),
|
||||||
getEntitlements(ip),
|
getEntitlements(ip),
|
||||||
|
getPerksForIp(ip),
|
||||||
]);
|
]);
|
||||||
return c.json({ wallet, entitlements });
|
return c.json({ wallet, entitlements, myPerks });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/shop/purchase { productId, options }
|
// POST /api/shop/purchase { productId, options }
|
||||||
|
|||||||
243
frontend/src/components/ChatComposer.vue
Normal file
243
frontend/src/components/ChatComposer.vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<!--
|
||||||
|
Barre de composition : texte simple, éditeur riche (HTML/CSS · JS), pièces
|
||||||
|
jointes, alerte audio, bouton d'envoi. Possède son propre état ; lit les
|
||||||
|
composables partagés directement (pas de prop-drilling). La réponse en cours
|
||||||
|
est passée par prop `replyingTo` ; on émet `clear-reply` une fois le message parti.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="composer">
|
||||||
|
<!-- Éditeur 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 === 'js' ? '⚡' : '🎨' }}</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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import SendButton from './SendButton.vue';
|
||||||
|
import { useMessages, useMyPerks } from '@/composables/useMessages';
|
||||||
|
import { useAttachments } from '@/composables/useAttachments';
|
||||||
|
import { useAlert } from '@/composables/useAlert';
|
||||||
|
|
||||||
|
const props = defineProps<{ replyingTo: { id: string; authorIp: string } | null }>();
|
||||||
|
const emit = defineEmits<{ 'clear-reply': [] }>();
|
||||||
|
|
||||||
|
const { sending, postMessage, sendTyping } = useMessages();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
const { uploadFile, kb } = useAttachments();
|
||||||
|
const { fireAlert } = useAlert();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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: props.replyingTo?.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 = [];
|
||||||
|
uploadError.value = null;
|
||||||
|
prevLen = 0;
|
||||||
|
emit('clear-reply');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Éditeur 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: 8px 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>
|
||||||
@@ -37,8 +37,8 @@ const { theme } = useTheme();
|
|||||||
.chat-header {
|
.chat-header {
|
||||||
height: 52px;
|
height: 52px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #0e0e16;
|
background: var(--xip-header-bg);
|
||||||
border-bottom: 1px solid #1a1a2a;
|
border-bottom: 1px solid var(--xip-header-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -103,22 +103,22 @@ const isMine = computed(() => props.message.authorIp === props.myIp);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
background: #141422;
|
background: var(--xip-bubble-other);
|
||||||
border: 1px solid #222236;
|
border: 1px solid var(--xip-bubble-other-border);
|
||||||
border-radius: 14px 14px 14px 4px;
|
border-radius: 14px 14px 14px 4px;
|
||||||
padding: 7px 13px;
|
padding: 7px 13px;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #c0c0c0;
|
color: #e0e0e8;
|
||||||
max-width: 72%;
|
max-width: 72%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.bubble--mine {
|
.bubble--mine {
|
||||||
background: #0e1f30;
|
background: var(--xip-bubble-sent);
|
||||||
border-color: #1a3a55;
|
border-color: var(--xip-bubble-sent-border);
|
||||||
border-radius: 14px 14px 4px 14px;
|
border-radius: 14px 14px 4px 14px;
|
||||||
color: #cce0f0;
|
color: #eef4f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-thread {
|
.bubble-thread {
|
||||||
|
|||||||
95
frontend/src/components/MessageItemCompact.vue
Normal file
95
frontend/src/components/MessageItemCompact.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<!-- Variante "compact" du message — une ligne dense (IP + contenu inline) -->
|
||||||
|
<template>
|
||||||
|
<div class="compact-item">
|
||||||
|
<div class="compact-line">
|
||||||
|
<span class="compact-ip" :style="ipStyle(message)">
|
||||||
|
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>{{ message.authorIp }}<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
v-if="message.authorGeo?.countryCode"
|
||||||
|
:src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`"
|
||||||
|
:alt="message.authorGeo.countryCode"
|
||||||
|
class="compact-flag"
|
||||||
|
/>
|
||||||
|
<RichContent
|
||||||
|
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
|
||||||
|
:mode="message.richMode"
|
||||||
|
:content="message.richContent"
|
||||||
|
/>
|
||||||
|
<span v-else class="compact-body">{{ message.content }}</span>
|
||||||
|
<span class="compact-ts">{{ fmt(message.createdAt) }}</span>
|
||||||
|
<button
|
||||||
|
class="compact-reply-btn"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('reply', { id: message.id, authorIp: message.authorIp })"
|
||||||
|
>↩</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
|
||||||
|
|
||||||
|
<!-- Réponses, inline et indentées -->
|
||||||
|
<div
|
||||||
|
v-for="reply in message.replies"
|
||||||
|
:key="reply.id"
|
||||||
|
class="compact-line compact-line--reply"
|
||||||
|
>
|
||||||
|
<span class="compact-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
|
||||||
|
<RichContent
|
||||||
|
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
|
||||||
|
:mode="reply.richMode"
|
||||||
|
:content="reply.richContent"
|
||||||
|
/>
|
||||||
|
<span v-else class="compact-body">{{ reply.content }}</span>
|
||||||
|
<span class="compact-ts">{{ fmt(reply.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Message } from '@/composables/useMessages';
|
||||||
|
import { useMessageItem } from '@/composables/useMessageItem';
|
||||||
|
import RichContent from './RichContent.vue';
|
||||||
|
import MessageAttachments from './MessageAttachments.vue';
|
||||||
|
|
||||||
|
defineProps<{ message: Message; myIp?: string }>();
|
||||||
|
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
|
||||||
|
|
||||||
|
const { ipStyle, petsLeft, petsRight, fmt } = useMessageItem();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.compact-item {
|
||||||
|
padding: 1px 14px;
|
||||||
|
border-bottom: 1px solid #0e0e18;
|
||||||
|
}
|
||||||
|
.compact-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.compact-line--reply { padding-left: 24px; opacity: 0.85; }
|
||||||
|
|
||||||
|
.compact-ip { font-weight: bold; flex-shrink: 0; }
|
||||||
|
.pet { font-size: 11px; }
|
||||||
|
.compact-flag { width: 14px; height: 10px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.compact-body {
|
||||||
|
font-family: 'Lato', Arial, sans-serif;
|
||||||
|
color: #c0c0c0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.compact-ts { color: #303040; font-size: 10px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.compact-reply-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
font-size: 11px; color: #33335a; padding: 0; flex-shrink: 0;
|
||||||
|
opacity: 0; transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.compact-item:hover .compact-reply-btn { opacity: 1; }
|
||||||
|
.compact-reply-btn:hover { color: var(--xip-accent); }
|
||||||
|
</style>
|
||||||
@@ -25,9 +25,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import type { Message } from '@/composables/useMessages';
|
import type { Message } from '@/composables/useMessages';
|
||||||
import { useTheme } from '@/composables/useTheme';
|
import { useTheme, THEME_LAYOUT, type Layout } from '@/composables/useTheme';
|
||||||
import MessageItem from './MessageItem.vue';
|
import MessageItem from './MessageItem.vue';
|
||||||
import MessageItemBubble from './MessageItemBubble.vue';
|
import MessageItemBubble from './MessageItemBubble.vue';
|
||||||
|
import MessageItemCompact from './MessageItemCompact.vue';
|
||||||
import InlineCasinoAd from './InlineCasinoAd.vue';
|
import InlineCasinoAd from './InlineCasinoAd.vue';
|
||||||
|
|
||||||
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
|
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
|
||||||
@@ -35,10 +36,16 @@ defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
|
|||||||
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const messageComponent = computed(() => {
|
// One component per layout family. The `?? MessageItem` fallback guarantees a
|
||||||
if (theme.value === 'bubble') return MessageItemBubble;
|
// missing/unknown layout can never produce `<component :is="undefined">`.
|
||||||
return MessageItem;
|
const LAYOUT_COMPONENT: Record<Layout, typeof MessageItem> = {
|
||||||
});
|
classic: MessageItem,
|
||||||
|
bubble: MessageItemBubble,
|
||||||
|
compact: MessageItemCompact,
|
||||||
|
};
|
||||||
|
const messageComponent = computed(
|
||||||
|
() => LAYOUT_COMPONENT[THEME_LAYOUT[theme.value]] ?? MessageItem,
|
||||||
|
);
|
||||||
|
|
||||||
const listEl = ref<HTMLElement | null>(null);
|
const listEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const activeSkinChar = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const btnStyle = computed(() => {
|
const btnStyle = computed(() => {
|
||||||
|
// On the default preset, defer to the theme's CSS variables (so e.g. the
|
||||||
|
// WhatsApp theme tints the button green). A chosen preset overrides the theme.
|
||||||
|
if (prefs.sendButton === 'default') return {};
|
||||||
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
|
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
|
||||||
return { background: p.bg, color: p.color, borderRadius: p.radius };
|
return { background: p.bg, color: p.color, borderRadius: p.radius };
|
||||||
});
|
});
|
||||||
@@ -77,6 +80,10 @@ function onRightClick(e: MouseEvent): void {
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: 1px solid #ffffff10;
|
border: 1px solid #ffffff10;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* Defaults from the theme palette; a chosen preset overrides via inline style. */
|
||||||
|
background: var(--xip-send-bg);
|
||||||
|
color: var(--xip-send-fg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export function getIpColor(ip: string): string {
|
|||||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIpGlow(color: string): string {
|
// Glows are currently disabled globally; params kept for signature stability.
|
||||||
|
export function getIpGlow(_color: string): string {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,6 @@ export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string
|
|||||||
return getIpColor(ip);
|
return getIpColor(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
|
export function getIpGlowWithPerks(_ip: string, _perks?: PerkLike | null): string {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,37 +48,19 @@ export interface Message extends Reply {
|
|||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
/** Refresh the viewer's own perks from the server (callable from anywhere). */
|
/**
|
||||||
|
* Refresh the viewer's own perks from the server (callable from anywhere).
|
||||||
|
* The backend computes the perks (entitlement.kind → Perks) and returns them
|
||||||
|
* precomputed as `myPerks`, so we just adopt them — no client-side re-derivation.
|
||||||
|
*/
|
||||||
export async function refreshMyPerks(): Promise<void> {
|
export async function refreshMyPerks(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/shop/me`);
|
const res = await fetch(`${API_URL}/api/shop/me`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const { entitlements } = (await res.json()) as {
|
const { myPerks: p } = (await res.json()) as { myPerks?: Perks };
|
||||||
entitlements: { kind: string; metaJson?: string | null }[];
|
myPerks.value = p ?? {};
|
||||||
};
|
|
||||||
const p: Perks = {};
|
|
||||||
const pets: { char: string; position: 'left' | 'right' | 'both' }[] = [];
|
|
||||||
for (const e of entitlements) {
|
|
||||||
let meta: any = {};
|
|
||||||
try { meta = e.metaJson ? JSON.parse(e.metaJson) : {}; } catch { /* */ }
|
|
||||||
if (e.kind === 'noads') { p.noads = true; if (meta.plan === 'annual') p.badge = true; }
|
|
||||||
if (e.kind === 'style-dore') p.skin = 'gold';
|
|
||||||
if (e.kind === 'pet' && meta.char) pets.push({ char: meta.char, position: meta.position ?? 'left' });
|
|
||||||
if (e.kind === 'element-skin') p.elementSkin = true; if (e.kind === 'ip-colors') p.ipColors = true;
|
|
||||||
if (e.kind.startsWith('send-skin-')) {
|
|
||||||
let meta2: any = {};
|
|
||||||
try { meta2 = e.metaJson ? JSON.parse(e.metaJson) : {}; } catch {}
|
|
||||||
if (!p.sendSkins) p.sendSkins = [];
|
|
||||||
p.sendSkins.push({ id: e.kind, char: meta2.char ?? '?', label: meta2.label });
|
|
||||||
} if (e.kind === 'rich-htmlcss') p.richHtmlcss = true;
|
|
||||||
if (e.kind === 'rich-js') p.richJs = true;
|
|
||||||
if (e.kind === 'no-file-limit') p.noFileLimit = true;
|
|
||||||
if (e.kind === 'audio-alert') p.audioAlert = true;
|
|
||||||
}
|
|
||||||
if (pets.length) p.pets = pets;
|
|
||||||
myPerks.value = p;
|
|
||||||
const { ip } = useWallet();
|
const { ip } = useWallet();
|
||||||
if (ip.value) setPerks(ip.value, p);
|
if (ip.value) setPerks(ip.value, myPerks.value);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -186,7 +168,7 @@ export function useMessages() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content: content.trim() || ' ',
|
content: content.trim(),
|
||||||
parentId: extras.parentId,
|
parentId: extras.parentId,
|
||||||
richMode: extras.richMode,
|
richMode: extras.richMode,
|
||||||
richContent: extras.richContent,
|
richContent: extras.richContent,
|
||||||
@@ -214,6 +196,8 @@ export function useMessages() {
|
|||||||
fetchMyPerks();
|
fetchMyPerks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: viewer-own perks live in the module-level `myPerks` singleton; read
|
||||||
|
// them via `useMyPerks()` rather than off this return (consistency rule).
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
loading,
|
loading,
|
||||||
@@ -222,7 +206,6 @@ export function useMessages() {
|
|||||||
stats,
|
stats,
|
||||||
connected,
|
connected,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
get myPerks() { return myPerks; },
|
|
||||||
myIp,
|
myIp,
|
||||||
fetchMyPerks,
|
fetchMyPerks,
|
||||||
};
|
};
|
||||||
|
|||||||
28
frontend/src/composables/useMeta.ts
Normal file
28
frontend/src/composables/useMeta.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Safe JSON parser for the `metaJson` strings carried by products and
|
||||||
|
* entitlements. Returns the fallback on any parse error instead of throwing,
|
||||||
|
* so callers can drop their repetitive try/catch + `any` casts.
|
||||||
|
*/
|
||||||
|
export function parseMeta<T = Record<string, unknown>>(
|
||||||
|
json: string | null | undefined,
|
||||||
|
fallback: T = {} as T,
|
||||||
|
): T {
|
||||||
|
if (!json) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(json) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of a product's metaJson (all fields optional — depends on kind). */
|
||||||
|
export interface ProductMeta {
|
||||||
|
designs?: { id: string; char: string }[];
|
||||||
|
positions?: string[];
|
||||||
|
plans?: { id: string; label: string; price: number }[];
|
||||||
|
durations?: { days: number; extra: number }[];
|
||||||
|
formats?: { id: string; label: string; extra: number }[];
|
||||||
|
char?: string;
|
||||||
|
label?: string;
|
||||||
|
includes?: string[];
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useWallet } from './useWallet';
|
import { useWallet } from './useWallet';
|
||||||
import { refreshMyPerks } from './useMessages';
|
import { refreshMyPerks } from './useMessages';
|
||||||
|
import { parseMeta, type ProductMeta } from './useMeta';
|
||||||
|
|
||||||
/** Marketplace client: catalogue, my entitlements, purchase flow. */
|
/** Marketplace client: catalogue, my entitlements, purchase flow. */
|
||||||
|
|
||||||
@@ -84,10 +85,7 @@ export function useShop() {
|
|||||||
function ownedPetChars(): string[] {
|
function ownedPetChars(): string[] {
|
||||||
return entitlements.value
|
return entitlements.value
|
||||||
.filter((e) => e.kind === 'pet' && e.active)
|
.filter((e) => e.kind === 'pet' && e.active)
|
||||||
.map((e) => {
|
.map((e) => parseMeta<ProductMeta>(e.metaJson).char ?? '')
|
||||||
try { return (JSON.parse(e.metaJson ?? '{}') as any).char ?? ''; }
|
|
||||||
catch { return ''; }
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue';
|
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue';
|
||||||
|
|
||||||
export type Theme = 'default' | 'bubble' | 'compact';
|
export type Theme = 'default' | 'bubble' | 'compact' | 'whatsapp';
|
||||||
|
|
||||||
|
/** Which message layout a theme uses (drives the dynamic <component :is>). */
|
||||||
|
export type Layout = 'classic' | 'bubble' | 'compact';
|
||||||
|
|
||||||
export interface ThemeContext {
|
export interface ThemeContext {
|
||||||
theme: Ref<Theme>;
|
theme: Ref<Theme>;
|
||||||
@@ -13,6 +16,19 @@ const THEMES: Record<Theme, { label: string; emoji: string }> = {
|
|||||||
default: { label: 'Classique', emoji: '📋' },
|
default: { label: 'Classique', emoji: '📋' },
|
||||||
bubble: { label: 'Bulles', emoji: '💬' },
|
bubble: { label: 'Bulles', emoji: '💬' },
|
||||||
compact: { label: 'Compact', emoji: '📐' },
|
compact: { label: 'Compact', emoji: '📐' },
|
||||||
|
whatsapp: { label: 'WhatsApp', emoji: '💚' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A theme = a message layout (component) + a CSS-variable palette (applied via a
|
||||||
|
* `data-theme` attribute on the app root). WhatsApp reuses the bubble layout with
|
||||||
|
* a green palette — no dedicated message component needed.
|
||||||
|
*/
|
||||||
|
const THEME_LAYOUT: Record<Theme, Layout> = {
|
||||||
|
default: 'classic',
|
||||||
|
bubble: 'bubble',
|
||||||
|
compact: 'compact',
|
||||||
|
whatsapp: 'bubble',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function provideTheme() {
|
export function provideTheme() {
|
||||||
@@ -36,4 +52,4 @@ export function useTheme(): ThemeContext {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { THEMES };
|
export { THEMES, THEME_LAYOUT };
|
||||||
|
|||||||
@@ -37,11 +37,42 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Thèmes : palette par variables CSS, basculée via [data-theme] sur la racine app ──
|
||||||
|
Le défaut = palette XIP sombre/néon. Chaque thème ne redéfinit que les surfaces
|
||||||
|
à fort impact (fond, header, bulles, bouton d'envoi). */
|
||||||
|
:root {
|
||||||
|
--xip-app-bg: #080808;
|
||||||
|
--xip-bg: #090910;
|
||||||
|
--xip-header-bg: #0e0e16;
|
||||||
|
--xip-header-border: #1a1a2a;
|
||||||
|
--xip-bubble-other: #141422;
|
||||||
|
--xip-bubble-other-border: #222236;
|
||||||
|
--xip-bubble-sent: #0e1f30;
|
||||||
|
--xip-bubble-sent-border: #1a3a55;
|
||||||
|
--xip-accent: #00ddff;
|
||||||
|
--xip-send-bg: #004488;
|
||||||
|
--xip-send-fg: #00ddff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="whatsapp"] {
|
||||||
|
--xip-app-bg: #0b141a;
|
||||||
|
--xip-bg: #0b141a;
|
||||||
|
--xip-header-bg: #202c33;
|
||||||
|
--xip-header-border: #2a3942;
|
||||||
|
--xip-bubble-other: #202c33;
|
||||||
|
--xip-bubble-other-border: #2a3942;
|
||||||
|
--xip-bubble-sent: #005c4b; /* vert sortant signature WhatsApp */
|
||||||
|
--xip-bubble-sent-border: #047857;
|
||||||
|
--xip-accent: #00a884;
|
||||||
|
--xip-send-bg: #00a884;
|
||||||
|
--xip-send-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #080808;
|
background: var(--xip-app-bg);
|
||||||
font-family: 'Lato', sans-serif;
|
font-family: 'Lato', sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="xip-app">
|
<div class="xip-app" :data-theme="theme">
|
||||||
<!-- Bandeau de stats temps réel, toujours visible -->
|
<!-- Bandeau de stats temps réel, toujours visible -->
|
||||||
<StatsTicker :stats="stats" :connected="connected" />
|
<StatsTicker :stats="stats" :connected="connected" />
|
||||||
|
|
||||||
@@ -17,69 +17,8 @@
|
|||||||
<button class="reply-cancel" @click="cancelReply" type="button">✕</button>
|
<button class="reply-cancel" @click="cancelReply" type="button">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Composer riche (HTML/CSS ou JS) -->
|
<!-- Composer (texte / riche / pièces jointes / envoi) -->
|
||||||
<div v-if="richMode !== 'none'" class="rich-composer">
|
<ChatComposer :replying-to="replyingTo" @clear-reply="cancelReply" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,19 +28,16 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import ChatHeader from '@/components/ChatHeader.vue';
|
import ChatHeader from '@/components/ChatHeader.vue';
|
||||||
import MessageList from '@/components/MessageList.vue';
|
import MessageList from '@/components/MessageList.vue';
|
||||||
import SendButton from '@/components/SendButton.vue';
|
import ChatComposer from '@/components/ChatComposer.vue';
|
||||||
import StatsTicker from '@/components/StatsTicker.vue';
|
import StatsTicker from '@/components/StatsTicker.vue';
|
||||||
import { useMessages } from '@/composables/useMessages';
|
import { useMessages, useMyPerks } from '@/composables/useMessages';
|
||||||
import { provideTheme } from '@/composables/useTheme';
|
import { provideTheme } from '@/composables/useTheme';
|
||||||
|
|
||||||
provideTheme();
|
|
||||||
import { useAttachments } from '@/composables/useAttachments';
|
|
||||||
import { useAlert } from '@/composables/useAlert';
|
|
||||||
import { useCustomStyles } from '@/composables/useCustomStyles';
|
import { useCustomStyles } from '@/composables/useCustomStyles';
|
||||||
|
|
||||||
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks, myIp } = useMessages();
|
const { theme } = provideTheme();
|
||||||
const { uploadFile, kb } = useAttachments();
|
|
||||||
const { fireAlert } = useAlert();
|
const { messages, stats, connected, myIp } = useMessages();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
const { prefs: stylePrefs } = useCustomStyles();
|
const { prefs: stylePrefs } = useCustomStyles();
|
||||||
|
|
||||||
const chatBgStyle = computed(() => {
|
const chatBgStyle = computed(() => {
|
||||||
@@ -114,17 +50,7 @@ const chatBgStyle = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const draft = ref('');
|
// ── Réponse (la bannière vit ici ; le composer envoie avec parentId) ──
|
||||||
|
|
||||||
// ── 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 ──
|
|
||||||
const replyingTo = ref<{ id: string; authorIp: string } | null>(null);
|
const replyingTo = ref<{ id: string; authorIp: string } | null>(null);
|
||||||
function startReply(payload: { id: string; authorIp: string }): void {
|
function startReply(payload: { id: string; authorIp: string }): void {
|
||||||
replyingTo.value = payload;
|
replyingTo.value = payload;
|
||||||
@@ -132,76 +58,6 @@ function startReply(payload: { id: string; authorIp: string }): void {
|
|||||||
function cancelReply(): void {
|
function cancelReply(): void {
|
||||||
replyingTo.value = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -210,7 +66,7 @@ async function submit(): Promise<void> {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
background: #080808;
|
background: var(--xip-app-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +82,7 @@ async function submit(): Promise<void> {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #090910;
|
background: var(--xip-bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,76 +100,4 @@ async function submit(): Promise<void> {
|
|||||||
.reply-ip { font-family: 'Courier New', monospace; color: #00ccff; font-weight: bold; }
|
.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 { background: none; border: none; color: #557; cursor: pointer; font-size: 13px; }
|
||||||
.reply-cancel:hover { color: #aac; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -76,14 +76,16 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { useShop, type PurchaseOptions } from '@/composables/useShop';
|
import { useShop, type PurchaseOptions } from '@/composables/useShop';
|
||||||
import { useWallet } from '@/composables/useWallet';
|
import { useWallet } from '@/composables/useWallet';
|
||||||
|
import { parseMeta, type ProductMeta } from '@/composables/useMeta';
|
||||||
import ProductCard from '@/components/shop/ProductCard.vue';
|
import ProductCard from '@/components/shop/ProductCard.vue';
|
||||||
import MesPersos from '@/components/shop/MesPersos.vue';
|
import MesPersos from '@/components/shop/MesPersos.vue';
|
||||||
|
|
||||||
const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
|
const { products, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
|
||||||
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
|
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
|
||||||
|
|
||||||
|
// Navigation forcée par catégorie : pas de « Tout voir », on entre directement
|
||||||
|
// dans une rubrique organisée.
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: 'Tout voir' },
|
|
||||||
{ id: 'publicite', label: 'Publicité' },
|
{ id: 'publicite', label: 'Publicité' },
|
||||||
{ id: 'abonnements', label: 'Abonnements' },
|
{ id: 'abonnements', label: 'Abonnements' },
|
||||||
{ id: 'cosmetiques', label: 'Cosmétiques' },
|
{ id: 'cosmetiques', label: 'Cosmétiques' },
|
||||||
@@ -91,19 +93,16 @@ const categories = [
|
|||||||
{ id: 'promotions', label: 'Promotions' },
|
{ id: 'promotions', label: 'Promotions' },
|
||||||
{ id: 'perso', label: '✨ Mes Persos' },
|
{ id: 'perso', label: '✨ Mes Persos' },
|
||||||
];
|
];
|
||||||
const activeCat = ref('all');
|
const activeCat = ref('publicite');
|
||||||
|
|
||||||
const visibleProducts = computed(() => {
|
const visibleProducts = computed(() => {
|
||||||
const chars = ownedPetChars();
|
const chars = ownedPetChars();
|
||||||
const base = activeCat.value === 'all'
|
return products.value
|
||||||
? products.value
|
.filter((p) => p.category === activeCat.value)
|
||||||
: products.value.filter((p) => p.category === activeCat.value);
|
.filter((p) => {
|
||||||
return base.filter((p) => {
|
|
||||||
if (p.kind !== 'pet') return true;
|
if (p.kind !== 'pet') return true;
|
||||||
try {
|
const designs = parseMeta<ProductMeta>(p.metaJson).designs ?? [];
|
||||||
const designs: any[] = JSON.parse((p as any).metaJson ?? '{}').designs ?? [];
|
|
||||||
return designs.some((d) => !chars.includes(d.char));
|
return designs.some((d) => !chars.includes(d.char));
|
||||||
} catch { return true; }
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user