Files
esports/src/lib/components/Sidebar.svelte

498 lines
18 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { queue, joinQueue, leaveQueue } from '$lib/stores/queue.svelte';
const user = $derived(page.data?.user);
const activeMatch = $derived(page.data?.activeMatch);
const unreadCount = $derived((page.data as any)?.unreadNotificationCount ?? 0);
let { open = $bindable(false) } = $props();
let notifOpen = $state(false);
let notifications = $state<any[]>([]);
let loadingNotifs = $state(false);
function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(href);
}
const isAdmin = $derived(user?.role === 'admin');
// Close sidebar on navigation (mobile)
$effect(() => {
page.url.pathname;
open = false;
notifOpen = false;
});
async function toggleNotifications() {
notifOpen = !notifOpen;
if (notifOpen) {
await loadNotifications();
}
}
async function loadNotifications() {
loadingNotifs = true;
try {
const res = await fetch('/api/notifications');
if (res.ok) notifications = await res.json();
} finally {
loadingNotifs = false;
}
}
async function handleAcceptInvite(referenceId: string) {
const res = await fetch(`/api/invites/${referenceId}/accept`, { method: 'POST' });
if (res.ok) {
const match = await res.json();
notifications = notifications.map((n) =>
n.referenceId === referenceId
? { ...n, actionedAt: new Date().toISOString(), read: true }
: n
);
window.location.href = `/matches/${match.id}`;
}
}
async function handleDeclineInvite(referenceId: string) {
const res = await fetch(`/api/invites/${referenceId}/decline`, { method: 'POST' });
if (res.ok) {
notifications = notifications.map((n) =>
n.referenceId === referenceId
? { ...n, actionedAt: new Date().toISOString(), read: true }
: n
);
}
}
async function markAllRead() {
await fetch('/api/notifications/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
});
notifications = notifications.map((n) => ({ ...n, read: true }));
}
function timeAgo(date: string | Date) {
const d = new Date(date);
const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
</script>
{#snippet navIcon(name: string)}
{#if name === 'dashboard'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect
x="14"
y="14"
width="7"
height="7"
/><rect x="3" y="14" width="7" height="7" /></svg
>
{:else if name === 'matches'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
{:else if name === 'mappools'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="8" cy="18" r="3" /><path d="M11 18V8l9-1v10" /><circle
cx="20"
cy="17"
r="3"
/></svg
>
{:else if name === 'teams'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path
d="M23 21v-2a4 4 0 0 0-3-3.87"
/><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg
>
{:else if name === 'leaderboard'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="18" y1="20" x2="18" y2="10" /><line x1="12" y1="20" x2="12" y2="4" /><line
x1="6"
y1="20"
x2="6"
y2="14"
/></svg
>
{:else if name === 'settings'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/><circle cx="12" cy="12" r="3" /></svg
>
{:else if name === 'admin'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg
>
{/if}
{/snippet}
<!-- Backdrop (mobile only) -->
{#if open}
<button
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onclick={() => (open = false)}
aria-label="Close menu"
></button>
{/if}
<aside
class="fixed top-0 left-0 z-50 flex h-full w-56 flex-col border-r border-border bg-surface-800 transition-transform duration-200 {open
? 'translate-x-0'
: '-translate-x-full'} lg:z-40 lg:translate-x-0"
>
<!-- Logo + Notifications -->
<div class="flex items-center justify-between px-5 py-5">
<a href="/" class="flex items-center gap-2.5">
<img src="/logo.png" alt="Vash Esports" class="h-8 w-8" />
<span class="font-700 text-base font-bold tracking-tight text-text-primary">Vash Esports</span
>
</a>
{#if user}
<div class="relative">
<button
onclick={toggleNotifications}
class="relative rounded-md p-1.5 text-text-secondary transition-colors hover:bg-surface-700 hover:text-text-primary"
aria-label="Notifications"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path
d="M10.3 21a1.94 1.94 0 0 0 3.4 0"
/></svg
>
{#if unreadCount > 0}
<span
class="font-700 absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] text-surface-900"
>{unreadCount > 99 ? '99+' : unreadCount}</span
>
{/if}
</button>
{#if notifOpen}
<!-- Backdrop -->
<button
class="fixed inset-0 z-50"
onclick={() => (notifOpen = false)}
aria-label="Close notifications"
></button>
<!-- Dropdown -->
<div
class="absolute top-full left-0 z-50 mt-2 w-72 rounded-lg border border-border bg-surface-800 shadow-xl"
>
<div class="flex items-center justify-between border-b border-border px-3 py-2">
<span class="font-600 text-xs text-text-secondary">Notifications</span>
{#if notifications.some((n) => !n.read)}
<button onclick={markAllRead} class="text-[10px] text-accent hover:underline"
>Mark all read</button
>
{/if}
</div>
<div class="max-h-80 overflow-y-auto">
{#if loadingNotifs}
<div class="px-3 py-6 text-center text-xs text-text-secondary">Loading...</div>
{:else if notifications.length === 0}
<div class="px-3 py-6 text-center text-xs text-text-secondary">
No notifications
</div>
{:else}
{#each notifications as n}
<div class="border-b border-border/50 px-3 py-2.5 {n.read ? 'opacity-60' : ''}">
<div class="flex items-start gap-2">
{#if !n.read}
<span class="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent"></span>
{/if}
<div class="min-w-0 flex-1">
<p class="font-600 text-xs text-text-primary">{n.title}</p>
{#if n.message}
<p class="mt-0.5 text-[11px] text-text-secondary">{n.message}</p>
{/if}
<p class="mt-0.5 text-[10px] text-text-secondary/60">
{timeAgo(n.createdAt)}
</p>
{#if n.type === 'match_invite' && !n.actionedAt && n.referenceId}
<div class="mt-1.5 flex gap-1.5">
<button
onclick={() => handleAcceptInvite(n.referenceId)}
class="font-600 rounded bg-green-500/20 px-2 py-0.5 text-[10px] text-green-400 transition-colors hover:bg-green-500/30"
>Accept</button
>
<button
onclick={() => handleDeclineInvite(n.referenceId)}
class="font-600 rounded bg-red-500/20 px-2 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/30"
>Decline</button
>
</div>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Queue Button -->
<div class="px-3 pb-4">
{#if activeMatch && page.url.pathname !== `/matches/${activeMatch.id}`}
<a
href="/matches/{activeMatch.id}"
class="font-600 flex w-full items-center justify-center gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2.5 text-sm text-yellow-300 transition-colors hover:bg-yellow-500/20"
>
<div class="relative h-2 w-2">
<div class="absolute inset-0 animate-ping rounded-full bg-yellow-400 opacity-75"></div>
<div class="h-2 w-2 rounded-full bg-yellow-400"></div>
</div>
Go to Match
</a>
{:else if queue.status?.inQueue}
<div class="flex flex-col gap-1.5">
<div
class="font-600 flex items-center justify-center gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2.5 text-sm text-yellow-300"
>
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400"></span>
In Queue ({queue.status.queueSize})
</div>
<button
onclick={leaveQueue}
disabled={queue.loading}
class="flex w-full items-center justify-center rounded-md px-3 py-1.5 text-xs text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
Leave Queue
</button>
</div>
{:else if !activeMatch}
<button
onclick={joinQueue}
disabled={queue.loading}
class="font-600 flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2.5 text-sm text-surface-900 transition-colors hover:bg-accent-hover disabled:opacity-50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14.5 17.5 3 6V3h3l11.5 11.5" /><path d="m13 19 3.5-3.5" /><path
d="m16.5 22 5-5"
/><path d="M10 5.5 6 2H3v3l4 4" /></svg
>
{queue.loading ? 'Joining...' : 'Find Match'}
</button>
{/if}
</div>
<!-- Nav -->
<nav class="mt-0 flex flex-1 flex-col gap-0.5 px-3">
{#each [{ href: '/', label: 'Dashboard', icon: 'dashboard' }, { href: '/matches', label: 'Matches', icon: 'matches' }, { href: '/mappools', label: 'Mappools', icon: 'mappools' }, { href: '/teams', label: 'Teams', icon: 'teams' }, { href: '/leaderboard', label: 'Leaderboard', icon: 'leaderboard' }] as item (item.href)}
<a
href={item.href}
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
item.href
)
? 'bg-accent-dim text-accent'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
{@render navIcon(item.icon)}
{item.label}
</a>
{/each}
{#if isAdmin}
<div class="mt-4 mb-1 px-3">
<p class="font-600 text-[10px] tracking-widest text-text-secondary uppercase">Staff</p>
</div>
<a
href="/admin"
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
'/admin'
)
? 'bg-red-500/10 text-red-400'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
{@render navIcon('admin')}
Admin
</a>
{/if}
</nav>
<!-- Bottom -->
<div class="border-t border-border p-3">
<!-- Social links -->
<div class="mb-1 flex items-center gap-1 px-2 py-2">
<a
href="https://github.com/VashSoftware/esports"
target="_blank"
rel="noopener"
aria-label="GitHub"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/></svg
>
</a>
<a
href="https://x.com/vashesports"
target="_blank"
rel="noopener"
aria-label="X / Twitter"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/></svg
>
</a>
<a
href="https://www.youtube.com/@vash-esports"
target="_blank"
rel="noopener"
aria-label="YouTube"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
/></svg
>
</a>
<a
href="https://www.twitch.tv/vashesports"
target="_blank"
rel="noopener"
aria-label="Twitch"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0 1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"
/></svg
>
</a>
<a
href="https://discord.gg/n3mZgWk"
target="_blank"
rel="noopener"
aria-label="Discord"
class="rounded-md p-1.5 text-text-secondary/50 transition-colors hover:bg-surface-700 hover:text-text-primary"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"
><path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.108.12 18.156.15 18.197a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/></svg
>
</a>
</div>
<a
href="/settings"
class="font-500 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors {isActive(
'/settings'
)
? 'bg-accent-dim text-accent'
: 'text-text-secondary hover:bg-surface-700 hover:text-text-primary'}"
>
{@render navIcon('settings')}
Settings
</a>
<div class="mt-2 flex gap-3 px-3 pb-1">
<a
href="/terms"
class="text-xs text-text-secondary/50 transition-colors hover:text-text-secondary">Terms</a
>
<a
href="/privacy"
class="text-xs text-text-secondary/50 transition-colors hover:text-text-secondary"
>Privacy</a
>
</div>
</div>
</aside>