feat(frontend): prioritize general creation inputs

This commit is contained in:
2026-08-08 17:12:11 +00:00
parent 0ef8b5ea20
commit b78aaade1f
4 changed files with 792 additions and 92 deletions
+237
View File
@@ -0,0 +1,237 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile } from './gameTestPaths.js';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
type FixtureState = {
mapRequests: number;
generalRequests: number;
};
const installFixture = async (page: Page, state: FixtureState): Promise<void> => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_join_layout');
localStorage.setItem('sammo-game-profile', profile);
}, gameProfile);
await page.route('**/events**', async (route) => route.abort());
await page.route('**/image/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="700" height="500"><rect width="700" height="500" fill="#283c2c"/></svg>',
});
});
await page.route(`**${gameBasePath}/api/trpc/**`, async (route) => {
const operations = operationNames(route);
const results = operations.map((operation) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') {
return response({ myGeneral: null, year: 180, month: 4, turnTerm: 5 });
}
if (operation === 'join.getConfig') {
return response({
rules: {
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
allowCustomName: true,
},
user: {
id: 'join-layout-user',
displayName: '생성장수',
canCreateGeneral: true,
icons: [],
preferredPicture: 'default.jpg',
},
personalities: [
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
{ key: 'che_대담', name: '대담', info: '과감한 행동을 선호합니다.' },
],
warSpecials: [{ key: 'che_무쌍', name: '무쌍', info: '전투 특기' }],
nations: [
{ id: 1, name: '촉', color: '#66aa44', scoutMessage: '함께 천하를 도모합시다.' },
{ id: 2, name: '위', color: '#5577bb', scoutMessage: '능력 있는 장수를 기다립니다.' },
],
serverInfo: {
currentYear: 180,
currentMonth: 4,
tickMinutes: 5,
maxGeneral: 500,
userGeneralCount: 2,
npcGeneralCount: 1,
},
inherit: {
totalPoint: 30,
costs: {
inheritBornSpecialPoint: 10,
inheritBornTurntimePoint: 5,
inheritBornCityPoint: 5,
inheritBornStatPoint: 10,
},
availableCities: [{ id: 1, name: '성도', level: 4, region: 5 }],
turnTimeZones: ['00분', '05분'],
availableSpecialWar: [{ key: 'che_무쌍', name: '무쌍', info: '전투 특기' }],
},
selectionPool: { enabled: false, hasGeneral: false },
npcPossession: { enabled: true },
});
}
if (operation === 'public.getCachedMap') {
state.mapRequests += 1;
return response({
year: 180,
month: 4,
startYear: 180,
cityList: [[1, 4, 0, 1, 5, 1]],
nationList: [[1, '촉', '#66aa44', 1]],
myCity: null,
myNation: null,
history: [],
});
}
if (operation === 'public.getMapLayout') {
state.mapRequests += 1;
return response({
mapName: 'che',
cityList: [{ id: 1, name: '성도', level: 4, region: 5, x: 100, y: 100, path: [] }],
regionMap: { 5: '익주' },
levelMap: { 4: '대도시' },
});
}
if (operation === 'public.getGeneralList') {
state.generalRequests += 1;
return response([
{
id: 1,
name: '유비',
npcState: 0,
nationId: 1,
nationName: '촉',
leadership: 72,
strength: 67,
intelligence: 76,
},
{
id: 2,
name: '조조',
npcState: 0,
nationId: 2,
nationName: '위',
leadership: 78,
strength: 62,
intelligence: 80,
},
{
id: 3,
name: '황건장수',
npcState: 2,
nationId: 0,
nationName: '무주',
leadership: 55,
strength: 65,
intelligence: 45,
},
]);
}
return response({});
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(operations.length === 1 ? results[0] : results),
});
});
};
test('prioritizes core general fields and keeps context and inheritance progressive', async ({ page }, testInfo) => {
const state: FixtureState = { mapRequests: 0, generalRequests: 0 };
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('join');
const flow = page.locator('.join-flow');
const basicPanel = flow.locator('.panel-card').first();
const advanced = flow.locator('.advanced-options');
const contextPanel = flow.locator('.panel-card').nth(1);
await expect(page.getByRole('heading', { name: '장수 기본 정보' })).toBeVisible();
await expect(page.getByLabel('장수명')).toHaveValue('생성장수');
await expect(page.getByLabel('성격')).toBeVisible();
await expect(page.locator('.create-form').getByLabel('통솔')).toHaveValue('55');
await expect(advanced).not.toHaveAttribute('open');
await expect(page.getByText('전투 특기 선택')).toBeHidden();
expect(state.mapRequests).toBe(0);
expect(state.generalRequests).toBe(0);
const geometry = await flow.evaluate((element) => {
const basic = element.querySelector<HTMLElement>('.panel-card');
const advancedOptions = element.querySelector<HTMLElement>('.advanced-options');
const context = element.querySelectorAll<HTMLElement>('.panel-card')[1];
const rect = element.getBoundingClientRect();
return {
width: rect.width,
left: rect.left,
basicTop: basic?.getBoundingClientRect().top,
advancedTop: advancedOptions?.getBoundingClientRect().top,
contextTop: context?.getBoundingClientRect().top,
};
});
expect(geometry.width).toBe(1000);
expect(geometry.left).toBe(100);
expect(geometry.basicTop).toBeLessThan(geometry.advancedTop ?? 0);
expect(geometry.advancedTop).toBeLessThan(geometry.contextTop ?? 0);
await expect(basicPanel).toBeVisible();
await expect(contextPanel).toBeVisible();
await advanced.locator('summary').click();
await expect(advanced).toHaveAttribute('open');
await expect(page.getByText('전투 특기 선택')).toBeVisible();
await page.getByLabel('전투 특기 선택').selectOption('che_무쌍');
await expect(advanced.locator('.advanced-point-summary')).toContainText('사용 10');
const mapTab = page.getByRole('tab', { name: '현재 지도' });
await mapTab.click();
await expect(mapTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#context-panel-map .map-area')).toBeVisible();
expect(state.mapRequests).toBe(2);
const generalTab = page.getByRole('tab', { name: '장수 목록' });
await generalTab.click();
await expect(page.locator('.context-general-table tbody tr')).toHaveCount(3);
expect(state.generalRequests).toBe(1);
await page.getByPlaceholder('장수명 또는 국가 검색').fill('촉');
await expect(page.locator('.context-general-table tbody tr')).toHaveCount(1);
await expect(page.locator('.context-general-table')).toContainText('유비');
await generalTab.focus();
await expect(generalTab).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('join-layout-desktop.png'), fullPage: true });
});
test('keeps the primary creation flow readable without horizontal overflow on mobile', async ({ page }, testInfo) => {
const state: FixtureState = { mapRequests: 0, generalRequests: 0 };
await installFixture(page, state);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('join');
await expect(page.getByRole('heading', { name: '장수 기본 정보' })).toBeVisible();
const mobileGeometry = await page.evaluate(() => {
const flow = document.querySelector<HTMLElement>('.join-flow');
const tabs = document.querySelector<HTMLElement>('.context-tabs');
return {
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
flowWidth: flow?.getBoundingClientRect().width,
tabsWidth: tabs?.getBoundingClientRect().width,
};
});
expect(mobileGeometry).toEqual({
viewportWidth: 390,
documentWidth: 390,
flowWidth: 366,
tabsWidth: 352,
});
await expect(page.locator('.advanced-options')).not.toHaveAttribute('open');
await page.getByRole('tab', { name: '임관 권유' }).focus();
await expect(page.getByRole('tab', { name: '임관 권유' })).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('join-layout-mobile.png'), fullPage: true });
});
@@ -33,6 +33,7 @@ export default defineConfig({
'mainNavigation.spec.ts',
'session-auth.spec.ts',
'npcPossession.spec.ts',
'joinLayout.spec.ts',
],
fullyParallel: false,
workers: 1,
+1
View File
@@ -19,6 +19,7 @@
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:join-layout": "playwright test joinLayout.spec.ts --config e2e/playwright.config.mjs",
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
"lint": "eslint .",
+553 -92
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import MapViewer from '../components/main/MapViewer.vue';
import { trpc } from '../utils/trpc';
import { useSessionStore } from '../stores/session';
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
@@ -15,6 +16,9 @@ type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
type PossessReservation = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.mutate>>;
type PossessCandidate = PossessReservation['candidates'][number];
type NpcGeneralList = Awaited<ReturnType<typeof trpc.public.getNpcList.query>>;
type PublicMap = Awaited<ReturnType<typeof trpc.public.getCachedMap.query>>;
type MapLayout = Awaited<ReturnType<typeof trpc.public.getMapLayout.query>>;
type PublicGeneral = Awaited<ReturnType<typeof trpc.public.getGeneralList.query>>[number];
type NpcGeneralRow = NpcGeneralList['generals'][number] & {
reservationState: 0 | 1 | 2;
keepCount: number | null;
@@ -45,6 +49,8 @@ const submitting = ref(false);
const joinConfig = ref<JoinConfig | null>(null);
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
const activeTab = ref<'create' | 'possess'>('create');
const contextTab = ref<'invitation' | 'map' | 'generals'>('invitation');
const inheritOpen = ref(false);
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
@@ -178,6 +184,16 @@ const npcGeneralList = ref<NpcGeneralList | null>(null);
const npcGeneralListLoading = ref(false);
const npcGeneralListError = ref('');
const npcGeneralListVisibleCount = ref(50);
const publicMap = ref<PublicMap | null>(null);
const mapLayout = ref<MapLayout | null>(null);
const mapLoaded = ref(false);
const mapLoading = ref(false);
const mapError = ref('');
const publicGenerals = ref<PublicGeneral[]>([]);
const publicGeneralsLoaded = ref(false);
const publicGeneralsLoading = ref(false);
const publicGeneralsError = ref('');
const publicGeneralFilter = ref('');
let npcTimer: number | null = null;
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
@@ -225,6 +241,17 @@ const npcGeneralRows = computed<NpcGeneralRow[]>(() => {
);
});
const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value));
const filteredPublicGenerals = computed(() => {
const keyword = publicGeneralFilter.value.trim().toLocaleLowerCase('ko-KR');
if (!keyword) {
return publicGenerals.value;
}
return publicGenerals.value.filter(
(general) =>
general.name.toLocaleLowerCase('ko-KR').includes(keyword) ||
general.nationName.toLocaleLowerCase('ko-KR').includes(keyword)
);
});
const npcValidColor = computed(() => {
const remaining = npcValidUntilMs.value - nowMs.value;
if (remaining > 30_000) return '#ffffff';
@@ -542,12 +569,58 @@ const loadNpcGeneralList = async () => {
}
};
const loadPublicMap = async () => {
if (mapLoading.value || mapLoaded.value) {
return;
}
mapLoading.value = true;
mapError.value = '';
try {
const [map, layout] = await Promise.all([trpc.public.getCachedMap.query(), trpc.public.getMapLayout.query()]);
publicMap.value = map;
mapLayout.value = layout;
mapLoaded.value = true;
} catch (err) {
mapError.value = err instanceof Error ? err.message : 'public_map_failed';
} finally {
mapLoading.value = false;
}
};
const loadPublicGenerals = async () => {
if (publicGeneralsLoading.value || publicGeneralsLoaded.value) {
return;
}
publicGeneralsLoading.value = true;
publicGeneralsError.value = '';
try {
publicGenerals.value = await trpc.public.getGeneralList.query();
publicGeneralsLoaded.value = true;
} catch (err) {
publicGeneralsError.value = err instanceof Error ? err.message : 'public_general_list_failed';
} finally {
publicGeneralsLoading.value = false;
}
};
const onInheritanceToggle = (event: Event) => {
inheritOpen.value = (event.currentTarget as HTMLDetailsElement).open;
};
watch(activeTab, (value) => {
if (value === 'possess' && !npcReservation.value) {
void loadNpcCandidates(false);
}
});
watch(contextTab, (value) => {
if (value === 'map') {
void loadPublicMap();
} else if (value === 'generals') {
void loadPublicGenerals();
}
});
onMounted(() => {
npcTimer = window.setInterval(() => {
nowMs.value = Date.now();
@@ -596,98 +669,105 @@ onUnmounted(() => {
<SkeletonLines :lines="4" />
</div>
<section v-else-if="activeTab === 'create'" class="join-grid">
<PanelCard title="국가 임관 권유">
<div v-if="nationList.length === 0" class="muted">국가 정보가 아직 준비되지 않았습니다.</div>
<div v-else class="nation-list">
<div v-for="nation in nationList" :key="nation.id" class="nation-card">
<div class="nation-name" :style="{ backgroundColor: nation.color }">{{ nation.name }}</div>
<div class="nation-message">
{{ nation.scoutMessage ?? '권유문 없음' }}
</div>
</div>
</div>
</PanelCard>
<PanelCard title="장수 기본 정보" subtitle="능력치와 성격을 지정합니다.">
<div class="form-grid">
<label class="form-field">
<span>장수명</span>
<input
v-if="joinConfig?.rules.allowCustomName"
v-model="form.name"
type="text"
class="form-input"
/>
<span v-else>무작위</span>
</label>
<label class="form-field">
<span>성격</span>
<select v-model="form.character" class="form-input">
<option v-for="option in personalities" :key="option.key" :value="option.key">
{{ option.name }}
</option>
</select>
<small class="muted">{{ personalities.find((p) => p.key === form.character)?.info }}</small>
</label>
</div>
<div class="stat-grid">
<label class="form-field">
<span>통솔</span>
<input v-model.number="form.leadership" type="number" class="form-input" />
</label>
<label class="form-field">
<span>무력</span>
<input v-model.number="form.strength" type="number" class="form-input" />
</label>
<label class="form-field">
<span>지력</span>
<input v-model.number="form.intel" type="number" class="form-input" />
</label>
</div>
<div v-if="accountIcons.length" class="icon-choice">
<div class="bonus-title">전용 아이콘 선택</div>
<label class="icon-option"> <input v-model="form.pic" type="checkbox" /> 전용 아이콘 사용 </label>
<div v-if="form.pic" class="icon-list" role="radiogroup" aria-label="전용 아이콘 선택">
<label v-for="icon in accountIcons" :key="icon.id" class="icon-card">
<input v-model="form.iconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl({ picture: icon.picture, imageServer: icon.imageServer })"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
<section v-else-if="activeTab === 'create'" class="join-flow">
<PanelCard title="장수 기본 정보" subtitle="장수명과 성격, 통솔·무력·지력을 먼저 결정하세요.">
<form class="create-form" @submit.prevent="submitJoin">
<div class="identity-grid">
<label class="form-field primary-field">
<span>장수명</span>
<input
v-if="joinConfig?.rules.allowCustomName"
v-model="form.name"
type="text"
class="form-input"
/>
<span v-else>무작위</span>
</label>
<label class="form-field primary-field">
<span>성격</span>
<select v-model="form.character" class="form-input">
<option v-for="option in personalities" :key="option.key" :value="option.key">
{{ option.name }}
</option>
</select>
<small class="muted">{{ personalities.find((p) => p.key === form.character)?.info }}</small>
</label>
</div>
</div>
<div class="stat-actions">
<button @click="applyRandomStats">랜덤형</button>
<button @click="applyFocusedStats('leadership')">통솔</button>
<button @click="applyFocusedStats('strength')">무력형</button>
<button @click="applyFocusedStats('intel')">지력형</button>
<button @click="applyBalancedStats">균형형</button>
</div>
<div class="stat-summary">
<div>능력치 합계: {{ statTotal }} / {{ statRules?.total ?? '-' }}</div>
<div v-if="statErrors.length" class="stat-errors">
<div v-for="item in statErrors" :key="item">{{ item }}</div>
<div class="stat-grid">
<label class="form-field primary-field">
<span>통솔</span>
<input v-model.number="form.leadership" type="number" class="form-input" />
</label>
<label class="form-field primary-field">
<span>무력</span>
<input v-model.number="form.strength" type="number" class="form-input" />
</label>
<label class="form-field primary-field">
<span>지력</span>
<input v-model.number="form.intel" type="number" class="form-input" />
</label>
</div>
</div>
<div class="form-actions">
<button :disabled="!canSubmit || submitting" @click="submitJoin">장수 생성</button>
<button class="ghost" @click="applyBalancedStats">다시 입력</button>
</div>
<div class="stat-actions" aria-label="능력치 빠른 설정">
<button type="button" @click="applyRandomStats">랜덤형</button>
<button type="button" @click="applyFocusedStats('leadership')">통솔형</button>
<button type="button" @click="applyFocusedStats('strength')">무력형</button>
<button type="button" @click="applyFocusedStats('intel')">지력형</button>
<button type="button" @click="applyBalancedStats">균형형</button>
</div>
<div v-if="accountIcons.length" class="icon-choice">
<div class="bonus-title">전용 아이콘 선택</div>
<label class="icon-option">
<input v-model="form.pic" type="checkbox" /> 전용 아이콘 사용
</label>
<div v-if="form.pic" class="icon-list" role="radiogroup" aria-label="전용 아이콘 선택">
<label v-for="icon in accountIcons" :key="icon.id" class="icon-card">
<input v-model="form.iconId" type="radio" :value="icon.id" />
<img
:src="
resolveGeneralIconUrl({ picture: icon.picture, imageServer: icon.imageServer })
"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
</div>
<div class="stat-summary">
<div>
<strong>능력치 합계: {{ statTotal }}</strong> / {{ statRules?.total ?? '-' }}
</div>
<div v-if="statErrors.length" class="stat-errors">
<div v-for="item in statErrors" :key="item">{{ item }}</div>
</div>
</div>
<div class="form-actions">
<button class="primary-action" type="submit" :disabled="!canSubmit || submitting">
{{ submitting ? '생성 중...' : '장수 생성' }}
</button>
<button type="button" class="ghost" @click="applyBalancedStats">균형형으로 되돌리기</button>
</div>
</form>
</PanelCard>
<PanelCard title="유산 포인트 옵션" subtitle="보유 포인트를 사용해 시작 옵션을 지정합니다.">
<div v-if="!inheritConfig" class="muted">유산 포인트 정보를 불러오지 못했습니다.</div>
<div v-else class="inherit-panel">
<details class="advanced-options" :open="inheritOpen" @toggle="onInheritanceToggle">
<summary>
<span class="advanced-title">
<strong>고급 옵션 · 유산 포인트</strong>
<small>시작 특기·도시· 시간과 보너스 능력치를 지정합니다.</small>
</span>
<span class="advanced-point-summary">
보유 {{ inheritTotalPoint }} · 사용 {{ inheritRequiredPoint }}
</span>
</summary>
<div v-if="!inheritConfig" class="advanced-body muted">유산 포인트 정보를 불러오지 못했습니다.</div>
<div v-else class="advanced-body inherit-panel">
<div class="inherit-summary">
<div>보유 포인트: {{ inheritTotalPoint }}</div>
<div>필요 포인트: {{ inheritRequiredPoint }}</div>
@@ -785,6 +865,122 @@ onUnmounted(() => {
<div v-for="item in inheritErrors" :key="item">{{ item }}</div>
</div>
</div>
</details>
<PanelCard title="현재 정보" subtitle="임관할 국가를 고를 때 필요한 정보만 확인하세요.">
<div class="context-tabs" role="tablist" aria-label="현재 정보 보기">
<button
id="context-tab-invitation"
type="button"
role="tab"
:class="{ active: contextTab === 'invitation' }"
:aria-selected="contextTab === 'invitation'"
aria-controls="context-panel-invitation"
@click="contextTab = 'invitation'"
>
임관 권유
</button>
<button
id="context-tab-map"
type="button"
role="tab"
:class="{ active: contextTab === 'map' }"
:aria-selected="contextTab === 'map'"
aria-controls="context-panel-map"
@click="contextTab = 'map'"
>
현재 지도
</button>
<button
id="context-tab-generals"
type="button"
role="tab"
:class="{ active: contextTab === 'generals' }"
:aria-selected="contextTab === 'generals'"
aria-controls="context-panel-generals"
@click="contextTab = 'generals'"
>
장수 목록
</button>
</div>
<div
v-if="contextTab === 'invitation'"
id="context-panel-invitation"
class="context-panel"
role="tabpanel"
aria-labelledby="context-tab-invitation"
>
<div v-if="nationList.length === 0" class="muted">국가 정보가 아직 준비되지 않았습니다.</div>
<div v-else class="nation-list">
<article v-for="nation in nationList" :key="nation.id" class="nation-card">
<h3 class="nation-name" :style="{ backgroundColor: nation.color }">{{ nation.name }}</h3>
<p class="nation-message">{{ nation.scoutMessage ?? '권유문 없음' }}</p>
</article>
</div>
</div>
<div
v-else-if="contextTab === 'map'"
id="context-panel-map"
class="context-panel map-context"
role="tabpanel"
aria-labelledby="context-tab-map"
>
<div v-if="mapError" class="context-error" role="alert">{{ mapError }}</div>
<MapViewer :map-data="publicMap" :map-layout="mapLayout" :loading="mapLoading" />
</div>
<div
v-else
id="context-panel-generals"
class="context-panel"
role="tabpanel"
aria-labelledby="context-tab-generals"
>
<div class="general-list-head">
<label>
<span class="sr-only">장수 또는 국가 검색</span>
<input
v-model="publicGeneralFilter"
class="form-input"
type="search"
placeholder="장수명 또는 국가 검색"
/>
</label>
<span> {{ filteredPublicGenerals.length }}</span>
</div>
<div v-if="publicGeneralsError" class="context-error" role="alert">
{{ publicGeneralsError }}
</div>
<SkeletonLines v-if="publicGeneralsLoading" :lines="5" />
<div v-else class="general-list-scroll">
<table class="context-general-table">
<thead>
<tr>
<th>장수명</th>
<th>국가</th>
<th>통솔</th>
<th>무력</th>
<th>지력</th>
</tr>
</thead>
<tbody>
<tr v-for="general in filteredPublicGenerals" :key="general.id">
<td>
<span v-if="general.npcState > 0" class="npc-badge">NPC</span>
{{ general.name }}
</td>
<td>{{ general.nationName }}</td>
<td>{{ general.leadership }}</td>
<td>{{ general.strength }}</td>
<td>{{ general.intelligence }}</td>
</tr>
</tbody>
</table>
<div v-if="filteredPublicGenerals.length === 0" class="empty-list">표시할 장수가 없습니다.</div>
</div>
</div>
</PanelCard>
</section>
@@ -1028,6 +1224,10 @@ onUnmounted(() => {
gap: 16px;
}
:global(#app:has(.join-page)) {
min-width: 320px;
}
.join-header {
display: flex;
flex-wrap: wrap;
@@ -1076,10 +1276,12 @@ onUnmounted(() => {
color: rgba(240, 150, 150, 0.9);
}
.join-grid {
display: grid;
.join-flow {
width: min(100%, 1000px);
align-self: center;
display: flex;
flex-direction: column;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.nation-list {
@@ -1090,28 +1292,41 @@ onUnmounted(() => {
.nation-card {
border: 1px solid rgba(201, 164, 90, 0.25);
padding: 8px;
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px;
align-items: stretch;
}
.nation-name {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
font-weight: 600;
padding: 4px 6px;
padding: 8px 6px;
color: #101010;
text-align: center;
}
.nation-message {
margin: 0;
padding: 8px;
font-size: 0.75rem;
color: rgba(232, 221, 196, 0.7);
}
.form-grid {
.create-form {
display: flex;
flex-direction: column;
}
.identity-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
gap: 16px;
padding: 10px;
border: 1px solid rgba(201, 164, 90, 0.2);
background: rgba(0, 0, 0, 0.16);
}
.stat-grid {
@@ -1128,6 +1343,12 @@ onUnmounted(() => {
font-size: 0.75rem;
}
.primary-field > span:first-child {
color: #f0d99e;
font-size: 0.85rem;
font-weight: 700;
}
.form-input {
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(10, 10, 10, 0.8);
@@ -1135,6 +1356,11 @@ onUnmounted(() => {
color: inherit;
}
.primary-field > .form-input {
min-height: 36px;
font-size: 1rem;
}
.stat-actions {
display: flex;
flex-wrap: wrap;
@@ -1171,6 +1397,186 @@ onUnmounted(() => {
font-size: 0.8rem;
}
.form-actions .primary-action {
min-width: 150px;
border-color: rgba(226, 190, 112, 0.8);
background: rgba(116, 81, 29, 0.75);
color: #fff6dc;
font-weight: 700;
}
.advanced-options {
border: 1px solid gray;
background-color: #302016;
background-image: var(--sammo-texture-walnut);
}
.advanced-options > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 10px;
cursor: pointer;
list-style: none;
background-color: #14241b;
background-image: var(--sammo-texture-green);
}
.advanced-options > summary::-webkit-details-marker {
display: none;
}
.advanced-options > summary::before {
content: '';
flex: 0 0 auto;
color: #dcbf7a;
font-weight: 700;
}
.advanced-options[open] > summary::before {
content: '';
}
.advanced-title {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
}
.advanced-title small {
color: #ccc;
font-size: 0.7rem;
font-weight: 400;
}
.advanced-point-summary {
color: #ead8ac;
font-size: 0.75rem;
white-space: nowrap;
}
.advanced-body {
padding: 12px;
border-top: 1px solid gray;
}
.context-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
}
.context-tabs button {
min-height: 38px;
border: 0;
border-right: 1px solid rgba(201, 164, 90, 0.25);
color: rgba(232, 221, 196, 0.68);
font-size: 0.8rem;
}
.context-tabs button:last-child {
border-right: 0;
}
.context-tabs button.active {
background: rgba(201, 164, 90, 0.18);
color: #fff2cf;
box-shadow: inset 0 -2px #d3ad60;
font-weight: 700;
}
.context-tabs button:focus-visible,
.advanced-options > summary:focus-visible,
.form-actions button:focus-visible {
outline: 2px solid #f0d58f;
outline-offset: -2px;
}
.context-panel {
padding-top: 10px;
}
.map-context {
overflow-x: auto;
}
.general-list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: rgba(232, 221, 196, 0.75);
font-size: 0.75rem;
}
.general-list-head label {
flex: 1;
}
.general-list-head input {
width: min(100%, 320px);
}
.general-list-scroll {
max-height: 420px;
overflow: auto;
}
.context-general-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.context-general-table th,
.context-general-table td {
border: 1px solid rgba(201, 164, 90, 0.28);
padding: 6px 8px;
text-align: center;
}
.context-general-table th {
position: sticky;
z-index: 1;
top: 0;
background: #14241b;
}
.context-general-table td:first-child,
.context-general-table td:nth-child(2) {
text-align: left;
}
.npc-badge {
margin-right: 4px;
padding: 1px 3px;
background: rgba(111, 74, 141, 0.8);
color: #fff;
font-size: 0.6rem;
}
.context-error,
.empty-list {
padding: 12px;
color: rgba(240, 150, 150, 0.9);
text-align: center;
font-size: 0.75rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.inherit-panel {
display: flex;
flex-direction: column;
@@ -1414,4 +1820,59 @@ onUnmounted(() => {
align-items: center;
gap: 3px;
}
@media (max-width: 700px) {
.join-page {
padding: 12px;
}
.join-header,
.join-tabs {
width: 100%;
}
.join-tabs {
flex-wrap: wrap;
}
.join-tabs > * {
flex: 1 1 auto;
text-align: center;
}
.identity-grid {
grid-template-columns: 1fr;
}
.stat-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.form-actions {
flex-direction: column;
}
.form-actions button {
min-height: 40px;
}
.advanced-options > summary {
align-items: flex-start;
gap: 8px;
}
.advanced-point-summary {
white-space: normal;
text-align: right;
}
.nation-card {
grid-template-columns: 90px 1fr;
}
.context-general-table {
min-width: 520px;
}
}
</style>