feat: conformite enonce - explorer, favoris, stats perso, tests, slots
Some checks failed
Deploy XIP / deploy (push) Failing after 37s

Fonctionnel
- Backend messages : GET /api/messages/:id (detail) + recherche (q),
  pagination par curseur (before/limit) avec enveloppe { items, nextCursor,
  hasMore } ; le flux temps reel garde l'ancien format quand aucun parametre.
- Explorer (/explorer) : catalogue distant, recherche debouncee + annulable
  (AbortController), filtre, defilement infini, etat garde (keep-alive).
- Details par id : /message/:id et /shop/p/:id (consomment route.params).
- Favoris (/favoris) : liste perso persistee en localStorage, notation
  (note/rating/statut) via modale, refletee partout (bouton favori).
- Mes stats (/mes-stats) : agregats derives des favoris (note moyenne, top
  pays/auteurs, statuts), auto-mis a jour, route gardee si liste vide.
- Routeur : pages secondaires en lazy-load + repli, garde beforeEnter.

Technique
- Slots : PrefSection (slot defaut + slot nomme) enveloppe les 5 sections
  "Mes Persos" ; Modal (Teleport + slots).
- v-model custom : SearchBox (defineModel + debounce).
- Directive custom : v-click-outside.
- Tests Vitest : 25 tests (etat, fonctions, composants), ~86% du code metier.
- Retrait d'Ionic (inutilise). Script typecheck backend ; tsconfig @types/bun.
- Correctif type : garde stockLimit nullable dans l'achat (catalog.ts).
- README complet (URL, stack, run, tests, secrets, deploiement, mention IA).
This commit is contained in:
2026-05-31 23:57:00 +02:00
committed by kerboul
parent 9dd72b9b2d
commit cfa2eadec9
111 changed files with 9634 additions and 7875 deletions

View File

@@ -1,238 +1,240 @@
<!-- Un message avec ses éventuelles réponses, perks d'auteur, rich content et pièces jointes -->
<template>
<div class="message-item">
<!-- Auteur + horodatage -->
<div class="message-meta">
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" :title="message.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span>
<span v-if="message.authorGeo && geoLabel(message.authorGeo)" class="geo-tag">
<a :href="geoLink(message.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="message.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`" :alt="message.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span>
{{ geoLabel(message.authorGeo) }}
</a>
</span>
<span class="ts">{{ fmt(message.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
</div>
<!-- Contenu : riche (iframe sandbox) ou texte simple -->
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<p v-else class="message-body">{{ message.content }}</p>
<!-- Pièces jointes -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses -->
<div
v-for="reply in message.replies"
:key="reply.id"
class="reply"
>
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
</span>
<span v-if="reply.authorGeo && geoLabel(reply.authorGeo)" class="geo-tag geo-tag--sm">
<a :href="geoLink(reply.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="reply.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${reply.authorGeo.countryCode.toLowerCase()}.png`" :alt="reply.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span>
{{ geoLabel(reply.authorGeo) }}
</a>
</span>
<span class="ts">{{ fmt(reply.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<p v-else class="message-body reply-body">{{ reply.content }}</p>
<MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" />
</div>
<div class="divider" />
</div>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useMessages';
import { openContextMenu } from '@/composables/useContextMenu';
import { IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
import { useMessageItem } from '@/composables/useMessageItem';
import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
const props = defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs } = useMessageItem();
function openIpMenu(e: MouseEvent, ip: string): void {
if (ip !== props.myIp) return;
const hasElementSkin = !!myPerks.value.elementSkin;
const ownedPets = myPerks.value.pets ?? [];
const hasPets = ownedPets.length > 0;
// Nothing to show if no perk unlocks customization.
if (!hasElementSkin && !hasPets) return;
const currentColor = prefs.ipColors[ip] ?? 'auto';
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
const items: import('@/composables/useContextMenu').ContextMenuItem[] = [];
if (hasElementSkin) {
items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })));
}
if (hasPets) {
items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
items.push({ value: 'pet:__inherit__', label: ' défaut' });
// Show only the pets the user actually owns.
const seen = new Set<string>();
for (const p of ownedPets) {
if (!seen.has(p.char)) {
seen.add(p.char);
items.push({ value: `pet:${p.char}`, label: p.char });
}
}
}
openContextMenu({
x: e.clientX,
y: e.clientY,
title: ip,
items,
current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
onSelect: (v) => {
if (v.startsWith('color:')) {
prefs.ipColors[ip] = v.slice(6);
} else if (v.startsWith('pet:')) {
const pet = v.slice(4);
if (pet === '__inherit__') {
delete prefs.ipPets[ip];
} else {
prefs.ipPets[ip] = pet;
}
}
},
});
}
</script>
<style scoped>
.message-item {
padding: 4px 0;
}
.message-meta {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 25px;
}
.ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; }
.pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
.pet--sm { font-size: 11px; }
.vip-badge {
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px;
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px;
}
.ip {
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: bold;
}
.ts {
font-family: 'Courier New', monospace;
font-size: 10px;
color: #303030;
}
.reply-btn {
background: none; border: none; cursor: pointer;
font-family: Arial, sans-serif; font-size: 10px; color: #33335a;
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s;
}
.message-item:hover .reply-btn,
.reply:hover .reply-btn { opacity: 1; }
.reply-btn:hover { color: #00ccff; }
.message-body {
font-family: Arial, sans-serif;
font-size: 13px;
color: #c0c0c0;
padding: 3px 25px 0;
margin: 0;
word-break: break-word;
}
.divider {
height: 1px;
background: #141420;
margin: 8px 25px 0;
}
/* ── Réponses ── */
.reply {
margin: 6px 25px 0 45px;
border-left: 2px solid #1a1a2a;
padding-left: 10px;
}
.reply-ip {
font-size: 11px;
}
.reply-body {
font-size: 12px;
padding-left: 0;
}
.geo-tag {
font-family: Arial, sans-serif;
font-size: 10px;
color: #44445a;
white-space: nowrap;
}
.geo-tag--sm { font-size: 9px; }
.geo-link {
color: inherit;
text-decoration: none;
opacity: 0.7;
display: inline-flex;
align-items: center;
gap: 4px;
transition: opacity 0.12s, color 0.12s;
}
.geo-link:hover {
color: #5588cc;
opacity: 1;
text-decoration: underline;
}
.geo-flag {
width: 16px;
height: 12px;
object-fit: cover;
border-radius: 2px;
vertical-align: middle;
flex-shrink: 0;
}
</style>
<!-- Un message avec ses éventuelles réponses, perks d'auteur, rich content et pièces jointes -->
<template>
<div class="message-item">
<!-- Auteur + horodatage -->
<div class="message-meta">
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" :title="message.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span>
<span v-if="message.authorGeo && geoLabel(message.authorGeo)" class="geo-tag">
<a :href="geoLink(message.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="message.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`" :alt="message.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span>
{{ geoLabel(message.authorGeo) }}
</a>
</span>
<span class="ts">{{ fmt(message.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
<FavButton :message="message" />
</div>
<!-- Contenu : riche (iframe sandbox) ou texte simple -->
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<p v-else class="message-body">{{ message.content }}</p>
<!-- Pièces jointes -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses -->
<div
v-for="reply in message.replies"
:key="reply.id"
class="reply"
>
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
</span>
<span v-if="reply.authorGeo && geoLabel(reply.authorGeo)" class="geo-tag geo-tag--sm">
<a :href="geoLink(reply.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="reply.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${reply.authorGeo.countryCode.toLowerCase()}.png`" :alt="reply.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span>
{{ geoLabel(reply.authorGeo) }}
</a>
</span>
<span class="ts">{{ fmt(reply.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<p v-else class="message-body reply-body">{{ reply.content }}</p>
<MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" />
</div>
<div class="divider" />
</div>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useMessages';
import { openContextMenu } from '@/composables/useContextMenu';
import { IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
import { useMessageItem } from '@/composables/useMessageItem';
import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
import FavButton from './FavButton.vue';
const props = defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs } = useMessageItem();
function openIpMenu(e: MouseEvent, ip: string): void {
if (ip !== props.myIp) return;
const hasElementSkin = !!myPerks.value.elementSkin;
const ownedPets = myPerks.value.pets ?? [];
const hasPets = ownedPets.length > 0;
// Nothing to show if no perk unlocks customization.
if (!hasElementSkin && !hasPets) return;
const currentColor = prefs.ipColors[ip] ?? 'auto';
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
const items: import('@/composables/useContextMenu').ContextMenuItem[] = [];
if (hasElementSkin) {
items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })));
}
if (hasPets) {
items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
items.push({ value: 'pet:__inherit__', label: ' défaut' });
// Show only the pets the user actually owns.
const seen = new Set<string>();
for (const p of ownedPets) {
if (!seen.has(p.char)) {
seen.add(p.char);
items.push({ value: `pet:${p.char}`, label: p.char });
}
}
}
openContextMenu({
x: e.clientX,
y: e.clientY,
title: ip,
items,
current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
onSelect: (v) => {
if (v.startsWith('color:')) {
prefs.ipColors[ip] = v.slice(6);
} else if (v.startsWith('pet:')) {
const pet = v.slice(4);
if (pet === '__inherit__') {
delete prefs.ipPets[ip];
} else {
prefs.ipPets[ip] = pet;
}
}
},
});
}
</script>
<style scoped>
.message-item {
padding: 4px 0;
}
.message-meta {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 25px;
}
.ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; }
.pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
.pet--sm { font-size: 11px; }
.vip-badge {
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px;
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px;
}
.ip {
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: bold;
}
.ts {
font-family: 'Courier New', monospace;
font-size: 10px;
color: #303030;
}
.reply-btn {
background: none; border: none; cursor: pointer;
font-family: Arial, sans-serif; font-size: 10px; color: #33335a;
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s;
}
.message-item:hover .reply-btn,
.reply:hover .reply-btn { opacity: 1; }
.reply-btn:hover { color: #00ccff; }
.message-body {
font-family: Arial, sans-serif;
font-size: 13px;
color: #c0c0c0;
padding: 3px 25px 0;
margin: 0;
word-break: break-word;
}
.divider {
height: 1px;
background: #141420;
margin: 8px 25px 0;
}
/* ── Réponses ── */
.reply {
margin: 6px 25px 0 45px;
border-left: 2px solid #1a1a2a;
padding-left: 10px;
}
.reply-ip {
font-size: 11px;
}
.reply-body {
font-size: 12px;
padding-left: 0;
}
.geo-tag {
font-family: Arial, sans-serif;
font-size: 10px;
color: #44445a;
white-space: nowrap;
}
.geo-tag--sm { font-size: 9px; }
.geo-link {
color: inherit;
text-decoration: none;
opacity: 0.7;
display: inline-flex;
align-items: center;
gap: 4px;
transition: opacity 0.12s, color 0.12s;
}
.geo-link:hover {
color: #5588cc;
opacity: 1;
text-decoration: underline;
}
.geo-flag {
width: 16px;
height: 12px;
object-fit: cover;
border-radius: 2px;
vertical-align: middle;
flex-shrink: 0;
}
</style>