feat: conformite enonce - explorer, favoris, stats perso, tests, slots
Some checks failed
Deploy XIP / deploy (push) Failing after 37s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user