feat(frontend): restore ref main navigation

This commit is contained in:
2026-07-30 19:47:27 +00:00
parent 78581c840f
commit b17843b609
11 changed files with 1808 additions and 67 deletions
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed } from 'vue';
import MainNavigationLink from './MainNavigationLink.vue';
import {
buildGlobalNavigation,
isNavigationConfigured,
type MainNavigationLink as MainNavigationLinkItem,
} from './mainNavigation';
import { useMenuPopup } from './useMenuPopup';
const props = defineProps<{
npcMode: number;
voteActive: boolean;
}>();
const entries = computed(() => buildGlobalNavigation(props.npcMode));
const { setRoot, openId, close, toggle } = useMenuPopup();
const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props.voteActive;
</script>
<template>
<nav :ref="setRoot" class="main-global-menu" aria-label="게임 공통 메뉴">
<template v-for="entry in entries" :key="entry.id">
<MainNavigationLink
v-if="entry.kind === 'link'"
:link="entry"
:enabled="isNavigationConfigured(entry)"
:active="isActive(entry)"
/>
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button
class="main-menu-button"
type="button"
:data-menu-id="entry.id"
:aria-expanded="openId === entry.id"
:aria-controls="`global-menu-${entry.id}`"
@click="toggle(entry.id, $event)"
>
{{ entry.label }}
<span class="menu-caret" aria-hidden="true"></span>
</button>
<ul
v-show="openId === entry.id"
:id="`global-menu-${entry.id}`"
class="main-menu-popup__list"
role="menu"
>
<template v-for="item in entry.items" :key="item.id">
<li v-if="item.kind === 'divider'" class="main-menu-divider" role="separator"></li>
<li v-else role="none">
<MainNavigationLink
:link="item"
:enabled="isNavigationConfigured(item)"
role="menuitem"
@navigate="close()"
/>
</li>
</template>
</ul>
</div>
<div v-else class="main-menu-split">
<MainNavigationLink
:link="entry.main"
:enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)"
/>
<button
class="main-menu-button main-menu-split__toggle"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
:aria-expanded="openId === entry.id"
:aria-controls="`global-menu-${entry.id}`"
@click="toggle(entry.id, $event)"
>
<span class="menu-caret" aria-hidden="true"></span>
</button>
<ul
v-show="openId === entry.id"
:id="`global-menu-${entry.id}`"
class="main-menu-popup__list"
role="menu"
>
<template v-for="item in entry.items" :key="item.id">
<li v-if="item.kind === 'divider'" class="main-menu-divider" role="separator"></li>
<li v-else role="none">
<MainNavigationLink
:link="item"
:enabled="isNavigationConfigured(item)"
role="menuitem"
@navigate="close()"
/>
</li>
</template>
</ul>
</div>
</template>
</nav>
</template>
<style scoped>
.main-global-menu {
position: relative;
z-index: 30;
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 1.6px;
width: 100%;
white-space: nowrap;
}
.main-menu-popup,
.main-menu-split {
position: relative;
min-width: 0;
}
.main-menu-popup > .main-menu-button,
.main-menu-split > :deep(.main-menu-link) {
width: 100%;
}
.main-menu-split {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
}
.main-menu-split__toggle {
min-width: 28px;
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
}
.menu-caret {
display: inline-block;
margin-left: 5px;
border-top: 4px solid currentColor;
border-right: 4px solid transparent;
border-left: 4px solid transparent;
vertical-align: middle;
}
.main-menu-popup__list {
position: absolute;
z-index: 120;
top: calc(100% + 2px);
left: 0;
min-width: max(100%, 180px);
margin: 0;
border: 1px solid #282828;
border-radius: 3px;
padding: 4px 0;
background: #202020;
box-shadow: 0 8px 18px rgb(0 0 0 / 45%);
list-style: none;
}
.main-menu-split .main-menu-popup__list {
right: 0;
left: auto;
}
.main-menu-popup__list :deep(.main-menu-link) {
min-height: 30px;
justify-content: flex-start;
border: 0;
border-radius: 0;
padding: 5px 16px;
background: transparent;
}
.main-menu-divider {
height: 1px;
margin: 4px 0;
background: #555;
}
@media (max-width: 939.98px) {
.main-global-menu {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,351 @@
<script setup lang="ts">
import { computed } from 'vue';
import MainNavigationLink from './MainNavigationLink.vue';
import {
buildGlobalNavigation,
isNationNavigationEnabled,
isNavigationConfigured,
nationNavigation,
quickNavigation,
type MainNavigationLink as MainNavigationLinkItem,
type NationNavigationAccess,
type QuickNavigationItem,
} from './mainNavigation';
import { useMenuPopup } from './useMenuPopup';
const props = defineProps<{
access: NationNavigationAccess;
tournamentStage: number;
nationColor: string;
npcMode: number;
}>();
const emit = defineEmits<{
refresh: [];
lobby: [];
quick: [item: QuickNavigationItem];
}>();
const { setRoot, openId, close, toggle } = useMenuPopup();
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode));
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
const onQuick = (item: QuickNavigationItem) => {
close();
emit('quick', item);
};
</script>
<template>
<nav
:ref="setRoot"
class="main-mobile-bottom"
:style="{ '--nation-menu-color': nationColor || '#000000' }"
aria-label="모바일 빠른 메뉴"
>
<div class="bottom-item">
<button
class="bottom-trigger"
type="button"
data-bottom-menu="global"
:aria-expanded="openId === 'global'"
aria-controls="mobile-global-menu"
@click="toggle('global', $event)"
>
외부 메뉴
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === 'global'" id="mobile-global-menu" class="bottom-popup" role="menu">
<template v-for="entry in globalEntries" :key="entry.id">
<li v-if="entry.kind === 'link'" role="none">
<MainNavigationLink
:link="entry"
:enabled="isNavigationConfigured(entry)"
compact
role="menuitem"
@navigate="close()"
/>
</li>
<template v-else>
<li class="bottom-heading" role="presentation">
{{ entry.kind === 'group' ? entry.label : entry.main.label }}
</li>
<template v-if="entry.kind === 'split'">
<li role="none">
<MainNavigationLink
:link="entry.main"
:enabled="isNavigationConfigured(entry.main)"
compact
role="menuitem"
@navigate="close()"
/>
</li>
</template>
<template v-for="item in entry.items" :key="item.id">
<li v-if="item.kind === 'divider'" class="bottom-divider" role="separator"></li>
<li v-else role="none">
<MainNavigationLink
:link="item"
:enabled="isNavigationConfigured(item)"
compact
role="menuitem"
@navigate="close()"
/>
</li>
</template>
</template>
</template>
</ul>
</div>
<div class="bottom-item nation-bottom-item">
<button
class="bottom-trigger nation-trigger"
type="button"
data-bottom-menu="nation"
:aria-expanded="openId === 'nation'"
aria-controls="mobile-nation-menu"
@click="toggle('nation', $event)"
>
국가 메뉴
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === 'nation'" id="mobile-nation-menu" class="bottom-popup" role="menu">
<template v-for="entry in nationNavigation" :key="entry.id">
<li v-if="entry.kind === 'link'" role="none">
<MainNavigationLink
:link="entry"
:enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)"
compact
role="menuitem"
@navigate="close()"
/>
</li>
<template v-else-if="entry.kind === 'split'">
<li role="none">
<MainNavigationLink
:link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)"
compact
role="menuitem"
@navigate="close()"
/>
</li>
<li v-for="item in entry.items" :key="item.id" role="none">
<template v-if="item.kind === 'link'">
<MainNavigationLink
:link="item"
:enabled="isNationNavigationEnabled(item, access)"
compact
role="menuitem"
@navigate="close()"
/>
</template>
</li>
</template>
</template>
</ul>
</div>
<div class="bottom-item">
<button
class="bottom-trigger quick-trigger"
type="button"
data-bottom-menu="quick"
:aria-expanded="openId === 'quick'"
aria-controls="mobile-quick-menu"
@click="toggle('quick', $event)"
>
빠른 이동
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === 'quick'" id="mobile-quick-menu" class="bottom-popup" role="menu">
<template v-for="item in quickNavigation" :key="item.id">
<li v-if="'kind' in item" class="bottom-divider" role="separator"></li>
<li v-else role="none">
<button
class="quick-link"
type="button"
role="menuitem"
:data-quick-id="item.id"
@click="onQuick(item)"
>
{{ item.label }}
</button>
</li>
</template>
<li class="bottom-lobby" role="none">
<button class="quick-link lobby-link" type="button" role="menuitem" @click="emit('lobby')">
로비로
</button>
</li>
</ul>
</div>
<button
class="bottom-trigger refresh-trigger"
type="button"
data-bottom-menu="refresh"
@click="emit('refresh')"
>
갱신
</button>
</nav>
</template>
<style scoped>
.main-mobile-bottom {
position: fixed;
z-index: 99;
bottom: 0;
left: 50%;
display: none;
width: 500px;
max-width: 100vw;
height: 45px;
grid-template-columns: repeat(4, 125px);
transform: translateX(-50%);
border-top: 1px solid #111;
background: #202020;
box-shadow: 0 -1px 0 #212529;
}
.bottom-item {
position: relative;
}
.bottom-trigger {
box-sizing: border-box;
width: 125px;
height: 45px;
border: 1px solid #1f1712;
padding: 6px 4px;
background: #302016 var(--sammo-texture-walnut);
color: #fff;
font-family: inherit;
font-size: 16px;
line-height: 1.5;
text-align: center;
cursor: pointer;
}
.nation-trigger {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000);
background: var(--nation-menu-color);
}
.quick-trigger {
background: #212529;
}
.bottom-trigger:hover,
.bottom-trigger:focus-visible,
.bottom-trigger[aria-expanded='true'] {
filter: brightness(1.14);
}
.bottom-trigger:active {
filter: brightness(0.82);
}
.dropup-caret {
display: inline-block;
margin-left: 4px;
border-right: 4px solid transparent;
border-bottom: 4px solid currentColor;
border-left: 4px solid transparent;
vertical-align: middle;
}
.bottom-popup {
position: absolute;
z-index: 120;
bottom: 47px;
left: 0;
columns: 3;
width: max-content;
min-width: 375px;
max-width: 500px;
max-height: calc(100vh - 50px);
margin: 0;
border: 1px solid #282828;
border-radius: 3px;
padding: 4px 0;
overflow-y: auto;
background: #202020;
box-shadow: 0 -8px 18px rgb(0 0 0 / 45%);
list-style: none;
}
.nation-bottom-item .bottom-popup {
left: -125px;
}
.bottom-item:nth-child(3) .bottom-popup {
right: -125px;
left: auto;
}
.bottom-popup li {
break-inside: avoid;
min-width: 125px;
font-size: 16px;
}
.bottom-popup :deep(.main-menu-link),
.quick-link {
box-sizing: border-box;
display: flex;
width: 100%;
min-height: 34px;
align-items: center;
justify-content: flex-start;
border: 0;
border-radius: 0;
padding: 5px 16px;
background: transparent;
color: #fff;
font-family: inherit;
font-size: 16px;
line-height: 1.5;
text-align: left;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.bottom-popup :deep(.main-menu-link:hover),
.bottom-popup :deep(.main-menu-link:focus-visible),
.quick-link:hover,
.quick-link:focus-visible {
background: #353535;
}
.bottom-heading {
padding: 5px 16px;
color: #888;
white-space: nowrap;
}
.bottom-divider {
height: 1px;
margin: 4px 0;
background: #555;
}
.bottom-lobby {
margin-top: 4px;
}
.lobby-link {
justify-content: center;
background: #302016 var(--sammo-texture-walnut);
}
@media (max-width: 939.98px) {
.main-mobile-bottom {
display: grid;
}
}
</style>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import MainNavigationLink from './MainNavigationLink.vue';
import {
isNationNavigationEnabled,
nationNavigation,
type MainNavigationLink as MainNavigationLinkItem,
type NationNavigationAccess,
} from './mainNavigation';
import { useMenuPopup } from './useMenuPopup';
const props = defineProps<{
access: NationNavigationAccess;
tournamentStage: number;
nationColor: string;
}>();
const { setRoot, openId, close, toggle } = useMenuPopup();
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
</script>
<template>
<nav
:ref="setRoot"
class="main-nation-menu"
:style="{ '--nation-menu-color': nationColor || '#000000' }"
aria-label="국가 메뉴"
>
<template v-for="entry in nationNavigation" :key="entry.id">
<MainNavigationLink
v-if="entry.kind === 'link'"
:link="entry"
:enabled="isNationNavigationEnabled(entry, access)"
:active="isActive(entry)"
/>
<div v-else-if="entry.kind === 'split'" class="nation-menu-split">
<MainNavigationLink
:link="entry.main"
:enabled="isNationNavigationEnabled(entry.main, access)"
:active="isActive(entry.main)"
/>
<button
class="main-menu-button nation-menu-split__toggle"
type="button"
:data-menu-id="entry.id"
:aria-label="`${entry.main.label} 하위 메뉴`"
:aria-expanded="openId === entry.id"
:aria-controls="`nation-menu-${entry.id}`"
@click="toggle(entry.id, $event)"
>
<span class="menu-caret" aria-hidden="true"></span>
</button>
<ul v-show="openId === entry.id" :id="`nation-menu-${entry.id}`" class="nation-menu-popup" role="menu">
<li v-for="item in entry.items" :key="item.id" role="none">
<template v-if="item.kind === 'link'">
<MainNavigationLink
:link="item"
:enabled="isNationNavigationEnabled(item, access)"
role="menuitem"
@navigate="close()"
/>
</template>
</li>
</ul>
</div>
</template>
</nav>
</template>
<style scoped>
.main-nation-menu {
position: relative;
z-index: 29;
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 1.6px;
width: 100%;
white-space: nowrap;
}
.main-nation-menu :deep(.main-menu-link),
.main-nation-menu .main-menu-button {
border-color: color-mix(in srgb, var(--nation-menu-color) 85%, #000);
background-color: var(--nation-menu-color);
background-image: none;
}
.nation-menu-split {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 28px;
min-width: 0;
}
.nation-menu-split > :deep(.main-menu-link) {
width: 100%;
}
.nation-menu-split__toggle {
min-width: 28px;
width: 28px;
padding: 0;
border-left-width: 0;
border-radius: 0 3px 3px 0;
}
.menu-caret {
display: inline-block;
border-top: 4px solid currentColor;
border-right: 4px solid transparent;
border-left: 4px solid transparent;
}
.nation-menu-popup {
position: absolute;
z-index: 120;
top: calc(100% + 2px);
right: 0;
min-width: 180px;
margin: 0;
border: 1px solid #282828;
border-radius: 3px;
padding: 4px 0;
background: #202020;
box-shadow: 0 8px 18px rgb(0 0 0 / 45%);
list-style: none;
}
.nation-menu-popup :deep(.main-menu-link) {
min-height: 30px;
justify-content: flex-start;
border: 0;
border-radius: 0;
padding: 5px 16px;
background: transparent;
}
@media (max-width: 939.98px) {
.main-nation-menu {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { MainNavigationLink } from './mainNavigation';
const props = withDefaults(
defineProps<{
link: MainNavigationLink;
enabled?: boolean;
compact?: boolean;
active?: boolean;
}>(),
{
enabled: true,
compact: false,
active: false,
}
);
const emit = defineEmits<{
navigate: [];
}>();
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
const rel = computed(() => (props.link.newTab ? 'noopener noreferrer' : undefined));
</script>
<template>
<RouterLink
v-if="enabled && link.to"
class="main-menu-link"
:class="{ highlight: active }"
:to="link.to"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
:data-navigation-id="link.id"
@click="emit('navigate')"
>
{{ label }}
</RouterLink>
<a
v-else-if="enabled && link.href"
class="main-menu-link"
:class="{ highlight: active }"
:href="link.href"
:target="link.newTab ? '_blank' : undefined"
:rel="rel"
:data-navigation-id="link.id"
@click="emit('navigate')"
>
{{ label }}
</a>
<span
v-else
class="main-menu-link disabled"
role="link"
aria-disabled="true"
:title="link.unavailableReason"
:data-navigation-id="link.id"
>
{{ label }}
</span>
</template>
<style>
.main-menu-link,
.main-menu-button {
box-sizing: border-box;
display: flex;
min-width: 0;
min-height: 31px;
align-items: center;
justify-content: center;
border: 1px solid #1f1712;
border-radius: 3px;
padding: 6px 12px;
background-color: #302016;
background-image: var(--sammo-texture-walnut);
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.main-menu-link:hover,
.main-menu-link:focus-visible,
.main-menu-button:hover,
.main-menu-button:focus-visible {
border-color: #6f5140;
color: #fff;
filter: brightness(1.14);
outline: 2px solid transparent;
}
.main-menu-link:active,
.main-menu-button:active,
.main-menu-button[aria-expanded='true'] {
filter: brightness(0.82);
}
.main-menu-link.highlight,
.main-menu-button.highlight {
color: magenta;
}
.main-menu-link.disabled,
.main-menu-button:disabled {
pointer-events: none;
cursor: not-allowed;
filter: none;
opacity: 0.65;
}
</style>
@@ -0,0 +1,357 @@
export type NationAccessRule =
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
export type MainNavigationLink = {
kind: 'link';
id: string;
label: string;
to?: string;
href?: string;
compactLabel?: string;
newTab?: boolean;
access?: NationAccessRule;
highlightStage?: 1 | 6;
unavailableReason?: string;
};
export interface MainNavigationDivider {
kind: 'divider';
id: string;
}
export interface MainNavigationGroup {
kind: 'group';
id: string;
label: string;
items: Array<MainNavigationLink | MainNavigationDivider>;
}
export interface MainNavigationSplit {
kind: 'split';
id: string;
main: MainNavigationLink;
items: Array<MainNavigationLink | MainNavigationDivider>;
}
export type MainNavigationEntry = MainNavigationLink | MainNavigationGroup | MainNavigationSplit;
export interface NationNavigationAccess {
permission: number;
officerLevel: number;
nationLevel: number;
}
export interface QuickNavigationItem {
id: string;
label: string;
tab: 'map' | 'commands' | 'status' | 'world' | 'messages';
selector: string;
}
const configuredExternalLink = (
id: string,
label: string,
value: string | undefined,
unavailableReason: string
): MainNavigationLink => {
const href = value?.trim();
return {
kind: 'link',
id,
label,
...(href ? { href } : {}),
newTab: true,
unavailableReason: href ? undefined : unavailableReason,
};
};
export const buildGlobalNavigation = (npcMode: number): MainNavigationEntry[] => [
{
kind: 'link',
id: 'nation-betting',
label: '천통국 베팅',
to: '/nation-betting',
},
{
kind: 'group',
id: 'game-info',
label: '게임정보',
items: [
{ kind: 'link', id: 'nation-list', label: '세력일람', to: '/nation-list', newTab: true },
{ kind: 'link', id: 'general-list', label: '장수일람', to: '/general-list', newTab: true },
{ kind: 'link', id: 'best-general', label: '명장일람', to: '/best-general', newTab: true },
{ kind: 'divider', id: 'game-info-divider' },
{ kind: 'link', id: 'hall-of-fame', label: '명예의전당', to: '/hall-of-fame', newTab: true },
{ kind: 'link', id: 'dynasty', label: '왕조일람', to: '/dynasty', newTab: true },
],
},
{ kind: 'link', id: 'yearbook', label: '연감', to: '/yearbook', newTab: true },
{
kind: 'split',
id: 'boards',
main: {
kind: 'link',
id: 'board-community',
label: '게시판',
href: import.meta.env.VITE_BOARD_COMMUNITY_URL?.trim() || '/xe/community',
newTab: true,
},
items: [
configuredExternalLink(
'board-request',
'건의/제안',
import.meta.env.VITE_BOARD_REQUEST_URL,
'건의/제안 게시판 URL이 설정되지 않았습니다.'
),
configuredExternalLink(
'board-tip',
'팁/강좌',
import.meta.env.VITE_BOARD_TIP_URL,
'팁/강좌 게시판 URL이 설정되지 않았습니다.'
),
{ kind: 'divider', id: 'board-divider' },
configuredExternalLink(
'board-patch',
'패치 내역',
import.meta.env.VITE_BOARD_PATCH_URL,
'패치 내역 URL이 설정되지 않았습니다.'
),
],
},
{
kind: 'split',
id: 'open-chat',
main: configuredExternalLink(
'official-chat',
'공식 오픈 톡',
import.meta.env.VITE_OFFICIAL_CHAT_URL,
'공식 오픈톡 URL이 설정되지 않았습니다.'
),
items: [
configuredExternalLink(
'casual-chat',
'잡담 오픈 톡',
import.meta.env.VITE_CASUAL_CHAT_URL,
'잡담 오픈톡 URL이 설정되지 않았습니다.'
),
],
},
{
kind: 'link',
id: 'battle-simulator',
label: '전투 시뮬레이터',
to: '/battle-simulator',
newTab: true,
},
{
kind: 'group',
id: 'other-info',
label: '기타 정보',
items: [
{ kind: 'link', id: 'traffic', label: '접속량정보', to: '/traffic', newTab: true },
...(npcMode > 0
? [{ kind: 'link', id: 'npc-list', label: '빙의일람', to: '/npc-list', newTab: true } as const]
: []),
],
},
{ kind: 'link', id: 'survey', label: '설문조사', to: '/survey', newTab: true },
];
export const nationNavigation: MainNavigationEntry[] = [
{
kind: 'link',
id: 'meeting',
label: '회 의 실',
compactLabel: '회의실',
to: '/board',
access: 'meeting',
},
{
kind: 'link',
id: 'secret-board',
label: '기 밀 실',
compactLabel: '기밀실',
to: '/board/secret',
access: 'secret',
},
{
kind: 'link',
id: 'troop',
label: '부대 편성',
to: '/troop',
access: 'nation-established',
},
{
kind: 'link',
id: 'diplomacy',
label: '외 교 부',
compactLabel: '외교부',
to: '/diplomacy',
access: 'nation-secret',
},
{
kind: 'link',
id: 'personnel',
label: '인 사 부',
compactLabel: '인사부',
to: '/nation/personnel',
access: 'nation-member',
},
{
kind: 'link',
id: 'finance',
label: '내 무 부',
compactLabel: '내무부',
to: '/nation/finance',
access: 'nation-secret',
},
{
kind: 'link',
id: 'chief-center',
label: '사 령 부',
compactLabel: '사령부',
to: '/chief-center',
access: 'nation-secret',
},
{
kind: 'link',
id: 'npc-control',
label: 'NPC 정책',
to: '/npc-control',
access: 'nation-secret',
},
{
kind: 'link',
id: 'nation-secret',
label: '암 행 부',
compactLabel: '암행부',
to: '/nation/secret',
newTab: true,
access: 'nation-secret',
},
{
kind: 'link',
id: 'tournament',
label: '토 너 먼 트',
compactLabel: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
highlightStage: 1,
},
{
kind: 'link',
id: 'nation-info',
label: '세력 정보',
to: '/nation/info',
access: 'nation-member',
},
{
kind: 'link',
id: 'nation-cities',
label: '세력 도시',
to: '/nation/cities',
access: 'nation-established',
},
{
kind: 'link',
id: 'nation-generals',
label: '세력 장수',
to: '/nation/generals',
access: 'nation-member',
},
{ kind: 'link', id: 'global-info', label: '중원 정보', to: '/global-info', access: 'always' },
{ kind: 'link', id: 'current-city', label: '현재 도시', to: '/current-city', access: 'always' },
{
kind: 'link',
id: 'battle-center',
label: '감 찰 부',
compactLabel: '감찰부',
to: '/battle-center',
newTab: true,
access: 'nation-secret',
},
{ kind: 'link', id: 'inherit', label: '유산 관리', to: '/inherit', access: 'always' },
{ kind: 'link', id: 'my-page', label: '내 정보&설정', to: '/my-page', access: 'always' },
{
kind: 'split',
id: 'auction',
main: {
kind: 'link',
id: 'auction-resource',
label: '경 매 장',
compactLabel: '금/쌀 경매장',
to: '/auction',
newTab: true,
access: 'always',
},
items: [
{
kind: 'link',
id: 'auction-resource-menu',
label: '금/쌀 경매장',
to: '/auction',
newTab: true,
access: 'always',
},
{
kind: 'link',
id: 'auction-unique',
label: '유니크 경매장',
to: '/auction?type=unique',
newTab: true,
access: 'always',
},
],
},
{
kind: 'link',
id: 'betting',
label: '베 팅 장',
compactLabel: '베팅장',
to: '/betting',
newTab: true,
access: 'always',
highlightStage: 6,
},
];
export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider> = [
{ kind: 'divider', id: 'quick-nation-heading' },
{ id: 'policy', label: '방침', tab: 'map', selector: '[data-main-target="policy"]' },
{ id: 'commands', label: '명령', tab: 'commands', selector: '[data-main-target="commands"]' },
{ id: 'nation', label: '국가', tab: 'status', selector: '[data-main-target="nation"]' },
{ id: 'general', label: '장수', tab: 'status', selector: '[data-main-target="general"]' },
{ id: 'city', label: '도시', tab: 'status', selector: '[data-main-target="city"]' },
{ kind: 'divider', id: 'quick-record-heading' },
{ id: 'map', label: '지도', tab: 'map', selector: '[data-main-target="map"]' },
{ id: 'global-records', label: '동향', tab: 'world', selector: '[data-main-target="global-records"]' },
{ id: 'general-records', label: '개인', tab: 'world', selector: '[data-main-target="general-records"]' },
{ id: 'world-history', label: '정세', tab: 'world', selector: '[data-main-target="world-history"]' },
{ kind: 'divider', id: 'quick-message-heading' },
{ id: 'public-message', label: '전체', tab: 'messages', selector: '[data-message-type="public"]' },
{ id: 'national-message', label: '국가', tab: 'messages', selector: '[data-message-type="national"]' },
{ id: 'private-message', label: '개인', tab: 'messages', selector: '[data-message-type="private"]' },
{ id: 'diplomacy-message', label: '외교', tab: 'messages', selector: '[data-message-type="diplomacy"]' },
];
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href);
export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
const rule = link.access ?? 'always';
const showSecret = access.permission >= 1 || access.officerLevel >= 2;
switch (rule) {
case 'meeting':
return access.officerLevel >= 1;
case 'secret':
return access.permission >= 2;
case 'nation-member':
return access.officerLevel >= 1;
case 'nation-established':
return access.officerLevel >= 1 && access.nationLevel >= 1;
case 'nation-secret':
return showSecret;
case 'always':
return true;
}
};
@@ -0,0 +1,50 @@
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
export const useMenuPopup = () => {
const root = ref<HTMLElement | null>(null);
const openId = ref<string | null>(null);
let trigger: HTMLElement | null = null;
const close = (restoreFocus = false) => {
openId.value = null;
if (restoreFocus && trigger) {
void nextTick(() => trigger?.focus());
}
};
const toggle = (id: string, event: MouseEvent) => {
const nextOpen = openId.value === id ? null : id;
trigger = nextOpen ? (event.currentTarget as HTMLElement) : null;
openId.value = nextOpen;
};
const setRoot = (element: unknown) => {
root.value = element instanceof HTMLElement ? element : null;
};
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && !root.value?.contains(target)) {
close();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && openId.value) {
event.preventDefault();
close(true);
}
};
onMounted(() => {
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
});
onUnmounted(() => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
});
return { setRoot, openId, close, toggle };
};
+6
View File
@@ -14,6 +14,12 @@ interface ImportMetaEnv {
readonly VITE_GAME_ASSET_URL?: string;
readonly VITE_GAME_PROFILE?: string;
readonly VITE_GATEWAY_WEB_URL?: string;
readonly VITE_BOARD_COMMUNITY_URL?: string;
readonly VITE_BOARD_REQUEST_URL?: string;
readonly VITE_BOARD_TIP_URL?: string;
readonly VITE_BOARD_PATCH_URL?: string;
readonly VITE_OFFICIAL_CHAT_URL?: string;
readonly VITE_CASUAL_CHAT_URL?: string;
}
interface ImportMeta {
+87 -64
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
import PanelCard from '../components/ui/PanelCard.vue';
@@ -13,6 +13,10 @@ import MessagePanel from '../components/main/MessagePanel.vue';
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
import RecordPanel from '../components/main/RecordPanel.vue';
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
import MainNationMenu from '../components/main/MainNationMenu.vue';
import MainMobileBottomBar from '../components/main/MainMobileBottomBar.vue';
import type { QuickNavigationItem } from '../components/main/mainNavigation';
import { formatLog } from '../utils/formatLog';
import { useSessionStore } from '../stores/session';
import { useMainDashboardStore } from '../stores/mainDashboard';
@@ -20,7 +24,7 @@ import { trpc } from '../utils/trpc';
const session = useSessionStore();
const dashboard = useMainDashboardStore();
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useMediaQuery('(max-width: 939.98px)');
const mobileTabs = [
{ key: 'map', label: '지도' },
@@ -34,6 +38,7 @@ type MobileTabKey = (typeof mobileTabs)[number]['key'];
const mobileTab = ref<MobileTabKey>('map');
const tournamentStage = ref(0);
const npcMode = ref(0);
const {
loading,
@@ -64,6 +69,14 @@ const {
realtimeLabel,
} = storeToRefs(dashboard);
const nationAccess = computed(() => ({
permission: boardAccess.value?.permission ?? -1,
officerLevel: general.value?.officerLevel ?? 0,
nationLevel: nation.value?.level ?? 0,
}));
const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
watch(surveyNotice, (notice) => {
if (surveyNoticeTimer) {
@@ -97,8 +110,24 @@ const shiftNationTurns = (amount: number) => {
};
const loadMainData = async () => {
const [, state] = await Promise.all([dashboard.loadMainData(), trpc.tournament.getState.query().catch(() => null)]);
const [, state, worldState] = await Promise.all([
dashboard.loadMainData(),
trpc.tournament.getState.query().catch(() => null),
trpc.world.getState.query().catch(() => null),
]);
tournamentStage.value = state?.stage ?? 0;
npcMode.value = worldState?.config.npcMode ?? 0;
};
const moveLobby = () => {
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
};
const quickNavigate = async (item: QuickNavigationItem) => {
mobileTab.value = item.tab;
await nextTick();
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
document.querySelector<HTMLElement>(item.selector)?.scrollIntoView({ behavior: 'auto', block: 'start' });
};
watch(
@@ -114,65 +143,29 @@ watch(
<template>
<main class="game-shell main-page">
<MainGlobalMenu :npc-mode="npcMode" :vote-active="voteActive" />
<header class="game-shell__header">
<div>
<h1 class="game-shell__title">전장 현황</h1>
<p class="game-shell__subtitle">{{ statusLine }}</p>
</div>
<div class="game-shell__actions">
<RouterLink v-if="boardAccess?.canMeeting" class="game-shell__action" to="/board">회의실</RouterLink>
<span v-else class="game-shell__action disabled" aria-disabled="true">회의실</span>
<RouterLink v-if="boardAccess?.canSecret" class="game-shell__action" to="/board/secret">기밀실</RouterLink>
<span v-else class="game-shell__action disabled" aria-disabled="true">기밀실</span>
<RouterLink class="game-shell__action" to="/nation/info">세력 정보</RouterLink>
<RouterLink class="game-shell__action" to="/nation/cities">세력 도시</RouterLink>
<RouterLink class="game-shell__action" to="/global-info">중원 정보</RouterLink>
<RouterLink class="game-shell__action" to="/nation-list">세력일람</RouterLink>
<RouterLink class="game-shell__action" to="/general-list">장수일람</RouterLink>
<RouterLink class="game-shell__action" to="/current-city">현재 도시</RouterLink>
<RouterLink class="game-shell__action" to="/nation/generals">세력 장수</RouterLink>
<RouterLink v-if="(boardAccess?.permission ?? -1) >= 1" class="game-shell__action" to="/nation/secret"
>암행부</RouterLink
>
<span v-else class="game-shell__action disabled" aria-disabled="true">암행부</span>
<RouterLink class="game-shell__action" to="/nation/personnel">인사부</RouterLink>
<RouterLink class="game-shell__action" to="/troop">부대 편성</RouterLink>
<RouterLink class="game-shell__action" to="/nation/finance">내무부</RouterLink>
<RouterLink class="game-shell__action" to="/diplomacy">외교부</RouterLink>
<RouterLink class="game-shell__action" to="/chief-center">사령부</RouterLink>
<RouterLink class="game-shell__action" to="/battle-center">감찰부</RouterLink>
<RouterLink class="game-shell__action" to="/best-general">명장일람</RouterLink>
<RouterLink class="game-shell__action" to="/hall-of-fame">명예의 전당</RouterLink>
<RouterLink class="game-shell__action" to="/dynasty">왕조일람</RouterLink>
<RouterLink class="game-shell__action" to="/yearbook">연감</RouterLink>
<RouterLink class="game-shell__action" to="/nation-betting">천통국 베팅</RouterLink>
<RouterLink class="game-shell__action" to="/traffic">접속량정보</RouterLink>
<RouterLink class="game-shell__action" to="/npc-list">빙의일람</RouterLink>
<a class="game-shell__action" href="/xe/community" target="_blank" rel="noopener">게시판</a>
<RouterLink class="game-shell__action" to="/battle-simulator">전투 시뮬레이터</RouterLink>
<RouterLink class="game-shell__action" to="/my-page"> 정보&amp;설정</RouterLink>
<RouterLink class="game-shell__action" to="/past-plays"> 지난 플레이</RouterLink>
<RouterLink class="game-shell__action" :class="{ highlight: tournamentStage === 1 }" to="/tournament"
>토너먼트</RouterLink
>
<RouterLink class="game-shell__action" :class="{ highlight: tournamentStage === 6 }" to="/betting"
>베팅장</RouterLink
>
<RouterLink class="game-shell__action" to="/auction">거래장</RouterLink>
<RouterLink class="game-shell__action" to="/survey">설문조사</RouterLink>
<RouterLink class="game-shell__action" to="/npc-control">NPC 정책</RouterLink>
<RouterLink class="game-shell__action" to="/inherit">유산 강화</RouterLink>
<div class="game-shell__actions desktop-action-controls">
<button
class="game-shell__action toggle"
:class="{ active: realtimeEnabled }"
type="button"
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
>
실시간 동기화: {{ realtimeLabel }}
</button>
<button class="game-shell__action" @click="loadMainData">새로고침</button>
<button class="game-shell__action" type="button" @click="loadMainData"> </button>
<button class="game-shell__action" type="button" @click="moveLobby">로비로</button>
</div>
</header>
<MainNationMenu :access="nationAccess" :tournament-stage="tournamentStage" :nation-color="nationColor" />
<div v-if="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
@@ -180,7 +173,9 @@ watch(
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
</div>
<MainFrontStatus :status="frontStatus" />
<div data-main-target="policy">
<MainFrontStatus :status="frontStatus" />
</div>
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
<div class="survey-notice-title">
@@ -203,7 +198,7 @@ watch(
</div>
<div v-if="mobileTab === 'map'" class="mobile-panel">
<PanelCard title="지도">
<PanelCard title="지도" data-main-target="map">
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="선택 도시">
@@ -212,7 +207,7 @@ watch(
</div>
<div v-if="mobileTab === 'commands'" class="mobile-panel">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
<CommandListPanel
:command-table="commandTable"
:loading="loading"
@@ -229,19 +224,19 @@ watch(
</div>
<div v-if="mobileTab === 'status'" class="mobile-panel">
<PanelCard title="장수 스탯">
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
</PanelCard>
<PanelCard title="도시 정보">
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
</PanelCard>
<PanelCard title="국가 정보">
<PanelCard title="국가 정보" data-main-target="nation">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
</div>
<div v-if="mobileTab === 'world'" class="mobile-panel record-zone-mobile">
<RecordPanel title="장수 동향">
<RecordPanel title="장수 동향" data-main-target="global-records">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="global">
@@ -255,7 +250,7 @@ watch(
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel title="개인 기록">
<RecordPanel title="개인 기록" data-main-target="general-records">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="general">
@@ -269,7 +264,7 @@ watch(
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel title="중원 정세">
<RecordPanel title="중원 정세" data-main-target="world-history">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="history">
@@ -311,7 +306,7 @@ watch(
<section v-else class="layout-desktop">
<div class="stack">
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황">
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황" data-main-target="map">
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
</PanelCard>
<PanelCard title="선택 도시">
@@ -320,7 +315,7 @@ watch(
</div>
<div class="stack">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
<CommandListPanel
:command-table="commandTable"
:loading="loading"
@@ -334,18 +329,18 @@ watch(
@shift-nation-turns="shiftNationTurns"
/>
</PanelCard>
<PanelCard title="장수 스탯">
<PanelCard title="장수 스탯" data-main-target="general">
<GeneralBasicCard :general="general" :loading="loading" />
</PanelCard>
<PanelCard title="도시 정보">
<PanelCard title="도시 정보" data-main-target="city">
<CityBasicCard :city="city" :loading="loading" />
</PanelCard>
<PanelCard title="국가 정보">
<PanelCard title="국가 정보" data-main-target="nation">
<NationBasicCard :nation="nation" :loading="loading" />
</PanelCard>
</div>
<section class="record-zone">
<RecordPanel title="장수 동향">
<RecordPanel title="장수 동향" data-main-target="global-records">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="global">
@@ -359,7 +354,7 @@ watch(
<div v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel title="개인 기록">
<RecordPanel title="개인 기록" data-main-target="general-records">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="general">
@@ -373,7 +368,7 @@ watch(
<div v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
</div>
</RecordPanel>
<RecordPanel class="world-history-panel" title="중원 정세">
<RecordPanel class="world-history-panel" title="중원 정세" data-main-target="world-history">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
<div v-else class="record-list" data-record-bucket="history">
@@ -409,6 +404,19 @@ watch(
@delete="dashboard.deleteMessage"
/>
</section>
<MainGlobalMenu class="common-menu-repeat" :npc-mode="npcMode" :vote-active="voteActive" />
<MainGlobalMenu class="common-menu-repeat" :npc-mode="npcMode" :vote-active="voteActive" />
<MainMobileBottomBar
:access="nationAccess"
:tournament-stage="tournamentStage"
:nation-color="nationColor"
:npc-mode="npcMode"
@refresh="loadMainData"
@lobby="moveLobby"
@quick="quickNavigate"
/>
</main>
</template>
@@ -585,4 +593,19 @@ button {
flex-direction: column;
gap: 6px;
}
@media (max-width: 939.98px) {
.main-page {
padding-bottom: 61px;
}
.desktop-action-controls {
display: none;
}
.survey-notice {
z-index: 90;
bottom: 61px;
}
}
</style>