From fe0232c896c5895363a27e7a5ae51482a28a7539 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 12 Aug 2026 15:55:48 +0000 Subject: [PATCH 1/2] fix(gateway): keep admin sidebar flush while scrolling --- .../e2e/lobby-admin-navigation.spec.ts | 54 ++++++++++++++++++- .../src/layouts/AdminConsoleLayout.vue | 4 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts index 541f3c27..5ef67985 100644 --- a/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts +++ b/app/gateway-frontend/e2e/lobby-admin-navigation.spec.ts @@ -139,7 +139,59 @@ test('bootstrap superuser can navigate the administrator workspace from the lobb await navigation.getByRole('link', { name: 'Gateway 릴리스' }).click(); await expect(page).toHaveURL(/\/gateway\/admin\/releases$/); - await expect(page.getByRole('heading', { name: 'Gateway 릴리스' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Gateway 릴리스', level: 1 })).toBeVisible(); +}); + +test('desktop administrator sidebar follows the navbar away and then sticks to the viewport top', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1200, height: 500 }); + await installGatewayFixture(page, ['superuser']); + await page.goto('admin'); + + const sidebar = page.locator('#admin-navigation'); + await expect(sidebar).toBeVisible(); + const measurements: Array<{ + scrollY: number; + top: number; + bottom: number; + height: number; + viewportHeight: number; + position: string; + backgroundColor: string; + }> = []; + + for (const scrollY of [0, 20, 55, 56, 120]) { + await page.evaluate((top) => window.scrollTo(0, top), scrollY); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY); + + const geometry = await sidebar.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top, + bottom: rect.bottom, + height: rect.height, + viewportHeight: window.innerHeight, + position: style.position, + backgroundColor: style.backgroundColor, + }; + }); + expect(geometry.top).toBeCloseTo(Math.max(0, 56 - scrollY), 0); + expect(geometry.position).toBe('sticky'); + expect(geometry.backgroundColor).toBe('rgb(17, 17, 19)'); + measurements.push({ scrollY, ...geometry }); + + if (scrollY >= 56) { + expect(geometry.bottom).toBeCloseTo(geometry.viewportHeight, 0); + } + + if (scrollY === 20 || scrollY === 56) { + await page.screenshot({ path: testInfo.outputPath(`admin-sidebar-scroll-${scrollY}.png`) }); + } + } + + await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2)); }); test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => { diff --git a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue index 50c4b81f..4cd44cc2 100644 --- a/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue +++ b/app/gateway-frontend/src/layouts/AdminConsoleLayout.vue @@ -201,8 +201,8 @@ onMounted(async () => { .admin-sidebar { position: sticky; - top: 56px; - height: calc(100vh - 56px); + top: 0; + height: 100vh; border-right: 1px solid #27272a; background: #111113; padding: 24px 16px; From 5d4d5009ee7c622290ba4ada0ae7554e8d9a5d53 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 12 Aug 2026 15:56:58 +0000 Subject: [PATCH 2/2] feat(game-ui): add action feedback layer --- app/game-frontend/e2e/board.spec.ts | 47 ++- app/game-frontend/e2e/inGameMenus.spec.ts | 14 +- app/game-frontend/e2e/npcPossession.spec.ts | 34 +- .../e2e/npcPossessionLive.spec.ts | 4 +- .../e2e/selectGeneralLive.spec.ts | 16 +- app/game-frontend/src/App.vue | 2 + .../src/components/ui/GameFeedbackLayer.vue | 354 ++++++++++++++++++ .../src/composables/useGameFeedback.ts | 122 ++++++ app/game-frontend/src/views/BoardView.vue | 6 +- app/game-frontend/src/views/JoinView.vue | 26 +- app/game-frontend/src/views/MyPageView.vue | 14 +- .../src/views/MySettingsView.vue | 8 +- .../src/views/NationPersonnelView.vue | 4 +- .../src/views/SelectGeneralView.vue | 14 +- app/game-frontend/test/gameFeedback.test.ts | 56 +++ 15 files changed, 671 insertions(+), 50 deletions(-) create mode 100644 app/game-frontend/src/components/ui/GameFeedbackLayer.vue create mode 100644 app/game-frontend/src/composables/useGameFeedback.ts create mode 100644 app/game-frontend/test/gameFeedback.test.ts diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts index ca1ea528..0a027abf 100644 --- a/app/game-frontend/e2e/board.spec.ts +++ b/app/game-frontend/e2e/board.spec.ts @@ -392,7 +392,7 @@ test('uses the ref 500px responsive form widths', async ({ page }) => { await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible(); }); -test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }) => { +test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => { const state: BoardFixture = { permission: 2, canMeeting: true, @@ -407,25 +407,54 @@ test('retains article and comment input after a failed mutation, then reloads af await page.locator('#board-title').fill('새 제목'); await page.locator('#board-content').fill('새 내용'); - page.once('dialog', async (dialog) => { - expect(dialog.message()).toBe('실패했습니다. :접속 제한입니다.'); - await dialog.accept(); - }); await page.locator('#submitArticle').click(); + const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); + await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error'); + await expect(articleToast).toHaveAttribute('role', 'alert'); await expect(page.locator('#board-title')).toHaveValue('새 제목'); await expect(page.locator('#board-content')).toHaveValue('새 내용'); + const desktopToastGeometry = await articleToast.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth }; + }); + expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0); + expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth); + expect(desktopToastGeometry.top).toBeGreaterThanOrEqual(0); + expect(desktopToastGeometry.width).toBeGreaterThan(250); + await page.screenshot({ path: testInfo.outputPath('game-toast-desktop.png'), fullPage: true }); + await articleToast.getByRole('button', { name: '알림 닫기' }).click(); + await expect(articleToast).toHaveCount(0); + await page.locator('#submitArticle').click(); await expect(page.getByText('새 제목', { exact: true })).toBeVisible(); await expect(page.locator('#board-title')).toHaveValue(''); const commentInput = page.locator('.comment-input').first(); await commentInput.fill('새 댓글'); - page.once('dialog', async (dialog) => { - expect(dialog.message()).toBe('실패했습니다: 접속 제한입니다.'); - await dialog.accept(); - }); + await page.setViewportSize({ width: 390, height: 844 }); + const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth); await commentInput.press('Enter'); + const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); + await expect(commentToast).toBeVisible(); + await expect + .poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)) + .toBeGreaterThanOrEqual(0); + const mobileToastGeometry = await commentToast.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: window.innerWidth - rect.right, + bottom: window.innerHeight - rect.bottom, + viewportWidth: window.innerWidth, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileToastGeometry.left).toBeGreaterThanOrEqual(0); + expect(mobileToastGeometry.right).toBeGreaterThanOrEqual(0); + expect(mobileToastGeometry.bottom).toBeGreaterThanOrEqual(0); + expect(mobileToastGeometry.documentWidth).toBe(documentWidthBeforeToast); + await page.screenshot({ path: testInfo.outputPath('game-toast-mobile.png') }); await expect(commentInput).toHaveValue('새 댓글'); await commentInput.press('Enter'); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 33ceb619..3db03da2 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -838,9 +838,7 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 await instantRetreatButton.click(); await expect.poll(() => state.instantRetreatInputs?.length).toBe(1); - await expect - .poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.'))) - .toBe(true); + await expect(page.getByTestId('game-toast')).toContainText('요청 처리 결과를 확인하지 못했습니다.'); await expect.poll(() => state.generalMeQueries).toBe(2); await expect.poll(() => state.ensurePrestartQueries).toBe(2); @@ -948,13 +946,9 @@ test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 time const timeoutReload = page.waitForEvent('framenavigated', (frame) => frame === page.mainFrame()); await deleteButton.click(); await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(1); - await expect - .poll(() => - dialogs.some((message) => - message.includes('alert:실패했습니다: 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.') - ) - ) - .toBe(true); + const failureDialog = page.getByRole('alertdialog', { name: '장수 삭제 실패' }); + await expect(failureDialog).toContainText('요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.'); + await failureDialog.getByRole('button', { name: '확인' }).click(); await timeoutReload; await page.waitForLoadState('networkidle'); await expect(deleteButton).toBeVisible(); diff --git a/app/game-frontend/e2e/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts index 727016da..8a035639 100644 --- a/app/game-frontend/e2e/npcPossession.spec.ts +++ b/app/game-frontend/e2e/npcPossession.spec.ts @@ -106,6 +106,13 @@ const installFixture = async (page: Page, state: FixtureState): Promise => body: '', }); }); + await page.route('https://sam-image.hided.net/icons/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }); + }); await page.route('**/gateway/api/user-icons/default.jpg', async (route) => { await route.fulfill({ status: 200, @@ -265,7 +272,10 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses await tooltip.focus(); await expect(tooltip).toBeFocused(); await expect(tooltipPopup).toHaveText('안전을 중시합니다.'); - await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg'); + await expect(page.locator('.npc-card-image').first()).toHaveAttribute( + 'src', + 'https://sam-image.hided.net/icons/default.jpg' + ); await page.locator('#btn-load-general-list').click(); await expect(page.locator('#tb-general-list')).toBeVisible(); @@ -347,11 +357,31 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses await expect(page.locator('.npc-token-expired')).toBeVisible(); await expect(possessButton).toBeEnabled(); await expect(refreshButton).toBeDisabled(); + await page.setViewportSize({ width: 390, height: 844 }); + const documentWidthBeforeDialog = await page.evaluate(() => document.documentElement.scrollWidth); await page.locator('#btn-retry-possession').click(); + const successDialog = page.getByRole('alertdialog', { name: '완료' }); + await expect(successDialog).toContainText('빙의에 성공했습니다.'); + await expect(successDialog.getByRole('button', { name: '확인' })).toBeFocused(); + const dialogGeometry = await successDialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: window.innerWidth - rect.right, + bottom: window.innerHeight - rect.bottom, + width: rect.width, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(dialogGeometry.left).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.right).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.bottom).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.documentWidth).toBe(documentWidthBeforeDialog); + await page.screenshot({ path: testInfo.outputPath('game-notice-dialog.png') }); + await successDialog.getByRole('button', { name: '확인' }).click(); await expect(page).toHaveURL(new RegExp(`${basePath}/$`)); expect(state.possessInputs).toHaveLength(2); expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId); - expect(dialogs).toContain('빙의에 성공했습니다.'); expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull(); await page.screenshot({ diff --git a/app/game-frontend/e2e/npcPossessionLive.spec.ts b/app/game-frontend/e2e/npcPossessionLive.spec.ts index 197cf285..053e9147 100644 --- a/app/game-frontend/e2e/npcPossessionLive.spec.ts +++ b/app/game-frontend/e2e/npcPossessionLive.spec.ts @@ -216,13 +216,15 @@ test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and C await startDaemon(); await possessButton.click(); + const successDialog = page.getByRole('alertdialog', { name: '완료' }); + await expect(successDialog).toContainText('빙의에 성공했습니다.'); + await successDialog.getByRole('button', { name: '확인' }).click(); await expect(page).toHaveURL(/\/hwe\/$/); expect(requestIds).toHaveLength(2); expect(requestIds[1]).toBe(requestIds[0]); expect(await db.general.count({ where: { userId } })).toBe(1); await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).resolves.toBeNull(); expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull(); - expect(dialogs).toContain('빙의에 성공했습니다.'); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId: eventRequestId }, }); diff --git a/app/game-frontend/e2e/selectGeneralLive.spec.ts b/app/game-frontend/e2e/selectGeneralLive.spec.ts index d52f7825..b2daf9d8 100644 --- a/app/game-frontend/e2e/selectGeneralLive.spec.ts +++ b/app/game-frontend/e2e/selectGeneralLive.spec.ts @@ -406,8 +406,8 @@ test.describe('scenario 903 live selection pool', () => { await expect(page.locator('.selected-card')).toHaveCount(1); await page.locator('.custom-form select').selectOption('che_안전'); await page.locator('#build-general').click(); - await expect.poll(() => dialogs).toContain( - '실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.' + await expect(page.getByTestId('game-toast')).toContainText( + '장수 생성에 실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.' ); await waitForPool(page); const retryCard = page @@ -417,9 +417,11 @@ test.describe('scenario 903 live selection pool', () => { await retryCard.locator('.select-button').click(); await page.locator('.custom-form select').selectOption('che_안전'); await page.locator('#build-general').click(); + const createDialog = page.getByRole('alertdialog', { name: '완료' }); + await expect(createDialog).toContainText('선택한 장수로 생성했습니다.'); + await createDialog.getByRole('button', { name: '확인' }).click(); await expect(page).toHaveURL(/\/hwe\/$/); expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2); - await expect.poll(() => dialogs).toContain('선택한 장수로 생성했습니다.'); expect(createClientRequestIds).toHaveLength(2); expect(createClientRequestIds[1]).toBe(createClientRequestIds[0]); @@ -452,7 +454,9 @@ test.describe('scenario 903 live selection pool', () => { dialogs.length = 0; await page.goto('select-general'); - await expect.poll(() => dialogs).toContain('실패했습니다: 아직 다시 고를 수 없습니다'); + await expect(page.getByTestId('game-toast')).toContainText( + '장수 선택 정보를 불러오지 못했습니다: 아직 다시 고를 수 없습니다' + ); await expect(page.locator('.error-text')).toHaveText('아직 다시 고를 수 없습니다'); const availableAt = '2026-07-29T00:00:00.000Z'; @@ -495,9 +499,11 @@ test.describe('scenario 903 live selection pool', () => { expect(targetIndex).toBeGreaterThanOrEqual(0); const targetName = names[targetIndex]!.trim(); await cards.nth(targetIndex).locator('.select-button').click(); + const reselectDialog = page.getByRole('alertdialog', { name: '완료' }); + await expect(reselectDialog).toContainText('선택한 장수로 변경했습니다.'); + await reselectDialog.getByRole('button', { name: '확인' }).click(); await expect(page).toHaveURL(/\/hwe\/$/); await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`); - await expect.poll(() => dialogs).toContain('선택한 장수로 변경했습니다.'); await expect .poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name) diff --git a/app/game-frontend/src/App.vue b/app/game-frontend/src/App.vue index 27ba3b60..6baf484f 100644 --- a/app/game-frontend/src/App.vue +++ b/app/game-frontend/src/App.vue @@ -1,9 +1,11 @@ diff --git a/app/game-frontend/src/composables/useGameFeedback.ts b/app/game-frontend/src/composables/useGameFeedback.ts new file mode 100644 index 00000000..c6405e59 --- /dev/null +++ b/app/game-frontend/src/composables/useGameFeedback.ts @@ -0,0 +1,122 @@ +import { readonly, ref } from 'vue'; + +export type GameFeedbackKind = 'success' | 'error' | 'info'; + +export type GameToast = { + id: number; + kind: GameFeedbackKind; + message: string; +}; + +export type GameNoticeDialog = { + id: number; + kind: GameFeedbackKind; + title: string; + message: string; + acknowledgeLabel: string; +}; + +export type GameNoticeDialogOptions = { + kind?: GameFeedbackKind; + title?: string; + message: string; + acknowledgeLabel?: string; +}; + +type QueuedDialog = { + dialog: GameNoticeDialog; + resolve: () => void; +}; + +const titleFor = (kind: GameFeedbackKind): string => { + if (kind === 'success') return '완료'; + if (kind === 'error') return '처리 실패'; + return '안내'; +}; + +export const createGameFeedbackStore = () => { + const visibleToasts = ref([]); + const activeDialog = ref(null); + const dismissTimers = new Map>(); + const dialogQueue: QueuedDialog[] = []; + let activeDialogResolve: (() => void) | null = null; + let nextId = 1; + + const dismissToast = (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 showToast = (message: string, kind: GameFeedbackKind = '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) dismissToast(duplicate.id); + + const id = nextId++; + visibleToasts.value = [...visibleToasts.value.slice(-3), { id, kind, message: normalizedMessage }]; + if (durationMs > 0) { + dismissTimers.set(id, setTimeout(() => dismissToast(id), durationMs)); + } + return id; + }; + + const activateNextDialog = (): void => { + const next = dialogQueue.shift(); + if (!next) { + activeDialog.value = null; + activeDialogResolve = null; + return; + } + activeDialogResolve = next.resolve; + activeDialog.value = next.dialog; + }; + + const showDialog = (options: GameNoticeDialogOptions): Promise => { + const message = options.message.trim(); + if (!message) return Promise.resolve(); + const kind = options.kind ?? 'info'; + return new Promise((resolve) => { + dialogQueue.push({ + dialog: { + id: nextId++, + kind, + title: options.title?.trim() || titleFor(kind), + message, + acknowledgeLabel: options.acknowledgeLabel?.trim() || '확인', + }, + resolve, + }); + if (!activeDialog.value) activateNextDialog(); + }); + }; + + const acknowledgeDialog = (): void => { + const resolve = activeDialogResolve; + activeDialog.value = null; + activeDialogResolve = null; + resolve?.(); + activateNextDialog(); + }; + + return { + toasts: readonly(visibleToasts), + dialog: readonly(activeDialog), + showToast, + success: (message: string, durationMs?: number) => showToast(message, 'success', durationMs), + error: (message: string, durationMs?: number) => showToast(message, 'error', durationMs), + info: (message: string, durationMs?: number) => showToast(message, 'info', durationMs), + showDialog, + acknowledgeDialog, + dismissToast, + }; +}; + +const gameFeedbackStore = createGameFeedbackStore(); + +export const useGameFeedback = () => gameFeedbackStore; diff --git a/app/game-frontend/src/views/BoardView.vue b/app/game-frontend/src/views/BoardView.vue index f08692f2..1622bb91 100644 --- a/app/game-frontend/src/views/BoardView.vue +++ b/app/game-frontend/src/views/BoardView.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; +import { useGameFeedback } from '../composables/useGameFeedback'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { trpc } from '../utils/trpc'; @@ -9,6 +10,7 @@ type BoardArticle = Awaited>[num const route = useRoute(); const router = useRouter(); +const { error: showErrorToast } = useGameFeedback(); const isSecretBoard = computed(() => route.name === 'board-secret'); const title = computed(() => (isSecretBoard.value ? '기밀실' : '회의실')); const closeBoard = () => router.push('/'); @@ -92,7 +94,7 @@ const submitArticle = async () => { resizeTextArea(articleTextArea.value); await refreshArticles(); } catch (error) { - window.alert(`실패했습니다. :${errorText(error, '게시물 등록에 실패했습니다.')}`); + showErrorToast(`게시물 등록에 실패했습니다: ${errorText(error, '게시물 등록에 실패했습니다.')}`); } }; @@ -106,7 +108,7 @@ const submitComment = async (postId: number) => { commentDrafts[postId] = ''; await refreshArticles(); } catch (error) { - window.alert(`실패했습니다: ${errorText(error, '댓글 등록에 실패했습니다.')}`); + showErrorToast(`댓글 등록에 실패했습니다: ${errorText(error, '댓글 등록에 실패했습니다.')}`); } }; diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 384053f7..ee5eac72 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -1,6 +1,7 @@