merge: 토너먼트 상태별 메인 메뉴를 main에 반영한다
This commit is contained in:
@@ -22,6 +22,13 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
|
||||
]);
|
||||
};
|
||||
|
||||
const waitForVisualAssets = async (page: Page): Promise<void> => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
});
|
||||
};
|
||||
|
||||
const readGeneralPanelImages = async (panel: Locator) =>
|
||||
panel.evaluate((element) =>
|
||||
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
|
||||
@@ -1270,6 +1277,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
await expect(page.locator('#container')).not.toContainText('che_');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
||||
await expect(page.getByRole('radiogroup', { name: '화면 폭 모드' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '순서 바꾸기', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('#custom_css')).toHaveCount(0);
|
||||
await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14);
|
||||
await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
|
||||
@@ -1290,7 +1300,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
|
||||
const save = saveButton.getBoundingClientRect();
|
||||
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
|
||||
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
|
||||
return {
|
||||
width: rect.width,
|
||||
@@ -1302,8 +1311,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
saveWidth: save.width,
|
||||
saveHeight: save.height,
|
||||
saveBackground: getComputedStyle(saveButton).backgroundColor,
|
||||
customCssWidth: customCss.width,
|
||||
customCssHeight: customCss.height,
|
||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
|
||||
};
|
||||
@@ -1317,8 +1324,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
expect(desktop.saveWidth).toBe(160);
|
||||
expect(desktop.saveHeight).toBe(30);
|
||||
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
|
||||
expect(desktop.customCssWidth).toBe(420);
|
||||
expect(desktop.customCssHeight).toBe(150);
|
||||
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
||||
await expectLumenButtonStates(page, page.locator('#set_my_setting'), 'rgb(34, 85, 0)');
|
||||
@@ -1583,11 +1588,68 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오
|
||||
}
|
||||
});
|
||||
|
||||
test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({ page }) => {
|
||||
test('화면 설정에서 화면 폭과 개인 CSS를 저장하고 게임 설정 API는 호출하지 않는다', async ({ page }, testInfo) => {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
await page.goto('my-settings');
|
||||
await waitForVisualAssets(page);
|
||||
|
||||
await expect(page.locator('.title-row')).toContainText('화 면 설 정');
|
||||
await expect(
|
||||
page.getByText('이 설정은 이 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.')
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('#set_my_setting')).toHaveCount(0);
|
||||
await expect(page.locator('#custom_css')).toBeVisible();
|
||||
expect(state.settingMutations).toHaveLength(0);
|
||||
|
||||
const desktop = await page.locator('#interface-settings').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const css = element.querySelector<HTMLTextAreaElement>('#custom_css')!.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns,
|
||||
cssWidth: css.width,
|
||||
cssHeight: css.height,
|
||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
|
||||
};
|
||||
});
|
||||
expect(desktop.width).toBe(1000);
|
||||
expect(desktop.columns.split(' ')).toHaveLength(2);
|
||||
expect(desktop.cssWidth).toBe(420);
|
||||
expect(desktop.cssHeight).toBe(150);
|
||||
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
|
||||
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
||||
await page.screenshot({ path: testInfo.outputPath('interface-settings-desktop.png'), fullPage: true });
|
||||
|
||||
await page.getByRole('radio', { name: '500px' }).check();
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px');
|
||||
await expect(page.locator('meta[name="viewport"]')).toHaveAttribute('content', 'width=500');
|
||||
|
||||
const cssText = '#interface-settings { --ui-settings-e2e: 23px; }';
|
||||
await page.getByLabel('개인용 CSS').fill(cssText);
|
||||
await expect(page.locator('.custom-css span')).toHaveText('(저장 중)');
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(cssText);
|
||||
await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText);
|
||||
await page.reload();
|
||||
await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText);
|
||||
expect(state.settingMutations).toHaveLength(0);
|
||||
|
||||
await page.getByLabel('개인용 CSS').fill('');
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe('');
|
||||
await page.getByRole('radio', { name: '자동' }).check();
|
||||
await persistParityArtifact(page, 'core-interface-settings-desktop', desktop);
|
||||
});
|
||||
|
||||
test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('my-page');
|
||||
await page.goto('my-settings');
|
||||
await waitForVisualAssets(page);
|
||||
|
||||
await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
||||
@@ -1637,7 +1699,8 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
|
||||
expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44);
|
||||
expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36);
|
||||
expect(dialogGeometry.documentWidth).toBe(390);
|
||||
await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry);
|
||||
await dialog.screenshot({ path: testInfo.outputPath('interface-settings-mobile-layout-dialog.png') });
|
||||
await persistParityArtifact(page, 'core-interface-settings-mobile-layout-order-dialog', dialogGeometry);
|
||||
|
||||
await dialog.getByRole('button', { name: '적용', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
@@ -1654,7 +1717,7 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
|
||||
.toEqual(defaultOrder);
|
||||
});
|
||||
|
||||
test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
||||
test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
||||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||
if (typeof configuredBaseUrl !== 'string') {
|
||||
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
||||
@@ -1672,7 +1735,8 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
|
||||
try {
|
||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||
await install(mobilePage, state);
|
||||
await mobilePage.goto('my-page');
|
||||
await mobilePage.goto('my-settings');
|
||||
await waitForVisualAssets(mobilePage);
|
||||
await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
|
||||
|
||||
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
||||
|
||||
@@ -29,6 +29,8 @@ type NavigationFixture = {
|
||||
permission: number;
|
||||
nationLevel: number;
|
||||
stage: number;
|
||||
tournamentType?: 0 | 1 | 2 | 3;
|
||||
tournamentWinnerId?: number;
|
||||
npcMode: number;
|
||||
generalMeCalls: number;
|
||||
operations: string[];
|
||||
@@ -758,7 +760,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
canSecret: state.permission >= 2,
|
||||
});
|
||||
}
|
||||
if (operation === 'tournament.getState') return response({ stage: state.stage });
|
||||
if (operation === 'tournament.getState') {
|
||||
if (state.stage === 0 && state.tournamentType === undefined && state.tournamentWinnerId === undefined) {
|
||||
return response(null);
|
||||
}
|
||||
return response({
|
||||
stage: state.stage,
|
||||
type: state.tournamentType ?? 0,
|
||||
winnerId: state.tournamentWinnerId,
|
||||
});
|
||||
}
|
||||
return response({ ok: true });
|
||||
});
|
||||
operations.forEach((operation, index) => {
|
||||
@@ -1260,7 +1271,27 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community');
|
||||
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/);
|
||||
const nationMenu = page.locator('.main-nation-menu:visible');
|
||||
await expect(nationMenu.locator(':scope > *')).toHaveCount(20);
|
||||
const tournamentMain = nationMenu.locator('[data-navigation-id="tournament"]');
|
||||
const tournamentToggle = nationMenu.locator('[data-menu-id="tournament-betting"]');
|
||||
await expect(tournamentMain).toHaveClass(/highlight/);
|
||||
await expect(tournamentMain).toHaveAttribute('href', `${basePath}/tournament`);
|
||||
await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(nationMenu.locator('[data-navigation-id="my-settings"]')).toHaveAttribute(
|
||||
'href',
|
||||
`${basePath}/my-settings`
|
||||
);
|
||||
await tournamentToggle.click();
|
||||
await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(
|
||||
nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]')
|
||||
).toHaveText('토너먼트');
|
||||
await expect(nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]')).toHaveAttribute(
|
||||
'href',
|
||||
`${basePath}/betting`
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const gameInfoButton = global.locator('[data-menu-id="game-info"]');
|
||||
const bettingButton = global.locator('[data-navigation-id="nation-betting"]');
|
||||
@@ -1417,6 +1448,95 @@ test('shows the persisted official game index beside the scenario title without
|
||||
}
|
||||
});
|
||||
|
||||
test('tournament split main action follows recruitment, betting, finals, and tournament type on desktop and mobile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 1,
|
||||
tournamentType: 3,
|
||||
npcMode: 1,
|
||||
scenarioTitle: '토너먼트 동적 메뉴 검증 시나리오',
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
};
|
||||
await installFixture(page, state);
|
||||
|
||||
const cases = [
|
||||
{ stage: 1, type: 3 as const, label: '설 전', route: '/tournament' },
|
||||
{ stage: 5, type: 2 as const, label: '일 기 토', route: '/tournament' },
|
||||
{ stage: 6, type: 2 as const, label: '베 팅 장', route: '/betting' },
|
||||
{ stage: 7, type: 2 as const, label: '일 기 토', route: '/tournament' },
|
||||
{ stage: 10, type: 3 as const, label: '설 전', route: '/tournament' },
|
||||
{ stage: 0, type: 3 as const, label: '설 전', route: '/tournament', winnerId: 17 },
|
||||
];
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 500, height: 900 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
for (const lifecycle of cases) {
|
||||
state.stage = lifecycle.stage;
|
||||
state.tournamentType = lifecycle.type;
|
||||
state.tournamentWinnerId = lifecycle.winnerId;
|
||||
if (page.url() === 'about:blank') {
|
||||
await waitForMain(page);
|
||||
} else {
|
||||
await page.reload();
|
||||
await waitForMain(page);
|
||||
}
|
||||
|
||||
const nationMenu = page.locator('.main-nation-menu:visible');
|
||||
const main = nationMenu.locator('[data-navigation-id="tournament"]');
|
||||
await expect(main).toHaveText(lifecycle.label);
|
||||
await expect(main).toHaveAttribute('href', `${basePath}${lifecycle.route}`);
|
||||
await expect(main).toHaveClass(/highlight/);
|
||||
|
||||
const toggle = nationMenu.locator('[data-menu-id="tournament-betting"]');
|
||||
await toggle.click();
|
||||
const tournamentItem = nationMenu.locator(
|
||||
'#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]'
|
||||
);
|
||||
const bettingItem = nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]');
|
||||
if (lifecycle.stage === 6) {
|
||||
await expect(tournamentItem).not.toHaveClass(/highlight/);
|
||||
await expect(bettingItem).toHaveClass(/highlight/);
|
||||
} else {
|
||||
await expect(tournamentItem).toHaveClass(/highlight/);
|
||||
await expect(bettingItem).not.toHaveClass(/highlight/);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
if (viewport.width === 500) {
|
||||
const nationTrigger = page.locator('[data-bottom-menu="nation"]');
|
||||
await nationTrigger.click();
|
||||
const mobileTournament = page.locator('#mobile-nation-menu [data-navigation-id="tournament-menu"]');
|
||||
const mobileBetting = page.locator('#mobile-nation-menu [data-navigation-id="betting"]');
|
||||
if (lifecycle.stage === 6) {
|
||||
await expect(mobileTournament).not.toHaveClass(/highlight/);
|
||||
await expect(mobileBetting).toHaveClass(/highlight/);
|
||||
} else {
|
||||
await expect(mobileTournament).toHaveClass(/highlight/);
|
||||
await expect(mobileBetting).not.toHaveClass(/highlight/);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
}
|
||||
|
||||
await page
|
||||
.locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]')
|
||||
.locator('..')
|
||||
.screenshot({
|
||||
path: artifactRoot
|
||||
? resolve(artifactRoot, `tournament-dynamic-main-${viewport.width}.png`)
|
||||
: testInfo.outputPath(`tournament-dynamic-main-${viewport.width}.png`),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('shows game index zero for a profile whose first game starts at zero', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
@@ -1455,7 +1575,7 @@ test('nation split buttons keep square inner corners and a single divider in eve
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 1,
|
||||
stage: 6,
|
||||
npcMode: 1,
|
||||
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
|
||||
generalMeCalls: 0,
|
||||
@@ -1515,14 +1635,21 @@ test('nation split buttons keep square inner corners and a single divider in eve
|
||||
for (const width of [1200, 500]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await waitForMain(page);
|
||||
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
|
||||
const pairs: Array<[string, Locator, Locator]> = [
|
||||
[
|
||||
'nation',
|
||||
nationSplit.locator('[data-navigation-id="auction-resource"]'),
|
||||
nationSplit.locator('[data-menu-id="auction"]'),
|
||||
'tournament',
|
||||
page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]'),
|
||||
page.locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]'),
|
||||
],
|
||||
[
|
||||
'auction',
|
||||
page.locator('.main-nation-menu:visible [data-navigation-id="auction-resource"]'),
|
||||
page.locator('.main-nation-menu:visible [data-menu-id="auction"]'),
|
||||
],
|
||||
];
|
||||
await expect(page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]')).toHaveClass(
|
||||
/highlight/
|
||||
);
|
||||
|
||||
for (const [label, main, toggle] of pairs) {
|
||||
await expect(main).toBeVisible();
|
||||
@@ -3959,9 +4086,7 @@ for (const viewport of [
|
||||
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||
const equipment = picker.getByLabel('장비', { exact: true });
|
||||
await equipment.selectOption('청룡언월도');
|
||||
const optionLabelBeforeRefresh = await equipment
|
||||
.locator('option[value="청룡언월도"]')
|
||||
.textContent();
|
||||
const optionLabelBeforeRefresh = await equipment.locator('option[value="청룡언월도"]').textContent();
|
||||
await equipment.evaluate((element) => {
|
||||
const select = element as HTMLSelectElement;
|
||||
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
||||
@@ -4091,8 +4216,8 @@ test('keeps an Android Chromium native command select untouched while a turn sig
|
||||
await waitForMain(mobilePage);
|
||||
await expect
|
||||
.poll(() =>
|
||||
mobilePage.evaluate(
|
||||
() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
|
||||
mobilePage.evaluate(() =>
|
||||
(window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
@@ -42,6 +42,7 @@ body {
|
||||
|
||||
/* These redesigned screens own a true handheld layout. */
|
||||
#app:has(.responsive-settings-page),
|
||||
#app:has(.interface-settings-page),
|
||||
#app:has(#tournament-container),
|
||||
#app:has(#tournament-betting-container),
|
||||
#app:has(#personnel-container) {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||
import MainNavigationLink from './MainNavigationLink.vue';
|
||||
import {
|
||||
buildGlobalNavigation,
|
||||
buildNationNavigation,
|
||||
isNationNavigationEnabled,
|
||||
isNavigationConfigured,
|
||||
nationNavigation,
|
||||
quickNavigation,
|
||||
type MainNavigationLink as MainNavigationLinkItem,
|
||||
type MainNavigationEntry,
|
||||
@@ -18,6 +18,7 @@ import { useMenuPopup } from './useMenuPopup';
|
||||
const props = defineProps<{
|
||||
access: NationNavigationAccess;
|
||||
tournamentStage: number;
|
||||
tournamentType: number | null;
|
||||
nationColor: string;
|
||||
npcMode: number;
|
||||
realtimeEnabled: boolean;
|
||||
@@ -35,9 +36,12 @@ const emit = defineEmits<{
|
||||
|
||||
const { setRoot, openId, close, toggle } = useMenuPopup();
|
||||
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
|
||||
const nationEntries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType));
|
||||
const nationMenuColor = computed(() => props.nationColor || '#000000');
|
||||
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
|
||||
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
|
||||
const isActive = (link: MainNavigationLinkItem) =>
|
||||
link.highlightStage === props.tournamentStage ||
|
||||
link.highlightStages?.some((stage) => stage === props.tournamentStage) === true;
|
||||
|
||||
const onQuick = (item: QuickNavigationItem) => {
|
||||
close();
|
||||
@@ -148,7 +152,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
|
||||
<span class="dropup-caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
<ul v-if="openId === 'nation'" id="mobile-nation-menu" class="bottom-popup" role="menu">
|
||||
<template v-for="entry in nationNavigation" :key="entry.id">
|
||||
<template v-for="entry in nationEntries" :key="entry.id">
|
||||
<li v-if="entry.kind === 'link'" role="none">
|
||||
<MainNavigationLink
|
||||
:link="entry"
|
||||
@@ -165,6 +169,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
|
||||
<MainNavigationLink
|
||||
:link="item"
|
||||
:enabled="isNationNavigationEnabled(item, access)"
|
||||
:active="isActive(item)"
|
||||
compact
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import MainNavigationLink from './MainNavigationLink.vue';
|
||||
import {
|
||||
buildNationNavigation,
|
||||
isNationNavigationEnabled,
|
||||
nationNavigation,
|
||||
type MainNavigationLink as MainNavigationLinkItem,
|
||||
type NationNavigationAccess,
|
||||
} from './mainNavigation';
|
||||
@@ -12,11 +13,15 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
||||
const props = defineProps<{
|
||||
access: NationNavigationAccess;
|
||||
tournamentStage: number;
|
||||
tournamentType: number | null;
|
||||
nationColor: string;
|
||||
}>();
|
||||
|
||||
const { setRoot, openId, close, toggle } = useMenuPopup();
|
||||
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
|
||||
const entries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType));
|
||||
const isActive = (link: MainNavigationLinkItem) =>
|
||||
link.highlightStage === props.tournamentStage ||
|
||||
link.highlightStages?.some((stage) => stage === props.tournamentStage) === true;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,7 +32,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
:class="{ 'dark-label': legacyNationTextColor(nationColor) === '#000000' }"
|
||||
aria-label="국가 메뉴"
|
||||
>
|
||||
<template v-for="entry in nationNavigation" :key="entry.id">
|
||||
<template v-for="entry in entries" :key="entry.id">
|
||||
<MainNavigationLink
|
||||
v-if="entry.kind === 'link'"
|
||||
:link="entry"
|
||||
@@ -59,6 +64,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
||||
<MainNavigationLink
|
||||
:link="item"
|
||||
:enabled="isNationNavigationEnabled(item, access)"
|
||||
:active="isActive(item)"
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RuntimeNavigationLink,
|
||||
} from '@sammo-ts/common/navigation/menuConfig';
|
||||
import defaultNavigationJson from '../../../../../resources/navigation.json';
|
||||
import { resolveTournamentMainPresentation } from '../../utils/tournamentNavigation';
|
||||
|
||||
export type NationAccessRule =
|
||||
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
|
||||
@@ -11,7 +12,8 @@ export type NationAccessRule =
|
||||
export type MainNavigationLink = RuntimeNavigationLink & {
|
||||
compactLabel?: string;
|
||||
access?: NationAccessRule;
|
||||
highlightStage?: 1 | 6;
|
||||
highlightStage?: number;
|
||||
highlightStages?: readonly number[];
|
||||
unavailableReason?: string;
|
||||
};
|
||||
|
||||
@@ -144,14 +146,37 @@ export const nationNavigation: MainNavigationEntry[] = [
|
||||
access: 'nation-secret',
|
||||
},
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'tournament',
|
||||
label: '토 너 먼 트',
|
||||
compactLabel: '토너먼트',
|
||||
to: '/tournament',
|
||||
newTab: true,
|
||||
access: 'always',
|
||||
highlightStage: 1,
|
||||
kind: 'split',
|
||||
id: 'tournament-betting',
|
||||
main: {
|
||||
kind: 'link',
|
||||
id: 'tournament',
|
||||
label: '토 너 먼 트',
|
||||
compactLabel: '토너먼트',
|
||||
to: '/tournament',
|
||||
newTab: true,
|
||||
access: 'always',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'tournament-menu',
|
||||
label: '토너먼트',
|
||||
to: '/tournament',
|
||||
newTab: true,
|
||||
access: 'always',
|
||||
highlightStage: 1,
|
||||
},
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'betting',
|
||||
label: '베팅장',
|
||||
to: '/betting',
|
||||
newTab: true,
|
||||
access: 'always',
|
||||
highlightStage: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'link',
|
||||
@@ -218,18 +243,42 @@ export const nationNavigation: MainNavigationEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'betting',
|
||||
label: '베 팅 장',
|
||||
compactLabel: '베팅장',
|
||||
to: '/betting',
|
||||
newTab: true,
|
||||
access: 'always',
|
||||
highlightStage: 6,
|
||||
},
|
||||
{ kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' },
|
||||
];
|
||||
|
||||
export const buildNationNavigation = (
|
||||
tournamentStage: number,
|
||||
tournamentType: number | null,
|
||||
source: MainNavigationEntry[] = nationNavigation
|
||||
): MainNavigationEntry[] =>
|
||||
source.map((entry) => {
|
||||
if (entry.kind !== 'split' || entry.id !== 'tournament-betting') return entry;
|
||||
|
||||
const presentation = resolveTournamentMainPresentation(tournamentStage, tournamentType);
|
||||
const main: MainNavigationLink = {
|
||||
...entry.main,
|
||||
label: presentation.label,
|
||||
compactLabel: presentation.compactLabel,
|
||||
to: presentation.to,
|
||||
highlightStage: presentation.active ? tournamentStage : undefined,
|
||||
highlightStages: undefined,
|
||||
};
|
||||
const items = entry.items.map((item) => {
|
||||
if (item.kind !== 'link') return item;
|
||||
if (item.id === 'tournament-menu') {
|
||||
return {
|
||||
...item,
|
||||
highlightStage: presentation.active && !presentation.bettingActive ? tournamentStage : undefined,
|
||||
};
|
||||
}
|
||||
if (item.id === 'betting') {
|
||||
return { ...item, highlightStage: presentation.bettingActive ? tournamentStage : undefined };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return { ...entry, main, items };
|
||||
});
|
||||
|
||||
export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider> = [
|
||||
{ kind: 'divider', id: 'quick-nation-heading', label: '국가 정보' },
|
||||
{ id: 'policy', label: '방침', tab: 'map', selector: '[data-main-target="policy"]' },
|
||||
@@ -249,7 +298,8 @@ 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 || link.action);
|
||||
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';
|
||||
|
||||
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
import { applyStoredCustomCss } from './utils/customCss';
|
||||
import { installImageAssetCssVariables } from './utils/imageAssets';
|
||||
import { installScreenModeViewport } from './utils/screenModeViewport';
|
||||
|
||||
installImageAssetCssVariables();
|
||||
installScreenModeViewport();
|
||||
applyStoredCustomCss();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const NotFoundView = () => import('../views/NotFoundView.vue');
|
||||
const TournamentView = () => import('../views/TournamentView.vue');
|
||||
const BettingView = () => import('../views/BettingView.vue');
|
||||
const MyPageView = () => import('../views/MyPageView.vue');
|
||||
const MySettingsView = () => import('../views/MySettingsView.vue');
|
||||
const BoardView = () => import('../views/BoardView.vue');
|
||||
const DiplomacyView = () => import('../views/DiplomacyView.vue');
|
||||
const BestGeneralView = () => import('../views/BestGeneralView.vue');
|
||||
@@ -333,7 +334,8 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/my-settings',
|
||||
redirect: '/my-page',
|
||||
name: 'my-settings',
|
||||
component: MySettingsView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
|
||||
type TournamentType = NonNullable<TournamentState>['type'];
|
||||
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
|
||||
type DashboardReadModelPatch = {
|
||||
contextSnapshot?: GeneralContext;
|
||||
@@ -76,6 +77,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
worldHistory?: RecentRecord[];
|
||||
frontStatus?: FrontStatus | null;
|
||||
tournamentStage?: number;
|
||||
tournamentType?: TournamentType | null;
|
||||
};
|
||||
type DashboardTabMessage =
|
||||
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
|
||||
@@ -117,6 +119,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const worldHistory = ref<RecentRecord[]>([]);
|
||||
const frontStatus = ref<FrontStatus | null>(null);
|
||||
const tournamentStage = ref(0);
|
||||
const tournamentType = ref<TournamentType | null>(null);
|
||||
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
||||
let lastGeneralRecordId = 0;
|
||||
let lastWorldHistoryId = 0;
|
||||
@@ -440,6 +443,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (patch.tournamentStage !== undefined) {
|
||||
tournamentStage.value = patch.tournamentStage;
|
||||
}
|
||||
if (patch.tournamentType !== undefined) {
|
||||
tournamentType.value = patch.tournamentType;
|
||||
}
|
||||
if (patch.contextRevision !== undefined) {
|
||||
contextRevision = patch.contextRevision;
|
||||
contextSourceRevision = patch.contextSourceRevision ?? null;
|
||||
@@ -487,6 +493,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
patch.worldHistory = toRaw(worldHistory.value);
|
||||
patch.frontStatus = toRaw(frontStatus.value);
|
||||
patch.tournamentStage = tournamentStage.value;
|
||||
patch.tournamentType = tournamentType.value;
|
||||
return patch;
|
||||
};
|
||||
|
||||
@@ -637,6 +644,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
if (tournamentState !== undefined) {
|
||||
tournamentStage.value = tournamentState?.stage ?? 0;
|
||||
tournamentType.value = tournamentState?.type ?? null;
|
||||
}
|
||||
if (initializedMailboxGeneralId !== id) {
|
||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||
@@ -765,7 +773,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
patch.worldHistory = nextWorldHistory;
|
||||
}
|
||||
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
|
||||
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
|
||||
if (tournamentState !== undefined) {
|
||||
patch.tournamentStage = tournamentState?.stage ?? 0;
|
||||
patch.tournamentType = tournamentState?.type ?? null;
|
||||
}
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
} catch (err) {
|
||||
@@ -1290,6 +1301,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
worldHistory,
|
||||
frontStatus,
|
||||
tournamentStage,
|
||||
tournamentType,
|
||||
surveyNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||
export const CUSTOM_CSS_STYLE_ID = 'sammo-custom-css';
|
||||
|
||||
export const applyCustomCss = (text: string): void => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
let style = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = CUSTOM_CSS_STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = text;
|
||||
};
|
||||
|
||||
export const applyStoredCustomCss = (): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
applyCustomCss(window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '');
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface TournamentMainPresentation {
|
||||
label: string;
|
||||
compactLabel: string;
|
||||
to: '/tournament' | '/betting';
|
||||
active: boolean;
|
||||
bettingActive: boolean;
|
||||
}
|
||||
|
||||
const tournamentLabels = [
|
||||
{ label: '전 력 전', compactLabel: '전력전' },
|
||||
{ label: '통 솔 전', compactLabel: '통솔전' },
|
||||
{ label: '일 기 토', compactLabel: '일기토' },
|
||||
{ label: '설 전', compactLabel: '설전' },
|
||||
] as const;
|
||||
|
||||
export const resolveTournamentMainPresentation = (
|
||||
tournamentStage: number,
|
||||
tournamentType: number | null
|
||||
): TournamentMainPresentation => {
|
||||
const active = tournamentStage > 0 || tournamentType !== null;
|
||||
const bettingActive = tournamentStage === 6;
|
||||
const tournamentLabel = tournamentType === null ? undefined : tournamentLabels[tournamentType];
|
||||
return {
|
||||
label: bettingActive ? '베 팅 장' : (tournamentLabel?.label ?? '토 너 먼 트'),
|
||||
compactLabel: bettingActive ? '베팅장' : (tournamentLabel?.compactLabel ?? '토너먼트'),
|
||||
to: bettingActive ? '/betting' : '/tournament',
|
||||
active,
|
||||
bettingActive,
|
||||
};
|
||||
};
|
||||
@@ -83,6 +83,7 @@ const {
|
||||
worldHistory,
|
||||
frontStatus,
|
||||
tournamentStage,
|
||||
tournamentType,
|
||||
surveyNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
@@ -301,6 +302,7 @@ watch(
|
||||
class="nation-menu-middle"
|
||||
:access="nationAccess"
|
||||
:tournament-stage="tournamentStage"
|
||||
:tournament-type="tournamentType"
|
||||
:nation-color="nationColor"
|
||||
/>
|
||||
</div>
|
||||
@@ -480,6 +482,7 @@ watch(
|
||||
class="nation-menu-middle"
|
||||
:access="nationAccess"
|
||||
:tournament-stage="tournamentStage"
|
||||
:tournament-type="tournamentType"
|
||||
:nation-color="nationColor"
|
||||
/>
|
||||
<section class="record-zone">
|
||||
@@ -570,6 +573,7 @@ watch(
|
||||
v-if="isMobile"
|
||||
:access="nationAccess"
|
||||
:tournament-stage="tournamentStage"
|
||||
:tournament-type="tournamentType"
|
||||
:nation-color="nationColor"
|
||||
:npc-mode="npcMode"
|
||||
:realtime-enabled="realtimeEnabled"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
@@ -10,19 +9,9 @@ import { useSessionStore } from '../stores/session';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
|
||||
import {
|
||||
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
|
||||
loadMobileMainPanelOrder,
|
||||
MOBILE_MAIN_PANEL_DEFINITIONS,
|
||||
moveMobileMainPanel,
|
||||
saveMobileMainPanelOrder,
|
||||
type MobileMainPanelId,
|
||||
} from '../utils/mobileMainPanelOrder';
|
||||
|
||||
const CUSTOM_CSS_KEY = 'sam_customCSS';
|
||||
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
|
||||
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
|
||||
const { error: showErrorToast, showDialog } = useGameFeedback();
|
||||
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
@@ -52,14 +41,8 @@ const dieOnPrestartStatusLoading = ref(false);
|
||||
const dieOnPrestartStatusError = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const screenMode = ref<ScreenMode>('auto');
|
||||
const customCss = ref('');
|
||||
const selectedIconId = ref('');
|
||||
const cssSaving = ref(false);
|
||||
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
||||
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
||||
const session = useSessionStore();
|
||||
let cssTimer: number | null = null;
|
||||
const readPendingDieOnPrestartId = (): string => {
|
||||
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
|
||||
@@ -179,30 +162,6 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
|
||||
);
|
||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
||||
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
||||
);
|
||||
|
||||
const openMobileLayoutDialog = () => {
|
||||
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
||||
mobileLayoutDialog.value?.showModal();
|
||||
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
|
||||
};
|
||||
|
||||
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
|
||||
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
|
||||
};
|
||||
|
||||
const resetMobileLayoutOrder = () => {
|
||||
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
|
||||
};
|
||||
|
||||
const applyMobileLayoutOrder = () => {
|
||||
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
|
||||
mobileLayoutDialog.value?.close();
|
||||
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
|
||||
};
|
||||
|
||||
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
||||
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
|
||||
@@ -233,16 +192,6 @@ const formatSelectionAvailableAt = computed(() => {
|
||||
return formatSeoulDateTime(value);
|
||||
});
|
||||
|
||||
const applyCustomCss = (text: string) => {
|
||||
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'sammo-custom-css';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = text;
|
||||
};
|
||||
|
||||
const loadLog = async (type: LogType, beforeId?: number) => {
|
||||
if (logLoading[type]) return;
|
||||
logLoading[type] = true;
|
||||
@@ -370,32 +319,13 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
|
||||
trpc.general.dropItem.mutate({ itemType: item.key })
|
||||
);
|
||||
|
||||
watch(screenMode, (mode) => {
|
||||
localStorage.setItem(SCREEN_MODE_KEY, mode);
|
||||
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
|
||||
});
|
||||
|
||||
watch(customCss, (text) => {
|
||||
if (cssTimer !== null) window.clearTimeout(cssTimer);
|
||||
cssSaving.value = true;
|
||||
cssTimer = window.setTimeout(() => {
|
||||
localStorage.setItem(CUSTOM_CSS_KEY, text);
|
||||
applyCustomCss(text);
|
||||
cssSaving.value = false;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
|
||||
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
|
||||
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
||||
applyCustomCss(customCss.value);
|
||||
void loadPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
|
||||
<main id="container" class="legacy-page bg0 responsive-settings-page">
|
||||
<div class="title-row">
|
||||
<span>내 정 보</span>
|
||||
<div class="title-actions">
|
||||
@@ -613,29 +543,6 @@ onMounted(() => {
|
||||
<br /><br />
|
||||
</div>
|
||||
|
||||
<div class="screen-mode-row">
|
||||
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
|
||||
<div class="button-group">
|
||||
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
|
||||
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
|
||||
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-layout-setting-row">
|
||||
<span>
|
||||
모바일 레이아웃 순서 바꾸기<br />
|
||||
<small>500px 메인 화면의 패널 순서를 이 기기에 저장합니다.</small>
|
||||
</span>
|
||||
<button
|
||||
class="legacy-button legacy-button--primary mobile-layout-open"
|
||||
type="button"
|
||||
@click="openMobileLayoutDialog"
|
||||
>
|
||||
순서 바꾸기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="item-title">아이템 파기</div>
|
||||
<div class="item-group">
|
||||
<button
|
||||
@@ -649,11 +556,6 @@ onMounted(() => {
|
||||
{{ item.displayName ?? '-' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="custom-css">
|
||||
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
|
||||
<textarea id="custom_css" v-model="customCss" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -691,67 +593,6 @@ onMounted(() => {
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
||||
</footer>
|
||||
</main>
|
||||
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
|
||||
<div class="mobile-layout-dialog__header">
|
||||
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
||||
<form method="dialog">
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="submit"
|
||||
aria-label="모바일 레이아웃 순서 창 닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
||||
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
|
||||
<template #item="{ element: panelId, index }">
|
||||
<li :data-mobile-layout-id="panelId">
|
||||
<span class="mobile-layout-handle" aria-hidden="true">≡</span>
|
||||
<span class="mobile-layout-label">
|
||||
<span class="mobile-layout-position">{{ index + 1 }}</span>
|
||||
{{ mobileLayoutLabels[panelId] }}
|
||||
</span>
|
||||
<span class="mobile-layout-move-buttons">
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
|
||||
:disabled="index === 0"
|
||||
@click="moveMobileLayoutItem(index, index - 1)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
|
||||
:disabled="index === mobileLayoutOrder.length - 1"
|
||||
@click="moveMobileLayoutItem(index, index + 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</SortableStringList>
|
||||
<div class="mobile-layout-dialog__actions">
|
||||
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
|
||||
기본값
|
||||
</button>
|
||||
<form method="dialog">
|
||||
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
|
||||
</form>
|
||||
<button
|
||||
class="legacy-button legacy-button--primary mobile-layout-apply"
|
||||
type="button"
|
||||
@click="applyMobileLayoutOrder"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
|
||||
</template>
|
||||
|
||||
@@ -773,12 +614,6 @@ onMounted(() => {
|
||||
.my-page-mobile-scroll-spacer {
|
||||
display: none;
|
||||
}
|
||||
.legacy-page.screen-500px {
|
||||
max-width: 500px;
|
||||
}
|
||||
.legacy-page.screen-1000px {
|
||||
max-width: 1000px;
|
||||
}
|
||||
.title-row {
|
||||
height: 54px;
|
||||
display: flex;
|
||||
@@ -815,8 +650,7 @@ textarea {
|
||||
background: #6b6b6b;
|
||||
font: inherit;
|
||||
}
|
||||
.legacy-page .legacy-button,
|
||||
.mobile-layout-dialog .legacy-button {
|
||||
.legacy-page .legacy-button {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
button {
|
||||
@@ -924,139 +758,6 @@ button:disabled {
|
||||
margin: 12px 0;
|
||||
color: #f66;
|
||||
}
|
||||
.screen-mode-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
align-items: center;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.mobile-layout-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 128px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.mobile-layout-setting-row small {
|
||||
color: orange;
|
||||
}
|
||||
.mobile-layout-open {
|
||||
min-height: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mobile-layout-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(460px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
border: 1px solid #777;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background: #171717 var(--sammo-texture-walnut);
|
||||
color: #fff;
|
||||
font: 14px/1.3 var(--sammo-font-sans);
|
||||
}
|
||||
.mobile-layout-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 72%);
|
||||
}
|
||||
.mobile-layout-dialog__header,
|
||||
.mobile-layout-dialog__actions,
|
||||
.mobile-layout-move-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.mobile-layout-dialog__header {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.mobile-layout-dialog__header h2,
|
||||
.mobile-layout-dialog p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.mobile-layout-dialog__header h2 {
|
||||
color: skyblue;
|
||||
font-size: 18px;
|
||||
}
|
||||
.mobile-layout-dialog__header form,
|
||||
.mobile-layout-dialog__actions form {
|
||||
margin: 0;
|
||||
}
|
||||
.mobile-layout-dialog__header button {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.mobile-layout-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.mobile-layout-list > li {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border: 1px solid #777;
|
||||
background: #172a52 var(--sammo-texture-blue);
|
||||
cursor: grab;
|
||||
}
|
||||
.mobile-layout-list > li:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.mobile-layout-handle {
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.mobile-layout-label {
|
||||
min-width: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mobile-layout-position {
|
||||
display: inline-grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
margin-right: 4px;
|
||||
border: 1px solid #7186a7;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mobile-layout-move-buttons {
|
||||
gap: 4px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.mobile-layout-move-buttons button {
|
||||
width: 36px;
|
||||
min-height: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mobile-layout-dialog__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mobile-layout-dialog__actions button {
|
||||
min-height: 34px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.mobile-layout-dialog__actions .mobile-layout-apply {
|
||||
font-weight: 700;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
}
|
||||
.button-group label {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #666;
|
||||
background: #26384d;
|
||||
}
|
||||
.button-group input {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.item-title {
|
||||
margin-top: 12px;
|
||||
}
|
||||
@@ -1068,17 +769,6 @@ button:disabled {
|
||||
.item-group button {
|
||||
min-height: 30px;
|
||||
}
|
||||
.custom-css {
|
||||
display: block;
|
||||
}
|
||||
.custom-css textarea {
|
||||
display: block;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
height: 150px;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
}
|
||||
.log-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
@@ -1153,24 +843,8 @@ button:disabled {
|
||||
.settings-column {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.screen-mode-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.mobile-layout-setting-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.mobile-layout-open {
|
||||
width: 100%;
|
||||
}
|
||||
.button-group {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.item-group {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.custom-css textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,453 +1,434 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const CUSTOM_CSS_KEY = 'sammo-custom-css';
|
||||
const SCREEN_MODE_KEY = 'sammo-screen-mode';
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
|
||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
|
||||
type WorldStateSnapshot = {
|
||||
config: Record<string, unknown>;
|
||||
meta: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
type ScreenMode = 'auto' | '500px' | '1000px';
|
||||
|
||||
type SettingForm = {
|
||||
tnmt: number;
|
||||
defence_train: number;
|
||||
use_treatment: number;
|
||||
use_auto_nation_turn: number;
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const data = ref<MyGeneralResponse | null>(null);
|
||||
const worldState = ref<WorldStateSnapshot>(null);
|
||||
|
||||
const form = reactive<SettingForm>({
|
||||
tnmt: 1,
|
||||
defence_train: 80,
|
||||
use_treatment: 10,
|
||||
use_auto_nation_turn: 1,
|
||||
});
|
||||
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
|
||||
import {
|
||||
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
|
||||
loadMobileMainPanelOrder,
|
||||
MOBILE_MAIN_PANEL_DEFINITIONS,
|
||||
moveMobileMainPanel,
|
||||
saveMobileMainPanelOrder,
|
||||
type MobileMainPanelId,
|
||||
} from '../utils/mobileMainPanelOrder';
|
||||
import {
|
||||
normalizeScreenMode,
|
||||
SCREEN_MODE_CHANGE_EVENT,
|
||||
SCREEN_MODE_KEY,
|
||||
type ScreenMode,
|
||||
} from '../utils/screenModeViewport';
|
||||
|
||||
const { success: showSuccessToast } = useGameFeedback();
|
||||
const screenMode = ref<ScreenMode>('auto');
|
||||
const customCss = ref('');
|
||||
const cssSaving = ref(false);
|
||||
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
||||
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
||||
let cssTimer: number | null = null;
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return 'unknown_error';
|
||||
};
|
||||
|
||||
const resolveNumber = (value: unknown, fallback: number): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const canSave = computed(() => {
|
||||
const remaining = data.value?.settings?.myset ?? null;
|
||||
if (remaining === null) {
|
||||
return true;
|
||||
}
|
||||
return remaining > 0;
|
||||
});
|
||||
|
||||
const remainingLabel = computed(() => {
|
||||
const remaining = data.value?.settings?.myset ?? null;
|
||||
if (remaining === null) {
|
||||
return '설정 저장 제한 없음';
|
||||
}
|
||||
return `설정저장은 이달중 ${remaining}회 남았습니다.`;
|
||||
});
|
||||
|
||||
const showAutoNationTurn = computed(() => {
|
||||
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
|
||||
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
|
||||
const options = (autorunUser.options ?? {}) as Record<string, unknown>;
|
||||
return Boolean(options.chief);
|
||||
});
|
||||
|
||||
const penalties = computed(() => {
|
||||
const list = data.value?.penalties ?? {};
|
||||
return Object.entries(list);
|
||||
});
|
||||
|
||||
const applyCustomCss = (text: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const id = 'sammo-custom-css';
|
||||
let style = document.getElementById(id) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = id;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = text;
|
||||
};
|
||||
|
||||
const saveCustomCss = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
|
||||
applyCustomCss(customCss.value);
|
||||
};
|
||||
|
||||
const loadScreenMode = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const value = window.localStorage.getItem(SCREEN_MODE_KEY);
|
||||
if (value === '500px' || value === '1000px') {
|
||||
screenMode.value = value;
|
||||
} else {
|
||||
screenMode.value = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
const saveScreenMode = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(SCREEN_MODE_KEY, screenMode.value);
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const general = await trpc.general.me.query();
|
||||
const world = await trpc.world.getState.query() as
|
||||
| {
|
||||
config?: Record<string, unknown>;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
| null;
|
||||
data.value = general;
|
||||
worldState.value = world
|
||||
? {
|
||||
config: world.config ?? {},
|
||||
meta: world.meta ?? {},
|
||||
}
|
||||
: null;
|
||||
|
||||
if (general?.settings) {
|
||||
form.tnmt = general.settings.tnmt;
|
||||
form.defence_train = general.settings.defence_train;
|
||||
form.use_treatment = general.settings.use_treatment;
|
||||
form.use_auto_nation_turn = general.settings.use_auto_nation_turn;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!canSave.value) {
|
||||
showErrorToast('설정 저장 가능 횟수가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await trpc.general.setMySetting.mutate({
|
||||
tnmt: resolveNumber(form.tnmt, 1),
|
||||
defence_train: resolveNumber(form.defence_train, 80),
|
||||
use_treatment: resolveNumber(form.use_treatment, 10),
|
||||
use_auto_nation_turn: resolveNumber(form.use_auto_nation_turn, 1),
|
||||
});
|
||||
await loadSettings();
|
||||
showSuccessToast('설정을 저장했습니다.');
|
||||
} catch (err) {
|
||||
showErrorToast(`설정 저장에 실패했습니다: ${resolveErrorMessage(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => screenMode.value,
|
||||
() => {
|
||||
saveScreenMode();
|
||||
}
|
||||
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
||||
);
|
||||
|
||||
watch(
|
||||
() => customCss.value,
|
||||
() => {
|
||||
if (cssTimer) {
|
||||
window.clearTimeout(cssTimer);
|
||||
}
|
||||
cssSaving.value = true;
|
||||
cssTimer = window.setTimeout(() => {
|
||||
saveCustomCss();
|
||||
cssSaving.value = false;
|
||||
}, 400);
|
||||
}
|
||||
);
|
||||
const openMobileLayoutDialog = () => {
|
||||
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
||||
mobileLayoutDialog.value?.showModal();
|
||||
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
|
||||
};
|
||||
|
||||
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
|
||||
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
|
||||
};
|
||||
|
||||
const resetMobileLayoutOrder = () => {
|
||||
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
|
||||
};
|
||||
|
||||
const applyMobileLayoutOrder = () => {
|
||||
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
|
||||
mobileLayoutDialog.value?.close();
|
||||
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
|
||||
};
|
||||
|
||||
watch(screenMode, (mode) => {
|
||||
window.localStorage.setItem(SCREEN_MODE_KEY, mode);
|
||||
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
|
||||
});
|
||||
|
||||
watch(customCss, (text) => {
|
||||
if (cssTimer !== null) window.clearTimeout(cssTimer);
|
||||
cssSaving.value = true;
|
||||
cssTimer = window.setTimeout(() => {
|
||||
window.localStorage.setItem(CUSTOM_CSS_KEY, text);
|
||||
applyCustomCss(text);
|
||||
cssSaving.value = false;
|
||||
cssTimer = null;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
||||
applyCustomCss(customCss.value);
|
||||
}
|
||||
loadScreenMode();
|
||||
void loadSettings();
|
||||
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
|
||||
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (cssTimer === null) return;
|
||||
window.clearTimeout(cssTimer);
|
||||
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
|
||||
applyCustomCss(customCss.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="settings-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">게임 설정</h1>
|
||||
<p class="page-subtitle">내 정보 설정을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
||||
<button class="ghost" @click="loadSettings">새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
|
||||
<div class="title-row">
|
||||
<span>화 면 설 정</span>
|
||||
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<section class="layout-grid">
|
||||
<div class="stack">
|
||||
<PanelCard title="게임 옵션" subtitle="설정 저장 시 즉시 반영됩니다.">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>토너먼트 참가</span>
|
||||
<select v-model.number="form.tnmt">
|
||||
<option :value="0">수동 참여</option>
|
||||
<option :value="1">자동 참여</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>환약 사용 기준</span>
|
||||
<select v-model.number="form.use_treatment">
|
||||
<option :value="10">경상</option>
|
||||
<option :value="21">중상</option>
|
||||
<option :value="41">심각</option>
|
||||
<option :value="61">위독</option>
|
||||
<option :value="100">사용 안함</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="showAutoNationTurn" class="form-field">
|
||||
<span>자동 사령턴 허용</span>
|
||||
<select v-model.number="form.use_auto_nation_turn">
|
||||
<option :value="1">허용</option>
|
||||
<option :value="0">허용 안함</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>수비 설정</span>
|
||||
<select v-model.number="form.defence_train">
|
||||
<option :value="90">훈련 90</option>
|
||||
<option :value="80">훈련 80</option>
|
||||
<option :value="60">훈련 60</option>
|
||||
<option :value="40">훈련 40</option>
|
||||
<option :value="999">절대 수비</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="hint">{{ remainingLabel }}</div>
|
||||
<button class="primary" type="button" :disabled="!canSave" @click="saveSettings">
|
||||
설정 저장
|
||||
</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard title="징계 목록" subtitle="설정 저장 시 갱신됩니다.">
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<div v-else class="penalty-list">
|
||||
<div v-if="penalties.length === 0" class="empty">징계가 없습니다.</div>
|
||||
<div v-for="[key, value] in penalties" :key="key" class="penalty-item">
|
||||
{{ key }}: {{ value }}
|
||||
<section class="settings-grid">
|
||||
<article class="settings-column">
|
||||
<h2 class="section-title">화면 크기와 배치</h2>
|
||||
<div class="settings-content">
|
||||
<div class="screen-mode-row">
|
||||
<span>500px/1000px 모드<br /><small>모바일 화면 폭을 즉시 바꿉니다.</small></span>
|
||||
<div class="button-group" role="radiogroup" aria-label="화면 폭 모드">
|
||||
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
|
||||
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
|
||||
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<PanelCard title="화면 모드" subtitle="모바일 전용 설정입니다.">
|
||||
<div class="screen-mode">
|
||||
<label>
|
||||
<input v-model="screenMode" type="radio" value="auto" />
|
||||
자동
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="screenMode" type="radio" value="500px" />
|
||||
500px
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="screenMode" type="radio" value="1000px" />
|
||||
1000px
|
||||
</label>
|
||||
<div class="mobile-layout-setting-row">
|
||||
<span>
|
||||
모바일 메인 레이아웃<br />
|
||||
<small>500px 메인 화면의 패널 순서를 이 기기에 저장합니다.</small>
|
||||
</span>
|
||||
<button
|
||||
class="legacy-button legacy-button--primary mobile-layout-open"
|
||||
type="button"
|
||||
@click="openMobileLayoutDialog"
|
||||
>
|
||||
순서 바꾸기
|
||||
</button>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<PanelCard title="개인용 CSS" subtitle="변경 사항은 자동 저장됩니다.">
|
||||
<textarea v-model="customCss" class="css-input" rows="8" />
|
||||
<div class="hint">{{ cssSaving ? '저장 중...' : '자동 저장 완료' }}</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
<article class="settings-column">
|
||||
<h2 class="section-title">개인용 CSS</h2>
|
||||
<div class="settings-content">
|
||||
<label class="custom-css">
|
||||
브라우저에 적용할 CSS <span aria-live="polite">{{ cssSaving ? '(저장 중)' : '' }}</span>
|
||||
<textarea id="custom_css" v-model="customCss" aria-label="개인용 CSS" />
|
||||
</label>
|
||||
<p class="css-hint">변경 사항은 이 기기에 자동 저장됩니다.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<footer class="legacy-credit">
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
|
||||
<div class="mobile-layout-dialog__header">
|
||||
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
||||
<form method="dialog">
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="submit"
|
||||
aria-label="모바일 레이아웃 순서 창 닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
||||
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
|
||||
<template #item="{ element: panelId, index }">
|
||||
<li :data-mobile-layout-id="panelId">
|
||||
<span class="mobile-layout-handle" aria-hidden="true">≡</span>
|
||||
<span class="mobile-layout-label">
|
||||
<span class="mobile-layout-position">{{ index + 1 }}</span>
|
||||
{{ mobileLayoutLabels[panelId] }}
|
||||
</span>
|
||||
<span class="mobile-layout-move-buttons">
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
|
||||
:disabled="index === 0"
|
||||
@click="moveMobileLayoutItem(index, index - 1)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
|
||||
:disabled="index === mobileLayoutOrder.length - 1"
|
||||
@click="moveMobileLayoutItem(index, index + 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</SortableStringList>
|
||||
<div class="mobile-layout-dialog__actions">
|
||||
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
|
||||
기본값
|
||||
</button>
|
||||
<form method="dialog">
|
||||
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
|
||||
</form>
|
||||
<button
|
||||
class="legacy-button legacy-button--primary mobile-layout-apply"
|
||||
type="button"
|
||||
@click="applyMobileLayoutOrder"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
.legacy-page {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background-color: transparent;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.title-row {
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
border: 1px solid #666;
|
||||
background: transparent;
|
||||
}
|
||||
.title-row > span {
|
||||
flex-basis: 100%;
|
||||
height: 18px;
|
||||
}
|
||||
.title-row .legacy-button {
|
||||
min-width: 90px;
|
||||
}
|
||||
.css-hint {
|
||||
margin: 0;
|
||||
color: orange;
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.settings-column {
|
||||
border: 1px solid #666;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
}
|
||||
.section-title {
|
||||
min-height: 34px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #666;
|
||||
background-color: #14241b;
|
||||
background-image: var(--sammo-texture-green);
|
||||
color: skyblue;
|
||||
font-size: 1.25em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.6rem;
|
||||
.settings-content {
|
||||
padding: 10px 18px;
|
||||
}
|
||||
.screen-mode-row,
|
||||
.mobile-layout-setting-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
.screen-mode-row {
|
||||
grid-template-columns: 160px 1fr;
|
||||
}
|
||||
.mobile-layout-setting-row {
|
||||
grid-template-columns: minmax(0, 1fr) 128px;
|
||||
}
|
||||
.screen-mode-row small,
|
||||
.mobile-layout-setting-row small,
|
||||
.css-hint {
|
||||
color: orange;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
}
|
||||
.button-group label {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #666;
|
||||
background: #26384d;
|
||||
}
|
||||
.button-group input {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.mobile-layout-open {
|
||||
min-height: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.custom-css {
|
||||
display: block;
|
||||
}
|
||||
.custom-css textarea {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
height: 150px;
|
||||
border: 1px solid #777;
|
||||
border-radius: 0;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font: inherit;
|
||||
}
|
||||
.css-hint {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.legacy-credit {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mobile-layout-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(460px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
border: 1px solid #777;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
background: #171717 var(--sammo-texture-walnut);
|
||||
color: #fff;
|
||||
font: 14px/1.3 var(--sammo-font-sans);
|
||||
}
|
||||
.mobile-layout-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 72%);
|
||||
}
|
||||
.mobile-layout-dialog__header,
|
||||
.mobile-layout-dialog__actions,
|
||||
.mobile-layout-move-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
.mobile-layout-dialog__header {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
.mobile-layout-dialog__header h2,
|
||||
.mobile-layout-dialog p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.mobile-layout-dialog__header h2 {
|
||||
color: skyblue;
|
||||
font-size: 18px;
|
||||
}
|
||||
.mobile-layout-dialog__header form,
|
||||
.mobile-layout-dialog__actions form {
|
||||
margin: 0;
|
||||
}
|
||||
.mobile-layout-dialog__header button {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.mobile-layout-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.form-field select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(12, 12, 12, 0.7);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border: 1px solid rgba(201, 164, 90, 0.6);
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
color: inherit;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.screen-mode {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.css-input {
|
||||
width: 100%;
|
||||
background: rgba(12, 12, 12, 0.7);
|
||||
color: inherit;
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.penalty-list {
|
||||
.mobile-layout-list > li {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border: 1px solid #777;
|
||||
background: #172a52 var(--sammo-texture-blue);
|
||||
cursor: grab;
|
||||
}
|
||||
.mobile-layout-list > li:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.mobile-layout-handle {
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
.mobile-layout-label {
|
||||
min-width: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mobile-layout-position {
|
||||
display: inline-grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
margin-right: 4px;
|
||||
border: 1px solid #7186a7;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mobile-layout-move-buttons {
|
||||
gap: 4px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.mobile-layout-move-buttons button {
|
||||
width: 36px;
|
||||
min-height: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mobile-layout-dialog__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.penalty-item {
|
||||
color: #f08a5d;
|
||||
.mobile-layout-dialog__actions button {
|
||||
min-height: 34px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
font-size: 0.8rem;
|
||||
.mobile-layout-dialog__actions .mobile-layout-apply {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid rgba(201, 164, 90, 0.5);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f08a5d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.layout-grid {
|
||||
@media (max-width: 939.98px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.screen-mode-row,
|
||||
.mobile-layout-setting-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.button-group {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mobile-layout-open,
|
||||
.custom-css textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveTournamentMainPresentation } from '../src/utils/tournamentNavigation.ts';
|
||||
|
||||
void test('routes every active tournament stage except betting to its type-specific tournament button', () => {
|
||||
const expectedLabels = ['전력전', '통솔전', '일기토', '설전'];
|
||||
for (const [type, expectedLabel] of expectedLabels.entries()) {
|
||||
for (const stage of [1, 2, 3, 4, 5, 7, 8, 9, 10, 0]) {
|
||||
const presentation = resolveTournamentMainPresentation(stage, type);
|
||||
assert.equal(presentation.compactLabel, expectedLabel);
|
||||
assert.equal(presentation.to, '/tournament');
|
||||
assert.equal(presentation.active, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('routes the betting stage to the betting hall regardless of tournament type', () => {
|
||||
for (const type of [0, 1, 2, 3]) {
|
||||
const presentation = resolveTournamentMainPresentation(6, type);
|
||||
assert.equal(presentation.compactLabel, '베팅장');
|
||||
assert.equal(presentation.to, '/betting');
|
||||
assert.equal(presentation.active, true);
|
||||
}
|
||||
});
|
||||
|
||||
void test('keeps the generic tournament button when no tournament state exists', () => {
|
||||
const presentation = resolveTournamentMainPresentation(0, null);
|
||||
assert.equal(presentation.compactLabel, '토너먼트');
|
||||
assert.equal(presentation.to, '/tournament');
|
||||
assert.equal(presentation.active, false);
|
||||
});
|
||||
Reference in New Issue
Block a user