feat(menu): PHP 기준 공통 메뉴를 런타임 설정으로 이관
Gateway와 게임 공통 메뉴를 영속 JSON에서 읽고 다음 페이지 로드에 반영한다. 운영 PHP의 항목과 순서, desktop/mobile geometry 및 정보 동작을 보존하고 검증·복구 문서를 추가한다.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const runtimeNavigation = JSON.parse(
|
||||
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
|
||||
) as unknown;
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
@@ -359,11 +362,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
await page.route('**/events**', async (route) => {
|
||||
await route.abort();
|
||||
});
|
||||
await page.route('**/gateway/api/navigation', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(runtimeNavigation) });
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const results = operations.map((operation) =>
|
||||
operation === 'me'
|
||||
? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' })
|
||||
: operation === 'navigation.get'
|
||||
? response(runtimeNavigation)
|
||||
: response({ ok: true })
|
||||
);
|
||||
await route.fulfill({
|
||||
@@ -924,7 +932,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
);
|
||||
await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute('target', '_blank');
|
||||
await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community');
|
||||
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank');
|
||||
await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/);
|
||||
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
|
||||
|
||||
@@ -971,6 +979,14 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await gameInfoButton.click();
|
||||
await global.locator('[data-navigation-id="version"]').click();
|
||||
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
|
||||
await expect(versionDialog).toBeVisible();
|
||||
await expect(versionDialog).toContainText('메인 화면 검증 시나리오');
|
||||
await versionDialog.getByRole('button', { name: '닫기' }).click();
|
||||
await expect(versionDialog).toBeHidden();
|
||||
|
||||
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||
const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||
await bottomGameInfoButton.click();
|
||||
@@ -995,7 +1011,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('split buttons keep square inner corners and a single divider in every interaction state', async ({
|
||||
test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
@@ -1062,14 +1078,8 @@ test('split buttons keep square inner corners and a single divider in every inte
|
||||
for (const width of [1200, 500]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await waitForMain(page);
|
||||
const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first();
|
||||
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
|
||||
const pairs: Array<[string, Locator, Locator]> = [
|
||||
[
|
||||
'global',
|
||||
globalSplit.locator('[data-navigation-id="board-community"]'),
|
||||
globalSplit.locator('[data-menu-id="boards"]'),
|
||||
],
|
||||
[
|
||||
'nation',
|
||||
nationSplit.locator('[data-navigation-id="auction-resource"]'),
|
||||
@@ -1129,7 +1139,8 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
|
||||
|
||||
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||
const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||
await gameInfoButton.click();
|
||||
await gameInfoButton.scrollIntoViewIfNeeded();
|
||||
await gameInfoButton.evaluate((button) => (button as HTMLElement).click());
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
|
||||
const geometry = await gameInfoButton.evaluate((button) => {
|
||||
@@ -2418,9 +2429,9 @@ test('all main Lumen button families share the rounded pressed geometry', async
|
||||
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
|
||||
],
|
||||
[
|
||||
'게임정보',
|
||||
'게임 정보',
|
||||
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
|
||||
name: '게임정보',
|
||||
name: '게임 정보',
|
||||
exact: true,
|
||||
}),
|
||||
],
|
||||
@@ -2566,7 +2577,7 @@ test('mobile main Lumen button families keep the same state geometry without ove
|
||||
const controls = [
|
||||
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
|
||||
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
|
||||
name: '게임정보',
|
||||
name: '게임 정보',
|
||||
exact: true,
|
||||
}),
|
||||
page.locator('.layout-mobile [data-navigation-id="meeting"]'),
|
||||
|
||||
@@ -12,7 +12,8 @@ const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
|
||||
const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
const frontendEnv =
|
||||
`VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` +
|
||||
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`;
|
||||
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` +
|
||||
'VITE_GATEWAY_API_URL=/gateway/api/trpc';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
@@ -16,7 +17,12 @@ 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 {
|
||||
defaultGlobalNavigation,
|
||||
type MainNavigationEntry,
|
||||
type MainNavigationLink,
|
||||
type QuickNavigationItem,
|
||||
} from '../components/main/mainNavigation';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
@@ -30,6 +36,9 @@ const { info: showInfoToast } = useGameFeedback();
|
||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||
|
||||
const npcMode = ref(0);
|
||||
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
|
||||
const versionDialog = ref<HTMLDialogElement | null>(null);
|
||||
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
|
||||
|
||||
const {
|
||||
loading,
|
||||
@@ -95,6 +104,17 @@ onUnmounted(() => {
|
||||
|
||||
onMounted(() => {
|
||||
dashboard.startRealtime();
|
||||
void fetch(navigationUrl, { headers: { Accept: 'application/json' } })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`);
|
||||
return (await response.json()) as RuntimeNavigationConfig;
|
||||
})
|
||||
.then((config) => {
|
||||
globalNavigation.value = config.game.items;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('운영 메뉴 설정을 불러오지 못해 기본 메뉴를 사용합니다.', error);
|
||||
});
|
||||
});
|
||||
|
||||
const shiftGeneralTurns = (amount: number) => {
|
||||
@@ -133,6 +153,10 @@ const moveQuick = (item: QuickNavigationItem) => {
|
||||
document.querySelector(item.selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const handleNavigationAction = (action: NonNullable<MainNavigationLink['action']>) => {
|
||||
if (action === 'show-version') versionDialog.value?.showModal();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [session.isReady, session.hasGeneral],
|
||||
([ready, hasGeneral]) => {
|
||||
@@ -146,7 +170,13 @@ watch(
|
||||
|
||||
<template>
|
||||
<main class="game-shell main-page">
|
||||
<MainGlobalMenu data-menu-position="top" :npc-mode="npcMode" :vote-active="voteActive" />
|
||||
<MainGlobalMenu
|
||||
data-menu-position="top"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
|
||||
<header class="game-shell__header">
|
||||
<h1 class="game-shell__title">
|
||||
@@ -308,6 +338,8 @@ watch(
|
||||
data-menu-position="middle"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
|
||||
<div class="mobile-panel">
|
||||
@@ -421,6 +453,8 @@ watch(
|
||||
data-menu-position="middle"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
<MessagePanel
|
||||
class="desktop-message-panel"
|
||||
@@ -449,6 +483,8 @@ watch(
|
||||
data-menu-position="bottom"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
</main>
|
||||
<div v-if="isMobile" class="main-mobile-bottom-spacer" aria-hidden="true"></div>
|
||||
@@ -460,11 +496,19 @@ watch(
|
||||
:npc-mode="npcMode"
|
||||
:realtime-enabled="realtimeEnabled"
|
||||
:refreshing="refreshing"
|
||||
:entries="globalNavigation"
|
||||
@refresh="requestManualRefresh"
|
||||
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||
@lobby="moveLobby"
|
||||
@quick="moveQuick"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
<dialog ref="versionDialog" class="game-version-dialog" aria-labelledby="game-version-title">
|
||||
<h2 id="game-version-title">게임 정보</h2>
|
||||
<p>{{ lobbyInfo?.scenarioTitle || 'Core2026' }}</p>
|
||||
<p>삼국지 모의전투 Core2026</p>
|
||||
<form method="dialog"><button class="legacy-button legacy-button--navigation" type="submit">닫기</button></form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -475,6 +519,30 @@ button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.game-version-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 18px;
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-version-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 65%);
|
||||
}
|
||||
|
||||
.game-version-dialog h2,
|
||||
.game-version-dialog p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.game-version-dialog form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ref's main document does not clip horizontally; the map panel below manages
|
||||
* its own overflow.
|
||||
|
||||
Reference in New Issue
Block a user