Files
BetiX/resources/js/pages/guilds/Index.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

522 lines
26 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 (26)" />
</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>