Merge branch 'main' into feat/special-account-access-20260808
This commit is contained in:
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
'gateway-notice-html.spec.ts',
|
'gateway-notice-html.spec.ts',
|
||||||
'kakao-otp.spec.ts',
|
'kakao-otp.spec.ts',
|
||||||
'kakao-account-recovery.spec.ts',
|
'kakao-account-recovery.spec.ts',
|
||||||
|
'public-map-tabs.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const operationNames = (route: Route): string[] => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfileFixture = {
|
||||||
|
profileName: string;
|
||||||
|
profile: string;
|
||||||
|
korName: string;
|
||||||
|
color: string;
|
||||||
|
status: 'RUNNING' | 'PREOPEN' | 'STOPPED';
|
||||||
|
apiPort: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const profiles: ProfileFixture[] = [
|
||||||
|
{
|
||||||
|
profileName: 'che:2',
|
||||||
|
profile: 'che',
|
||||||
|
korName: '체',
|
||||||
|
color: '#ff8080',
|
||||||
|
status: 'RUNNING',
|
||||||
|
apiPort: 15003,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileName: 'hwe:2',
|
||||||
|
profile: 'hwe',
|
||||||
|
korName: '훼',
|
||||||
|
color: '#80c0ff',
|
||||||
|
status: 'PREOPEN',
|
||||||
|
apiPort: 15015,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileName: 'kwe:2',
|
||||||
|
profile: 'kwe',
|
||||||
|
korName: '퀘',
|
||||||
|
color: '#b0b0b0',
|
||||||
|
status: 'STOPPED',
|
||||||
|
apiPort: 15005,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fulfill = async (route: Route, results: unknown[]): Promise<void> => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(results),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const installGatewayFixture = async (page: Page, fixtureProfiles: ProfileFixture[], authenticated: boolean) => {
|
||||||
|
if (authenticated) {
|
||||||
|
await page.addInitScript(() => window.localStorage.setItem('sammo-session-token', 'map-tab-session'));
|
||||||
|
}
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'me') {
|
||||||
|
return response(
|
||||||
|
authenticated
|
||||||
|
? {
|
||||||
|
id: 'map-tab-user',
|
||||||
|
username: 'map-tab-user',
|
||||||
|
displayName: '지도 탭 사용자',
|
||||||
|
roles: [],
|
||||||
|
kakaoVerified: true,
|
||||||
|
createdAt: '2026-08-08T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation === 'lobby.notice') return response('');
|
||||||
|
if (operation === 'lobby.profiles') return response(fixtureProfiles);
|
||||||
|
if (operation === 'auth.issueGameSession') {
|
||||||
|
const body = route.request().postData() ?? '';
|
||||||
|
const selected = fixtureProfiles.find((profile) => body.includes(profile.profileName));
|
||||||
|
return response({
|
||||||
|
profile: selected?.profileName ?? fixtureProfiles[0]?.profileName,
|
||||||
|
gameToken: 'map-tab-game-token',
|
||||||
|
expiresAt: '2026-08-08T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled gateway operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await fulfill(route, results);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const installGameFixture = async (page: Page, profile: ProfileFixture, userCnt: number) => {
|
||||||
|
await page.route(`**/${profile.profile}/api/trpc/**`, async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'auth.exchangeGatewayToken') {
|
||||||
|
return response({
|
||||||
|
accessToken: `${profile.profile}-access-token`,
|
||||||
|
profile: profile.profileName,
|
||||||
|
expiresAt: '2026-08-08T01:00:00.000Z',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'lobby.info') {
|
||||||
|
return response({
|
||||||
|
year: 200,
|
||||||
|
month: profile.profile === 'che' ? 1 : 2,
|
||||||
|
userCnt,
|
||||||
|
maxUserCnt: 500,
|
||||||
|
npcCnt: 0,
|
||||||
|
nationCnt: profile.profile === 'che' ? 2 : 3,
|
||||||
|
turnTerm: 10,
|
||||||
|
fictionMode: '가상',
|
||||||
|
starttime: '2026-08-08 00:00:00',
|
||||||
|
opentime: '2026-08-08 00:00:00',
|
||||||
|
turntime: '2026-08-08 00:10:00',
|
||||||
|
otherTextInfo: '',
|
||||||
|
isUnited: 0,
|
||||||
|
selectionPoolEnabled: true,
|
||||||
|
npcPossessionEnabled: false,
|
||||||
|
myGeneral: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'public.getMapLayout') return response({ mapName: profile.profile, cityList: [] });
|
||||||
|
if (operation === 'public.getCachedMap') {
|
||||||
|
return response({ year: 200, month: 1, cityList: [], nationList: [], history: [] });
|
||||||
|
}
|
||||||
|
throw new Error(`Unhandled ${profile.profile} operation: ${operation}`);
|
||||||
|
});
|
||||||
|
await fulfill(route, results);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shows one public map panel and switches it by hover, click, and keyboard', async ({ page }, testInfo) => {
|
||||||
|
await installGatewayFixture(page, profiles, true);
|
||||||
|
await installGameFixture(page, profiles[0]!, 11);
|
||||||
|
await installGameFixture(page, profiles[1]!, 22);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('lobby');
|
||||||
|
|
||||||
|
const tablist = page.getByRole('tablist', { name: '공개 지도 서버 선택' });
|
||||||
|
const cheTab = tablist.getByRole('tab', { name: '체섭' });
|
||||||
|
const hweTab = tablist.getByRole('tab', { name: '훼섭' });
|
||||||
|
const panel = page.getByTestId('public-map-preview-panel');
|
||||||
|
await expect(tablist.getByRole('tab')).toHaveCount(2);
|
||||||
|
await expect(tablist.getByRole('tab', { name: '퀘섭' })).toHaveCount(0);
|
||||||
|
await expect(panel).toHaveCount(1);
|
||||||
|
await expect(cheTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(panel).toContainText('유저 11 / 500');
|
||||||
|
|
||||||
|
await hweTab.hover();
|
||||||
|
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(panel).toContainText('유저 22 / 500');
|
||||||
|
|
||||||
|
await cheTab.click();
|
||||||
|
await expect(cheTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await cheTab.press('ArrowRight');
|
||||||
|
await expect(hweTab).toBeFocused();
|
||||||
|
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
const geometry = await panel.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
borderLeft: style.borderLeft,
|
||||||
|
borderTopWidth: style.borderTopWidth,
|
||||||
|
backgroundColor: style.backgroundColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBeLessThanOrEqual(992);
|
||||||
|
expect(geometry.borderLeft).toBe('1px solid rgb(63, 63, 70)');
|
||||||
|
expect(geometry.borderTopWidth).toBe('0px');
|
||||||
|
expect(geometry.backgroundColor).toBe('rgba(9, 9, 11, 0.498)');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true });
|
||||||
|
await testInfo.attach('public-map-tabs-desktop-geometry', {
|
||||||
|
body: Buffer.from(`${JSON.stringify(geometry, null, 2)}\n`),
|
||||||
|
contentType: 'application/json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('touch navigation', () => {
|
||||||
|
test.use({ hasTouch: true });
|
||||||
|
|
||||||
|
test('switches the single map panel by touch-sized tab on mobile', async ({ page }, testInfo) => {
|
||||||
|
await installGatewayFixture(page, profiles, true);
|
||||||
|
await installGameFixture(page, profiles[0]!, 11);
|
||||||
|
await installGameFixture(page, profiles[1]!, 22);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('lobby');
|
||||||
|
const hweTab = page.getByRole('tab', { name: '훼섭' });
|
||||||
|
const box = await hweTab.boundingBox();
|
||||||
|
expect(box?.height).toBeGreaterThanOrEqual(34);
|
||||||
|
await hweTab.tap();
|
||||||
|
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.getByTestId('public-map-preview-panel')).toContainText('유저 22 / 500');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('public-map-tabs-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats an all-closed profile list as a normal empty login status', async ({ page }, testInfo) => {
|
||||||
|
let gameRequestCount = 0;
|
||||||
|
await installGatewayFixture(page, [profiles[2]!], false);
|
||||||
|
await page.route('**/kwe/api/trpc/**', async (route) => {
|
||||||
|
gameRequestCount += 1;
|
||||||
|
await route.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/gateway/');
|
||||||
|
const status = page.locator('#map-subframe');
|
||||||
|
await expect(status).toContainText('서버 현황');
|
||||||
|
await expect(status).toContainText('현재 공개 중인 서버가 없습니다.');
|
||||||
|
await expect(status).not.toContainText('Failed to fetch');
|
||||||
|
expect(gameRequestCount).toBe(0);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('login-no-public-server.png'), fullPage: true });
|
||||||
|
});
|
||||||
@@ -33,7 +33,7 @@ const info = ref<LobbyInfo | null>(null);
|
|||||||
const mapData = ref<PublicMap | null>(null);
|
const mapData = ref<PublicMap | null>(null);
|
||||||
const mapLayout = ref<PublicMapLayout | null>(null);
|
const mapLayout = ref<PublicMapLayout | null>(null);
|
||||||
|
|
||||||
const statusTitle = computed(() => `${profile.value?.korName ?? '체'} 현황`);
|
const statusTitle = computed(() => (profile.value ? `${profile.value.korName} 현황` : '서버 현황'));
|
||||||
const dateText = computed(() => {
|
const dateText = computed(() => {
|
||||||
if (!info.value) {
|
if (!info.value) {
|
||||||
return '';
|
return '';
|
||||||
@@ -45,15 +45,18 @@ const seasonStatus = computed(() => (info.value ? resolveServerSeasonStatus(info
|
|||||||
const loadPublicStatus = async (): Promise<void> => {
|
const loadPublicStatus = async (): Promise<void> => {
|
||||||
statusLoading.value = true;
|
statusLoading.value = true;
|
||||||
statusError.value = '';
|
statusError.value = '';
|
||||||
|
profile.value = null;
|
||||||
|
info.value = null;
|
||||||
|
mapData.value = null;
|
||||||
|
mapLayout.value = null;
|
||||||
try {
|
try {
|
||||||
const profiles = await trpc.lobby.profiles.query();
|
const profiles = await trpc.lobby.profiles.query();
|
||||||
profile.value =
|
profile.value =
|
||||||
profiles.find((entry) => entry.status === 'RUNNING') ??
|
profiles.find((entry) => entry.status === 'RUNNING') ??
|
||||||
profiles.find((entry) => entry.status === 'PREOPEN') ??
|
profiles.find((entry) => entry.status === 'PREOPEN') ??
|
||||||
profiles[0] ??
|
|
||||||
null;
|
null;
|
||||||
if (!profile.value) {
|
if (!profile.value) {
|
||||||
statusError.value = '공개 중인 서버가 없습니다.';
|
statusError.value = '현재 공개 중인 서버가 없습니다.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const game = createGameTrpc(profile.value.profile, profile.value.apiPort);
|
const game = createGameTrpc(profile.value.profile, profile.value.apiPort);
|
||||||
@@ -65,8 +68,8 @@ const loadPublicStatus = async (): Promise<void> => {
|
|||||||
info.value = nextInfo;
|
info.value = nextInfo;
|
||||||
mapLayout.value = nextLayout;
|
mapLayout.value = nextLayout;
|
||||||
mapData.value = nextMap;
|
mapData.value = nextMap;
|
||||||
} catch (error) {
|
} catch {
|
||||||
statusError.value = error instanceof Error ? error.message : '서버 현황을 불러오지 못했습니다.';
|
statusError.value = '서버 현황을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.';
|
||||||
} finally {
|
} finally {
|
||||||
statusLoading.value = false;
|
statusLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue';
|
import { computed, ref, onMounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import type { inferRouterOutputs } from '@trpc/server';
|
import type { inferRouterOutputs } from '@trpc/server';
|
||||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||||
@@ -30,6 +30,7 @@ const notice = ref('');
|
|||||||
const profiles = ref<LobbyProfile[]>([]);
|
const profiles = ref<LobbyProfile[]>([]);
|
||||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||||
|
const selectedMapProfileName = ref<string | null>(null);
|
||||||
const entryLoading = ref<Record<string, boolean>>({});
|
const entryLoading = ref<Record<string, boolean>>({});
|
||||||
const logoutLoading = ref(false);
|
const logoutLoading = ref(false);
|
||||||
const logoutError = ref('');
|
const logoutError = ref('');
|
||||||
@@ -43,6 +44,46 @@ const canAccessAdmin = computed(
|
|||||||
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
||||||
const userIconBaseUrl = configuredUserIconPublicUrl();
|
const userIconBaseUrl = configuredUserIconPublicUrl();
|
||||||
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
|
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 =>
|
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||||
@@ -479,34 +520,63 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
>
|
>
|
||||||
공개 지도 미리보기
|
공개 지도 미리보기
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="p-4">
|
||||||
<div
|
<div
|
||||||
v-for="profile in profiles"
|
v-if="publicMapProfiles.length"
|
||||||
:key="profile.profileName"
|
class="map-preview-tabs"
|
||||||
class="border border-zinc-800 rounded bg-zinc-950/50 p-3"
|
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">
|
<div class="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
||||||
<span class="font-semibold" :style="{ color: profile.color }">
|
<span class="font-semibold" :style="{ color: selectedMapProfile.color }">
|
||||||
{{ profile.korName }}섭
|
{{ selectedMapProfile.korName }}섭
|
||||||
</span>
|
</span>
|
||||||
<span>{{ profile.status }}</span>
|
<span>{{ selectedMapProfile.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="profile.status === 'RUNNING' || profile.status === 'PREOPEN'">
|
<div v-if="selectedMapPreview">
|
||||||
<div v-if="profileMapPreviews[profile.profileName]">
|
<MapPreview
|
||||||
<MapPreview
|
:map-data="selectedMapPreview.mapData"
|
||||||
:map-data="profileMapPreviews[profile.profileName]!.mapData"
|
:map-layout="selectedMapPreview.mapLayout"
|
||||||
:map-layout="profileMapPreviews[profile.profileName]!.mapLayout"
|
/>
|
||||||
/>
|
<div v-if="profileDetails[selectedMapProfile.profileName]" class="text-xs text-zinc-400 mt-2">
|
||||||
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-400 mt-2">
|
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
||||||
유저 {{ profileDetails[profile.profileName]?.userCnt ?? '-' }} /
|
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} ·
|
||||||
{{ profileDetails[profile.profileName]?.maxUserCnt ?? '-' }} ·
|
{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 ·
|
||||||
{{ profileDetails[profile.profileName]?.nationCnt ?? '-' }}국 ·
|
{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||||
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,6 +636,53 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
padding: 10px;
|
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:hover,
|
||||||
.legacy-logout-button:focus,
|
.legacy-logout-button:focus,
|
||||||
.legacy-logout-button:active {
|
.legacy-logout-button:active {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
|
||||||
|
|
||||||
export type PostgresLogLevel = 'query' | 'info' | 'warn' | 'error';
|
export type PostgresLogLevel = 'query' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
@@ -89,11 +88,13 @@ export const createPostgresConnector = <TClient>(
|
|||||||
): PostgresConnector<TClient> => {
|
): PostgresConnector<TClient> => {
|
||||||
const schema =
|
const schema =
|
||||||
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
|
extractSchemaFromDatabaseUrl(config.url) ?? process.env.POSTGRES_SCHEMA ?? process.env.DATABASE_SCHEMA;
|
||||||
const pool = new Pool({
|
const adapter = new PrismaPg(
|
||||||
connectionString: config.url,
|
{
|
||||||
...(schema ? { options: `-c search_path=${schema}` } : {}),
|
connectionString: config.url,
|
||||||
});
|
...(schema ? { options: `-c search_path=${schema}` } : {}),
|
||||||
const adapter = new PrismaPg(pool, schema ? { schema } : undefined);
|
},
|
||||||
|
schema ? { schema } : undefined
|
||||||
|
);
|
||||||
const prisma = createClient({
|
const prisma = createClient({
|
||||||
adapter,
|
adapter,
|
||||||
log: config.log,
|
log: config.log,
|
||||||
@@ -102,9 +103,6 @@ export const createPostgresConnector = <TClient>(
|
|||||||
return {
|
return {
|
||||||
prisma,
|
prisma,
|
||||||
connect: () => (prisma as { $connect: () => Promise<void> }).$connect(),
|
connect: () => (prisma as { $connect: () => Promise<void> }).$connect(),
|
||||||
disconnect: async () => {
|
disconnect: () => (prisma as { $disconnect: () => Promise<void> }).$disconnect(),
|
||||||
await (prisma as { $disconnect: () => Promise<void> }).$disconnect();
|
|
||||||
await pool.end();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user