feat(gateway): show action result toasts
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import ToastViewport from './components/ToastViewport.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastViewport />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast, type ToastKind } from '../composables/useToast';
|
||||
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const titleFor = (kind: ToastKind): string => {
|
||||
if (kind === 'success') return '완료';
|
||||
if (kind === 'error') return '처리 실패';
|
||||
return '안내';
|
||||
};
|
||||
|
||||
const iconFor = (kind: ToastKind): string => {
|
||||
if (kind === 'success') return '✓';
|
||||
if (kind === 'error') return '!';
|
||||
return 'i';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-viewport" aria-label="작업 알림">
|
||||
<TransitionGroup name="toast" tag="div" class="toast-stack">
|
||||
<article
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast-card"
|
||||
:class="`toast-card--${toast.kind}`"
|
||||
:role="toast.kind === 'error' ? 'alert' : 'status'"
|
||||
:aria-live="toast.kind === 'error' ? 'assertive' : 'polite'"
|
||||
data-testid="action-toast"
|
||||
:data-toast-kind="toast.kind"
|
||||
>
|
||||
<span class="toast-icon" aria-hidden="true">{{ iconFor(toast.kind) }}</span>
|
||||
<span class="toast-copy">
|
||||
<strong>{{ titleFor(toast.kind) }}</strong>
|
||||
<span>{{ toast.message }}</span>
|
||||
</span>
|
||||
<button type="button" class="toast-close" aria-label="알림 닫기" @click="dismiss(toast.id)">
|
||||
×
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-viewport {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: max(1rem, env(safe-area-inset-top));
|
||||
right: max(1rem, env(safe-area-inset-right));
|
||||
width: min(25rem, calc(100vw - 2rem));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.toast-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1.75rem minmax(0, 1fr) 2rem;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.875rem;
|
||||
color: #f4f4f5;
|
||||
background: rgb(24 24 27 / 96%);
|
||||
border: 1px solid #52525b;
|
||||
border-left-width: 4px;
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 14px 38px rgb(0 0 0 / 45%);
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.toast-card--success {
|
||||
border-left-color: #34d399;
|
||||
}
|
||||
|
||||
.toast-card--error {
|
||||
border-left-color: #fb7185;
|
||||
}
|
||||
|
||||
.toast-card--info {
|
||||
border-left-color: #60a5fa;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: #09090b;
|
||||
background: #a1a1aa;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toast-card--success .toast-icon {
|
||||
background: #34d399;
|
||||
}
|
||||
|
||||
.toast-card--error .toast-icon {
|
||||
background: #fb7185;
|
||||
}
|
||||
|
||||
.toast-card--info .toast-icon {
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
.toast-copy {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toast-copy strong {
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin: -0.35rem -0.35rem 0 0;
|
||||
color: #d4d4d8;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toast-close:hover,
|
||||
.toast-close:focus-visible {
|
||||
color: #fff;
|
||||
background: #3f3f46;
|
||||
outline: 2px solid #a1a1aa;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active,
|
||||
.toast-move {
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.toast-viewport {
|
||||
top: auto;
|
||||
right: max(0.75rem, env(safe-area-inset-right));
|
||||
bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
left: max(0.75rem, env(safe-area-inset-left));
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
transform: translateY(0.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-enter-active,
|
||||
.toast-leave-active,
|
||||
.toast-move {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
|
||||
export type ToastKind = 'success' | 'error' | 'info';
|
||||
|
||||
export type Toast = {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const visibleToasts = ref<Toast[]>([]);
|
||||
const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
let nextToastId = 1;
|
||||
|
||||
const dismiss = (id: number): void => {
|
||||
const timer = dismissTimers.get(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
dismissTimers.delete(id);
|
||||
visibleToasts.value = visibleToasts.value.filter((toast) => toast.id !== id);
|
||||
};
|
||||
|
||||
const show = (message: string, kind: ToastKind = 'info', durationMs = 5_000): number => {
|
||||
const normalizedMessage = message.trim();
|
||||
if (!normalizedMessage) return -1;
|
||||
|
||||
const duplicate = visibleToasts.value.find(
|
||||
(toast) => toast.message === normalizedMessage && toast.kind === kind
|
||||
);
|
||||
if (duplicate) {
|
||||
dismiss(duplicate.id);
|
||||
}
|
||||
|
||||
const id = nextToastId++;
|
||||
visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }];
|
||||
if (durationMs > 0) {
|
||||
dismissTimers.set(id, setTimeout(() => dismiss(id), durationMs));
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const feedback = (message: string): number => {
|
||||
if (/실패|오류|못했|필요|입력|선택|유효|일치하지|비활성화|없습니다|해야 합니다/.test(message)) {
|
||||
return show(message, 'error');
|
||||
}
|
||||
if (/완료|성공|저장|등록|적용|변경|해제|부여|생성|철회|예약/.test(message)) {
|
||||
return show(message, 'success');
|
||||
}
|
||||
return show(message, 'info');
|
||||
};
|
||||
|
||||
export const useToast = () => ({
|
||||
toasts: readonly(visibleToasts),
|
||||
show,
|
||||
success: (message: string, durationMs?: number) => show(message, 'success', durationMs),
|
||||
error: (message: string, durationMs?: number) => show(message, 'error', durationMs),
|
||||
info: (message: string, durationMs?: number) => show(message, 'info', durationMs),
|
||||
feedback,
|
||||
dismiss,
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useToast } from '../composables/useToast';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -22,6 +23,10 @@ const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
watch(successMessage, (value) => value && showSuccessToast(value), { flush: 'sync' });
|
||||
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const newPasswordConfirm = ref('');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import {
|
||||
normalizeProfileResetDefaults,
|
||||
type ProfileResetDefaults,
|
||||
@@ -513,6 +514,45 @@ const userHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
|
||||
const globalAuditStatus = ref('');
|
||||
|
||||
const { feedback: showFeedbackToast } = useToast();
|
||||
const actionFeedback = [
|
||||
noticeStatus,
|
||||
userError,
|
||||
kakaoGraceStatus,
|
||||
specialAccessStatus,
|
||||
passwordStatus,
|
||||
rolesStatus,
|
||||
banStatus,
|
||||
profileIconStatus,
|
||||
restrictionStatus,
|
||||
forceDeleteStatus,
|
||||
];
|
||||
|
||||
watch(
|
||||
actionFeedback,
|
||||
(current, previous) => {
|
||||
current.forEach((message, index) => {
|
||||
if (message && message !== previous[index]) showFeedbackToast(message);
|
||||
});
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
const setLocalAccountFeedback = (message: string): void => {
|
||||
localAccountStatus.value = message;
|
||||
showFeedbackToast(message);
|
||||
};
|
||||
|
||||
watch(
|
||||
profileActionStatus,
|
||||
(current, previous) => {
|
||||
Object.entries(current).forEach(([profileName, message]) => {
|
||||
if (message && message !== previous[profileName]) showFeedbackToast(message);
|
||||
});
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
const hasUser = computed(() => Boolean(userResult.value));
|
||||
|
||||
const loadLocalAccountStatus = async () => {
|
||||
@@ -723,9 +763,10 @@ const updateProfileMeta = async (profileName: string) => {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : '';
|
||||
profileActionStatus.value = {
|
||||
...profileActionStatus.value,
|
||||
[profileName]: '메타 저장 실패',
|
||||
[profileName]: detail ? `메타 저장 실패: ${detail}` : '메타 저장 실패',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1183,14 +1224,14 @@ const createLocalAccount = async () => {
|
||||
localAccountStatus.value = '';
|
||||
localAccountResult.value = '';
|
||||
if (!localAccountEnabled.value) {
|
||||
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
|
||||
setLocalAccountFeedback('ENV 설정이 비활성화 상태입니다.');
|
||||
return;
|
||||
}
|
||||
const username = localAccountForm.value.username.trim();
|
||||
const password = localAccountForm.value.password.trim();
|
||||
const displayName = localAccountForm.value.displayName.trim();
|
||||
if (!username || !password) {
|
||||
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
|
||||
setLocalAccountFeedback('아이디와 비밀번호를 입력하세요.');
|
||||
return;
|
||||
}
|
||||
localAccountLoading.value = true;
|
||||
@@ -1201,7 +1242,7 @@ const createLocalAccount = async () => {
|
||||
displayName: displayName || undefined,
|
||||
});
|
||||
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
|
||||
localAccountStatus.value = '로컬 계정 생성 완료';
|
||||
setLocalAccountFeedback('로컬 계정 생성 완료');
|
||||
localAccountForm.value = {
|
||||
username: result.user.username,
|
||||
password: '',
|
||||
@@ -1211,7 +1252,7 @@ const createLocalAccount = async () => {
|
||||
userLookupValue.value = result.user.username;
|
||||
await Promise.all([lookupUser(), loadUserDirectory()]);
|
||||
} catch (error) {
|
||||
localAccountStatus.value = '로컬 계정 생성 실패';
|
||||
setLocalAccountFeedback('로컬 계정 생성 실패');
|
||||
} finally {
|
||||
localAccountLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import MapPreview from '../components/MapPreview.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import type { GameRouter } from '../utils/gameTrpc';
|
||||
@@ -34,6 +35,9 @@ const selectedMapProfileName = ref<string | null>(null);
|
||||
const entryLoading = ref<Record<string, boolean>>({});
|
||||
const logoutLoading = ref(false);
|
||||
const logoutError = ref('');
|
||||
const { error: showErrorToast } = useToast();
|
||||
|
||||
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||
const canAccessAdmin = computed(
|
||||
() =>
|
||||
me.value?.roles.some(
|
||||
@@ -207,7 +211,7 @@ const handleKakaoVerification = async (): Promise<void> => {
|
||||
});
|
||||
window.location.assign(result.authUrl);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
|
||||
showErrorToast(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,13 +249,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
});
|
||||
const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken);
|
||||
if (!url) {
|
||||
alert('게임 프론트엔드 주소가 설정되지 않았습니다.');
|
||||
showErrorToast('게임 프론트엔드 주소가 설정되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
console.error('Failed to issue game session', e);
|
||||
alert(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
|
||||
showErrorToast(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
|
||||
} finally {
|
||||
entryLoading.value[profile.profileName] = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
|
||||
import {
|
||||
normalizeProfileResetDefaults,
|
||||
@@ -95,6 +96,10 @@ const catalogAttempted = ref(false);
|
||||
const submitting = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
watch(message, (value) => value && showSuccessToast(value), { flush: 'sync' });
|
||||
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
|
||||
const resetDefaultsSource = ref<'SYSTEM' | 'PROFILE'>('SYSTEM');
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let stateRequestInFlight = false;
|
||||
|
||||
Reference in New Issue
Block a user