1558 lines
73 KiB
Vue
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>
|