merge: 최신 main을 재야 광고 발송 복원에 통합
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import type { CommandMapData, CommandOption } from './types';
|
||||
|
||||
export const commandCityOptions = (
|
||||
commandKey: string,
|
||||
options: readonly CommandOption[],
|
||||
mapData?: CommandMapData | null
|
||||
): CommandOption[] => {
|
||||
if (commandKey !== 'che_발령' || typeof mapData?.myNation !== 'number') return [...options];
|
||||
|
||||
const nationByCityId = new Map(mapData.cityList.map(([cityId, , , nationId]) => [cityId, nationId]));
|
||||
return options
|
||||
.map((option, index) => ({ option, index }))
|
||||
.sort((left, right) => {
|
||||
const leftOwned =
|
||||
typeof left.option.value === 'number' && nationByCityId.get(left.option.value) === mapData.myNation;
|
||||
const rightOwned =
|
||||
typeof right.option.value === 'number' && nationByCityId.get(right.option.value) === mapData.myNation;
|
||||
return Number(rightOwned) - Number(leftOwned) || left.index - right.index;
|
||||
})
|
||||
.map(({ option }) => option);
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { CommandInputField } from './types';
|
||||
|
||||
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
|
||||
|
||||
export type CommandArgumentPresentation = {
|
||||
lines: string[];
|
||||
mapTarget?: 'city' | 'nation' | 'capital';
|
||||
mapTarget?: CommandArgumentMapTarget;
|
||||
};
|
||||
|
||||
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
||||
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
|
||||
PRESENTATIONS[commandKey] ?? { lines: [] };
|
||||
|
||||
/**
|
||||
* 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다.
|
||||
* 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다.
|
||||
*/
|
||||
export const resolveCommandArgumentMapTarget = (
|
||||
commandKey: string,
|
||||
fields: readonly CommandInputField[]
|
||||
): CommandArgumentMapTarget | undefined => {
|
||||
const selectableTargets = fields.filter((field) => field.kind === 'select');
|
||||
if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city';
|
||||
if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation';
|
||||
return commandArgumentPresentation(commandKey).mapTarget;
|
||||
};
|
||||
|
||||
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
||||
import MapViewer from './MapViewer.vue';
|
||||
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
||||
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
|
||||
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||
import {
|
||||
commandArgumentFieldContract,
|
||||
shouldPreserveCommandArgumentValue,
|
||||
@@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||
if (field.optionSource === 'nations') {
|
||||
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
|
||||
}
|
||||
if (field.optionSource === 'cities') {
|
||||
return commandCityOptions(props.commandKey, props.options.cities, props.mapData);
|
||||
}
|
||||
if (field.optionSource === 'items') {
|
||||
return props.options.items[String(values.itemType ?? '')] ?? [];
|
||||
}
|
||||
@@ -104,12 +108,7 @@ const synchronizeValues = () => {
|
||||
for (const field of props.fields) {
|
||||
const preserve =
|
||||
!commandChanged &&
|
||||
shouldPreserveCommandArgumentValue(
|
||||
field,
|
||||
previousFieldContracts.get(field.key),
|
||||
values,
|
||||
optionsFor(field)
|
||||
);
|
||||
shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field));
|
||||
if (!preserve) values[field.key] = defaultValue(field);
|
||||
}
|
||||
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
||||
@@ -162,20 +161,15 @@ const nationTargetField = computed(() =>
|
||||
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
||||
)
|
||||
);
|
||||
const showMap = computed(
|
||||
() =>
|
||||
Boolean(props.mapData && props.mapLayout) &&
|
||||
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
|
||||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
|
||||
presentation.value.mapTarget === 'capital')
|
||||
);
|
||||
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
|
||||
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
|
||||
const mapSelectedCityId = computed<number | null>(() => {
|
||||
if (!props.mapData) return null;
|
||||
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||
if (mapTarget.value === 'city' && cityTargetField.value) {
|
||||
const value = values[cityTargetField.value.key];
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const value = values[nationTargetField.value.key];
|
||||
if (typeof value !== 'number') return null;
|
||||
return (
|
||||
@@ -184,7 +178,7 @@ const mapSelectedCityId = computed<number | null>(() => {
|
||||
null
|
||||
);
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital') {
|
||||
if (mapTarget.value === 'capital') {
|
||||
const myNation = props.mapData.myNation;
|
||||
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
|
||||
}
|
||||
@@ -198,17 +192,17 @@ const currentCityName = computed(() => {
|
||||
});
|
||||
|
||||
const selectedMapTargetName = computed(() => {
|
||||
if (presentation.value.mapTarget === 'city') {
|
||||
if (mapTarget.value === 'city') {
|
||||
const cityId = mapSelectedCityId.value;
|
||||
if (!cityId) return '-';
|
||||
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const nationId = values[nationTargetField.value.key];
|
||||
if (typeof nationId !== 'number') return '-';
|
||||
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital') {
|
||||
if (mapTarget.value === 'capital') {
|
||||
const cityId = mapSelectedCityId.value;
|
||||
if (!cityId) return '-';
|
||||
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||
@@ -240,7 +234,7 @@ const distanceFromMyCity = (destination: number): number | null => {
|
||||
|
||||
const mapTargetSummary = computed(() => {
|
||||
if (!props.mapData || !props.mapLayout) return '';
|
||||
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
|
||||
if (mapTarget.value === 'city' && mapSelectedCityId.value) {
|
||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||
if (!city) return '';
|
||||
@@ -256,7 +250,7 @@ const mapTargetSummary = computed(() => {
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const value = values[nationTargetField.value.key];
|
||||
if (typeof value !== 'number') return '';
|
||||
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
|
||||
@@ -265,7 +259,7 @@ const mapTargetSummary = computed(() => {
|
||||
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital' && mapSelectedCityId.value) {
|
||||
if (mapTarget.value === 'capital' && mapSelectedCityId.value) {
|
||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||
if (!city) return '';
|
||||
@@ -278,11 +272,11 @@ const mapTargetSummary = computed(() => {
|
||||
|
||||
const selectMapCity = (cityId: number) => {
|
||||
if (!props.mapData) return;
|
||||
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||
if (mapTarget.value === 'city' && cityTargetField.value) {
|
||||
setSelectValue(cityTargetField.value, String(cityId));
|
||||
return;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
|
||||
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
|
||||
}
|
||||
@@ -417,24 +411,20 @@ watch(
|
||||
:detail-mode="true"
|
||||
:fit-container="true"
|
||||
:show-current-city-marker="true"
|
||||
:readonly="presentation.mapTarget === 'capital'"
|
||||
:readonly="mapTarget === 'capital'"
|
||||
@select-city="selectMapCity"
|
||||
/>
|
||||
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
||||
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
|
||||
<span v-if="mapTarget !== 'capital'" class="current-city-status">
|
||||
<span class="status-key">현재 도시</span>
|
||||
<strong>{{ currentCityName }}</strong>
|
||||
</span>
|
||||
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||
<span v-if="mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||
<span class="selected-target-status">
|
||||
<span class="status-key">{{
|
||||
presentation.mapTarget === 'nation'
|
||||
? '선택 국가'
|
||||
: presentation.mapTarget === 'capital'
|
||||
? '현재 수도'
|
||||
: '선택 도시'
|
||||
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
|
||||
}}</span>
|
||||
<strong>{{ selectedMapTargetName }}</strong>
|
||||
</span>
|
||||
|
||||
@@ -28,6 +28,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(event: 'hover', cityId: number): void;
|
||||
(event: 'leave'): void;
|
||||
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
|
||||
(event: 'touchleave'): void;
|
||||
(event: 'select', cityId: number): void;
|
||||
}>();
|
||||
|
||||
@@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
||||
const selectCity = () => {
|
||||
if (!props.readonly) emit('select', props.city.id);
|
||||
};
|
||||
|
||||
let touchOnTrack = false;
|
||||
|
||||
const touchstart = () => {
|
||||
touchOnTrack = true;
|
||||
};
|
||||
|
||||
const touchmove = () => {
|
||||
touchOnTrack = false;
|
||||
};
|
||||
|
||||
const touchend = (event: TouchEvent) => {
|
||||
if (touchOnTrack) {
|
||||
event.stopPropagation();
|
||||
emit('touch', props.city.id, event);
|
||||
return;
|
||||
}
|
||||
emit('touchleave');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -59,6 +80,9 @@ const selectCity = () => {
|
||||
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@mouseleave="emit('leave')"
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchmove"
|
||||
@touchend="touchend"
|
||||
@click.stop="selectCity"
|
||||
>
|
||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||
|
||||
@@ -54,6 +54,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(event: 'hover', cityId: number): void;
|
||||
(event: 'leave'): void;
|
||||
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
|
||||
(event: 'touchleave'): void;
|
||||
(event: 'select', cityId: number): void;
|
||||
}>();
|
||||
|
||||
@@ -148,6 +150,25 @@ const selectCity = () => {
|
||||
if (!props.readonly) emit('select', props.city.id);
|
||||
};
|
||||
|
||||
let touchOnTrack = false;
|
||||
|
||||
const touchstart = () => {
|
||||
touchOnTrack = true;
|
||||
};
|
||||
|
||||
const touchmove = () => {
|
||||
touchOnTrack = false;
|
||||
};
|
||||
|
||||
const touchend = (event: TouchEvent) => {
|
||||
if (touchOnTrack) {
|
||||
event.stopPropagation();
|
||||
emit('touch', props.city.id, event);
|
||||
return;
|
||||
}
|
||||
emit('touchleave');
|
||||
};
|
||||
|
||||
const cityStateStyle = computed(() => ({
|
||||
width: `${12 * props.mapScale}px`,
|
||||
height: `${12 * props.mapScale}px`,
|
||||
@@ -174,6 +195,9 @@ const cityStateStyle = computed(() => ({
|
||||
:style="cityBaseStyle"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@mouseleave="emit('leave')"
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchmove"
|
||||
@touchend="touchend"
|
||||
@click.stop="selectCity"
|
||||
>
|
||||
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
||||
|
||||
@@ -94,9 +94,11 @@ const mapStore = useMapViewerStore();
|
||||
const {
|
||||
showCityName,
|
||||
detailMode: storeDetailMode,
|
||||
singleTapNavigation,
|
||||
hoveredCityId,
|
||||
selectedCityId: storeSelectedCityId,
|
||||
} = storeToRefs(mapStore);
|
||||
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
||||
|
||||
const mapArea = ref<HTMLElement | null>(null);
|
||||
const mapBody = ref<HTMLElement | null>(null);
|
||||
@@ -404,6 +406,28 @@ const setHoveredCity = (cityId: number | null) => {
|
||||
mapStore.setHoveredCity(cityId);
|
||||
};
|
||||
|
||||
const touchPreviewCityId = ref<number | null>(null);
|
||||
|
||||
const clearTouchPreview = () => {
|
||||
touchPreviewCityId.value = null;
|
||||
setHoveredCity(null);
|
||||
};
|
||||
|
||||
const touchCity = (cityId: number, event: TouchEvent) => {
|
||||
if (touchPreviewCityId.value !== cityId) {
|
||||
touchPreviewCityId.value = cityId;
|
||||
setHoveredCity(cityId);
|
||||
if (!singleTapNavigation.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSingleTapNavigation = () => {
|
||||
clearTouchPreview();
|
||||
mapStore.toggleSingleTapNavigation();
|
||||
};
|
||||
|
||||
const selectCity = (cityId: number) => {
|
||||
if (props.readonly) return;
|
||||
emit('select-city', cityId);
|
||||
@@ -433,6 +457,7 @@ const selectCity = (cityId: number) => {
|
||||
class="map-area"
|
||||
:class="[mapThemeClass, mapSeasonClass]"
|
||||
:style="{ width: mapWidth, height: mapHeight }"
|
||||
@click="clearTouchPreview"
|
||||
>
|
||||
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
||||
<div class="map-layer map-bglayer2" />
|
||||
@@ -449,6 +474,8 @@ const selectCity = (cityId: number) => {
|
||||
v-bind="detailProps"
|
||||
@hover="setHoveredCity"
|
||||
@leave="setHoveredCity(null)"
|
||||
@touch="touchCity"
|
||||
@touchleave="clearTouchPreview"
|
||||
@select="selectCity"
|
||||
/>
|
||||
<div
|
||||
@@ -466,9 +493,18 @@ const selectCity = (cityId: number) => {
|
||||
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||
</div>
|
||||
<div class="map-controls">
|
||||
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
|
||||
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName">
|
||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasTouchInput"
|
||||
class="map-toggle map-toggle-single-tap"
|
||||
:class="{ active: singleTapNavigation }"
|
||||
:aria-pressed="singleTapNavigation"
|
||||
@click.stop="toggleSingleTapNavigation"
|
||||
>
|
||||
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -555,6 +591,8 @@ const selectCity = (cityId: number) => {
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.map-toggle {
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -3,14 +3,23 @@ import { defineStore } from 'pinia';
|
||||
interface MapViewerState {
|
||||
showCityName: boolean;
|
||||
detailMode: boolean;
|
||||
singleTapNavigation: boolean;
|
||||
hoveredCityId: number | null;
|
||||
selectedCityId: number | null;
|
||||
}
|
||||
|
||||
const SINGLE_TAP_STORAGE_KEY = 'sam.toggleSingleTap';
|
||||
|
||||
const loadSingleTapNavigation = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.localStorage.getItem(SINGLE_TAP_STORAGE_KEY) === 'yes';
|
||||
};
|
||||
|
||||
export const useMapViewerStore = defineStore('mapViewer', {
|
||||
state: (): MapViewerState => ({
|
||||
showCityName: true,
|
||||
detailMode: true,
|
||||
singleTapNavigation: loadSingleTapNavigation(),
|
||||
hoveredCityId: null,
|
||||
selectedCityId: null,
|
||||
}),
|
||||
@@ -21,6 +30,12 @@ export const useMapViewerStore = defineStore('mapViewer', {
|
||||
toggleDetailMode() {
|
||||
this.detailMode = !this.detailMode;
|
||||
},
|
||||
toggleSingleTapNavigation() {
|
||||
this.singleTapNavigation = !this.singleTapNavigation;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SINGLE_TAP_STORAGE_KEY, this.singleTapNavigation ? 'yes' : 'no');
|
||||
}
|
||||
},
|
||||
setHoveredCity(cityId: number | null) {
|
||||
this.hoveredCityId = cityId;
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||
@@ -58,7 +59,6 @@ const selectedIconId = ref('');
|
||||
const cssSaving = ref(false);
|
||||
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
||||
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
||||
const mobileLayoutDragIndex = ref<number | null>(null);
|
||||
const session = useSessionStore();
|
||||
let cssTimer: number | null = null;
|
||||
const readPendingDieOnPrestartId = (): string => {
|
||||
@@ -180,9 +180,9 @@ 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 = Object.fromEntries(
|
||||
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
||||
) as Record<MobileMainPanelId, string>;
|
||||
);
|
||||
|
||||
const openMobileLayoutDialog = () => {
|
||||
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
||||
@@ -194,20 +194,6 @@ 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];
|
||||
};
|
||||
@@ -697,7 +683,6 @@ onMounted(() => {
|
||||
ref="mobileLayoutDialog"
|
||||
class="mobile-layout-dialog"
|
||||
aria-labelledby="mobile-layout-dialog-title"
|
||||
@close="mobileLayoutDragIndex = null"
|
||||
>
|
||||
<div class="mobile-layout-dialog__header">
|
||||
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
||||
@@ -706,42 +691,39 @@ onMounted(() => {
|
||||
</form>
|
||||
</div>
|
||||
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
||||
<ol class="mobile-layout-list">
|
||||
<li
|
||||
v-for="(panelId, index) in mobileLayoutOrder"
|
||||
:key="panelId"
|
||||
:data-mobile-layout-id="panelId"
|
||||
draggable="true"
|
||||
@dragstart="startMobileLayoutDrag($event, index)"
|
||||
@dragend="mobileLayoutDragIndex = null"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropMobileLayoutItem($event, index)"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
|
||||
:disabled="index === 0"
|
||||
@click="moveMobileLayoutItem(index, index - 1)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
|
||||
:disabled="index === mobileLayoutOrder.length - 1"
|
||||
@click="moveMobileLayoutItem(index, index + 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
<SortableStringList
|
||||
: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
|
||||
type="button"
|
||||
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
|
||||
:disabled="index === 0"
|
||||
@click="moveMobileLayoutItem(index, index - 1)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
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 type="button" @click="resetMobileLayoutOrder">기본값</button>
|
||||
<form method="dialog"><button type="submit">취소</button></form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import SortableStringList from '../components/ui/SortableStringList';
|
||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -8,7 +9,6 @@ type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
||||
type PrioritySectionKey = 'nation' | 'general';
|
||||
type PriorityBucket = 'active' | 'inactive';
|
||||
|
||||
interface PolicyField {
|
||||
key: NumericPolicyKey;
|
||||
@@ -35,12 +35,6 @@ interface PriorityPanel {
|
||||
state: PriorityListState;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
section: PrioritySectionKey;
|
||||
bucket: PriorityBucket;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const error = 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 lastSavedNationPriority = ref<string[]>([]);
|
||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||
const dragState = ref<DragState | null>(null);
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -474,66 +447,58 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
|
||||
<div class="priority-columns">
|
||||
<div class="priority-column">
|
||||
<div class="sub_bar legacy-bg2">비활성</div>
|
||||
<div
|
||||
<SortableStringList
|
||||
:list="panel.state.inactive"
|
||||
:group="`npc-priority-${panel.key}`"
|
||||
tag="div"
|
||||
class="priority-list"
|
||||
@dragover.prevent
|
||||
@drop="dropPriority($event, panel.key, 'inactive')"
|
||||
>
|
||||
<div class="inactive-header"><비활성화 항목들></div>
|
||||
<div
|
||||
v-for="(item, index) in panel.state.inactive"
|
||||
:key="item"
|
||||
class="priority-item"
|
||||
draggable="true"
|
||||
@dragstart="startDrag($event, panel.key, 'inactive', index)"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
|
||||
>
|
||||
<div class="priority_info">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span>{{ item }}</span>
|
||||
<button
|
||||
class="help-button"
|
||||
type="button"
|
||||
:aria-label="`${item} 설명`"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<template #header>
|
||||
<div class="inactive-header"><비활성화 항목들></div>
|
||||
</template>
|
||||
<template #item="{ element: item }">
|
||||
<div class="priority-item">
|
||||
<div class="priority_info">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span>{{ item }}</span>
|
||||
<button
|
||||
class="help-button"
|
||||
type="button"
|
||||
:aria-label="`${item} 설명`"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SortableStringList>
|
||||
</div>
|
||||
<div class="priority-column">
|
||||
<div class="sub_bar legacy-bg2">활성</div>
|
||||
<div
|
||||
<SortableStringList
|
||||
:list="panel.state.active"
|
||||
:group="`npc-priority-${panel.key}`"
|
||||
tag="div"
|
||||
class="priority-list"
|
||||
@dragover.prevent
|
||||
@drop="dropPriority($event, panel.key, 'active')"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in panel.state.active"
|
||||
:key="`${item}-${index}`"
|
||||
class="priority-item"
|
||||
draggable="true"
|
||||
@dragstart="startDrag($event, panel.key, 'active', index)"
|
||||
@dragover.prevent
|
||||
@drop.stop="dropPriority($event, panel.key, 'active', index)"
|
||||
>
|
||||
<div class="priority_info">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span>{{ item }}</span>
|
||||
<button
|
||||
class="help-button"
|
||||
type="button"
|
||||
:aria-label="`${item} 설명`"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<template #item="{ element: item }">
|
||||
<div class="priority-item">
|
||||
<div class="priority_info">
|
||||
<span class="drag-handle">≡</span>
|
||||
<span>{{ item }}</span>
|
||||
<button
|
||||
class="help-button"
|
||||
type="button"
|
||||
:aria-label="`${item} 설명`"
|
||||
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SortableStringList>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control_bar priority-control">
|
||||
|
||||
Reference in New Issue
Block a user