From 1a76e9076c55ae19306ab79d2e289901e47ea565 Mon Sep 17 00:00:00 2001 From: arussac Date: Sun, 31 May 2026 14:47:40 +0200 Subject: [PATCH] feat: implement right-click context menu for style customization and enhance real-time stats tracking --- backend/src/realtime.ts | 13 +- frontend/src/App.vue | 5 + frontend/src/components/AdBand.vue | 26 +++- frontend/src/components/MessageItem.vue | 53 +++++++- frontend/src/components/SendButton.vue | 57 ++++---- frontend/src/components/StyleContextMenu.vue | 130 +++++++++++++++++++ frontend/src/composables/useContextMenu.ts | 58 +++++++++ frontend/src/composables/useCustomStyles.ts | 78 +++++++++++ frontend/src/views/HomePage.vue | 4 - 9 files changed, 389 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/StyleContextMenu.vue create mode 100644 frontend/src/composables/useContextMenu.ts create mode 100644 frontend/src/composables/useCustomStyles.ts diff --git a/backend/src/realtime.ts b/backend/src/realtime.ts index 81a84d6..f2834fa 100644 --- a/backend/src/realtime.ts +++ b/backend/src/realtime.ts @@ -55,8 +55,10 @@ async function flushStats(): Promise { broadcastScheduled = false; lastBroadcastAt = Date.now(); if (clients.size === 0) return; + const distinctIps = new Set(); + for (const s of clients.values()) distinctIps.add(s.ip); const snapshot = await buildSnapshot({ - connectedTabs: clients.size, + connectedTabs: distinctIps.size, typingNow: countTyping(Date.now()), }); const payload = JSON.stringify({ type: "stats", data: snapshot }); @@ -78,6 +80,15 @@ setInterval(() => { if (clients.size > 0) void flushStats(); }, 1000); +// Periodic console log of connected IPs (every 10 s). +setInterval(() => { + if (clients.size === 0) return; + const ips = new Set(); + for (const s of clients.values()) ips.add(s.ip); + const lines = [...ips].map((ip) => ` ${ip}`).join("\n"); + console.log(`[connectés] ${ips.size} IP(s):\n${lines}`); +}, 10_000); + /** Send an arbitrary frame to every connected tab. */ export function broadcast(payload: object): void { const str = JSON.stringify(payload); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7c2aa3f..d96ca47 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,3 +1,8 @@ + + diff --git a/frontend/src/components/AdBand.vue b/frontend/src/components/AdBand.vue index dd67bbf..8d075ff 100644 --- a/frontend/src/components/AdBand.vue +++ b/frontend/src/components/AdBand.vue @@ -9,8 +9,11 @@ :key="ad.id" class="ad-card" :href="ad.url || undefined" + :style="cardStyle" target="_blank" rel="noopener noreferrer nofollow" + title="Clic droit pour personnaliser le cadre" + @contextmenu.prevent="onRightClick" >

{{ ad.brand }}

@@ -26,16 +29,35 @@ diff --git a/frontend/src/components/StyleContextMenu.vue b/frontend/src/components/StyleContextMenu.vue new file mode 100644 index 0000000..03aa9cb --- /dev/null +++ b/frontend/src/components/StyleContextMenu.vue @@ -0,0 +1,130 @@ + + + + + + diff --git a/frontend/src/composables/useContextMenu.ts b/frontend/src/composables/useContextMenu.ts new file mode 100644 index 0000000..fae9385 --- /dev/null +++ b/frontend/src/composables/useContextMenu.ts @@ -0,0 +1,58 @@ +/** + * Global singleton for the right-click style context menu. + * Any component calls openContextMenu() to display the floating picker, + * and StyleContextMenu.vue (mounted once in App.vue) renders it. + */ +import { reactive } from 'vue'; + +export interface ContextMenuItem { + value: string; + label: string; + swatch?: string; // optional color swatch dot + isHeader?: boolean; // non-interactive section heading +} + +interface MenuState { + visible: boolean; + x: number; + y: number; + title: string; + items: ContextMenuItem[]; + current: string; + onSelect: (value: string) => void; +} + +const state = reactive({ + visible: false, + x: 0, + y: 0, + title: '', + items: [], + current: '', + onSelect: () => {}, +}); + +export function openContextMenu(opts: { + x: number; + y: number; + title: string; + items: ContextMenuItem[]; + current: string; + onSelect: (value: string) => void; +}): void { + state.visible = true; + state.x = opts.x; + state.y = opts.y; + state.title = opts.title; + state.items = opts.items; + state.current = opts.current; + state.onSelect = opts.onSelect; +} + +export function closeContextMenu(): void { + state.visible = false; +} + +export function useContextMenu() { + return { state }; +} diff --git a/frontend/src/composables/useCustomStyles.ts b/frontend/src/composables/useCustomStyles.ts new file mode 100644 index 0000000..b56bece --- /dev/null +++ b/frontend/src/composables/useCustomStyles.ts @@ -0,0 +1,78 @@ +/** + * Viewer-side visual customisations, persisted in localStorage. + * None of these affect other users — they're purely local display overrides. + */ +import { reactive, watch } from 'vue'; + +const STORAGE_KEY = 'xip_custom_styles_v1'; + +// ── Preset catalogues ──────────────────────────────────────────────────────── + +export const SEND_BUTTON_PRESETS = { + default: { bg: '#004488', color: '#00ddff', radius: '50%', label: 'Cyan (défaut)' }, + green: { bg: '#1a4a1a', color: '#00ee77', radius: '50%', label: 'Vert' }, + purple: { bg: '#2a1040', color: '#cc44ff', radius: '50%', label: 'Violet' }, + red: { bg: '#3a0a0a', color: '#ff5533', radius: '50%', label: 'Rouge' }, + square: { bg: '#1a1a1a', color: '#ffffff', radius: '4px', label: 'Blanc carré' }, +} as const; +export type SendButtonKey = keyof typeof SEND_BUTTON_PRESETS; + +export const AD_FRAME_PRESETS = { + default: { border: '1px solid #1e1e2a', bg: '#121218', label: 'Défaut' }, + neon: { border: '1px solid #00ddff66', bg: '#0a1220', label: 'Néon bleu' }, + gold: { border: '1px solid #ffdd4466', bg: '#141208', label: 'Or' }, + minimal: { border: '1px solid transparent', bg: '#0c0c10', label: 'Minimal' }, +} as const; +export type AdFrameKey = keyof typeof AD_FRAME_PRESETS; + +export const IP_COLOR_OPTIONS: { value: string; label: string; swatch?: string }[] = [ + { value: 'auto', label: 'Auto (palette)' }, + { value: '#00ddff', label: 'Cyan', swatch: '#00ddff' }, + { value: '#ff00cc', label: 'Rose', swatch: '#ff00cc' }, + { value: '#00ee77', label: 'Vert', swatch: '#00ee77' }, + { value: '#ffdd44', label: 'Or', swatch: '#ffdd44' }, + { value: '#ff5533', label: 'Rouge', swatch: '#ff5533' }, + { value: '#ffffff', label: 'Blanc', swatch: '#ffffff' }, +]; + +export const PET_OPTIONS: { value: string; label: string }[] = [ + { value: '', label: 'Aucun' }, + { value: '🐱', label: '🐱 Chat' }, + { value: '🐶', label: '🐶 Chien' }, + { value: '✨', label: '✨ Sparkle' }, + { value: '🔥', label: '🔥 Feu' }, + { value: '👾', label: '👾 Ghost' }, + { value: '⚡', label: '⚡ Éclair' }, + { value: '🌙', label: '🌙 Lune' }, +]; + +// ── Preferences shape ──────────────────────────────────────────────────────── + +export interface CustomStylePrefs { + sendButton: SendButtonKey; + adFrame: AdFrameKey; + ipColors: Record; // ip → hex or 'auto' + ipPets: Record; // ip → emoji or '' +} + +function defaults(): CustomStylePrefs { + return { sendButton: 'default', adFrame: 'default', ipColors: {}, ipPets: {} }; +} + +function load(): CustomStylePrefs { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...defaults(), ...JSON.parse(raw) }; + } catch { /* ignore */ } + return defaults(); +} + +const prefs = reactive(load()); + +watch(prefs, (v) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(v)); +}, { deep: true }); + +export function useCustomStyles() { + return { prefs }; +} diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue index 51c715c..b044bbf 100644 --- a/frontend/src/views/HomePage.vue +++ b/frontend/src/views/HomePage.vue @@ -4,9 +4,6 @@
- - -
@@ -90,7 +87,6 @@