feat: enhance customization options with new 'Mes Persos' panel and improved context menus
This commit is contained in:
@@ -33,9 +33,11 @@ import { computed, onMounted, watch } from 'vue';
|
|||||||
import { useAds } from '@/composables/useAds';
|
import { useAds } from '@/composables/useAds';
|
||||||
import { openContextMenu } from '@/composables/useContextMenu';
|
import { openContextMenu } from '@/composables/useContextMenu';
|
||||||
import { useCustomStyles, AD_FRAME_PRESETS } from '@/composables/useCustomStyles';
|
import { useCustomStyles, AD_FRAME_PRESETS } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
|
||||||
const { ads, fetchAds, reportImpression } = useAds('band');
|
const { ads, fetchAds, reportImpression } = useAds('band');
|
||||||
const { prefs } = useCustomStyles();
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
|
||||||
const cardStyle = computed(() => {
|
const cardStyle = computed(() => {
|
||||||
const p = AD_FRAME_PRESETS[prefs.adFrame];
|
const p = AD_FRAME_PRESETS[prefs.adFrame];
|
||||||
@@ -43,6 +45,7 @@ const cardStyle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function onRightClick(e: MouseEvent): void {
|
function onRightClick(e: MouseEvent): void {
|
||||||
|
if (!myPerks.value.elementSkin) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openContextMenu({
|
openContextMenu({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="message-item">
|
<div class="message-item">
|
||||||
<!-- Auteur + horodatage -->
|
<!-- Auteur + horodatage -->
|
||||||
<div class="message-meta">
|
<div class="message-meta">
|
||||||
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" title="Clic droit pour personnaliser">
|
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" :title="message.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
|
||||||
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
|
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
|
||||||
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
|
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
|
||||||
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
|
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
:key="reply.id"
|
:key="reply.id"
|
||||||
class="reply"
|
class="reply"
|
||||||
>
|
>
|
||||||
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" title="Clic droit pour personnaliser">
|
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
|
||||||
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
|
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
|
||||||
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
|
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
|
||||||
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
|
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
|
||||||
@@ -55,14 +55,16 @@ import type { Message, Reply } from '@/composables/useMessages';
|
|||||||
import { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
|
import { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
|
||||||
import { usePerks } from '@/composables/usePerks';
|
import { usePerks } from '@/composables/usePerks';
|
||||||
import { openContextMenu } from '@/composables/useContextMenu';
|
import { openContextMenu } from '@/composables/useContextMenu';
|
||||||
import { useCustomStyles, IP_COLOR_OPTIONS, PET_OPTIONS } from '@/composables/useCustomStyles';
|
import { useCustomStyles, IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
import RichContent from './RichContent.vue';
|
import RichContent from './RichContent.vue';
|
||||||
import MessageAttachments from './MessageAttachments.vue';
|
import MessageAttachments from './MessageAttachments.vue';
|
||||||
|
|
||||||
defineProps<{ message: Message }>();
|
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 { perksFor } = usePerks();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
const { prefs } = useCustomStyles();
|
const { prefs } = useCustomStyles();
|
||||||
|
|
||||||
function perksOf(m: Reply): any {
|
function perksOf(m: Reply): any {
|
||||||
@@ -102,19 +104,43 @@ function petsRight(m: Reply): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openIpMenu(e: MouseEvent, ip: string): void {
|
function openIpMenu(e: MouseEvent, ip: string): void {
|
||||||
|
if (ip !== props.myIp) return;
|
||||||
|
|
||||||
|
const hasElementSkin = !!myPerks.value.elementSkin;
|
||||||
|
const ownedPets = myPerks.value.pets ?? [];
|
||||||
|
const hasPets = ownedPets.length > 0;
|
||||||
|
|
||||||
|
// Nothing to show if no perk unlocks customization.
|
||||||
|
if (!hasElementSkin && !hasPets) return;
|
||||||
|
|
||||||
const currentColor = prefs.ipColors[ip] ?? 'auto';
|
const currentColor = prefs.ipColors[ip] ?? 'auto';
|
||||||
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
|
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
|
||||||
|
|
||||||
|
const items: import('@/composables/useContextMenu').ContextMenuItem[] = [];
|
||||||
|
|
||||||
|
if (hasElementSkin) {
|
||||||
|
items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
|
||||||
|
items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPets) {
|
||||||
|
items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
|
||||||
|
items.push({ value: 'pet:__inherit__', label: '↩ défaut' });
|
||||||
|
// Show only the pets the user actually owns.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of ownedPets) {
|
||||||
|
if (!seen.has(p.char)) {
|
||||||
|
seen.add(p.char);
|
||||||
|
items.push({ value: `pet:${p.char}`, label: p.char });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
openContextMenu({
|
openContextMenu({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
title: ip,
|
title: ip,
|
||||||
items: [
|
items,
|
||||||
{ value: '__h_color', label: 'Couleur', isHeader: true },
|
|
||||||
...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })),
|
|
||||||
{ value: '__h_pet', label: 'Pet', isHeader: true },
|
|
||||||
{ value: 'pet:__inherit__', label: '↩ défaut (perk)' },
|
|
||||||
...PET_OPTIONS.map((o) => ({ value: `pet:${o.value}`, label: o.label })),
|
|
||||||
],
|
|
||||||
current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
|
current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
|
||||||
onSelect: (v) => {
|
onSelect: (v) => {
|
||||||
if (v.startsWith('color:')) {
|
if (v.startsWith('color:')) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
|
:my-ip="myIp"
|
||||||
@reply="$emit('reply', $event)"
|
@reply="$emit('reply', $event)"
|
||||||
/>
|
/>
|
||||||
<div v-if="messages.length === 0" class="feed-empty">
|
<div v-if="messages.length === 0" class="feed-empty">
|
||||||
@@ -25,7 +26,7 @@ import type { Message } from '@/composables/useMessages';
|
|||||||
import MessageItem from './MessageItem.vue';
|
import MessageItem from './MessageItem.vue';
|
||||||
import InlineCasinoAd from './InlineCasinoAd.vue';
|
import InlineCasinoAd from './InlineCasinoAd.vue';
|
||||||
|
|
||||||
const props = defineProps<{ messages: Message[]; hideAds?: boolean }>();
|
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
|
||||||
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
|
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
|
||||||
|
|
||||||
const listEl = ref<HTMLElement | null>(null);
|
const listEl = ref<HTMLElement | null>(null);
|
||||||
|
|||||||
@@ -19,11 +19,13 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { openContextMenu } from '@/composables/useContextMenu';
|
import { openContextMenu } from '@/composables/useContextMenu';
|
||||||
import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles';
|
import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
|
||||||
defineProps<{ disabled?: boolean }>();
|
defineProps<{ disabled?: boolean }>();
|
||||||
defineEmits<{ send: [] }>();
|
defineEmits<{ send: [] }>();
|
||||||
|
|
||||||
const { prefs } = useCustomStyles();
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
|
||||||
const btnStyle = computed(() => {
|
const btnStyle = computed(() => {
|
||||||
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
|
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
|
||||||
@@ -31,6 +33,7 @@ const btnStyle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function onRightClick(e: MouseEvent): void {
|
function onRightClick(e: MouseEvent): void {
|
||||||
|
if (!myPerks.value.elementSkin) return;
|
||||||
openContextMenu({
|
openContextMenu({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
|
|||||||
313
frontend/src/components/shop/MesPersos.vue
Normal file
313
frontend/src/components/shop/MesPersos.vue
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<!-- Mes Personnalisations — visible depuis le shop, onglet "Mes Persos" -->
|
||||||
|
<template>
|
||||||
|
<div class="persos">
|
||||||
|
|
||||||
|
<!-- ── Image de fond du chat ─────────────────────────────────── -->
|
||||||
|
<section class="section">
|
||||||
|
<h2 class="section-title">🖼️ Fond du chat</h2>
|
||||||
|
<p class="section-sub">URL d'une image (jpg, png, gif, webp…) ou laisse vide pour le fond par défaut.</p>
|
||||||
|
<div class="bg-row">
|
||||||
|
<input
|
||||||
|
v-model="bgDraft"
|
||||||
|
class="bg-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
@keydown.enter="applyBg"
|
||||||
|
/>
|
||||||
|
<button class="btn-apply" @click="applyBg" type="button">Appliquer</button>
|
||||||
|
<button v-if="prefs.chatBgUrl" class="btn-reset" @click="resetBg" type="button">✕ Retirer</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="prefs.chatBgUrl" class="bg-preview" :style="{ backgroundImage: `url(${prefs.chatBgUrl})` }" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Bouton d'envoi ─────────────────────────────────────────── -->
|
||||||
|
<section class="section" :class="{ locked: !myPerks.elementSkin }">
|
||||||
|
<h2 class="section-title">
|
||||||
|
➤ Bouton d'envoi
|
||||||
|
<span v-if="!myPerks.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span>
|
||||||
|
</h2>
|
||||||
|
<div class="style-grid">
|
||||||
|
<button
|
||||||
|
v-for="[k, p] in Object.entries(SEND_BUTTON_PRESETS)"
|
||||||
|
:key="k"
|
||||||
|
class="style-tile"
|
||||||
|
:class="{ 'style-tile--active': prefs.sendButton === k }"
|
||||||
|
:disabled="!myPerks.elementSkin"
|
||||||
|
@click="prefs.sendButton = k as SendButtonKey"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="style-swatch" :style="{ background: p.bg, color: p.color, borderRadius: p.radius }">➤</span>
|
||||||
|
<span class="style-label">{{ p.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Couleur de l'IP ────────────────────────────────────────── -->
|
||||||
|
<section class="section" :class="{ locked: !myPerks.elementSkin }">
|
||||||
|
<h2 class="section-title">
|
||||||
|
🎨 Couleur de mon IP
|
||||||
|
<span v-if="!myPerks.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span>
|
||||||
|
</h2>
|
||||||
|
<p v-if="myIp" class="section-sub">IP : <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
|
||||||
|
<div class="style-grid">
|
||||||
|
<button
|
||||||
|
v-for="opt in IP_COLOR_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="style-tile"
|
||||||
|
:class="{ 'style-tile--active': currentIpColor === opt.value }"
|
||||||
|
:disabled="!myPerks.elementSkin"
|
||||||
|
@click="setIpColor(opt.value)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span v-if="opt.swatch" class="color-dot" :style="{ background: opt.swatch }" />
|
||||||
|
<span v-else class="color-dot color-dot--auto" />
|
||||||
|
<span class="style-label">{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Pets ───────────────────────────────────────────────────── -->
|
||||||
|
<section class="section" :class="{ locked: !hasPets }">
|
||||||
|
<h2 class="section-title">
|
||||||
|
✨ Mes pets
|
||||||
|
<span v-if="!hasPets" class="lock-badge">Achetez un Pet dans le shop</span>
|
||||||
|
</h2>
|
||||||
|
<template v-if="hasPets">
|
||||||
|
<div class="pet-grid">
|
||||||
|
<button
|
||||||
|
v-for="pet in ownedPets"
|
||||||
|
:key="pet.char"
|
||||||
|
class="pet-cell"
|
||||||
|
:class="{ 'pet-cell--active': activePet === pet.char }"
|
||||||
|
@click="togglePet(pet.char)"
|
||||||
|
type="button"
|
||||||
|
>{{ pet.char }}</button>
|
||||||
|
<button
|
||||||
|
class="pet-cell pet-cell--none"
|
||||||
|
:class="{ 'pet-cell--active': activePet === '' }"
|
||||||
|
@click="togglePet('')"
|
||||||
|
type="button"
|
||||||
|
>✕ Aucun</button>
|
||||||
|
</div>
|
||||||
|
<p class="section-sub" style="margin-top:6px">
|
||||||
|
Actif : <strong>{{ activePet || 'aucun' }}</strong>
|
||||||
|
— s'affiche à gauche de ton IP dans le chat.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="section-sub">Aucun pet possédé pour l'instant.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useCustomStyles, SEND_BUTTON_PRESETS, IP_COLOR_OPTIONS, type SendButtonKey } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor';
|
||||||
|
import { useWallet } from '@/composables/useWallet';
|
||||||
|
|
||||||
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
const { ip: myIp } = useWallet();
|
||||||
|
|
||||||
|
// ── Background ──────────────────────────────────────────────────────────────
|
||||||
|
const bgDraft = ref(prefs.chatBgUrl);
|
||||||
|
watch(() => prefs.chatBgUrl, (v) => { bgDraft.value = v; });
|
||||||
|
|
||||||
|
function applyBg(): void { prefs.chatBgUrl = bgDraft.value.trim(); }
|
||||||
|
function resetBg(): void { prefs.chatBgUrl = ''; bgDraft.value = ''; }
|
||||||
|
|
||||||
|
// ── IP color ────────────────────────────────────────────────────────────────
|
||||||
|
const currentIpColor = computed(() => prefs.ipColors[myIp.value] ?? 'auto');
|
||||||
|
|
||||||
|
function setIpColor(value: string): void {
|
||||||
|
if (!myIp.value) return;
|
||||||
|
prefs.ipColors[myIp.value] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipPreviewStyle = computed(() => {
|
||||||
|
if (!myIp.value) return {};
|
||||||
|
const color = currentIpColor.value === 'auto'
|
||||||
|
? getIpColorWithPerks(myIp.value, myPerks.value)
|
||||||
|
: currentIpColor.value;
|
||||||
|
return { color, textShadow: getIpGlow(color) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Pets ────────────────────────────────────────────────────────────────────
|
||||||
|
const ownedPets = computed(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (myPerks.value.pets ?? []).filter((p) => {
|
||||||
|
if (seen.has(p.char)) return false;
|
||||||
|
seen.add(p.char);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const hasPets = computed(() => ownedPets.value.length > 0);
|
||||||
|
const activePet = computed(() =>
|
||||||
|
myIp.value && myIp.value in prefs.ipPets ? prefs.ipPets[myIp.value] : (ownedPets.value[0]?.char ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
function togglePet(char: string): void {
|
||||||
|
if (!myIp.value) return;
|
||||||
|
prefs.ipPets[myIp.value] = char;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.persos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 4px 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #101018;
|
||||||
|
border: 1px solid #20203a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.section.locked {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ccccee;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.section-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #5a5a80;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #886644;
|
||||||
|
background: #1a1408;
|
||||||
|
border: 1px solid #44330066;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background */
|
||||||
|
.bg-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.bg-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #141420;
|
||||||
|
border: 1px solid #222234;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #aaaacc;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.bg-input:focus { border-color: #333355; }
|
||||||
|
.btn-apply {
|
||||||
|
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
|
||||||
|
font-size: 12px; font-weight: bold; padding: 7px 14px; border-radius: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-apply:hover { background: #234a23; }
|
||||||
|
.btn-reset {
|
||||||
|
background: #2a1010; border: 1px solid #882222; color: #ff6655;
|
||||||
|
font-size: 11px; padding: 7px 12px; border-radius: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-reset:hover { background: #3a1818; }
|
||||||
|
|
||||||
|
.bg-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #222234;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style tiles */
|
||||||
|
.style-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.style-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #141420;
|
||||||
|
border: 1px solid #222234;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.style-tile:hover:not(:disabled) { background: #1a1a2e; border-color: #333355; }
|
||||||
|
.style-tile--active { border-color: #00ddff; background: #0a1a20; }
|
||||||
|
.style-tile:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||||
|
|
||||||
|
.style-swatch {
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: inherit;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px; font-weight: bold;
|
||||||
|
border: 1px solid #ffffff10;
|
||||||
|
}
|
||||||
|
.style-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8888aa;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.style-tile--active .style-label { color: #00ddff; }
|
||||||
|
|
||||||
|
/* Color dots */
|
||||||
|
.color-dot {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #ffffff22;
|
||||||
|
}
|
||||||
|
.color-dot--auto {
|
||||||
|
background: conic-gradient(#00ddff, #ff00cc, #00ee77, #ffdd44, #00ddff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IP code */
|
||||||
|
.code-ip {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pet grid */
|
||||||
|
.pet-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.pet-cell {
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
background: #141420;
|
||||||
|
border: 1px solid #222234;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s, background 0.1s;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.pet-cell:hover { background: #1a1a2e; border-color: #333355; }
|
||||||
|
.pet-cell--active { border-color: #00ddff; background: #0a1a20; }
|
||||||
|
.pet-cell--none { font-size: 11px; color: #666; width: auto; padding: 0 10px; }
|
||||||
|
.pet-cell--none.pet-cell--active { color: #00ddff; }
|
||||||
|
</style>
|
||||||
@@ -81,7 +81,15 @@
|
|||||||
<span class="price-now">{{ fmt(effectivePrice) }}</span>
|
<span class="price-now">{{ fmt(effectivePrice) }}</span>
|
||||||
<span class="price-unit">cr</span>
|
<span class="price-unit">cr</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Pets: redirige vers Mes Persos au lieu d'acheter -->
|
||||||
<button
|
<button
|
||||||
|
v-if="product.kind === 'pet'"
|
||||||
|
class="buy buy--perso"
|
||||||
|
@click="$emit('goPerso')"
|
||||||
|
type="button"
|
||||||
|
>✨ Mes Persos</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
class="buy"
|
class="buy"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="onBuy"
|
@click="onBuy"
|
||||||
@@ -103,7 +111,10 @@ const props = defineProps<{
|
|||||||
freeMode: boolean;
|
freeMode: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ buy: [productId: string, options: PurchaseOptions] }>();
|
const emit = defineEmits<{
|
||||||
|
buy: [productId: string, options: PurchaseOptions];
|
||||||
|
goPerso: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
const meta = computed<any>(() => {
|
const meta = computed<any>(() => {
|
||||||
try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; }
|
try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; }
|
||||||
@@ -293,4 +304,11 @@ function onBuy(): void {
|
|||||||
}
|
}
|
||||||
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; }
|
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; }
|
||||||
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
|
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
|
||||||
|
|
||||||
|
.buy--perso {
|
||||||
|
background: #1a1030; border: 1px solid #8844cc; color: #cc88ff;
|
||||||
|
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.buy--perso:hover { background: #261844; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ export interface CustomStylePrefs {
|
|||||||
adFrame: AdFrameKey;
|
adFrame: AdFrameKey;
|
||||||
ipColors: Record<string, string>; // ip → hex or 'auto'
|
ipColors: Record<string, string>; // ip → hex or 'auto'
|
||||||
ipPets: Record<string, string>; // ip → emoji or ''
|
ipPets: Record<string, string>; // ip → emoji or ''
|
||||||
|
chatBgUrl: string; // URL or '' for no background
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaults(): CustomStylePrefs {
|
function defaults(): CustomStylePrefs {
|
||||||
return { sendButton: 'default', adFrame: 'default', ipColors: {}, ipPets: {} };
|
return { sendButton: 'default', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(): CustomStylePrefs {
|
function load(): CustomStylePrefs {
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import { setPerks, applyPerksFrame, type Perks } from './usePerks';
|
|||||||
import { bumpAdsRevision } from './useAds';
|
import { bumpAdsRevision } from './useAds';
|
||||||
import { handleAlertFrame } from './useAlert';
|
import { handleAlertFrame } from './useAlert';
|
||||||
|
|
||||||
|
// Module-level singleton so any component can read the viewer's own perks
|
||||||
|
// without prop-drilling (e.g. SendButton, AdBand).
|
||||||
|
export const myPerks = ref<Perks>({});
|
||||||
|
|
||||||
|
export function useMyPerks() {
|
||||||
|
return { myPerks };
|
||||||
|
}
|
||||||
|
|
||||||
export interface Reply {
|
export interface Reply {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -91,7 +99,7 @@ export function useMessages() {
|
|||||||
const { fetchWallet, ip: myIp } = useWallet();
|
const { fetchWallet, ip: myIp } = useWallet();
|
||||||
|
|
||||||
// The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.).
|
// The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.).
|
||||||
const myPerks = ref<Perks>({});
|
// myPerks is module-level; this ref is the same reference.
|
||||||
|
|
||||||
async function fetchMyPerks(): Promise<void> {
|
async function fetchMyPerks(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -193,7 +201,8 @@ export function useMessages() {
|
|||||||
stats,
|
stats,
|
||||||
connected,
|
connected,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
myPerks,
|
get myPerks() { return myPerks; },
|
||||||
|
myIp,
|
||||||
fetchMyPerks,
|
fetchMyPerks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
<div class="xip-root">
|
<div class="xip-root">
|
||||||
<!-- Zone chat centrale -->
|
<!-- Zone chat centrale -->
|
||||||
<div class="xip-center">
|
<div class="xip-center" :style="chatBgStyle">
|
||||||
<ChatHeader :connected-count="stats?.connectedTabs ?? 0" />
|
<ChatHeader :connected-count="stats?.connectedTabs ?? 0" />
|
||||||
<MessageList :messages="messages" :hide-ads="!!myPerks.noads" @reply="startReply" />
|
<MessageList :messages="messages" :hide-ads="!!myPerks.noads" :my-ip="myIp" @reply="startReply" />
|
||||||
|
|
||||||
<!-- Bannière de réponse -->
|
<!-- Bannière de réponse -->
|
||||||
<div v-if="replyingTo" class="reply-banner">
|
<div v-if="replyingTo" class="reply-banner">
|
||||||
@@ -94,10 +94,22 @@ import StatsTicker from '@/components/StatsTicker.vue';
|
|||||||
import { useMessages } from '@/composables/useMessages';
|
import { useMessages } from '@/composables/useMessages';
|
||||||
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';
|
||||||
|
|
||||||
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks } = useMessages();
|
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks, myIp } = useMessages();
|
||||||
const { uploadFile, kb } = useAttachments();
|
const { uploadFile, kb } = useAttachments();
|
||||||
const { fireAlert } = useAlert();
|
const { fireAlert } = useAlert();
|
||||||
|
const { prefs: stylePrefs } = useCustomStyles();
|
||||||
|
|
||||||
|
const chatBgStyle = computed(() => {
|
||||||
|
if (!stylePrefs.chatBgUrl) return {};
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${stylePrefs.chatBgUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const draft = ref('');
|
const draft = ref('');
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
<div v-if="lastError" class="toast toast--err">{{ lastError }}</div>
|
<div v-if="lastError" class="toast toast--err">{{ lastError }}</div>
|
||||||
<div v-else-if="lastSuccess" class="toast toast--ok">✓ Achat effectué</div>
|
<div v-else-if="lastSuccess" class="toast toast--ok">✓ Achat effectué</div>
|
||||||
|
|
||||||
|
<!-- Mes Persos panel -->
|
||||||
|
<MesPersos v-if="activeCat === 'perso'" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<ProductCard
|
<ProductCard
|
||||||
v-for="p in visibleProducts"
|
v-for="p in visibleProducts"
|
||||||
@@ -57,9 +61,11 @@
|
|||||||
:pet-count="petCount()"
|
:pet-count="petCount()"
|
||||||
:free-mode="freeMode"
|
:free-mode="freeMode"
|
||||||
@buy="onBuy"
|
@buy="onBuy"
|
||||||
|
@go-perso="activeCat = 'perso'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="visibleProducts.length === 0" class="empty">Aucun produit dans cette catégorie.</p>
|
<p v-if="visibleProducts.length === 0" class="empty">Aucun produit dans cette catégorie.</p>
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +76,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|||||||
import { useShop, type PurchaseOptions } from '@/composables/useShop';
|
import { useShop, type PurchaseOptions } from '@/composables/useShop';
|
||||||
import { useWallet } from '@/composables/useWallet';
|
import { useWallet } from '@/composables/useWallet';
|
||||||
import ProductCard from '@/components/shop/ProductCard.vue';
|
import ProductCard from '@/components/shop/ProductCard.vue';
|
||||||
|
import MesPersos from '@/components/shop/MesPersos.vue';
|
||||||
|
|
||||||
const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, purchase } = useShop();
|
const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, purchase } = useShop();
|
||||||
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
|
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
|
||||||
@@ -81,6 +88,7 @@ const categories = [
|
|||||||
{ id: 'cosmetiques', label: 'Cosmétiques' },
|
{ id: 'cosmetiques', label: 'Cosmétiques' },
|
||||||
{ id: 'premium', label: 'Premium' },
|
{ id: 'premium', label: 'Premium' },
|
||||||
{ id: 'promotions', label: 'Promotions' },
|
{ id: 'promotions', label: 'Promotions' },
|
||||||
|
{ id: 'perso', label: '✨ Mes Persos' },
|
||||||
];
|
];
|
||||||
const activeCat = ref('all');
|
const activeCat = ref('all');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user