Files
BetiX/resources/js/pages/Social/Hub.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

1473 lines
59 KiB
Vue

<script setup lang="ts">
import { usePage, Link, router } from '@inertiajs/vue3';
import UserLayout from '@/layouts/user/userlayout.vue';
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { csrfFetch } from '@/utils/csrfFetch';
import { Picker } from 'emoji-mart';
import data from '@emoji-mart/data';
import {
Search, UserPlus, UserCheck, UserX, MessageCircle, Send,
Smile, ImagePlay, Reply, Flag, X, CheckCircle2, Loader2,
ShieldAlert, MessageSquareOff, BadgeDollarSign, HelpCircle,
ChevronLeft, Users, Inbox, Clock, Trash2, Shield,
} from 'lucide-vue-next';
const page = usePage();
const me = computed(() => (page.props.auth as any)?.user);
const GIPHY_KEY = 'GF7y5viqpCeUeJdzoj5U9PY5XkFpswmC';
// ── Tabs ─────────────────────────────────────────────────
type Tab = 'chats' | 'friends' | 'requests' | 'search';
const activeTab = ref<Tab>('chats');
// ── Left-panel data ───────────────────────────────────────
const conversations = ref<any[]>([]);
const friends = ref<any[]>([]);
const requests = ref<any[]>([]);
const searchQuery = ref('');
const searchResults = ref<any[]>([]);
const searchLoading = ref(false);
// ── Chat window ───────────────────────────────────────────
const selectedPartner = ref<any>(null);
const messages = ref<any[]>([]);
const chatLoading = ref(false);
const input = ref('');
const sending = ref(false);
const replyTo = ref<any>(null);
let pollTimer: number | undefined;
// ── Emoji / GIF ───────────────────────────────────────────
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;
// ── Guild chat ────────────────────────────────────────
const myGuild = ref<any>(null);
const guildChatActive = ref(false);
const guildMessages = ref<any[]>([]);
const guildChatLoading = ref(false);
const membersOpen = ref(false);
const guildMembers = ref<any[]>([]);
let guildPollTimer: number | undefined;
// ── Report modal ──────────────────────────────────────────
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 Nachrichten 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', desc: 'Betrügerische Links oder Forderungen', icon: BadgeDollarSign, color: '#a855f7' },
{ value: 'other', label: 'Sonstiges', desc: 'Anderer Verstoß gegen die Nutzungsregeln', icon: HelpCircle, color: '#6b7280' },
];
// ── Load data ─────────────────────────────────────────────
async function loadConversations() {
try {
const r = await fetch('/api/dm/conversations', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) conversations.value = (await r.json()).data ?? [];
} catch { /* silent */ }
}
async function loadFriends() {
try {
const r = await fetch('/api/dm/friends', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) friends.value = (await r.json()).data ?? [];
} catch { /* silent */ }
}
async function loadRequests() {
try {
const r = await fetch('/api/dm/friends/requests', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) requests.value = (await r.json()).data ?? [];
} catch { /* silent */ }
}
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function doSearch() {
const q = searchQuery.value.trim();
if (!q) { searchResults.value = []; return; }
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
searchLoading.value = true;
try {
const r = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } });
if (r.ok) {
const data = await r.json();
searchResults.value = Array.isArray(data) ? data : (data.users ?? data.data ?? []);
}
} catch { /* silent */ } finally {
searchLoading.value = false;
}
}, 350);
}
// ── Open a conversation ───────────────────────────────────
async function openChat(partner: any) {
selectedPartner.value = partner;
messages.value = [];
chatLoading.value = true;
stopPolling();
try {
const r = await fetch(`/api/dm/${partner.id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) messages.value = (await r.json()).data ?? [];
await nextTick();
scrollToBottom();
} finally {
chatLoading.value = false;
}
startPolling();
// Update unread in conversations list
const conv = conversations.value.find(c => c.partner.id === partner.id);
if (conv) conv.unread_count = 0;
}
async function pollMessages() {
if (!selectedPartner.value) return;
try {
const r = await fetch(`/api/dm/${selectedPartner.value.id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!r.ok) return;
const fresh = (await r.json()).data ?? [];
const lastKnown = messages.value.length ? messages.value[messages.value.length - 1].id : 0;
const newMsgs = fresh.filter((m: any) => m.id > lastKnown);
if (newMsgs.length) {
messages.value.push(...newMsgs);
await nextTick();
scrollToBottomIfNear();
}
} catch { /* silent */ }
}
function startPolling() {
stopPolling();
pollTimer = window.setInterval(pollMessages, 4000);
}
function stopPolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = undefined;
}
function scrollToBottom() {
const el = document.getElementById('dm-messages');
if (el) el.scrollTop = el.scrollHeight;
}
function scrollToBottomIfNear() {
const el = document.getElementById('dm-messages');
if (!el) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 180) scrollToBottom();
}
// ── Send message ──────────────────────────────────────────
function isGif(msg: string) { return typeof msg === 'string' && msg.startsWith('[GIF:') && msg.endsWith(']'); }
function gifUrl(msg: string) { return msg.slice(5, -1); }
async function send() {
const text = input.value.trim();
if (!text || sending.value) return;
if (!selectedPartner.value && !guildChatActive.value) return;
sending.value = true;
const payload: any = { message: text };
if (replyTo.value) payload.reply_to_id = replyTo.value.id;
try {
const url = guildChatActive.value
? `/api/guild-chat/${myGuild.value.id}`
: `/api/dm/${selectedPartner.value.id}`;
const r = await csrfFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!r.ok) return;
const msg = (await r.json()).data;
if (guildChatActive.value) {
guildMessages.value.push(msg);
} else {
messages.value.push(msg);
updateConversationLast(msg);
}
input.value = '';
replyTo.value = null;
await nextTick();
scrollToBottom();
} finally {
sending.value = false;
}
}
async function sendGif(url: string) {
if (sending.value) return;
if (!selectedPartner.value && !guildChatActive.value) return;
giphyOpen.value = false;
sending.value = true;
try {
const endpoint = guildChatActive.value
? `/api/guild-chat/${myGuild.value.id}`
: `/api/dm/${selectedPartner.value.id}`;
const r = await csrfFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: `[GIF:${url}]` }),
});
if (!r.ok) return;
const msg = (await r.json()).data;
if (guildChatActive.value) {
guildMessages.value.push(msg);
} else {
messages.value.push(msg);
updateConversationLast(msg);
}
await nextTick();
scrollToBottom();
} finally {
sending.value = false;
}
}
function updateConversationLast(msg: any) {
const pid = selectedPartner.value?.id;
const conv = conversations.value.find(c => c.partner.id === pid);
if (conv) {
conv.last_message = { id: msg.id, message: msg.message, sender_id: msg.sender_id, created_at: msg.created_at };
} else {
conversations.value.unshift({
partner: selectedPartner.value,
last_message: { id: msg.id, message: msg.message, sender_id: msg.sender_id, created_at: msg.created_at },
unread_count: 0,
});
}
}
// ── Emoji picker ──────────────────────────────────────────
function toggleEmoji() {
giphyOpen.value = false;
emojiOpen.value = !emojiOpen.value;
if (emojiOpen.value) {
nextTick(() => {
if (emojiPickerEl.value && !pickerInstance) {
pickerInstance = new Picker({
data,
onEmojiSelect: (em: any) => insertEmoji(em.native),
theme: 'dark',
locale: 'de',
previewPosition: 'none',
});
emojiPickerEl.value.appendChild(pickerInstance as unknown as Node);
}
});
}
}
function insertEmoji(em: string) {
const el = document.getElementById('dm-input') as HTMLTextAreaElement | null;
const at = el?.selectionStart ?? input.value.length;
input.value = input.value.slice(0, at) + em + input.value.slice(at);
emojiOpen.value = false;
nextTick(() => { el?.focus(); });
}
// ── GIPHY ─────────────────────────────────────────────────
function toggleGiphy() {
emojiOpen.value = false;
giphyOpen.value = !giphyOpen.value;
if (giphyOpen.value && !giphyResults.value.length) searchGiphy('hello');
}
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 r = await fetch(`https://api.giphy.com/v1/gifs/search?api_key=${GIPHY_KEY}&q=${encodeURIComponent(query)}&limit=18&rating=pg-13`);
giphyResults.value = (await r.json()).data ?? [];
} finally { giphyLoading.value = false; }
}, 350);
}
function onGiphyInput() { searchGiphy(giphyQuery.value); }
// ── Report ────────────────────────────────────────────────
function openReport(msg: any) { reportModal.value = { msg }; reportReason.value = ''; reportDone.value = false; }
function closeReport() { reportModal.value = null; }
async function submitReport() {
if (!reportReason.value || reportSubmitting.value) return;
reportSubmitting.value = true;
try {
const r = await csrfFetch(`/api/dm/messages/${reportModal.value!.msg.id}/report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reportReason.value }),
});
if (r.ok) { reportDone.value = true; setTimeout(closeReport, 1800); }
} finally { reportSubmitting.value = false; }
}
// ── Friend actions ────────────────────────────────────────
async function sendFriendRequest(userId: number) {
await csrfFetch('/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
});
await loadFriends();
}
async function acceptRequest(id: number) {
await csrfFetch(`/friends/${id}/accept`, { method: 'POST' });
await Promise.all([loadRequests(), loadFriends()]);
}
async function declineRequest(id: number) {
await csrfFetch(`/friends/${id}/decline`, { method: 'POST' });
await loadRequests();
}
// ── Guild chat functions ──────────────────────────────────
async function loadMyGuild() {
try {
const r = await fetch('/api/guild-chat/me', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) myGuild.value = (await r.json()).data;
} catch { /* silent */ }
}
async function loadGuildMembers() {
if (!myGuild.value) return;
try {
const r = await fetch(`/api/guild-chat/${myGuild.value.id}/members`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) guildMembers.value = (await r.json()).data ?? [];
} catch { /* silent */ }
}
async function openGuildChat() {
selectedPartner.value = null;
stopPolling();
replyTo.value = null;
membersOpen.value = false;
guildChatActive.value = true;
guildMessages.value = [];
guildChatLoading.value = true;
try {
const r = await fetch(`/api/guild-chat/${myGuild.value.id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (r.ok) guildMessages.value = (await r.json()).data ?? [];
await nextTick();
scrollToBottom();
} finally {
guildChatLoading.value = false;
}
loadGuildMembers();
startGuildPolling();
}
async function pollGuildMessages() {
if (!guildChatActive.value || !myGuild.value) return;
try {
const r = await fetch(`/api/guild-chat/${myGuild.value.id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!r.ok) return;
const fresh = (await r.json()).data ?? [];
const lastKnown = guildMessages.value.length ? guildMessages.value[guildMessages.value.length - 1].id : 0;
const newMsgs = fresh.filter((m: any) => m.id > lastKnown);
if (newMsgs.length) {
guildMessages.value.push(...newMsgs);
await nextTick();
scrollToBottomIfNear();
}
} catch { /* silent */ }
}
function startGuildPolling() {
stopGuildPolling();
guildPollTimer = window.setInterval(pollGuildMessages, 4000);
}
function stopGuildPolling() {
if (guildPollTimer) window.clearInterval(guildPollTimer);
guildPollTimer = undefined;
}
function closeActiveChat() {
if (guildChatActive.value) {
guildChatActive.value = false;
stopGuildPolling();
} else {
selectedPartner.value = null;
stopPolling();
}
replyTo.value = null;
}
// ── Helpers ───────────────────────────────────────────────
function avatar(u: any) { return u?.avatar || u?.avatar_url || null; }
function initial(u: any) { return (u?.username || u?.name || '?').charAt(0).toUpperCase(); }
function shortMsg(msg: string | null) {
if (!msg) return 'Nachricht gelöscht';
if (isGif(msg)) return '🎞 GIF';
return msg.length > 40 ? msg.slice(0, 40) + '…' : msg;
}
function timeAgo(date: string) {
const d = new Date(date);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return 'Jetzt';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function isMyMsg(msg: any) { return msg.sender_id === me.value?.id; }
const isFriendWith = (userId: number) => friends.value.some(f => f.id === userId);
const totalUnread = computed(() => conversations.value.reduce((s, c) => s + (c.unread_count || 0), 0));
const isChatOpen = computed(() => !!selectedPartner.value || guildChatActive.value);
const activeMessages = computed(() => guildChatActive.value ? guildMessages.value : messages.value);
const isActiveMyMsg = (msg: any) => guildChatActive.value
? msg.user_id === me.value?.id
: msg.sender_id === me.value?.id;
function formatDateLabel(dateStr: string): string {
const date = new Date(dateStr);
const today = new Date(); today.setHours(0, 0, 0, 0);
const yesterday = new Date(today.getTime() - 86400000);
const d = new Date(date); d.setHours(0, 0, 0, 0);
if (d.getTime() === today.getTime()) return 'Heute';
if (d.getTime() === yesterday.getTime()) return 'Gestern';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
const messageWithDates = computed(() => {
const result: any[] = [];
let lastDate = '';
for (const msg of activeMessages.value) {
const dateKey = new Date(msg.created_at).toDateString();
if (dateKey !== lastDate) {
result.push({ _divider: true, label: formatDateLabel(msg.created_at) });
lastDate = dateKey;
}
result.push(msg);
}
return result;
});
watch(activeTab, (tab) => {
if (tab === 'friends') loadFriends();
else if (tab === 'requests') loadRequests();
else if (tab === 'chats') loadConversations();
});
onMounted(async () => {
await loadConversations();
await loadFriends();
await loadRequests();
await loadMyGuild();
});
onUnmounted(() => { stopPolling(); stopGuildPolling(); });
</script>
<template>
<UserLayout>
<div class="social-hub">
<!-- Left Panel -->
<div :class="['sh-left', { 'sh-left--hidden': isChatOpen }]">
<div class="sh-left-head">
<span class="sh-title">Social</span>
<div class="sh-tabs">
<button :class="['sh-tab', { active: activeTab === 'chats' }]" @click="activeTab = 'chats'">
<MessageCircle :size="14" />
<span class="sh-tab-label">Chats</span>
<span v-if="totalUnread > 0" class="sh-badge">{{ totalUnread }}</span>
</button>
<button :class="['sh-tab', { active: activeTab === 'friends' }]" @click="activeTab = 'friends'">
<Users :size="14" />
<span class="sh-tab-label">Freunde</span>
<span v-if="friends.length" class="sh-badge sh-badge--muted">{{ friends.length }}</span>
</button>
<button :class="['sh-tab', { active: activeTab === 'requests' }]" @click="activeTab = 'requests'">
<Inbox :size="14" />
<span class="sh-tab-label">Anfragen</span>
<span v-if="requests.length" class="sh-badge sh-badge--warn">{{ requests.length }}</span>
</button>
<button :class="['sh-tab', { active: activeTab === 'search' }]" @click="activeTab = 'search'">
<Search :size="14" />
<span class="sh-tab-label">Suchen</span>
</button>
</div>
</div>
<!-- Chats -->
<div v-if="activeTab === 'chats'" class="sh-list">
<!-- Guild chat entry -->
<button v-if="myGuild" :class="['sh-conv-item', 'sh-conv-item--guild', { active: guildChatActive }]" @click="openGuildChat">
<div class="sh-ava sh-ava--guild">
<img v-if="myGuild.logo_url" :src="myGuild.logo_url" alt="">
<span v-else>{{ myGuild.tag?.[0] ?? '?' }}</span>
</div>
<div class="sh-conv-info">
<div class="sh-conv-row">
<span class="sh-conv-name">[{{ myGuild.tag }}] {{ myGuild.name }}</span>
</div>
<div class="sh-conv-row">
<span class="sh-conv-preview"><Shield :size="10" /> Gildenchat</span>
</div>
</div>
</button>
<div v-if="!conversations.length && !myGuild" class="sh-empty">
<MessageCircle :size="32" />
<strong>Noch keine Chats</strong>
<span>Suche nach Nutzern und schreib ihnen</span>
</div>
<button
v-for="conv in conversations"
:key="conv.partner.id"
:class="['sh-conv-item', { active: selectedPartner?.id === conv.partner.id }]"
@click="openChat(conv.partner)"
>
<div class="sh-ava">
<img v-if="avatar(conv.partner)" :src="avatar(conv.partner)" alt="">
<span v-else>{{ initial(conv.partner) }}</span>
</div>
<div class="sh-conv-info">
<div class="sh-conv-row">
<span class="sh-conv-name">{{ conv.partner.username || conv.partner.name }}</span>
<span class="sh-conv-time" v-if="conv.last_message">{{ timeAgo(conv.last_message.created_at) }}</span>
</div>
<div class="sh-conv-row">
<span class="sh-conv-preview">{{ shortMsg(conv.last_message?.message) }}</span>
<span v-if="conv.unread_count > 0" class="sh-unread-pip">{{ conv.unread_count }}</span>
</div>
</div>
</button>
</div>
<!-- Friends -->
<div v-if="activeTab === 'friends'" class="sh-list">
<div v-if="!friends.length" class="sh-empty">
<Users :size="32" />
<strong>Noch keine Freunde</strong>
<span>Suche nach Nutzern und füge sie hinzu</span>
</div>
<div v-for="f in friends" :key="f.id" class="sh-user-row">
<Link :href="`/profile/${f.username}`" class="sh-ava">
<img v-if="avatar(f)" :src="avatar(f)" alt="">
<span v-else>{{ initial(f) }}</span>
</Link>
<div class="sh-user-info">
<Link :href="`/profile/${f.username}`" class="sh-uname">{{ f.username || f.name }}</Link>
</div>
<button class="sh-icon-btn sh-icon-btn--primary" title="Nachricht" @click="openChat(f); activeTab = 'chats'">
<MessageCircle :size="14" />
</button>
</div>
</div>
<!-- Requests -->
<div v-if="activeTab === 'requests'" class="sh-list">
<div v-if="!requests.length" class="sh-empty">
<Inbox :size="32" />
<strong>Keine Anfragen</strong>
<span>Eingehende Freundschaftsanfragen erscheinen hier</span>
</div>
<div v-for="req in requests" :key="req.id" class="sh-user-row">
<Link :href="`/profile/${req.from.username}`" class="sh-ava">
<img v-if="avatar(req.from)" :src="avatar(req.from)" alt="">
<span v-else>{{ initial(req.from) }}</span>
</Link>
<div class="sh-user-info">
<Link :href="`/profile/${req.from.username}`" class="sh-uname">{{ req.from.username || req.from.name }}</Link>
<span class="sh-sub"><Clock :size="10" /> {{ timeAgo(req.created_at) }}</span>
</div>
<div class="sh-row-actions">
<button class="sh-icon-btn sh-icon-btn--green" @click="acceptRequest(req.from.id)" title="Annehmen"><UserCheck :size="14" /></button>
<button class="sh-icon-btn sh-icon-btn--red" @click="declineRequest(req.from.id)" title="Ablehnen"><UserX :size="14" /></button>
</div>
</div>
</div>
<!-- Search -->
<div v-if="activeTab === 'search'" class="sh-list">
<div class="sh-search-bar">
<Search :size="14" class="sh-search-ico" />
<input v-model="searchQuery" @input="doSearch" class="sh-search-inp" placeholder="Nutzer suchen..." type="text" autofocus />
<Loader2 v-if="searchLoading" :size="14" class="spin" />
</div>
<div v-if="!searchQuery" class="sh-empty">
<Search :size="32" />
<strong>Nutzer suchen</strong>
<span>Gib einen Nutzernamen ein</span>
</div>
<div v-else-if="!searchResults.length && !searchLoading" class="sh-empty">
<span>Keine Ergebnisse für {{ searchQuery }}"</span>
</div>
<div v-for="u in searchResults" :key="u.id" class="sh-user-row">
<Link :href="`/profile/${u.username}`" class="sh-ava">
<img v-if="avatar(u)" :src="avatar(u)" alt="">
<span v-else>{{ initial(u) }}</span>
</Link>
<div class="sh-user-info">
<Link :href="`/profile/${u.username}`" class="sh-uname">{{ u.username || u.name }}</Link>
<span v-if="isFriendWith(u.id)" class="sh-sub sh-sub--green">Befreundet</span>
</div>
<div class="sh-row-actions">
<button v-if="!isFriendWith(u.id) && u.id !== me?.id" class="sh-icon-btn sh-icon-btn--green" title="Freund hinzufügen" @click="sendFriendRequest(u.id)">
<UserPlus :size="14" />
</button>
<button v-if="isFriendWith(u.id) && u.id !== me?.id" class="sh-icon-btn sh-icon-btn--primary" title="Nachricht" @click="openChat(u); activeTab = 'chats'">
<MessageCircle :size="14" />
</button>
</div>
</div>
</div>
</div>
<!-- ── Right Panel ─────────────────────────────────── -->
<div :class="['sh-right', { 'sh-right--active': isChatOpen }]">
<!-- Empty state -->
<div v-if="!isChatOpen" class="sh-right-empty">
<div class="sh-empty-icon"><MessageCircle :size="28" /></div>
<strong>Deine Nachrichten</strong>
<span>Wähle einen Chat oder suche nach einem Nutzer</span>
</div>
<!-- Active chat -->
<template v-else>
<!-- Header -->
<div class="sh-chat-head">
<button class="sh-back" @click="closeActiveChat">
<ChevronLeft :size="18" />
</button>
<!-- Guild header -->
<template v-if="guildChatActive">
<div class="sh-chat-user sh-chat-user--guild">
<div class="sh-ava sh-ava--sm sh-ava--guild">
<img v-if="myGuild?.logo_url" :src="myGuild.logo_url" alt="">
<span v-else>{{ myGuild?.tag?.[0] ?? '?' }}</span>
</div>
<div>
<span class="sh-chat-uname">[{{ myGuild?.tag }}] {{ myGuild?.name }}</span>
<span class="sh-chat-sub">{{ guildMembers.length }} Mitglieder</span>
</div>
</div>
<button :class="['sh-members-btn', { active: membersOpen }]" @click="membersOpen = !membersOpen" title="Mitgliederliste">
<Users :size="15" />
</button>
</template>
<!-- DM header -->
<template v-else>
<Link :href="`/profile/${selectedPartner.username}`" class="sh-chat-user">
<div class="sh-ava sh-ava--sm">
<img v-if="avatar(selectedPartner)" :src="avatar(selectedPartner)" alt="">
<span v-else>{{ initial(selectedPartner) }}</span>
</div>
<span class="sh-chat-uname">{{ selectedPartner.username || selectedPartner.name }}</span>
</Link>
</template>
</div>
<!-- Messages -->
<div :class="['sh-messages', { 'sh-messages--shifted': membersOpen }]" id="dm-messages">
<div v-if="chatLoading || guildChatLoading" class="sh-loading-center"><Loader2 :size="22" class="spin" /></div>
<template v-else>
<template v-for="item in messageWithDates" :key="item._divider ? item.label : item.id">
<!-- Date divider -->
<div v-if="item._divider" class="sh-date-divider">
<span>{{ item.label }}</span>
</div>
<!-- System message -->
<div v-else-if="item.type === 'system'" class="sh-system-msg">
<span class="sh-system-inner">
<Shield :size="11" />
<b>{{ item.user?.username }}</b> {{ item.message }}
</span>
</div>
<!-- Regular message -->
<div v-else
:class="['sh-msg', { 'sh-msg--mine': isActiveMyMsg(item), 'sh-msg--del': item.is_deleted }]">
<div v-if="!isActiveMyMsg(item)" class="sh-msg-ava">
<img v-if="avatar(item.user)" :src="avatar(item.user)" alt="">
<span v-else>{{ initial(item.user) }}</span>
</div>
<div class="sh-msg-body">
<span v-if="guildChatActive && !isActiveMyMsg(item)" class="sh-msg-sender">{{ item.user?.username }}</span>
<div v-if="item.reply_to && !item.is_deleted" class="sh-reply-quote">
<Reply :size="9" />
<span>{{ shortMsg(item.reply_to.message) }}</span>
</div>
<div class="sh-bubble">
<span v-if="item.is_deleted" class="sh-del-text">Nachricht gelöscht</span>
<template v-else>
<img v-if="isGif(item.message)" :src="gifUrl(item.message)" class="sh-gif" alt="GIF" />
<span v-else>{{ item.message }}</span>
</template>
</div>
<div v-if="!item.is_deleted" class="sh-msg-opts">
<button @click="replyTo = item"><Reply :size="11" /></button>
<button v-if="!isActiveMyMsg(item) && !guildChatActive" @click="openReport(item)"><Flag :size="11" /></button>
</div>
<span class="sh-msg-ts">{{ timeAgo(item.created_at) }}</span>
</div>
</div>
</template>
</template>
</div>
<!-- Guild member list panel -->
<div v-if="guildChatActive && membersOpen" class="sh-members-panel">
<div class="sh-members-head">
<Shield :size="13" />
<span>Mitglieder ({{ guildMembers.length }})</span>
<button @click="membersOpen = false"><X :size="13" /></button>
</div>
<div class="sh-members-list">
<div v-for="m in guildMembers" :key="m.id" class="sh-member-row">
<div class="sh-ava sh-ava--xs">
<img v-if="avatar(m)" :src="avatar(m)" alt="">
<span v-else>{{ initial(m) }}</span>
</div>
<span class="sh-member-name">{{ m.username }}</span>
<span :class="['sh-member-role', `sh-member-role--${m.role}`]">{{ m.role }}</span>
</div>
</div>
</div>
<!-- Reply bar -->
<div v-if="replyTo" class="sh-reply-bar">
<Reply :size="12" />
<span>{{ shortMsg(replyTo.message) }}</span>
<button @click="replyTo = null"><X :size="12" /></button>
</div>
<!-- Emoji picker -->
<div v-if="emojiOpen" class="sh-emoji-wrap" ref="emojiPickerEl"></div>
<!-- GIF picker -->
<div v-if="giphyOpen" class="sh-gif-panel">
<div class="sh-gif-top">
<Search :size="12" />
<input v-model="giphyQuery" @input="onGiphyInput" placeholder="GIF suchen..." />
</div>
<div class="sh-gif-grid">
<div v-if="giphyLoading" class="sh-gif-spin"><Loader2 :size="18" class="spin" /></div>
<img v-else v-for="g in giphyResults" :key="g.id"
:src="g.images.fixed_height_small.url"
@click="sendGif(g.images.original.url)" alt="GIF" />
</div>
</div>
<!-- Input -->
<div class="sh-input-row">
<button :class="['sh-tool', { on: emojiOpen }]" @click="toggleEmoji"><Smile :size="17" /></button>
<button :class="['sh-tool', { on: giphyOpen }]" @click="toggleGiphy"><ImagePlay :size="17" /></button>
<textarea id="dm-input" v-model="input" class="sh-inp" :placeholder="guildChatActive ? 'Nachricht an Gilde...' : 'Nachricht...'" rows="1"
@keydown.enter.exact.prevent="send" />
<button class="sh-send" :disabled="!input.trim() || sending" @click="send">
<Loader2 v-if="sending" :size="15" class="spin" />
<Send v-else :size="15" />
</button>
</div>
</template>
</div>
<!-- ── Report Modal ─────────────────────────────────── -->
<Teleport to="body">
<div v-if="reportModal" class="sh-overlay" @click.self="closeReport">
<div class="sh-modal">
<div class="sh-modal-head">
<span>Nachricht melden</span>
<button @click="closeReport"><X :size="15" /></button>
</div>
<div v-if="reportDone" class="sh-modal-ok">
<CheckCircle2 :size="30" color="#22c55e" />
<p>Gemeldet — Danke!</p>
</div>
<template v-else>
<p class="sh-modal-quote">„{{ shortMsg(reportModal.msg.message) }}"</p>
<div class="sh-reasons">
<button v-for="r in reportReasons" :key="r.value"
:class="['sh-reason', { active: reportReason === r.value }]"
@click="reportReason = r.value">
<component :is="r.icon" :size="14" :style="{ color: r.color }" />
<div>
<b>{{ r.label }}</b>
<span>{{ r.desc }}</span>
</div>
</button>
</div>
<button class="sh-submit" :disabled="!reportReason || reportSubmitting" @click="submitReport">
<Loader2 v-if="reportSubmitting" :size="13" class="spin" />
<Flag v-else :size="13" />
Absenden
</button>
</template>
</div>
</div>
</Teleport>
</div>
</UserLayout>
</template>
<style scoped>
/* ── Layout ──────────────────────────────────────────── */
.social-hub {
display: flex;
height: calc(100vh - 76px);
overflow: hidden;
background: var(--bg-deep, #050505);
position: relative;
}
/* Background animation orbs */
.social-hub::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 50% at 10% 90%, rgba(223,0,106,0.055) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 90% 10%, rgba(120,40,200,0.035) 0%, transparent 70%);
animation: hub-orb 18s ease-in-out infinite alternate;
pointer-events: none;
z-index: 0;
}
.social-hub > * { position: relative; z-index: 1; }
@keyframes hub-orb {
0% { opacity: 0.6; transform: scale(1) translate(0, 0); }
50% { opacity: 1; transform: scale(1.04) translate(1%, 0.5%); }
100% { opacity: 0.7; transform: scale(1.02) translate(-0.5%, 1%); }
}
/* ── Left panel ──────────────────────────────────────── */
.sh-left {
width: 300px;
min-width: 300px;
display: flex;
flex-direction: column;
border-right: 1px solid #181818;
background: #070707;
overflow: hidden;
transition: transform 0.22s ease;
}
.sh-left-head {
padding: 18px 14px 0;
flex-shrink: 0;
}
.sh-title {
font-size: 18px;
font-weight: 900;
color: #fff;
display: block;
margin-bottom: 12px;
letter-spacing: -0.4px;
background: linear-gradient(90deg, #fff 60%, rgba(223,0,106,0.7));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Tabs */
.sh-tabs {
display: flex;
border-bottom: 1px solid #161616;
}
.sh-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 8px 4px;
border: none;
background: transparent;
color: #484848;
font-size: 11px;
font-weight: 700;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.sh-tab:hover { color: #888; }
.sh-tab.active { color: #fff; border-bottom-color: var(--primary); }
.sh-badge {
background: var(--primary);
color: #fff;
border-radius: 99px;
font-size: 9px;
font-weight: 900;
padding: 1px 5px;
min-width: 15px;
text-align: center;
line-height: 1.4;
}
.sh-badge--warn { background: #f59e0b; }
.sh-badge--muted { background: #333; color: #888; }
/* List area */
.sh-list {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
/* Empty state */
.sh-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #333;
padding: 40px 24px;
text-align: center;
}
.sh-empty strong { font-size: 13px; color: #484848; }
.sh-empty span { font-size: 11.5px; line-height: 1.4; }
/* Conversation row */
.sh-conv-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
background: transparent;
border: none;
width: 100%;
text-align: left;
cursor: pointer;
transition: background 0.12s;
color: inherit;
}
.sh-conv-item:hover { background: #0d0d0d; }
.sh-conv-item.active { background: rgba(223,0,106,0.06); border-right: 2px solid var(--primary); }
.sh-conv-info { flex: 1; min-width: 0; }
.sh-conv-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.sh-conv-name { font-size: 13px; font-weight: 700; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sh-conv-time { font-size: 10px; color: #353535; flex-shrink: 0; }
.sh-conv-preview { font-size: 11.5px; color: #424242; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; margin-top: 2px; }
.sh-unread-pip {
background: var(--primary);
color: #fff;
border-radius: 99px;
font-size: 9px;
font-weight: 900;
padding: 1px 5px;
min-width: 15px;
text-align: center;
flex-shrink: 0;
}
/* Avatar */
.sh-ava {
width: 38px; height: 38px;
border-radius: 50%;
background: #1a1a1a;
border: 1px solid #272727;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 900; color: #ddd;
overflow: hidden; flex-shrink: 0; text-decoration: none;
}
.sh-ava img { width: 100%; height: 100%; object-fit: cover; }
.sh-ava--sm { width: 32px; height: 32px; font-size: 12px; }
/* Friend / search / request row */
.sh-user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
transition: background 0.12s;
}
.sh-user-row:hover { background: #0d0d0d; }
.sh-user-info { flex: 1; min-width: 0; }
.sh-uname { font-size: 13px; font-weight: 700; color: #ddd; text-decoration: none; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sh-uname:hover { color: var(--primary); }
.sh-sub { font-size: 10px; color: #444; display: flex; align-items: center; gap: 3px; margin-top: 1px; }
.sh-sub--green { color: #22c55e; }
/* Action buttons */
.sh-row-actions { display: flex; gap: 4px; flex-shrink: 0; }
.sh-icon-btn {
width: 30px; height: 30px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.06);
background: transparent;
color: #484848;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.15s;
}
.sh-icon-btn--green:hover { background: rgba(34,197,94,0.1); border-color: rgba(34,197,94,0.2); color: #22c55e; }
.sh-icon-btn--red:hover { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.2); color: #ef4444; }
.sh-icon-btn--primary:hover{ background: rgba(223,0,106,0.1); border-color: rgba(223,0,106,0.2); color: var(--primary); }
/* Search bar in panel */
.sh-search-bar {
display: flex; align-items: center; gap: 8px;
margin: 10px 14px 6px;
padding: 7px 10px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 9px;
}
.sh-search-ico { color: #444; flex-shrink: 0; }
.sh-search-inp {
flex: 1; background: transparent; border: none; outline: none;
color: #fff; font-size: 13px;
}
/* ── Right panel ──────────────────────────────────────── */
.sh-right {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
min-width: 0;
}
.sh-right-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #2a2a2a;
text-align: center;
padding: 40px;
}
.sh-right-empty strong { font-size: 15px; color: #3a3a3a; }
.sh-right-empty span { font-size: 12px; line-height: 1.5; color: #2e2e2e; }
.sh-empty-icon {
width: 64px; height: 64px;
border-radius: 50%;
background: radial-gradient(circle, #111 0%, #080808 100%);
border: 1px solid rgba(223,0,106,0.12);
box-shadow: 0 0 24px rgba(223,0,106,0.06);
display: flex; align-items: center; justify-content: center;
margin-bottom: 4px;
color: #272727;
}
/* Chat header */
.sh-chat-head {
height: 54px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px;
border-bottom: 1px solid #141414;
background: #070707;
flex-shrink: 0;
}
.sh-back {
width: 30px; height: 30px; border-radius: 8px;
background: transparent; border: none; color: #555;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: 0.15s; flex-shrink: 0;
}
.sh-back:hover { background: #111; color: #fff; }
.sh-chat-user { display: flex; align-items: center; gap: 9px; text-decoration: none; }
.sh-chat-uname { font-size: 14px; font-weight: 800; color: #fff; }
.sh-chat-uname:hover { color: var(--primary); }
/* Messages */
.sh-messages {
flex: 1;
overflow-y: auto;
padding: 14px 14px 6px;
display: flex;
flex-direction: column;
gap: 5px;
}
.sh-loading-center { flex: 1; display: flex; align-items: center; justify-content: center; color: #333; }
.sh-msg {
display: flex;
align-items: flex-end;
gap: 7px;
max-width: 72%;
}
.sh-msg--mine { align-self: flex-end; flex-direction: row-reverse; }
.sh-msg-ava {
width: 26px; height: 26px; border-radius: 50%;
background: #1a1a1a; border: 1px solid #272727;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 900; color: #ddd;
overflow: hidden; flex-shrink: 0;
}
.sh-msg-ava img { width: 100%; height: 100%; object-fit: cover; }
.sh-msg-body { display: flex; flex-direction: column; gap: 2px; position: relative; min-width: 0; }
.sh-msg--mine .sh-msg-body { align-items: flex-end; }
.sh-reply-quote {
display: flex; align-items: center; gap: 4px;
font-size: 10px; color: #555;
background: rgba(255,255,255,0.03);
border-left: 2px solid #333;
padding: 3px 7px;
border-radius: 4px;
max-width: 220px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sh-bubble {
background: #111;
border: 1px solid #1e1e1e;
border-radius: 16px 16px 16px 4px;
padding: 8px 12px;
font-size: 13.5px; color: #d8d8d8;
line-height: 1.5; word-break: break-word;
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.sh-msg--mine .sh-bubble {
background: var(--primary);
border-color: transparent;
border-radius: 16px 16px 4px 16px;
color: #fff;
box-shadow: 0 2px 8px rgba(223,0,106,0.25);
}
.sh-msg--del .sh-bubble { opacity: 0.45; }
.sh-del-text { font-style: italic; color: #555; font-size: 12px; }
.sh-gif { max-width: 200px; max-height: 150px; border-radius: 8px; display: block; }
.sh-msg-ts { font-size: 9px; color: #333; padding: 0 2px; align-self: flex-end; }
/* Hover opts */
.sh-msg-opts {
display: none;
gap: 2px;
position: absolute;
top: -24px;
right: 0;
background: #111;
border: 1px solid #222;
border-radius: 7px;
padding: 3px 5px;
z-index: 2;
}
.sh-msg--mine .sh-msg-opts { right: auto; left: 0; }
.sh-msg-body:hover .sh-msg-opts { display: flex; }
.sh-msg-opts button {
background: transparent; border: none; color: #555;
cursor: pointer; display: flex; align-items: center;
padding: 3px; border-radius: 4px; transition: 0.12s;
}
.sh-msg-opts button:hover { color: #ccc; }
/* Reply bar */
.sh-reply-bar {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px;
background: rgba(255,255,255,0.02);
border-top: 1px solid #151515;
font-size: 12px; color: #555;
flex-shrink: 0;
}
.sh-reply-bar span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sh-reply-bar button { background: transparent; border: none; color: #444; cursor: pointer; display: flex; align-items: center; transition: 0.12s; }
.sh-reply-bar button:hover { color: #aaa; }
/* Emoji */
.sh-emoji-wrap {
position: absolute; bottom: 56px; left: 10px; z-index: 50;
}
.sh-emoji-wrap :deep(em-emoji-picker) { --shadow: none; --border-radius: 12px; height: 320px; }
/* GIF panel */
.sh-gif-panel {
position: absolute; bottom: 56px; left: 10px;
width: 320px;
background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: 12px;
z-index: 50; overflow: hidden;
}
.sh-gif-top {
display: flex; align-items: center; gap: 7px;
padding: 8px 11px; border-bottom: 1px solid #181818; color: #555;
}
.sh-gif-top input { flex: 1; background: transparent; border: none; outline: none; color: #fff; font-size: 12.5px; }
.sh-gif-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px;
padding: 6px; max-height: 240px; overflow-y: auto;
}
.sh-gif-grid img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 5px; cursor: pointer; transition: 0.12s; }
.sh-gif-grid img:hover { opacity: 0.8; }
.sh-gif-spin { grid-column: 1/-1; display: flex; justify-content: center; padding: 18px; color: #333; }
/* Input row */
.sh-input-row {
display: flex; align-items: flex-end; gap: 5px;
padding: 8px 10px;
border-top: 1px solid #131313;
background: #070707;
flex-shrink: 0;
}
.sh-tool {
width: 32px; height: 32px; border-radius: 8px;
background: transparent; border: none; color: #404040;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: 0.12s; flex-shrink: 0;
}
.sh-tool:hover, .sh-tool.on { color: var(--primary); background: rgba(223,0,106,0.07); }
.sh-inp {
flex: 1;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
padding: 7px 12px;
color: #fff; font-size: 13.5px; font-family: inherit;
outline: none; resize: none; max-height: 100px; line-height: 1.4;
}
.sh-inp:focus { border-color: rgba(223,0,106,0.22); }
.sh-send {
width: 34px; height: 34px; border-radius: 9px;
background: var(--primary); border: none; color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: 0.15s; flex-shrink: 0;
}
.sh-send:hover:not(:disabled) { box-shadow: 0 3px 12px rgba(223,0,106,0.3); transform: translateY(-1px); }
.sh-send:disabled { opacity: 0.35; cursor: default; }
/* ── Report Modal ─────────────────────────────────────── */
.sh-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.sh-modal {
background: #0d0d0d; border: 1px solid #1e1e1e; border-radius: 16px;
width: 360px; max-width: 95vw; overflow: hidden;
}
.sh-modal-head {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px; border-bottom: 1px solid #181818;
font-size: 14px; font-weight: 800; color: #fff;
}
.sh-modal-head button { background: transparent; border: none; color: #555; cursor: pointer; display: flex; align-items: center; transition: 0.12s; }
.sh-modal-head button:hover { color: #fff; }
.sh-modal-ok { padding: 36px; display: flex; flex-direction: column; align-items: center; gap: 10px; font-size: 14px; color: #888; }
.sh-modal-quote { padding: 10px 16px; font-size: 12px; color: #555; font-style: italic; border-bottom: 1px solid #181818; margin: 0; }
.sh-reasons { display: flex; flex-direction: column; gap: 3px; padding: 8px 10px; }
.sh-reason {
display: flex; align-items: center; gap: 10px;
padding: 9px 10px; border-radius: 9px;
border: 1px solid transparent; background: transparent;
cursor: pointer; text-align: left; transition: 0.12s; color: inherit; width: 100%;
}
.sh-reason:hover { background: rgba(255,255,255,0.04); }
.sh-reason.active { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); }
.sh-reason b { display: block; font-size: 12.5px; color: #ddd; }
.sh-reason span { font-size: 11px; color: #555; }
.sh-submit {
display: flex; align-items: center; justify-content: center; gap: 6px;
width: calc(100% - 20px); margin: 4px 10px 12px;
height: 38px; border-radius: 9px;
background: var(--primary); border: none; color: #fff;
font-size: 13px; font-weight: 800; cursor: pointer; transition: 0.15s;
}
.sh-submit:disabled { opacity: 0.4; cursor: default; }
.sh-submit:not(:disabled):hover { box-shadow: 0 3px 12px rgba(223,0,106,0.3); transform: translateY(-1px); }
/* ── Responsive ───────────────────────────────────────── */
@media (max-width: 768px) {
.sh-left {
position: absolute;
inset: 0;
width: 100%;
min-width: 0;
z-index: 2;
}
/* Hide left when chat is open on mobile */
.sh-left--hidden {
transform: translateX(-100%);
pointer-events: none;
}
.sh-right {
position: absolute;
inset: 0;
z-index: 3;
transform: translateX(100%);
transition: transform 0.22s ease;
background: var(--bg-deep, #050505);
}
.sh-right--active {
transform: translateX(0);
}
/* Always show back button on mobile */
.sh-back { display: flex !important; }
}
@media (min-width: 769px) {
.sh-back { display: none; }
.sh-tab-label { display: inline; }
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 0.7s linear infinite; }
/* ── Date divider ────────────────────────────────────── */
.sh-date-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0 6px;
color: #2e2e2e;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
flex-shrink: 0;
}
.sh-date-divider::before,
.sh-date-divider::after {
content: '';
flex: 1;
height: 1px;
background: #181818;
}
.sh-date-divider span { white-space: nowrap; }
/* ── System message ──────────────────────────────────── */
.sh-system-msg {
display: flex;
justify-content: center;
margin: 4px 0;
}
.sh-system-inner {
display: inline-flex;
align-items: center;
gap: 5px;
background: rgba(223,0,106,0.06);
border: 1px solid rgba(223,0,106,0.12);
border-radius: 99px;
padding: 3px 12px;
font-size: 11px;
color: #666;
}
.sh-system-inner svg { color: rgba(223,0,106,0.5); flex-shrink: 0; }
.sh-system-inner b { color: #999; font-weight: 700; }
/* ── Guild chat ──────────────────────────────────────── */
.sh-conv-item--guild {
border-left: 2px solid var(--primary, #df006a);
background: rgba(223,0,106,0.03);
}
.sh-conv-item--guild:hover { background: rgba(223,0,106,0.06); }
.sh-conv-item--guild.active { background: rgba(223,0,106,0.08); }
.sh-ava--guild {
background: linear-gradient(135deg, #1a0a12 0%, #2d0f1e 100%);
border: 1px solid rgba(223,0,106,0.35);
color: var(--primary, #df006a);
font-weight: 700;
}
.sh-ava--xs { width: 26px; height: 26px; font-size: 9px; }
.sh-chat-user { flex: 1; min-width: 0; }
.sh-chat-user--guild { flex-direction: row; align-items: center; gap: 10px; }
.sh-chat-sub {
display: block;
font-size: 11px;
color: #555;
margin-top: 1px;
}
.sh-members-btn {
width: 30px; height: 30px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.06);
background: transparent; color: #444;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: 0.15s; flex-shrink: 0; margin-left: auto;
}
.sh-members-btn:hover, .sh-members-btn.active {
background: rgba(223,0,106,0.1);
border-color: rgba(223,0,106,0.2);
color: var(--primary);
}
.sh-msg-sender {
font-size: 11px;
font-weight: 700;
color: var(--primary, #df006a);
margin-bottom: 2px;
display: block;
}
/* ── Member list panel ───────────────────────────────── */
.sh-messages--shifted { /* no layout shift needed — panel overlays */ }
.sh-members-panel {
position: absolute;
top: 54px; /* below header */
right: 0;
bottom: 0;
width: 210px;
background: #080808;
border-left: 1px solid #181818;
display: flex;
flex-direction: column;
z-index: 10;
animation: slide-in-right 0.18s ease;
}
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.sh-members-head {
display: flex; align-items: center; gap: 7px;
padding: 10px 12px;
border-bottom: 1px solid #161616;
font-size: 12px; font-weight: 800; color: #888;
}
.sh-members-head svg { color: rgba(223,0,106,0.6); }
.sh-members-head span { flex: 1; }
.sh-members-head button {
background: transparent; border: none; color: #444;
cursor: pointer; display: flex; align-items: center;
padding: 2px; border-radius: 4px; transition: 0.12s;
}
.sh-members-head button:hover { color: #ccc; }
.sh-members-list {
flex: 1; overflow-y: auto; padding: 6px 0;
}
.sh-member-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px;
transition: background 0.12s;
}
.sh-member-row:hover { background: #0d0d0d; }
.sh-member-name {
flex: 1; font-size: 12px; font-weight: 600; color: #ccc;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sh-member-role {
font-size: 9px; font-weight: 800; letter-spacing: 0.4px;
text-transform: uppercase; border-radius: 4px;
padding: 1px 6px; flex-shrink: 0;
}
.sh-member-role--owner { background: rgba(223,0,106,0.15); color: var(--primary); }
.sh-member-role--officer { background: rgba(245,158,11,0.12); color: #f59e0b; }
.sh-member-role--member { background: rgba(255,255,255,0.05); color: #555; }
</style>