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

@@ -25,6 +25,9 @@ export interface Perks {
richHtmlcss?: boolean;
richJs?: boolean;
noFileLimit?: boolean;
ipColors?: boolean;
audioAlert?: boolean;
sendSkins?: { id: string; char: string; label?: string }[];
}
const perksKey = (ip: string) => `xip:perks:${ip}`;
@@ -88,6 +91,22 @@ export async function getPerksForIp(ip: string): Promise<Perks> {
case "no-file-limit":
perks.noFileLimit = true;
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);

View File

@@ -66,17 +66,25 @@ messages.post("/", async (c) => {
const ip = getClientIp(c);
const body = await c.req.json<{
content: string;
content?: string;
parentId?: string;
richMode?: "htmlcss" | "js";
richContent?: string;
attachmentIds?: string[];
}>();
if (!body.content || body.content.trim().length === 0) {
return c.json({ error: "Content is required" }, 400);
// A message is valid if it has ANY of: plain text, rich content, or attachments.
// (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);
}
@@ -97,7 +105,7 @@ messages.post("/", async (c) => {
richContent = body.richContent;
}
const content = body.content.trim();
const content = (body.content ?? "").trim();
const parentId = body.parentId ?? null;
const message = await prisma.message.create({

View File

@@ -11,6 +11,7 @@ import {
type PurchaseOptions,
} from "../lib/catalog";
import { broadcast, broadcastToIp } from "../realtime";
import { getPerksForIp } from "../lib/perks";
const shop = new Hono();
@@ -30,11 +31,12 @@ shop.get("/products/:id", async (c) => {
// GET /api/shop/me — my balance + owned entitlements
shop.get("/me", async (c) => {
const ip = getClientIp(c);
const [wallet, entitlements] = await Promise.all([
const [wallet, entitlements, myPerks] = await Promise.all([
getWallet(ip),
getEntitlements(ip),
getPerksForIp(ip),
]);
return c.json({ wallet, entitlements });
return c.json({ wallet, entitlements, myPerks });
});
// POST /api/shop/purchase { productId, options }