feat(frontend): restore ref main navigation
This commit is contained in:
@@ -48,3 +48,14 @@ PROFILE=hwe
|
|||||||
SCENARIO=default
|
SCENARIO=default
|
||||||
DAEMON_REQUEST_TIMEOUT_MS=5000
|
DAEMON_REQUEST_TIMEOUT_MS=5000
|
||||||
TRPC_PATH=/trpc
|
TRPC_PATH=/trpc
|
||||||
|
|
||||||
|
# Frontend public URLs
|
||||||
|
# These values are public and become part of the browser bundle.
|
||||||
|
VITE_GATEWAY_WEB_URL=/gateway/
|
||||||
|
VITE_BOARD_COMMUNITY_URL=/xe/community
|
||||||
|
# Set the remaining links only after their public routes are confirmed.
|
||||||
|
VITE_BOARD_REQUEST_URL=
|
||||||
|
VITE_BOARD_TIP_URL=
|
||||||
|
VITE_BOARD_PATCH_URL=
|
||||||
|
VITE_OFFICIAL_CHAT_URL=
|
||||||
|
VITE_CASUAL_CHAT_URL=
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||||
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||||
|
const operationNames = (route: Route) =>
|
||||||
|
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||||
|
|
||||||
|
type NavigationFixture = {
|
||||||
|
officerLevel: number;
|
||||||
|
permission: number;
|
||||||
|
nationLevel: number;
|
||||||
|
stage: number;
|
||||||
|
npcMode: number;
|
||||||
|
generalMeCalls: number;
|
||||||
|
operations: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyMessages = (permission: number) => ({
|
||||||
|
private: [],
|
||||||
|
national: [],
|
||||||
|
public: [],
|
||||||
|
diplomacy: [],
|
||||||
|
permission,
|
||||||
|
sequence: -1,
|
||||||
|
hasMore: { private: false, national: false, public: false, diplomacy: false },
|
||||||
|
latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 },
|
||||||
|
canRespondDiplomacy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalContext = (state: NavigationFixture) => ({
|
||||||
|
general: {
|
||||||
|
id: 7,
|
||||||
|
name: '메뉴검증장수',
|
||||||
|
nationId: 1,
|
||||||
|
cityId: 1,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
officerLevel: state.officerLevel,
|
||||||
|
picture: null,
|
||||||
|
imageServer: 0,
|
||||||
|
stats: { leadership: 70, strength: 60, intelligence: 50 },
|
||||||
|
gold: 1_000,
|
||||||
|
rice: 2_000,
|
||||||
|
crew: 300,
|
||||||
|
train: 80,
|
||||||
|
atmos: 90,
|
||||||
|
injury: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 200,
|
||||||
|
items: { horse: null, weapon: null, book: null, item: null },
|
||||||
|
},
|
||||||
|
city: {
|
||||||
|
id: 1,
|
||||||
|
name: '업',
|
||||||
|
level: 8,
|
||||||
|
nationId: 1,
|
||||||
|
region: 1,
|
||||||
|
population: 100_000,
|
||||||
|
populationMax: 200_000,
|
||||||
|
agriculture: 1_000,
|
||||||
|
agricultureMax: 2_000,
|
||||||
|
commerce: 1_000,
|
||||||
|
commerceMax: 2_000,
|
||||||
|
security: 1_000,
|
||||||
|
securityMax: 2_000,
|
||||||
|
trust: 80,
|
||||||
|
trade: 100,
|
||||||
|
defence: 1_000,
|
||||||
|
defenceMax: 2_000,
|
||||||
|
wall: 1_000,
|
||||||
|
wallMax: 2_000,
|
||||||
|
supplyState: 1,
|
||||||
|
frontState: 0,
|
||||||
|
},
|
||||||
|
nation: {
|
||||||
|
id: 1,
|
||||||
|
name: '위',
|
||||||
|
color: '#008000',
|
||||||
|
level: state.nationLevel,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 20_000,
|
||||||
|
tech: 100,
|
||||||
|
rate: 20,
|
||||||
|
bill: 100,
|
||||||
|
capitalCityId: 1,
|
||||||
|
typeCode: 'che_유가',
|
||||||
|
},
|
||||||
|
settings: {},
|
||||||
|
penalties: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ profile }) => {
|
||||||
|
localStorage.setItem('sammo-game-token', 'ga_navigation');
|
||||||
|
localStorage.setItem('sammo-game-profile', profile);
|
||||||
|
},
|
||||||
|
{ profile: gameProfile }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route('**/image/**', async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') });
|
||||||
|
});
|
||||||
|
await page.route('**/events**', async (route) => {
|
||||||
|
await route.abort();
|
||||||
|
});
|
||||||
|
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: '메뉴 사용자' })
|
||||||
|
: response({ ok: true })
|
||||||
|
);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||||
|
const operations = operationNames(route);
|
||||||
|
state.operations.push(...operations);
|
||||||
|
const results = operations.map((operation) => {
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({ myGeneral: { id: 7, name: '메뉴검증장수' }, year: 185, month: 1, turnTerm: 10 });
|
||||||
|
}
|
||||||
|
if (operation === 'general.me') {
|
||||||
|
state.generalMeCalls += 1;
|
||||||
|
return response(generalContext(state));
|
||||||
|
}
|
||||||
|
if (operation === 'world.getState') {
|
||||||
|
return response({
|
||||||
|
currentYear: 185,
|
||||||
|
currentMonth: 1,
|
||||||
|
tickSeconds: 600,
|
||||||
|
config: { npcMode: state.npcMode, const: {}, environment: {} },
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'world.getMapLayout') {
|
||||||
|
return response({
|
||||||
|
mapName: 'che',
|
||||||
|
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 200, y: 120, path: [] }],
|
||||||
|
regionMap: { 1: '하북' },
|
||||||
|
levelMap: { 8: '특' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'world.getMap') {
|
||||||
|
return response({
|
||||||
|
result: true,
|
||||||
|
version: 0,
|
||||||
|
startYear: 180,
|
||||||
|
year: 185,
|
||||||
|
month: 1,
|
||||||
|
cityList: [[1, 8, 0, 1, 1, 1]],
|
||||||
|
nationList: [[1, '위', '#008000', 1]],
|
||||||
|
spyList: {},
|
||||||
|
shownByGeneralList: [],
|
||||||
|
myCity: 1,
|
||||||
|
myNation: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] });
|
||||||
|
if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') {
|
||||||
|
return response({ turns: [], revision: 0 });
|
||||||
|
}
|
||||||
|
if (operation === 'messages.getRecent') return response(emptyMessages(state.permission));
|
||||||
|
if (operation === 'messages.getContacts') return response({ nation: [] });
|
||||||
|
if (operation === 'general.getRecentRecords') {
|
||||||
|
return response({
|
||||||
|
global: [{ id: 3, text: '장수 동향 기록' }],
|
||||||
|
general: [{ id: 2, text: '개인 기록' }],
|
||||||
|
history: [{ id: 1, text: '중원 정세 기록' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'general.getFrontStatus') {
|
||||||
|
return response({
|
||||||
|
onlineUserCount: 1,
|
||||||
|
onlineNations: '위(1)',
|
||||||
|
onlineGenerals: '메뉴검증장수',
|
||||||
|
nationNotice: '<p>국가 방침</p>',
|
||||||
|
lastExecuted: null,
|
||||||
|
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'board.getAccess') {
|
||||||
|
return response({
|
||||||
|
permission: state.permission,
|
||||||
|
canMeeting: state.officerLevel >= 1,
|
||||||
|
canSecret: state.permission >= 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'tournament.getState') return response({ stage: state.stage });
|
||||||
|
return response({ ok: true });
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForMain = async (page: Page) => {
|
||||||
|
await page.goto('./');
|
||||||
|
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
await expect(page.locator('.main-global-menu').first()).toBeVisible();
|
||||||
|
await expect(page.locator('.main-nation-menu')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(4);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridColumnCount = async (page: Page, selector: string) =>
|
||||||
|
page
|
||||||
|
.locator(selector)
|
||||||
|
.first()
|
||||||
|
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
|
||||||
|
|
||||||
|
const persistArtifact = async (page: Page, name: string) => {
|
||||||
|
if (!artifactRoot) return;
|
||||||
|
const target = resolve(artifactRoot);
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const describe = (selector: string) => {
|
||||||
|
const element = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||||
|
display: style.display,
|
||||||
|
position: style.position,
|
||||||
|
zIndex: style.zIndex,
|
||||||
|
gridTemplateColumns: style.gridTemplateColumns,
|
||||||
|
columnCount: style.columnCount,
|
||||||
|
gap: style.gap,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
color: style.color,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
backgroundImage: style.backgroundImage,
|
||||||
|
borderColor: style.borderColor,
|
||||||
|
padding: style.padding,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
overflowY: style.overflowY,
|
||||||
|
maxHeight: style.maxHeight,
|
||||||
|
cursor: style.cursor,
|
||||||
|
opacity: style.opacity,
|
||||||
|
visibility: style.visibility,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
viewport: { width: innerWidth, height: innerHeight },
|
||||||
|
global: describe('.main-global-menu'),
|
||||||
|
nation: describe('.main-nation-menu'),
|
||||||
|
bottom: describe('.main-mobile-bottom'),
|
||||||
|
globalPopup: describe('#mobile-global-menu'),
|
||||||
|
nationPopup: describe('#mobile-nation-menu'),
|
||||||
|
quickPopup: describe('#mobile-quick-menu'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
|
||||||
|
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 1,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
await expect(page.locator('.main-global-menu')).toHaveCount(3);
|
||||||
|
expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
|
||||||
|
expect(await gridColumnCount(page, '.main-nation-menu')).toBe(10);
|
||||||
|
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
|
||||||
|
await expect(page.locator('.layout-desktop')).toBeVisible();
|
||||||
|
await expect(page.locator('.layout-mobile')).toHaveCount(0);
|
||||||
|
|
||||||
|
const global = page.locator('.main-global-menu').first();
|
||||||
|
await expect(global.locator('[data-navigation-id="nation-betting"]')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`${basePath}/nation-betting`
|
||||||
|
);
|
||||||
|
await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`${basePath}/nation-list`
|
||||||
|
);
|
||||||
|
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="survey"]')).toHaveClass(/highlight/);
|
||||||
|
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
|
||||||
|
|
||||||
|
const gameInfoButton = global.locator('[data-menu-id="game-info"]');
|
||||||
|
await gameInfoButton.focus();
|
||||||
|
await gameInfoButton.press('Enter');
|
||||||
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await expect(global.locator('#global-menu-game-info')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(gameInfoButton).toBeFocused();
|
||||||
|
|
||||||
|
await gameInfoButton.click();
|
||||||
|
await page.getByRole('heading', { name: '전장 현황' }).click();
|
||||||
|
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 939/940 boundary and 500px bottom bar match the ref responsive contract', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 6,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 940, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
expect(await gridColumnCount(page, '.main-global-menu')).toBe(8);
|
||||||
|
expect(await gridColumnCount(page, '.main-nation-menu')).toBe(10);
|
||||||
|
await expect(page.locator('.main-mobile-bottom')).toBeHidden();
|
||||||
|
await expect(page.locator('.layout-desktop')).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 939, height: 900 });
|
||||||
|
await expect(page.locator('.layout-mobile')).toBeVisible();
|
||||||
|
expect(await gridColumnCount(page, '.main-global-menu')).toBe(4);
|
||||||
|
expect(await gridColumnCount(page, '.main-nation-menu')).toBe(5);
|
||||||
|
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
const bottomGeometry = await page.locator('.main-mobile-bottom').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
bottom: innerHeight - rect.bottom,
|
||||||
|
buttons: [...element.children].map((child) => child.getBoundingClientRect().width),
|
||||||
|
position: getComputedStyle(element).position,
|
||||||
|
zIndex: getComputedStyle(element).zIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(bottomGeometry).toEqual({
|
||||||
|
x: 0,
|
||||||
|
width: 500,
|
||||||
|
height: 45,
|
||||||
|
bottom: 0,
|
||||||
|
buttons: [125, 125, 125, 125],
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: '99',
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalTrigger = page.locator('[data-bottom-menu="global"]');
|
||||||
|
await externalTrigger.focus();
|
||||||
|
await externalTrigger.press('Enter');
|
||||||
|
await expect(externalTrigger).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
const popupStyle = await page.locator('#mobile-global-menu').evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
columns: style.columnCount,
|
||||||
|
overflowY: style.overflowY,
|
||||||
|
maxHeight: style.maxHeight,
|
||||||
|
bottom: style.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(popupStyle.columns).toBe('3');
|
||||||
|
expect(popupStyle.overflowY).toBe('auto');
|
||||||
|
expect(popupStyle.maxHeight).toBe('850px');
|
||||||
|
expect(popupStyle.bottom).toBe('47px');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(externalTrigger).toBeFocused();
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 1,
|
||||||
|
permission: 0,
|
||||||
|
nationLevel: 1,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
const nationMenu = page.locator('.main-nation-menu');
|
||||||
|
|
||||||
|
await expect(nationMenu.locator('[data-navigation-id="meeting"]')).toHaveAttribute('href', `${basePath}/board`);
|
||||||
|
await expect(nationMenu.locator('[data-navigation-id="secret-board"]')).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect(nationMenu.locator('[data-navigation-id="diplomacy"]')).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
await expect(nationMenu.locator('[data-navigation-id="nation-info"]')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`${basePath}/nation/info`
|
||||||
|
);
|
||||||
|
|
||||||
|
state.officerLevel = 2;
|
||||||
|
const callsBeforeRefresh = state.generalMeCalls;
|
||||||
|
await page.getByRole('button', { name: '갱 신' }).click();
|
||||||
|
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||||
|
await expect(nationMenu.locator('[data-navigation-id="diplomacy"]')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`${basePath}/diplomacy`
|
||||||
|
);
|
||||||
|
await expect(nationMenu.locator('[data-navigation-id="nation-secret"]')).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`${basePath}/nation/secret`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile quick navigation changes tabs before scrolling, refreshes once, and preserves tokens on lobby return', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 5,
|
||||||
|
permission: 2,
|
||||||
|
nationLevel: 3,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
['policy', 'map', '[data-main-target="policy"]'],
|
||||||
|
['commands', 'commands', '[data-main-target="commands"]'],
|
||||||
|
['nation', 'status', '[data-main-target="nation"]'],
|
||||||
|
['general', 'status', '[data-main-target="general"]'],
|
||||||
|
['city', 'status', '[data-main-target="city"]'],
|
||||||
|
['map', 'map', '[data-main-target="map"]'],
|
||||||
|
['global-records', 'world', '[data-main-target="global-records"]'],
|
||||||
|
['general-records', 'world', '[data-main-target="general-records"]'],
|
||||||
|
['world-history', 'world', '[data-main-target="world-history"]'],
|
||||||
|
['public-message', 'messages', '[data-message-type="public"]'],
|
||||||
|
['national-message', 'messages', '[data-message-type="national"]'],
|
||||||
|
['private-message', 'messages', '[data-message-type="private"]'],
|
||||||
|
['diplomacy-message', 'messages', '[data-message-type="diplomacy"]'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [id, tab, selector] of cases) {
|
||||||
|
await page.locator('[data-bottom-menu="quick"]').click();
|
||||||
|
await page.locator(`[data-quick-id="${id}"]`).click();
|
||||||
|
await expect(page.locator('.mobile-tabs button.active')).toHaveText(
|
||||||
|
{ map: '지도', commands: '명령', status: '상태', world: '동향', messages: '메시지' }[tab]
|
||||||
|
);
|
||||||
|
await expect(page.locator(selector)).toBeVisible();
|
||||||
|
const targetTop = await page.locator(selector).evaluate((element) => element.getBoundingClientRect().top);
|
||||||
|
expect(targetTop).toBeLessThan(855);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callsBeforeRefresh = state.generalMeCalls;
|
||||||
|
await page.locator('[data-bottom-menu="refresh"]').click();
|
||||||
|
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('sammo-session-token', 'session_navigation');
|
||||||
|
});
|
||||||
|
await page.route('**/gateway/', async (route) => {
|
||||||
|
await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>gateway</title>' });
|
||||||
|
});
|
||||||
|
await page.locator('[data-bottom-menu="quick"]').click();
|
||||||
|
await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('menuitem', { name: '로비로' }).click()]);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
session: localStorage.getItem('sammo-session-token'),
|
||||||
|
game: localStorage.getItem('sammo-game-token'),
|
||||||
|
profile: localStorage.getItem('sammo-game-profile'),
|
||||||
|
}))
|
||||||
|
).toEqual({
|
||||||
|
session: 'session_navigation',
|
||||||
|
game: 'ga_navigation',
|
||||||
|
profile: gameProfile,
|
||||||
|
});
|
||||||
|
expect(state.operations).not.toContain('auth.logout');
|
||||||
|
});
|
||||||
@@ -4,8 +4,11 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15120);
|
const port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15120);
|
||||||
const baseURL = `http://127.0.0.1:${port}/che/`;
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? '/che/api/trpc';
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||||
|
const baseURL = `http://127.0.0.1:${port}${basePath}/`;
|
||||||
|
const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`;
|
||||||
|
const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
@@ -24,6 +27,7 @@ export default defineConfig({
|
|||||||
'battleSimulatorRef.spec.ts',
|
'battleSimulatorRef.spec.ts',
|
||||||
'commandArguments.spec.ts',
|
'commandArguments.spec.ts',
|
||||||
'commandArgumentsLive.spec.ts',
|
'commandArgumentsLive.spec.ts',
|
||||||
|
'mainNavigation.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
@@ -45,7 +49,7 @@ export default defineConfig({
|
|||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=${gameApiUrl} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
|
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${port}`,
|
||||||
cwd: repositoryRoot,
|
cwd: repositoryRoot,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
Vendored
+6
@@ -14,6 +14,12 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_GAME_ASSET_URL?: string;
|
readonly VITE_GAME_ASSET_URL?: string;
|
||||||
readonly VITE_GAME_PROFILE?: string;
|
readonly VITE_GAME_PROFILE?: string;
|
||||||
readonly VITE_GATEWAY_WEB_URL?: 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 {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
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 SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
||||||
import RecordPanel from '../components/main/RecordPanel.vue';
|
import RecordPanel from '../components/main/RecordPanel.vue';
|
||||||
import MainFrontStatus from '../components/main/MainFrontStatus.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 { formatLog } from '../utils/formatLog';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||||
@@ -20,7 +24,7 @@ import { trpc } from '../utils/trpc';
|
|||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const dashboard = useMainDashboardStore();
|
const dashboard = useMainDashboardStore();
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||||
|
|
||||||
const mobileTabs = [
|
const mobileTabs = [
|
||||||
{ key: 'map', label: '지도' },
|
{ key: 'map', label: '지도' },
|
||||||
@@ -34,6 +38,7 @@ type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
|||||||
|
|
||||||
const mobileTab = ref<MobileTabKey>('map');
|
const mobileTab = ref<MobileTabKey>('map');
|
||||||
const tournamentStage = ref(0);
|
const tournamentStage = ref(0);
|
||||||
|
const npcMode = ref(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
@@ -64,6 +69,14 @@ const {
|
|||||||
realtimeLabel,
|
realtimeLabel,
|
||||||
} = storeToRefs(dashboard);
|
} = 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;
|
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
watch(surveyNotice, (notice) => {
|
watch(surveyNotice, (notice) => {
|
||||||
if (surveyNoticeTimer) {
|
if (surveyNoticeTimer) {
|
||||||
@@ -97,8 +110,24 @@ const shiftNationTurns = (amount: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadMainData = async () => {
|
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;
|
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(
|
watch(
|
||||||
@@ -114,65 +143,29 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="game-shell main-page">
|
<main class="game-shell main-page">
|
||||||
|
<MainGlobalMenu :npc-mode="npcMode" :vote-active="voteActive" />
|
||||||
|
|
||||||
<header class="game-shell__header">
|
<header class="game-shell__header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="game-shell__title">전장 현황</h1>
|
<h1 class="game-shell__title">전장 현황</h1>
|
||||||
<p class="game-shell__subtitle">{{ statusLine }}</p>
|
<p class="game-shell__subtitle">{{ statusLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="game-shell__actions">
|
<div class="game-shell__actions desktop-action-controls">
|
||||||
<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">내 정보&설정</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>
|
|
||||||
<button
|
<button
|
||||||
class="game-shell__action toggle"
|
class="game-shell__action toggle"
|
||||||
:class="{ active: realtimeEnabled }"
|
:class="{ active: realtimeEnabled }"
|
||||||
|
type="button"
|
||||||
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
@click="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||||
>
|
>
|
||||||
실시간 동기화: {{ realtimeLabel }}
|
실시간 동기화: {{ realtimeLabel }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</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="error" class="game-feedback game-feedback--error" role="alert">{{ error }}</div>
|
||||||
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
|
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
|
||||||
|
|
||||||
@@ -180,7 +173,9 @@ watch(
|
|||||||
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
||||||
</div>
|
</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">
|
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||||||
<div class="survey-notice-title">
|
<div class="survey-notice-title">
|
||||||
@@ -203,7 +198,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'map'" class="mobile-panel">
|
<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" />
|
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="선택 도시">
|
<PanelCard title="선택 도시">
|
||||||
@@ -212,7 +207,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'commands'" class="mobile-panel">
|
<div v-if="mobileTab === 'commands'" class="mobile-panel">
|
||||||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
|
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
|
||||||
<CommandListPanel
|
<CommandListPanel
|
||||||
:command-table="commandTable"
|
:command-table="commandTable"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -229,19 +224,19 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'status'" class="mobile-panel">
|
<div v-if="mobileTab === 'status'" class="mobile-panel">
|
||||||
<PanelCard title="장수 스탯">
|
<PanelCard title="장수 스탯" data-main-target="general">
|
||||||
<GeneralBasicCard :general="general" :loading="loading" />
|
<GeneralBasicCard :general="general" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보">
|
<PanelCard title="도시 정보" data-main-target="city">
|
||||||
<CityBasicCard :city="city" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="국가 정보">
|
<PanelCard title="국가 정보" data-main-target="nation">
|
||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mobileTab === 'world'" class="mobile-panel record-zone-mobile">
|
<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" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
<div v-else class="record-list" data-record-bucket="global">
|
<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 v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</RecordPanel>
|
</RecordPanel>
|
||||||
<RecordPanel title="개인 기록">
|
<RecordPanel title="개인 기록" data-main-target="general-records">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
<div v-else class="record-list" data-record-bucket="general">
|
<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 v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</RecordPanel>
|
</RecordPanel>
|
||||||
<RecordPanel title="중원 정세">
|
<RecordPanel title="중원 정세" data-main-target="world-history">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
<div v-else class="record-list" data-record-bucket="history">
|
<div v-else class="record-list" data-record-bucket="history">
|
||||||
@@ -311,7 +306,7 @@ watch(
|
|||||||
|
|
||||||
<section v-else class="layout-desktop">
|
<section v-else class="layout-desktop">
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황">
|
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황" data-main-target="map">
|
||||||
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="선택 도시">
|
<PanelCard title="선택 도시">
|
||||||
@@ -320,7 +315,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
|
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
|
||||||
<CommandListPanel
|
<CommandListPanel
|
||||||
:command-table="commandTable"
|
:command-table="commandTable"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@@ -334,18 +329,18 @@ watch(
|
|||||||
@shift-nation-turns="shiftNationTurns"
|
@shift-nation-turns="shiftNationTurns"
|
||||||
/>
|
/>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="장수 스탯">
|
<PanelCard title="장수 스탯" data-main-target="general">
|
||||||
<GeneralBasicCard :general="general" :loading="loading" />
|
<GeneralBasicCard :general="general" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="도시 정보">
|
<PanelCard title="도시 정보" data-main-target="city">
|
||||||
<CityBasicCard :city="city" :loading="loading" />
|
<CityBasicCard :city="city" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
<PanelCard title="국가 정보">
|
<PanelCard title="국가 정보" data-main-target="nation">
|
||||||
<NationBasicCard :nation="nation" :loading="loading" />
|
<NationBasicCard :nation="nation" :loading="loading" />
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
<section class="record-zone">
|
<section class="record-zone">
|
||||||
<RecordPanel title="장수 동향">
|
<RecordPanel title="장수 동향" data-main-target="global-records">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
<div v-else class="record-list" data-record-bucket="global">
|
<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 v-if="globalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</RecordPanel>
|
</RecordPanel>
|
||||||
<RecordPanel title="개인 기록">
|
<RecordPanel title="개인 기록" data-main-target="general-records">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
<div v-else class="record-list" data-record-bucket="general">
|
<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 v-if="generalRecords.length === 0" class="record-empty">기록이 없습니다.</div>
|
||||||
</div>
|
</div>
|
||||||
</RecordPanel>
|
</RecordPanel>
|
||||||
<RecordPanel class="world-history-panel" title="중원 정세">
|
<RecordPanel class="world-history-panel" title="중원 정세" data-main-target="world-history">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<SkeletonLines v-if="loading" :lines="4" />
|
||||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||||
<div v-else class="record-list" data-record-bucket="history">
|
<div v-else class="record-list" data-record-bucket="history">
|
||||||
@@ -409,6 +404,19 @@ watch(
|
|||||||
@delete="dashboard.deleteMessage"
|
@delete="dashboard.deleteMessage"
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -585,4 +593,19 @@ button {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user