서버 탭 아래 중복 이름과 내부 지도 테마명을 제거하고 런타임 상태를 하단 요약으로 옮긴다. 연월은 레거시 지도 제목과 같은 20px 중앙 띠로 표시하고 데스크톱·모바일 Chromium 검증을 보강한다.
872 lines
41 KiB
Vue
872 lines
41 KiB
Vue
<script setup lang="ts">
|
|
import { formatServerDateTime } from '@sammo-ts/common';
|
|
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import type { inferRouterOutputs } from '@trpc/server';
|
|
import type { AppRouter } from '@sammo-ts/gateway-api';
|
|
import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
|
|
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';
|
|
import { resolveServerSeasonStatus } from '../utils/serverSeasonStatus';
|
|
import { configuredSharedIconPublicUrl, configuredUserIconPublicUrl } from '../utils/imageAssets';
|
|
|
|
type GatewayRouterOutput = inferRouterOutputs<AppRouter>;
|
|
type GameRouterOutput = inferRouterOutputs<GameRouter>;
|
|
type MeOutput = GatewayRouterOutput['me'];
|
|
type LobbyProfile = GatewayRouterOutput['lobby']['profiles'][number];
|
|
type LobbyInfo = GameRouterOutput['lobby']['info'];
|
|
type LobbyGeneral = NonNullable<LobbyInfo['myGeneral']>;
|
|
type PublicMap = GameRouterOutput['public']['getCachedMap'];
|
|
type PublicMapLayout = GameRouterOutput['public']['getMapLayout'];
|
|
type MapPreviewBundle = {
|
|
mapData: PublicMap;
|
|
mapLayout: PublicMapLayout;
|
|
};
|
|
type ProfileLoadState = {
|
|
status: 'loading' | 'retrying' | 'ready';
|
|
failures: number;
|
|
};
|
|
|
|
const PROFILE_REQUEST_TIMEOUT_MS = 10_000;
|
|
const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const;
|
|
const router = useRouter();
|
|
const me = ref<MeOutput>(null);
|
|
const notice = ref('');
|
|
const profiles = ref<LobbyProfile[]>([]);
|
|
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
|
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
|
const profileLoadStates = ref<Record<string, ProfileLoadState | undefined>>({});
|
|
const selectedMapProfileName = ref<string | null>(null);
|
|
const entryLoading = ref<Record<string, boolean>>({});
|
|
const logoutLoading = ref(false);
|
|
const logoutError = ref('');
|
|
const { error: showErrorToast } = useToast();
|
|
const profileRetryTimers = new Map<string, number>();
|
|
const profileRequestControllers = new Map<string, AbortController>();
|
|
let lobbyMounted = true;
|
|
|
|
watch(logoutError, (value) => value && showErrorToast(value), { flush: 'sync' });
|
|
const canAccessAdmin = computed(
|
|
() =>
|
|
me.value?.roles.some(
|
|
(role) =>
|
|
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
|
) ?? false
|
|
);
|
|
const needsKakaoVerification = computed(
|
|
() =>
|
|
me.value !== null &&
|
|
!me.value.kakaoVerified &&
|
|
profiles.value.some((profile) => profile.localAccountPolicy?.requiresKakaoVerification === true)
|
|
);
|
|
const userIconBaseUrl = configuredUserIconPublicUrl();
|
|
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
|
const publicMapProfiles = computed(() => profiles.value.filter((profile) => profile.lifecycle.userAccessible));
|
|
const selectedMapProfile = computed(
|
|
() => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null
|
|
);
|
|
const selectedMapPreview = computed(() =>
|
|
selectedMapProfileName.value ? profileMapPreviews.value[selectedMapProfileName.value] : undefined
|
|
);
|
|
|
|
watch(publicMapProfiles, (availableProfiles) => {
|
|
if (!availableProfiles.some((profile) => profile.profileName === selectedMapProfileName.value)) {
|
|
selectedMapProfileName.value = availableProfiles[0]?.profileName ?? null;
|
|
}
|
|
});
|
|
|
|
const selectMapProfile = (profileName: string): void => {
|
|
selectedMapProfileName.value = profileName;
|
|
};
|
|
|
|
const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void => {
|
|
const currentIndex = publicMapProfiles.value.findIndex((profile) => profile.profileName === profileName);
|
|
if (currentIndex < 0) return;
|
|
|
|
let nextIndex: number | null = null;
|
|
if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % publicMapProfiles.value.length;
|
|
if (event.key === 'ArrowLeft') {
|
|
nextIndex = (currentIndex - 1 + publicMapProfiles.value.length) % publicMapProfiles.value.length;
|
|
}
|
|
if (event.key === 'Home') nextIndex = 0;
|
|
if (event.key === 'End') nextIndex = publicMapProfiles.value.length - 1;
|
|
if (nextIndex === null) return;
|
|
|
|
event.preventDefault();
|
|
selectedMapProfileName.value = publicMapProfiles.value[nextIndex]?.profileName ?? null;
|
|
const tabButtons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>(
|
|
'[role="tab"]'
|
|
);
|
|
tabButtons?.[nextIndex]?.focus();
|
|
};
|
|
|
|
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
|
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
|
|
const unavailableProfileText = (profile: LobbyProfile): string => {
|
|
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
|
|
if (profile.status === 'RESERVED') return '- 준 비 중 · 접근 불가 -';
|
|
if (profile.status === 'DISABLED') return '- 비 활 성 -';
|
|
return '- 서버 중지 · 접근 불가 -';
|
|
};
|
|
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
|
|
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
|
|
profileLoadStates.value = {
|
|
...profileLoadStates.value,
|
|
[profileName]: state,
|
|
};
|
|
};
|
|
const clearProfileRetry = (profileName: string): void => {
|
|
const timer = profileRetryTimers.get(profileName);
|
|
if (timer !== undefined) {
|
|
window.clearTimeout(timer);
|
|
profileRetryTimers.delete(profileName);
|
|
}
|
|
};
|
|
const requestOptions = (componentSignal: AbortSignal): { signal: AbortSignal } => ({
|
|
signal: AbortSignal.any([componentSignal, AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS)]),
|
|
});
|
|
const encodeLegacyIconPath = (value: string): string =>
|
|
value
|
|
.split('/')
|
|
.map((segment) => {
|
|
if (segment === '.') return '%2E';
|
|
if (segment === '..') return '%2E%2E';
|
|
return encodeURIComponent(segment);
|
|
})
|
|
.join('/');
|
|
const resolveGeneralPicture = (general: LobbyGeneral): string => {
|
|
const picture = general.picture?.trim() || 'default.jpg';
|
|
return general.imageServer
|
|
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeLegacyIconPath(picture)}`
|
|
: `${sharedIconBaseUrl}/${encodeLegacyIconPath(picture)}`;
|
|
};
|
|
const handleGeneralPictureError = (event: Event): void => {
|
|
const image = event.currentTarget as HTMLImageElement;
|
|
if (image.dataset.generalIconFallbackSource === image.currentSrc) {
|
|
return;
|
|
}
|
|
image.dataset.generalIconFallbackSource = image.currentSrc;
|
|
image.src = `${sharedIconBaseUrl}/default.jpg`;
|
|
};
|
|
|
|
const loadProfileDetails = async (profile: LobbyProfile, sessionToken: string | null): Promise<void> => {
|
|
if (!lobbyMounted || !isProfileRuntimeAvailable(profile)) {
|
|
return;
|
|
}
|
|
clearProfileRetry(profile.profileName);
|
|
profileRequestControllers.get(profile.profileName)?.abort();
|
|
const requestController = new AbortController();
|
|
profileRequestControllers.set(profile.profileName, requestController);
|
|
const previousFailures = profileLoadState(profile.profileName)?.failures ?? 0;
|
|
setProfileLoadState(profile.profileName, { status: 'loading', failures: previousFailures });
|
|
|
|
const publicGameTrpc = createGameTrpc(profile.profile, profile.apiPort);
|
|
let gameTrpc = publicGameTrpc;
|
|
let authenticated = sessionToken === null;
|
|
if (sessionToken) {
|
|
try {
|
|
const issued = await trpc.auth.issueGameSession.mutate(
|
|
{
|
|
sessionToken,
|
|
profile: profile.profileName,
|
|
},
|
|
requestOptions(requestController.signal)
|
|
);
|
|
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate(
|
|
{
|
|
gatewayToken: issued.gameToken,
|
|
},
|
|
requestOptions(requestController.signal)
|
|
);
|
|
gameTrpc = createGameTrpc(profile.profile, profile.apiPort, exchanged.accessToken);
|
|
authenticated = true;
|
|
} catch (error) {
|
|
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
|
}
|
|
}
|
|
|
|
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
|
gameTrpc.lobby.info.query(undefined, requestOptions(requestController.signal)),
|
|
gameTrpc.public.getMapLayout.query(undefined, requestOptions(requestController.signal)),
|
|
gameTrpc.public.getCachedMap.query(undefined, requestOptions(requestController.signal)),
|
|
]);
|
|
if (profileRequestControllers.get(profile.profileName) !== requestController) {
|
|
return;
|
|
}
|
|
profileRequestControllers.delete(profile.profileName);
|
|
if (!lobbyMounted) {
|
|
return;
|
|
}
|
|
|
|
if (infoResult.status === 'fulfilled') {
|
|
profileDetails.value[profile.profileName] = infoResult.value;
|
|
} else {
|
|
console.error(`Failed to fetch info for ${profile.profileName}`, infoResult.reason);
|
|
}
|
|
if (layoutResult.status === 'fulfilled' && mapResult.status === 'fulfilled') {
|
|
profileMapPreviews.value[profile.profileName] = {
|
|
mapLayout: layoutResult.value,
|
|
mapData: mapResult.value,
|
|
};
|
|
}
|
|
|
|
const fullyLoaded =
|
|
authenticated &&
|
|
infoResult.status === 'fulfilled' &&
|
|
layoutResult.status === 'fulfilled' &&
|
|
mapResult.status === 'fulfilled';
|
|
if (fullyLoaded) {
|
|
setProfileLoadState(profile.profileName, { status: 'ready', failures: 0 });
|
|
return;
|
|
}
|
|
|
|
const failures = previousFailures + 1;
|
|
setProfileLoadState(profile.profileName, { status: 'retrying', failures });
|
|
const retryDelay = PROFILE_RETRY_DELAYS_MS[Math.min(failures - 1, PROFILE_RETRY_DELAYS_MS.length - 1)];
|
|
const timer = window.setTimeout(() => {
|
|
profileRetryTimers.delete(profile.profileName);
|
|
void loadProfileDetails(profile, sessionToken);
|
|
}, retryDelay);
|
|
profileRetryTimers.set(profile.profileName, timer);
|
|
};
|
|
|
|
const retryProfileDetails = (profile: LobbyProfile): void => {
|
|
clearProfileRetry(profile.profileName);
|
|
setProfileLoadState(profile.profileName, { status: 'loading', failures: 0 });
|
|
void loadProfileDetails(profile, window.localStorage.getItem('sammo-session-token'));
|
|
};
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
me.value = await trpc.me.query();
|
|
if (!me.value) {
|
|
await router.push('/');
|
|
return;
|
|
}
|
|
|
|
notice.value = await trpc.lobby.notice.query();
|
|
profiles.value = await trpc.lobby.profiles.query();
|
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
|
await Promise.all(profiles.value.map((profile) => loadProfileDetails(profile, sessionToken)));
|
|
} catch (e) {
|
|
console.error('Failed to load lobby', e);
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
lobbyMounted = false;
|
|
for (const timer of profileRetryTimers.values()) {
|
|
window.clearTimeout(timer);
|
|
}
|
|
profileRetryTimers.clear();
|
|
for (const controller of profileRequestControllers.values()) {
|
|
controller.abort();
|
|
}
|
|
profileRequestControllers.clear();
|
|
});
|
|
|
|
const handleLogout = async () => {
|
|
if (logoutLoading.value) {
|
|
return;
|
|
}
|
|
logoutError.value = '';
|
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
|
if (!sessionToken) {
|
|
await router.replace('/');
|
|
return;
|
|
}
|
|
logoutLoading.value = true;
|
|
try {
|
|
await trpc.auth.logout.mutate({ sessionToken });
|
|
window.localStorage.removeItem('sammo-session-token');
|
|
window.localStorage.removeItem('sammo-game-token');
|
|
window.localStorage.removeItem('sammo-game-profile');
|
|
me.value = null;
|
|
await router.replace('/');
|
|
} catch (error) {
|
|
logoutError.value = error instanceof Error ? error.message : '로그아웃에 실패했습니다.';
|
|
} finally {
|
|
logoutLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleKakaoVerification = async (): Promise<void> => {
|
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
|
if (!sessionToken) {
|
|
await router.push('/');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await trpc.auth.kakaoStart.query({
|
|
mode: 'verify',
|
|
});
|
|
window.location.assign(result.authUrl);
|
|
} catch (error) {
|
|
showErrorToast(error instanceof Error ? error.message : '카카오 인증을 시작하지 못했습니다.');
|
|
}
|
|
};
|
|
|
|
const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => {
|
|
const profile = profileName.split(':', 1)[0] ?? profileName;
|
|
const baseUrl =
|
|
import.meta.env.VITE_GAME_WEB_URL ??
|
|
import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ??
|
|
'';
|
|
if (!baseUrl) {
|
|
return null;
|
|
}
|
|
const base = new URL(baseUrl, window.location.origin);
|
|
const normalizedPath = path.replace(/^\//, '');
|
|
const url = new URL(normalizedPath, base);
|
|
let transferredInSessionStorage = false;
|
|
if (url.origin === window.location.origin) {
|
|
try {
|
|
transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, {
|
|
profile: profileName,
|
|
gatewayToken: gameToken,
|
|
});
|
|
} catch {
|
|
transferredInSessionStorage = false;
|
|
}
|
|
}
|
|
if (!transferredInSessionStorage) {
|
|
url.searchParams.set('profile', profileName);
|
|
url.searchParams.set('gameToken', gameToken);
|
|
}
|
|
return url.toString();
|
|
};
|
|
|
|
const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|
if (entryLoading.value[profile.profileName]) {
|
|
return;
|
|
}
|
|
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
|
if (!sessionToken) {
|
|
await router.push('/');
|
|
return;
|
|
}
|
|
entryLoading.value[profile.profileName] = true;
|
|
try {
|
|
const issued = await trpc.auth.issueGameSession.mutate({
|
|
sessionToken,
|
|
profile: profile.profileName,
|
|
});
|
|
const url = resolveGameUrl(targetPath, issued.profile, issued.gameToken);
|
|
if (!url) {
|
|
showErrorToast('게임 프론트엔드 주소가 설정되지 않았습니다.');
|
|
return;
|
|
}
|
|
window.location.href = url;
|
|
} catch (e) {
|
|
console.error('Failed to issue game session', e);
|
|
showErrorToast(e instanceof Error ? e.message : '게임 서버 접속에 실패했습니다.');
|
|
} finally {
|
|
entryLoading.value[profile.profileName] = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<DefaultLayout>
|
|
<div class="max-w-5xl mx-auto pt-24 pb-8 px-4 space-y-8">
|
|
<!-- Notice -->
|
|
<div v-if="notice" class="text-center">
|
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
<span data-testid="gateway-notice" class="text-orange-500 text-3xl font-bold" v-html="notice"></span>
|
|
</div>
|
|
|
|
<section
|
|
v-if="needsKakaoVerification"
|
|
id="kakao-verification-banner"
|
|
class="bg-amber-950/70 border border-amber-600 rounded p-4 text-amber-100"
|
|
>
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
|
<div>
|
|
<strong class="block text-amber-300">카카오 인증이 필요합니다.</strong>
|
|
<span class="text-sm">
|
|
유예기간이 지나면 게임에 입장할 수 없습니다. che·kwe·twe는 인증 전 장수 생성도 제한됩니다.
|
|
</span>
|
|
</div>
|
|
<button
|
|
id="connect-kakao"
|
|
class="shrink-0 bg-yellow-400 hover:bg-yellow-300 text-black font-bold px-5 py-2 rounded"
|
|
type="button"
|
|
@click="handleKakaoVerification"
|
|
>
|
|
카카오 인증 연결
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Server List Table -->
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
|
<div
|
|
class="bg-zinc-800 px-6 py-3 text-center font-bold text-white border-b border-zinc-700 text-xl tracking-widest"
|
|
>
|
|
서 버 선 택
|
|
</div>
|
|
<div class="overflow-x-auto" data-testid="profile-table-scroll">
|
|
<table class="w-full min-w-[760px] text-sm text-left">
|
|
<thead class="bg-zinc-800 text-zinc-400 uppercase text-xs">
|
|
<tr>
|
|
<th class="px-4 py-3 border-b border-zinc-700 w-24 text-center">서 버</th>
|
|
<th class="px-4 py-3 border-b border-zinc-700">정 보</th>
|
|
<th class="px-4 py-3 border-b border-zinc-700 w-48 text-center" colspan="2">
|
|
캐 릭 터
|
|
</th>
|
|
<th class="px-4 py-3 border-b border-zinc-700 w-32 text-center">선 택</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-zinc-800">
|
|
<tr
|
|
v-for="profile in profiles"
|
|
:key="profile.profileName"
|
|
class="hover:bg-zinc-800/50 transition-colors"
|
|
>
|
|
<!-- Server Name -->
|
|
<td class="px-4 py-4 text-center border-r border-zinc-800">
|
|
<div
|
|
:style="{ color: profile.color }"
|
|
class="text-lg font-bold cursor-help"
|
|
:title="
|
|
profileDetails[profile.profileName]
|
|
? serverSeasonStatus(profileDetails[profile.profileName]!).period
|
|
: ''
|
|
"
|
|
>
|
|
{{ profile.korName }}섭
|
|
</div>
|
|
<div
|
|
v-if="profileDetails[profile.profileName]"
|
|
class="season-status mt-1 whitespace-nowrap text-xs text-zinc-500"
|
|
>
|
|
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
|
|
</div>
|
|
<div
|
|
v-if="profile.status === 'PAUSED'"
|
|
class="mt-1 whitespace-nowrap text-xs text-amber-300"
|
|
data-testid="profile-paused-status"
|
|
>
|
|
턴 일시정지 · 조회/예약턴 가능
|
|
</div>
|
|
<div
|
|
v-if="profile.localAccountPolicy?.specialAccess"
|
|
class="mt-2 text-xs text-emerald-300"
|
|
>
|
|
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
|
|
</div>
|
|
<div
|
|
v-else-if="
|
|
profile.localAccountPolicy?.requiresKakaoVerification &&
|
|
!profile.localAccountPolicy.canCreateGeneral
|
|
"
|
|
class="mt-2 text-xs text-red-400"
|
|
>
|
|
인증 전 생성 불가
|
|
</div>
|
|
<div
|
|
v-else-if="profile.localAccountPolicy?.requiresKakaoVerification"
|
|
class="mt-2 text-xs text-amber-300"
|
|
>
|
|
{{ formatGraceEndsAt(profile.localAccountPolicy.graceEndsAt) }}까지 유예
|
|
</div>
|
|
</td>
|
|
|
|
<!-- Server Info -->
|
|
<td class="px-4 py-4 border-r border-zinc-800">
|
|
<template v-if="profileDetails[profile.profileName]">
|
|
<div class="space-y-1">
|
|
<div>
|
|
서기 {{ profileDetails[profile.profileName]?.year }}년
|
|
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
|
class="text-orange-400"
|
|
>{{ profile.scenario }}</span
|
|
>)
|
|
</div>
|
|
<div class="text-zinc-400">
|
|
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
|
|
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
|
|
<span class="text-cyan-400 ml-2"
|
|
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
|
|
>
|
|
<span class="text-green-400 ml-2"
|
|
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴
|
|
서버)</span
|
|
>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">
|
|
(상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}),
|
|
(기타 설정:{{ profileDetails[profile.profileName]?.otherTextInfo }})
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="!isProfileRuntimeAvailable(profile)">
|
|
<div class="text-center text-zinc-600 py-2">
|
|
{{ unavailableProfileText(profile) }}
|
|
</div>
|
|
</template>
|
|
<template v-else-if="profileLoadState(profile.profileName)?.status === 'retrying'">
|
|
<div
|
|
class="text-center text-zinc-500 py-1"
|
|
role="status"
|
|
data-testid="profile-info-retrying"
|
|
>
|
|
<div>서버 응답을 기다리고 있습니다.</div>
|
|
<button
|
|
type="button"
|
|
class="mt-2 text-xs text-orange-300 underline underline-offset-2 hover:text-orange-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-orange-300"
|
|
@click="retryProfileDetails(profile)"
|
|
>
|
|
지금 다시 확인
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="text-center text-zinc-500 py-2">정보를 불러오는 중...</div>
|
|
</template>
|
|
</td>
|
|
|
|
<!-- Character Info -->
|
|
<td class="px-2 py-4 w-16 border-r border-zinc-800">
|
|
<div
|
|
v-if="profileDetails[profile.profileName]?.myGeneral"
|
|
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
|
|
>
|
|
<img
|
|
:src="
|
|
resolveGeneralPicture(profileDetails[profile.profileName]!.myGeneral!)
|
|
"
|
|
class="w-full h-full object-cover"
|
|
@error="handleGeneralPictureError"
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-4 border-r border-zinc-800 text-center">
|
|
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
|
|
{{ profileDetails[profile.profileName]?.myGeneral?.name }}
|
|
</div>
|
|
<div v-else class="text-zinc-600">- 미 등 록 -</div>
|
|
</td>
|
|
|
|
<!-- Action -->
|
|
<td class="px-4 py-4 text-center">
|
|
<template v-if="profileDetails[profile.profileName]">
|
|
<button
|
|
v-if="profileDetails[profile.profileName]?.myGeneral"
|
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
|
:disabled="entryLoading[profile.profileName]"
|
|
@click="handleEnter(profile, '/')"
|
|
>
|
|
입장
|
|
</button>
|
|
<div
|
|
v-else-if="
|
|
profileDetails[profile.profileName]!.userCnt >=
|
|
profileDetails[profile.profileName]!.maxUserCnt
|
|
"
|
|
class="text-zinc-500"
|
|
>
|
|
- 장수 등록 마감 -
|
|
</div>
|
|
<div v-else class="grid gap-1">
|
|
<button
|
|
v-if="profileDetails[profile.profileName]?.selectionPoolEnabled"
|
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
|
:disabled="entryLoading[profile.profileName]"
|
|
@click="handleEnter(profile, '/select-general')"
|
|
>
|
|
장수선택
|
|
</button>
|
|
<template v-else>
|
|
<button
|
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
|
:disabled="
|
|
entryLoading[profile.profileName] ||
|
|
profile.localAccountPolicy?.canCreateGeneral === false
|
|
"
|
|
@click="handleEnter(profile, '/join')"
|
|
>
|
|
{{
|
|
profile.localAccountPolicy?.canCreateGeneral === false
|
|
? '인증 필요'
|
|
: '장수생성'
|
|
}}
|
|
</button>
|
|
<button
|
|
v-if="profileDetails[profile.profileName]?.npcPossessionEnabled"
|
|
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
|
:disabled="
|
|
entryLoading[profile.profileName] ||
|
|
profile.localAccountPolicy?.canCreateGeneral === false
|
|
"
|
|
@click="handleEnter(profile, '/join?tab=possess')"
|
|
>
|
|
장수빙의
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="!isProfileRuntimeAvailable(profile)">
|
|
<span class="text-zinc-700">-</span>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- Footer Info -->
|
|
<div class="bg-zinc-800/50 p-4 text-xs text-zinc-500 space-y-2 border-t border-zinc-800">
|
|
<p class="text-red-500 font-bold">
|
|
★ 1명이 2개 이상의 계정을 사용하거나 타 유저의 턴을 대신 입력하는 것이 적발될 경우 차단 될 수
|
|
있습니다.
|
|
</p>
|
|
<p>계정은 한번 등록으로 계속 사용합니다. 각 서버 리셋시 캐릭터만 새로 생성하면 됩니다.</p>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-1 mt-2">
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">체섭</span> : 메인서버입니다. 천하통일에 도전하여
|
|
왕조일람과 명예의전당에 올라봅시다! (주로 1턴=60분)
|
|
</p>
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">퀘섭</span> : 마이너 서버 그룹1. 비교적 느린 시간으로
|
|
운영됩니다.
|
|
</p>
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">풰섭</span> : 마이너 서버 그룹1. 비교적 느린 시간으로
|
|
운영됩니다.
|
|
</p>
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">퉤섭</span> : 마이너 서버 그룹2. 비교적 빠른 시간으로
|
|
운영됩니다.
|
|
</p>
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">냐섭</span> : 마이너 서버 그룹3. 독특한 컨셉 위주로
|
|
운영됩니다.
|
|
</p>
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">퍄섭</span> : 마이너 서버 그룹3. 독특한 컨셉 위주로
|
|
운영됩니다.
|
|
</p>
|
|
<p>
|
|
<span class="text-zinc-300 font-bold">훼섭</span> : 운영자 테스트 서버입니다. 기습적으로
|
|
열리고, 닫힐 수 있습니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
|
<div
|
|
class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest"
|
|
>
|
|
공개 지도 미리보기
|
|
</div>
|
|
<div class="p-4">
|
|
<div
|
|
v-if="publicMapProfiles.length"
|
|
class="map-preview-tabs"
|
|
role="tablist"
|
|
aria-label="공개 지도 서버 선택"
|
|
>
|
|
<button
|
|
v-for="profile in publicMapProfiles"
|
|
:id="`map-preview-tab-${profile.profileName}`"
|
|
:key="profile.profileName"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="selectedMapProfileName === profile.profileName"
|
|
:aria-controls="`map-preview-panel-${profile.profileName}`"
|
|
:tabindex="selectedMapProfileName === profile.profileName ? 0 : -1"
|
|
class="map-preview-tab"
|
|
:class="{ 'is-active': selectedMapProfileName === profile.profileName }"
|
|
:style="{ '--profile-color': profile.color }"
|
|
@mouseenter="selectMapProfile(profile.profileName)"
|
|
@focus="selectMapProfile(profile.profileName)"
|
|
@click="selectMapProfile(profile.profileName)"
|
|
@keydown="handleMapTabKeydown($event, profile.profileName)"
|
|
>
|
|
{{ profile.korName }}섭
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-if="selectedMapProfile"
|
|
:id="`map-preview-panel-${selectedMapProfile.profileName}`"
|
|
role="tabpanel"
|
|
:aria-labelledby="`map-preview-tab-${selectedMapProfile.profileName}`"
|
|
class="map-preview-panel"
|
|
data-testid="public-map-preview-panel"
|
|
>
|
|
<div v-if="selectedMapPreview">
|
|
<MapPreview
|
|
:map-data="selectedMapPreview.mapData"
|
|
:map-layout="selectedMapPreview.mapLayout"
|
|
mode="detail"
|
|
/>
|
|
<div
|
|
v-if="profileDetails[selectedMapProfile.profileName]"
|
|
class="map-preview-summary"
|
|
data-testid="public-map-preview-summary"
|
|
>
|
|
<span class="map-preview-runtime-status" :style="{ color: selectedMapProfile.color }">
|
|
{{ selectedMapProfile.status }}
|
|
</span>
|
|
<span>
|
|
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
|
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }}
|
|
</span>
|
|
<span>{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국</span>
|
|
<span>{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
|
</div>
|
|
<div v-else class="text-sm text-zinc-500 py-8 text-center" data-testid="public-map-empty">
|
|
현재 공개 중인 서버가 없습니다.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Account Management -->
|
|
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
|
|
<div
|
|
class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest"
|
|
>
|
|
계 정 관 리
|
|
</div>
|
|
<div class="p-6 flex justify-center space-x-4">
|
|
<RouterLink
|
|
to="/account"
|
|
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
|
>
|
|
비밀번호 & 전콘 & 탈퇴
|
|
</RouterLink>
|
|
<button
|
|
id="btn_logout"
|
|
class="legacy-logout-button"
|
|
:disabled="logoutLoading"
|
|
@click="handleLogout"
|
|
>
|
|
{{ logoutLoading ? '로그아웃 중…' : '로 그 아 웃' }}
|
|
</button>
|
|
<RouterLink
|
|
v-if="canAccessAdmin"
|
|
to="/admin"
|
|
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
|
>
|
|
관리자 페이지
|
|
</RouterLink>
|
|
</div>
|
|
<p v-if="logoutError" class="px-6 pb-4 text-center text-sm text-red-400" role="alert">
|
|
{{ logoutError }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</DefaultLayout>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.legacy-logout-button {
|
|
box-sizing: border-box;
|
|
width: 200px;
|
|
height: 48px;
|
|
border: 0;
|
|
border-radius: 6px;
|
|
background: #303030;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-family: Pretendard, sans-serif;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
line-height: 24px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.map-preview-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
overflow-x: auto;
|
|
border-bottom: 1px solid #3f3f46;
|
|
padding: 0 2px;
|
|
}
|
|
|
|
.map-preview-tab {
|
|
flex: 0 0 auto;
|
|
border: 1px solid #3f3f46;
|
|
border-bottom: 0;
|
|
border-radius: 6px 6px 0 0;
|
|
background: #18181b;
|
|
color: #a1a1aa;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
line-height: 20px;
|
|
padding: 7px 16px;
|
|
}
|
|
|
|
.map-preview-tab:hover,
|
|
.map-preview-tab:focus,
|
|
.map-preview-tab.is-active {
|
|
background: #27272a;
|
|
color: var(--profile-color, #fff);
|
|
}
|
|
|
|
.map-preview-tab:focus-visible {
|
|
outline: 2px solid #fff;
|
|
outline-offset: -3px;
|
|
}
|
|
|
|
.map-preview-tab.is-active {
|
|
box-shadow: inset 0 3px 0 var(--profile-color, #fff);
|
|
}
|
|
|
|
.map-preview-panel {
|
|
min-width: 0;
|
|
border: 1px solid #3f3f46;
|
|
border-top: 0;
|
|
border-radius: 0 0 6px 6px;
|
|
background: rgb(9 9 11 / 50%);
|
|
padding: 12px;
|
|
}
|
|
|
|
.map-preview-summary {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 2px 0;
|
|
margin-top: 8px;
|
|
color: #a1a1aa;
|
|
font-size: 12px;
|
|
line-height: 18px;
|
|
}
|
|
|
|
.map-preview-summary > span + span::before {
|
|
margin-inline: 6px;
|
|
color: #52525b;
|
|
content: '·';
|
|
}
|
|
|
|
.map-preview-runtime-status {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.legacy-logout-button:hover,
|
|
.legacy-logout-button:focus,
|
|
.legacy-logout-button:active {
|
|
background: #303030;
|
|
color: #fff;
|
|
}
|
|
|
|
.legacy-logout-button:focus-visible {
|
|
outline: 2px solid #fff;
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.legacy-logout-button:disabled {
|
|
cursor: default;
|
|
opacity: 0.65;
|
|
}
|
|
</style>
|