feat(menu): PHP 기준 공통 메뉴를 런타임 설정으로 이관

Gateway와 게임 공통 메뉴를 영속 JSON에서 읽고 다음 페이지 로드에 반영한다. 운영 PHP의 항목과 순서, desktop/mobile geometry 및 정보 동작을 보존하고 검증·복구 문서를 추가한다.
This commit is contained in:
2026-08-19 11:13:57 +00:00
parent b43d7601e7
commit a14282b34d
25 changed files with 744 additions and 173 deletions
@@ -5,17 +5,23 @@ import {
buildGlobalNavigation,
isNavigationConfigured,
type MainNavigationLink as MainNavigationLinkItem,
type MainNavigationEntry,
} from './mainNavigation';
import { useMenuPopup } from './useMenuPopup';
const props = defineProps<{
npcMode: number;
voteActive: boolean;
entries?: MainNavigationEntry[];
}>();
const entries = computed(() => buildGlobalNavigation(props.npcMode));
const emit = defineEmits<{
action: [action: NonNullable<MainNavigationLinkItem['action']>];
}>();
const entries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
const { setRoot, openId, close, toggle } = useMenuPopup();
const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props.voteActive;
const isActive = (link: MainNavigationLinkItem) => link.highlightWhen === 'vote' && props.voteActive;
</script>
<template>
@@ -27,6 +33,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(entry)"
:active="isActive(entry)"
lumen-variant="navigation"
@action="emit('action', $event)"
/>
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
<button
@@ -54,6 +61,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(item)"
role="menuitem"
@navigate="close()"
@action="emit('action', $event); close()"
/>
</li>
</template>
@@ -65,6 +73,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(entry.main)"
:active="isActive(entry.main)"
lumen-variant="navigation"
@action="emit('action', $event)"
/>
<button
class="main-menu-button main-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--navigation"
@@ -91,6 +100,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
:enabled="isNavigationConfigured(item)"
role="menuitem"
@navigate="close()"
@action="emit('action', $event); close()"
/>
</li>
</template>
@@ -9,6 +9,7 @@ import {
nationNavigation,
quickNavigation,
type MainNavigationLink as MainNavigationLinkItem,
type MainNavigationEntry,
type NationNavigationAccess,
type QuickNavigationItem,
} from './mainNavigation';
@@ -21,6 +22,7 @@ const props = defineProps<{
npcMode: number;
realtimeEnabled: boolean;
refreshing: boolean;
entries?: MainNavigationEntry[];
}>();
const emit = defineEmits<{
@@ -28,10 +30,11 @@ const emit = defineEmits<{
toggleRealtime: [];
lobby: [];
quick: [item: QuickNavigationItem];
action: [action: NonNullable<MainNavigationLinkItem['action']>];
}>();
const { setRoot, openId, close, toggle } = useMenuPopup();
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode));
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
const nationMenuColor = computed(() => props.nationColor || '#000000');
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
@@ -40,6 +43,10 @@ const onQuick = (item: QuickNavigationItem) => {
close();
emit('quick', item);
};
const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
close();
emit('action', action);
};
</script>
<template>
@@ -73,6 +80,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
<template v-else-if="entry.kind === 'group'">
@@ -88,6 +96,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
</template>
@@ -100,6 +109,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
<template v-for="item in entry.items" :key="item.id">
@@ -111,6 +121,7 @@ const onQuick = (item: QuickNavigationItem) => {
compact
role="menuitem"
@navigate="close()"
@action="onAction"
/>
</li>
</template>
@@ -20,6 +20,7 @@ const props = withDefaults(
const emit = defineEmits<{
navigate: [];
action: [action: NonNullable<MainNavigationLink['action']>];
}>();
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
@@ -54,6 +55,16 @@ const lumenClasses = computed(() =>
>
{{ label }}
</a>
<button
v-else-if="enabled && link.action"
class="main-menu-link main-menu-link--button"
:class="[lumenClasses, { highlight: active }]"
type="button"
:data-navigation-id="link.id"
@click="emit('action', link.action)"
>
{{ label }}
</button>
<span
v-else
class="main-menu-link disabled"
@@ -92,6 +103,10 @@ const lumenClasses = computed(() =>
cursor: pointer;
}
.main-menu-link--button {
width: 100%;
}
.main-menu-link:hover,
.main-menu-link:focus-visible,
.main-menu-button:hover,
@@ -1,14 +1,15 @@
import type {
RuntimeNavigationConfig,
RuntimeNavigationEntry,
RuntimeNavigationLink,
} from '@sammo-ts/common/navigation/menuConfig';
import defaultNavigationJson from '../../../../../resources/navigation.json';
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;
export type MainNavigationLink = RuntimeNavigationLink & {
compactLabel?: string;
newTab?: boolean;
access?: NationAccessRule;
highlightStage?: 1 | 6;
unavailableReason?: string;
@@ -49,114 +50,26 @@ export interface QuickNavigationItem {
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,
};
};
const defaultNavigation = defaultNavigationJson as RuntimeNavigationConfig;
export const defaultGlobalNavigation = defaultNavigation.game.items as MainNavigationEntry[];
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 },
];
const isVisible = (link: MainNavigationLink, npcMode: number): boolean =>
link.showWhen !== 'npc-enabled' || npcMode > 0;
export const buildGlobalNavigation = (
npcMode: number,
source: RuntimeNavigationEntry[] = defaultGlobalNavigation
): MainNavigationEntry[] =>
source.flatMap((entry): MainNavigationEntry[] => {
if (entry.kind === 'link') return isVisible(entry, npcMode) ? [entry] : [];
const items = entry.items.filter(
(item): item is MainNavigationLink | MainNavigationDivider =>
item.kind === 'divider' || isVisible(item, npcMode)
);
if (entry.kind === 'group') return items.length > 0 ? [{ ...entry, items }] : [];
if (!isVisible(entry.main, npcMode)) return [];
return items.length > 0 ? [{ ...entry, items }] : [entry.main];
});
export const nationNavigation: MainNavigationEntry[] = [
{
@@ -336,7 +249,7 @@ export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider>
{ id: 'diplomacy-message', label: '외교', tab: 'messages', selector: '[data-message-type="diplomacy"]' },
];
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href);
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href || link.action);
export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
const rule = link.access ?? 'always';