Merge branch 'main' into feature/auction-menu-parity
# Conflicts: # app/game-frontend/e2e/playwright.config.mjs # app/game-frontend/package.json # app/gateway-api/src/adminRouter.ts # app/gateway-api/src/lobby/profileStatusService.ts # app/gateway-api/src/orchestrator/gatewayOrchestrator.ts # app/gateway-api/test/orchestratorOperations.test.ts # app/gateway-api/test/orchestratorPlan.test.ts # app/gateway-frontend/e2e/server-operations.spec.ts # app/gateway-frontend/src/views/AdminView.vue # app/gateway-frontend/src/views/ServerOperationsView.vue
This commit is contained in:
@@ -7,6 +7,7 @@ interface Props {
|
||||
options: BattleSimOptions;
|
||||
mode: 'attacker' | 'defender';
|
||||
title: string;
|
||||
canImportServer: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -59,7 +60,19 @@ const officerLevelOptions = [
|
||||
<div class="general-subtitle">No {{ general.no }}</div>
|
||||
</div>
|
||||
<div class="general-actions">
|
||||
<button class="action" type="button" @click="emit('import')">서버에서 가져오기</button>
|
||||
<button
|
||||
class="action"
|
||||
type="button"
|
||||
:disabled="!canImportServer"
|
||||
:title="
|
||||
canImportServer
|
||||
? '게임 서버의 장수 정보를 불러옵니다.'
|
||||
: '게임 장수를 보유해야 사용할 수 있습니다.'
|
||||
"
|
||||
@click="emit('import')"
|
||||
>
|
||||
서버에서 가져오기
|
||||
</button>
|
||||
<button class="action" type="button" @click="emit('save')">저장</button>
|
||||
<input ref="fileInput" type="file" accept=".json" hidden @change="handleFileChange" />
|
||||
<button class="action" type="button" @click="triggerLoad">불러오기</button>
|
||||
@@ -187,21 +200,11 @@ const officerLevelOptions = [
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>훈련</span>
|
||||
<input
|
||||
v-model.number="general.train"
|
||||
type="number"
|
||||
min="40"
|
||||
:max="options.config.maxTrainByWar"
|
||||
/>
|
||||
<input v-model.number="general.train" type="number" min="40" :max="options.config.maxTrainByWar" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>사기</span>
|
||||
<input
|
||||
v-model.number="general.atmos"
|
||||
type="number"
|
||||
min="40"
|
||||
:max="options.config.maxAtmosByWar"
|
||||
/>
|
||||
<input v-model.number="general.atmos" type="number" min="40" :max="options.config.maxAtmosByWar" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>전특</span>
|
||||
@@ -308,30 +311,15 @@ const officerLevelOptions = [
|
||||
<div class="form-row buff-row">
|
||||
<label class="field">
|
||||
<span>상대 회피</span>
|
||||
<input
|
||||
v-model.number="general.inheritBuff.warAvoidRatioOppose"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
<input v-model.number="general.inheritBuff.warAvoidRatioOppose" type="number" min="0" max="5" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>상대 필살</span>
|
||||
<input
|
||||
v-model.number="general.inheritBuff.warCriticalRatioOppose"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
<input v-model.number="general.inheritBuff.warCriticalRatioOppose" type="number" min="0" max="5" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>상대 계략</span>
|
||||
<input
|
||||
v-model.number="general.inheritBuff.warMagicTrialProbOppose"
|
||||
type="number"
|
||||
min="0"
|
||||
max="5"
|
||||
/>
|
||||
<input v-model.number="general.inheritBuff.warMagicTrialProbOppose" type="number" min="0" max="5" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,6 +379,11 @@ const officerLevelOptions = [
|
||||
color: #f0b6b6;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.form-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -197,7 +197,6 @@ const routes = [
|
||||
component: BattleSimulatorView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ type BattleExport = {
|
||||
type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport };
|
||||
|
||||
type GeneralListResponse = Awaited<ReturnType<typeof trpc.battle.getGeneralList.query>>;
|
||||
type GeneralMeResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -72,6 +73,7 @@ const importTarget = ref<GeneralDraft | null>(null);
|
||||
const generalList = ref<GeneralListResponse | null>(null);
|
||||
const generalListLoading = ref(false);
|
||||
const selectedGeneralId = ref<number | null>(null);
|
||||
const gameDefaults = ref<GeneralMeResponse>(null);
|
||||
|
||||
let generalIdSeed = 0;
|
||||
|
||||
@@ -250,6 +252,7 @@ const initializeDefaults = async () => {
|
||||
try {
|
||||
const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]);
|
||||
options.value = context;
|
||||
gameDefaults.value = me;
|
||||
year.value = context.world.currentYear;
|
||||
month.value = context.world.currentMonth;
|
||||
repeatCnt.value = 1;
|
||||
@@ -283,6 +286,47 @@ const initializeDefaults = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id);
|
||||
|
||||
const applyGameEnvironment = () => {
|
||||
if (!options.value) {
|
||||
return;
|
||||
}
|
||||
const me = gameDefaults.value;
|
||||
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
|
||||
year.value = options.value.world.currentYear;
|
||||
month.value = options.value.world.currentMonth;
|
||||
attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault;
|
||||
defenderNation.type = attackerNation.type;
|
||||
attackerNation.level = me?.nation?.level ?? 0;
|
||||
defenderNation.level = attackerNation.level;
|
||||
attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1;
|
||||
defenderNation.tech = attackerNation.tech;
|
||||
attackerCity.level = me?.city?.level ?? 5;
|
||||
defenderCity.level = attackerCity.level;
|
||||
defenderCity.def = me?.city?.defence ?? 1000;
|
||||
defenderCity.wall = me?.city?.wall ?? 1000;
|
||||
attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id;
|
||||
defenderNation.isCapital = attackerNation.isCapital;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const applyIndependentEnvironment = () => {
|
||||
if (!options.value) {
|
||||
return;
|
||||
}
|
||||
const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립';
|
||||
year.value = options.value.world.startYear;
|
||||
month.value = 1;
|
||||
seed.value = '';
|
||||
repeatCnt.value = 1;
|
||||
Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
|
||||
Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false });
|
||||
attackerCity.level = 5;
|
||||
Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 });
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void initializeDefaults();
|
||||
});
|
||||
@@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => {
|
||||
}
|
||||
|
||||
isSimulating.value = true;
|
||||
error.value = null;
|
||||
if (action === 'battle') {
|
||||
battleResult.value = null;
|
||||
}
|
||||
statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.';
|
||||
|
||||
try {
|
||||
const payload = buildBattlePayload(action);
|
||||
const response = await trpc.battle.simulate.mutate(payload);
|
||||
const result =
|
||||
'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId);
|
||||
'payload' in response && response.payload
|
||||
? response.payload
|
||||
: await waitForSimulationResult(response.jobId);
|
||||
|
||||
if (!result.result) {
|
||||
error.value = result.reason || 'battle_failed';
|
||||
@@ -786,6 +836,10 @@ const loadGeneralList = async () => {
|
||||
};
|
||||
|
||||
const openImportModal = async (target: GeneralDraft) => {
|
||||
if (!hasGameGeneral.value) {
|
||||
error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.';
|
||||
return;
|
||||
}
|
||||
importTarget.value = target;
|
||||
importOpen.value = true;
|
||||
if (!generalList.value) {
|
||||
@@ -801,47 +855,62 @@ const closeImportModal = () => {
|
||||
importTarget.value = null;
|
||||
};
|
||||
|
||||
const applyServerGeneral = async (target: GeneralDraft, generalId: number) => {
|
||||
const response = await trpc.battle.getGeneralDetail.query({ generalId });
|
||||
applyGeneralExport(target, {
|
||||
no: response.general.no,
|
||||
name: response.general.name,
|
||||
officerLevel: response.general.officer_level,
|
||||
expLevel: response.general.explevel,
|
||||
leadership: response.general.leadership,
|
||||
strength: response.general.strength,
|
||||
intel: response.general.intel,
|
||||
horse: response.general.horse,
|
||||
weapon: response.general.weapon,
|
||||
book: response.general.book,
|
||||
item: response.general.item,
|
||||
injury: response.general.injury,
|
||||
rice: response.general.rice,
|
||||
personal: response.general.personal,
|
||||
special2: response.general.special2,
|
||||
crew: response.general.crew,
|
||||
crewtype: response.general.crewtype,
|
||||
atmos: response.general.atmos,
|
||||
train: response.general.train,
|
||||
dex1: response.general.dex1,
|
||||
dex2: response.general.dex2,
|
||||
dex3: response.general.dex3,
|
||||
dex4: response.general.dex4,
|
||||
dex5: response.general.dex5,
|
||||
defenceTrain: response.general.defence_train,
|
||||
warnum: response.general.warnum,
|
||||
killnum: response.general.killnum,
|
||||
killcrew: response.general.killcrew,
|
||||
inheritBuff: createInheritBuff(),
|
||||
});
|
||||
target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id);
|
||||
};
|
||||
|
||||
const applyMyGeneralToAttacker = async () => {
|
||||
const generalId = gameDefaults.value?.general?.id;
|
||||
if (!attackerGeneral.value || !generalId) {
|
||||
error.value = '불러올 내 장수가 없습니다.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
error.value = null;
|
||||
await applyServerGeneral(attackerGeneral.value, generalId);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!importTarget.value || !selectedGeneralId.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value });
|
||||
applyGeneralExport(importTarget.value, {
|
||||
no: response.general.no,
|
||||
name: response.general.name,
|
||||
officerLevel: response.general.officer_level,
|
||||
expLevel: response.general.explevel,
|
||||
leadership: response.general.leadership,
|
||||
strength: response.general.strength,
|
||||
intel: response.general.intel,
|
||||
horse: response.general.horse,
|
||||
weapon: response.general.weapon,
|
||||
book: response.general.book,
|
||||
item: response.general.item,
|
||||
injury: response.general.injury,
|
||||
rice: response.general.rice,
|
||||
personal: response.general.personal,
|
||||
special2: response.general.special2,
|
||||
crew: response.general.crew,
|
||||
crewtype: response.general.crewtype,
|
||||
atmos: response.general.atmos,
|
||||
train: response.general.train,
|
||||
dex1: response.general.dex1,
|
||||
dex2: response.general.dex2,
|
||||
dex3: response.general.dex3,
|
||||
dex4: response.general.dex4,
|
||||
dex5: response.general.dex5,
|
||||
defenceTrain: response.general.defence_train,
|
||||
warnum: response.general.warnum,
|
||||
killnum: response.general.killnum,
|
||||
killcrew: response.general.killcrew,
|
||||
inheritBuff: createInheritBuff(),
|
||||
});
|
||||
importTarget.value.no =
|
||||
importTarget.value === attackerGeneral.value
|
||||
? 1
|
||||
: resolveGeneralNo(response.general.no, importTarget.value.id);
|
||||
await applyServerGeneral(importTarget.value, selectedGeneralId.value);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
} finally {
|
||||
@@ -882,19 +951,21 @@ const summaryRows = computed(() => {
|
||||
{ label: '전투 페이즈', value: formatNumber(battleResult.value.phase) },
|
||||
{
|
||||
label: '준 피해',
|
||||
value: battleResult.value.minKilled !== battleResult.value.maxKilled
|
||||
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
||||
battleResult.value.maxKilled
|
||||
)})`
|
||||
: formatNumber(battleResult.value.killed),
|
||||
value:
|
||||
battleResult.value.minKilled !== battleResult.value.maxKilled
|
||||
? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber(
|
||||
battleResult.value.maxKilled
|
||||
)})`
|
||||
: formatNumber(battleResult.value.killed),
|
||||
},
|
||||
{
|
||||
label: '받은 피해',
|
||||
value: battleResult.value.minDead !== battleResult.value.maxDead
|
||||
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
||||
battleResult.value.maxDead
|
||||
)})`
|
||||
: formatNumber(battleResult.value.dead),
|
||||
value:
|
||||
battleResult.value.minDead !== battleResult.value.maxDead
|
||||
? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber(
|
||||
battleResult.value.maxDead
|
||||
)})`
|
||||
: formatNumber(battleResult.value.dead),
|
||||
},
|
||||
{ label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) },
|
||||
{ label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) },
|
||||
@@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="independence-notice" aria-label="시뮬레이터 데이터 안내">
|
||||
<div>
|
||||
<strong>게임 상태와 분리된 모의 계산</strong>
|
||||
<p>
|
||||
현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지
|
||||
않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="notice-actions">
|
||||
<button class="ghost" type="button" :disabled="!options" @click="applyGameEnvironment">
|
||||
현재 게임 환경 적용
|
||||
</button>
|
||||
<button class="ghost" type="button" :disabled="!options" @click="applyIndependentEnvironment">
|
||||
독립 기본값
|
||||
</button>
|
||||
<button
|
||||
class="ghost"
|
||||
type="button"
|
||||
:disabled="!hasGameGeneral || !attackerGeneral"
|
||||
@click="applyMyGeneralToAttacker"
|
||||
>
|
||||
내 장수를 출병자로
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="statusMessage" class="status">{{ statusMessage }}</div>
|
||||
|
||||
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
:options="options!"
|
||||
mode="attacker"
|
||||
title="출병자 설정"
|
||||
:can-import-server="hasGameGeneral"
|
||||
@import="openImportModal(attackerGeneral!)"
|
||||
@save="saveGeneral(attackerGeneral!)"
|
||||
@load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })"
|
||||
@@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
:options="options!"
|
||||
mode="defender"
|
||||
:title="`수비자 설정 ${index + 1}`"
|
||||
:can-import-server="hasGameGeneral"
|
||||
@import="openImportModal(defender)"
|
||||
@save="saveGeneral(defender)"
|
||||
@load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })"
|
||||
@@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
</div>
|
||||
<div v-else class="select-wrap">
|
||||
<select v-model.number="selectedGeneralId">
|
||||
<optgroup
|
||||
v-for="group in generalGroups"
|
||||
:key="group.nation.id"
|
||||
:label="group.nation.name"
|
||||
>
|
||||
<optgroup v-for="group in generalGroups" :key="group.nation.id" :label="group.nation.name">
|
||||
<option
|
||||
v-for="general in group.generals"
|
||||
:key="general.id"
|
||||
@@ -1176,6 +1271,8 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
width: min(100%, 1000px);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 30px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(201, 164, 90, 0.15), transparent 45%),
|
||||
@@ -1207,6 +1304,34 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.independence-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(112, 170, 141, 0.45);
|
||||
background: rgba(18, 52, 40, 0.35);
|
||||
}
|
||||
|
||||
.independence-notice strong {
|
||||
color: #bfe2cd;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.independence-notice p {
|
||||
margin: 4px 0 0;
|
||||
color: rgba(221, 239, 228, 0.75);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.notice-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
@@ -1230,6 +1355,11 @@ button {
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f5b7b1;
|
||||
font-size: 0.85rem;
|
||||
@@ -1369,5 +1499,10 @@ button {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.independence-notice {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -281,6 +281,7 @@ onMounted(() => {
|
||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||
</div>
|
||||
<div class="join-tabs">
|
||||
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
||||
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
||||
</div>
|
||||
@@ -464,17 +465,13 @@ onMounted(() => {
|
||||
<section v-else class="join-grid">
|
||||
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
||||
<template #actions>
|
||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">
|
||||
목록 새로고침
|
||||
</button>
|
||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
|
||||
</template>
|
||||
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
||||
<div v-if="npcLoading && npcCandidates.length === 0">
|
||||
<SkeletonLines :lines="3" />
|
||||
</div>
|
||||
<div v-else-if="npcCandidates.length === 0" class="muted">
|
||||
빙의 가능한 NPC가 없습니다.
|
||||
</div>
|
||||
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
|
||||
<div v-else class="npc-list">
|
||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||
<div class="npc-header">
|
||||
@@ -490,9 +487,7 @@ onMounted(() => {
|
||||
<div>나이 {{ npc.age }}</div>
|
||||
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
||||
</div>
|
||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">
|
||||
빙의
|
||||
</button>
|
||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="npc-footer">
|
||||
@@ -542,6 +537,14 @@ onMounted(() => {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.simulator-link {
|
||||
border: 1px solid rgba(112, 170, 141, 0.55);
|
||||
padding: 6px 10px;
|
||||
color: #bfe2cd;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.join-tabs button.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user