fix: 모바일 터치 드래그 정렬 복구
NPC 정책 우선순위와 모바일 메인 패널 순서를 Ref와 같은 vuedraggable 기반으로 전환한다. 실제 모바일 Chromium 터치 제스처 회귀 검증을 추가한다.
This commit is contained in:
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { basename, resolve } from 'node:path';
|
import { basename, resolve } from 'node:path';
|
||||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||||
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
import { touchDrag } from './touchDrag.js';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
||||||
@@ -1469,6 +1470,70 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
|
|||||||
.toEqual(defaultOrder);
|
.toEqual(defaultOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: configuredBaseUrl,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
try {
|
||||||
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
|
await install(mobilePage, state);
|
||||||
|
await mobilePage.goto('my-page');
|
||||||
|
await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
|
||||||
|
|
||||||
|
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
||||||
|
const commands = dialog.locator('[data-mobile-layout-id="commands"]');
|
||||||
|
const nationMenu = dialog.locator('[data-mobile-layout-id="nation-menu"]');
|
||||||
|
await touchDrag(mobilePage, nationMenu, commands);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
dialog
|
||||||
|
.locator('[data-mobile-layout-id]')
|
||||||
|
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id')))
|
||||||
|
)
|
||||||
|
.toEqual([
|
||||||
|
'nation-menu',
|
||||||
|
'commands',
|
||||||
|
'nation',
|
||||||
|
'general',
|
||||||
|
'city',
|
||||||
|
'map',
|
||||||
|
'records',
|
||||||
|
'global-menu',
|
||||||
|
'messages',
|
||||||
|
]);
|
||||||
|
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
|
||||||
|
await dialog.getByRole('button', { name: '적용', exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
|
||||||
|
.toEqual([
|
||||||
|
'nation-menu',
|
||||||
|
'commands',
|
||||||
|
'nation',
|
||||||
|
'general',
|
||||||
|
'city',
|
||||||
|
'map',
|
||||||
|
'records',
|
||||||
|
'global-menu',
|
||||||
|
'messages',
|
||||||
|
]);
|
||||||
|
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch.png'), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const [label, failure] of [
|
for (const [label, failure] of [
|
||||||
['daemon timeout', 'TIMEOUT'],
|
['daemon timeout', 'TIMEOUT'],
|
||||||
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
|
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
import { touchDrag } from './touchDrag.js';
|
||||||
|
|
||||||
type FixtureState = {
|
type FixtureState = {
|
||||||
permissionLevel: number;
|
permissionLevel: number;
|
||||||
@@ -320,6 +321,58 @@ test('500px layout stacks policy fields and priority panels like the reference',
|
|||||||
await screenshot(page, 'core-npc-policy-mobile.png');
|
await screenshot(page, 'core-npc-policy-mobile.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('physical mobile touch reorders NPC priority across active and inactive lists', 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');
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: configuredBaseUrl,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
try {
|
||||||
|
await installFixture(mobilePage, { permissionLevel: 4, mutations: [] });
|
||||||
|
await gotoPolicy(mobilePage);
|
||||||
|
await expect(mobilePage.locator('#container')).toBeVisible();
|
||||||
|
|
||||||
|
const nationPanel = mobilePage.locator('.priority-panel').first();
|
||||||
|
const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list');
|
||||||
|
const activeRows = activeList.locator('.priority-item');
|
||||||
|
await touchDrag(
|
||||||
|
mobilePage,
|
||||||
|
activeRows.nth(0),
|
||||||
|
activeRows.nth(3),
|
||||||
|
{ targetYRatio: 0.9 }
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
activeList
|
||||||
|
.locator('.priority-item .priority_info > span:nth-child(2)')
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
)
|
||||||
|
.toBe('선전포고');
|
||||||
|
|
||||||
|
const activeItem = activeList.getByText('불가침제의', { exact: true });
|
||||||
|
const inactiveList = nationPanel.locator('.priority-column').first().locator('.priority-list');
|
||||||
|
await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header'));
|
||||||
|
|
||||||
|
await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
activeList.getByText('불가침제의', { exact: true })
|
||||||
|
).toHaveCount(0);
|
||||||
|
await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
|
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
|
||||||
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
|
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
type TouchPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TouchDragOptions = {
|
||||||
|
targetYRatio?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointIn = async (locator: Locator, yRatio = 0.5): Promise<TouchPoint> => {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error('Touch drag target has no visible bounding box');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: box.x + box.width / 2,
|
||||||
|
y: box.y + box.height * yRatio,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const touchDrag = async (
|
||||||
|
page: Page,
|
||||||
|
source: Locator,
|
||||||
|
target: Locator,
|
||||||
|
options: TouchDragOptions = {}
|
||||||
|
): Promise<void> => {
|
||||||
|
await source.scrollIntoViewIfNeeded();
|
||||||
|
await target.scrollIntoViewIfNeeded();
|
||||||
|
const from = await pointIn(source);
|
||||||
|
const to = await pointIn(target, options.targetYRatio);
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.removeAttribute('data-playwright-touch-trusted');
|
||||||
|
document.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
(event) => document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)),
|
||||||
|
{ capture: true, once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }],
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
const dispatchMove = async (ratio: number) => {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [
|
||||||
|
{
|
||||||
|
x: from.x + (to.x - from.x) * ratio,
|
||||||
|
y: from.y + (to.y - from.y) * ratio,
|
||||||
|
id: 0,
|
||||||
|
radiusX: 1,
|
||||||
|
radiusY: 1,
|
||||||
|
force: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await dispatchMove(0.05);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
for (let step = 2; step <= 20; step += 1) {
|
||||||
|
await dispatchMove(step / 20);
|
||||||
|
await page.waitForTimeout(16);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
const trusted = await page.evaluate(
|
||||||
|
() => document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true'
|
||||||
|
);
|
||||||
|
if (!trusted) {
|
||||||
|
throw new Error('Chromium did not dispatch a trusted touchstart event');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
|
"vuedraggable-es": "4.1.1",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineComponent, h, type PropType, type SlotsType, type VNode } from 'vue';
|
||||||
|
import VueDraggable from 'vuedraggable-es';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'SortableStringList',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
list: {
|
||||||
|
type: Array as PropType<string[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slots: Object as SlotsType<{
|
||||||
|
header?: () => VNode[];
|
||||||
|
item: (props: { element: string; index: number }) => VNode[];
|
||||||
|
}>,
|
||||||
|
setup(props, { attrs, slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
VueDraggable,
|
||||||
|
{
|
||||||
|
...attrs,
|
||||||
|
list: props.list,
|
||||||
|
group: props.group,
|
||||||
|
itemKey: (item: string) => item,
|
||||||
|
tag: props.tag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => slots.header?.(),
|
||||||
|
item: ({ element, index }: { element: string; index: number }) =>
|
||||||
|
slots.item({ element, index }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } 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';
|
||||||
@@ -58,7 +59,6 @@ const selectedIconId = ref('');
|
|||||||
const cssSaving = ref(false);
|
const cssSaving = ref(false);
|
||||||
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
||||||
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
||||||
const mobileLayoutDragIndex = ref<number | null>(null);
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
let cssTimer: number | null = null;
|
let cssTimer: number | null = null;
|
||||||
const readPendingDieOnPrestartId = (): string => {
|
const readPendingDieOnPrestartId = (): string => {
|
||||||
@@ -180,9 +180,9 @@ 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 = Object.fromEntries(
|
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
|
||||||
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
||||||
) as Record<MobileMainPanelId, string>;
|
);
|
||||||
|
|
||||||
const openMobileLayoutDialog = () => {
|
const openMobileLayoutDialog = () => {
|
||||||
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
||||||
@@ -194,20 +194,6 @@ const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
|
|||||||
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
|
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 = () => {
|
const resetMobileLayoutOrder = () => {
|
||||||
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
|
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
|
||||||
};
|
};
|
||||||
@@ -697,7 +683,6 @@ onMounted(() => {
|
|||||||
ref="mobileLayoutDialog"
|
ref="mobileLayoutDialog"
|
||||||
class="mobile-layout-dialog"
|
class="mobile-layout-dialog"
|
||||||
aria-labelledby="mobile-layout-dialog-title"
|
aria-labelledby="mobile-layout-dialog-title"
|
||||||
@close="mobileLayoutDragIndex = null"
|
|
||||||
>
|
>
|
||||||
<div class="mobile-layout-dialog__header">
|
<div class="mobile-layout-dialog__header">
|
||||||
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
||||||
@@ -706,42 +691,39 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
||||||
<ol class="mobile-layout-list">
|
<SortableStringList
|
||||||
<li
|
:list="mobileLayoutOrder"
|
||||||
v-for="(panelId, index) in mobileLayoutOrder"
|
tag="ol"
|
||||||
:key="panelId"
|
class="mobile-layout-list"
|
||||||
:data-mobile-layout-id="panelId"
|
>
|
||||||
draggable="true"
|
<template #item="{ element: panelId, index }">
|
||||||
@dragstart="startMobileLayoutDrag($event, index)"
|
<li :data-mobile-layout-id="panelId">
|
||||||
@dragend="mobileLayoutDragIndex = null"
|
<span class="mobile-layout-handle" aria-hidden="true">≡</span>
|
||||||
@dragover.prevent
|
<span class="mobile-layout-label">
|
||||||
@drop.stop="dropMobileLayoutItem($event, index)"
|
<span class="mobile-layout-position">{{ index + 1 }}</span>
|
||||||
>
|
{{ mobileLayoutLabels[panelId] }}
|
||||||
<span class="mobile-layout-handle" aria-hidden="true">≡</span>
|
</span>
|
||||||
<span class="mobile-layout-label">
|
<span class="mobile-layout-move-buttons">
|
||||||
<span class="mobile-layout-position">{{ index + 1 }}</span>
|
<button
|
||||||
{{ mobileLayoutLabels[panelId] }}
|
type="button"
|
||||||
</span>
|
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
|
||||||
<span class="mobile-layout-move-buttons">
|
:disabled="index === 0"
|
||||||
<button
|
@click="moveMobileLayoutItem(index, index - 1)"
|
||||||
type="button"
|
>
|
||||||
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
|
↑
|
||||||
:disabled="index === 0"
|
</button>
|
||||||
@click="moveMobileLayoutItem(index, index - 1)"
|
<button
|
||||||
>
|
type="button"
|
||||||
↑
|
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
|
||||||
</button>
|
:disabled="index === mobileLayoutOrder.length - 1"
|
||||||
<button
|
@click="moveMobileLayoutItem(index, index + 1)"
|
||||||
type="button"
|
>
|
||||||
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
|
↓
|
||||||
:disabled="index === mobileLayoutOrder.length - 1"
|
</button>
|
||||||
@click="moveMobileLayoutItem(index, index + 1)"
|
</span>
|
||||||
>
|
</li>
|
||||||
↓
|
</template>
|
||||||
</button>
|
</SortableStringList>
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<div class="mobile-layout-dialog__actions">
|
<div class="mobile-layout-dialog__actions">
|
||||||
<button type="button" @click="resetMobileLayoutOrder">기본값</button>
|
<button type="button" @click="resetMobileLayoutOrder">기본값</button>
|
||||||
<form method="dialog"><button type="submit">취소</button></form>
|
<form method="dialog"><button type="submit">취소</button></form>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import SortableStringList from '../components/ui/SortableStringList';
|
||||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -8,7 +9,6 @@ type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
|||||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||||
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
||||||
type PrioritySectionKey = 'nation' | 'general';
|
type PrioritySectionKey = 'nation' | 'general';
|
||||||
type PriorityBucket = 'active' | 'inactive';
|
|
||||||
|
|
||||||
interface PolicyField {
|
interface PolicyField {
|
||||||
key: NumericPolicyKey;
|
key: NumericPolicyKey;
|
||||||
@@ -35,12 +35,6 @@ interface PriorityPanel {
|
|||||||
state: PriorityListState;
|
state: PriorityListState;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
section: PrioritySectionKey;
|
|
||||||
bucket: PriorityBucket;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const notice = ref<string | null>(null);
|
const notice = ref<string | null>(null);
|
||||||
@@ -51,7 +45,6 @@ const nationPriority = ref<PriorityListState | null>(null);
|
|||||||
const generalPriority = ref<PriorityListState | null>(null);
|
const generalPriority = ref<PriorityListState | null>(null);
|
||||||
const lastSavedNationPriority = ref<string[]>([]);
|
const lastSavedNationPriority = ref<string[]>([]);
|
||||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||||
const dragState = ref<DragState | null>(null);
|
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) return value.message;
|
if (value instanceof Error) return value.message;
|
||||||
@@ -363,26 +356,6 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
|
|
||||||
dragState.value = { section, bucket, index };
|
|
||||||
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const source = dragState.value;
|
|
||||||
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
|
||||||
if (!source || source.section !== section || !state) return;
|
|
||||||
const sourceList = state[source.bucket];
|
|
||||||
const targetList = state[bucket];
|
|
||||||
const [item] = sourceList.splice(source.index, 1);
|
|
||||||
if (!item) return;
|
|
||||||
let index = targetIndex ?? targetList.length;
|
|
||||||
if (sourceList === targetList && source.index < index) index -= 1;
|
|
||||||
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
|
|
||||||
dragState.value = null;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -474,66 +447,58 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
|
|||||||
<div class="priority-columns">
|
<div class="priority-columns">
|
||||||
<div class="priority-column">
|
<div class="priority-column">
|
||||||
<div class="sub_bar legacy-bg2">비활성</div>
|
<div class="sub_bar legacy-bg2">비활성</div>
|
||||||
<div
|
<SortableStringList
|
||||||
|
:list="panel.state.inactive"
|
||||||
|
:group="`npc-priority-${panel.key}`"
|
||||||
|
tag="div"
|
||||||
class="priority-list"
|
class="priority-list"
|
||||||
@dragover.prevent
|
|
||||||
@drop="dropPriority($event, panel.key, 'inactive')"
|
|
||||||
>
|
>
|
||||||
<div class="inactive-header"><비활성화 항목들></div>
|
<template #header>
|
||||||
<div
|
<div class="inactive-header"><비활성화 항목들></div>
|
||||||
v-for="(item, index) in panel.state.inactive"
|
</template>
|
||||||
:key="item"
|
<template #item="{ element: item }">
|
||||||
class="priority-item"
|
<div class="priority-item">
|
||||||
draggable="true"
|
<div class="priority_info">
|
||||||
@dragstart="startDrag($event, panel.key, 'inactive', index)"
|
<span class="drag-handle">≡</span>
|
||||||
@dragover.prevent
|
<span>{{ item }}</span>
|
||||||
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
|
<button
|
||||||
>
|
class="help-button"
|
||||||
<div class="priority_info">
|
type="button"
|
||||||
<span class="drag-handle">≡</span>
|
:aria-label="`${item} 설명`"
|
||||||
<span>{{ item }}</span>
|
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||||
<button
|
>
|
||||||
class="help-button"
|
?
|
||||||
type="button"
|
</button>
|
||||||
:aria-label="`${item} 설명`"
|
</div>
|
||||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</SortableStringList>
|
||||||
</div>
|
</div>
|
||||||
<div class="priority-column">
|
<div class="priority-column">
|
||||||
<div class="sub_bar legacy-bg2">활성</div>
|
<div class="sub_bar legacy-bg2">활성</div>
|
||||||
<div
|
<SortableStringList
|
||||||
|
:list="panel.state.active"
|
||||||
|
:group="`npc-priority-${panel.key}`"
|
||||||
|
tag="div"
|
||||||
class="priority-list"
|
class="priority-list"
|
||||||
@dragover.prevent
|
|
||||||
@drop="dropPriority($event, panel.key, 'active')"
|
|
||||||
>
|
>
|
||||||
<div
|
<template #item="{ element: item }">
|
||||||
v-for="(item, index) in panel.state.active"
|
<div class="priority-item">
|
||||||
:key="`${item}-${index}`"
|
<div class="priority_info">
|
||||||
class="priority-item"
|
<span class="drag-handle">≡</span>
|
||||||
draggable="true"
|
<span>{{ item }}</span>
|
||||||
@dragstart="startDrag($event, panel.key, 'active', index)"
|
<button
|
||||||
@dragover.prevent
|
class="help-button"
|
||||||
@drop.stop="dropPriority($event, panel.key, 'active', index)"
|
type="button"
|
||||||
>
|
:aria-label="`${item} 설명`"
|
||||||
<div class="priority_info">
|
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||||
<span class="drag-handle">≡</span>
|
>
|
||||||
<span>{{ item }}</span>
|
?
|
||||||
<button
|
</button>
|
||||||
class="help-button"
|
</div>
|
||||||
type="button"
|
|
||||||
:aria-label="`${item} 설명`"
|
|
||||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
|
||||||
>
|
|
||||||
?
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</SortableStringList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control_bar priority-control">
|
<div class="control_bar priority-control">
|
||||||
|
|||||||
Generated
+23
-4
@@ -66,7 +66,7 @@ importers:
|
|||||||
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||||
vitepress:
|
vitepress:
|
||||||
specifier: 1.6.4
|
specifier: 1.6.4
|
||||||
version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3)
|
version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(sortablejs@1.14.0)(typescript@6.0.3)
|
||||||
vue-eslint-parser:
|
vue-eslint-parser:
|
||||||
specifier: ^10.4.1
|
specifier: ^10.4.1
|
||||||
version: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
|
version: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
|
||||||
@@ -213,6 +213,9 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.6.4
|
specifier: ^4.6.4
|
||||||
version: 4.6.4(vue@3.5.41(typescript@6.0.3))
|
version: 4.6.4(vue@3.5.41(typescript@6.0.3))
|
||||||
|
vuedraggable-es:
|
||||||
|
specifier: 4.1.1
|
||||||
|
version: 4.1.1(vue@3.5.41(typescript@6.0.3))
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -4400,6 +4403,9 @@ packages:
|
|||||||
sonic-boom@4.2.1:
|
sonic-boom@4.2.1:
|
||||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
|
sortablejs@1.14.0:
|
||||||
|
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4795,6 +4801,11 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vuedraggable-es@4.1.1:
|
||||||
|
resolution: {integrity: sha512-F35pjSwC8HS/lnaOd+B59nYR4FZmwuhWAzccK9xftRuWds8SU1TZh5myKVM86j5dFOI7S26O64Kwe7LUHnXjlA==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.2.31
|
||||||
|
|
||||||
w3c-keyname@2.2.8:
|
w3c-keyname@2.2.8:
|
||||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
@@ -6643,13 +6654,14 @@ snapshots:
|
|||||||
'@vueuse/shared': 14.4.0(vue@3.5.41(typescript@6.0.3))
|
'@vueuse/shared': 14.4.0(vue@3.5.41(typescript@6.0.3))
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
|
|
||||||
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)':
|
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(sortablejs@1.14.0)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
||||||
'@vueuse/shared': 12.8.2(typescript@6.0.3)
|
'@vueuse/shared': 12.8.2(typescript@6.0.3)
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
focus-trap: 7.8.0
|
focus-trap: 7.8.0
|
||||||
|
sortablejs: 1.14.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
@@ -8434,6 +8446,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
|
sortablejs@1.14.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map@0.6.1:
|
source-map@0.6.1:
|
||||||
@@ -8691,7 +8705,7 @@ snapshots:
|
|||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.12
|
tsx: 4.23.12
|
||||||
|
|
||||||
vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3):
|
vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(sortablejs@1.14.0)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@docsearch/css': 3.8.2
|
'@docsearch/css': 3.8.2
|
||||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)
|
'@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)
|
||||||
@@ -8704,7 +8718,7 @@ snapshots:
|
|||||||
'@vue/devtools-api': 7.7.10
|
'@vue/devtools-api': 7.7.10
|
||||||
'@vue/shared': 3.5.41
|
'@vue/shared': 3.5.41
|
||||||
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
||||||
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3)
|
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(sortablejs@1.14.0)(typescript@6.0.3)
|
||||||
focus-trap: 7.8.0
|
focus-trap: 7.8.0
|
||||||
mark.js: 8.11.1
|
mark.js: 8.11.1
|
||||||
minisearch: 7.2.0
|
minisearch: 7.2.0
|
||||||
@@ -8803,6 +8817,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
vuedraggable-es@4.1.1(vue@3.5.41(typescript@6.0.3)):
|
||||||
|
dependencies:
|
||||||
|
sortablejs: 1.14.0
|
||||||
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user