feat: implement right-click context menu for style customization and enhance real-time stats tracking
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<StyleContextMenu />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StyleContextMenu from '@/components/StyleContextMenu.vue';
|
||||
</script>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div class="ad-header" :class="`ad-header--${ad.tone}`">
|
||||
<p class="ad-brand" :class="`ad-brand--${ad.tone}`">{{ ad.brand }}</p>
|
||||
@@ -26,16 +29,35 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useAds } from '@/composables/useAds';
|
||||
import { openContextMenu } from '@/composables/useContextMenu';
|
||||
import { useCustomStyles, AD_FRAME_PRESETS } from '@/composables/useCustomStyles';
|
||||
|
||||
const { ads, fetchAds, reportImpression } = useAds('band');
|
||||
const { prefs } = useCustomStyles();
|
||||
|
||||
const cardStyle = computed(() => {
|
||||
const p = AD_FRAME_PRESETS[prefs.adFrame];
|
||||
return { border: p.border, background: p.bg };
|
||||
});
|
||||
|
||||
function onRightClick(e: MouseEvent): void {
|
||||
e.stopPropagation();
|
||||
openContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
title: 'Cadre pub',
|
||||
items: Object.entries(AD_FRAME_PRESETS).map(([k, v]) => ({ value: k, label: v.label })),
|
||||
current: prefs.adFrame,
|
||||
onSelect: (v) => { prefs.adFrame = v as typeof prefs.adFrame; },
|
||||
});
|
||||
}
|
||||
|
||||
function prettyUrl(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Report one impression per ad each time the set (re)loads.
|
||||
watch(ads, (list) => {
|
||||
for (const a of list) reportImpression(a.id);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="message-item">
|
||||
<!-- Auteur + horodatage -->
|
||||
<div class="message-meta">
|
||||
<span class="ip-wrap">
|
||||
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" title="Clic droit pour personnaliser">
|
||||
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
|
||||
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
|
||||
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
|
||||
@@ -30,7 +30,7 @@
|
||||
:key="reply.id"
|
||||
class="reply"
|
||||
>
|
||||
<span class="ip-wrap">
|
||||
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" title="Clic droit pour personnaliser">
|
||||
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
|
||||
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
|
||||
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
|
||||
@@ -52,8 +52,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Message, Reply } from '@/composables/useMessages';
|
||||
import { getIpColorWithPerks, getIpGlowWithPerks } from '@/composables/ipColor';
|
||||
import { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
|
||||
import { usePerks } from '@/composables/usePerks';
|
||||
import { openContextMenu } from '@/composables/useContextMenu';
|
||||
import { useCustomStyles, IP_COLOR_OPTIONS, PET_OPTIONS } from '@/composables/useCustomStyles';
|
||||
import RichContent from './RichContent.vue';
|
||||
import MessageAttachments from './MessageAttachments.vue';
|
||||
|
||||
@@ -61,21 +63,28 @@ defineProps<{ message: Message }>();
|
||||
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
|
||||
|
||||
const { perksFor } = usePerks();
|
||||
const { prefs } = useCustomStyles();
|
||||
|
||||
/** Perks for an author: prefer the perks embedded in the payload, else the store. */
|
||||
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(m.authorIp, p),
|
||||
textShadow: getIpGlowWithPerks(m.authorIp, p),
|
||||
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')
|
||||
@@ -83,6 +92,8 @@ function petsLeft(m: Reply): string {
|
||||
.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')
|
||||
@@ -90,6 +101,36 @@ function petsRight(m: Reply): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
function openIpMenu(e: MouseEvent, ip: string): void {
|
||||
const currentColor = prefs.ipColors[ip] ?? 'auto';
|
||||
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
|
||||
openContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
title: ip,
|
||||
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}`,
|
||||
onSelect: (v) => {
|
||||
if (v.startsWith('color:')) {
|
||||
prefs.ipColors[ip] = v.slice(6);
|
||||
} else if (v.startsWith('pet:')) {
|
||||
const pet = v.slice(4);
|
||||
if (pet === '__inherit__') {
|
||||
delete prefs.ipPets[ip];
|
||||
} else {
|
||||
prefs.ipPets[ip] = pet;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function fmt(date: string): string {
|
||||
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<!-- Bouton d'envoi circulaire avec flèche cyan -->
|
||||
<!-- Bouton d'envoi — clic gauche : envoyer / clic droit : personnaliser le style -->
|
||||
<template>
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="disabled"
|
||||
:style="btnStyle"
|
||||
aria-label="Envoyer"
|
||||
title="Clic droit pour personnaliser"
|
||||
@click="$emit('send')"
|
||||
@contextmenu.prevent="onRightClick"
|
||||
>
|
||||
<!-- 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>
|
||||
@@ -14,38 +16,49 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { openContextMenu } from '@/composables/useContextMenu';
|
||||
import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles';
|
||||
|
||||
defineProps<{ disabled?: boolean }>();
|
||||
defineEmits<{ send: [] }>();
|
||||
|
||||
const { prefs } = useCustomStyles();
|
||||
|
||||
const btnStyle = computed(() => {
|
||||
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
|
||||
return { background: p.bg, color: p.color, borderRadius: p.radius };
|
||||
});
|
||||
|
||||
function onRightClick(e: MouseEvent): void {
|
||||
openContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
title: 'Bouton d\'envoi',
|
||||
items: Object.entries(SEND_BUTTON_PRESETS).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.label,
|
||||
swatch: v.color,
|
||||
})),
|
||||
current: prefs.sendButton,
|
||||
onSelect: (v) => { prefs.sendButton = v as typeof prefs.sendButton; },
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.send-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: #004488;
|
||||
border: 1px solid #004466;
|
||||
color: #00ddff;
|
||||
border: 1px solid #ffffff10;
|
||||
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;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.send-btn:hover:not(:disabled) { filter: brightness(1.3); }
|
||||
.send-btn:active:not(:disabled) { filter: brightness(0.85); }
|
||||
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
130
frontend/src/components/StyleContextMenu.vue
Normal file
130
frontend/src/components/StyleContextMenu.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<!-- Generic right-click style picker. Mounted once in App.vue via Teleport. -->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="state.visible"
|
||||
ref="menuEl"
|
||||
class="style-ctx-menu"
|
||||
:style="menuPos"
|
||||
@click.stop
|
||||
>
|
||||
<div class="ctx-title">{{ state.title }}</div>
|
||||
<template v-for="item in state.items" :key="item.value">
|
||||
<div v-if="item.isHeader" class="ctx-header">{{ item.label }}</div>
|
||||
<button
|
||||
v-else
|
||||
class="ctx-item"
|
||||
:class="{ 'ctx-item--active': item.value === state.current }"
|
||||
@click="pick(item.value)"
|
||||
>
|
||||
<span v-if="item.swatch" class="ctx-swatch" :style="{ background: item.swatch }" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useContextMenu, closeContextMenu } from '@/composables/useContextMenu';
|
||||
|
||||
const { state } = useContextMenu();
|
||||
const menuEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const menuPos = computed(() => ({
|
||||
top: `${Math.min(state.y, window.innerHeight - 260)}px`,
|
||||
left: `${Math.min(state.x, window.innerWidth - 175)}px`,
|
||||
}));
|
||||
|
||||
function pick(value: string): void {
|
||||
state.onSelect(value);
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent): void {
|
||||
if (state.visible && menuEl.value && !menuEl.value.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') closeContextMenu();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.style-ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
min-width: 160px;
|
||||
background: #111118;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px #000a, 0 0 0 1px #ffffff08;
|
||||
padding: 4px 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.ctx-title {
|
||||
font-size: 10px;
|
||||
color: #44445a;
|
||||
padding: 4px 12px 3px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #1e1e2a;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ctx-header {
|
||||
font-size: 9px;
|
||||
color: #33334a;
|
||||
padding: 6px 12px 2px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9999bb;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.ctx-item:hover {
|
||||
background: #1a1a28;
|
||||
color: #ffffff;
|
||||
}
|
||||
.ctx-item--active {
|
||||
color: #00ddff;
|
||||
}
|
||||
.ctx-item--active::after {
|
||||
content: '✓';
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ctx-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #ffffff22;
|
||||
}
|
||||
</style>
|
||||
58
frontend/src/composables/useContextMenu.ts
Normal file
58
frontend/src/composables/useContextMenu.ts
Normal file
@@ -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<MenuState>({
|
||||
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 };
|
||||
}
|
||||
78
frontend/src/composables/useCustomStyles.ts
Normal file
78
frontend/src/composables/useCustomStyles.ts
Normal file
@@ -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<string, string>; // ip → hex or 'auto'
|
||||
ipPets: Record<string, string>; // 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<CustomStylePrefs>(load());
|
||||
|
||||
watch(prefs, (v) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(v));
|
||||
}, { deep: true });
|
||||
|
||||
export function useCustomStyles() {
|
||||
return { prefs };
|
||||
}
|
||||
@@ -4,9 +4,6 @@
|
||||
<StatsTicker :stats="stats" :connected="connected" />
|
||||
|
||||
<div class="xip-root">
|
||||
<!-- Bande pub gauche — masquée si l'utilisateur a NoAds -->
|
||||
<AdBand v-if="!myPerks.noads" />
|
||||
|
||||
<!-- Zone chat centrale -->
|
||||
<div class="xip-center">
|
||||
<ChatHeader :connected-count="stats?.connectedTabs ?? 0" />
|
||||
@@ -90,7 +87,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } 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';
|
||||
|
||||
Reference in New Issue
Block a user