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;
|
||||
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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
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 {
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
background: #0e0e16;
|
||||
border-bottom: 1px solid #1a1a2a;
|
||||
background: var(--xip-header-bg);
|
||||
border-bottom: 1px solid var(--xip-header-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -103,22 +103,22 @@ const isMine = computed(() => props.message.authorIp === props.myIp);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: #141422;
|
||||
border: 1px solid #222236;
|
||||
background: var(--xip-bubble-other);
|
||||
border: 1px solid var(--xip-bubble-other-border);
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
padding: 7px 13px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #c0c0c0;
|
||||
color: #e0e0e8;
|
||||
max-width: 72%;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.bubble--mine {
|
||||
background: #0e1f30;
|
||||
border-color: #1a3a55;
|
||||
background: var(--xip-bubble-sent);
|
||||
border-color: var(--xip-bubble-sent-border);
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
color: #cce0f0;
|
||||
color: #eef4f0;
|
||||
}
|
||||
|
||||
.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">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
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 MessageItemBubble from './MessageItemBubble.vue';
|
||||
import MessageItemCompact from './MessageItemCompact.vue';
|
||||
import InlineCasinoAd from './InlineCasinoAd.vue';
|
||||
|
||||
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
|
||||
@@ -35,10 +36,16 @@ defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const messageComponent = computed(() => {
|
||||
if (theme.value === 'bubble') return MessageItemBubble;
|
||||
return MessageItem;
|
||||
});
|
||||
// One component per layout family. The `?? MessageItem` fallback guarantees a
|
||||
// missing/unknown layout can never produce `<component :is="undefined">`.
|
||||
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);
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ const activeSkinChar = 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];
|
||||
return { background: p.bg, color: p.color, borderRadius: p.radius };
|
||||
});
|
||||
@@ -77,6 +80,10 @@ function onRightClick(e: MouseEvent): void {
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -10,7 +10,8 @@ export function getIpColor(ip: string): string {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -27,6 +28,6 @@ export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string
|
||||
return getIpColor(ip);
|
||||
}
|
||||
|
||||
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
|
||||
export function getIpGlowWithPerks(_ip: string, _perks?: PerkLike | null): string {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
@@ -48,37 +48,19 @@ export interface Message extends Reply {
|
||||
|
||||
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> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/shop/me`);
|
||||
if (!res.ok) return;
|
||||
const { entitlements } = (await res.json()) as {
|
||||
entitlements: { kind: string; metaJson?: string | null }[];
|
||||
};
|
||||
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 { myPerks: p } = (await res.json()) as { myPerks?: Perks };
|
||||
myPerks.value = p ?? {};
|
||||
const { ip } = useWallet();
|
||||
if (ip.value) setPerks(ip.value, p);
|
||||
if (ip.value) setPerks(ip.value, myPerks.value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -186,7 +168,7 @@ export function useMessages() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: content.trim() || ' ',
|
||||
content: content.trim(),
|
||||
parentId: extras.parentId,
|
||||
richMode: extras.richMode,
|
||||
richContent: extras.richContent,
|
||||
@@ -214,6 +196,8 @@ export function useMessages() {
|
||||
fetchMyPerks();
|
||||
});
|
||||
|
||||
// Note: viewer-own perks live in the module-level `myPerks` singleton; read
|
||||
// them via `useMyPerks()` rather than off this return (consistency rule).
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
@@ -222,7 +206,6 @@ export function useMessages() {
|
||||
stats,
|
||||
connected,
|
||||
sendTyping,
|
||||
get myPerks() { return myPerks; },
|
||||
myIp,
|
||||
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 { useWallet } from './useWallet';
|
||||
import { refreshMyPerks } from './useMessages';
|
||||
import { parseMeta, type ProductMeta } from './useMeta';
|
||||
|
||||
/** Marketplace client: catalogue, my entitlements, purchase flow. */
|
||||
|
||||
@@ -84,10 +85,7 @@ export function useShop() {
|
||||
function ownedPetChars(): string[] {
|
||||
return entitlements.value
|
||||
.filter((e) => e.kind === 'pet' && e.active)
|
||||
.map((e) => {
|
||||
try { return (JSON.parse(e.metaJson ?? '{}') as any).char ?? ''; }
|
||||
catch { return ''; }
|
||||
})
|
||||
.map((e) => parseMeta<ProductMeta>(e.metaJson).char ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 {
|
||||
theme: Ref<Theme>;
|
||||
@@ -10,9 +13,22 @@ export interface ThemeContext {
|
||||
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('xip-theme');
|
||||
|
||||
const THEMES: Record<Theme, { label: string; emoji: string }> = {
|
||||
default: { label: 'Classique', emoji: '📋' },
|
||||
bubble: { label: 'Bulles', emoji: '💬' },
|
||||
compact: { label: 'Compact', emoji: '📐' },
|
||||
default: { label: 'Classique', emoji: '📋' },
|
||||
bubble: { label: 'Bulles', 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() {
|
||||
@@ -36,4 +52,4 @@ export function useTheme(): ThemeContext {
|
||||
});
|
||||
}
|
||||
|
||||
export { THEMES };
|
||||
export { THEMES, THEME_LAYOUT };
|
||||
|
||||
@@ -37,11 +37,42 @@
|
||||
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,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #080808;
|
||||
background: var(--xip-app-bg);
|
||||
font-family: 'Lato', sans-serif;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -76,14 +76,16 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useShop, type PurchaseOptions } from '@/composables/useShop';
|
||||
import { useWallet } from '@/composables/useWallet';
|
||||
import { parseMeta, type ProductMeta } from '@/composables/useMeta';
|
||||
import ProductCard from '@/components/shop/ProductCard.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();
|
||||
|
||||
// Navigation forcée par catégorie : pas de « Tout voir », on entre directement
|
||||
// dans une rubrique organisée.
|
||||
const categories = [
|
||||
{ id: 'all', label: 'Tout voir' },
|
||||
{ id: 'publicite', label: 'Publicité' },
|
||||
{ id: 'abonnements', label: 'Abonnements' },
|
||||
{ id: 'cosmetiques', label: 'Cosmétiques' },
|
||||
@@ -91,20 +93,17 @@ const categories = [
|
||||
{ id: 'promotions', label: 'Promotions' },
|
||||
{ id: 'perso', label: '✨ Mes Persos' },
|
||||
];
|
||||
const activeCat = ref('all');
|
||||
const activeCat = ref('publicite');
|
||||
|
||||
const visibleProducts = computed(() => {
|
||||
const chars = ownedPetChars();
|
||||
const base = activeCat.value === 'all'
|
||||
? products.value
|
||||
: products.value.filter((p) => p.category === activeCat.value);
|
||||
return base.filter((p) => {
|
||||
if (p.kind !== 'pet') return true;
|
||||
try {
|
||||
const designs: any[] = JSON.parse((p as any).metaJson ?? '{}').designs ?? [];
|
||||
return products.value
|
||||
.filter((p) => p.category === activeCat.value)
|
||||
.filter((p) => {
|
||||
if (p.kind !== 'pet') return true;
|
||||
const designs = parseMeta<ProductMeta>(p.metaJson).designs ?? [];
|
||||
return designs.some((d) => !chars.includes(d.char));
|
||||
} catch { return true; }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function onBuy(productId: string, options: PurchaseOptions): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user