mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
386 lines
13 KiB
Vue
386 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { t } from './i18n';
|
|
import { useScoreboardStore } from './stores/scoreboard';
|
|
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
|
|
|
|
// ── Sidebar collapse ──────────────────────────────────────────────────────────
|
|
const LS_KEY = 'sidebar_collapsed';
|
|
const isCollapsed = ref(localStorage.getItem(LS_KEY) === 'true');
|
|
const drawerWidth = computed(() => (isCollapsed.value ? 60 : 220));
|
|
watch(isCollapsed, (val) => localStorage.setItem(LS_KEY, String(val)));
|
|
const toggleCollapse = () => { isCollapsed.value = !isCollapsed.value; };
|
|
|
|
// ── Version ───────────────────────────────────────────────────────────────────
|
|
const appVersion = import.meta.env.PACKAGE_VERSION as string | undefined;
|
|
|
|
// ── Logo ──────────────────────────────────────────────────────────────────────
|
|
const logoUrl = new URL('./image.png', import.meta.url).href;
|
|
|
|
// ── Menu groups ───────────────────────────────────────────────────────────────
|
|
const mainItems = computed(() => [
|
|
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
|
|
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
|
|
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
|
|
]);
|
|
const configItems = computed(() => [
|
|
{ label: t('menuSettings'), to: '/settings', icon: 'settings' },
|
|
{ label: t('menuAbout'), to: '/about', icon: 'info' },
|
|
]);
|
|
|
|
// ── Online / Offline ──────────────────────────────────────────────────────────
|
|
const isOnline = ref(navigator.onLine);
|
|
|
|
const checkOnline = async () => {
|
|
try {
|
|
await fetch('https://www.google.com/favicon.ico', {
|
|
method: 'HEAD',
|
|
mode: 'no-cors',
|
|
cache: 'no-store',
|
|
});
|
|
isOnline.value = true;
|
|
} catch {
|
|
isOnline.value = false;
|
|
}
|
|
};
|
|
|
|
const onNetworkOnline = () => { isOnline.value = true; };
|
|
const onNetworkOffline = () => { isOnline.value = false; };
|
|
let pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
|
const scoreboardStore = useScoreboardStore();
|
|
const shortcutSettingsStore = useShortcutSettingsStore();
|
|
|
|
const isEditableTarget = (target: EventTarget | null): boolean => {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
return (
|
|
target.isContentEditable ||
|
|
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
|
|
Boolean(target.closest('[contenteditable="true"]'))
|
|
);
|
|
};
|
|
|
|
const onShortcutPress = (event: KeyboardEvent) => {
|
|
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') return;
|
|
const { shortcuts } = shortcutSettingsStore;
|
|
if (isShortcutMatch(event, shortcuts.leftIncrement)) { scoreboardStore.leftScore += 1; event.preventDefault(); return; }
|
|
if (isShortcutMatch(event, shortcuts.leftDecrement)) { scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1); event.preventDefault(); return; }
|
|
if (isShortcutMatch(event, shortcuts.rightIncrement)) { scoreboardStore.rightScore += 1; event.preventDefault(); return; }
|
|
if (isShortcutMatch(event, shortcuts.rightDecrement)) { scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1); event.preventDefault(); }
|
|
};
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('keydown', onShortcutPress);
|
|
window.addEventListener('online', onNetworkOnline);
|
|
window.addEventListener('offline', onNetworkOffline);
|
|
pingInterval = setInterval(checkOnline, 15_000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', onShortcutPress);
|
|
window.removeEventListener('online', onNetworkOnline);
|
|
window.removeEventListener('offline', onNetworkOffline);
|
|
if (pingInterval) clearInterval(pingInterval);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<QLayout view="lHh LpR fFf">
|
|
<QDrawer
|
|
show-if-above
|
|
side="left"
|
|
bordered
|
|
:width="drawerWidth"
|
|
class="sidebar-drawer"
|
|
>
|
|
<!-- ── Header ─────────────────────────────────────────── -->
|
|
<div class="sidebar-header" :class="{ 'is-collapsed': isCollapsed }">
|
|
<img :src="logoUrl" alt="Logo" class="sidebar-logo">
|
|
|
|
<Transition name="slide-fade">
|
|
<div v-if="!isCollapsed" class="sidebar-title">
|
|
<span class="title-text">Scoreko-dev</span>
|
|
<span v-if="appVersion" class="title-version">v{{ appVersion }}</span>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Chevron — siempre visible, arriba a la derecha -->
|
|
<QBtn
|
|
flat
|
|
round
|
|
dense
|
|
size="sm"
|
|
:icon="isCollapsed ? 'chevron_right' : 'chevron_left'"
|
|
class="collapse-btn"
|
|
@click="toggleCollapse"
|
|
/>
|
|
</div>
|
|
|
|
<QSeparator />
|
|
|
|
<!-- ── Sección MAIN ───────────────────────────────────── -->
|
|
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
|
|
<span v-if="!isCollapsed" class="section-label">MAIN</span>
|
|
</div>
|
|
|
|
<QList padding>
|
|
<QItem
|
|
v-for="item in mainItems"
|
|
:key="item.to"
|
|
clickable
|
|
:to="item.to"
|
|
exact
|
|
active-class="sidebar-item-active"
|
|
:class="{ 'nav-item-collapsed': isCollapsed }"
|
|
>
|
|
<QItemSection avatar>
|
|
<QIcon :name="item.icon" size="sm" />
|
|
</QItemSection>
|
|
<QItemSection v-if="!isCollapsed">
|
|
<QItemLabel>{{ item.label }}</QItemLabel>
|
|
</QItemSection>
|
|
<QTooltip
|
|
v-if="isCollapsed"
|
|
anchor="center right"
|
|
self="center left"
|
|
:offset="[10, 0]"
|
|
>
|
|
{{ item.label }}
|
|
</QTooltip>
|
|
</QItem>
|
|
</QList>
|
|
|
|
<!-- ── Sección CONFIG ─────────────────────────────────── -->
|
|
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
|
|
<span v-if="!isCollapsed" class="section-label">CONFIG</span>
|
|
</div>
|
|
|
|
<QList padding>
|
|
<QItem
|
|
v-for="item in configItems"
|
|
:key="item.to"
|
|
clickable
|
|
:to="item.to"
|
|
exact
|
|
active-class="sidebar-item-active"
|
|
:class="{ 'nav-item-collapsed': isCollapsed }"
|
|
>
|
|
<QItemSection avatar>
|
|
<QIcon :name="item.icon" size="sm" />
|
|
</QItemSection>
|
|
<QItemSection v-if="!isCollapsed">
|
|
<QItemLabel>{{ item.label }}</QItemLabel>
|
|
</QItemSection>
|
|
<QTooltip
|
|
v-if="isCollapsed"
|
|
anchor="center right"
|
|
self="center left"
|
|
:offset="[10, 0]"
|
|
>
|
|
{{ item.label }}
|
|
</QTooltip>
|
|
</QItem>
|
|
</QList>
|
|
|
|
<!-- ── Footer: Online / Offline ──────────────────────── -->
|
|
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapsed }">
|
|
<div class="online-row">
|
|
<span class="online-dot" :class="isOnline ? 'dot-online' : 'dot-offline'" />
|
|
<Transition name="slide-fade">
|
|
<span v-if="!isCollapsed" class="online-label">
|
|
{{ isOnline ? 'Online' : 'Offline' }}
|
|
</span>
|
|
</Transition>
|
|
<QTooltip
|
|
v-if="isCollapsed"
|
|
anchor="center right"
|
|
self="center left"
|
|
:offset="[10, 0]"
|
|
>
|
|
{{ isOnline ? 'Online' : 'Offline' }}
|
|
</QTooltip>
|
|
</div>
|
|
</div>
|
|
</QDrawer>
|
|
|
|
<QPageContainer>
|
|
<RouterView />
|
|
</QPageContainer>
|
|
</QLayout>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Drawer shell ─────────────────────────────────────────────────────────── */
|
|
.sidebar-drawer :deep(.q-drawer__content) {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Header ───────────────────────────────────────────────────────────────── */
|
|
.sidebar-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 12px 14px 14px;
|
|
min-height: 64px;
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
transition: padding 0.25s ease;
|
|
}
|
|
.sidebar-header.is-collapsed {
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 10px 4px;
|
|
}
|
|
|
|
.sidebar-logo {
|
|
width: 36px;
|
|
height: 36px;
|
|
object-fit: contain;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.sidebar-title {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
flex: 1;
|
|
}
|
|
.title-text {
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.title-version {
|
|
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
|
font-size: 0.65rem;
|
|
letter-spacing: 0.05em;
|
|
opacity: 0.45;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
/* ── Collapse button ──────────────────────────────────────────────────────── */
|
|
.collapse-btn {
|
|
flex-shrink: 0;
|
|
opacity: 0.4;
|
|
transition: opacity 0.2s ease, transform 0.25s ease;
|
|
}
|
|
.collapse-btn:hover {
|
|
opacity: 1;
|
|
}
|
|
/* en modo expandido queda al extremo derecho */
|
|
.sidebar-header:not(.is-collapsed) .collapse-btn {
|
|
margin-left: auto;
|
|
}
|
|
|
|
/* ── Section separators ───────────────────────────────────────────────────── */
|
|
.section-sep {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 14px 2px;
|
|
min-height: 28px;
|
|
transition: padding 0.2s ease, min-height 0.2s ease;
|
|
}
|
|
.section-sep.is-collapsed {
|
|
padding: 6px 12px 2px;
|
|
min-height: 0;
|
|
}
|
|
.section-sep.is-collapsed::after {
|
|
content: '';
|
|
display: block;
|
|
width: 100%;
|
|
height: 1px;
|
|
background: currentColor;
|
|
opacity: 0.12;
|
|
}
|
|
|
|
.section-label {
|
|
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
|
font-size: 0.62rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
opacity: 0.38;
|
|
}
|
|
|
|
/* ── Nav items (collapsed centrado) ──────────────────────────────────────── */
|
|
.nav-item-collapsed {
|
|
justify-content: center;
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
.nav-item-collapsed :deep(.q-item__section--avatar) {
|
|
min-width: unset;
|
|
padding-right: 0;
|
|
}
|
|
|
|
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
|
.sidebar-footer {
|
|
margin-top: auto;
|
|
padding: 10px 14px;
|
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
|
flex-shrink: 0;
|
|
transition: padding 0.25s ease;
|
|
}
|
|
.sidebar-footer.is-collapsed {
|
|
padding: 10px 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.online-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
position: relative;
|
|
}
|
|
|
|
/* ── Online dot ───────────────────────────────────────────────────────────── */
|
|
.online-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
display: inline-block;
|
|
}
|
|
.dot-online {
|
|
background: #22c55e;
|
|
animation: pulse-green 2s ease-in-out infinite;
|
|
}
|
|
.dot-offline {
|
|
background: #ef4444;
|
|
}
|
|
|
|
.online-label {
|
|
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
|
font-size: 0.7rem;
|
|
letter-spacing: 0.04em;
|
|
opacity: 0.6;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Transitions ──────────────────────────────────────────────────────────── */
|
|
.slide-fade-enter-active,
|
|
.slide-fade-leave-active {
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
}
|
|
.slide-fade-enter-from,
|
|
.slide-fade-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-6px);
|
|
}
|
|
|
|
/* ── Pulse animation ──────────────────────────────────────────────────────── */
|
|
@keyframes pulse-green {
|
|
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55); }
|
|
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
|
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
|
}
|
|
</style> |