proposition frontend

This commit is contained in:
arussac
2026-05-29 12:06:40 +02:00
parent 12afb71a67
commit 97f6fdaeae
14 changed files with 793 additions and 193 deletions

View File

@@ -1,9 +1,3 @@
<template>
<ion-app>
<ion-router-outlet />
</ion-app>
<RouterView />
</template>
<script setup lang="ts">
import { IonApp, IonRouterOutlet } from "@ionic/vue";
</script>

View 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>

View 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>

View 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 &bull; 500&euro; 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 &rarr;
</button>
<p class="disclaimer">18+ &bull; Jeu responsable &bull; 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>

View 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>

View 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>

View 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>

View 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>

View 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`;
}

View 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 };
}

View File

@@ -1,35 +1,12 @@
import { createApp } from "vue";
import { IonicVue } from "@ionic/vue";
import { createRouter, createWebHistory } from "@ionic/vue-router";
import type { RouteRecordRaw } from "vue-router";
import App from "./App.vue";
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 },
];
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import HomePage from './views/HomePage.vue';
import './style.css';
const router = createRouter({
history: createWebHistory(),
routes,
routes: [{ path: '/', component: HomePage }],
});
const app = createApp(App).use(IonicVue).use(router);
router.isReady().then(() => {
app.mount("#app");
});
createApp(App).use(router).mount('#app');

13
frontend/src/style.css Normal file
View File

@@ -0,0 +1,13 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
background: #080808;
}

View File

@@ -1,174 +1,96 @@
<template>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>XIP 📡</ion-title>
</ion-toolbar>
</ion-header>
<div class="xip-root">
<!-- Bande pub gauche -->
<AdBand />
<ion-content :fullscreen="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">XIP</ion-title>
</ion-toolbar>
</ion-header>
<!-- Zone chat centrale -->
<div class="xip-center">
<ChatHeader :connected-count="connectedCount" />
<MessageList :messages="messages" />
<!-- Composer -->
<div class="composer">
<ion-textarea
v-model="newContent"
<!-- Barre de saisie -->
<div class="input-bar">
<input
v-model="draft"
class="input-field"
type="text"
placeholder="Entrez un message..."
:maxlength="267"
:counter="true"
:auto-grow="true"
placeholder="Exprime-toi... (267 caractères max)"
fill="outline"
@keydown.enter.exact.prevent="submit"
/>
<ion-button
expand="block"
class="send-btn"
:disabled="!newContent.trim() || sending"
@click="postMessage"
>
{{ sending ? "Envoi..." : "Envoyer" }}
</ion-button>
<SendButton :disabled="!draft.trim() || sending" @send="submit" />
</div>
</div>
<!-- Feed -->
<ion-list v-if="messages.length > 0">
<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>
<!-- Bouton hamburger droit -->
<MenuToggle @toggle="menuOpen = !menuOpen" />
</div>
</ion-content>
</ion-page>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import {
IonPage,
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonList,
IonItem,
IonLabel,
IonTextarea,
IonButton,
} from "@ionic/vue";
import { ref } from 'vue';
import AdBand from '@/components/AdBand.vue';
import ChatHeader from '@/components/ChatHeader.vue';
import MessageList from '@/components/MessageList.vue';
import SendButton from '@/components/SendButton.vue';
import MenuToggle from '@/components/MenuToggle.vue';
import { useMessages } from '@/composables/useMessages';
interface Message {
id: string;
content: string;
authorIp: string;
createdAt: string;
replies: Omit<Message, "replies">[];
const { messages, sending, postMessage } = useMessages();
const draft = ref('');
const menuOpen = ref(false);
// Compte simulé (connexion WebSocket à brancher plus tard)
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>
<style scoped>
.composer {
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 {
.xip-root {
display: flex;
justify-content: center;
padding: 48px 16px;
color: var(--ion-color-medium);
width: 100vw;
height: 100dvh;
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>

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />