725 lines
34 KiB
Vue
725 lines
34 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, onMounted, 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;
|
|
};
|
|
|
|
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 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(
|
|
(role) =>
|
|
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
|
) ?? false
|
|
);
|
|
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
|
const userIconBaseUrl = configuredUserIconPublicUrl();
|
|
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
|
const publicMapProfiles = computed(() =>
|
|
profiles.value.filter((profile) => profile.status === 'RUNNING' || profile.status === 'PREOPEN')
|
|
);
|
|
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 =>
|
|
value ? new Date(value).toLocaleString('ko-KR') : '';
|
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
|
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`;
|
|
};
|
|
|
|
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');
|
|
|
|
const detailTasks = profiles.value.map(async (profile) => {
|
|
if (profile.status !== 'RUNNING' && profile.status !== 'PREOPEN') {
|
|
return;
|
|
}
|
|
const publicGameTrpc = createGameTrpc(profile.profile, profile.apiPort);
|
|
let gameToken: string | undefined;
|
|
if (sessionToken) {
|
|
try {
|
|
const issued = await trpc.auth.issueGameSession.mutate({
|
|
sessionToken,
|
|
profile: profile.profileName,
|
|
});
|
|
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
|
|
gatewayToken: issued.gameToken,
|
|
});
|
|
gameToken = exchanged.accessToken;
|
|
} catch (error) {
|
|
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
|
}
|
|
}
|
|
const gameTrpc = gameToken ? createGameTrpc(profile.profile, profile.apiPort, gameToken) : publicGameTrpc;
|
|
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
|
gameTrpc.lobby.info.query(),
|
|
gameTrpc.public.getMapLayout.query(),
|
|
gameTrpc.public.getCachedMap.query(),
|
|
]);
|
|
|
|
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,
|
|
};
|
|
}
|
|
});
|
|
|
|
await Promise.all(detailTasks);
|
|
} catch (e) {
|
|
console.error('Failed to load lobby', e);
|
|
}
|
|
});
|
|
|
|
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>
|
|
<table class="w-full 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.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="profile.status === 'STOPPED'">
|
|
<div class="text-center text-zinc-600 py-2">- 폐 쇄 중 -</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="profile.status === 'STOPPED'">
|
|
<span class="text-zinc-700">-</span>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<!-- 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 class="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
|
<span class="font-semibold" :style="{ color: selectedMapProfile.color }">
|
|
{{ selectedMapProfile.korName }}섭
|
|
</span>
|
|
<span>{{ selectedMapProfile.status }}</span>
|
|
</div>
|
|
<div v-if="selectedMapPreview">
|
|
<MapPreview
|
|
:map-data="selectedMapPreview.mapData"
|
|
:map-layout="selectedMapPreview.mapLayout"
|
|
mode="detail"
|
|
/>
|
|
<div
|
|
v-if="profileDetails[selectedMapProfile.profileName]"
|
|
class="text-xs text-zinc-400 mt-2"
|
|
>
|
|
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
|
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} ·
|
|
{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 ·
|
|
{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴
|
|
</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;
|
|
}
|
|
|
|
.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>
|