1473 lines
59 KiB
Vue
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>
|