systeme de themes qui fonctionnouille
All checks were successful
Deploy XIP / deploy (push) Successful in 34s

This commit is contained in:
raphael.thieffry
2026-05-31 18:41:39 +02:00
parent 942fcaa4d1
commit c0b82222bd
8 changed files with 334 additions and 82 deletions

View File

@@ -9,6 +9,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<ThemePicker v-model="theme" />
<span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span> <span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span>
<span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP"> <span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP">
<span class="balance-coin"></span> <span class="balance-coin"></span>
@@ -23,10 +24,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import { useTheme } from '@/composables/useTheme';
import ThemePicker from './ThemePicker.vue';
defineProps<{ connectedCount: number }>(); defineProps<{ connectedCount: number }>();
const { ip, freeMode, displayBalance } = useWallet(); const { ip, freeMode, displayBalance } = useWallet();
const { theme } = useTheme();
</script> </script>
<style scoped> <style scoped>

View File

@@ -65,57 +65,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Message, Reply, GeoInfo } from '@/composables/useMessages'; import type { Message } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
import { usePerks } from '@/composables/usePerks';
import { openContextMenu } from '@/composables/useContextMenu'; import { openContextMenu } from '@/composables/useContextMenu';
import { useCustomStyles, IP_COLOR_OPTIONS } from '@/composables/useCustomStyles'; import { IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMessageItem } from '@/composables/useMessageItem';
import RichContent from './RichContent.vue'; import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue'; import MessageAttachments from './MessageAttachments.vue';
const props = defineProps<{ message: Message; myIp?: string }>(); const props = defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { perksFor } = usePerks(); const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs } = useMessageItem();
const { myPerks } = useMyPerks();
const { prefs } = useCustomStyles();
function perksOf(m: Reply): any {
return m.authorPerks ?? perksFor(m.authorIp);
}
function ipStyle(m: Reply) {
const ip = m.authorIp;
const colorOverride = prefs.ipColors[ip];
if (colorOverride && colorOverride !== 'auto') {
return { color: colorOverride, textShadow: getIpGlow(colorOverride) };
}
const p = perksOf(m);
return {
color: getIpColorWithPerks(ip, p),
textShadow: getIpGlowWithPerks(ip, p),
};
}
function petsLeft(m: Reply): string {
const ip = m.authorIp;
if (ip in prefs.ipPets) return prefs.ipPets[ip]; // '' = aucun pet
const pets = perksOf(m)?.pets ?? [];
return pets
.filter((x: any) => x.position === 'left' || x.position === 'both')
.map((x: any) => x.char)
.join('');
}
function petsRight(m: Reply): string {
const ip = m.authorIp;
if (ip in prefs.ipPets) return ''; // override = pet gauche uniquement
const pets = perksOf(m)?.pets ?? [];
return pets
.filter((x: any) => x.position === 'right' || x.position === 'both')
.map((x: any) => x.char)
.join('');
}
function openIpMenu(e: MouseEvent, ip: string): void { function openIpMenu(e: MouseEvent, ip: string): void {
if (ip !== props.myIp) return; if (ip !== props.myIp) return;
@@ -171,26 +131,6 @@ function openIpMenu(e: MouseEvent, ip: string): void {
}); });
} }
function fmt(date: string): string {
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
function geoLabel(geo?: GeoInfo | null): string {
if (!geo) return '';
if (!geo.countryCode) return 'Local';
const place = geo.city || geo.country;
if (geo.lat != null && geo.lon != null) {
const lat = geo.lat.toFixed(4);
const lon = geo.lon.toFixed(4);
return `${place} · ${lat}, ${lon}`;
}
return place;
}
function geoLink(geo?: GeoInfo | null): string {
if (!geo || geo.lat == null || geo.lon == null) return 'https://maps.google.com';
return `https://www.google.com/maps/search/?api=1&query=${geo.lat},${geo.lon}`;
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -0,0 +1,154 @@
<!-- Variante "bulles" du message style chat mobile -->
<template>
<div class="bubble-item" :class="{ 'bubble-item--mine': isMine }">
<div class="bubble-header">
<span class="bubble-ip" :style="ipStyle(message)">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
{{ message.authorIp }}
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span>
<span class="bubble-ts">{{ fmt(message.createdAt) }}</span>
<a
v-if="message.authorGeo && geoLabel(message.authorGeo)"
: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>
</a>
</div>
<!-- Contenu -->
<div class="bubble" :class="{ 'bubble--mine': isMine }">
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<span v-else>{{ message.content }}</span>
</div>
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses en thread -->
<div v-if="message.replies?.length" class="bubble-thread">
<div v-for="reply in message.replies" :key="reply.id" class="bubble-reply">
<span class="bubble-reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span class="bubble-reply-ts">{{ fmt(reply.createdAt) }}</span>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<span v-else class="bubble-reply-body">{{ reply.content }}</span>
</div>
</div>
<button
class="bubble-reply-btn"
type="button"
@click="$emit('reply', { id: message.id, authorIp: message.authorIp })"
></button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Message } from '@/composables/useMessages';
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 } = useMessageItem();
const isMine = computed(() => props.message.authorIp === props.myIp);
</script>
<style scoped>
.bubble-item {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 4px 12px;
gap: 3px;
position: relative;
}
.bubble-item--mine { align-items: flex-end; }
.bubble-header {
display: flex;
align-items: center;
gap: 6px;
font-family: 'Courier New', monospace;
font-size: 10px;
}
.bubble-ip { font-weight: bold; font-size: 11px; }
.bubble-ts { color: #303040; }
.pet { 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: 2px;
}
.bubble {
background: #141422;
border: 1px solid #222236;
border-radius: 14px 14px 14px 4px;
padding: 7px 13px;
font-family: Arial, sans-serif;
font-size: 13px;
color: #c0c0c0;
max-width: 72%;
word-break: break-word;
line-height: 1.4;
}
.bubble--mine {
background: #0e1f30;
border-color: #1a3a55;
border-radius: 14px 14px 4px 14px;
color: #cce0f0;
}
.bubble-thread {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 12px;
border-left: 2px solid #1a1a2e;
margin-top: 2px;
}
.bubble-reply {
display: flex;
align-items: baseline;
gap: 6px;
font-family: Arial, sans-serif;
font-size: 11px;
color: #888;
}
.bubble-reply-ip { font-family: 'Courier New', monospace; font-size: 10px; font-weight: bold; }
.bubble-reply-ts { font-family: 'Courier New', monospace; font-size: 9px; color: #303040; }
.bubble-reply-body { color: #888; }
.bubble-reply-btn {
background: none; border: none; cursor: pointer;
font-size: 10px; color: #33335a;
padding: 0; opacity: 0; transition: opacity 0.12s;
}
.bubble-item:hover .bubble-reply-btn { opacity: 1; }
.bubble-reply-btn:hover { color: #00ccff; }
.geo-link { color: #44445a; text-decoration: none; display: inline-flex; align-items: center; }
.geo-flag { width: 14px; height: 10px; object-fit: cover; border-radius: 2px; }
</style>

View File

@@ -1,15 +1,17 @@
<!-- Zone de messages scrollable avec la pub casino en overlay --> <!-- Zone de messages scrollable avec la pub casino en overlay -->
<template> <template>
<div class="feed-wrapper"> <div class="feed-wrapper">
<!-- Messages -->
<div ref="listEl" class="feed-scroll"> <div ref="listEl" class="feed-scroll">
<MessageItem <TransitionGroup name="msg" tag="div">
<component
:is="messageComponent"
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
:my-ip="myIp" :my-ip="myIp"
@reply="$emit('reply', $event)" @reply="$emit('reply', $event)"
/> />
</TransitionGroup>
<div v-if="messages.length === 0" class="feed-empty"> <div v-if="messages.length === 0" class="feed-empty">
Aucun message pour l'instant. Aucun message pour l'instant.
</div> </div>
@@ -21,24 +23,30 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import type { Message } from '@/composables/useMessages'; import type { Message } from '@/composables/useMessages';
import { useTheme } from '@/composables/useTheme';
import MessageItem from './MessageItem.vue'; import MessageItem from './MessageItem.vue';
import MessageItemBubble from './MessageItemBubble.vue';
import InlineCasinoAd from './InlineCasinoAd.vue'; import InlineCasinoAd from './InlineCasinoAd.vue';
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>(); const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { theme } = useTheme();
const messageComponent = computed(() => {
if (theme.value === 'bubble') return MessageItemBubble;
return MessageItem;
});
const listEl = ref<HTMLElement | null>(null); const listEl = ref<HTMLElement | null>(null);
// Auto-scroll vers le bas à chaque nouveau message
watch( watch(
() => props.messages.length, () => props.messages.length,
async () => { async () => {
await nextTick(); await nextTick();
if (listEl.value) { listEl.value?.scrollTo({ top: listEl.value.scrollHeight, behavior: 'smooth' });
listEl.value.scrollTop = listEl.value.scrollHeight;
}
}, },
); );
</script> </script>
@@ -69,14 +77,15 @@ watch(
font-size: 13px; font-size: 13px;
} }
/* Positionné en absolu sur la droite du wrapper */
.casino-overlay { .casino-overlay {
position: absolute; position: absolute;
right: 30px; right: 30px;
top: 20px; top: 20px;
pointer-events: none; pointer-events: none;
} }
.casino-overlay :deep(.casino-cta) { .casino-overlay :deep(.casino-cta) { pointer-events: all; }
pointer-events: all;
} /* Transition d'entrée des nouveaux messages */
.msg-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.msg-enter-from { opacity: 0; transform: translateY(6px); }
</style> </style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { THEMES, type Theme } from '@/composables/useTheme';
const model = defineModel<Theme>({ required: true });
</script>
<template>
<div class="theme-picker">
<button
v-for="(info, key) in THEMES"
:key="key"
class="theme-btn"
:class="{ 'theme-btn--active': model === key }"
:title="info.label"
type="button"
@click="model = key"
>{{ info.emoji }}</button>
</div>
</template>
<style scoped>
.theme-picker {
display: flex;
gap: 4px;
}
.theme-btn {
background: #131320;
border: 1px solid #222233;
border-radius: 8px;
padding: 3px 7px;
cursor: pointer;
font-size: 14px;
opacity: 0.5;
transition: opacity 0.15s, border-color 0.15s;
}
.theme-btn:hover { opacity: 0.8; }
.theme-btn--active {
opacity: 1;
border-color: #5577aa;
}
</style>

View File

@@ -0,0 +1,62 @@
import { type GeoInfo } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlowWithPerks, getIpGlow } from '@/composables/ipColor';
import { usePerks } from '@/composables/usePerks';
import { useCustomStyles } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
export function useMessageItem() {
const { perksFor } = usePerks();
const { myPerks } = useMyPerks();
const { prefs } = useCustomStyles();
function perksOf(m: { authorIp: string; authorPerks?: any }) {
return m.authorPerks ?? perksFor(m.authorIp);
}
function ipStyle(m: { authorIp: string; authorPerks?: any }) {
const ip = m.authorIp;
const override = prefs.ipColors[ip];
if (override && override !== 'auto') {
return { color: override, textShadow: getIpGlow(override) };
}
const p = perksOf(m);
return { color: getIpColorWithPerks(ip, p), textShadow: getIpGlowWithPerks(ip, p) };
}
function petsLeft(m: { authorIp: string; authorPerks?: any }) {
const ip = m.authorIp;
if (ip in prefs.ipPets) return prefs.ipPets[ip];
return (perksOf(m)?.pets ?? [])
.filter((x: any) => x.position === 'left' || x.position === 'both')
.map((x: any) => x.char).join('');
}
function petsRight(m: { authorIp: string; authorPerks?: any }) {
const ip = m.authorIp;
if (ip in prefs.ipPets) return '';
return (perksOf(m)?.pets ?? [])
.filter((x: any) => x.position === 'right' || x.position === 'both')
.map((x: any) => x.char).join('');
}
function fmt(date: string) {
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
function geoLabel(geo?: GeoInfo | null): string {
if (!geo) return '';
if (!geo.countryCode) return 'Local';
const place = geo.city || geo.country;
if (geo.lat != null && geo.lon != null) {
return `${place} · ${geo.lat.toFixed(4)}, ${geo.lon.toFixed(4)}`;
}
return place;
}
function geoLink(geo?: GeoInfo | null): string {
if (!geo || geo.lat == null || geo.lon == null) return 'https://maps.google.com';
return `https://www.google.com/maps/search/?api=1&query=${geo.lat},${geo.lon}`;
}
return { perksOf, ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs };
}

View File

@@ -0,0 +1,39 @@
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue';
export type Theme = 'default' | 'bubble' | 'compact';
export interface ThemeContext {
theme: Ref<Theme>;
setTheme: (t: Theme) => void;
}
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('xip-theme');
const THEMES: Record<Theme, { label: string; emoji: string }> = {
default: { label: 'Classique', emoji: '📋' },
bubble: { label: 'Bulles', emoji: '💬' },
compact: { label: 'Compact', emoji: '📐' },
};
export function provideTheme() {
const saved = (localStorage.getItem('xip-theme') ?? 'default') as Theme;
const theme = ref<Theme>(THEMES[saved] ? saved : 'default');
function setTheme(t: Theme) {
theme.value = t;
localStorage.setItem('xip-theme', t);
}
const ctx: ThemeContext = { theme, setTheme };
provide(THEME_KEY, ctx);
return ctx;
}
export function useTheme(): ThemeContext {
return inject(THEME_KEY, {
theme: ref<Theme>('default'),
setTheme: () => {},
});
}
export { THEMES };

View File

@@ -57,7 +57,7 @@
type="button" type="button"
>🔊</button> >🔊</button>
<div class="field-wrap"> <div v-show="richMode === 'none'" class="field-wrap">
<input <input
v-model="draft" v-model="draft"
class="input-field" class="input-field"
@@ -92,6 +92,9 @@ import MessageList from '@/components/MessageList.vue';
import SendButton from '@/components/SendButton.vue'; import SendButton from '@/components/SendButton.vue';
import StatsTicker from '@/components/StatsTicker.vue'; import StatsTicker from '@/components/StatsTicker.vue';
import { useMessages } from '@/composables/useMessages'; import { useMessages } from '@/composables/useMessages';
import { provideTheme } from '@/composables/useTheme';
provideTheme();
import { useAttachments } from '@/composables/useAttachments'; import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert'; import { useAlert } from '@/composables/useAlert';
import { useCustomStyles } from '@/composables/useCustomStyles'; import { useCustomStyles } from '@/composables/useCustomStyles';