From 331851c4817a1e08c3da0978cdd9934f89370101 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 15:46:32 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=A8=EB=84=90=20=EC=88=9C=EC=84=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-frontend/e2e/inGameMenus.spec.ts | 75 ++++- app/game-frontend/e2e/mainNavigation.spec.ts | 117 ++++--- .../src/utils/mobileMainPanelOrder.ts | 85 +++++ app/game-frontend/src/views/MainView.vue | 297 ++++++++++-------- app/game-frontend/src/views/MyPageView.vue | 240 +++++++++++++- .../test/mobileMainPanelOrder.test.ts | 55 ++++ 6 files changed, 701 insertions(+), 168 deletions(-) create mode 100644 app/game-frontend/src/utils/mobileMainPanelOrder.ts create mode 100644 app/game-frontend/test/mobileMainPanelOrder.test.ts diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 54ab7cd7..afd6bcc7 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1231,7 +1231,9 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 const actions = element.querySelector('.title-actions')!.getBoundingClientRect(); const navigation = element.querySelector('.navigation-actions')!.getBoundingClientRect(); const back = element.querySelector('.navigation-actions a')!.getBoundingClientRect(); - const refresh = element.querySelector('.navigation-actions button')!.getBoundingClientRect(); + const refresh = element + .querySelector('.navigation-actions button')! + .getBoundingClientRect(); const past = element.querySelector('.past-plays-link')!.getBoundingClientRect(); const pastStyle = getComputedStyle(element.querySelector('.past-plays-link')!); return { @@ -1269,6 +1271,77 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); +test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 Ref 순서로 복원한다', async ({ page }) => { + 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.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); + await expect(dialog).toBeVisible(); + const readOrder = () => + dialog + .locator('[data-mobile-layout-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id'))); + const defaultOrder = [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]; + await expect.poll(readOrder).toEqual(defaultOrder); + + await dialog + .locator('[data-mobile-layout-id="messages"]') + .dragTo(dialog.locator('[data-mobile-layout-id="commands"]')); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu']); + await dialog.getByRole('button', { name: '지도 위로' }).click(); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + const dialogGeometry = await dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const firstItem = element.querySelector('[data-mobile-layout-id]')?.getBoundingClientRect(); + const moveButton = element.querySelector('[aria-label$="아래로"]')?.getBoundingClientRect(); + return { + rect: rect.toJSON(), + firstItem: firstItem?.toJSON() ?? null, + moveButton: moveButton?.toJSON() ?? null, + overflowX: getComputedStyle(element).overflowX, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(dialogGeometry.rect.left).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.rect.right).toBeLessThanOrEqual(390); + 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.getByRole('button', { name: '적용', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + await dialog.getByRole('button', { name: 'Ref 초깃값' }).click(); + await expect.poll(readOrder).toEqual(defaultOrder); + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(defaultOrder); +}); + for (const [label, failure] of [ ['daemon timeout', 'TIMEOUT'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 4e53a936..a7fb2e50 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -95,11 +95,10 @@ type DashboardBundleInput = { const operationInput = (route: Route, index: number): DashboardBundleInput => { const request = route.request(); const queryInput = new URL(request.url()).searchParams.get('input'); - const parsed = (request.postData() - ? request.postDataJSON() - : queryInput - ? JSON.parse(queryInput) - : {}) as Record; + const parsed = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as Record< + string, + unknown + >; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; return entry.json ?? (entry as DashboardBundleInput); }; @@ -253,32 +252,32 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f general: draftCommands ? draftCommandGroups : refCategories - ? refCommandCategoryFixture - : large - ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ - category, - values: Array.from({ length: 16 }, (_, localIndex) => { - const index = categoryIndex * 16 + localIndex; - return { - key: `command-${index}`, - name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, - reqArg: index % 2 === 0, - possible: index >= blockedCount, - status: index >= blockedCount ? 'available' : 'blocked', - inputFields: [ - { - key: 'amount', - label: '수량', - kind: 'number', - required: true, - min: 1, - max: 10_000, - }, - ], - }; - }), - })) - : [], + ? refCommandCategoryFixture + : large + ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ + category, + values: Array.from({ length: 16 }, (_, localIndex) => { + const index = categoryIndex * 16 + localIndex; + return { + key: `command-${index}`, + name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, + reqArg: index % 2 === 0, + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + }; + }), + })) + : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), @@ -500,7 +499,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) : operation === 'navigation.get' ? response(runtimeNavigation) - : response({ ok: true }) + : response({ ok: true }) ); await route.fulfill({ status: 200, @@ -1558,10 +1557,7 @@ test('message targets keep reply behavior and use nation-color contrast in label await page.setViewportSize({ width: 500, height: 900 }); const mobilePanel = page.locator('.mobile-message-panel'); - await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS( - 'color', - 'rgb(255, 255, 255)' - ); + await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(mobilePanel.locator('.msg-plate[data-id="103"] .msg-target')).toHaveCSS('color', 'rgb(0, 0, 0)'); await expect(mobilePanel.locator('#mailbox_list optgroup[label="밝은국"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); await persistArtifact(page, `${basePath.slice(1)}-message-nation-contrast-mobile-500`); @@ -2217,6 +2213,49 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy ]) { await expect(page.locator(selector)).toBeVisible(); } + const readMobilePanelOrder = () => + page + .locator('.layout-mobile > [data-mobile-panel-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-panel-id'))); + await expect + .poll(readMobilePanelOrder) + .toEqual(['commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu', 'messages']); + await page.evaluate(() => { + localStorage.setItem( + 'sam.mobileMainPanelOrder.v1', + JSON.stringify([ + 'messages', + 'map', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'records', + 'global-menu', + ]) + ); + document.dispatchEvent(new CustomEvent('sam-mobile-main-panel-order-changed')); + }); + await expect + .poll(readMobilePanelOrder) + .toEqual(['messages', 'map', 'commands', 'nation-menu', 'nation', 'general', 'city', 'records', 'global-menu']); + const customPanelGeometry = await page.locator('.layout-mobile > [data-mobile-panel-id]').evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { + id: element.getAttribute('data-mobile-panel-id'), + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + }; + }) + ); + expect(customPanelGeometry.every(({ left, right }) => left >= 0 && right <= 500)).toBe(true); + expect( + customPanelGeometry.every((panel, index) => index === 0 || panel.top >= customPanelGeometry[index - 1]!.bottom) + ).toBe(true); await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); @@ -3040,9 +3079,9 @@ for (const viewport of [ await refreshActivityAndCommands(); await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon'); await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도'); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( - viewport.width - ); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(viewport.width); }); } diff --git a/app/game-frontend/src/utils/mobileMainPanelOrder.ts b/app/game-frontend/src/utils/mobileMainPanelOrder.ts new file mode 100644 index 00000000..b337c833 --- /dev/null +++ b/app/game-frontend/src/utils/mobileMainPanelOrder.ts @@ -0,0 +1,85 @@ +export const MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY = 'sam.mobileMainPanelOrder.v1'; +export const MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT = 'sam-mobile-main-panel-order-changed'; + +export const MOBILE_MAIN_PANEL_DEFINITIONS = [ + { id: 'commands', label: '명령 목록' }, + { id: 'nation-menu', label: '국가 메뉴' }, + { id: 'nation', label: '국가 정보' }, + { id: 'general', label: '장수 정보' }, + { id: 'city', label: '도시 정보' }, + { id: 'map', label: '지도' }, + { id: 'records', label: '기록 영역' }, + { id: 'global-menu', label: '공통 메뉴' }, + { id: 'messages', label: '서신' }, +] as const; + +export type MobileMainPanelId = (typeof MOBILE_MAIN_PANEL_DEFINITIONS)[number]['id']; + +export const DEFAULT_MOBILE_MAIN_PANEL_ORDER: readonly MobileMainPanelId[] = MOBILE_MAIN_PANEL_DEFINITIONS.map( + ({ id }) => id +); + +const mobilePanelIds = new Set(DEFAULT_MOBILE_MAIN_PANEL_ORDER); + +export const normalizeMobileMainPanelOrder = (value: unknown): MobileMainPanelId[] => { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const normalized: MobileMainPanelId[] = []; + + for (const item of source) { + if (typeof item !== 'string' || !mobilePanelIds.has(item) || seen.has(item)) continue; + seen.add(item); + normalized.push(item as MobileMainPanelId); + } + + for (const item of DEFAULT_MOBILE_MAIN_PANEL_ORDER) { + if (!seen.has(item)) normalized.push(item); + } + + return normalized; +}; + +export const parseMobileMainPanelOrder = (raw: string | null): MobileMainPanelId[] => { + if (!raw) return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + try { + return normalizeMobileMainPanelOrder(JSON.parse(raw)); + } catch { + return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + } +}; + +export const loadMobileMainPanelOrder = (storage: Pick = window.localStorage) => + parseMobileMainPanelOrder(storage.getItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY)); + +export const saveMobileMainPanelOrder = ( + value: readonly MobileMainPanelId[], + storage: Pick = window.localStorage +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + storage.setItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, JSON.stringify(normalized)); + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT)); + } + return normalized; +}; + +export const moveMobileMainPanel = ( + value: readonly MobileMainPanelId[], + fromIndex: number, + toIndex: number +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + if ( + fromIndex < 0 || + fromIndex >= normalized.length || + toIndex < 0 || + toIndex >= normalized.length || + fromIndex === toIndex + ) { + return normalized; + } + const [moved] = normalized.splice(fromIndex, 1); + if (!moved) return normalized; + normalized.splice(toIndex, 0, moved); + return normalized; +}; diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 45c72071..ef828879 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -29,6 +29,12 @@ import { useMainDashboardStore } from '../stores/mainDashboard'; import { useGameFeedback } from '../composables/useGameFeedback'; import { trpc } from '../utils/trpc'; import type { CommandPatternEntry } from '../components/command/types'; +import { + loadMobileMainPanelOrder, + MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, + MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, + type MobileMainPanelId, +} from '../utils/mobileMainPanelOrder'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); @@ -38,8 +44,20 @@ const isMobile = useMediaQuery('(max-width: 939.98px)'); const npcMode = ref(0); const globalNavigation = ref(defaultGlobalNavigation); const versionDialog = ref(null); +const mobilePanelOrder = ref(loadMobileMainPanelOrder()); const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation'); +const reloadMobilePanelOrder = () => { + mobilePanelOrder.value = loadMobileMainPanelOrder(); +}; +const handleMobilePanelStorage = (event: StorageEvent) => { + if (event.key === MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY) reloadMobilePanelOrder(); +}; +const isFlushMobilePanel = (panelId: MobileMainPanelId, index: number): boolean => { + const previous = mobilePanelOrder.value[index - 1]; + return (panelId === 'general' && previous === 'nation') || (panelId === 'city' && previous === 'general'); +}; + const { loading, refreshing, @@ -100,10 +118,14 @@ onUnmounted(() => { clearTimeout(surveyNoticeTimer); } dashboard.stopRealtime(); + window.removeEventListener('storage', handleMobilePanelStorage); + document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); }); onMounted(() => { dashboard.startRealtime(); + window.addEventListener('storage', handleMobilePanelStorage); + document.addEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); void fetch(navigationUrl, { headers: { Accept: 'application/json' } }) .then(async (response) => { if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`); @@ -130,10 +152,7 @@ const repeatGeneralTurns = (amount: number) => { }; const loadMainData = async () => { - const [, worldState] = await Promise.all([ - dashboard.loadMainData(), - trpc.world.getState.query().catch(() => null), - ]); + const [, worldState] = await Promise.all([dashboard.loadMainData(), trpc.world.getState.query().catch(() => null)]); npcMode.value = worldState?.config.npcMode ?? 0; }; @@ -239,131 +258,155 @@ watch(
-
- - +
+ + + +
+ +
+ - -
+
-
- + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ +
+ + -
-
- - - - - - - - - -
- -
- - - -
- -
- - - -
- -
-
기록이 없습니다.
-
- - - - -
- -
-
기록이 없습니다.
-
- - - - -
- -
-
기록이 없습니다.
-
- -
- - - -
- -
+
+ +
+
@@ -798,8 +841,8 @@ button { gap: 0; } -.layout-mobile > .mobile-panel:nth-of-type(3) { - gap: 0; +.layout-mobile > .mobile-panel--flush { + margin-top: -4px; } .layout-mobile [data-main-target='commands'] { diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index ae8f57d7..9798859c 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -9,11 +9,19 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; +import { + DEFAULT_MOBILE_MAIN_PANEL_ORDER, + loadMobileMainPanelOrder, + MOBILE_MAIN_PANEL_DEFINITIONS, + moveMobileMainPanel, + saveMobileMainPanelOrder, + type MobileMainPanelId, +} from '../utils/mobileMainPanelOrder'; const SCREEN_MODE_KEY = 'sam.screenMode'; const CUSTOM_CSS_KEY = 'sam_customCSS'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; -const { error: showErrorToast, showDialog } = useGameFeedback(); +const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; @@ -48,6 +56,9 @@ const screenMode = ref('auto'); const customCss = ref(''); const selectedIconId = ref(''); const cssSaving = ref(false); +const mobileLayoutDialog = ref(null); +const mobileLayoutOrder = ref(loadMobileMainPanelOrder()); +const mobileLayoutDragIndex = ref(null); const session = useSessionStore(); let cssTimer: number | null = null; const readPendingDieOnPrestartId = (): string => { @@ -169,6 +180,43 @@ const items = computed data.value?.iconChoices ?? []); const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null); +const mobileLayoutLabels = Object.fromEntries( + MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label]) +) as Record; + +const openMobileLayoutDialog = () => { + mobileLayoutOrder.value = loadMobileMainPanelOrder(); + mobileLayoutDialog.value?.showModal(); + window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector('button')?.focus()); +}; + +const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => { + mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex); +}; + +const startMobileLayoutDrag = (event: DragEvent, index: number) => { + mobileLayoutDragIndex.value = index; + event.dataTransfer?.setData('text/plain', mobileLayoutOrder.value[index] ?? ''); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; +}; + +const dropMobileLayoutItem = (event: DragEvent, targetIndex: number) => { + event.preventDefault(); + const sourceIndex = mobileLayoutDragIndex.value; + mobileLayoutDragIndex.value = null; + if (sourceIndex === null) return; + moveMobileLayoutItem(sourceIndex, targetIndex); +}; + +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); @@ -581,6 +629,16 @@ onMounted(() => { +
+ + 모바일 레이아웃 순서 바꾸기
+ 500px 메인 화면의 패널 순서를 이 기기에 저장합니다. +
+ +
+
아이템 파기
+ +
+

항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.

+
    +
  1. + + + {{ index + 1 }} + {{ mobileLayoutLabels[panelId] }} + + + + + +
  2. +
+
+ +
+ +
+ @@ -822,6 +935,125 @@ button:disabled { 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; + background: #315f86; + 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; + background: #315f86; + 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 { + background: #225500; + font-weight: 700; +} .button-group { display: flex; } @@ -933,6 +1165,12 @@ button:disabled { grid-template-columns: 1fr; gap: 6px; } + .mobile-layout-setting-row { + grid-template-columns: 1fr; + } + .mobile-layout-open { + width: 100%; + } .button-group { overflow-x: auto; } diff --git a/app/game-frontend/test/mobileMainPanelOrder.test.ts b/app/game-frontend/test/mobileMainPanelOrder.test.ts new file mode 100644 index 00000000..288f2b4c --- /dev/null +++ b/app/game-frontend/test/mobileMainPanelOrder.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + DEFAULT_MOBILE_MAIN_PANEL_ORDER, + MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, + loadMobileMainPanelOrder, + moveMobileMainPanel, + normalizeMobileMainPanelOrder, + parseMobileMainPanelOrder, + saveMobileMainPanelOrder, +} from '../src/utils/mobileMainPanelOrder.ts'; + +void test('uses the Ref mobile panel order as the default', () => { + assert.deepEqual(DEFAULT_MOBILE_MAIN_PANEL_ORDER, [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + assert.deepEqual(parseMobileMainPanelOrder(null), DEFAULT_MOBILE_MAIN_PANEL_ORDER); +}); + +void test('keeps known unique entries and appends newly introduced panels', () => { + assert.deepEqual(normalizeMobileMainPanelOrder(['messages', 'commands', 'messages', 'unknown']), [ + 'messages', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + ]); + assert.deepEqual(parseMobileMainPanelOrder('{broken'), DEFAULT_MOBILE_MAIN_PANEL_ORDER); +}); + +void test('moves and persists the normalized order', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + const moved = moveMobileMainPanel(DEFAULT_MOBILE_MAIN_PANEL_ORDER, 8, 0); + assert.equal(moved[0], 'messages'); + assert.deepEqual(saveMobileMainPanelOrder(moved, storage), moved); + assert.equal(values.has(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY), true); + assert.deepEqual(loadMobileMainPanelOrder(storage), moved); +});