522 lines
26 KiB
Vue
522 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { Head, usePage, router } from '@inertiajs/vue3';
|
||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||
import UserLayout from '../../layouts/user/userlayout.vue';
|
||
import { useNotifications } from '@/composables/useNotifications';
|
||
import ConfirmModal from '@/components/ui/ConfirmModal.vue';
|
||
|
||
const page = usePage();
|
||
const { notify } = useNotifications();
|
||
|
||
// Props from controller
|
||
const props = defineProps<{
|
||
guild: null | {
|
||
id: number;
|
||
name: string;
|
||
tag: string;
|
||
logo_url?: string | null;
|
||
description?: string | null;
|
||
points: number;
|
||
members_count: number;
|
||
owner: { id: number; username?: string; name?: string; avatar_url?: string | null };
|
||
members: { id: number; username?: string; name?: string; avatar_url?: string | null; role: string; joined_at?: string | null; wagered?: number }[];
|
||
};
|
||
myRole: string | null;
|
||
canManage: boolean;
|
||
invite: string | null;
|
||
}>();
|
||
|
||
const me = computed(() => page.props.auth?.user);
|
||
const hasGuild = computed(() => !!props.guild);
|
||
|
||
// Sort members by wagered amount (descending)
|
||
const sortedMembers = computed(() => {
|
||
if (!props.guild?.members) return [];
|
||
return [...props.guild.members].sort((a, b) => (b.wagered || 0) - (a.wagered || 0));
|
||
});
|
||
|
||
// Create form
|
||
const cName = ref('');
|
||
const cTag = ref('');
|
||
const cLogoFile = ref<File | null>(null);
|
||
const cLogoPreview = ref<string | null>(null);
|
||
const cDesc = ref('');
|
||
const cErrors = ref<Record<string,string>>({});
|
||
const cBusy = ref(false);
|
||
|
||
function onCLogoChange(e: Event) {
|
||
const file = (e.target as HTMLInputElement).files?.[0] ?? null;
|
||
cLogoFile.value = file;
|
||
cLogoPreview.value = file ? URL.createObjectURL(file) : null;
|
||
}
|
||
|
||
async function createGuild() {
|
||
cBusy.value = true; cErrors.value = {};
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('name', cName.value);
|
||
fd.append('tag', cTag.value);
|
||
if (cDesc.value) fd.append('description', cDesc.value);
|
||
if (cLogoFile.value) fd.append('logo', cLogoFile.value);
|
||
const res = await fetch('/guilds', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||
},
|
||
body: fd,
|
||
});
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({}));
|
||
if (j?.errors) cErrors.value = Object.fromEntries(Object.entries(j.errors).map(([k,v]: any) => [k, (v as string[])[0]]));
|
||
throw new Error(j.message || 'Failed to create guild');
|
||
}
|
||
notify({ type: 'green', title: 'GUILD CREATED', desc: 'Welcome to your new guild!', icon: 'check-circle' });
|
||
router.visit('/guilds', { preserveScroll: true, preserveState: false });
|
||
} catch (e: any) {
|
||
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
|
||
} finally { cBusy.value = false; }
|
||
}
|
||
|
||
// Join form
|
||
const jCode = ref('');
|
||
const jBusy = ref(false);
|
||
async function joinGuild() {
|
||
jBusy.value = true;
|
||
try {
|
||
const res = await fetch('/guilds/join', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||
},
|
||
body: JSON.stringify({ invite_code: jCode.value })
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j.message || 'Failed to join guild');
|
||
|
||
notify({ type: 'green', title: 'JOINED GUILD', desc: 'You are now a member.', icon: 'users' });
|
||
router.visit('/guilds', { preserveScroll: true, preserveState: false });
|
||
} catch (e: any) {
|
||
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
|
||
} finally { jBusy.value = false; }
|
||
}
|
||
|
||
// Manage actions
|
||
const uLogoFile = ref<File | null>(null);
|
||
const uLogoPreview = ref<string | null>(props.guild?.logo_url || null);
|
||
const uDesc = ref(props.guild?.description || '');
|
||
const mBusy = ref(false);
|
||
|
||
function onULogoChange(e: Event) {
|
||
const file = (e.target as HTMLInputElement).files?.[0] ?? null;
|
||
uLogoFile.value = file;
|
||
uLogoPreview.value = file ? URL.createObjectURL(file) : (props.guild?.logo_url || null);
|
||
}
|
||
|
||
async function saveGuild() {
|
||
mBusy.value = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('_method', 'PATCH');
|
||
fd.append('description', uDesc.value || '');
|
||
if (uLogoFile.value) fd.append('logo', uLogoFile.value);
|
||
const res = await fetch('/guilds/update', {
|
||
method: 'POST',
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||
},
|
||
body: fd,
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j.message || 'Failed to update guild');
|
||
notify({ type: 'green', title: 'SAVED', desc: 'Guild settings updated.', icon: 'save' });
|
||
} catch (e: any) {
|
||
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
|
||
} finally { mBusy.value = false; }
|
||
}
|
||
|
||
// Kick Logic
|
||
const kickBusy = ref<number|null>(null);
|
||
const showKickModal = ref(false);
|
||
const userToKick = ref<number|null>(null);
|
||
|
||
function confirmKick(id: number) {
|
||
userToKick.value = id;
|
||
showKickModal.value = true;
|
||
}
|
||
|
||
async function kickUser() {
|
||
if (!userToKick.value) return;
|
||
showKickModal.value = false;
|
||
kickBusy.value = userToKick.value;
|
||
|
||
try {
|
||
const res = await fetch('/guilds/kick', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||
},
|
||
body: JSON.stringify({ user_id: userToKick.value })
|
||
});
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({}));
|
||
throw new Error(j.message || 'Failed to remove member');
|
||
}
|
||
notify({ type: 'green', title: 'KICKED', desc: 'Member removed.', icon: 'user-minus' });
|
||
router.visit('/guilds', { preserveScroll: true, preserveState: false });
|
||
} catch (e: any) {
|
||
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
|
||
} finally { kickBusy.value = null; userToKick.value = null; }
|
||
}
|
||
|
||
// Leave Logic
|
||
const leaveBusy = ref(false);
|
||
const showLeaveModal = ref(false);
|
||
|
||
async function leaveGuild() {
|
||
showLeaveModal.value = false;
|
||
leaveBusy.value = true;
|
||
try {
|
||
const res = await fetch('/guilds/leave', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||
},
|
||
body: JSON.stringify({})
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j.message || 'Failed to leave guild');
|
||
|
||
notify({ type: 'green', title: 'LEFT GUILD', desc: 'You have left the guild.', icon: 'log-out' });
|
||
router.visit('/guilds', { preserveScroll: true, preserveState: false });
|
||
} catch (e: any) {
|
||
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
|
||
} finally { leaveBusy.value = false; }
|
||
}
|
||
|
||
const inviteCode = ref(props.invite || '');
|
||
const regenBusy = ref(false);
|
||
async function regenerateInvite() {
|
||
regenBusy.value = true;
|
||
try {
|
||
const res = await fetch('/guilds/invite/regenerate', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '',
|
||
},
|
||
body: JSON.stringify({})
|
||
});
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j.message || 'Failed to regenerate invite');
|
||
inviteCode.value = j.invite_code;
|
||
notify({ type: 'green', title: 'REGENERATED', desc: 'New invite code created.', icon: 'refresh-cw' });
|
||
} catch (e: any) {
|
||
notify({ type: 'magenta', title: 'ERROR', desc: e.message, icon: 'alert-triangle' });
|
||
} finally { regenBusy.value = false; }
|
||
}
|
||
|
||
function copyInvite() {
|
||
if (!inviteCode.value) return;
|
||
navigator.clipboard.writeText(inviteCode.value);
|
||
notify({ type: 'green', title: 'COPIED', desc: 'Invite code copied to clipboard.', icon: 'copy' });
|
||
}
|
||
|
||
onMounted(() => nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); }));
|
||
</script>
|
||
|
||
<template>
|
||
<UserLayout>
|
||
<Head title="Guilds" />
|
||
|
||
<!-- Modals -->
|
||
<ConfirmModal
|
||
:isOpen="showKickModal"
|
||
:title="$t('guilds.kick')"
|
||
message="Are you sure you want to remove this member from the guild? They will need a new invite to rejoin."
|
||
confirmText="Kick"
|
||
@close="showKickModal = false"
|
||
@confirm="kickUser"
|
||
/>
|
||
|
||
<ConfirmModal
|
||
:isOpen="showLeaveModal"
|
||
:title="$t('guilds.leave')"
|
||
message="WARNING: Leaving the guild will reset your guild contribution. You must hold the button to confirm."
|
||
:confirmText="$t('guilds.leave').toUpperCase()"
|
||
:requireHold="true"
|
||
@close="showLeaveModal = false"
|
||
@confirm="leaveGuild"
|
||
/>
|
||
|
||
<section class="content">
|
||
<div class="wrap">
|
||
<div class="panel main-panel">
|
||
<header class="page-head">
|
||
<div class="head-flex">
|
||
<div class="title-group">
|
||
<div class="title">{{ $t('guilds.title') }}</div>
|
||
<p class="subtitle" v-if="!hasGuild">{{ $t('guilds.subtitle_new') }}</p>
|
||
<p class="subtitle" v-else>{{ $t('guilds.subtitle_member') }}</p>
|
||
</div>
|
||
<div class="security-badge" v-if="hasGuild">
|
||
<i data-lucide="shield-check"></i>
|
||
<span>{{ $t('guilds.tab_active') }}</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- No guild: show Create / Join -->
|
||
<div v-if="!hasGuild" class="grid two">
|
||
<div class="glass-card">
|
||
<div class="card-head"><i data-lucide="plus-circle"></i> {{ $t('guilds.create') }}</div>
|
||
<div class="grid-form">
|
||
<label class="field">
|
||
<span class="lbl">{{ $t('guilds.name') }}</span>
|
||
<input class="inp" v-model="cName" :placeholder="$t('guilds.name')" />
|
||
</label>
|
||
<label class="field">
|
||
<span class="lbl">{{ $t('guilds.tag') }}</span>
|
||
<input class="inp" v-model="cTag" placeholder="TAG (2–6)" />
|
||
</label>
|
||
<label class="field">
|
||
<span class="lbl">{{ $t('guilds.logo_url') }}</span>
|
||
<div class="logo-upload-wrap">
|
||
<div class="logo-preview" :style="cLogoPreview ? { backgroundImage: `url(${cLogoPreview})` } : {}">
|
||
<span v-if="!cLogoPreview">{{ cTag?.[0]?.toUpperCase() || '?' }}</span>
|
||
</div>
|
||
<label class="logo-pick-btn">
|
||
<i data-lucide="upload"></i> Choose Logo
|
||
<input type="file" accept="image/*" class="sr-only" @change="onCLogoChange" />
|
||
</label>
|
||
</div>
|
||
</label>
|
||
<label class="field col-span-2">
|
||
<span class="lbl">{{ $t('guilds.description') }}</span>
|
||
<textarea class="inp" v-model="cDesc" rows="3" placeholder="Short description (max 500)"></textarea>
|
||
</label>
|
||
</div>
|
||
<div class="errors" v-if="Object.keys(cErrors).length">
|
||
<div v-for="(v,k) in cErrors" :key="k" class="err">{{ v }}</div>
|
||
</div>
|
||
<button class="btn-primary mt-4" :disabled="cBusy" @click="createGuild">
|
||
<span v-if="cBusy" class="spinner"></span>
|
||
<span>{{ cBusy ? 'Creating…' : 'Create Guild' }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="glass-card">
|
||
<div class="card-head"><i data-lucide="log-in"></i> {{ $t('guilds.join') }}</div>
|
||
<div class="grid-form">
|
||
<label class="field col-span-2">
|
||
<span class="lbl">{{ $t('guilds.invite_code') }}</span>
|
||
<input class="inp" v-model="jCode" :placeholder="$t('guilds.invite_code')" />
|
||
</label>
|
||
</div>
|
||
<button class="btn-secondary mt-4" :disabled="jBusy || !jCode.trim()" @click="joinGuild">
|
||
<span v-if="jBusy" class="spinner"></span>
|
||
<span>{{ jBusy ? 'Joining…' : 'Join' }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Has guild: dashboard -->
|
||
<div v-else class="guild-dashboard">
|
||
<div class="g-head-card">
|
||
<div class="logo-wrap">
|
||
<div class="logo" :style="{ backgroundImage: props.guild?.logo_url ? `url(${props.guild.logo_url})` : '' }">
|
||
<span v-if="!props.guild?.logo_url">{{ props.guild?.tag?.[0] || 'G' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="meta">
|
||
<div class="g-name">{{ props.guild?.name }} <span class="tag">[{{ props.guild?.tag }}]</span></div>
|
||
<div class="g-desc">{{ props.guild?.description || 'No description yet.' }}</div>
|
||
<div class="g-stats">
|
||
<div class="stat-pill"><i data-lucide="trophy"></i> {{ props.guild?.points }} Points</div>
|
||
<div class="stat-pill"><i data-lucide="users"></i> {{ props.guild?.members_count }} Members</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="controls-grid" v-if="props.canManage">
|
||
<div class="glass-card">
|
||
<div class="card-head"><i data-lucide="link"></i> {{ $t('guilds.invite_code') }}</div>
|
||
<div class="inv-row">
|
||
<input class="inp" :value="inviteCode" readonly />
|
||
<button class="btn-icon" @click="copyInvite" :disabled="!inviteCode" title="Copy"><i data-lucide="copy"></i></button>
|
||
<button class="btn-icon" @click="regenerateInvite" :disabled="regenBusy" title="Regenerate"><i data-lucide="refresh-cw"></i></button>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card">
|
||
<div class="card-head"><i data-lucide="settings"></i> {{ $t('guilds.appearance') }}</div>
|
||
<div class="grid-form">
|
||
<label class="field">
|
||
<span class="lbl">{{ $t('guilds.logo_url') }}</span>
|
||
<div class="logo-upload-wrap">
|
||
<div class="logo-preview" :style="uLogoPreview ? { backgroundImage: `url(${uLogoPreview})` } : {}">
|
||
<span v-if="!uLogoPreview">{{ props.guild?.tag?.[0] || 'G' }}</span>
|
||
</div>
|
||
<label class="logo-pick-btn">
|
||
<i data-lucide="upload"></i> Change Logo
|
||
<input type="file" accept="image/*" class="sr-only" @change="onULogoChange" />
|
||
</label>
|
||
</div>
|
||
</label>
|
||
<label class="field col-span-2">
|
||
<span class="lbl">{{ $t('guilds.description') }}</span>
|
||
<textarea class="inp" v-model="uDesc" rows="2" />
|
||
</label>
|
||
</div>
|
||
<button class="btn-primary mt-3" :disabled="mBusy" @click="saveGuild">{{ mBusy ? 'Saving…' : 'Save Changes' }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="members-section">
|
||
<div class="section-head">
|
||
<h3>{{ $t('guilds.members') }}</h3>
|
||
<span class="sub">{{ $t('guilds.sorted_by_wager') }}</span>
|
||
</div>
|
||
<div class="m-list">
|
||
<div v-for="m in sortedMembers" :key="m.id" class="m-row">
|
||
<div class="m-avatar"><img v-if="m.avatar_url" :src="m.avatar_url" alt="" /><span v-else>{{ (m.username || m.name || 'U')?.[0]?.toUpperCase?.() }}</span></div>
|
||
<div class="m-info">
|
||
<div class="m-name">{{ m.username || m.name || 'User' }} <span v-if="m.id === me?.id" class="you-badge">(You)</span></div>
|
||
<div class="m-wager">{{ $t('guilds.wagered') }}: {{ m.wagered ? m.wagered.toFixed(2) : '0.00' }}</div>
|
||
</div>
|
||
<div class="m-role" :data-role="m.role">{{ m.role }}</div>
|
||
<div class="m-actions">
|
||
<button v-if="props.canManage && m.role !== 'owner' && m.id !== me?.id" class="btn-danger-sm" :disabled="kickBusy===m.id" @click="confirmKick(m.id)">
|
||
<i data-lucide="user-minus"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer-actions">
|
||
<button class="btn-leave" :disabled="leaveBusy" @click="showLeaveModal = true">
|
||
<i data-lucide="log-out"></i> {{ $t('guilds.leave') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</UserLayout>
|
||
</template>
|
||
|
||
<style scoped>
|
||
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; }
|
||
|
||
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
|
||
.wrap { max-width: 1100px; margin: 0 auto; }
|
||
.main-panel { background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.6); }
|
||
|
||
/* Header */
|
||
.page-head { padding: 25px 30px; border-bottom: 1px solid var(--border); background: linear-gradient(to right, rgba(0,242,255,0.03), transparent); }
|
||
.head-flex { display: flex; justify-content: space-between; align-items: center; }
|
||
.title { font-size: 14px; font-weight: 900; color: #fff; letter-spacing: 3px; text-transform: uppercase; }
|
||
.subtitle { color: #555; font-size: 12px; margin-top: 4px; font-weight: 600; }
|
||
.security-badge { display:flex; align-items:center; gap:8px; color: var(--green); background: rgba(0,255,157,0.05); padding:6px 14px; border-radius:50px; border:1px solid rgba(0,255,157,0.1); font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:1px; }
|
||
|
||
/* Cards */
|
||
.grid.two { padding: 30px; display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||
.glass-card { background: #050505; border: 1px solid var(--border); border-radius: 16px; padding: 20px; transition: 0.3s; }
|
||
.glass-card:hover { border-color: #222; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
|
||
.card-head { font-size: 11px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; color: #fff; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }
|
||
.card-head i { width: 14px; color: var(--cyan); }
|
||
|
||
/* Forms */
|
||
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
|
||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||
.lbl { font-size: 10px; font-weight: 900; color: #555; text-transform: uppercase; letter-spacing: 1px; }
|
||
.inp { background: #000; border: 1px solid var(--border); color: #fff; border-radius: 10px; padding: 12px 14px; font-size: 13px; transition: 0.3s; width: 100%; }
|
||
.inp:focus { border-color: var(--cyan); outline: none; box-shadow: 0 0 15px rgba(0,242,255,0.1); }
|
||
.col-span-2 { grid-column: span 2; }
|
||
|
||
/* Buttons */
|
||
.btn-primary { background: var(--cyan); color: #000; border: none; border-radius: 10px; padding: 12px 20px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: 0.3s; display: flex; align-items: center; justify-content: center; gap: 10px; }
|
||
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(0,242,255,0.3); }
|
||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||
|
||
.btn-secondary { background: transparent; border: 1px solid #333; color: #fff; border-radius: 10px; padding: 12px 20px; font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: 0.3s; display: flex; align-items: center; justify-content: center; gap: 10px; }
|
||
.btn-secondary:hover:not(:disabled) { border-color: #fff; background: #111; }
|
||
|
||
.btn-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #111; border: 1px solid var(--border); color: #888; border-radius: 8px; cursor: pointer; transition: 0.2s; }
|
||
.btn-icon:hover { color: #fff; border-color: #444; }
|
||
|
||
.btn-danger-sm { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: rgba(255,62,62,0.1); border: 1px solid rgba(255,62,62,0.2); color: #ff3e3e; border-radius: 8px; cursor: pointer; transition: 0.2s; }
|
||
.btn-danger-sm:hover { background: rgba(255,62,62,0.2); transform: scale(1.1); }
|
||
|
||
.btn-leave { background: transparent; border: 1px solid #333; color: #666; border-radius: 10px; padding: 10px 16px; font-size: 11px; font-weight: 800; text-transform: uppercase; cursor: pointer; transition: 0.3s; display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||
.btn-leave:hover { color: #ff3e3e; border-color: #ff3e3e; background: rgba(255,62,62,0.05); }
|
||
|
||
/* Guild Dashboard */
|
||
.guild-dashboard { padding: 30px; display: flex; flex-direction: column; gap: 30px; }
|
||
|
||
.g-head-card { display: flex; gap: 24px; align-items: center; background: #050505; border: 1px solid var(--border); padding: 24px; border-radius: 16px; position: relative; overflow: hidden; }
|
||
.g-head-card::after { content: ''; position: absolute; top: 0; right: 0; width: 200px; height: 100%; background: linear-gradient(90deg, transparent, rgba(0,242,255,0.03)); pointer-events: none; }
|
||
|
||
.logo { width: 80px; height: 80px; border-radius: 20px; border: 1px solid var(--border); background: #000 center/cover no-repeat; display: grid; place-items: center; font-size: 24px; font-weight: 900; color: #333; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
|
||
.meta { display: flex; flex-direction: column; gap: 8px; }
|
||
.g-name { color: #fff; font-size: 20px; font-weight: 900; letter-spacing: 1px; }
|
||
.tag { color: var(--cyan); font-size: 12px; margin-left: 8px; background: rgba(0,242,255,0.1); padding: 2px 6px; border-radius: 4px; }
|
||
.g-desc { color: #888; font-size: 13px; max-width: 600px; line-height: 1.5; }
|
||
.g-stats { display: flex; gap: 12px; margin-top: 5px; }
|
||
.stat-pill { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 800; color: #bbb; background: #111; padding: 4px 10px; border-radius: 50px; border: 1px solid #222; }
|
||
.stat-pill i { width: 12px; color: var(--gold); }
|
||
|
||
.controls-grid { display: grid; grid-template-columns: 1fr 1.5fr; gap: 24px; }
|
||
.inv-row { display: flex; gap: 10px; }
|
||
|
||
/* Members */
|
||
.members-section { background: #050505; border: 1px solid var(--border); border-radius: 16px; padding: 24px; }
|
||
.section-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid #151515; padding-bottom: 15px; }
|
||
.section-head h3 { font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 1px; color: #fff; margin: 0; }
|
||
.section-head .sub { font-size: 10px; color: #666; font-weight: 700; text-transform: uppercase; }
|
||
|
||
.m-list { display: flex; flex-direction: column; gap: 10px; }
|
||
.m-row { display: grid; grid-template-columns: 40px 1fr auto auto; gap: 15px; align-items: center; background: #0a0a0a; border: 1px solid #151515; border-radius: 12px; padding: 12px 16px; transition: 0.2s; }
|
||
.m-row:hover { border-color: #333; background: #0e0e0e; }
|
||
|
||
.m-avatar { width: 40px; height: 40px; border: 1px solid #222; background: #000; color: #444; display: grid; place-items: center; border-radius: 10px; overflow: hidden; font-weight: 800; }
|
||
.m-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||
|
||
.m-info { display: flex; flex-direction: column; gap: 2px; }
|
||
.m-name { color: #fff; font-weight: 800; font-size: 13px; }
|
||
.you-badge { font-size: 9px; color: var(--cyan); margin-left: 5px; }
|
||
.m-wager { font-size: 10px; color: #666; font-weight: 700; }
|
||
|
||
.m-role { font-size: 10px; font-weight: 900; text-transform: uppercase; padding: 4px 10px; border-radius: 6px; background: #111; color: #888; border: 1px solid #222; }
|
||
.m-role[data-role="owner"] { color: var(--gold); border-color: rgba(247,147,26,0.3); background: rgba(247,147,26,0.05); }
|
||
|
||
/* Logo upload */
|
||
.logo-upload-wrap { display: flex; align-items: center; gap: 12px; }
|
||
.logo-preview { width: 52px; height: 52px; border-radius: 12px; border: 1px solid var(--border); background: #000 center/cover no-repeat; display: grid; place-items: center; font-size: 18px; font-weight: 900; color: #444; flex-shrink: 0; }
|
||
.logo-pick-btn { display: flex; align-items: center; gap: 6px; background: #111; border: 1px solid #333; color: #aaa; border-radius: 8px; padding: 8px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; cursor: pointer; transition: 0.2s; }
|
||
.logo-pick-btn:hover { border-color: var(--cyan); color: var(--cyan); }
|
||
.logo-pick-btn i { width: 13px; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||
|
||
.spinner { width: 16px; height: 16px; border: 2px solid rgba(0,0,0,0.1); border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||
.err { color: #ff3e3e; font-size: 11px; font-weight: 700; }
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||
|
||
@media (max-width: 900px) {
|
||
.grid.two { grid-template-columns: 1fr; }
|
||
.controls-grid { grid-template-columns: 1fr; }
|
||
.g-head-card { flex-direction: column; text-align: center; }
|
||
.g-stats { justify-content: center; }
|
||
}
|
||
</style>
|