Files
BetiX/resources/js/components/chat/GlobalChat.vue
Dolo 0280278978
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Initialer Laravel Commit für BetiX
2026-04-04 18:01:50 +02:00

1558 lines
73 KiB
Vue

<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue';
import { csrfFetch } from '@/utils/csrfFetch';
import {
Flag, X, AlertCircle, UserX, ShieldAlert, BadgeDollarSign, HelpCircle,
MessageSquareOff, CheckCircle2, Loader2, ChevronRight, Smile, ImagePlay,
Reply, Trash2
} from 'lucide-vue-next';
import { Picker } from 'emoji-mart';
import data from '@emoji-mart/data';
const GIPHY_KEY = 'GF7y5viqpCeUeJdzoj5U9PY5XkFpswmC';
// v-model:open support
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
(e: 'update:unread', n: number): void;
}>();
// ── Popup mode (persisted in localStorage) ───────────────────
const chatMode = ref<'drawer' | 'popup'>(
(localStorage.getItem('chatMode') as 'drawer' | 'popup') || 'drawer'
);
function toggleChatMode() {
chatMode.value = chatMode.value === 'drawer' ? 'popup' : 'drawer';
localStorage.setItem('chatMode', chatMode.value);
}
// ── Drag for popup mode ───────────────────────────────────────
const popupPos = ref({ right: 20, bottom: 80 });
let dragging = false;
let dragStart = { x: 0, y: 0, right: 0, bottom: 0 };
function onPopupDragStart(e: MouseEvent) {
if (chatMode.value !== 'popup') return;
dragging = true;
dragStart = { x: e.clientX, y: e.clientY, right: popupPos.value.right, bottom: popupPos.value.bottom };
window.addEventListener('mousemove', onPopupDragMove);
window.addEventListener('mouseup', onPopupDragEnd);
}
function onPopupDragMove(e: MouseEvent) {
if (!dragging) return;
const dx = dragStart.x - e.clientX;
const dy = dragStart.y - e.clientY;
popupPos.value = {
right: Math.max(0, Math.min(window.innerWidth - 380, dragStart.right + dx)),
bottom: Math.max(0, Math.min(window.innerHeight - 60, dragStart.bottom + dy)),
};
}
function onPopupDragEnd() {
dragging = false;
window.removeEventListener('mousemove', onPopupDragMove);
window.removeEventListener('mouseup', onPopupDragEnd);
}
// ── Unread badge (persisted) ──────────────────────────────────
const UNREAD_KEY = 'chatUnread';
const unreadCount = ref<number>(parseInt(localStorage.getItem(UNREAD_KEY) || '0', 10) || 0);
function resetUnread() {
unreadCount.value = 0;
localStorage.setItem(UNREAD_KEY, '0');
emit('update:unread', 0);
}
const page = usePage();
const me = computed(() => (page.props.auth as any)?.user || null);
const isAdmin = computed(() => {
const role = me.value?.role?.toLowerCase();
return role === 'admin' || role === 'moderator' || role === 'mod';
});
function openProfile(user: any) {
if (!user?.username) return;
window.open(`/profile/${user.username}`, '_blank');
}
async function deleteMessage(msg: any) {
if (!isAdmin.value) return;
try {
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '';
const res = await fetch(`/api/chat/${msg.id}`, {
method: 'DELETE',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf },
});
if (res.ok) {
const j = await res.json();
if (j?.data) mergeMessageUpdate(j.data);
}
} catch { /* silent */ }
}
// Active chat ban detection (frontend gate)
const chatBan = computed<any | null>(() => {
// Depend on nowTs so this recomputes every second when open
void nowTs.value;
const u: any = me.value;
if (!u || !Array.isArray(u.restrictions)) return null;
const now = Date.now();
const found = u.restrictions.find((r: any) => {
if (!r || r.type !== 'chat_ban') return false;
const startsOk = !r.starts_at || new Date(r.starts_at).getTime() <= now;
const endsOk = !r.ends_at || new Date(r.ends_at).getTime() > now;
return (!!r.active || r.active === undefined) && startsOk && endsOk;
});
return found || null;
});
const chatBanned = computed<boolean>(() => !!chatBan.value);
const isOpen = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
const messages = ref<any[]>([]);
const input = ref('');
const loading = ref(false);
const sending = ref(false);
const lastId = ref<number | null>(null);
let pollTimer: number | undefined;
let pollFailCount = 0;
const POLL_BASE_MS = 5_000;
const POLL_MAX_MS = 60_000;
// Countdown ticker for bans
const nowTs = ref<number>(Date.now());
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let nowTimer: number | undefined;
function startNowTicker() {
stopNowTicker();
nowTimer = window.setInterval(() => { nowTs.value = Date.now(); }, 1000);
}
function stopNowTicker() {
if (nowTimer) window.clearInterval(nowTimer);
nowTimer = undefined;
}
// Countdown helpers for chat ban
const banEndsAt = computed<Date | null>(() => {
const e = (chatBan.value as any)?.ends_at;
if (!e) return null;
const d = new Date(e);
return isNaN(d.getTime()) ? null : d;
});
const remainingMs = computed<number>(() => {
if (!banEndsAt.value) return 0;
return Math.max(0, banEndsAt.value.getTime() - nowTs.value);
});
function formatCountdown(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
if (days > 0) return `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
const countdownText = computed<string | null>(() => {
if (!banEndsAt.value || remainingMs.value <= 0) return null;
return formatCountdown(remainingMs.value);
});
const exactEndText = computed<string | null>(() => {
if (!banEndsAt.value) return null;
try {
return banEndsAt.value.toLocaleString();
} catch {
return banEndsAt.value.toISOString();
}
});
const emojiOpen = ref(false);
const emojiPickerEl = ref<HTMLElement | null>(null);
let pickerInstance: any = null;
const giphyOpen = ref(false);
const giphyQuery = ref('');
const giphyResults = ref<any[]>([]);
const giphyLoading = ref(false);
let giphyTimer: ReturnType<typeof setTimeout> | null = null;
function toggleEmoji() {
giphyOpen.value = false;
emojiOpen.value = !emojiOpen.value;
}
function toggleGiphy() {
emojiOpen.value = false;
giphyOpen.value = !giphyOpen.value;
if (giphyOpen.value && !giphyResults.value.length) searchGiphy('casino');
}
function closeToolPanels() {
emojiOpen.value = false;
giphyOpen.value = false;
}
async function searchGiphy(q?: string) {
const query = q ?? giphyQuery.value;
if (!query.trim()) return;
if (giphyTimer) clearTimeout(giphyTimer);
giphyTimer = setTimeout(async () => {
giphyLoading.value = true;
try {
const res = await fetch(
`https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_KEY}&q=${encodeURIComponent(query)}&limit=18&rating=pg-13`
);
const j = await res.json();
giphyResults.value = j.data ?? [];
} catch { /* silent */ } finally {
giphyLoading.value = false;
}
}, 400);
}
function onGiphyInput() {
searchGiphy(giphyQuery.value);
}
async function sendGif(url: string) {
if (sending.value || chatBanned.value) return;
giphyOpen.value = false;
sending.value = true;
try {
const res = await csrfFetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `[GIF:${url}]` }),
});
if (!res.ok) return;
const j = await res.json();
if (j?.data) {
messages.value.push(j.data);
lastId.value = j.data.id;
await nextTick();
scrollToBottom();
}
} catch { /* silent */ } finally {
sending.value = false;
}
}
function isGif(msg: string) {
return typeof msg === 'string' && msg.startsWith('[GIF:') && msg.endsWith(']');
}
function gifUrl(msg: string) {
return msg.slice(5, -1);
}
// Reduced list for quick bar to prevent overflow on mobile
const reactionEmojis = ['👍','😂','🔥','😍','🎉','💎'];
// Reply state
const replyTo = ref<any>(null);
// Report modal state
const reportModal = ref<{ msg: any } | null>(null);
const reportReason = ref('');
const reportSubmitting = ref(false);
const reportDone = ref(false);
const reportReasons = [
{ value: 'spam', label: 'Spam', desc: 'Unerwünschte Werbung oder Wiederholungen', icon: MessageSquareOff, color: '#f59e0b' },
{ value: 'harassment', label: 'Belästigung', desc: 'Belästigendes oder feindseliges Verhalten', icon: UserX, color: '#ef4444' },
{ value: 'offensive', label: 'Beleidigung', desc: 'Beleidigende oder diskriminierende Inhalte', icon: ShieldAlert, color: '#f97316' },
{ value: 'scam', label: 'Betrug / Scam', desc: 'Betrügerische Links oder Forderungen', icon: BadgeDollarSign, color: '#a855f7' },
{ value: 'other', label: 'Sonstiges', desc: 'Anderer Verstoß gegen die Nutzungsregeln', icon: HelpCircle, color: '#6b7280' },
];
function openReportModal(msg: any) {
reportModal.value = { msg };
reportReason.value = '';
reportDone.value = false;
}
function closeReportModal() {
reportModal.value = null;
}
async function submitReport() {
if (!reportReason.value || reportSubmitting.value) return;
const msg = reportModal.value?.msg;
if (!msg) return;
const msgIndex = messages.value.findIndex((m: any) => m.id === msg.id);
const contextStart = Math.max(0, msgIndex - 10);
const contextMessages = messages.value.slice(contextStart, msgIndex).map((m: any) => ({
id: m.id,
message: m.message,
user: { id: m.user?.id, username: m.user?.username },
created_at: m.created_at,
}));
reportSubmitting.value = true;
try {
const csrf = (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '';
const res = await fetch(`/api/chat/${msg.id}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf,
},
body: JSON.stringify({
message_text: msg.message,
sender_id: msg.user?.id ?? null,
sender_username: msg.user?.username ?? null,
context_messages: contextMessages,
reason: reportReason.value,
}),
});
if (res.ok) {
reportDone.value = true;
setTimeout(closeReportModal, 1800);
}
} catch {
// silent
} finally {
reportSubmitting.value = false;
}
}
function mergeMessageUpdate(updated: any) {
const idx = messages.value.findIndex(x => x.id === updated.id);
if (idx >= 0) {
// shallow merge to keep reactivity
messages.value[idx] = { ...messages.value[idx], ...updated };
} else {
messages.value.push(updated);
}
}
async function fetchInitial() {
loading.value = true;
try {
const res = await fetch('/api/chat?limit=50', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!res.ok) throw new Error('Failed to load chat');
const data = await res.json();
messages.value = data.data || [];
lastId.value = data.last_id ?? (messages.value.length ? messages.value[messages.value.length - 1].id : null);
await nextTick();
if ((window as any).lucide) (window as any).lucide.createIcons();
scrollToBottom();
} finally {
loading.value = false;
}
}
async function fetchAfter() {
if (lastId.value == null) return;
try {
const res = await fetch(`/api/chat?after_id=${lastId.value}&limit=100`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!res.ok) {
pollFailCount++;
return;
}
pollFailCount = 0;
const data = await res.json();
const batch = data.data || [];
if (batch.length) {
const newCount = batch.filter((m: any) => m.user_id !== me.value?.id).length;
for (const msg of batch) {
mergeMessageUpdate(msg);
}
lastId.value = data.last_id ?? lastId.value;
if (!isOpen.value && chatMode.value === 'drawer' && newCount > 0) {
unreadCount.value = Math.min(99, unreadCount.value + newCount);
localStorage.setItem(UNREAD_KEY, String(unreadCount.value));
emit('update:unread', unreadCount.value);
}
await nextTick();
if ((window as any).lucide) (window as any).lucide.createIcons();
scrollToBottomIfNearEnd();
}
} catch {
pollFailCount++;
}
}
function scheduleNextPoll() {
stopPolling();
// Exponential backoff: 5s → 10s → 20s → … → max 60s
const delay = Math.min(POLL_BASE_MS * Math.pow(2, pollFailCount), POLL_MAX_MS);
pollTimer = window.setTimeout(async () => {
await fetchAfter();
if (pollTimer !== undefined) scheduleNextPoll();
}, delay);
}
function startPolling() {
pollFailCount = 0;
scheduleNextPoll();
}
function stopPolling() {
if (pollTimer) window.clearTimeout(pollTimer);
pollTimer = undefined;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen.value = false;
}
}
function getListEl() {
return document.getElementById(chatMode.value === 'popup' ? 'gc-list-popup' : 'gc-list');
}
function scrollToBottom() {
const el = getListEl();
if (el) el.scrollTop = el.scrollHeight;
}
function scrollToBottomIfNearEnd() {
const el = getListEl();
if (!el) return;
const nearEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
if (nearEnd) scrollToBottom();
}
async function send() {
if (chatBanned.value) return;
const text = input.value.trim();
if (!text || sending.value) return;
sending.value = true;
try {
const payload: any = { message: text };
if (replyTo.value) {
payload.reply_to_id = replyTo.value.id;
}
const res = await csrfFetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.message || 'Failed to send');
}
const j = await res.json();
if (j?.data) {
messages.value.push(j.data);
lastId.value = j.data.id;
input.value = '';
replyTo.value = null; // Clear reply
await nextTick();
if ((window as any).lucide) (window as any).lucide.createIcons();
scrollToBottom();
}
} catch (e) {
console.warn(e);
} finally {
sending.value = false;
}
}
function insertEmoji(em: string) {
const el = document.getElementById('gc-input') as HTMLTextAreaElement | null;
const at = el?.selectionStart ?? input.value.length;
input.value = input.value.slice(0, at) + em + input.value.slice(at);
nextTick(() => {
if (el) {
el.focus();
const pos = at + em.length;
el.setSelectionRange(pos, pos);
}
});
}
async function toggleReaction(msg: any, emoji: string) {
// Optimistic update
const orig = JSON.parse(JSON.stringify(msg.reactions_agg || []));
const arr = Array.isArray(msg.reactions_agg) ? [...msg.reactions_agg] : [];
const idx = arr.findIndex((r: any) => r.emoji === emoji);
if (idx === -1) {
arr.push({ emoji, count: 1, reactedByMe: true });
} else {
const r = { ...arr[idx] };
if (r.reactedByMe) {
r.count -= 1;
r.reactedByMe = false;
if (r.count <= 0) { arr.splice(idx, 1); }
else { arr[idx] = r; }
} else {
r.count += 1;
r.reactedByMe = true;
arr[idx] = r;
}
}
mergeMessageUpdate({ ...msg, reactions_agg: arr });
try {
const res = await csrfFetch(`/api/chat/${msg.id}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ emoji }),
});
if (!res.ok) throw new Error('Failed');
const j = await res.json();
if (j?.data) mergeMessageUpdate(j.data);
} catch {
// Revert optimistic on error
mergeMessageUpdate({ ...msg, reactions_agg: orig });
}
}
function scrollToMessage(id?: number) {
if (!id) return;
const el = document.getElementById(`msg-${id}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 1200);
}
}
function setReply(msg: any) {
replyTo.value = msg;
nextTick(() => {
const el = document.getElementById('gc-input');
if (el) el.focus();
});
}
function reportMessage(msg: any) {
openReportModal(msg);
}
// Date separator helper
function showDateSeparator(index: number) {
if (index === 0) return true;
const curr = new Date(messages.value[index].created_at);
const prev = new Date(messages.value[index - 1].created_at);
return curr.toDateString() !== prev.toDateString();
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
watch(emojiOpen, async (open) => {
if (open) {
await nextTick();
if (emojiPickerEl.value) {
// Re-create if mounted in a different element (e.g. mode switched)
if (pickerInstance && !emojiPickerEl.value.children.length) {
pickerInstance = null;
}
if (!pickerInstance) {
pickerInstance = new Picker({
data,
theme: 'dark',
locale: 'de',
onEmojiSelect: (em: any) => {
insertEmoji(em.native);
emojiOpen.value = false;
},
parent: emojiPickerEl.value,
previewPosition: 'none',
skinTonePosition: 'none',
});
}
}
}
});
watch(isOpen, async (open) => {
if (open) {
resetUnread();
// Prevent body scroll when chat is open on mobile (drawer only)
if (chatMode.value === 'drawer') document.body.style.overflow = 'hidden';
startNowTicker();
// If user is chat-banned, do not fetch; just show ban panel
if (chatBanned.value) {
document.addEventListener('keydown', onKeydown);
return;
}
await nextTick();
if (messages.value.length === 0) await fetchInitial();
else scrollToBottom();
document.addEventListener('keydown', onKeydown);
} else {
if (chatMode.value === 'drawer') document.body.style.overflow = '';
stopNowTicker();
closeToolPanels();
document.removeEventListener('keydown', onKeydown);
// Keep polling running so the unread badge updates while chat is closed
}
});
onMounted(async () => {
if ((window as any).lucide) (window as any).lucide.createIcons();
// Restore persisted unread count to parent
if (unreadCount.value > 0) emit('update:unread', unreadCount.value);
// Fetch lastId on mount so polling can detect new messages even before chat opens
try {
const res = await fetch('/api/chat?limit=1', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (res.ok) {
const d = await res.json();
const items: any[] = d.data || [];
if (items.length) lastId.value = d.last_id ?? items[items.length - 1].id;
}
} catch {}
startPolling();
});
onBeforeUnmount(() => {
stopPolling();
stopNowTicker();
document.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
</script>
<template>
<!-- Report Modal -->
<teleport to="body">
<transition name="modal-fade">
<div v-if="reportModal" class="gc-report-overlay" @click.self="closeReportModal">
<div class="gc-report-modal" role="dialog" aria-modal="true" aria-labelledby="rm-title">
<!-- Success -->
<div v-if="reportDone" class="report-success">
<div class="success-ring">
<CheckCircle2 :size="36" />
</div>
<h3>Erfolgreich gemeldet</h3>
<p>Danke für deinen Hinweis.<br>Unser Moderationsteam wird den Fall prüfen.</p>
</div>
<template v-else>
<!-- Header -->
<div class="rm-head">
<div class="rm-head-left">
<div class="rm-head-icon">
<Flag :size="16" />
</div>
<span id="rm-title" class="rm-title">Nachricht melden</span>
</div>
<button class="rm-close" @click="closeReportModal" aria-label="Schließen">
<X :size="15" />
</button>
</div>
<!-- User card -->
<div class="rm-user">
<div class="rm-avatar-wrap">
<div class="rm-avatar">
<img v-if="reportModal.msg.user?.avatar_url" :src="reportModal.msg.user.avatar_url" :alt="reportModal.msg.user?.username" />
<div v-else class="rm-avatar-fallback">{{ (reportModal.msg.user?.username || '?')[0].toUpperCase() }}</div>
</div>
<div class="rm-avatar-glow"></div>
</div>
<div class="rm-user-info">
<span class="rm-username">@{{ reportModal.msg.user?.username || 'Unbekannt' }}</span>
<div class="rm-badges">
<span v-if="reportModal.msg.user?.role === 'admin'" class="rm-badge admin">ADMIN</span>
<span v-else-if="reportModal.msg.user?.role === 'staff'" class="rm-badge staff">STAFF</span>
<span v-else-if="reportModal.msg.user?.role === 'mod'" class="rm-badge mod">MOD</span>
<span v-else-if="reportModal.msg.user?.role === 'streamer'" class="rm-badge streamer">STREAMER</span>
<span v-else class="rm-badge user">USER</span>
<span v-if="reportModal.msg.user?.vip_level && reportModal.msg.user.vip_level > 0" class="rm-badge vip">
VIP {{ reportModal.msg.user.vip_level }}
</span>
<span v-if="reportModal.msg.user?.clan_tag" class="rm-badge clan">[{{ reportModal.msg.user.clan_tag }}]</span>
</div>
</div>
</div>
<!-- Reported message -->
<div class="rm-section">
<div class="rm-section-label">
<AlertCircle :size="12" />
Gemeldete Nachricht
</div>
<div class="rm-message-bubble">
<div class="rm-quote-mark">"</div>
<p class="rm-message-text">{{ reportModal.msg.message }}</p>
</div>
</div>
<!-- ── Reason grid ── -->
<div class="rm-section">
<div class="rm-section-label">
<Flag :size="12" />
Meldegrund auswählen
</div>
<div class="rm-reasons">
<button
v-for="r in reportReasons"
:key="r.value"
class="rm-reason-btn"
:class="{ selected: reportReason === r.value }"
:style="reportReason === r.value ? `--reason-color: ${r.color}` : ''"
@click="reportReason = r.value"
>
<div class="reason-icon-wrap" :style="`color: ${r.color}`">
<component :is="r.icon" :size="18" />
</div>
<div class="reason-text">
<span class="reason-label">{{ r.label }}</span>
<span class="reason-desc">{{ r.desc }}</span>
</div>
<div class="reason-check-wrap" v-if="reportReason === r.value">
<CheckCircle2 :size="16" />
</div>
<ChevronRight v-else :size="14" class="reason-chevron" />
</button>
</div>
</div>
<!-- ── Actions ── -->
<div class="rm-actions">
<button class="rm-btn-cancel" @click="closeReportModal">
<X :size="14" />
Abbrechen
</button>
<button
class="rm-btn-submit"
:disabled="!reportReason || reportSubmitting"
@click="submitReport"
>
<Loader2 v-if="reportSubmitting" :size="15" class="rm-spin" />
<Flag v-else :size="15" />
{{ reportSubmitting ? 'Wird gesendet…' : 'Jetzt melden' }}
</button>
</div>
</template>
</div>
</div>
</transition>
</teleport>
<!-- Popup mode -->
<transition name="pop-up">
<div
v-show="isOpen && chatMode === 'popup'"
class="gc-popup"
:style="{ right: popupPos.right + 'px', bottom: popupPos.bottom + 'px' }"
>
<header class="gc-head" @mousedown="onPopupDragStart" style="cursor:grab">
<div class="head-left">
<div class="online-dot"></div>
<span class="head-title">Global Chat</span>
</div>
<div style="display:flex;gap:4px;align-items:center">
<button class="mode-btn" title="Drawer-Modus" @click.stop="toggleChatMode">
<i data-lucide="panel-right"></i>
</button>
<button class="close-btn" title="Schließen" @click.stop="isOpen = false">
<i data-lucide="x"></i>
</button>
</div>
</header>
<!-- reuse same body via slot trick — just include it inline -->
<div v-if="chatBanned" class="gc-banned">
<div class="ban-card">
<div class="ban-stamp">BANNED</div>
<h2 class="ban-title">Chat Suspended</h2>
<p class="ban-reason" v-if="chatBan?.reason">Reason: {{ chatBan.reason }}</p>
<p class="ban-sub" v-if="chatBan?.ends_at">Until: {{ new Date(chatBan.ends_at).toLocaleString() }}</p>
</div>
</div>
<div v-else id="gc-list-popup" class="gc-list" style="flex:1;overflow-y:auto">
<div v-if="loading" class="loading-state"><div class="spinner"></div><span>Verbinde...</span></div>
<template v-else>
<div v-if="!messages.length" class="empty-state"><p>Noch keine Nachrichten.</p></div>
<transition-group name="msg-list">
<template v-for="(m, index) in messages" :key="m.id">
<div v-if="showDateSeparator(index)" class="date-separator"><span>{{ formatDate(m.created_at) }}</span></div>
<div class="msg-row" :id="`msg-p-${m.id}`" :class="{ 'msg-me': m.user_id === me?.id }">
<div class="msg-avatar">
<img v-if="m.user?.avatar_url" :src="m.user.avatar_url" />
<div v-else class="avatar-fallback">{{ (m.user?.username || 'U')[0].toUpperCase() }}</div>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-name">@{{ m.user?.username || 'user' }}</span>
<span class="msg-time">{{ new Date(m.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) }}</span>
</div>
<div class="msg-bubble-wrap">
<div class="msg-bubble">
<img v-if="isGif(m.message)" :src="gifUrl(m.message)" class="msg-gif" loading="lazy" />
<template v-else>{{ m.message }}</template>
</div>
</div>
</div>
</div>
</template>
</transition-group>
</template>
</div>
<footer v-if="!chatBanned" class="gc-footer">
<div class="compose-area" :class="{ disabled: !me }">
<div v-if="!me" class="login-prompt">Bitte <a href="/login">einloggen</a></div>
<template v-else>
<transition name="pop-up">
<div v-if="emojiOpen" class="emoji-picker-wrap"><div ref="emojiPickerEl"></div></div>
</transition>
<transition name="pop-up">
<div v-if="giphyOpen" class="giphy-panel">
<div class="giphy-search-row">
<input v-model="giphyQuery" @input="onGiphyInput" placeholder="GIF suchen..." class="giphy-input" />
</div>
<div class="giphy-grid">
<img v-for="g in giphyResults" :key="g.id" :src="g.images.fixed_height_small.url" class="giphy-thumb" loading="lazy" @click="sendGif(g.images.downsized.url)" />
</div>
</div>
</transition>
<div class="input-row">
<div class="tools">
<button class="tool-btn" :class="{ active: emojiOpen }" @click="toggleEmoji"><Smile :size="16" /></button>
<button class="tool-btn gif-btn" :class="{ active: giphyOpen }" @click="toggleGiphy"><ImagePlay :size="16" /></button>
</div>
<textarea id="gc-input-popup" class="chat-input" rows="1" placeholder="Nachricht..." v-model="input" @keydown.enter.exact.prevent="send" @keydown.enter.shift.exact.stop></textarea>
<button class="send-btn" :disabled="sending || !input.trim()" @click="send"><i data-lucide="send"></i></button>
</div>
</template>
</div>
</footer>
</div>
</transition>
<transition name="slide-fade">
<div v-show="isOpen && chatMode === 'drawer'" class="gc-overlay" role="dialog" aria-modal="true" aria-label="Global chat" @click.self="isOpen = false">
<aside class="gc-drawer">
<header class="gc-head">
<div class="head-left">
<div class="online-dot"></div>
<span class="head-title">Global Chat</span>
</div>
<div style="display:flex;gap:4px;align-items:center">
<button class="mode-btn" title="Popup-Modus" @click="toggleChatMode">
<i data-lucide="picture-in-picture-2"></i>
</button>
<button class="close-btn" title="Close" @click="isOpen = false">
<i data-lucide="x"></i>
</button>
</div>
</header>
<div v-if="chatBanned" class="gc-banned">
<div class="ban-card">
<div class="ban-stamp">BANNED</div>
<h2 class="ban-title">Chat Suspended</h2>
<p class="ban-reason" v-if="chatBan?.reason">Reason: {{ chatBan.reason }}</p>
<p class="ban-sub" v-if="chatBan?.ends_at">Until: {{ new Date(chatBan.ends_at).toLocaleString() }}</p>
<p class="ban-sub" v-else>Your account currently has a restriction that prevents using the global chat.</p>
</div>
</div>
<div v-else id="gc-list" class="gc-list">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<span>Connecting to server...</span>
</div>
<template v-else>
<div v-if="!messages.length" class="empty-state">
<i data-lucide="message-square" style="width: 40px; height: 40px; opacity: 0.3; margin-bottom: 10px;"></i>
<p>No messages yet.</p>
<small>Be the first to say hello! 👋</small>
</div>
<transition-group name="msg-list">
<template v-for="(m, index) in messages" :key="m.id">
<div v-if="showDateSeparator(index)" class="date-separator">
<span>{{ formatDate(m.created_at) }}</span>
</div>
<div class="msg-row" :id="`msg-${m.id}`" :class="{ 'msg-me': m.user_id === me?.id }">
<div class="msg-avatar">
<img v-if="m.user?.avatar_url" :src="m.user.avatar_url" alt="" />
<div v-else class="avatar-fallback">{{ (m.user?.username || m.user?.name || 'U')?.[0]?.toUpperCase?.() }}</div>
<!-- Level Badge (Absolute on Avatar) -->
<div class="level-badge" v-if="m.user?.vip_level && m.user.vip_level > 0">{{ m.user.vip_level }}</div>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-name msg-name-link" @click="openProfile(m.user)">@{{ m.user?.username || 'user' }}</span>
<!-- Role Badge -->
<span v-if="m.user?.role === 'admin'" class="badge badge-admin" title="Admin">ADMIN</span>
<span v-else-if="m.user?.role === 'staff'" class="badge badge-staff" title="Staff">STAFF</span>
<span v-else-if="m.user?.role === 'mod'" class="badge badge-mod" title="Moderator">MOD</span>
<span v-else-if="m.user?.role === 'streamer'" class="badge badge-streamer" title="Streamer">STREAMER</span>
<span v-else-if="m.user?.role === 'youtuber'" class="badge badge-youtuber" title="YouTuber">YOUTUBER</span>
<span v-else class="badge badge-user" title="User">USER</span>
<!-- VIP Badge -->
<span v-if="m.user?.vip_level && m.user.vip_level > 0" class="badge badge-vip" :title="`VIP Level ${m.user.vip_level}`">VIP {{ m.user.vip_level }}</span>
<!-- Clan tag -->
<span v-if="m.user?.clan_tag" class="badge badge-clan" :title="`Clan ${m.user.clan_tag}`">[{{ m.user.clan_tag }}]</span>
<span class="msg-time">{{ new Date(m.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }}</span>
</div>
<div class="msg-bubble-wrap">
<div v-if="m.reply_to" class="reply-preview" @click="scrollToMessage(m.reply_to?.id)" title="Jump to original">
<div class="reply-bar"></div>
<span class="reply-user">@{{ m.reply_to.user?.username || 'user' }}:</span>
<span class="reply-text">{{ m.reply_to.message }}</span>
</div>
<div class="msg-bubble" :class="{ 'msg-deleted': m.is_deleted }">
<template v-if="m.is_deleted">
<Trash2 :size="12" style="opacity:.45;flex-shrink:0;" />
<span class="deleted-text">
Nachricht gelöscht
<span v-if="m.deleted_by_user" class="deleted-by">
von <span class="deleted-role">[{{ (m.deleted_by_user.role || 'mod').toUpperCase() }}]</span>
{{ m.deleted_by_user.username }}
</span>
</span>
</template>
<template v-else-if="isGif(m.message)">
<img :src="gifUrl(m.message)" class="msg-gif" loading="lazy" />
</template>
<template v-else>{{ m.message }}</template>
</div>
<!-- Actions & Reactions Container -->
<div class="msg-controls" v-if="!m.is_deleted">
<!-- Reaction quick bar -->
<div class="reaction-bar">
<button
v-for="em in reactionEmojis"
:key="em"
class="reaction-btn"
:aria-label="`React ${em}`"
@click="toggleReaction(m, em)"
>{{ em }}</button>
</div>
<!-- Actions -->
<div class="msg-actions">
<button class="act-btn" title="Reply" @click="setReply(m)"><Reply :size="14" /></button>
<button class="act-btn" title="Report" @click="reportMessage(m)"><Flag :size="14" /></button>
<button v-if="isAdmin" class="act-btn act-btn-delete" title="Löschen" @click="deleteMessage(m)"><Trash2 :size="14" /></button>
</div>
</div>
<!-- Existing reactions -->
<div v-if="m.reactions_agg && m.reactions_agg.length" class="reactions">
<button
v-for="r in m.reactions_agg"
:key="r.emoji"
class="reaction-chip"
:class="{ reacted: r.reactedByMe }"
@click="toggleReaction(m, r.emoji)"
:title="r.reactedByMe ? 'Remove reaction' : 'Add reaction'"
>
<span class="emoji">{{ r.emoji }}</span>
<span class="count">{{ r.count }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
</transition-group>
</template>
</div>
<footer v-if="!chatBanned" class="gc-footer">
<div class="compose-area" :class="{ disabled: !me }">
<div v-if="!me" class="login-prompt">
Please <a href="/login">login</a> to chat
</div>
<template v-else>
<transition name="slide-up">
<div v-if="replyTo" class="replying-to">
<div class="reply-info">
<span class="reply-label">Replying to <span class="reply-target">{{ replyTo.user?.username || 'User' }}</span></span>
<button class="close-reply" @click="replyTo = null"><i data-lucide="x"></i></button>
</div>
<div class="reply-msg-preview">{{ replyTo.message }}</div>
</div>
</transition>
<!-- Emoji picker (above footer) -->
<transition name="pop-up">
<div v-if="emojiOpen" class="emoji-picker-wrap">
<div ref="emojiPickerEl"></div>
</div>
</transition>
<!-- Giphy panel (above footer) -->
<transition name="pop-up">
<div v-if="giphyOpen" class="giphy-panel">
<div class="giphy-search-row">
<input
v-model="giphyQuery"
@input="onGiphyInput"
placeholder="GIF suchen..."
class="giphy-input"
autofocus
/>
<span v-if="giphyLoading" class="giphy-spin"><Loader2 :size="14" class="spin-anim" /></span>
</div>
<div class="giphy-grid">
<div v-if="!giphyResults.length && !giphyLoading" class="giphy-empty">Keine GIFs gefunden</div>
<img
v-for="g in giphyResults"
:key="g.id"
:src="g.images.fixed_height_small.url"
class="giphy-thumb"
loading="lazy"
@click="sendGif(g.images.downsized.url)"
/>
</div>
<div class="giphy-brand">Powered by GIPHY</div>
</div>
</transition>
<div class="input-row">
<div class="tools">
<button class="tool-btn" :class="{ active: emojiOpen }" @click="toggleEmoji" title="Emoji">
<Smile :size="16" />
</button>
<button class="tool-btn gif-btn" :class="{ active: giphyOpen }" @click="toggleGiphy" title="GIF">
<ImagePlay :size="16" />
</button>
</div>
<textarea
id="gc-input"
class="chat-input"
rows="1"
placeholder="Type a message..."
v-model="input"
@keydown.enter.exact.prevent="send"
@keydown.enter.shift.exact.stop
></textarea>
<button class="send-btn" :disabled="sending || !input.trim()" @click="send">
<i data-lucide="send"></i>
</button>
</div>
</template>
</div>
</footer>
</aside>
</div>
</transition>
</template>
<style scoped>
:global(:root) {
--chat-bg: #0a0a0a;
--chat-border: #1a1a1a;
--chat-bubble-me: #1a1a1a;
--chat-bubble-other: #111;
--accent: #00f2ff;
/* Roles */
--admin: #ff3e3e;
--staff: #007bff;
--mod: #00ff9d;
--streamer: #a855f7;
--youtuber: #ff0000;
--gold: #f7931a;
--user: #666;
}
/* Compact banned view inside chat drawer */
.gc-banned { flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px; }
.ban-card { text-align: center; color: #fff; background: #0d0d0d; border: 1px solid #1a1a1a; border-radius: 16px; padding: 24px; max-width: 340px; width: 100%; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
.ban-stamp { display: inline-block; color: #ff3e3e; border: 2px solid #ff3e3e; padding: 4px 12px; font-weight: 900; letter-spacing: 2px; border-radius: 6px; transform: rotate(-6deg); margin-bottom: 12px; font-size: 14px; }
.ban-title { margin: 0 0 8px; font-size: 18px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; }
.ban-reason { font-size: 12px; color: #ffaaaa; background: rgba(255,0,0,0.06); border: 1px solid rgba(255,0,0,0.2); padding: 6px 10px; border-radius: 8px; display: inline-block; margin-bottom: 8px; }
.ban-sub { font-size: 12px; color: #888; }
/* Popup mode */
.gc-popup {
position: fixed; z-index: 9999;
width: 380px; height: 580px;
background: var(--chat-bg); border: 1px solid #222;
border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.7);
display: flex; flex-direction: column; overflow: hidden;
user-select: none;
}
/* Mode toggle button */
.mode-btn {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
background: transparent; border: none; color: #666; cursor: pointer; border-radius: 6px; transition: .15s;
}
.mode-btn:hover { color: #fff; background: #1a1a1a; }
.mode-btn i { width: 15px; height: 15px; }
.gc-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); z-index: 9999; display: flex; justify-content: flex-end; }
.gc-drawer { width: 100%; max-width: 400px; height: 100%; background: var(--chat-bg); border-left: 1px solid var(--chat-border); display: flex; flex-direction: column; box-shadow: -10px 0 50px rgba(0,0,0,0.5); }
/* Transitions */
.slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.slide-fade-enter-from, .slide-fade-leave-to { opacity: 0; }
.slide-fade-enter-from .gc-drawer { transform: translateX(100%); }
.slide-fade-leave-to .gc-drawer { transform: translateX(100%); }
.msg-list-enter-active { transition: all 0.4s ease; }
.msg-list-enter-from { opacity: 0; transform: translateY(20px); }
.slide-up-enter-active, .slide-up-leave-active { transition: all 0.2s ease; }
.slide-up-enter-from, .slide-up-leave-to { opacity: 0; transform: translateY(10px); }
.pop-up-enter-active, .pop-up-leave-active { transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.pop-up-enter-from, .pop-up-leave-to { opacity: 0; transform: scale(0.8) translateY(10px); }
/* Header */
.gc-head { height: 60px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--chat-border); background: rgba(10,10,10,0.95); backdrop-filter: blur(10px); flex-shrink: 0; }
.head-left { display: flex; align-items: center; gap: 10px; }
.online-dot { width: 8px; height: 8px; background: var(--mod); border-radius: 50%; box-shadow: 0 0 10px var(--mod); animation: pulse-dot 2s infinite; }
.head-title { font-weight: 900; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; color: #fff; }
.close-btn { background: transparent; border: none; color: #666; cursor: pointer; transition: 0.2s; padding: 5px; border-radius: 5px; }
.close-btn:hover { color: #fff; background: #1a1a1a; }
@keyframes pulse-dot { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
/* List */
.gc-list { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 40px 20px 20px; display: flex; flex-direction: column; gap: 15px; scroll-behavior: smooth; overscroll-behavior: contain; }
.gc-list::-webkit-scrollbar { width: 6px; }
.gc-list::-webkit-scrollbar-thumb { background: #222; border-radius: 10px; }
/* Date Separator */
.date-separator { display: flex; justify-content: center; margin: 10px 0; position: relative; }
.date-separator span { background: #151515; color: #666; font-size: 10px; padding: 4px 12px; border-radius: 20px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; z-index: 1; border: 1px solid #222; }
.date-separator::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #1a1a1a; z-index: 0; }
/* Messages */
.msg-row { display: flex; gap: 12px; align-items: flex-start; }
.msg-me { flex-direction: row-reverse; }
.msg-avatar { width: 32px; height: 32px; border-radius: 10px; overflow: visible; background: #151515; flex-shrink: 0; border: 1px solid #222; transition: 0.2s; position: relative; }
.msg-row:hover .msg-avatar { border-color: #333; transform: scale(1.05); }
.msg-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 10px; }
.avatar-fallback { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-weight: 800; color: #555; font-size: 12px; border-radius: 10px; }
.level-badge { position: absolute; bottom: -5px; right: -5px; background: #000; border: 1px solid var(--accent); color: var(--accent); font-size: 8px; font-weight: 900; padding: 1px 4px; border-radius: 4px; z-index: 2; box-shadow: 0 2px 5px rgba(0,0,0,0.5); }
.msg-content { display: flex; flex-direction: column; gap: 4px; max-width: 80%; position: relative; }
.msg-me .msg-content { align-items: flex-end; }
.msg-meta { display: flex; align-items: center; gap: 6px; font-size: 10px; color: #666; flex-wrap: wrap; }
.msg-name { font-weight: 800; color: #aaa; transition: 0.2s; cursor: pointer; }
.msg-name-link:hover { color: var(--primary); text-decoration: underline; }
.msg-time { font-size: 9px; opacity: 0.7; margin-left: 4px; }
.msg-time-me { font-size: 9px; color: #444; margin-top: 2px; }
/* Badges */
.badge { font-size: 8px; font-weight: 900; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; animation: pulse-badge 2s infinite; box-shadow: 0 0 10px rgba(0,0,0,0.2); }
.badge-admin { background: rgba(255,62,62,0.15); color: var(--admin); border: 1px solid rgba(255,62,62,0.3); animation: pulse-admin 1.5s infinite; }
.badge-staff { background: rgba(0,123,255,0.15); color: var(--staff); border: 1px solid rgba(0,123,255,0.3); }
.badge-mod { background: rgba(0,255,157,0.15); color: var(--mod); border: 1px solid rgba(0,255,157,0.3); }
.badge-streamer { background: linear-gradient(45deg, rgba(0,255,157,0.15), rgba(168,85,247,0.15)); color: var(--streamer); border: 1px solid rgba(168,85,247,0.3); animation: pulse-streamer 3s infinite; }
.badge-youtuber { background: rgba(255,0,0,0.15); color: var(--youtuber); border: 1px solid rgba(255,0,0,0.3); animation: pulse-yt 2s infinite; }
.badge-vip { background: rgba(247,147,26,0.15); color: var(--gold); border: 1px solid rgba(247,147,26,0.3); animation: pulse-vip 2s infinite; }
.badge-clan { background: #151515; color: #888; border: 1px solid #222; animation: none; }
.badge-user { background: #151515; color: var(--user); border: 1px solid #222; animation: none; }
@keyframes pulse-badge { 0% { opacity: 1; } 50% { opacity: 0.7; } 100% { opacity: 1; } }
@keyframes pulse-admin { 0% { box-shadow: 0 0 0 rgba(255,62,62,0); } 50% { box-shadow: 0 0 10px rgba(255,62,62,0.4); } 100% { box-shadow: 0 0 0 rgba(255,62,62,0); } }
@keyframes pulse-streamer { 0% { filter: hue-rotate(0deg); } 100% { filter: hue-rotate(360deg); } }
@keyframes pulse-yt { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } }
@keyframes pulse-vip { 0% { border-color: var(--gold); } 50% { border-color: #fff; } 100% { border-color: var(--gold); } }
.msg-bubble-wrap { position: relative; transition: 0.2s; }
.msg-row:hover .msg-controls { opacity: 1; pointer-events: auto; transform: translateY(0); }
.msg-bubble { background: var(--chat-bubble-other); padding: 10px 14px; border-radius: 12px; border-top-left-radius: 2px; font-size: 13px; line-height: 1.5; color: #ddd; border: 1px solid #1a1a1a; word-break: break-word; box-shadow: 0 2px 5px rgba(0,0,0,0.1); transition: 0.2s; }
.msg-row:hover .msg-bubble { border-color: #2a2a2a; }
.msg-me .msg-bubble { background: var(--chat-bubble-me); border-radius: 12px; border-top-right-radius: 2px; color: #fff; border-color: #222; }
.msg-me:hover .msg-bubble { border-color: #333; }
/* Reply Preview inside bubble */
.reply-preview { font-size: 11px; margin-bottom: 6px; padding-left: 8px; position: relative; opacity: 0.7; cursor: pointer; transition: 0.2s; }
.reply-preview:hover { opacity: 1; }
.reply-bar { position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--accent); border-radius: 2px; }
.reply-user { font-weight: 800; margin-right: 5px; color: var(--accent); }
.reply-text { color: #aaa; }
/* Controls Container (Reactions + Actions) */
.msg-controls {
position: absolute;
top: -38px;
/* Default: Left aligned message -> Align controls to left, grow right */
left: 0;
right: auto;
display: flex;
gap: 8px;
opacity: 0;
pointer-events: none;
transition: 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform: translateY(5px);
z-index: 10;
}
/* Me: Right aligned message -> Align controls to right, grow left */
.msg-me .msg-controls {
left: auto;
right: 0;
flex-direction: row; /* Keep order consistent */
}
/* Actions (Reply/Report) */
.msg-actions { background: #151515; border: 1px solid #222; border-radius: 8px; display: flex; gap: 2px; padding: 3px; box-shadow: 0 5px 15px rgba(0,0,0,0.5); }
.act-btn { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; color: #666; cursor: pointer; border-radius: 6px; transition: 0.2s; }
.act-btn:hover { background: #222; color: #fff; transform: scale(1.1); }
.act-btn-delete:hover { background: rgba(239,68,68,0.15) !important; color: #ef4444 !important; }
.act-btn i { width: 14px; height: 14px; }
.msg-deleted { opacity: .65; display: flex; align-items: center; gap: 6px; }
.deleted-text { font-style: italic; font-size: 12px; color: #888; }
.deleted-by { font-style: normal; font-size: 11px; color: #666; margin-left: 4px; }
.deleted-role { color: #ef4444; font-weight: 700; font-size: 10px; margin-right: 2px; }
/* Reactions Bar */
.reaction-bar { display: flex; gap: 4px; background: #151515; padding: 4px; border-radius: 8px; border: 1px solid #222; box-shadow: 0 5px 15px rgba(0,0,0,0.5); }
.reaction-btn { background: transparent; border: none; color: #ccc; font-size: 16px; padding: 2px 4px; cursor: pointer; transition: 0.15s; border-radius: 4px; }
.reaction-btn:hover { background: rgba(255,255,255,0.1); transform: scale(1.2); }
.reactions { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
.reaction-chip { display: inline-flex; align-items: center; gap: 4px; background: #121212; border: 1px solid #222; color: #aaa; border-radius: 6px; padding: 2px 6px; font-size: 11px; cursor: pointer; transition: 0.15s; }
.reaction-chip .emoji { font-size: 12px; }
.reaction-chip .count { font-weight: 800; font-size: 10px; color: #888; }
.reaction-chip:hover { border-color: #333; color: #ddd; transform: translateY(-1px); }
.reaction-chip.reacted { background: rgba(0,242,255,0.1); border-color: var(--accent); color: var(--accent); }
/* Flash highlight when jumping to a replied message */
@keyframes flashPulse {
0% { box-shadow: 0 0 0px rgba(0,242,255,0.0); }
50% { box-shadow: 0 0 20px rgba(0,242,255,0.5); }
100% { box-shadow: 0 0 0px rgba(0,242,255,0.0); }
}
.flash .msg-bubble { animation: flashPulse 1.2s ease; border-color: var(--accent) !important; }
/* Footer */
.gc-footer { padding: 15px; border-top: 1px solid var(--chat-border); background: #0a0a0a; position: relative; z-index: 20; flex-shrink: 0; }
.compose-area { background: #050505; border: 1px solid #1a1a1a; padding: 8px; border-radius: 12px; transition: 0.3s; display: flex; flex-direction: column; gap: 8px; position: relative; }
.compose-area:focus-within { border-color: #333; box-shadow: 0 0 20px rgba(0,0,0,0.3); }
.compose-area.disabled { opacity: 0.7; pointer-events: none; justify-content: center; align-items: center; padding: 15px; }
.replying-to { display: flex; flex-direction: column; gap: 2px; padding: 8px; background: #111; border-radius: 8px; border-left: 2px solid var(--accent); font-size: 11px; position: relative; }
.reply-info { display: flex; justify-content: space-between; align-items: center; color: #888; }
.reply-target { color: var(--accent); font-weight: 800; }
.reply-msg-preview { color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.close-reply { background: transparent; border: none; color: #666; cursor: pointer; transition: 0.2s; }
.close-reply:hover { color: #fff; transform: scale(1.1); }
.input-row { display: flex; align-items: flex-end; gap: 10px; }
.login-prompt { font-size: 12px; color: #666; text-align: center; }
.login-prompt a { color: var(--accent); text-decoration: none; font-weight: 800; pointer-events: auto; }
.chat-input { flex: 1; background: transparent; border: none; color: #fff; font-size: 13px; resize: none; max-height: 100px; padding: 8px 5px; outline: none; }
.chat-input::placeholder { color: #444; }
.tool-btn, .send-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; border: none; cursor: pointer; transition: 0.2s; }
.tool-btn { background: transparent; color: #666; }
.tool-btn:hover { color: #fff; background: #151515; transform: scale(1.1); }
.tool-btn.active { color: #df006a; background: rgba(223,0,106,0.1); }
.gif-btn { font-size: 10px; font-weight: 800; color: #555; width: auto; padding: 0 8px; border-radius: 6px; border: 1px solid #252525; }
.gif-btn.active { color: #df006a; border-color: rgba(223,0,106,0.3); background: rgba(223,0,106,0.08); }
.send-btn { background: var(--accent); color: #000; }
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; background: #222; color: #555; }
.send-btn:hover:not(:disabled) { transform: scale(1.05); box-shadow: 0 0 15px rgba(0,242,255,0.4); }
/* emoji-mart dark theme override */
.emoji-picker-wrap {
position: absolute; bottom: calc(100% + 6px); left: 0;
z-index: 9999; border-radius: 12px; overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.6);
}
:global(em-emoji-picker) {
--border-radius: 12px;
--background-rgb: 17, 17, 19;
--rgb-background: 17, 17, 19;
--color-border: #2a2a2a;
--rgb-color: 220, 220, 220;
--rgb-input: 255, 255, 255;
--color-border-over: #3a3a3a;
height: 380px;
}
/* Giphy panel */
.giphy-panel {
position: absolute; bottom: calc(100% + 6px); left: 0; right: 0;
background: #111113; border: 1px solid #252528; border-radius: 12px;
padding: 10px; z-index: 9999; box-shadow: 0 10px 40px rgba(0,0,0,0.6);
display: flex; flex-direction: column; gap: 8px;
}
.giphy-search-row { display: flex; align-items: center; gap: 6px; }
.giphy-input {
flex: 1; background: #0c0c0e; border: 1px solid #252528; color: #ccc;
padding: 7px 11px; border-radius: 8px; font-size: 12px;
}
.giphy-input:focus { outline: none; border-color: rgba(223,0,106,0.35); }
.giphy-spin { color: #555; display: flex; }
.spin-anim { animation: spin-anim .7s linear infinite; }
@keyframes spin-anim { to { transform: rotate(360deg); } }
.giphy-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px;
max-height: 260px; overflow-y: auto;
}
.giphy-grid::-webkit-scrollbar { width: 4px; }
.giphy-grid::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 2px; }
.giphy-thumb {
width: 100%; height: 80px; object-fit: cover; border-radius: 6px;
cursor: pointer; transition: 0.15s; border: 2px solid transparent;
}
.giphy-thumb:hover { border-color: #df006a; transform: scale(1.02); }
.giphy-empty { grid-column: 1/-1; text-align: center; color: #555; font-size: 12px; padding: 20px 0; }
.giphy-brand { font-size: 9px; color: #444; text-align: right; font-weight: 600; }
/* GIF in chat */
.msg-gif { max-width: 200px; border-radius: 8px; display: block; }
/* States */
.loading-state, .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #444; font-size: 12px; gap: 10px; }
.spinner { width: 20px; height: 20px; border: 2px solid #222; border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 600px) {
.gc-drawer { max-width: 100%; }
.gc-overlay { z-index: 99999; }
}
/* ===== Report Modal ===== */
.gc-report-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px) saturate(0.8);
-webkit-backdrop-filter: blur(8px) saturate(0.8);
z-index: 2147483647;
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.gc-report-modal {
background: #0c0c0e;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 22px;
width: 100%; max-width: 460px;
box-shadow: 0 40px 100px rgba(0,0,0,0.9), 0 0 0 1px rgba(223,0,106,0.05);
overflow: hidden;
animation: modal-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modal-pop {
from { opacity: 0; transform: scale(0.9) translateY(16px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Transition */
.modal-fade-enter-active { transition: opacity 0.25s ease; }
.modal-fade-leave-active { transition: opacity 0.15s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
/* ── Header ── */
.rm-head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 18px;
background: linear-gradient(135deg, rgba(223,0,106,0.12), rgba(0,0,0,0));
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.rm-head-left { display: flex; align-items: center; gap: 10px; }
.rm-head-icon {
width: 30px; height: 30px; border-radius: 9px;
background: rgba(223,0,106,0.18); border: 1px solid rgba(223,0,106,0.35);
color: #df006a; display: flex; align-items: center; justify-content: center;
}
.rm-title { font-size: 14px; font-weight: 800; color: #fff; letter-spacing: 0.2px; }
.rm-close {
width: 28px; height: 28px; border-radius: 8px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
color: #555; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: 0.2s; flex-shrink: 0;
}
.rm-close:hover { background: rgba(255,255,255,0.1); color: #fff; }
/* ── User card ── */
.rm-user {
display: flex; align-items: center; gap: 14px;
padding: 14px 18px;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.rm-avatar-wrap { position: relative; flex-shrink: 0; }
.rm-avatar {
width: 52px; height: 52px; border-radius: 14px; overflow: hidden;
border: 2px solid rgba(255,255,255,0.08); background: #151515; position: relative; z-index: 1;
}
.rm-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rm-avatar-fallback {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 900; color: #555;
}
.rm-avatar-glow {
position: absolute; inset: -4px; border-radius: 18px; z-index: 0;
background: radial-gradient(circle, rgba(223,0,106,0.25), transparent 70%);
filter: blur(6px);
}
.rm-user-info { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.rm-username { font-size: 15px; font-weight: 800; color: #fff; }
.rm-badges { display: flex; gap: 5px; flex-wrap: wrap; }
.rm-badge {
font-size: 9px; font-weight: 800; padding: 2px 8px; border-radius: 5px;
text-transform: uppercase; letter-spacing: 0.5px; border: 1px solid;
}
.rm-badge.admin { color: #ff3e3e; border-color: rgba(255,62,62,0.35); background: rgba(255,62,62,0.08); }
.rm-badge.staff { color: #3b82f6; border-color: rgba(59,130,246,0.35); background: rgba(59,130,246,0.08); }
.rm-badge.mod { color: #00ff9d; border-color: rgba(0,255,157,0.35); background: rgba(0,255,157,0.08); }
.rm-badge.streamer { color: #a855f7; border-color: rgba(168,85,247,0.35); background: rgba(168,85,247,0.08); }
.rm-badge.user { color: #555; border-color: rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); }
.rm-badge.vip { color: #ffd700; border-color: rgba(255,215,0,0.35); background: rgba(255,215,0,0.08); }
.rm-badge.clan { color: #00f2ff; border-color: rgba(0,242,255,0.35); background: rgba(0,242,255,0.08); }
/* ── Sections ── */
.rm-section { padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.05); }
.rm-section-label {
display: flex; align-items: center; gap: 6px;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.7px; color: #444; margin-bottom: 10px;
}
/* ── Message bubble ── */
.rm-message-bubble {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.07);
border-left: 3px solid #df006a;
border-radius: 10px; padding: 12px 14px;
position: relative; overflow: hidden;
}
.rm-quote-mark {
position: absolute; top: 4px; right: 12px;
font-size: 40px; line-height: 1; color: rgba(255,255,255,0.04);
font-family: Georgia, serif; font-weight: 900; pointer-events: none;
}
.rm-message-text {
font-size: 13px; color: #ccc; line-height: 1.6;
word-break: break-word; margin: 0; position: relative; z-index: 1;
}
/* ── Reasons ── */
.rm-reasons { display: flex; flex-direction: column; gap: 7px; }
.rm-reason-btn {
display: flex; align-items: center; gap: 12px;
padding: 11px 14px; border-radius: 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
color: #888; cursor: pointer; transition: all 0.18s ease;
text-align: left; width: 100%;
}
.rm-reason-btn:hover {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.12);
color: #ddd;
transform: translateX(2px);
}
.rm-reason-btn.selected {
background: rgba(var(--reason-color, 223 0 106) / 0.1);
border-color: color-mix(in srgb, var(--reason-color, #df006a) 50%, transparent);
color: #fff;
}
.reason-icon-wrap {
width: 34px; height: 34px; border-radius: 10px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: 0.18s;
}
.rm-reason-btn.selected .reason-icon-wrap {
background: rgba(var(--reason-color, 223 0 106) / 0.15);
border-color: color-mix(in srgb, var(--reason-color, #df006a) 40%, transparent);
}
.reason-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.reason-label { font-size: 13px; font-weight: 700; color: inherit; }
.reason-desc { font-size: 11px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rm-reason-btn.selected .reason-desc { color: #888; }
.reason-check-wrap { color: #22c55e; flex-shrink: 0; }
.reason-chevron { color: #333; flex-shrink: 0; transition: 0.18s; }
.rm-reason-btn:hover .reason-chevron { color: #555; transform: translateX(2px); }
/* ── Actions ── */
.rm-actions {
display: flex; gap: 10px;
padding: 14px 18px 18px;
}
.rm-btn-cancel {
flex: 1; padding: 11px 14px; border-radius: 11px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: #888; cursor: pointer; font-size: 13px; font-weight: 700;
transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 7px;
}
.rm-btn-cancel:hover { background: rgba(255,255,255,0.08); color: #ccc; }
.rm-btn-submit {
flex: 2; padding: 11px 14px; border-radius: 11px;
background: linear-gradient(135deg, #df006a, #a8004e);
border: 1px solid rgba(223,0,106,0.5);
color: #fff; cursor: pointer; font-size: 13px; font-weight: 800;
transition: 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px;
box-shadow: 0 4px 20px rgba(223,0,106,0.25);
}
.rm-btn-submit:hover:not(:disabled) {
background: linear-gradient(135deg, #f2007a, #c0005c);
box-shadow: 0 6px 30px rgba(223,0,106,0.45);
transform: translateY(-1px);
}
.rm-btn-submit:active:not(:disabled) { transform: translateY(0); }
.rm-btn-submit:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }
.rm-spin { animation: spin 0.8s linear infinite; }
/* ── Success ── */
.report-success {
padding: 44px 24px 40px;
display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center;
}
.success-ring {
width: 64px; height: 64px; border-radius: 50%;
background: radial-gradient(circle, rgba(34,197,94,0.2), rgba(34,197,94,0.05));
border: 2px solid rgba(34,197,94,0.4);
color: #22c55e; display: flex; align-items: center; justify-content: center;
box-shadow: 0 0 30px rgba(34,197,94,0.25);
animation: success-bounce 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes success-bounce {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
.report-success h3 { font-size: 18px; font-weight: 900; color: #fff; margin: 0; }
.report-success p { font-size: 13px; color: #666; margin: 0; line-height: 1.6; }
/* ── Responsive ── */
@media (max-width: 480px) {
.gc-report-overlay { align-items: flex-end; padding: 0; }
.gc-report-modal { border-radius: 22px 22px 0 0; max-width: 100%; }
.reason-desc { display: none; }
}
</style>