systeme de themes qui fonctionnouille
All checks were successful
Deploy XIP / deploy (push) Successful in 34s
All checks were successful
Deploy XIP / deploy (push) Successful in 34s
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
154
frontend/src/components/MessageItemBubble.vue
Normal file
154
frontend/src/components/MessageItemBubble.vue
Normal 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>
|
||||||
@@ -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">
|
||||||
v-for="msg in messages"
|
<component
|
||||||
:key="msg.id"
|
:is="messageComponent"
|
||||||
:message="msg"
|
v-for="msg in messages"
|
||||||
:my-ip="myIp"
|
:key="msg.id"
|
||||||
@reply="$emit('reply', $event)"
|
:message="msg"
|
||||||
/>
|
:my-ip="myIp"
|
||||||
|
@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>
|
||||||
|
|||||||
41
frontend/src/components/ThemePicker.vue
Normal file
41
frontend/src/components/ThemePicker.vue
Normal 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>
|
||||||
62
frontend/src/composables/useMessageItem.ts
Normal file
62
frontend/src/composables/useMessageItem.ts
Normal 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 };
|
||||||
|
}
|
||||||
39
frontend/src/composables/useTheme.ts
Normal file
39
frontend/src/composables/useTheme.ts
Normal 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 };
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user