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) =>
|
const readGeneralPanelImages = async (panel: Locator) =>
|
||||||
panel.evaluate((element) =>
|
panel.evaluate((element) =>
|
||||||
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
|
[...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('#container')).not.toContainText('che_');
|
||||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||||
await expect(page.locator('#set_my_setting')).toBeVisible();
|
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 [role="progressbar"]')).toHaveCount(14);
|
||||||
await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||||
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
|
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 settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
|
||||||
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
|
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
|
||||||
const save = saveButton.getBoundingClientRect();
|
const save = saveButton.getBoundingClientRect();
|
||||||
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
|
|
||||||
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
|
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
|
||||||
return {
|
return {
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
@@ -1302,8 +1311,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
|||||||
saveWidth: save.width,
|
saveWidth: save.width,
|
||||||
saveHeight: save.height,
|
saveHeight: save.height,
|
||||||
saveBackground: getComputedStyle(saveButton).backgroundColor,
|
saveBackground: getComputedStyle(saveButton).backgroundColor,
|
||||||
customCssWidth: customCss.width,
|
|
||||||
customCssHeight: customCss.height,
|
|
||||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||||
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).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.saveWidth).toBe(160);
|
||||||
expect(desktop.saveHeight).toBe(30);
|
expect(desktop.saveHeight).toBe(30);
|
||||||
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
|
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.backgroundImage).toContain('back_walnut.jpg');
|
||||||
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
|
||||||
await expectLumenButtonStates(page, page.locator('#set_my_setting'), 'rgb(34, 85, 0)');
|
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: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(page, state);
|
await install(page, state);
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
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();
|
await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
|
||||||
const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
||||||
@@ -1637,7 +1699,8 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
|
|||||||
expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44);
|
expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44);
|
||||||
expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36);
|
expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36);
|
||||||
expect(dialogGeometry.documentWidth).toBe(390);
|
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 dialog.getByRole('button', { name: '적용', exact: true }).click();
|
||||||
await expect(dialog).toBeHidden();
|
await expect(dialog).toBeHidden();
|
||||||
@@ -1654,7 +1717,7 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
|
|||||||
.toEqual(defaultOrder);
|
.toEqual(defaultOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
||||||
const configuredBaseUrl = testInfo.project.use.baseURL;
|
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||||
if (typeof configuredBaseUrl !== 'string') {
|
if (typeof configuredBaseUrl !== 'string') {
|
||||||
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
||||||
@@ -1672,7 +1735,8 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
|
|||||||
try {
|
try {
|
||||||
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
await install(mobilePage, state);
|
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();
|
await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
|
||||||
|
|
||||||
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type NavigationFixture = {
|
|||||||
permission: number;
|
permission: number;
|
||||||
nationLevel: number;
|
nationLevel: number;
|
||||||
stage: number;
|
stage: number;
|
||||||
|
tournamentType?: 0 | 1 | 2 | 3;
|
||||||
|
tournamentWinnerId?: number;
|
||||||
npcMode: number;
|
npcMode: number;
|
||||||
generalMeCalls: number;
|
generalMeCalls: number;
|
||||||
operations: string[];
|
operations: string[];
|
||||||
@@ -758,7 +760,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
canSecret: state.permission >= 2,
|
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 });
|
return response({ ok: true });
|
||||||
});
|
});
|
||||||
operations.forEach((operation, index) => {
|
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="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="official-chat"]')).toHaveAttribute('target', '_blank');
|
||||||
await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/);
|
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 gameInfoButton = global.locator('[data-menu-id="game-info"]');
|
||||||
const bettingButton = global.locator('[data-navigation-id="nation-betting"]');
|
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 }) => {
|
test('shows game index zero for a profile whose first game starts at zero', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
@@ -1455,7 +1575,7 @@ test('nation split buttons keep square inner corners and a single divider in eve
|
|||||||
officerLevel: 5,
|
officerLevel: 5,
|
||||||
permission: 2,
|
permission: 2,
|
||||||
nationLevel: 3,
|
nationLevel: 3,
|
||||||
stage: 1,
|
stage: 6,
|
||||||
npcMode: 1,
|
npcMode: 1,
|
||||||
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
|
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
|
||||||
generalMeCalls: 0,
|
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]) {
|
for (const width of [1200, 500]) {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
await waitForMain(page);
|
await waitForMain(page);
|
||||||
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
|
|
||||||
const pairs: Array<[string, Locator, Locator]> = [
|
const pairs: Array<[string, Locator, Locator]> = [
|
||||||
[
|
[
|
||||||
'nation',
|
'tournament',
|
||||||
nationSplit.locator('[data-navigation-id="auction-resource"]'),
|
page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]'),
|
||||||
nationSplit.locator('[data-menu-id="auction"]'),
|
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) {
|
for (const [label, main, toggle] of pairs) {
|
||||||
await expect(main).toBeVisible();
|
await expect(main).toBeVisible();
|
||||||
@@ -3959,9 +4086,7 @@ for (const viewport of [
|
|||||||
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
await picker.getByLabel('장비 종류', { exact: true }).selectOption('weapon');
|
||||||
const equipment = picker.getByLabel('장비', { exact: true });
|
const equipment = picker.getByLabel('장비', { exact: true });
|
||||||
await equipment.selectOption('청룡언월도');
|
await equipment.selectOption('청룡언월도');
|
||||||
const optionLabelBeforeRefresh = await equipment
|
const optionLabelBeforeRefresh = await equipment.locator('option[value="청룡언월도"]').textContent();
|
||||||
.locator('option[value="청룡언월도"]')
|
|
||||||
.textContent();
|
|
||||||
await equipment.evaluate((element) => {
|
await equipment.evaluate((element) => {
|
||||||
const select = element as HTMLSelectElement;
|
const select = element as HTMLSelectElement;
|
||||||
const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
|
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 waitForMain(mobilePage);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
mobilePage.evaluate(
|
mobilePage.evaluate(() =>
|
||||||
() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
|
(window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ body {
|
|||||||
|
|
||||||
/* These redesigned screens own a true handheld layout. */
|
/* These redesigned screens own a true handheld layout. */
|
||||||
#app:has(.responsive-settings-page),
|
#app:has(.responsive-settings-page),
|
||||||
|
#app:has(.interface-settings-page),
|
||||||
#app:has(#tournament-container),
|
#app:has(#tournament-container),
|
||||||
#app:has(#tournament-betting-container),
|
#app:has(#tournament-betting-container),
|
||||||
#app:has(#personnel-container) {
|
#app:has(#personnel-container) {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
|||||||
import MainNavigationLink from './MainNavigationLink.vue';
|
import MainNavigationLink from './MainNavigationLink.vue';
|
||||||
import {
|
import {
|
||||||
buildGlobalNavigation,
|
buildGlobalNavigation,
|
||||||
|
buildNationNavigation,
|
||||||
isNationNavigationEnabled,
|
isNationNavigationEnabled,
|
||||||
isNavigationConfigured,
|
isNavigationConfigured,
|
||||||
nationNavigation,
|
|
||||||
quickNavigation,
|
quickNavigation,
|
||||||
type MainNavigationLink as MainNavigationLinkItem,
|
type MainNavigationLink as MainNavigationLinkItem,
|
||||||
type MainNavigationEntry,
|
type MainNavigationEntry,
|
||||||
@@ -18,6 +18,7 @@ import { useMenuPopup } from './useMenuPopup';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
access: NationNavigationAccess;
|
access: NationNavigationAccess;
|
||||||
tournamentStage: number;
|
tournamentStage: number;
|
||||||
|
tournamentType: number | null;
|
||||||
nationColor: string;
|
nationColor: string;
|
||||||
npcMode: number;
|
npcMode: number;
|
||||||
realtimeEnabled: boolean;
|
realtimeEnabled: boolean;
|
||||||
@@ -35,9 +36,12 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { setRoot, openId, close, toggle } = useMenuPopup();
|
const { setRoot, openId, close, toggle } = useMenuPopup();
|
||||||
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
|
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
|
||||||
|
const nationEntries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType));
|
||||||
const nationMenuColor = computed(() => props.nationColor || '#000000');
|
const nationMenuColor = computed(() => props.nationColor || '#000000');
|
||||||
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
|
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) => {
|
const onQuick = (item: QuickNavigationItem) => {
|
||||||
close();
|
close();
|
||||||
@@ -148,7 +152,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
|
|||||||
<span class="dropup-caret" aria-hidden="true"></span>
|
<span class="dropup-caret" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<ul v-if="openId === 'nation'" id="mobile-nation-menu" class="bottom-popup" role="menu">
|
<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">
|
<li v-if="entry.kind === 'link'" role="none">
|
||||||
<MainNavigationLink
|
<MainNavigationLink
|
||||||
:link="entry"
|
:link="entry"
|
||||||
@@ -165,6 +169,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
|
|||||||
<MainNavigationLink
|
<MainNavigationLink
|
||||||
:link="item"
|
:link="item"
|
||||||
:enabled="isNationNavigationEnabled(item, access)"
|
:enabled="isNationNavigationEnabled(item, access)"
|
||||||
|
:active="isActive(item)"
|
||||||
compact
|
compact
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@navigate="close()"
|
@navigate="close()"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import MainNavigationLink from './MainNavigationLink.vue';
|
import MainNavigationLink from './MainNavigationLink.vue';
|
||||||
import {
|
import {
|
||||||
|
buildNationNavigation,
|
||||||
isNationNavigationEnabled,
|
isNationNavigationEnabled,
|
||||||
nationNavigation,
|
|
||||||
type MainNavigationLink as MainNavigationLinkItem,
|
type MainNavigationLink as MainNavigationLinkItem,
|
||||||
type NationNavigationAccess,
|
type NationNavigationAccess,
|
||||||
} from './mainNavigation';
|
} from './mainNavigation';
|
||||||
@@ -12,11 +13,15 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
access: NationNavigationAccess;
|
access: NationNavigationAccess;
|
||||||
tournamentStage: number;
|
tournamentStage: number;
|
||||||
|
tournamentType: number | null;
|
||||||
nationColor: string;
|
nationColor: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { setRoot, openId, close, toggle } = useMenuPopup();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -27,7 +32,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
|||||||
:class="{ 'dark-label': legacyNationTextColor(nationColor) === '#000000' }"
|
:class="{ 'dark-label': legacyNationTextColor(nationColor) === '#000000' }"
|
||||||
aria-label="국가 메뉴"
|
aria-label="국가 메뉴"
|
||||||
>
|
>
|
||||||
<template v-for="entry in nationNavigation" :key="entry.id">
|
<template v-for="entry in entries" :key="entry.id">
|
||||||
<MainNavigationLink
|
<MainNavigationLink
|
||||||
v-if="entry.kind === 'link'"
|
v-if="entry.kind === 'link'"
|
||||||
:link="entry"
|
:link="entry"
|
||||||
@@ -59,6 +64,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
|
|||||||
<MainNavigationLink
|
<MainNavigationLink
|
||||||
:link="item"
|
:link="item"
|
||||||
:enabled="isNationNavigationEnabled(item, access)"
|
:enabled="isNationNavigationEnabled(item, access)"
|
||||||
|
:active="isActive(item)"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@navigate="close()"
|
@navigate="close()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
RuntimeNavigationLink,
|
RuntimeNavigationLink,
|
||||||
} from '@sammo-ts/common/navigation/menuConfig';
|
} from '@sammo-ts/common/navigation/menuConfig';
|
||||||
import defaultNavigationJson from '../../../../../resources/navigation.json';
|
import defaultNavigationJson from '../../../../../resources/navigation.json';
|
||||||
|
import { resolveTournamentMainPresentation } from '../../utils/tournamentNavigation';
|
||||||
|
|
||||||
export type NationAccessRule =
|
export type NationAccessRule =
|
||||||
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
|
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
|
||||||
@@ -11,7 +12,8 @@ export type NationAccessRule =
|
|||||||
export type MainNavigationLink = RuntimeNavigationLink & {
|
export type MainNavigationLink = RuntimeNavigationLink & {
|
||||||
compactLabel?: string;
|
compactLabel?: string;
|
||||||
access?: NationAccessRule;
|
access?: NationAccessRule;
|
||||||
highlightStage?: 1 | 6;
|
highlightStage?: number;
|
||||||
|
highlightStages?: readonly number[];
|
||||||
unavailableReason?: string;
|
unavailableReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,14 +146,37 @@ export const nationNavigation: MainNavigationEntry[] = [
|
|||||||
access: 'nation-secret',
|
access: 'nation-secret',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'link',
|
kind: 'split',
|
||||||
id: 'tournament',
|
id: 'tournament-betting',
|
||||||
label: '토 너 먼 트',
|
main: {
|
||||||
compactLabel: '토너먼트',
|
kind: 'link',
|
||||||
to: '/tournament',
|
id: 'tournament',
|
||||||
newTab: true,
|
label: '토 너 먼 트',
|
||||||
access: 'always',
|
compactLabel: '토너먼트',
|
||||||
highlightStage: 1,
|
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',
|
kind: 'link',
|
||||||
@@ -218,18 +243,42 @@ export const nationNavigation: MainNavigationEntry[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{ kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' },
|
||||||
kind: 'link',
|
|
||||||
id: 'betting',
|
|
||||||
label: '베 팅 장',
|
|
||||||
compactLabel: '베팅장',
|
|
||||||
to: '/betting',
|
|
||||||
newTab: true,
|
|
||||||
access: 'always',
|
|
||||||
highlightStage: 6,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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> = [
|
export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider> = [
|
||||||
{ kind: 'divider', id: 'quick-nation-heading', label: '국가 정보' },
|
{ kind: 'divider', id: 'quick-nation-heading', label: '국가 정보' },
|
||||||
{ id: 'policy', label: '방침', tab: 'map', selector: '[data-main-target="policy"]' },
|
{ 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"]' },
|
{ 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 => {
|
export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
|
||||||
const rule = link.access ?? 'always';
|
const rule = link.access ?? 'always';
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import './assets/main.css';
|
import './assets/main.css';
|
||||||
|
import { applyStoredCustomCss } from './utils/customCss';
|
||||||
import { installImageAssetCssVariables } from './utils/imageAssets';
|
import { installImageAssetCssVariables } from './utils/imageAssets';
|
||||||
import { installScreenModeViewport } from './utils/screenModeViewport';
|
import { installScreenModeViewport } from './utils/screenModeViewport';
|
||||||
|
|
||||||
installImageAssetCssVariables();
|
installImageAssetCssVariables();
|
||||||
installScreenModeViewport();
|
installScreenModeViewport();
|
||||||
|
applyStoredCustomCss();
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const NotFoundView = () => import('../views/NotFoundView.vue');
|
|||||||
const TournamentView = () => import('../views/TournamentView.vue');
|
const TournamentView = () => import('../views/TournamentView.vue');
|
||||||
const BettingView = () => import('../views/BettingView.vue');
|
const BettingView = () => import('../views/BettingView.vue');
|
||||||
const MyPageView = () => import('../views/MyPageView.vue');
|
const MyPageView = () => import('../views/MyPageView.vue');
|
||||||
|
const MySettingsView = () => import('../views/MySettingsView.vue');
|
||||||
const BoardView = () => import('../views/BoardView.vue');
|
const BoardView = () => import('../views/BoardView.vue');
|
||||||
const DiplomacyView = () => import('../views/DiplomacyView.vue');
|
const DiplomacyView = () => import('../views/DiplomacyView.vue');
|
||||||
const BestGeneralView = () => import('../views/BestGeneralView.vue');
|
const BestGeneralView = () => import('../views/BestGeneralView.vue');
|
||||||
@@ -333,7 +334,8 @@ const routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/my-settings',
|
path: '/my-settings',
|
||||||
redirect: '/my-page',
|
name: 'my-settings',
|
||||||
|
component: MySettingsView,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresGeneral: true,
|
requiresGeneral: true,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||||
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||||
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.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 ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
|
||||||
type DashboardReadModelPatch = {
|
type DashboardReadModelPatch = {
|
||||||
contextSnapshot?: GeneralContext;
|
contextSnapshot?: GeneralContext;
|
||||||
@@ -76,6 +77,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
worldHistory?: RecentRecord[];
|
worldHistory?: RecentRecord[];
|
||||||
frontStatus?: FrontStatus | null;
|
frontStatus?: FrontStatus | null;
|
||||||
tournamentStage?: number;
|
tournamentStage?: number;
|
||||||
|
tournamentType?: TournamentType | null;
|
||||||
};
|
};
|
||||||
type DashboardTabMessage =
|
type DashboardTabMessage =
|
||||||
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
|
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
|
||||||
@@ -117,6 +119,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
const worldHistory = ref<RecentRecord[]>([]);
|
const worldHistory = ref<RecentRecord[]>([]);
|
||||||
const frontStatus = ref<FrontStatus | null>(null);
|
const frontStatus = ref<FrontStatus | null>(null);
|
||||||
const tournamentStage = ref(0);
|
const tournamentStage = ref(0);
|
||||||
|
const tournamentType = ref<TournamentType | null>(null);
|
||||||
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
||||||
let lastGeneralRecordId = 0;
|
let lastGeneralRecordId = 0;
|
||||||
let lastWorldHistoryId = 0;
|
let lastWorldHistoryId = 0;
|
||||||
@@ -440,6 +443,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
if (patch.tournamentStage !== undefined) {
|
if (patch.tournamentStage !== undefined) {
|
||||||
tournamentStage.value = patch.tournamentStage;
|
tournamentStage.value = patch.tournamentStage;
|
||||||
}
|
}
|
||||||
|
if (patch.tournamentType !== undefined) {
|
||||||
|
tournamentType.value = patch.tournamentType;
|
||||||
|
}
|
||||||
if (patch.contextRevision !== undefined) {
|
if (patch.contextRevision !== undefined) {
|
||||||
contextRevision = patch.contextRevision;
|
contextRevision = patch.contextRevision;
|
||||||
contextSourceRevision = patch.contextSourceRevision ?? null;
|
contextSourceRevision = patch.contextSourceRevision ?? null;
|
||||||
@@ -487,6 +493,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
patch.worldHistory = toRaw(worldHistory.value);
|
patch.worldHistory = toRaw(worldHistory.value);
|
||||||
patch.frontStatus = toRaw(frontStatus.value);
|
patch.frontStatus = toRaw(frontStatus.value);
|
||||||
patch.tournamentStage = tournamentStage.value;
|
patch.tournamentStage = tournamentStage.value;
|
||||||
|
patch.tournamentType = tournamentType.value;
|
||||||
return patch;
|
return patch;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -637,6 +644,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
}
|
}
|
||||||
if (tournamentState !== undefined) {
|
if (tournamentState !== undefined) {
|
||||||
tournamentStage.value = tournamentState?.stage ?? 0;
|
tournamentStage.value = tournamentState?.stage ?? 0;
|
||||||
|
tournamentType.value = tournamentState?.type ?? null;
|
||||||
}
|
}
|
||||||
if (initializedMailboxGeneralId !== id) {
|
if (initializedMailboxGeneralId !== id) {
|
||||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||||
@@ -765,7 +773,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
patch.worldHistory = nextWorldHistory;
|
patch.worldHistory = nextWorldHistory;
|
||||||
}
|
}
|
||||||
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
|
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);
|
applyDashboardPatch(patch);
|
||||||
publishDashboardPatch(patch);
|
publishDashboardPatch(patch);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1290,6 +1301,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
|||||||
worldHistory,
|
worldHistory,
|
||||||
frontStatus,
|
frontStatus,
|
||||||
tournamentStage,
|
tournamentStage,
|
||||||
|
tournamentType,
|
||||||
surveyNotice,
|
surveyNotice,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
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,
|
worldHistory,
|
||||||
frontStatus,
|
frontStatus,
|
||||||
tournamentStage,
|
tournamentStage,
|
||||||
|
tournamentType,
|
||||||
surveyNotice,
|
surveyNotice,
|
||||||
messageDraftText,
|
messageDraftText,
|
||||||
targetMailbox,
|
targetMailbox,
|
||||||
@@ -301,6 +302,7 @@ watch(
|
|||||||
class="nation-menu-middle"
|
class="nation-menu-middle"
|
||||||
:access="nationAccess"
|
:access="nationAccess"
|
||||||
:tournament-stage="tournamentStage"
|
:tournament-stage="tournamentStage"
|
||||||
|
:tournament-type="tournamentType"
|
||||||
:nation-color="nationColor"
|
:nation-color="nationColor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,6 +482,7 @@ watch(
|
|||||||
class="nation-menu-middle"
|
class="nation-menu-middle"
|
||||||
:access="nationAccess"
|
:access="nationAccess"
|
||||||
:tournament-stage="tournamentStage"
|
:tournament-stage="tournamentStage"
|
||||||
|
:tournament-type="tournamentType"
|
||||||
:nation-color="nationColor"
|
:nation-color="nationColor"
|
||||||
/>
|
/>
|
||||||
<section class="record-zone">
|
<section class="record-zone">
|
||||||
@@ -570,6 +573,7 @@ watch(
|
|||||||
v-if="isMobile"
|
v-if="isMobile"
|
||||||
:access="nationAccess"
|
:access="nationAccess"
|
||||||
:tournament-stage="tournamentStage"
|
:tournament-stage="tournamentStage"
|
||||||
|
:tournament-type="tournamentType"
|
||||||
:nation-color="nationColor"
|
:nation-color="nationColor"
|
||||||
:npc-mode="npcMode"
|
:npc-mode="npcMode"
|
||||||
:realtime-enabled="realtimeEnabled"
|
:realtime-enabled="realtimeEnabled"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import SortableStringList from '../components/ui/SortableStringList';
|
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
@@ -10,19 +9,9 @@ import { useSessionStore } from '../stores/session';
|
|||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
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 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 LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
|
||||||
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
|
||||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||||
@@ -52,14 +41,8 @@ const dieOnPrestartStatusLoading = ref(false);
|
|||||||
const dieOnPrestartStatusError = ref<string | null>(null);
|
const dieOnPrestartStatusError = ref<string | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const screenMode = ref<ScreenMode>('auto');
|
|
||||||
const customCss = ref('');
|
|
||||||
const selectedIconId = ref('');
|
const selectedIconId = ref('');
|
||||||
const cssSaving = ref(false);
|
|
||||||
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
|
||||||
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
let cssTimer: number | null = null;
|
|
||||||
const readPendingDieOnPrestartId = (): string => {
|
const readPendingDieOnPrestartId = (): string => {
|
||||||
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
|
||||||
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
|
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 iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||||
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
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 autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
|
||||||
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
|
||||||
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
|
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
|
||||||
@@ -233,16 +192,6 @@ const formatSelectionAvailableAt = computed(() => {
|
|||||||
return formatSeoulDateTime(value);
|
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) => {
|
const loadLog = async (type: LogType, beforeId?: number) => {
|
||||||
if (logLoading[type]) return;
|
if (logLoading[type]) return;
|
||||||
logLoading[type] = true;
|
logLoading[type] = true;
|
||||||
@@ -370,32 +319,13 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
|
|||||||
trpc.general.dropItem.mutate({ itemType: item.key })
|
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(() => {
|
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();
|
void loadPage();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="title-row">
|
||||||
<span>내 정 보</span>
|
<span>내 정 보</span>
|
||||||
<div class="title-actions">
|
<div class="title-actions">
|
||||||
@@ -613,29 +543,6 @@ onMounted(() => {
|
|||||||
<br /><br />
|
<br /><br />
|
||||||
</div>
|
</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-title">아이템 파기</div>
|
||||||
<div class="item-group">
|
<div class="item-group">
|
||||||
<button
|
<button
|
||||||
@@ -649,11 +556,6 @@ onMounted(() => {
|
|||||||
{{ item.displayName ?? '-' }}
|
{{ item.displayName ?? '-' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="custom-css">
|
|
||||||
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
|
|
||||||
<textarea id="custom_css" v-model="customCss" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -691,67 +593,6 @@ onMounted(() => {
|
|||||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</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>
|
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -773,12 +614,6 @@ onMounted(() => {
|
|||||||
.my-page-mobile-scroll-spacer {
|
.my-page-mobile-scroll-spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.legacy-page.screen-500px {
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
.legacy-page.screen-1000px {
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
|
||||||
.title-row {
|
.title-row {
|
||||||
height: 54px;
|
height: 54px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -815,8 +650,7 @@ textarea {
|
|||||||
background: #6b6b6b;
|
background: #6b6b6b;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
.legacy-page .legacy-button,
|
.legacy-page .legacy-button {
|
||||||
.mobile-layout-dialog .legacy-button {
|
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
@@ -924,139 +758,6 @@ button:disabled {
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
color: #f66;
|
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 {
|
.item-title {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
@@ -1068,17 +769,6 @@ button:disabled {
|
|||||||
.item-group button {
|
.item-group button {
|
||||||
min-height: 30px;
|
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 {
|
.log-panel {
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
}
|
}
|
||||||
@@ -1153,24 +843,8 @@ button:disabled {
|
|||||||
.settings-column {
|
.settings-column {
|
||||||
padding: 10px 12px;
|
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 {
|
.item-group {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
.custom-css textarea {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,453 +1,434 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import PanelCard from '../components/ui/PanelCard.vue';
|
import SortableStringList from '../components/ui/SortableStringList';
|
||||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
|
||||||
import { useGameFeedback } from '../composables/useGameFeedback';
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
||||||
import { trpc } from '../utils/trpc';
|
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
|
||||||
|
import {
|
||||||
const CUSTOM_CSS_KEY = 'sammo-custom-css';
|
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
|
||||||
const SCREEN_MODE_KEY = 'sammo-screen-mode';
|
loadMobileMainPanelOrder,
|
||||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
MOBILE_MAIN_PANEL_DEFINITIONS,
|
||||||
|
moveMobileMainPanel,
|
||||||
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
saveMobileMainPanelOrder,
|
||||||
|
type MobileMainPanelId,
|
||||||
type WorldStateSnapshot = {
|
} from '../utils/mobileMainPanelOrder';
|
||||||
config: Record<string, unknown>;
|
import {
|
||||||
meta: Record<string, unknown>;
|
normalizeScreenMode,
|
||||||
} | null;
|
SCREEN_MODE_CHANGE_EVENT,
|
||||||
|
SCREEN_MODE_KEY,
|
||||||
type ScreenMode = 'auto' | '500px' | '1000px';
|
type ScreenMode,
|
||||||
|
} from '../utils/screenModeViewport';
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const { success: showSuccessToast } = useGameFeedback();
|
||||||
const screenMode = ref<ScreenMode>('auto');
|
const screenMode = ref<ScreenMode>('auto');
|
||||||
const customCss = ref('');
|
const customCss = ref('');
|
||||||
const cssSaving = ref(false);
|
const cssSaving = ref(false);
|
||||||
|
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
||||||
|
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
||||||
let cssTimer: number | null = null;
|
let cssTimer: number | null = null;
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
|
||||||
if (value instanceof Error) {
|
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
||||||
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();
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
const openMobileLayoutDialog = () => {
|
||||||
() => customCss.value,
|
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
||||||
() => {
|
mobileLayoutDialog.value?.showModal();
|
||||||
if (cssTimer) {
|
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
|
||||||
window.clearTimeout(cssTimer);
|
};
|
||||||
}
|
|
||||||
cssSaving.value = true;
|
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
|
||||||
cssTimer = window.setTimeout(() => {
|
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
|
||||||
saveCustomCss();
|
};
|
||||||
cssSaving.value = false;
|
|
||||||
}, 400);
|
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(() => {
|
onMounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
|
||||||
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
|
||||||
applyCustomCss(customCss.value);
|
});
|
||||||
}
|
|
||||||
loadScreenMode();
|
onBeforeUnmount(() => {
|
||||||
void loadSettings();
|
if (cssTimer === null) return;
|
||||||
|
window.clearTimeout(cssTimer);
|
||||||
|
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
|
||||||
|
applyCustomCss(customCss.value);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="settings-page">
|
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
|
||||||
<header class="page-header">
|
<div class="title-row">
|
||||||
<div>
|
<span>화 면 설 정</span>
|
||||||
<h1 class="page-title">게임 설정</h1>
|
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
|
||||||
<p class="page-subtitle">내 정보 설정을 관리합니다.</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<RouterLink class="ghost" to="/my-page">내 정보</RouterLink>
|
|
||||||
<button class="ghost" @click="loadSettings">새로고침</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<section class="settings-grid">
|
||||||
|
<article class="settings-column">
|
||||||
<section class="layout-grid">
|
<h2 class="section-title">화면 크기와 배치</h2>
|
||||||
<div class="stack">
|
<div class="settings-content">
|
||||||
<PanelCard title="게임 옵션" subtitle="설정 저장 시 즉시 반영됩니다.">
|
<div class="screen-mode-row">
|
||||||
<SkeletonLines v-if="loading" :lines="4" />
|
<span>500px/1000px 모드<br /><small>모바일 화면 폭을 즉시 바꿉니다.</small></span>
|
||||||
<div v-else class="form-grid">
|
<div class="button-group" role="radiogroup" aria-label="화면 폭 모드">
|
||||||
<label class="form-field">
|
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
|
||||||
<span>토너먼트 참가</span>
|
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
|
||||||
<select v-model.number="form.tnmt">
|
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
|
||||||
<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 }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stack">
|
<div class="mobile-layout-setting-row">
|
||||||
<PanelCard title="화면 모드" subtitle="모바일 전용 설정입니다.">
|
<span>
|
||||||
<div class="screen-mode">
|
모바일 메인 레이아웃<br />
|
||||||
<label>
|
<small>500px 메인 화면의 패널 순서를 이 기기에 저장합니다.</small>
|
||||||
<input v-model="screenMode" type="radio" value="auto" />
|
</span>
|
||||||
자동
|
<button
|
||||||
</label>
|
class="legacy-button legacy-button--primary mobile-layout-open"
|
||||||
<label>
|
type="button"
|
||||||
<input v-model="screenMode" type="radio" value="500px" />
|
@click="openMobileLayoutDialog"
|
||||||
500px
|
>
|
||||||
</label>
|
순서 바꾸기
|
||||||
<label>
|
</button>
|
||||||
<input v-model="screenMode" type="radio" value="1000px" />
|
|
||||||
1000px
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<PanelCard title="개인용 CSS" subtitle="변경 사항은 자동 저장됩니다.">
|
<article class="settings-column">
|
||||||
<textarea v-model="customCss" class="css-input" rows="8" />
|
<h2 class="section-title">개인용 CSS</h2>
|
||||||
<div class="hint">{{ cssSaving ? '저장 중...' : '자동 저장 완료' }}</div>
|
<div class="settings-content">
|
||||||
</PanelCard>
|
<label class="custom-css">
|
||||||
</div>
|
브라우저에 적용할 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>
|
</section>
|
||||||
|
|
||||||
|
<footer class="legacy-credit">
|
||||||
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
||||||
|
</footer>
|
||||||
</main>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page {
|
.legacy-page {
|
||||||
min-height: 100vh;
|
box-sizing: border-box;
|
||||||
padding: 24px;
|
width: 100%;
|
||||||
display: flex;
|
max-width: 1000px;
|
||||||
flex-direction: column;
|
min-height: 0;
|
||||||
gap: 24px;
|
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;
|
||||||
}
|
}
|
||||||
|
.title-row {
|
||||||
.page-header {
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 16px;
|
border-bottom: 1px solid #666;
|
||||||
flex-wrap: wrap;
|
background-color: #14241b;
|
||||||
|
background-image: var(--sammo-texture-green);
|
||||||
|
color: skyblue;
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
.settings-content {
|
||||||
.page-title {
|
padding: 10px 18px;
|
||||||
font-size: 1.6rem;
|
}
|
||||||
|
.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;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.custom-css {
|
||||||
.page-subtitle {
|
display: block;
|
||||||
color: rgba(232, 221, 196, 0.7);
|
}
|
||||||
|
.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;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
.legacy-credit {
|
||||||
.header-actions {
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: center;
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
.mobile-layout-dialog__header {
|
||||||
.layout-grid {
|
justify-content: space-between;
|
||||||
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;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.mobile-layout-dialog__header h2,
|
||||||
.form-field {
|
.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;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 0.9rem;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
.mobile-layout-list > li {
|
||||||
.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 {
|
|
||||||
display: grid;
|
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;
|
gap: 6px;
|
||||||
font-size: 0.85rem;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
.mobile-layout-dialog__actions button {
|
||||||
.penalty-item {
|
min-height: 34px;
|
||||||
color: #f08a5d;
|
padding: 4px 10px;
|
||||||
}
|
}
|
||||||
|
.mobile-layout-dialog__actions .mobile-layout-apply {
|
||||||
.hint {
|
font-weight: 700;
|
||||||
color: rgba(232, 221, 196, 0.7);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
@media (max-width: 939.98px) {
|
||||||
.ghost {
|
.settings-grid {
|
||||||
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 {
|
|
||||||
grid-template-columns: 1fr;
|
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>
|
</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