사람 장수는 활성 전용 아이콘 ID를 명시한 경우에만 사용자 아이콘을 사용한다. 일반 생성, 선택 pool, Gateway 서버 동기화의 대표·후보 preset fallback을 제거하고 회귀 검증을 추가한다.
719 lines
25 KiB
Vue
719 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
import { useGameFeedback } from '../composables/useGameFeedback';
|
|
import { useSessionStore } from '../stores/session';
|
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
|
import { legacyLuminanceTextColor } from '../utils/legacyNationColor';
|
|
import { trpc } from '../utils/trpc';
|
|
|
|
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
|
type Reservation = Awaited<ReturnType<typeof trpc.join.getSelectionPool.mutate>>;
|
|
type Candidate = Reservation['candidates'][number];
|
|
type Nation = JoinConfig['nations'][number];
|
|
type PendingSelectionAction = {
|
|
operation: 'create' | 'reselect';
|
|
uniqueName: string;
|
|
personality?: string;
|
|
iconId?: string;
|
|
clientRequestId: string;
|
|
};
|
|
|
|
const router = useRouter();
|
|
const session = useSessionStore();
|
|
const { error: showErrorToast, showDialog } = useGameFeedback();
|
|
|
|
const config = ref<JoinConfig | null>(null);
|
|
const reservation = ref<Reservation | null>(null);
|
|
const selectedUniqueName = ref<string | null>(null);
|
|
const nations = ref<Nation[]>([]);
|
|
const personality = ref('Random');
|
|
const selectedIconId = ref('');
|
|
const loading = ref(true);
|
|
const submitting = ref(false);
|
|
const error = ref('');
|
|
const now = ref(Date.now());
|
|
let timer: number | null = null;
|
|
const pendingActionStorageKey = 'sammo-select-pool-pending-action';
|
|
|
|
const candidates = computed(() => reservation.value?.candidates ?? []);
|
|
const selectedCandidate = computed(
|
|
() => candidates.value.find((candidate) => candidate.uniqueName === selectedUniqueName.value) ?? null
|
|
);
|
|
const hasGeneral = computed(() => reservation.value?.hasGeneral ?? config.value?.selectionPool.hasGeneral ?? false);
|
|
const allowPersonality = computed(() => config.value?.selectionPool.allowOptions.includes('ego') ?? false);
|
|
const personalities = computed(() => config.value?.personalities ?? []);
|
|
const serverInfo = computed(() => config.value?.serverInfo ?? null);
|
|
const validUntil = computed(() => {
|
|
const value = reservation.value?.validUntil;
|
|
return value ? new Date(value).getTime() : 0;
|
|
});
|
|
const expired = computed(() => validUntil.value > 0 && now.value > validUntil.value);
|
|
const validUntilColor = computed(() => {
|
|
const remaining = validUntil.value - now.value;
|
|
if (remaining <= 0 || remaining > 30_000) {
|
|
return '#fff';
|
|
}
|
|
const channel = Math.max(0, Math.round((255 * remaining) / 30_000));
|
|
return `rgb(255, ${channel}, ${channel})`;
|
|
});
|
|
|
|
const errorText = (value: unknown): string =>
|
|
value instanceof Error ? value.message : typeof value === 'string' ? value : 'unknown_error';
|
|
|
|
const readPendingAction = (): PendingSelectionAction | null => {
|
|
try {
|
|
const raw = window.sessionStorage.getItem(pendingActionStorageKey);
|
|
if (!raw) return null;
|
|
const value = JSON.parse(raw) as Partial<PendingSelectionAction>;
|
|
if (
|
|
(value.operation !== 'create' && value.operation !== 'reselect') ||
|
|
typeof value.uniqueName !== 'string' ||
|
|
typeof value.clientRequestId !== 'string' ||
|
|
(value.iconId !== undefined && typeof value.iconId !== 'string')
|
|
) {
|
|
return null;
|
|
}
|
|
return value as PendingSelectionAction;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getPendingAction = (
|
|
operation: PendingSelectionAction['operation'],
|
|
uniqueName: string,
|
|
requestedPersonality?: string,
|
|
requestedIconId?: string
|
|
): PendingSelectionAction => {
|
|
const current = readPendingAction();
|
|
if (
|
|
current?.operation === operation &&
|
|
current.uniqueName === uniqueName &&
|
|
current.personality === requestedPersonality &&
|
|
current.iconId === requestedIconId
|
|
) {
|
|
return current;
|
|
}
|
|
const next: PendingSelectionAction = {
|
|
operation,
|
|
uniqueName,
|
|
...(requestedPersonality ? { personality: requestedPersonality } : {}),
|
|
...(requestedIconId ? { iconId: requestedIconId } : {}),
|
|
clientRequestId: crypto.randomUUID(),
|
|
};
|
|
window.sessionStorage.setItem(pendingActionStorageKey, JSON.stringify(next));
|
|
return next;
|
|
};
|
|
|
|
const clearPendingAction = (action: PendingSelectionAction): void => {
|
|
if (readPendingAction()?.clientRequestId === action.clientRequestId) {
|
|
window.sessionStorage.removeItem(pendingActionStorageKey);
|
|
}
|
|
};
|
|
|
|
const isIndeterminateTimeout = (value: unknown): boolean => {
|
|
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
|
const data = value.data;
|
|
return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT');
|
|
};
|
|
|
|
const formatDateTime = (value: string | null | undefined): string => {
|
|
if (!value) return '';
|
|
return formatSeoulDateTime(value);
|
|
};
|
|
|
|
const shuffleNations = (source: Nation[]): Nation[] => {
|
|
const shuffled = [...source];
|
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
|
const random = new Uint32Array(1);
|
|
crypto.getRandomValues(random);
|
|
const swapIndex = random[0]! % (index + 1);
|
|
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex]!, shuffled[index]!];
|
|
}
|
|
return shuffled;
|
|
};
|
|
|
|
const imageUrl = (candidate: Candidate): string => resolveGeneralIconUrl(candidate);
|
|
|
|
const personalityName = (key: string | null): string | null => {
|
|
if (!key) return null;
|
|
return personalities.value.find((entry) => entry.key === key)?.name ?? key;
|
|
};
|
|
const personalityInfo = (key: string | null): string =>
|
|
key ? (personalities.value.find((entry) => entry.key === key)?.info ?? '') : '';
|
|
|
|
const selectCandidate = async (candidate: Candidate): Promise<void> => {
|
|
if (!hasGeneral.value) {
|
|
selectedUniqueName.value = candidate.uniqueName;
|
|
return;
|
|
}
|
|
if (!confirm(`이 장수를 선택할까요? : ${candidate.generalName}`)) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
const pending = getPendingAction('reselect', candidate.uniqueName);
|
|
try {
|
|
await trpc.join.reselectPoolGeneral.mutate({
|
|
uniqueName: candidate.uniqueName,
|
|
clientRequestId: pending.clientRequestId,
|
|
});
|
|
clearPendingAction(pending);
|
|
await showDialog({ kind: 'success', message: '선택한 장수로 변경했습니다.' });
|
|
await session.refreshGeneralStatus();
|
|
await router.push('/');
|
|
} catch (cause) {
|
|
console.error(cause);
|
|
if (!isIndeterminateTimeout(cause)) {
|
|
clearPendingAction(pending);
|
|
}
|
|
showErrorToast(`장수 변경에 실패했습니다: ${errorText(cause)}`);
|
|
await loadPage();
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
const createGeneral = async (): Promise<void> => {
|
|
const candidate = selectedCandidate.value;
|
|
if (!candidate) {
|
|
showErrorToast('장수를 선택해주세요.');
|
|
return;
|
|
}
|
|
if (!confirm('이 장수로 생성할까요?')) {
|
|
return;
|
|
}
|
|
submitting.value = true;
|
|
const pending = getPendingAction('create', candidate.uniqueName, personality.value, selectedIconId.value);
|
|
try {
|
|
await trpc.join.selectPoolGeneral.mutate({
|
|
uniqueName: candidate.uniqueName,
|
|
personality: personality.value,
|
|
...(selectedIconId.value ? { iconId: selectedIconId.value } : {}),
|
|
clientRequestId: pending.clientRequestId,
|
|
});
|
|
clearPendingAction(pending);
|
|
await showDialog({ kind: 'success', message: '선택한 장수로 생성했습니다.' });
|
|
await session.refreshGeneralStatus();
|
|
await router.push('/');
|
|
} catch (cause) {
|
|
console.error(cause);
|
|
if (!isIndeterminateTimeout(cause)) {
|
|
clearPendingAction(pending);
|
|
}
|
|
showErrorToast(`장수 생성에 실패했습니다: ${errorText(cause)}`);
|
|
await loadPage();
|
|
} finally {
|
|
submitting.value = false;
|
|
}
|
|
};
|
|
|
|
async function loadPage(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = '';
|
|
selectedUniqueName.value = null;
|
|
try {
|
|
const nextConfig = await trpc.join.getConfig.query();
|
|
config.value = nextConfig;
|
|
nations.value = shuffleNations(nextConfig.nations);
|
|
if (!nextConfig.selectionPool.enabled) {
|
|
await router.replace(nextConfig.selectionPool.hasGeneral ? '/' : '/join');
|
|
return;
|
|
}
|
|
reservation.value = await trpc.join.getSelectionPool.mutate();
|
|
now.value = Date.now();
|
|
} catch (cause) {
|
|
console.error(cause);
|
|
error.value = errorText(cause);
|
|
showErrorToast(`장수 선택 정보를 불러오지 못했습니다: ${error.value}`);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
const goBack = (): void => {
|
|
if (window.history.length > 1) {
|
|
router.back();
|
|
return;
|
|
}
|
|
void router.push(hasGeneral.value ? '/' : '/join');
|
|
};
|
|
|
|
onMounted(() => {
|
|
timer = window.setInterval(() => {
|
|
now.value = Date.now();
|
|
}, 1_000);
|
|
void loadPage();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (timer !== null) {
|
|
window.clearInterval(timer);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<main class="select-pool-page legacy-bg0">
|
|
<header class="page-title with-border">
|
|
장 수 선 택<br />
|
|
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
|
|
</header>
|
|
|
|
<table v-if="serverInfo" class="server-info-table legacy-bg0">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
현재 : {{ serverInfo.currentYear }}年 {{ serverInfo.currentMonth }}月 (<span class="cyan"
|
|
>{{ serverInfo.tickMinutes }}분 턴</span
|
|
>
|
|
서버)<br />
|
|
등록 장수 : 유저 {{ serverInfo.userGeneralCount }} / {{ serverInfo.maxGeneral }} 명 +
|
|
<span class="cyan">NPC {{ serverInfo.npcGeneralCount }} 명</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<table class="invitation-table legacy-bg0">
|
|
<thead>
|
|
<tr>
|
|
<td colspan="2" class="legacy-bg1">임관 권유 메시지</td>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="nation in nations"
|
|
:key="nation.id"
|
|
:style="{
|
|
color: legacyLuminanceTextColor(nation.color),
|
|
backgroundColor: nation.color,
|
|
}"
|
|
>
|
|
<td class="invitation-nation">{{ nation.name }}</td>
|
|
<td>
|
|
<div class="invitation-message">{{ nation.scoutMessage ?? '-' }}</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<section class="selection-section">
|
|
<h1 class="section-title legacy-bg1 with-border">장수 선택</h1>
|
|
<div class="selection-body with-border">
|
|
<div v-if="loading">불러오는 중...</div>
|
|
<div v-else-if="error" class="error-text">{{ error }}</div>
|
|
<template v-else-if="reservation">
|
|
<small v-if="!expired">
|
|
(<span :style="{ color: validUntilColor }">{{ formatDateTime(reservation.validUntil) }}</span
|
|
>까지 유효)
|
|
</small>
|
|
<small v-else class="expired-text">- 만료 -</small>
|
|
<br />
|
|
<div class="card-holder">
|
|
<article v-for="candidate in candidates" :key="candidate.uniqueName" class="general-card">
|
|
<h4 class="legacy-bg1 with-border">{{ candidate.generalName }}</h4>
|
|
<h4 class="portrait">
|
|
<img
|
|
:src="imageUrl(candidate)"
|
|
:alt="candidate.generalName"
|
|
width="64"
|
|
height="64"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</h4>
|
|
<p>
|
|
{{ candidate.leadership }} / {{ candidate.strength }} / {{ candidate.intel }}<br />
|
|
<span v-if="candidate.ego" class="trait-tooltip" tabindex="0">
|
|
{{ personalityName(candidate.ego) }}
|
|
<span role="tooltip">{{ personalityInfo(candidate.ego) }}</span>
|
|
</span>
|
|
<br v-if="candidate.ego" />
|
|
<span class="trait-tooltip" tabindex="0">
|
|
{{ candidate.specialDomesticName }}
|
|
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
|
|
</span>
|
|
/
|
|
<span v-if="candidate.specialWarName" class="trait-tooltip" tabindex="0">
|
|
{{ candidate.specialWarName }}
|
|
<span role="tooltip">{{ candidate.specialWarInfo }}</span>
|
|
</span>
|
|
<span v-else>-</span><br /><br />
|
|
보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br />
|
|
궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br />
|
|
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
|
|
귀병: {{ Math.trunc(candidate.dex[3] / 1000) }}K<br />
|
|
차병: {{ Math.trunc(candidate.dex[4] / 1000) }}K<br />
|
|
</p>
|
|
<button
|
|
class="select-button with-border"
|
|
type="button"
|
|
:disabled="submitting"
|
|
@click="selectCandidate(candidate)"
|
|
>
|
|
선택하기
|
|
</button>
|
|
</article>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="reservation && !hasGeneral" class="create-section">
|
|
<h1 class="section-title legacy-bg1 with-border">장수 생성</h1>
|
|
<div class="create-body with-border">
|
|
<div id="left-pad">
|
|
<article v-if="selectedCandidate" class="general-card selected-card">
|
|
<h4 class="legacy-bg1 with-border">{{ selectedCandidate.generalName }}</h4>
|
|
<h4 class="portrait">
|
|
<img
|
|
:src="imageUrl(selectedCandidate)"
|
|
:alt="selectedCandidate.generalName"
|
|
width="64"
|
|
height="64"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</h4>
|
|
<p>
|
|
{{ selectedCandidate.leadership }} / {{ selectedCandidate.strength }} /
|
|
{{ selectedCandidate.intel }}<br />
|
|
<span v-if="selectedCandidate.ego" class="trait-tooltip" tabindex="0">
|
|
{{ personalityName(selectedCandidate.ego) }}
|
|
<span role="tooltip">{{ personalityInfo(selectedCandidate.ego) }}</span>
|
|
</span>
|
|
<br v-if="selectedCandidate.ego" />
|
|
<span class="trait-tooltip" tabindex="0">
|
|
{{ selectedCandidate.specialDomesticName }}
|
|
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
|
|
</span>
|
|
/
|
|
<span v-if="selectedCandidate.specialWarName" class="trait-tooltip" tabindex="0">
|
|
{{ selectedCandidate.specialWarName }}
|
|
<span role="tooltip">{{ selectedCandidate.specialWarInfo }}</span>
|
|
</span>
|
|
<span v-else>-</span><br /><br />
|
|
보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br />
|
|
궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br />
|
|
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
|
|
귀병: {{ Math.trunc(selectedCandidate.dex[3] / 1000) }}K<br />
|
|
차병: {{ Math.trunc(selectedCandidate.dex[4] / 1000) }}K<br />
|
|
</p>
|
|
<button class="select-button with-border" type="button">선택하기</button>
|
|
</article>
|
|
<template v-else>장수를<br />선택해주세요!</template>
|
|
</div>
|
|
<form class="custom-form" @submit.prevent="createGeneral">
|
|
<table>
|
|
<tbody>
|
|
<tr v-if="allowPersonality">
|
|
<th class="legacy-bg1">성격</th>
|
|
<td>
|
|
<select v-model="personality">
|
|
<option value="Random">????</option>
|
|
<option
|
|
v-for="entry in personalities.filter((item) => item.key !== 'Random')"
|
|
:key="entry.key"
|
|
:value="entry.key"
|
|
>
|
|
{{ entry.name }}
|
|
</option>
|
|
</select>
|
|
<span>
|
|
{{ personalities.find((entry) => entry.key === personality)?.info ?? '' }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="config?.user.icons.length">
|
|
<th class="legacy-bg1">전콘 선택</th>
|
|
<td class="pool-icon-choice">
|
|
<label>
|
|
<input v-model="selectedIconId" type="radio" value="" /> 기본 아이콘
|
|
</label>
|
|
<label v-for="icon in config.user.icons" :key="icon.id">
|
|
<input v-model="selectedIconId" type="radio" :value="icon.id" />
|
|
<img
|
|
:src="resolveGeneralIconUrl(icon)"
|
|
width="64"
|
|
height="64"
|
|
alt="내 전용 아이콘"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</label>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="2" class="join-guidance">
|
|
임의의 도시에서 재야로 시작하며 건국과 임관은 게임 내에서 실행합니다.
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="create-action">
|
|
<button
|
|
id="build-general"
|
|
class="legacy-button"
|
|
type="submit"
|
|
:disabled="submitting"
|
|
>
|
|
장수생성
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<button
|
|
class="legacy-button"
|
|
type="reset"
|
|
:disabled="submitting"
|
|
@click="personality = 'Random'"
|
|
>
|
|
다시입력
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class="page-footer">
|
|
<div class="footer-back with-border">
|
|
<button class="legacy-button" type="button" @click="goBack">돌아가기</button>
|
|
</div>
|
|
<div class="footer-banner with-border">
|
|
<small>
|
|
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
|
|
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noopener noreferrer"
|
|
>Credit</a
|
|
>
|
|
</small>
|
|
</div>
|
|
</footer>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.select-pool-page {
|
|
width: 1000px;
|
|
min-width: 1000px;
|
|
margin: 8px auto 0;
|
|
color: #fff;
|
|
line-height: 1.3;
|
|
text-align: center;
|
|
overflow: visible;
|
|
}
|
|
.with-border {
|
|
border: solid 1px;
|
|
border-top-color: gray;
|
|
border-left-color: gray;
|
|
border-right-color: #000;
|
|
border-bottom-color: #000;
|
|
}
|
|
.page-title {
|
|
padding: 0;
|
|
text-align: left;
|
|
}
|
|
.page-title .legacy-button,
|
|
.footer-back .legacy-button {
|
|
padding: 1px 6px;
|
|
font-weight: 400;
|
|
line-height: 1.3;
|
|
}
|
|
.server-info-table,
|
|
.invitation-table {
|
|
border: 1px solid;
|
|
border-collapse: collapse;
|
|
border-top-color: gray;
|
|
border-left-color: gray;
|
|
border-right-color: #000;
|
|
border-bottom-color: #000;
|
|
border-spacing: 0;
|
|
font-size: 14px;
|
|
text-align: center;
|
|
word-break: break-all;
|
|
}
|
|
.server-info-table {
|
|
width: 100%;
|
|
}
|
|
.server-info-table td,
|
|
.invitation-table td {
|
|
border: 1px solid gray;
|
|
padding: 0;
|
|
}
|
|
.server-info-table td {
|
|
padding: 1px 0;
|
|
}
|
|
.invitation-table {
|
|
margin: 0 auto;
|
|
}
|
|
.invitation-nation {
|
|
width: 130px;
|
|
}
|
|
.invitation-message {
|
|
width: 870px;
|
|
max-width: 870px;
|
|
max-height: 200px;
|
|
overflow: hidden;
|
|
}
|
|
.cyan {
|
|
color: cyan;
|
|
}
|
|
.selection-section,
|
|
.create-section {
|
|
margin: 0;
|
|
}
|
|
.section-title {
|
|
margin: 0;
|
|
padding: 0;
|
|
color: inherit;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
line-height: 18.2px;
|
|
}
|
|
.selection-body {
|
|
padding: 0;
|
|
}
|
|
.card-holder {
|
|
text-align: center;
|
|
white-space: normal;
|
|
}
|
|
.general-card {
|
|
width: 125px;
|
|
display: inline-block;
|
|
box-sizing: content-box;
|
|
border: solid 1px;
|
|
border-top-color: gray;
|
|
border-left-color: gray;
|
|
border-right-color: #000;
|
|
border-bottom-color: #000;
|
|
vertical-align: top;
|
|
}
|
|
.general-card h4,
|
|
.general-card p {
|
|
margin: 0;
|
|
}
|
|
.general-card h4 {
|
|
padding: 0;
|
|
}
|
|
.portrait {
|
|
text-align: center;
|
|
}
|
|
.portrait img {
|
|
display: inline;
|
|
width: 64px;
|
|
height: 64px;
|
|
vertical-align: baseline;
|
|
object-fit: fill;
|
|
}
|
|
.select-button {
|
|
width: 100%;
|
|
height: 19px;
|
|
padding: 0 4px;
|
|
border-radius: 0;
|
|
background: #191919;
|
|
color: #fff;
|
|
line-height: normal;
|
|
}
|
|
.expired-text,
|
|
.error-text {
|
|
color: red;
|
|
}
|
|
.create-section {
|
|
margin-top: 10px;
|
|
}
|
|
.create-body {
|
|
display: flex;
|
|
text-align: left;
|
|
}
|
|
#left-pad {
|
|
flex: 1;
|
|
padding-top: 8px;
|
|
text-align: center;
|
|
}
|
|
.selected-card .select-button {
|
|
display: none;
|
|
}
|
|
.custom-form {
|
|
flex: 4;
|
|
}
|
|
.custom-form table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.custom-form th,
|
|
.custom-form td {
|
|
padding: 0;
|
|
text-align: left;
|
|
}
|
|
.custom-form th {
|
|
width: 200px;
|
|
text-align: right;
|
|
}
|
|
.custom-form select {
|
|
color: #fff;
|
|
background: #000;
|
|
}
|
|
.custom-form .legacy-button {
|
|
padding: 3px 6px;
|
|
font-weight: 400;
|
|
}
|
|
.join-guidance {
|
|
text-align: center;
|
|
}
|
|
.create-action {
|
|
width: 200px;
|
|
text-align: right;
|
|
}
|
|
.footer-back,
|
|
.footer-banner {
|
|
text-align: left;
|
|
}
|
|
.footer-banner a {
|
|
color: #fff;
|
|
text-decoration: underline;
|
|
}
|
|
button:disabled {
|
|
cursor: default;
|
|
opacity: 0.55;
|
|
}
|
|
.select-button:disabled {
|
|
background: #333;
|
|
}
|
|
.select-button:focus-visible,
|
|
.custom-form select:focus-visible,
|
|
.trait-tooltip:focus-visible {
|
|
outline: auto 1px;
|
|
outline-offset: 0;
|
|
}
|
|
.trait-tooltip {
|
|
position: relative;
|
|
cursor: help;
|
|
}
|
|
.trait-tooltip [role='tooltip'] {
|
|
display: none;
|
|
position: absolute;
|
|
z-index: 20;
|
|
left: 50%;
|
|
bottom: calc(100% + 4px);
|
|
width: 220px;
|
|
padding: 5px 7px;
|
|
transform: translateX(-50%);
|
|
border: 1px solid #888;
|
|
background: #202020;
|
|
color: #fff;
|
|
text-align: left;
|
|
white-space: normal;
|
|
word-break: keep-all;
|
|
}
|
|
.trait-tooltip:hover [role='tooltip'],
|
|
.trait-tooltip:focus [role='tooltip'] {
|
|
display: block;
|
|
}
|
|
@media (max-width: 1000px) {
|
|
.select-pool-page {
|
|
margin-left: 8px;
|
|
margin-right: 0;
|
|
}
|
|
}
|
|
</style>
|