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:
162
frontend/src/views/FavorisPage.vue
Normal file
162
frontend/src/views/FavorisPage.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<!-- Liste personnelle « Favoris » : éléments enregistrés (localStorage),
|
||||
éditables (note, commentaire, statut) via une modale, retirables. -->
|
||||
<template>
|
||||
<div class="favs">
|
||||
<header class="favs-head">
|
||||
<h1 class="favs-title">⭐ Mes favoris <span class="favs-count">{{ all.length }}</span></h1>
|
||||
<div class="favs-actions">
|
||||
<RouterLink v-if="all.length" to="/mes-stats" class="btn-stats">📊 Voir mes stats</RouterLink>
|
||||
<button v-if="all.length" class="btn-clear" type="button" @click="clear">Tout vider</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="favs-scroll">
|
||||
<div v-if="all.length === 0" class="favs-empty">
|
||||
<p>Aucun favori pour l'instant.</p>
|
||||
<RouterLink to="/explorer" class="btn-explore">🔎 Explorer des messages</RouterLink>
|
||||
</div>
|
||||
|
||||
<ul v-else class="favs-list">
|
||||
<li v-for="f in all" :key="f.id" class="fav-card">
|
||||
<div class="fav-main">
|
||||
<div class="fav-meta">
|
||||
<RouterLink :to="`/message/${f.id}`" class="fav-ip" :style="{ color: ipColor(f.authorIp) }">{{ f.authorIp }}</RouterLink>
|
||||
<span class="fav-status" :class="`fav-status--${f.status}`">{{ statusLabel(f.status) }}</span>
|
||||
<span v-if="f.rating" class="fav-rating">{{ '★'.repeat(f.rating) }}<span class="dim">{{ '★'.repeat(5 - f.rating) }}</span></span>
|
||||
</div>
|
||||
<p class="fav-content">{{ f.content }}</p>
|
||||
<p v-if="f.note" class="fav-note">📝 {{ f.note }}</p>
|
||||
</div>
|
||||
<div class="fav-buttons">
|
||||
<button class="fav-edit" type="button" @click="openEdit(f.id)">✏️</button>
|
||||
<button class="fav-del" type="button" @click="remove(f.id)">🗑️</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Modale d'édition (Teleport + slots) -->
|
||||
<Modal v-model:open="editOpen" :title="`Annoter ${editing?.authorIp ?? ''}`">
|
||||
<div v-if="editing" class="edit">
|
||||
<p class="edit-content">« {{ editing.content }} »</p>
|
||||
|
||||
<label class="edit-label">Note</label>
|
||||
<div class="stars">
|
||||
<button
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="star"
|
||||
:class="{ on: n <= draftRating }"
|
||||
type="button"
|
||||
@click="draftRating = n === draftRating ? 0 : n"
|
||||
>★</button>
|
||||
</div>
|
||||
|
||||
<label class="edit-label">Statut</label>
|
||||
<select v-model="draftStatus" class="edit-select">
|
||||
<option value="a-lire">À lire</option>
|
||||
<option value="lu">Lu</option>
|
||||
<option value="top">Coup de cœur</option>
|
||||
</select>
|
||||
|
||||
<label class="edit-label">Commentaire</label>
|
||||
<textarea v-model="draftNote" class="edit-note" rows="3" placeholder="Ton annotation…" />
|
||||
|
||||
<div class="edit-foot">
|
||||
<button class="btn-save" type="button" @click="save">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useFavorites, type FavStatus } from '@/composables/useFavorites';
|
||||
import { getIpColor } from '@/composables/ipColor';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
|
||||
const { all, remove, clear, setNote, setRating, setStatus } = useFavorites();
|
||||
|
||||
const editOpen = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
const editing = computed(() => all.value.find((f) => f.id === editingId.value) ?? null);
|
||||
|
||||
const draftNote = ref('');
|
||||
const draftRating = ref(0);
|
||||
const draftStatus = ref<FavStatus>('a-lire');
|
||||
|
||||
function openEdit(id: string): void {
|
||||
const f = all.value.find((x) => x.id === id);
|
||||
if (!f) return;
|
||||
editingId.value = id;
|
||||
draftNote.value = f.note;
|
||||
draftRating.value = f.rating;
|
||||
draftStatus.value = f.status;
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!editingId.value) return;
|
||||
setNote(editingId.value, draftNote.value);
|
||||
setRating(editingId.value, draftRating.value);
|
||||
setStatus(editingId.value, draftStatus.value);
|
||||
editOpen.value = false;
|
||||
}
|
||||
|
||||
function ipColor(ip: string): string { return getIpColor(ip); }
|
||||
function statusLabel(s: FavStatus): string {
|
||||
return s === 'lu' ? 'Lu' : s === 'top' ? 'Coup de cœur' : 'À lire';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favs { height: 100%; display: flex; flex-direction: column; background: var(--xip-app-bg, #08080e); }
|
||||
.favs-head {
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid #1a1a2a;
|
||||
}
|
||||
.favs-title { font-family: Arial, sans-serif; font-size: 17px; color: #ccccee; margin: 0; }
|
||||
.favs-count { font-size: 13px; color: #ffcc44; margin-left: 6px; }
|
||||
.favs-actions { display: flex; gap: 10px; }
|
||||
.btn-stats { font-size: 12px; color: #00ddff; text-decoration: none; border: 1px solid #00aaff44; border-radius: 14px; padding: 6px 12px; }
|
||||
.btn-stats:hover { background: #00aaff14; }
|
||||
.btn-clear { font-size: 12px; color: #ff6655; background: #2a1010; border: 1px solid #882222; border-radius: 14px; padding: 6px 12px; cursor: pointer; }
|
||||
|
||||
.favs-scroll { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.favs-empty { text-align: center; color: #55557a; font-family: Arial, sans-serif; padding: 50px 0; }
|
||||
.btn-explore { display: inline-block; margin-top: 14px; color: #00ddff; text-decoration: none; border: 1px solid #00aaff44; border-radius: 16px; padding: 8px 18px; }
|
||||
.btn-explore:hover { background: #00aaff14; }
|
||||
|
||||
.favs-list { list-style: none; display: flex; flex-direction: column; gap: 10px; max-width: 720px; margin: 0 auto; }
|
||||
.fav-card {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: #101018; border: 1px solid #20203a; border-radius: 10px; padding: 12px 14px;
|
||||
}
|
||||
.fav-main { flex: 1; min-width: 0; }
|
||||
.fav-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; flex-wrap: wrap; }
|
||||
.fav-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; text-decoration: none; }
|
||||
.fav-status { font-size: 9px; padding: 1px 7px; border-radius: 6px; font-family: Arial, sans-serif; }
|
||||
.fav-status--a-lire { color: #8888aa; background: #16162a; }
|
||||
.fav-status--lu { color: #33aa77; background: #0e2018; }
|
||||
.fav-status--top { color: #ffcc44; background: #2a2206; }
|
||||
.fav-rating { font-size: 11px; color: #ffcc44; }
|
||||
.fav-rating .dim { color: #333; }
|
||||
.fav-content { font-family: Arial, sans-serif; font-size: 13px; color: #c0c0c0; margin: 0; word-break: break-word; }
|
||||
.fav-note { font-family: Arial, sans-serif; font-size: 11px; color: #6688aa; margin: 6px 0 0; font-style: italic; }
|
||||
.fav-buttons { display: flex; flex-direction: column; gap: 6px; }
|
||||
.fav-edit, .fav-del { background: #141420; border: 1px solid #222234; border-radius: 8px; cursor: pointer; padding: 4px 8px; font-size: 13px; }
|
||||
.fav-edit:hover, .fav-del:hover { background: #1c1c2e; }
|
||||
|
||||
/* Modale d'édition */
|
||||
.edit-content { font-family: Arial, sans-serif; font-size: 12px; color: #8899aa; font-style: italic; margin: 0 0 16px; }
|
||||
.edit-label { display: block; font-family: Arial, sans-serif; font-size: 11px; color: #6a6a90; margin: 12px 0 5px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stars { display: flex; gap: 4px; }
|
||||
.star { background: none; border: none; cursor: pointer; font-size: 22px; color: #333; padding: 0; }
|
||||
.star.on { color: #ffcc44; }
|
||||
.edit-select { width: 100%; background: #141420; border: 1px solid #222234; border-radius: 6px; color: #ccccdd; font-size: 13px; padding: 8px 10px; outline: none; }
|
||||
.edit-note { width: 100%; box-sizing: border-box; background: #141420; border: 1px solid #222234; border-radius: 6px; color: #ccccdd; font-family: Arial, sans-serif; font-size: 13px; padding: 8px 10px; outline: none; resize: vertical; }
|
||||
.edit-foot { margin-top: 18px; text-align: right; }
|
||||
.btn-save { background: #004488; border: 1px solid #0066aa; color: #00ddff; font-size: 13px; font-weight: bold; padding: 8px 18px; border-radius: 18px; cursor: pointer; }
|
||||
.btn-save:hover { background: #005599; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user