fix(game-ui): iPhone Safari 확인 조작을 안정화
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useGameFeedback, type GameFeedbackKind } from '../../composables/useGameFeedback';
|
||||
|
||||
const { toasts, dialog, dismissToast, acknowledgeDialog } = useGameFeedback();
|
||||
const { toasts, dialog, dismissToast, acknowledgeDialog, cancelDialog } = useGameFeedback();
|
||||
const dialogPanel = ref<HTMLElement | null>(null);
|
||||
const acknowledgeButton = ref<HTMLButtonElement | null>(null);
|
||||
const cancelButton = ref<HTMLButtonElement | null>(null);
|
||||
let returnFocus: HTMLElement | null = null;
|
||||
let previousBodyOverflow = '';
|
||||
|
||||
@@ -37,7 +38,7 @@ watch(
|
||||
}
|
||||
if (next) {
|
||||
await nextTick();
|
||||
acknowledgeButton.value?.focus();
|
||||
(next.cancelLabel ? cancelButton.value : acknowledgeButton.value)?.focus();
|
||||
return;
|
||||
}
|
||||
if (previous) restorePage();
|
||||
@@ -48,7 +49,8 @@ watch(
|
||||
const handleDialogKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
acknowledgeDialog();
|
||||
if (dialog.value?.cancelLabel) cancelDialog();
|
||||
else acknowledgeDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !dialogPanel.value) return;
|
||||
@@ -119,6 +121,15 @@ onBeforeUnmount(() => {
|
||||
</header>
|
||||
<p id="game-dialog-message">{{ dialog.message }}</p>
|
||||
<footer>
|
||||
<button
|
||||
v-if="dialog.cancelLabel"
|
||||
ref="cancelButton"
|
||||
type="button"
|
||||
class="game-dialog-cancel"
|
||||
@click="cancelDialog"
|
||||
>
|
||||
{{ dialog.cancelLabel }}
|
||||
</button>
|
||||
<button ref="acknowledgeButton" type="button" @click="acknowledgeDialog">
|
||||
{{ dialog.acknowledgeLabel }}
|
||||
</button>
|
||||
@@ -277,6 +288,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.game-dialog-panel footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -296,6 +308,12 @@ onBeforeUnmount(() => {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.game-dialog-panel footer .game-dialog-cancel {
|
||||
color: #ddd;
|
||||
background: #292929;
|
||||
border-color: #626262;
|
||||
}
|
||||
|
||||
.game-toast-enter-active,
|
||||
.game-toast-leave-active,
|
||||
.game-toast-move,
|
||||
|
||||
@@ -14,6 +14,7 @@ export type GameNoticeDialog = {
|
||||
title: string;
|
||||
message: string;
|
||||
acknowledgeLabel: string;
|
||||
cancelLabel: string | null;
|
||||
};
|
||||
|
||||
export type GameNoticeDialogOptions = {
|
||||
@@ -23,9 +24,13 @@ export type GameNoticeDialogOptions = {
|
||||
acknowledgeLabel?: string;
|
||||
};
|
||||
|
||||
export type GameConfirmDialogOptions = GameNoticeDialogOptions & {
|
||||
cancelLabel?: string;
|
||||
};
|
||||
|
||||
type QueuedDialog = {
|
||||
dialog: GameNoticeDialog;
|
||||
resolve: () => void;
|
||||
resolve: (confirmed: boolean) => void;
|
||||
};
|
||||
|
||||
const titleFor = (kind: GameFeedbackKind): string => {
|
||||
@@ -39,7 +44,7 @@ export const createGameFeedbackStore = () => {
|
||||
const activeDialog = ref<GameNoticeDialog | null>(null);
|
||||
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
const dialogQueue: QueuedDialog[] = [];
|
||||
let activeDialogResolve: (() => void) | null = null;
|
||||
let activeDialogResolve: ((confirmed: boolean) => void) | null = null;
|
||||
let nextId = 1;
|
||||
|
||||
const dismissToast = (id: number): void => {
|
||||
@@ -61,7 +66,10 @@ export const createGameFeedbackStore = () => {
|
||||
const id = nextId++;
|
||||
visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }];
|
||||
if (durationMs > 0) {
|
||||
dismissTimers.set(id, setTimeout(() => dismissToast(id), durationMs));
|
||||
dismissTimers.set(
|
||||
id,
|
||||
setTimeout(() => dismissToast(id), durationMs)
|
||||
);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
@@ -89,6 +97,28 @@ export const createGameFeedbackStore = () => {
|
||||
title: options.title?.trim() || titleFor(kind),
|
||||
message,
|
||||
acknowledgeLabel: options.acknowledgeLabel?.trim() || '확인',
|
||||
cancelLabel: null,
|
||||
},
|
||||
resolve: () => resolve(),
|
||||
});
|
||||
if (!activeDialog.value) activateNextDialog();
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = (options: GameConfirmDialogOptions | string): Promise<boolean> => {
|
||||
const normalizedOptions = typeof options === 'string' ? { message: options } : options;
|
||||
const message = normalizedOptions.message.trim();
|
||||
if (!message) return Promise.resolve(false);
|
||||
const kind = normalizedOptions.kind ?? 'info';
|
||||
return new Promise((resolve) => {
|
||||
dialogQueue.push({
|
||||
dialog: {
|
||||
id: nextId++,
|
||||
kind,
|
||||
title: normalizedOptions.title?.trim() || '확인',
|
||||
message,
|
||||
acknowledgeLabel: normalizedOptions.acknowledgeLabel?.trim() || '확인',
|
||||
cancelLabel: normalizedOptions.cancelLabel?.trim() || '취소',
|
||||
},
|
||||
resolve,
|
||||
});
|
||||
@@ -96,14 +126,17 @@ export const createGameFeedbackStore = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const acknowledgeDialog = (): void => {
|
||||
const resolveDialog = (confirmed: boolean): void => {
|
||||
const resolve = activeDialogResolve;
|
||||
activeDialog.value = null;
|
||||
activeDialogResolve = null;
|
||||
resolve?.();
|
||||
resolve?.(confirmed);
|
||||
activateNextDialog();
|
||||
};
|
||||
|
||||
const acknowledgeDialog = (): void => resolveDialog(true);
|
||||
const cancelDialog = (): void => resolveDialog(false);
|
||||
|
||||
return {
|
||||
toasts: readonly(visibleToasts),
|
||||
dialog: readonly(activeDialog),
|
||||
@@ -112,7 +145,9 @@ export const createGameFeedbackStore = () => {
|
||||
error: (message: string, durationMs?: number) => showToast(message, 'error', durationMs),
|
||||
info: (message: string, durationMs?: number) => showToast(message, 'info', durationMs),
|
||||
showDialog,
|
||||
confirm,
|
||||
acknowledgeDialog,
|
||||
cancelDialog,
|
||||
dismissToast,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
|
||||
@@ -39,7 +39,7 @@ const kickTargetId = ref(0);
|
||||
const ambassadorSelection = ref<number[]>([]);
|
||||
const auditorSelection = ref<number[]>([]);
|
||||
const router = useRouter();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
const { success: showSuccessToast, error: showErrorToast, confirm: showConfirm } = useGameFeedback();
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string =>
|
||||
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
|
||||
@@ -135,7 +135,7 @@ const appointChief = async (level: number, targetId: number) => {
|
||||
const prompt = target
|
||||
? `${JosaUtil.put(target.name, '을')} ${office}직에 임명하시겠습니까?`
|
||||
: `${office}직을 비우시겠습니까?`;
|
||||
if (!window.confirm(prompt)) return;
|
||||
if (!(await showConfirm(prompt))) return;
|
||||
await runMutation(
|
||||
() => trpc.nation.appoint.mutate({ destGeneralId: targetId, destCityId: 0, officerLevel: level }),
|
||||
target ? `${JosaUtil.put(target.name, '을')} 임명했습니다.` : '관직을 비웠습니다.'
|
||||
@@ -148,7 +148,7 @@ const appointCityOfficer = async (level: OfficerLevel, cityId: number, targetId:
|
||||
const prompt = target
|
||||
? `${JosaUtil.put(target.name, '을')} ${city?.name ?? ''} ${officerLabels[level]}직에 임명하시겠습니까?`
|
||||
: `${city?.name ?? ''} ${officerLabels[level]}직을 비우시겠습니까?`;
|
||||
if (!window.confirm(prompt)) return;
|
||||
if (!(await showConfirm(prompt))) return;
|
||||
await runMutation(
|
||||
() =>
|
||||
trpc.nation.appoint.mutate({
|
||||
@@ -248,6 +248,7 @@ const applySelection = async (id: number): Promise<void> => {
|
||||
const context = selectionContext.value;
|
||||
if (!context) return;
|
||||
selectionContext.value = null;
|
||||
await nextTick();
|
||||
if (context.kind === 'chief-general') await appointChief(context.level, id);
|
||||
else await appointCityOfficer(context.level, context.cityId, id);
|
||||
};
|
||||
@@ -258,7 +259,7 @@ const reportPermissionLimit = () => {
|
||||
|
||||
const changePermissions = async (isAmbassador: boolean) => {
|
||||
const selection = isAmbassador ? ambassadorSelection.value : auditorSelection.value;
|
||||
if (!window.confirm(`${isAmbassador ? '외교권자' : '조언자'}를 변경할까요?`)) return;
|
||||
if (!(await showConfirm(`${isAmbassador ? '외교권자' : '조언자'}를 변경할까요?`))) return;
|
||||
await runMutation(
|
||||
() => trpc.nation.changePermission.mutate({ isAmbassador, targetGeneralIds: selection }),
|
||||
'권한을 변경했습니다.'
|
||||
@@ -267,7 +268,7 @@ const changePermissions = async (isAmbassador: boolean) => {
|
||||
|
||||
const kickGeneral = async () => {
|
||||
const target = generalMap.value.get(kickTargetId.value);
|
||||
if (!target || !window.confirm(`${JosaUtil.put(target.name, '을')} 추방하시겠습니까?`)) return;
|
||||
if (!target || !(await showConfirm(`${JosaUtil.put(target.name, '을')} 추방하시겠습니까?`))) return;
|
||||
await runMutation(
|
||||
() => trpc.nation.kick.mutate({ destGeneralId: target.id }),
|
||||
`${JosaUtil.put(target.name, '을')} 추방했습니다.`
|
||||
|
||||
@@ -45,7 +45,12 @@ const nationPriority = ref<PriorityListState | null>(null);
|
||||
const generalPriority = ref<PriorityListState | null>(null);
|
||||
const lastSavedNationPriority = ref<string[]>([]);
|
||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||
const { success: showSuccessToast, error: showErrorToast, info: showInfoToast } = useGameFeedback();
|
||||
const {
|
||||
success: showSuccessToast,
|
||||
error: showErrorToast,
|
||||
info: showInfoToast,
|
||||
confirm: showConfirm,
|
||||
} = useGameFeedback();
|
||||
const canManagePolicy = computed(() => (data.value?.permissionLevel ?? -1) >= 3);
|
||||
|
||||
const resolveErrorMessage = (value: unknown): string => {
|
||||
@@ -282,20 +287,20 @@ const priorityPanels = computed<PriorityPanel[]>(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const resetPolicy = () => {
|
||||
if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||
const resetPolicy = async () => {
|
||||
if (!canManagePolicy.value || !data.value || !(await showConfirm('초기 설정으로 되돌릴까요?'))) return;
|
||||
policyDraft.value = clonePolicy(data.value.defaultNationPolicy);
|
||||
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
|
||||
};
|
||||
|
||||
const rollbackPolicy = () => {
|
||||
if (!canManagePolicy.value || !lastSavedPolicy.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||
const rollbackPolicy = async () => {
|
||||
if (!canManagePolicy.value || !lastSavedPolicy.value || !(await showConfirm('이전 설정으로 되돌릴까요?'))) return;
|
||||
policyDraft.value = clonePolicy(lastSavedPolicy.value);
|
||||
showInfoToast('이전 설정으로 되돌렸습니다.');
|
||||
};
|
||||
|
||||
const submitPolicy = async () => {
|
||||
if (!canManagePolicy.value || !policyDraft.value || !window.confirm('저장할까요?')) return;
|
||||
if (!canManagePolicy.value || !policyDraft.value || !(await showConfirm('저장할까요?'))) return;
|
||||
try {
|
||||
await trpc.npc.setNationPolicy.mutate(policyDraft.value);
|
||||
lastSavedPolicy.value = clonePolicy(policyDraft.value);
|
||||
@@ -305,8 +310,8 @@ const submitPolicy = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetPriority = (section: PrioritySectionKey) => {
|
||||
if (!canManagePolicy.value || !data.value || !window.confirm('초기 설정으로 되돌릴까요?')) return;
|
||||
const resetPriority = async (section: PrioritySectionKey) => {
|
||||
if (!canManagePolicy.value || !data.value || !(await showConfirm('초기 설정으로 되돌릴까요?'))) return;
|
||||
if (section === 'nation') {
|
||||
nationPriority.value = assignPriorityState(
|
||||
data.value.defaultNationPriority,
|
||||
@@ -321,8 +326,8 @@ const resetPriority = (section: PrioritySectionKey) => {
|
||||
showInfoToast('서버 초깃값을 적용했습니다. 설정 버튼을 누르면 반영됩니다.');
|
||||
};
|
||||
|
||||
const rollbackPriority = (section: PrioritySectionKey) => {
|
||||
if (!canManagePolicy.value || !data.value || !window.confirm('이전 설정으로 되돌릴까요?')) return;
|
||||
const rollbackPriority = async (section: PrioritySectionKey) => {
|
||||
if (!canManagePolicy.value || !data.value || !(await showConfirm('이전 설정으로 되돌릴까요?'))) return;
|
||||
if (section === 'nation') {
|
||||
nationPriority.value = assignPriorityState(
|
||||
lastSavedNationPriority.value,
|
||||
@@ -339,7 +344,7 @@ const rollbackPriority = (section: PrioritySectionKey) => {
|
||||
|
||||
const submitPriority = async (section: PrioritySectionKey) => {
|
||||
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
||||
if (!canManagePolicy.value || !state || !window.confirm('저장할까요?')) return;
|
||||
if (!canManagePolicy.value || !state || !(await showConfirm('저장할까요?'))) return;
|
||||
try {
|
||||
if (section === 'nation') {
|
||||
await trpc.npc.setNationPriority.mutate(state.active);
|
||||
|
||||
@@ -25,7 +25,7 @@ const dialogTroopId = ref(0);
|
||||
const popupMember = ref<Member | null>(null);
|
||||
const popupTop = ref(0);
|
||||
const router = useRouter();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
|
||||
const { success: showSuccessToast, error: showErrorToast, confirm: showConfirm } = useGameFeedback();
|
||||
|
||||
const me = computed(() => data.value?.me ?? null);
|
||||
|
||||
@@ -92,7 +92,7 @@ const joinTroop = async (troop: Troop) => {
|
||||
const exitTroop = async (troop: Troop) => {
|
||||
const isLeader = me.value?.id === troop.id;
|
||||
const prompt = isLeader ? `${troop.name} 부대를 해산하겠습니까?` : `${troop.name} 부대에서 탈퇴하겠습니까?`;
|
||||
if (!window.confirm(prompt)) {
|
||||
if (!(await showConfirm(prompt))) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
@@ -130,7 +130,7 @@ const hasFinalConsonant = (value: string): boolean => {
|
||||
const renameTroop = async (troop: Troop) => {
|
||||
const troopName = editName.value;
|
||||
const particle = hasFinalConsonant(troopName) ? '으로' : '로';
|
||||
if (!window.confirm(`${troop.name} 부대의 이름을 ${troopName}${particle} 바꾸시겠습니까?`)) {
|
||||
if (!(await showConfirm(`${troop.name} 부대의 이름을 ${troopName}${particle} 바꾸시겠습니까?`))) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
@@ -147,7 +147,7 @@ const kickMember = async (troop: Troop) => {
|
||||
return;
|
||||
}
|
||||
const particle = hasFinalConsonant(member.name) ? '을' : '를';
|
||||
if (!window.confirm(`${troop.name} 부대에서 ${member.name}${particle} 추방하시겠습니까?`)) {
|
||||
if (!(await showConfirm(`${troop.name} 부대에서 ${member.name}${particle} 추방하시겠습니까?`))) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
|
||||
Reference in New Issue
Block a user