feat(game-ui): 토너먼트와 베팅 메뉴를 합치고 화면 설정을 분리한다

토너먼트 기본 동작과 베팅 선택을 split 메뉴로 묶고 빈 국가 메뉴 칸에 화면 설정을 배치한다. 내 정보 화면에는 게임 상태 설정만 남기며 화면 폭, 모바일 패널 순서, 개인 CSS는 기기별 전용 화면에서 관리한다.
This commit is contained in:
2026-08-21 23:09:07 +00:00
parent 6537bcc5c1
commit bd894398b5
11 changed files with 567 additions and 768 deletions
+72 -10
View File
@@ -22,6 +22,13 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown
]);
};
const waitForVisualAssets = async (page: Page): Promise<void> => {
await page.waitForLoadState('networkidle');
await page.evaluate(async () => {
await document.fonts.ready;
});
};
const readGeneralPanelImages = async (panel: Locator) =>
panel.evaluate((element) =>
[...element.querySelectorAll<HTMLElement>('.general-image')].map((image) => {
@@ -1270,6 +1277,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await expect(page.locator('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect(page.getByRole('radiogroup', { name: '화면 폭 모드' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '순서 바꾸기', exact: true })).toHaveCount(0);
await expect(page.locator('#custom_css')).toHaveCount(0);
await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.general-column [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
@@ -1290,7 +1300,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
const settings = element.querySelector<HTMLElement>('.settings-column')!.getBoundingClientRect();
const saveButton = element.querySelector<HTMLElement>('#set_my_setting')!;
const save = saveButton.getBoundingClientRect();
const customCss = element.querySelector<HTMLElement>('#custom_css')!.getBoundingClientRect();
const columns = getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns;
return {
width: rect.width,
@@ -1302,8 +1311,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
saveWidth: save.width,
saveHeight: save.height,
saveBackground: getComputedStyle(saveButton).backgroundColor,
customCssWidth: customCss.width,
customCssHeight: customCss.height,
backgroundImage: getComputedStyle(element).backgroundImage,
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
};
@@ -1317,8 +1324,6 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
expect(desktop.saveWidth).toBe(160);
expect(desktop.saveHeight).toBe(30);
expect(desktop.saveBackground).toBe('rgb(34, 85, 0)');
expect(desktop.customCssWidth).toBe(420);
expect(desktop.customCssHeight).toBe(150);
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
await expectLumenButtonStates(page, page.locator('#set_my_setting'), 'rgb(34, 85, 0)');
@@ -1583,11 +1588,66 @@ 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.locator('.scope-note')).toContainText('게임 상태에는 영향을 주지 않습니다.');
await expect(page.locator('#set_my_setting')).toHaveCount(0);
await expect(page.locator('#custom_css')).toBeVisible();
expect(state.settingMutations).toHaveLength(0);
const desktop = await page.locator('#interface-settings').evaluate((element) => {
const rect = element.getBoundingClientRect();
const css = element.querySelector<HTMLTextAreaElement>('#custom_css')!.getBoundingClientRect();
return {
width: rect.width,
columns: getComputedStyle(element.querySelector('.settings-grid')!).gridTemplateColumns,
cssWidth: css.width,
cssHeight: css.height,
backgroundImage: getComputedStyle(element).backgroundImage,
sectionBackgroundImage: getComputedStyle(element.querySelector('.section-title')!).backgroundImage,
};
});
expect(desktop.width).toBe(1000);
expect(desktop.columns.split(' ')).toHaveLength(2);
expect(desktop.cssWidth).toBe(420);
expect(desktop.cssHeight).toBe(150);
expect(desktop.backgroundImage).toContain('back_walnut.jpg');
expect(desktop.sectionBackgroundImage).toContain('back_green.jpg');
await page.screenshot({ path: testInfo.outputPath('interface-settings-desktop.png'), fullPage: true });
await page.getByRole('radio', { name: '500px' }).check();
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam.screenMode'))).toBe('500px');
await expect(page.locator('meta[name="viewport"]')).toHaveAttribute('content', 'width=500');
const cssText = '#interface-settings { --ui-settings-e2e: 23px; }';
await page.getByLabel('개인용 CSS').fill(cssText);
await expect(page.locator('.custom-css span')).toHaveText('(저장 중)');
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe(cssText);
await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText);
await page.reload();
await expect.poll(() => page.locator('#sammo-custom-css').textContent()).toBe(cssText);
expect(state.settingMutations).toHaveLength(0);
await page.getByLabel('개인용 CSS').fill('');
await expect.poll(() => page.evaluate(() => localStorage.getItem('sam_customCSS'))).toBe('');
await page.getByRole('radio', { name: '자동' }).check();
await persistParityArtifact(page, 'core-interface-settings-desktop', desktop);
});
test('화면 설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 기본 순서로 복원한다', async ({
page,
}, testInfo) => {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('my-page');
await page.goto('my-settings');
await waitForVisualAssets(page);
await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
@@ -1637,7 +1697,8 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44);
expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36);
expect(dialogGeometry.documentWidth).toBe(390);
await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry);
await dialog.screenshot({ path: testInfo.outputPath('interface-settings-mobile-layout-dialog.png') });
await persistParityArtifact(page, 'core-interface-settings-mobile-layout-order-dialog', dialogGeometry);
await dialog.getByRole('button', { name: '적용', exact: true }).click();
await expect(dialog).toBeHidden();
@@ -1654,7 +1715,7 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
.toEqual(defaultOrder);
});
test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
test('화면 설정에서 실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile touch contract');
@@ -1672,7 +1733,8 @@ test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async
try {
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(mobilePage, state);
await mobilePage.goto('my-page');
await mobilePage.goto('my-settings');
await waitForVisualAssets(mobilePage);
await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
+33 -6
View File
@@ -1260,7 +1260,27 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community');
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank');
await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/);
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
const nationMenu = page.locator('.main-nation-menu:visible');
await expect(nationMenu.locator(':scope > *')).toHaveCount(20);
const tournamentMain = nationMenu.locator('[data-navigation-id="tournament"]');
const tournamentToggle = nationMenu.locator('[data-menu-id="tournament-betting"]');
await expect(tournamentMain).toHaveClass(/highlight/);
await expect(tournamentMain).toHaveAttribute('href', `${basePath}/tournament`);
await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'false');
await expect(nationMenu.locator('[data-navigation-id="my-settings"]')).toHaveAttribute(
'href',
`${basePath}/my-settings`
);
await tournamentToggle.click();
await expect(tournamentToggle).toHaveAttribute('aria-expanded', 'true');
await expect(nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="tournament-menu"]')).toHaveText(
'토너먼트'
);
await expect(nationMenu.locator('#nation-menu-tournament-betting [data-navigation-id="betting"]')).toHaveAttribute(
'href',
`${basePath}/betting`
);
await page.keyboard.press('Escape');
const gameInfoButton = global.locator('[data-menu-id="game-info"]');
const bettingButton = global.locator('[data-navigation-id="nation-betting"]');
@@ -1455,7 +1475,7 @@ test('nation split buttons keep square inner corners and a single divider in eve
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 1,
stage: 6,
npcMode: 1,
scenarioTitle: '분할 버튼 이음새 검증 시나리오',
generalMeCalls: 0,
@@ -1515,14 +1535,21 @@ test('nation split buttons keep square inner corners and a single divider in eve
for (const width of [1200, 500]) {
await page.setViewportSize({ width, height: 900 });
await waitForMain(page);
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
const pairs: Array<[string, Locator, Locator]> = [
[
'nation',
nationSplit.locator('[data-navigation-id="auction-resource"]'),
nationSplit.locator('[data-menu-id="auction"]'),
'tournament',
page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]'),
page.locator('.main-nation-menu:visible [data-menu-id="tournament-betting"]'),
],
[
'auction',
page.locator('.main-nation-menu:visible [data-navigation-id="auction-resource"]'),
page.locator('.main-nation-menu:visible [data-menu-id="auction"]'),
],
];
await expect(page.locator('.main-nation-menu:visible [data-navigation-id="tournament"]')).toHaveClass(
/highlight/
);
for (const [label, main, toggle] of pairs) {
await expect(main).toBeVisible();
+1
View File
@@ -42,6 +42,7 @@ body {
/* These redesigned screens own a true handheld layout. */
#app:has(.responsive-settings-page),
#app:has(.interface-settings-page),
#app:has(#tournament-container),
#app:has(#tournament-betting-container),
#app:has(#personnel-container) {
@@ -37,7 +37,9 @@ const { setRoot, openId, close, toggle } = useMenuPopup();
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
const nationMenuColor = computed(() => props.nationColor || '#000000');
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
const isActive = (link: MainNavigationLinkItem) =>
link.highlightStage === props.tournamentStage ||
link.highlightStages?.some((stage) => stage === props.tournamentStage) === true;
const onQuick = (item: QuickNavigationItem) => {
close();
@@ -165,6 +167,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<MainNavigationLink
:link="item"
:enabled="isNationNavigationEnabled(item, access)"
:active="isActive(item)"
compact
role="menuitem"
@navigate="close()"
@@ -16,7 +16,9 @@ const props = defineProps<{
}>();
const { setRoot, openId, close, toggle } = useMenuPopup();
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
const isActive = (link: MainNavigationLinkItem) =>
link.highlightStage === props.tournamentStage ||
link.highlightStages?.some((stage) => stage === props.tournamentStage) === true;
</script>
<template>
@@ -59,6 +61,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
<MainNavigationLink
:link="item"
:enabled="isNationNavigationEnabled(item, access)"
:active="isActive(item)"
role="menuitem"
@navigate="close()"
/>
@@ -12,6 +12,7 @@ export type MainNavigationLink = RuntimeNavigationLink & {
compactLabel?: string;
access?: NationAccessRule;
highlightStage?: 1 | 6;
highlightStages?: readonly [1, 6];
unavailableReason?: string;
};
@@ -144,14 +145,38 @@ export const nationNavigation: MainNavigationEntry[] = [
access: 'nation-secret',
},
{
kind: 'link',
id: 'tournament',
label: '토 너 먼 트',
compactLabel: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
highlightStage: 1,
kind: 'split',
id: 'tournament-betting',
main: {
kind: 'link',
id: 'tournament',
label: '토 너 먼 트',
compactLabel: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
highlightStages: [1, 6],
},
items: [
{
kind: 'link',
id: 'tournament-menu',
label: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
highlightStage: 1,
},
{
kind: 'link',
id: 'betting',
label: '베팅장',
to: '/betting',
newTab: true,
access: 'always',
highlightStage: 6,
},
],
},
{
kind: 'link',
@@ -218,16 +243,7 @@ export const nationNavigation: MainNavigationEntry[] = [
},
],
},
{
kind: 'link',
id: 'betting',
label: '베 팅 장',
compactLabel: '베팅장',
to: '/betting',
newTab: true,
access: 'always',
highlightStage: 6,
},
{ kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' },
];
export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider> = [
+2
View File
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './assets/main.css';
import { applyStoredCustomCss } from './utils/customCss';
import { installImageAssetCssVariables } from './utils/imageAssets';
import { installScreenModeViewport } from './utils/screenModeViewport';
installImageAssetCssVariables();
installScreenModeViewport();
applyStoredCustomCss();
const app = createApp(App);
+3 -1
View File
@@ -25,6 +25,7 @@ const NotFoundView = () => import('../views/NotFoundView.vue');
const TournamentView = () => import('../views/TournamentView.vue');
const BettingView = () => import('../views/BettingView.vue');
const MyPageView = () => import('../views/MyPageView.vue');
const MySettingsView = () => import('../views/MySettingsView.vue');
const BoardView = () => import('../views/BoardView.vue');
const DiplomacyView = () => import('../views/DiplomacyView.vue');
const BestGeneralView = () => import('../views/BestGeneralView.vue');
@@ -333,7 +334,8 @@ const routes = [
},
{
path: '/my-settings',
redirect: '/my-page',
name: 'my-settings',
component: MySettingsView,
meta: {
requiresAuth: true,
requiresGeneral: true,
+19
View File
@@ -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) ?? '');
};
+4 -330
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
@@ -10,19 +9,9 @@ import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder,
MOBILE_MAIN_PANEL_DEFINITIONS,
moveMobileMainPanel,
saveMobileMainPanelOrder,
type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder';
const CUSTOM_CSS_KEY = 'sam_customCSS';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
const { error: showErrorToast, showDialog } = useGameFeedback();
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
@@ -52,14 +41,8 @@ const dieOnPrestartStatusLoading = ref(false);
const dieOnPrestartStatusError = ref<string | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const selectedIconId = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
const session = useSessionStore();
let cssTimer: number | null = null;
const readPendingDieOnPrestartId = (): string => {
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
@@ -179,30 +162,6 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
mobileLayoutDialog.value?.showModal();
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
};
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
};
const resetMobileLayoutOrder = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
const applyMobileLayoutOrder = () => {
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
mobileLayoutDialog.value?.close();
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
};
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
@@ -233,16 +192,6 @@ const formatSelectionAvailableAt = computed(() => {
return formatSeoulDateTime(value);
});
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) return;
logLoading[type] = true;
@@ -370,32 +319,13 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<main id="container" class="legacy-page bg0 responsive-settings-page">
<div class="title-row">
<span> </span>
<div class="title-actions">
@@ -613,29 +543,6 @@ onMounted(() => {
<br /><br />
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="mobile-layout-setting-row">
<span>
모바일 레이아웃 순서 바꾸기<br />
<small>500px 메인 화면의 패널 순서를 기기에 저장합니다.</small>
</span>
<button
class="legacy-button legacy-button--primary mobile-layout-open"
type="button"
@click="openMobileLayoutDialog"
>
순서 바꾸기
</button>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
@@ -649,11 +556,6 @@ onMounted(() => {
{{ item.displayName ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
@@ -691,67 +593,6 @@ onMounted(() => {
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
<form method="dialog">
<button
class="legacy-button legacy-button--secondary"
type="submit"
aria-label="모바일 레이아웃 순서 닫기"
>
×
</button>
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
기본값
</button>
<form method="dialog">
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
</form>
<button
class="legacy-button legacy-button--primary mobile-layout-apply"
type="button"
@click="applyMobileLayoutOrder"
>
적용
</button>
</div>
</dialog>
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
</template>
@@ -773,12 +614,6 @@ onMounted(() => {
.my-page-mobile-scroll-spacer {
display: none;
}
.legacy-page.screen-500px {
max-width: 500px;
}
.legacy-page.screen-1000px {
max-width: 1000px;
}
.title-row {
height: 54px;
display: flex;
@@ -815,8 +650,7 @@ textarea {
background: #6b6b6b;
font: inherit;
}
.legacy-page .legacy-button,
.mobile-layout-dialog .legacy-button {
.legacy-page .legacy-button {
letter-spacing: 0;
}
button {
@@ -924,139 +758,6 @@ button:disabled {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.mobile-layout-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
align-items: center;
gap: 8px;
margin: 14px 0;
}
.mobile-layout-setting-row small {
color: orange;
}
.mobile-layout-open {
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog {
box-sizing: border-box;
width: min(460px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
margin: auto;
overflow: auto;
border: 1px solid #777;
border-radius: 4px;
padding: 12px;
background: #171717 var(--sammo-texture-walnut);
color: #fff;
font: 14px/1.3 var(--sammo-font-sans);
}
.mobile-layout-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.mobile-layout-dialog__header,
.mobile-layout-dialog__actions,
.mobile-layout-move-buttons {
display: flex;
align-items: center;
}
.mobile-layout-dialog__header {
justify-content: space-between;
gap: 12px;
}
.mobile-layout-dialog__header h2,
.mobile-layout-dialog p {
margin: 0 0 10px;
}
.mobile-layout-dialog__header h2 {
color: skyblue;
font-size: 18px;
}
.mobile-layout-dialog__header form,
.mobile-layout-dialog__actions form {
margin: 0;
}
.mobile-layout-dialog__header button {
min-width: 32px;
min-height: 32px;
font-size: 20px;
}
.mobile-layout-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.mobile-layout-list > li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
min-height: 44px;
align-items: center;
border: 1px solid #777;
background: #172a52 var(--sammo-texture-blue);
cursor: grab;
}
.mobile-layout-list > li:active {
cursor: grabbing;
}
.mobile-layout-handle {
color: #aaa;
text-align: center;
font-size: 20px;
}
.mobile-layout-label {
min-width: 0;
font-weight: 700;
}
.mobile-layout-position {
display: inline-grid;
width: 22px;
height: 22px;
place-items: center;
margin-right: 4px;
border: 1px solid #7186a7;
border-radius: 50%;
font-size: 12px;
}
.mobile-layout-move-buttons {
gap: 4px;
padding-right: 5px;
}
.mobile-layout-move-buttons button {
width: 36px;
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog__actions {
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
}
.mobile-layout-dialog__actions button {
min-height: 34px;
padding: 4px 10px;
}
.mobile-layout-dialog__actions .mobile-layout-apply {
font-weight: 700;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
@@ -1068,17 +769,6 @@ button:disabled {
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
@@ -1153,24 +843,8 @@ button:disabled {
.settings-column {
padding: 10px 12px;
}
.screen-mode-row {
grid-template-columns: 1fr;
gap: 6px;
}
.mobile-layout-setting-row {
grid-template-columns: 1fr;
}
.mobile-layout-open {
width: 100%;
}
.button-group {
overflow-x: auto;
}
.item-group {
grid-template-columns: repeat(2, 1fr);
}
.custom-css textarea {
width: 100%;
}
}
</style>
+391 -401
View File
@@ -1,453 +1,443 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc';
const CUSTOM_CSS_KEY = 'sammo-custom-css';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
config: Record<string, unknown>;
meta: Record<string, unknown>;
} | null;
type ScreenMode = 'auto' | '500px' | '1000px';
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder,
MOBILE_MAIN_PANEL_DEFINITIONS,
moveMobileMainPanel,
saveMobileMainPanelOrder,
type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder';
import {
normalizeScreenMode,
SCREEN_MODE_CHANGE_EVENT,
SCREEN_MODE_KEY,
type ScreenMode,
} from '../utils/screenModeViewport';
const { success: showSuccessToast } = useGameFeedback();
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
let cssTimer: number | null = null;
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const canSave = computed(() => {
const remaining = data.value?.settings?.myset ?? null;
if (remaining === null) {
return true;
}
return remaining > 0;
});
const remainingLabel = computed(() => {
const remaining = data.value?.settings?.myset ?? null;
if (remaining === null) {
return '설정 저장 제한 없음';
}
return `설정저장은 이달중 ${remaining}회 남았습니다.`;
});
const showAutoNationTurn = computed(() => {
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const options = (autorunUser.options ?? {}) as Record<string, unknown>;
return Boolean(options.chief);
});
const penalties = computed(() => {
const list = data.value?.penalties ?? {};
return Object.entries(list);
});
const applyCustomCss = (text: string) => {
if (typeof window === 'undefined') {
return;
}
const id = 'sammo-custom-css';
let style = document.getElementById(id) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = id;
document.head.appendChild(style);
}
style.textContent = text;
};
const saveCustomCss = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
applyCustomCss(customCss.value);
};
const loadScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const value = window.localStorage.getItem(SCREEN_MODE_KEY);
if (value === '500px' || value === '1000px') {
screenMode.value = value;
} else {
screenMode.value = 'auto';
}
};
const saveScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(SCREEN_MODE_KEY, screenMode.value);
};
const loadSettings = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await trpc.world.getState.query() as
| {
config?: Record<string, unknown>;
meta?: Record<string, unknown>;
}
| null;
data.value = general;
worldState.value = world
? {
config: world.config ?? {},
meta: world.meta ?? {},
}
: null;
if (general?.settings) {
form.tnmt = general.settings.tnmt;
form.defence_train = general.settings.defence_train;
form.use_treatment = general.settings.use_treatment;
form.use_auto_nation_turn = general.settings.use_auto_nation_turn;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const saveSettings = async () => {
if (!canSave.value) {
showErrorToast('설정 저장 가능 횟수가 없습니다.');
return;
}
try {
await trpc.general.setMySetting.mutate({
tnmt: resolveNumber(form.tnmt, 1),
defence_train: resolveNumber(form.defence_train, 80),
use_treatment: resolveNumber(form.use_treatment, 10),
use_auto_nation_turn: resolveNumber(form.use_auto_nation_turn, 1),
});
await loadSettings();
showSuccessToast('설정을 저장했습니다.');
} catch (err) {
showErrorToast(`설정 저장에 실패했습니다: ${resolveErrorMessage(err)}`);
}
};
watch(
() => screenMode.value,
() => {
saveScreenMode();
}
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
);
watch(
() => customCss.value,
() => {
if (cssTimer) {
window.clearTimeout(cssTimer);
}
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
saveCustomCss();
cssSaving.value = false;
}, 400);
}
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
mobileLayoutDialog.value?.showModal();
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
};
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
};
const resetMobileLayoutOrder = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
const applyMobileLayoutOrder = () => {
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
mobileLayoutDialog.value?.close();
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
};
watch(screenMode, (mode) => {
window.localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
window.localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
cssTimer = null;
}, 500);
});
onMounted(() => {
if (typeof window !== 'undefined') {
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
}
loadScreenMode();
void loadSettings();
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
});
onBeforeUnmount(() => {
if (cssTimer === null) return;
window.clearTimeout(cssTimer);
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
applyCustomCss(customCss.value);
});
</script>
<template>
<main class="settings-page">
<header class="page-header">
<div>
<h1 class="page-title">게임 설정</h1>
<p class="page-subtitle"> 정보 설정을 관리합니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<button class="ghost" @click="loadSettings">새로고침</button>
</div>
</header>
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
</div>
<div v-if="error" class="error">{{ error }}</div>
<p class="scope-note"> 설정은 기기의 화면 표시만 바꾸며 게임 상태에는 영향을 주지 않습니다.</p>
<section class="layout-grid">
<div class="stack">
<PanelCard title="게임 옵션" subtitle="설정 저장 시 즉시 반영됩니다.">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="form-grid">
<label class="form-field">
<span>토너먼트 참가</span>
<select v-model.number="form.tnmt">
<option :value="0">수동 참여</option>
<option :value="1">자동 참여</option>
</select>
</label>
<label class="form-field">
<span>환약 사용 기준</span>
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용 안함</option>
</select>
</label>
<label v-if="showAutoNationTurn" class="form-field">
<span>자동 사령턴 허용</span>
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="form-field">
<span>수비 설정</span>
<select v-model.number="form.defence_train">
<option :value="90">훈련 90</option>
<option :value="80">훈련 80</option>
<option :value="60">훈련 60</option>
<option :value="40">훈련 40</option>
<option :value="999">절대 수비</option>
</select>
</label>
<div class="hint">{{ remainingLabel }}</div>
<button class="primary" type="button" :disabled="!canSave" @click="saveSettings">
설정 저장
</button>
</div>
</PanelCard>
<PanelCard title="징계 목록" subtitle="설정 저장 시 갱신됩니다.">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="penalty-list">
<div v-if="penalties.length === 0" class="empty">징계가 없습니다.</div>
<div v-for="[key, value] in penalties" :key="key" class="penalty-item">
{{ key }}: {{ value }}
<section class="settings-grid">
<article class="settings-column">
<h2 class="section-title">화면 크기와 배치</h2>
<div class="settings-content">
<div class="screen-mode-row">
<span>500px/1000px 모드<br /><small>모바일 화면 폭을 즉시 바꿉니다.</small></span>
<div class="button-group" role="radiogroup" aria-label="화면 모드">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
</PanelCard>
</div>
<div class="stack">
<PanelCard title="화면 모드" subtitle="모바일 전용 설정입니다.">
<div class="screen-mode">
<label>
<input v-model="screenMode" type="radio" value="auto" />
자동
</label>
<label>
<input v-model="screenMode" type="radio" value="500px" />
500px
</label>
<label>
<input v-model="screenMode" type="radio" value="1000px" />
1000px
</label>
<div class="mobile-layout-setting-row">
<span>
모바일 메인 레이아웃<br />
<small>500px 메인 화면의 패널 순서를 기기에 저장합니다.</small>
</span>
<button
class="legacy-button legacy-button--primary mobile-layout-open"
type="button"
@click="openMobileLayoutDialog"
>
순서 바꾸기
</button>
</div>
</PanelCard>
</div>
</article>
<PanelCard title="개인용 CSS" subtitle="변경 사항은 자동 저장됩니다.">
<textarea v-model="customCss" class="css-input" rows="8" />
<div class="hint">{{ cssSaving ? '저장 중...' : '자동 저장 완료' }}</div>
</PanelCard>
</div>
<article class="settings-column">
<h2 class="section-title">개인용 CSS</h2>
<div class="settings-content">
<label class="custom-css">
브라우저에 적용할 CSS <span aria-live="polite">{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" aria-label="개인용 CSS" />
</label>
<p class="css-hint">변경 사항은 기기에 자동 저장됩니다.</p>
</div>
</article>
</section>
<footer class="legacy-credit">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
<form method="dialog">
<button
class="legacy-button legacy-button--secondary"
type="submit"
aria-label="모바일 레이아웃 순서 닫기"
>
×
</button>
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
기본값
</button>
<form method="dialog">
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
</form>
<button
class="legacy-button legacy-button--primary mobile-layout-apply"
type="button"
@click="applyMobileLayoutOrder"
>
적용
</button>
</div>
</dialog>
</template>
<style scoped>
.settings-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
.legacy-page {
box-sizing: border-box;
width: 100%;
max-width: 1000px;
min-height: 0;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
.page-header {
.title-row {
min-height: 54px;
display: flex;
align-content: flex-start;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
border: 1px solid #666;
background: transparent;
}
.title-row > span {
flex-basis: 100%;
height: 18px;
}
.title-row .legacy-button {
min-width: 90px;
}
.scope-note,
.css-hint {
margin: 0;
color: orange;
}
.scope-note {
border: 1px solid #666;
border-top: 0;
padding: 7px 10px;
text-align: center;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-column {
border: 1px solid #666;
background-image: var(--sammo-texture-walnut);
}
.section-title {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: var(--sammo-texture-green);
color: skyblue;
font-size: 1.25em;
font-weight: 500;
}
.page-title {
font-size: 1.6rem;
.settings-content {
padding: 10px 18px;
}
.screen-mode-row,
.mobile-layout-setting-row {
display: grid;
align-items: center;
gap: 8px;
margin: 8px 0 14px;
}
.screen-mode-row {
grid-template-columns: 160px 1fr;
}
.mobile-layout-setting-row {
grid-template-columns: minmax(0, 1fr) 128px;
}
.screen-mode-row small,
.mobile-layout-setting-row small,
.css-hint {
color: orange;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.mobile-layout-open {
min-height: 34px;
font-weight: 700;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
.custom-css {
display: block;
}
.custom-css textarea {
box-sizing: border-box;
display: block;
width: 420px;
max-width: 100%;
height: 150px;
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #000;
font: inherit;
}
.css-hint {
margin-top: 6px;
}
.header-actions {
.legacy-credit {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.mobile-layout-dialog {
box-sizing: border-box;
width: min(460px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
margin: auto;
overflow: auto;
border: 1px solid #777;
border-radius: 4px;
padding: 12px;
background: #171717 var(--sammo-texture-walnut);
color: #fff;
font: 14px/1.3 var(--sammo-font-sans);
}
.mobile-layout-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.mobile-layout-dialog__header,
.mobile-layout-dialog__actions,
.mobile-layout-move-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-grid {
display: grid;
.mobile-layout-dialog__header {
justify-content: space-between;
gap: 12px;
}
.form-field {
.mobile-layout-dialog__header h2,
.mobile-layout-dialog p {
margin: 0 0 10px;
}
.mobile-layout-dialog__header h2 {
color: skyblue;
font-size: 18px;
}
.mobile-layout-dialog__header form,
.mobile-layout-dialog__actions form {
margin: 0;
}
.mobile-layout-dialog__header button {
min-width: 32px;
min-height: 32px;
font-size: 20px;
}
.mobile-layout-list {
display: grid;
gap: 6px;
font-size: 0.9rem;
margin: 0;
padding: 0;
list-style: none;
}
.form-field select {
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
color: inherit;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
background: rgba(201, 164, 90, 0.2);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
cursor: pointer;
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.screen-mode {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.85rem;
}
.css-input {
width: 100%;
background: rgba(12, 12, 12, 0.7);
color: inherit;
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
}
.penalty-list {
.mobile-layout-list > li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
min-height: 44px;
align-items: center;
border: 1px solid #777;
background: #172a52 var(--sammo-texture-blue);
cursor: grab;
}
.mobile-layout-list > li:active {
cursor: grabbing;
}
.mobile-layout-handle {
color: #aaa;
text-align: center;
font-size: 20px;
}
.mobile-layout-label {
min-width: 0;
font-weight: 700;
}
.mobile-layout-position {
display: inline-grid;
width: 22px;
height: 22px;
place-items: center;
margin-right: 4px;
border: 1px solid #7186a7;
border-radius: 50%;
font-size: 12px;
}
.mobile-layout-move-buttons {
gap: 4px;
padding-right: 5px;
}
.mobile-layout-move-buttons button {
width: 36px;
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog__actions {
justify-content: flex-end;
gap: 6px;
font-size: 0.85rem;
margin-top: 12px;
}
.penalty-item {
color: #f08a5d;
.mobile-layout-dialog__actions button {
min-height: 34px;
padding: 4px 10px;
}
.hint {
color: rgba(232, 221, 196, 0.7);
font-size: 0.8rem;
.mobile-layout-dialog__actions .mobile-layout-apply {
font-weight: 700;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
@media (max-width: 1024px) {
.layout-grid {
@media (max-width: 939.98px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.screen-mode-row,
.mobile-layout-setting-row {
grid-template-columns: 1fr;
}
.button-group {
overflow-x: auto;
}
.mobile-layout-open,
.custom-css textarea {
width: 100%;
}
}
</style>