proposition frontend
This commit is contained in:
@@ -1,9 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<ion-app>
|
<RouterView />
|
||||||
<ion-router-outlet />
|
|
||||||
</ion-app>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { IonApp, IonRouterOutlet } from "@ionic/vue";
|
|
||||||
</script>
|
|
||||||
|
|||||||
128
frontend/src/components/AdBand.vue
Normal file
128
frontend/src/components/AdBand.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<!-- Bande publicitaire gauche (130 px) -->
|
||||||
|
<template>
|
||||||
|
<aside class="ad-band">
|
||||||
|
<p class="ad-label">PUBLICITÉ</p>
|
||||||
|
|
||||||
|
<!-- ── NOVA STORE ── -->
|
||||||
|
<div class="ad-card">
|
||||||
|
<div class="ad-header ad-header--blue">
|
||||||
|
<p class="ad-brand ad-brand--blue">NOVA</p>
|
||||||
|
<p class="ad-sub">STORE 2026</p>
|
||||||
|
</div>
|
||||||
|
<div class="ad-body">
|
||||||
|
<span class="ad-icon">🛒</span>
|
||||||
|
</div>
|
||||||
|
<p class="ad-cta ad-cta--blue">DÉCOUVRIR</p>
|
||||||
|
<p class="ad-url">nova-store.io</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── APEX GEAR ── -->
|
||||||
|
<div class="ad-card">
|
||||||
|
<div class="ad-header ad-header--green">
|
||||||
|
<p class="ad-brand ad-brand--green">APEX GEAR</p>
|
||||||
|
<p class="ad-sub">Gaming Setup</p>
|
||||||
|
</div>
|
||||||
|
<div class="ad-body ad-body--green">
|
||||||
|
<span class="ad-icon">🎮</span>
|
||||||
|
</div>
|
||||||
|
<p class="ad-cta ad-cta--green">ACHETER</p>
|
||||||
|
<p class="ad-url">apex-gear.com</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── SHIELDVPN ── -->
|
||||||
|
<div class="ad-card">
|
||||||
|
<div class="ad-header ad-header--purple">
|
||||||
|
<p class="ad-brand ad-brand--purple">SHIELDVPN</p>
|
||||||
|
<p class="ad-sub">Sécurité totale</p>
|
||||||
|
</div>
|
||||||
|
<div class="ad-body ad-body--purple">
|
||||||
|
<span class="ad-icon">🔒</span>
|
||||||
|
</div>
|
||||||
|
<p class="ad-cta ad-cta--purple">ESSAI GRATUIT</p>
|
||||||
|
<p class="ad-url">shieldvpn.net</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ad-band {
|
||||||
|
width: 130px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #0c0c10;
|
||||||
|
border-right: 1px solid #1a1a22;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.ad-band::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.ad-label {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #2a2a38;
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px 0 3px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Carte pub ── */
|
||||||
|
.ad-card {
|
||||||
|
margin: 0 4px 4px;
|
||||||
|
background: #121218;
|
||||||
|
border: 1px solid #1e1e2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-header {
|
||||||
|
padding: 8px 4px 6px;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
}
|
||||||
|
.ad-header--blue { background: #161620; }
|
||||||
|
.ad-header--green { background: #101614; }
|
||||||
|
.ad-header--purple { background: #16101a; }
|
||||||
|
|
||||||
|
.ad-brand {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ad-brand--blue { color: #5555cc; text-shadow: 0 0 8px #4444aa; }
|
||||||
|
.ad-brand--green { color: #33aa55; text-shadow: 0 0 8px #225533; }
|
||||||
|
.ad-brand--purple { color: #9944dd; text-shadow: 0 0 8px #6622aa; }
|
||||||
|
|
||||||
|
.ad-sub {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #383870;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-body {
|
||||||
|
background: #0e0e16;
|
||||||
|
margin: 6px 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.ad-body--green { background: #0e160e; }
|
||||||
|
.ad-body--purple { background: #110e16; }
|
||||||
|
|
||||||
|
.ad-icon { font-size: 24px; }
|
||||||
|
|
||||||
|
.ad-cta {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
}
|
||||||
|
.ad-cta--blue { color: #3a3a88; }
|
||||||
|
.ad-cta--green { color: #33aa55; }
|
||||||
|
.ad-cta--purple { color: #9944dd; }
|
||||||
|
|
||||||
|
.ad-url {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #282840;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
frontend/src/components/ChatHeader.vue
Normal file
74
frontend/src/components/ChatHeader.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!-- En-tête du chat -->
|
||||||
|
<template>
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="xip-title">XIP</span>
|
||||||
|
<span class="chat-label">Chat</span>
|
||||||
|
<span class="online-dot" aria-hidden="true" />
|
||||||
|
<span class="online-count">{{ connectedCount }} connectés</span>
|
||||||
|
</div>
|
||||||
|
<div class="channel-badge"># général</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ connectedCount: number }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-header {
|
||||||
|
height: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #0e0e16;
|
||||||
|
border-bottom: 1px solid #1a1a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xip-title {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00eeff;
|
||||||
|
text-shadow: 0 0 10px #00ccff99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-label {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #aaaacc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00ff88;
|
||||||
|
box-shadow: 0 0 6px #00ff44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-count {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #33ff66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-badge {
|
||||||
|
background: #131320;
|
||||||
|
border: 1px solid #222233;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #5555aa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
frontend/src/components/InlineCasinoAd.vue
Normal file
135
frontend/src/components/InlineCasinoAd.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<!-- Pub casino néon : overlay dans le feed (identique à la maquette SVG) -->
|
||||||
|
<template>
|
||||||
|
<div class="casino">
|
||||||
|
<div class="casino-head">
|
||||||
|
<p class="casino-title">♠ CASINO LUCKY ♠</p>
|
||||||
|
<p class="casino-subtitle">OFFRE EXCLUSIVE</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="casino-body">
|
||||||
|
<p class="bonus">+200%</p>
|
||||||
|
<p class="bonus-sub">sur votre 1er dépôt • 500€ max</p>
|
||||||
|
|
||||||
|
<div class="slots">
|
||||||
|
<span class="suit suit--diamond">♦</span>
|
||||||
|
<span class="seven">7</span>
|
||||||
|
<span class="seven">7</span>
|
||||||
|
<span class="seven">7</span>
|
||||||
|
<span class="suit suit--spade">♠</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="casino-cta">
|
||||||
|
JOUER MAINTENANT →
|
||||||
|
</button>
|
||||||
|
<p class="disclaimer">18+ • Jeu responsable • casino-lucky.bet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.casino {
|
||||||
|
width: 248px;
|
||||||
|
background: #100400;
|
||||||
|
border: 2px solid #ff2200;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 18px #ff220055;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête rouge ── */
|
||||||
|
.casino-head {
|
||||||
|
background: #1a0400;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
border-bottom: 1px solid #440000;
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-title {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff5533;
|
||||||
|
text-shadow: 0 0 8px #ff2200;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-subtitle {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #882200;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Corps ── */
|
||||||
|
.casino-body {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffdd00;
|
||||||
|
text-shadow: 0 0 14px #99660099;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-sub {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #cc6600;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Machines à sous ── */
|
||||||
|
.slots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suit {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.suit--diamond { color: #ffaa44; }
|
||||||
|
.suit--spade { color: #ffaa44; }
|
||||||
|
|
||||||
|
.seven {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 10px #ffdd00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA ── */
|
||||||
|
.casino-cta {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: #220000;
|
||||||
|
border: 1.5px solid #ff2200;
|
||||||
|
border-radius: 19px;
|
||||||
|
color: #ff4422;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 0 6px #ff2200;
|
||||||
|
box-shadow: 0 0 8px #ff220044;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.casino-cta:hover {
|
||||||
|
box-shadow: 0 0 16px #ff220088;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 7px;
|
||||||
|
color: #440000;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
frontend/src/components/MenuToggle.vue
Normal file
49
frontend/src/components/MenuToggle.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!-- Bouton hamburger (panneau latéral droit, 35 px) -->
|
||||||
|
<template>
|
||||||
|
<div class="menu-toggle">
|
||||||
|
<button class="hamburger" aria-label="Menu" @click="$emit('toggle')">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits<{ toggle: [] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-toggle {
|
||||||
|
width: 35px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #0c0c10;
|
||||||
|
border-left: 1px solid #1a1a22;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger span {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
background: #3a3a55;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger:hover span {
|
||||||
|
background: #6666aa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
frontend/src/components/MessageItem.vue
Normal file
107
frontend/src/components/MessageItem.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!-- Un message avec ses éventuelles réponses -->
|
||||||
|
<template>
|
||||||
|
<div class="message-item">
|
||||||
|
<!-- Auteur + horodatage -->
|
||||||
|
<div class="message-meta">
|
||||||
|
<span
|
||||||
|
class="ip"
|
||||||
|
:style="{ color: color, textShadow: glow }"
|
||||||
|
>{{ message.authorIp }}</span>
|
||||||
|
<span class="ts">{{ fmt(message.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<p class="message-body">{{ message.content }}</p>
|
||||||
|
|
||||||
|
<!-- Réponses -->
|
||||||
|
<div
|
||||||
|
v-for="reply in message.replies"
|
||||||
|
:key="reply.id"
|
||||||
|
class="reply"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="ip reply-ip"
|
||||||
|
:style="{ color: getColor(reply.authorIp) }"
|
||||||
|
>{{ reply.authorIp }}</span>
|
||||||
|
<span class="ts">{{ fmt(reply.createdAt) }}</span>
|
||||||
|
<p class="message-body reply-body">{{ reply.content }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { Message } from '@/composables/useMessages';
|
||||||
|
import { getIpColor, getIpGlow } from '@/composables/ipColor';
|
||||||
|
|
||||||
|
const props = defineProps<{ message: Message }>();
|
||||||
|
|
||||||
|
const color = computed(() => getIpColor(props.message.authorIp));
|
||||||
|
const glow = computed(() => getIpGlow(color.value));
|
||||||
|
|
||||||
|
function getColor(ip: string) { return getIpColor(ip); }
|
||||||
|
|
||||||
|
function fmt(date: string): string {
|
||||||
|
return new Date(date).toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-item {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #c0c0c0;
|
||||||
|
padding: 3px 25px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend/src/components/MessageList.vue
Normal file
79
frontend/src/components/MessageList.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<!-- Zone de messages scrollable avec la pub casino en overlay -->
|
||||||
|
<template>
|
||||||
|
<div class="feed-wrapper">
|
||||||
|
<!-- Messages -->
|
||||||
|
<div ref="listEl" class="feed-scroll">
|
||||||
|
<MessageItem
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
/>
|
||||||
|
<div v-if="messages.length === 0" class="feed-empty">
|
||||||
|
Aucun message pour l'instant.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pub casino : overlay absolu sur la droite du feed -->
|
||||||
|
<InlineCasinoAd class="casino-overlay" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
import type { Message } from '@/composables/useMessages';
|
||||||
|
import MessageItem from './MessageItem.vue';
|
||||||
|
import InlineCasinoAd from './InlineCasinoAd.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ messages: Message[] }>();
|
||||||
|
|
||||||
|
const listEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Auto-scroll vers le bas à chaque nouveau message
|
||||||
|
watch(
|
||||||
|
() => props.messages.length,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (listEl.value) {
|
||||||
|
listEl.value.scrollTop = listEl.value.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #252535 #080810;
|
||||||
|
}
|
||||||
|
.feed-scroll::-webkit-scrollbar { width: 8px; }
|
||||||
|
.feed-scroll::-webkit-scrollbar-track { background: #080810; }
|
||||||
|
.feed-scroll::-webkit-scrollbar-thumb { background: #252535; border-radius: 3px; }
|
||||||
|
|
||||||
|
.feed-empty {
|
||||||
|
padding: 48px 25px;
|
||||||
|
color: #2a2a44;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Positionné en absolu sur la droite du wrapper */
|
||||||
|
.casino-overlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 30px;
|
||||||
|
top: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.casino-overlay :deep(.casino-cta) {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
frontend/src/components/SendButton.vue
Normal file
51
frontend/src/components/SendButton.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!-- Bouton d'envoi circulaire avec flèche cyan -->
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="send-btn"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="Envoyer"
|
||||||
|
@click="$emit('send')"
|
||||||
|
>
|
||||||
|
<!-- Flèche droite SVG (identique au SVG de la maquette) -->
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<polygon points="4,5 15,9 4,13 7,9" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ disabled?: boolean }>();
|
||||||
|
defineEmits<{ send: [] }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.send-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #004488;
|
||||||
|
border: 1px solid #004466;
|
||||||
|
color: #00ddff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 0 12px #00448866;
|
||||||
|
transition: background 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover:not(:disabled) {
|
||||||
|
background: #005599;
|
||||||
|
box-shadow: 0 0 20px #00ddff55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:active:not(:disabled) {
|
||||||
|
background: #003377;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
frontend/src/composables/ipColor.ts
Normal file
15
frontend/src/composables/ipColor.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** Couleurs assignées de façon déterministe à chaque adresse IP */
|
||||||
|
const PALETTE = ['#666688', '#00ddff', '#ff00cc', '#00ee77', '#ff8844'] as const;
|
||||||
|
|
||||||
|
export function getIpColor(ip: string): string {
|
||||||
|
// djb2 hash
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < ip.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + ip.charCodeAt(i)) & 0xffffffff;
|
||||||
|
}
|
||||||
|
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIpGlow(color: string): string {
|
||||||
|
return color === '#666688' ? 'none' : `0 0 8px ${color}80`;
|
||||||
|
}
|
||||||
55
frontend/src/composables/useMessages.ts
Normal file
55
frontend/src/composables/useMessages.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
|
||||||
|
export interface Reply {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
authorIp: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message extends Reply {
|
||||||
|
parentId: string | null;
|
||||||
|
replies: Reply[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
export function useMessages() {
|
||||||
|
const messages = ref<Message[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const sending = ref(false);
|
||||||
|
|
||||||
|
async function fetchMessages(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/messages`);
|
||||||
|
if (res.ok) {
|
||||||
|
// L'API renvoie du plus récent au plus ancien ; on inverse pour affichage chronologique
|
||||||
|
messages.value = ((await res.json()) as Message[]).reverse();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postMessage(content: string): Promise<boolean> {
|
||||||
|
if (!content.trim()) return false;
|
||||||
|
sending.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: content.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
await fetchMessages();
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchMessages);
|
||||||
|
|
||||||
|
return { messages, loading, sending, postMessage };
|
||||||
|
}
|
||||||
@@ -1,35 +1,12 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from 'vue';
|
||||||
import { IonicVue } from "@ionic/vue";
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { createRouter, createWebHistory } from "@ionic/vue-router";
|
import App from './App.vue';
|
||||||
import type { RouteRecordRaw } from "vue-router";
|
import HomePage from './views/HomePage.vue';
|
||||||
import App from "./App.vue";
|
import './style.css';
|
||||||
import HomePage from "./views/HomePage.vue";
|
|
||||||
|
|
||||||
/* Ionic core CSS */
|
|
||||||
import "@ionic/vue/css/core.css";
|
|
||||||
import "@ionic/vue/css/normalize.css";
|
|
||||||
import "@ionic/vue/css/structure.css";
|
|
||||||
import "@ionic/vue/css/typography.css";
|
|
||||||
|
|
||||||
/* Optional utilities */
|
|
||||||
import "@ionic/vue/css/padding.css";
|
|
||||||
import "@ionic/vue/css/float-elements.css";
|
|
||||||
import "@ionic/vue/css/text-alignment.css";
|
|
||||||
import "@ionic/vue/css/text-transformation.css";
|
|
||||||
import "@ionic/vue/css/flex-utils.css";
|
|
||||||
import "@ionic/vue/css/display.css";
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
|
||||||
{ path: "/", component: HomePage },
|
|
||||||
];
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes: [{ path: '/', component: HomePage }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createApp(App).use(IonicVue).use(router);
|
createApp(App).use(router).mount('#app');
|
||||||
|
|
||||||
router.isReady().then(() => {
|
|
||||||
app.mount("#app");
|
|
||||||
});
|
|
||||||
|
|||||||
13
frontend/src/style.css
Normal file
13
frontend/src/style.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #080808;
|
||||||
|
}
|
||||||
@@ -1,174 +1,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<ion-page>
|
<div class="xip-root">
|
||||||
<ion-header :translucent="true">
|
<!-- Bande pub gauche -->
|
||||||
<ion-toolbar>
|
<AdBand />
|
||||||
<ion-title>XIP 📡</ion-title>
|
|
||||||
</ion-toolbar>
|
|
||||||
</ion-header>
|
|
||||||
|
|
||||||
<ion-content :fullscreen="true">
|
<!-- Zone chat centrale -->
|
||||||
<ion-header collapse="condense">
|
<div class="xip-center">
|
||||||
<ion-toolbar>
|
<ChatHeader :connected-count="connectedCount" />
|
||||||
<ion-title size="large">XIP</ion-title>
|
<MessageList :messages="messages" />
|
||||||
</ion-toolbar>
|
|
||||||
</ion-header>
|
|
||||||
|
|
||||||
<!-- Composer -->
|
<!-- Barre de saisie -->
|
||||||
<div class="composer">
|
<div class="input-bar">
|
||||||
<ion-textarea
|
<input
|
||||||
v-model="newContent"
|
v-model="draft"
|
||||||
|
class="input-field"
|
||||||
|
type="text"
|
||||||
|
placeholder="Entrez un message..."
|
||||||
:maxlength="267"
|
:maxlength="267"
|
||||||
:counter="true"
|
@keydown.enter.exact.prevent="submit"
|
||||||
:auto-grow="true"
|
|
||||||
placeholder="Exprime-toi... (267 caractères max)"
|
|
||||||
fill="outline"
|
|
||||||
/>
|
/>
|
||||||
<ion-button
|
<SendButton :disabled="!draft.trim() || sending" @send="submit" />
|
||||||
expand="block"
|
</div>
|
||||||
class="send-btn"
|
|
||||||
:disabled="!newContent.trim() || sending"
|
|
||||||
@click="postMessage"
|
|
||||||
>
|
|
||||||
{{ sending ? "Envoi..." : "Envoyer" }}
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feed -->
|
<!-- Bouton hamburger droit -->
|
||||||
<ion-list v-if="messages.length > 0">
|
<MenuToggle @toggle="menuOpen = !menuOpen" />
|
||||||
<template v-for="msg in messages" :key="msg.id">
|
|
||||||
<ion-item lines="full">
|
|
||||||
<ion-label class="ion-text-wrap">
|
|
||||||
<p class="meta">{{ msg.authorIp }} · {{ formatDate(msg.createdAt) }}</p>
|
|
||||||
<h2>{{ msg.content }}</h2>
|
|
||||||
<p v-if="msg.replies.length > 0" class="replies-count">
|
|
||||||
{{ msg.replies.length }} réponse(s)
|
|
||||||
</p>
|
|
||||||
</ion-label>
|
|
||||||
</ion-item>
|
|
||||||
|
|
||||||
<!-- Replies -->
|
|
||||||
<ion-item
|
|
||||||
v-for="reply in msg.replies"
|
|
||||||
:key="reply.id"
|
|
||||||
lines="none"
|
|
||||||
class="reply-item"
|
|
||||||
>
|
|
||||||
<ion-label class="ion-text-wrap">
|
|
||||||
<p class="meta">↳ {{ reply.authorIp }} · {{ formatDate(reply.createdAt) }}</p>
|
|
||||||
<p>{{ reply.content }}</p>
|
|
||||||
</ion-label>
|
|
||||||
</ion-item>
|
|
||||||
</template>
|
|
||||||
</ion-list>
|
|
||||||
|
|
||||||
<div v-else-if="!loading" class="empty-state">
|
|
||||||
<p>Aucun message pour l'instant.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</ion-content>
|
|
||||||
</ion-page>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref } from 'vue';
|
||||||
import {
|
import AdBand from '@/components/AdBand.vue';
|
||||||
IonPage,
|
import ChatHeader from '@/components/ChatHeader.vue';
|
||||||
IonHeader,
|
import MessageList from '@/components/MessageList.vue';
|
||||||
IonToolbar,
|
import SendButton from '@/components/SendButton.vue';
|
||||||
IonTitle,
|
import MenuToggle from '@/components/MenuToggle.vue';
|
||||||
IonContent,
|
import { useMessages } from '@/composables/useMessages';
|
||||||
IonList,
|
|
||||||
IonItem,
|
|
||||||
IonLabel,
|
|
||||||
IonTextarea,
|
|
||||||
IonButton,
|
|
||||||
} from "@ionic/vue";
|
|
||||||
|
|
||||||
interface Message {
|
const { messages, sending, postMessage } = useMessages();
|
||||||
id: string;
|
const draft = ref('');
|
||||||
content: string;
|
const menuOpen = ref(false);
|
||||||
authorIp: string;
|
|
||||||
createdAt: string;
|
// Compte simulé (connexion WebSocket à brancher plus tard)
|
||||||
replies: Omit<Message, "replies">[];
|
const connectedCount = ref(312);
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
const ok = await postMessage(draft.value);
|
||||||
|
if (ok) draft.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
|
|
||||||
|
|
||||||
const messages = ref<Message[]>([]);
|
|
||||||
const newContent = ref("");
|
|
||||||
const loading = ref(false);
|
|
||||||
const sending = ref(false);
|
|
||||||
|
|
||||||
async function fetchMessages(): Promise<void> {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/api/messages`);
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch messages");
|
|
||||||
messages.value = (await res.json()) as Message[];
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postMessage(): Promise<void> {
|
|
||||||
if (!newContent.value.trim()) return;
|
|
||||||
sending.value = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/api/messages`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ content: newContent.value.trim() }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Failed to post message");
|
|
||||||
newContent.value = "";
|
|
||||||
await fetchMessages();
|
|
||||||
} finally {
|
|
||||||
sending.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(date: string): string {
|
|
||||||
return new Date(date).toLocaleString("fr-FR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchMessages);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.composer {
|
.xip-root {
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid var(--ion-color-light-shade);
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--ion-color-medium);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replies-count {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--ion-color-primary);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-item {
|
|
||||||
--padding-start: 32px;
|
|
||||||
background-color: var(--ion-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
width: 100vw;
|
||||||
padding: 48px 16px;
|
height: 100dvh;
|
||||||
color: var(--ion-color-medium);
|
background: #080808;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.xip-center {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #090910;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Barre de saisie ── */
|
||||||
|
.input-bar {
|
||||||
|
height: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #0e0e16;
|
||||||
|
border-top: 1px solid #1a1a26;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
flex: 1;
|
||||||
|
background: #141420;
|
||||||
|
border: 1px solid #222234;
|
||||||
|
border-radius: 23px;
|
||||||
|
padding: 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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user