Initialer Laravel Commit für BetiX
This commit is contained in:
521
resources/js/pages/guilds/Index.vue
Normal file
521
resources/js/pages/guilds/Index.vue
Normal file
@@ -0,0 +1,521 @@
|
||||
<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>
|
||||
152
resources/js/pages/guilds/Top.vue
Normal file
152
resources/js/pages/guilds/Top.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { onMounted, nextTick } from 'vue';
|
||||
import UserLayout from '../../layouts/user/userlayout.vue';
|
||||
|
||||
type Guild = { id:number; name:string; tag:string; logo_url?:string|null; points:number; members_count:number };
|
||||
|
||||
const props = defineProps<{ guilds: Guild[] }>();
|
||||
|
||||
onMounted(() => nextTick(() => { if (window.lucide) window.lucide.createIcons(); }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserLayout>
|
||||
<Head :title="$t('guilds.top_title')" />
|
||||
<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.top_title') }}</div>
|
||||
<p class="subtitle">{{ $t('guilds.top_desc') }}</p>
|
||||
</div>
|
||||
<div class="trophy-icon">
|
||||
<i data-lucide="trophy"></i>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="list-container">
|
||||
<div class="list-header-row">
|
||||
<div class="col rank">{{ $t('guilds.rank') }}</div>
|
||||
<div class="col guild">{{ $t('guilds.guild') }}</div>
|
||||
<div class="col spacer"></div>
|
||||
<div class="col stat">{{ $t('guilds.points') }}</div>
|
||||
<div class="col stat">{{ $t('guilds.members_col') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<div v-for="(g, i) in props.guilds" :key="g.id" class="row" :class="`rank-${i+1}`">
|
||||
<div class="rank-cell">
|
||||
<span v-if="i < 3" class="medal">
|
||||
<i data-lucide="medal" v-if="i===0" class="gold"></i>
|
||||
<i data-lucide="medal" v-if="i===1" class="silver"></i>
|
||||
<i data-lucide="medal" v-if="i===2" class="bronze"></i>
|
||||
</span>
|
||||
<span v-else class="num">#{{ i+1 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="logo-cell">
|
||||
<div class="logo" :style="{ backgroundImage: g.logo_url ? `url(${g.logo_url})` : '' }">
|
||||
<span v-if="!g.logo_url">{{ g.tag?.[0] || 'G' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="name-cell">
|
||||
<div class="g-name">{{ g.name }}</div>
|
||||
<div class="g-tag">[{{ g.tag }}]</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="stat-cell points">
|
||||
<i data-lucide="zap" class="stat-icon"></i>
|
||||
<span>{{ g.points.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-cell members">
|
||||
<i data-lucide="users" class="stat-icon"></i>
|
||||
<span>{{ g.members_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.guilds?.length" class="empty-state">
|
||||
<i data-lucide="shield-off"></i>
|
||||
<p>{{ $t('guilds.no_guilds') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</UserLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(:root) { --bg-card:#0a0a0a; --border:#151515; --cyan:#00f2ff; --magenta:#ff007a; --green:#00ff9d; --gold:#f7931a; --silver:#c0c0c0; --bronze:#cd7f32; }
|
||||
|
||||
.content { padding: 30px; animation: fade-in 0.8s cubic-bezier(0.2, 0, 0, 1); }
|
||||
.wrap { max-width: 1000px; 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(247,147,26,0.05), 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; }
|
||||
.trophy-icon { width: 40px; height: 40px; background: rgba(247,147,26,0.1); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--gold); border: 1px solid rgba(247,147,26,0.2); }
|
||||
|
||||
/* List Header */
|
||||
.list-container { padding: 20px; }
|
||||
.list-header-row { display: flex; padding: 0 20px 10px; border-bottom: 1px solid #151515; margin-bottom: 10px; font-size: 10px; font-weight: 900; color: #555; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.col.rank { width: 60px; text-align: center; }
|
||||
.col.guild { width: 200px; }
|
||||
.col.spacer { flex: 1; }
|
||||
.col.stat { width: 100px; text-align: right; }
|
||||
|
||||
/* List Items */
|
||||
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.row { display: flex; align-items: center; background: #050505; border: 1px solid var(--border); border-radius: 14px; padding: 12px 20px; transition: 0.2s; }
|
||||
.row:hover { transform: translateX(5px); border-color: #333; background: #0a0a0a; }
|
||||
|
||||
/* Rank Styling */
|
||||
.rank-cell { width: 60px; display: flex; justify-content: center; align-items: center; font-weight: 900; font-size: 14px; color: #444; }
|
||||
.medal i { width: 20px; height: 20px; }
|
||||
.gold { color: var(--gold); filter: drop-shadow(0 0 5px rgba(247,147,26,0.5)); }
|
||||
.silver { color: var(--silver); filter: drop-shadow(0 0 5px rgba(192,192,192,0.5)); }
|
||||
.bronze { color: var(--bronze); filter: drop-shadow(0 0 5px rgba(205,127,50,0.5)); }
|
||||
|
||||
/* Top 3 Highlights */
|
||||
.rank-1 { border-color: rgba(247,147,26,0.3); background: linear-gradient(90deg, rgba(247,147,26,0.05), transparent); }
|
||||
.rank-2 { border-color: rgba(192,192,192,0.2); }
|
||||
.rank-3 { border-color: rgba(205,127,50,0.2); }
|
||||
|
||||
.logo-cell { margin-right: 15px; }
|
||||
.logo { width: 42px; height: 42px; border-radius: 12px; background: #000 center/cover no-repeat; border: 1px solid #222; display: grid; place-items: center; font-weight: 900; color: #666; }
|
||||
|
||||
.name-cell { display: flex; flex-direction: column; }
|
||||
.g-name { color: #fff; font-weight: 800; font-size: 13px; }
|
||||
.g-tag { color: var(--cyan); font-size: 10px; font-weight: 700; }
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.stat-cell { width: 100px; text-align: right; font-weight: 800; font-size: 13px; color: #ccc; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
||||
.stat-icon { width: 14px; color: #444; }
|
||||
.points { color: var(--gold); }
|
||||
.points .stat-icon { color: var(--gold); opacity: 0.5; }
|
||||
|
||||
.empty-state { padding: 40px; text-align: center; color: #444; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.empty-state i { width: 32px; height: 32px; opacity: 0.5; }
|
||||
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.list-header-row { display: none; }
|
||||
.row { display: grid; grid-template-columns: auto auto 1fr auto; gap: 10px; padding: 15px; }
|
||||
.rank-cell { width: 30px; }
|
||||
.stat-cell { width: auto; font-size: 11px; }
|
||||
.stat-cell.members { display: none; } /* Hide members count on very small screens */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user