feat: Implement session management and routing for general creation
- Added session initialization in main.ts to manage user sessions. - Updated router to include a new route for joining or creating a general. - Enhanced session store with methods for managing session tokens and user profiles. - Introduced new components for displaying city, general, nation, and command information. - Implemented loading states and error handling in the main view. - Created a skeleton loader component for better user experience during data fetching. - Updated TypeScript definitions for route metadata to include new authentication requirements.
This commit is contained in:
@@ -684,6 +684,109 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
general: router({
|
||||
me: authedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const general = await ctx.db.general.findFirst({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
npcState: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
officerLevel: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
crew: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
injury: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!general) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [city, nation] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
level: true,
|
||||
nationId: true,
|
||||
population: true,
|
||||
agriculture: true,
|
||||
commerce: true,
|
||||
security: true,
|
||||
defence: true,
|
||||
wall: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
level: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
tech: true,
|
||||
typeCode: true,
|
||||
capitalCityId: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
return {
|
||||
general: {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
npcState: general.npcState,
|
||||
nationId: general.nationId,
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
officerLevel: general.officerLevel,
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
injury: general.injury,
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
},
|
||||
city,
|
||||
nation,
|
||||
};
|
||||
}),
|
||||
dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const general = await getMyGeneral(ctx);
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@sammo-ts/common": "workspace:*",
|
||||
"@sammo-ts/game-api": "workspace:*",
|
||||
"@sammo-ts/gateway-api": "workspace:*",
|
||||
"@sammo-ts/logic": "workspace:*",
|
||||
"@trpc/client": "^11.8.1",
|
||||
"@trpc/server": "^11.8.1",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
|
||||
interface CityInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
nationId: number;
|
||||
population: number;
|
||||
agriculture: number;
|
||||
commerce: number;
|
||||
security: number;
|
||||
defence: number;
|
||||
wall: number;
|
||||
supplyState: number;
|
||||
frontState: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
city: CityInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="city-card">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="city-body">
|
||||
<div class="title">{{ props.city.name }} (Lv {{ props.city.level }})</div>
|
||||
<div class="grid">
|
||||
<div>인구 {{ props.city.population }}</div>
|
||||
<div>농업 {{ props.city.agriculture }}</div>
|
||||
<div>상업 {{ props.city.commerce }}</div>
|
||||
<div>치안 {{ props.city.security }}</div>
|
||||
<div>방어 {{ props.city.defence }}</div>
|
||||
<div>성벽 {{ props.city.wall }}</div>
|
||||
<div>보급 {{ props.city.supplyState }}</div>
|
||||
<div>전방 {{ props.city.frontState }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.city-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
|
||||
interface TurnCommandAvailability {
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface TurnCommandGroup {
|
||||
category: string;
|
||||
values: TurnCommandAvailability[];
|
||||
}
|
||||
|
||||
interface TurnCommandTable {
|
||||
general: TurnCommandGroup[];
|
||||
nation: TurnCommandGroup[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
commandTable: TurnCommandTable | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const generalGroups = computed(() => props.commandTable?.general ?? []);
|
||||
const nationGroups = computed(() => props.commandTable?.nation ?? []);
|
||||
|
||||
const takePreview = (group: TurnCommandGroup): TurnCommandAvailability[] => group.values.slice(0, 6);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="command-list">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="5" />
|
||||
</div>
|
||||
<div v-else-if="!props.commandTable" class="empty">
|
||||
명령 목록을 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else class="command-body">
|
||||
<div class="group" v-for="group in generalGroups" :key="`g-${group.category}`">
|
||||
<div class="group-title">{{ group.category }}</div>
|
||||
<div class="commands">
|
||||
<span v-for="command in takePreview(group)" :key="command.name" class="command">
|
||||
{{ command.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="nationGroups.length" class="divider">국가 명령</div>
|
||||
<div class="group" v-for="group in nationGroups" :key="`n-${group.category}`">
|
||||
<div class="group-title">{{ group.category }}</div>
|
||||
<div class="commands">
|
||||
<span v-for="command in takePreview(group)" :key="command.name" class="command">
|
||||
{{ command.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.command-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.75);
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.command {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.35);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed rgba(201, 164, 90, 0.3);
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
|
||||
interface GeneralStats {
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intelligence: number;
|
||||
}
|
||||
|
||||
interface GeneralInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
stats: GeneralStats;
|
||||
gold: number;
|
||||
rice: number;
|
||||
crew: number;
|
||||
train: number;
|
||||
atmos: number;
|
||||
injury: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
general: GeneralInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="general-card">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="5" />
|
||||
</div>
|
||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="general-body">
|
||||
<div class="general-header">
|
||||
<span class="name">{{ props.general.name }}</span>
|
||||
<span class="meta">ID {{ props.general.id }} · 관직 {{ props.general.officerLevel }}</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div>통솔 {{ props.general.stats.leadership }}</div>
|
||||
<div>무력 {{ props.general.stats.strength }}</div>
|
||||
<div>지력 {{ props.general.stats.intelligence }}</div>
|
||||
</div>
|
||||
<div class="resources">
|
||||
<div>금 {{ props.general.gold }}</div>
|
||||
<div>쌀 {{ props.general.rice }}</div>
|
||||
<div>병 {{ props.general.crew }}</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div>훈련 {{ props.general.train }}</div>
|
||||
<div>사기 {{ props.general.atmos }}</div>
|
||||
<div>부상 {{ props.general.injury }}</div>
|
||||
<div>경험 {{ props.general.experience }}</div>
|
||||
<div>공헌 {{ props.general.dedication }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.general-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.general-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.general-header .name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.general-header .meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.stats,
|
||||
.resources,
|
||||
.status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
|
||||
interface MapSummary {
|
||||
year: number;
|
||||
month: number;
|
||||
cityList: unknown[];
|
||||
nationList: unknown[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mapData: MapSummary | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-viewer">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.mapData" class="map-empty">
|
||||
지도 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else class="map-body">
|
||||
<div class="map-meta">
|
||||
<span>연월: {{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
||||
<span>도시 {{ props.mapData.cityList.length }}</span>
|
||||
<span>세력 {{ props.mapData.nationList.length }}</span>
|
||||
</div>
|
||||
<div class="map-placeholder">지도 뷰어는 추후 이식 예정</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.map-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
height: 240px;
|
||||
border: 1px dashed rgba(201, 164, 90, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
}
|
||||
|
||||
.map-empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
|
||||
interface MessageEntry {
|
||||
id: number;
|
||||
text: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface MessageBucket {
|
||||
private: MessageEntry[];
|
||||
public: MessageEntry[];
|
||||
national: MessageEntry[];
|
||||
diplomacy: MessageEntry[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
messages: MessageBucket | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const preview = (items: MessageEntry[]): MessageEntry | null => (items.length ? items[0] : null);
|
||||
|
||||
const trimText = (value: string): string => {
|
||||
const clean = value.replace(/\s+/g, ' ').trim();
|
||||
if (clean.length <= 60) {
|
||||
return clean;
|
||||
}
|
||||
return `${clean.slice(0, 60)}...`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-panel">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="5" />
|
||||
</div>
|
||||
<div v-else-if="!props.messages" class="empty">
|
||||
메시지를 불러오지 못했습니다.
|
||||
</div>
|
||||
<div v-else class="message-body">
|
||||
<div class="bucket">
|
||||
<div class="bucket-title">개인</div>
|
||||
<div class="bucket-item" v-if="preview(props.messages.private)">
|
||||
<div class="text">{{ trimText(preview(props.messages.private)?.text ?? '') }}</div>
|
||||
<div class="time">{{ preview(props.messages.private)?.time }}</div>
|
||||
</div>
|
||||
<div v-else class="bucket-empty">메시지 없음</div>
|
||||
</div>
|
||||
<div class="bucket">
|
||||
<div class="bucket-title">공공</div>
|
||||
<div class="bucket-item" v-if="preview(props.messages.public)">
|
||||
<div class="text">{{ trimText(preview(props.messages.public)?.text ?? '') }}</div>
|
||||
<div class="time">{{ preview(props.messages.public)?.time }}</div>
|
||||
</div>
|
||||
<div v-else class="bucket-empty">메시지 없음</div>
|
||||
</div>
|
||||
<div class="bucket">
|
||||
<div class="bucket-title">국가</div>
|
||||
<div class="bucket-item" v-if="preview(props.messages.national)">
|
||||
<div class="text">{{ trimText(preview(props.messages.national)?.text ?? '') }}</div>
|
||||
<div class="time">{{ preview(props.messages.national)?.time }}</div>
|
||||
</div>
|
||||
<div v-else class="bucket-empty">메시지 없음</div>
|
||||
</div>
|
||||
<div class="bucket">
|
||||
<div class="bucket-title">외교</div>
|
||||
<div class="bucket-item" v-if="preview(props.messages.diplomacy)">
|
||||
<div class="text">{{ trimText(preview(props.messages.diplomacy)?.text ?? '') }}</div>
|
||||
<div class="time">{{ preview(props.messages.diplomacy)?.time }}</div>
|
||||
</div>
|
||||
<div v-else class="bucket-empty">메시지 없음</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bucket {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bucket-title {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.bucket-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bucket-item .time {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.bucket-empty {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(232, 221, 196, 0.5);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
|
||||
interface NationInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
gold: number;
|
||||
rice: number;
|
||||
tech: number;
|
||||
typeCode: string;
|
||||
capitalCityId: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nation: NationInfo | null;
|
||||
loading: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nation-card">
|
||||
<div v-if="props.loading">
|
||||
<SkeletonLines :lines="4" />
|
||||
</div>
|
||||
<div v-else-if="!props.nation" class="empty">국가 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="nation-body">
|
||||
<div class="title">
|
||||
<span class="color" :style="{ backgroundColor: props.nation.color }" />
|
||||
{{ props.nation.name }} (Lv {{ props.nation.level }})
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>국고 {{ props.nation.gold }}</div>
|
||||
<div>국량 {{ props.nation.rice }}</div>
|
||||
<div>기술 {{ props.nation.tech }}</div>
|
||||
<div>체제 {{ props.nation.typeCode }}</div>
|
||||
<div>수도 {{ props.nation.capitalCityId ?? '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nation-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.color {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: rgba(232, 221, 196, 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel-card">
|
||||
<header class="panel-header">
|
||||
<div>
|
||||
<h2 class="panel-title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="panel-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel-card {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
background: rgba(12, 12, 12, 0.8);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e8ddc4;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(232, 221, 196, 0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
lines?: number;
|
||||
}>(),
|
||||
{
|
||||
lines: 3,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skeleton-lines">
|
||||
<div v-for="line in props.lines" :key="line" class="skeleton-line" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, rgba(201, 164, 90, 0.15), rgba(201, 164, 90, 0.3), rgba(201, 164, 90, 0.15));
|
||||
background-size: 180% 100%;
|
||||
animation: shimmer 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 0% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -180% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
+10
@@ -5,3 +5,13 @@ declare module '*.vue' {
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, Record<string, never>>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GATEWAY_API_URL?: string;
|
||||
readonly VITE_GAME_API_URL?: string;
|
||||
readonly VITE_GAME_PROFILE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
import { useSessionStore } from './stores/session';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
const session = useSessionStore(pinia);
|
||||
void session.initialize();
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import MainView from '../views/MainView.vue';
|
||||
import PublicView from '../views/PublicView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import JoinView from '../views/JoinView.vue';
|
||||
import NotFoundView from '../views/NotFoundView.vue';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
@@ -20,6 +21,15 @@ const routes = [
|
||||
name: 'public',
|
||||
component: PublicView,
|
||||
},
|
||||
{
|
||||
path: '/join',
|
||||
name: 'join',
|
||||
component: JoinView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresNoGeneral: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
@@ -40,23 +50,31 @@ const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const session = useSessionStore();
|
||||
|
||||
if (!session.isReady) {
|
||||
await session.initialize();
|
||||
}
|
||||
|
||||
if (!session.isReady) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (to.meta.publicOnly && session.isAuthed) {
|
||||
return { name: 'home' };
|
||||
return { name: session.hasGeneral ? 'home' : 'join' };
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !session.isAuthed) {
|
||||
return { name: 'public' };
|
||||
}
|
||||
|
||||
if (to.meta.requiresNoGeneral && session.hasGeneral) {
|
||||
return { name: 'home' };
|
||||
}
|
||||
|
||||
if (to.meta.requiresGeneral && !session.hasGeneral) {
|
||||
return { name: 'public' };
|
||||
return { name: session.isAuthed ? 'join' : 'public' };
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
requiresAuth?: boolean;
|
||||
requiresGeneral?: boolean;
|
||||
requiresNoGeneral?: boolean;
|
||||
publicOnly?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,186 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { gatewayTrpc } from '../utils/gatewayTrpc';
|
||||
import { trpc as gameTrpc } from '../utils/trpc';
|
||||
|
||||
export type SessionStatus = 'unknown' | 'public' | 'authed' | 'general';
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
status: SessionStatus;
|
||||
user: SessionUser | null;
|
||||
sessionToken: string | null;
|
||||
gameToken: string | null;
|
||||
profile: string | null;
|
||||
initializing: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SESSION_TOKEN_KEY = 'sammo-session-token';
|
||||
const PROFILE_KEY = 'sammo-game-profile';
|
||||
const GAME_TOKEN_KEY = 'sammo-game-token';
|
||||
|
||||
const readStorage = (key: string): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.localStorage.getItem(key);
|
||||
};
|
||||
|
||||
const writeStorage = (key: string, value: string | null): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
window.localStorage.setItem(key, value);
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
};
|
||||
|
||||
const readQueryParam = (key: string): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
url.searchParams.delete(key);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useSessionStore = defineStore('session', {
|
||||
state: (): SessionState => ({
|
||||
status: 'unknown',
|
||||
user: null,
|
||||
sessionToken: null,
|
||||
gameToken: null,
|
||||
profile: null,
|
||||
initializing: false,
|
||||
error: null,
|
||||
}),
|
||||
getters: {
|
||||
isReady: (state) => state.status !== 'unknown',
|
||||
isAuthed: (state) => state.status === 'authed' || state.status === 'general',
|
||||
hasGeneral: (state) => state.status === 'general',
|
||||
needsGeneral: (state) => state.status === 'authed',
|
||||
},
|
||||
actions: {
|
||||
setStatus(status: SessionStatus) {
|
||||
this.status = status;
|
||||
setSessionToken(sessionToken: string | null) {
|
||||
this.sessionToken = sessionToken;
|
||||
writeStorage(SESSION_TOKEN_KEY, sessionToken);
|
||||
},
|
||||
setProfile(profile: string | null) {
|
||||
this.profile = profile;
|
||||
writeStorage(PROFILE_KEY, profile);
|
||||
},
|
||||
setGameToken(gameToken: string | null) {
|
||||
this.gameToken = gameToken;
|
||||
writeStorage(GAME_TOKEN_KEY, gameToken);
|
||||
},
|
||||
clearSession() {
|
||||
this.user = null;
|
||||
this.setSessionToken(null);
|
||||
this.setGameToken(null);
|
||||
this.status = 'public';
|
||||
},
|
||||
async refreshGeneralStatus() {
|
||||
if (!this.gameToken) {
|
||||
this.status = 'authed';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const lobby = await gameTrpc.lobby.info.query();
|
||||
this.status = lobby.myGeneral ? 'general' : 'authed';
|
||||
} catch {
|
||||
this.error = 'game_status_unavailable';
|
||||
}
|
||||
},
|
||||
async initialize() {
|
||||
if (this.initializing || this.status !== 'unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initializing = true;
|
||||
this.error = null;
|
||||
|
||||
const tokenFromQuery = readQueryParam('sessionToken');
|
||||
if (tokenFromQuery) {
|
||||
this.setSessionToken(tokenFromQuery);
|
||||
}
|
||||
|
||||
const profileFromQuery = readQueryParam('profile');
|
||||
if (profileFromQuery) {
|
||||
this.setProfile(profileFromQuery);
|
||||
}
|
||||
|
||||
const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY);
|
||||
if (storedToken && storedToken !== this.sessionToken) {
|
||||
this.setSessionToken(storedToken);
|
||||
}
|
||||
|
||||
const storedProfile = this.profile ?? readStorage(PROFILE_KEY) ?? import.meta.env.VITE_GAME_PROFILE;
|
||||
if (storedProfile && storedProfile !== this.profile) {
|
||||
this.setProfile(storedProfile);
|
||||
}
|
||||
|
||||
if (!this.sessionToken) {
|
||||
this.status = 'public';
|
||||
this.initializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await gatewayTrpc.me.query();
|
||||
if (!me) {
|
||||
this.clearSession();
|
||||
this.initializing = false;
|
||||
return;
|
||||
}
|
||||
this.user = {
|
||||
id: me.id,
|
||||
username: me.username,
|
||||
displayName: me.displayName,
|
||||
};
|
||||
this.status = 'authed';
|
||||
} catch {
|
||||
this.error = 'gateway_unavailable';
|
||||
this.status = 'public';
|
||||
this.initializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.profile) {
|
||||
this.initializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionToken = this.sessionToken;
|
||||
if (!sessionToken) {
|
||||
this.status = 'public';
|
||||
this.initializing = false;
|
||||
return;
|
||||
}
|
||||
const issued = await gatewayTrpc.auth.issueGameSession.mutate({
|
||||
sessionToken,
|
||||
profile: this.profile,
|
||||
});
|
||||
this.setGameToken(issued.gameToken);
|
||||
await this.refreshGeneralStatus();
|
||||
} catch {
|
||||
this.error = 'game_session_unavailable';
|
||||
this.status = 'authed';
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
|
||||
const getSessionToken = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.localStorage.getItem('sammo-session-token');
|
||||
};
|
||||
|
||||
export const gatewayTrpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc',
|
||||
headers() {
|
||||
const token = getSessionToken();
|
||||
return token ? { 'x-session-token': token } : {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,21 +1,21 @@
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/game-api';
|
||||
|
||||
const getSessionToken = (): string | null => {
|
||||
const getGameToken = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.localStorage.getItem('sammo-session-token');
|
||||
return window.localStorage.getItem('sammo-game-token');
|
||||
};
|
||||
|
||||
export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: '/api/trpc',
|
||||
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
|
||||
headers() {
|
||||
const token = getSessionToken();
|
||||
return token ? { 'x-session-token': token } : {};
|
||||
const token = getGameToken();
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen px-6 py-8">
|
||||
<h1 class="text-2xl font-semibold">Create or Possess General</h1>
|
||||
<p class="mt-2 text-sm text-amber-200/80">
|
||||
You are logged in but do not have a playable general yet.
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,10 +1,368 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import MapViewer from '../components/main/MapViewer.vue';
|
||||
import CommandListPanel from '../components/main/CommandListPanel.vue';
|
||||
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
|
||||
import CityBasicCard from '../components/main/CityBasicCard.vue';
|
||||
import NationBasicCard from '../components/main/NationBasicCard.vue';
|
||||
import MessagePanel from '../components/main/MessagePanel.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
|
||||
type GeneralContext = Awaited<ReturnType<typeof trpc.general.me.query>>;
|
||||
type LobbyInfo = Awaited<ReturnType<typeof trpc.lobby.info.query>>;
|
||||
type CommandTable = Awaited<ReturnType<typeof trpc.turns.getCommandTable.query>>;
|
||||
type WorldMapResult = Awaited<ReturnType<typeof trpc.world.getMap.query>>;
|
||||
type MessageBundle = Awaited<ReturnType<typeof trpc.messages.getRecent.query>>;
|
||||
|
||||
type GeneralInfo = NonNullable<GeneralContext>['general'];
|
||||
type CityInfo = NonNullable<GeneralContext>['city'];
|
||||
type NationInfo = NonNullable<GeneralContext>['nation'];
|
||||
|
||||
const session = useSessionStore();
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
|
||||
const mobileTabs = [
|
||||
{ key: 'map', label: '지도' },
|
||||
{ key: 'commands', label: '명령' },
|
||||
{ key: 'status', label: '상태' },
|
||||
{ key: 'world', label: '동향' },
|
||||
{ key: 'messages', label: '메시지' },
|
||||
] as const;
|
||||
|
||||
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
||||
|
||||
const mobileTab = ref<MobileTabKey>('map');
|
||||
const realtimeEnabled = ref(true);
|
||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('connected');
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const generalInfo = ref<GeneralInfo | null>(null);
|
||||
const cityInfo = ref<CityInfo | null>(null);
|
||||
const nationInfo = ref<NationInfo | null>(null);
|
||||
const lobbyInfo = ref<LobbyInfo | null>(null);
|
||||
const worldMap = ref<WorldMapResult | null>(null);
|
||||
const commandTable = ref<CommandTable | null>(null);
|
||||
const messages = ref<MessageBundle | null>(null);
|
||||
|
||||
const statusLine = computed(() => {
|
||||
if (!lobbyInfo.value) {
|
||||
return '상태 정보를 불러오는 중';
|
||||
}
|
||||
return `${lobbyInfo.value.year}년 ${lobbyInfo.value.month}월 · 턴 ${lobbyInfo.value.turnTerm}분`;
|
||||
});
|
||||
|
||||
const realtimeLabel = computed(() => {
|
||||
if (!realtimeEnabled.value) {
|
||||
return '끔';
|
||||
}
|
||||
if (realtimeStatus.value === 'connected') {
|
||||
return '연결됨';
|
||||
}
|
||||
return '대기중';
|
||||
});
|
||||
|
||||
const loadMainData = async () => {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const generalContext = await trpc.general.me.query();
|
||||
if (!generalContext) {
|
||||
generalInfo.value = null;
|
||||
cityInfo.value = null;
|
||||
nationInfo.value = null;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
generalInfo.value = generalContext.general;
|
||||
cityInfo.value = generalContext.city;
|
||||
nationInfo.value = generalContext.nation;
|
||||
|
||||
const generalId = generalContext.general.id;
|
||||
const [lobby, map, commands, messageData] = await Promise.all([
|
||||
trpc.lobby.info.query(),
|
||||
trpc.world.getMap.query({ generalId, showMe: true, useCache: true }),
|
||||
trpc.turns.getCommandTable.query({ generalId }),
|
||||
trpc.messages.getRecent.query({ generalId }),
|
||||
]);
|
||||
|
||||
lobbyInfo.value = lobby;
|
||||
worldMap.value = map;
|
||||
commandTable.value = commands;
|
||||
messages.value = messageData;
|
||||
} catch (err) {
|
||||
error.value = '메인 정보를 불러오지 못했습니다.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [session.isReady, session.hasGeneral],
|
||||
([ready, hasGeneral]) => {
|
||||
if (ready && hasGeneral) {
|
||||
void loadMainData();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(realtimeEnabled, (enabled) => {
|
||||
realtimeStatus.value = enabled ? 'connected' : 'paused';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen px-6 py-8">
|
||||
<h1 class="text-2xl font-semibold">Game Home</h1>
|
||||
<p class="mt-2 text-sm text-amber-200/80">
|
||||
Authenticated main screen placeholder.
|
||||
</p>
|
||||
<main class="main-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">전장 현황</h1>
|
||||
<p class="page-subtitle">{{ statusLine }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="toggle" :class="{ active: realtimeEnabled }" @click="realtimeEnabled = !realtimeEnabled">
|
||||
실시간 동기화: {{ realtimeLabel }}
|
||||
</button>
|
||||
<button class="ghost" @click="loadMainData">새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<div v-if="session.needsGeneral" class="warning">
|
||||
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
||||
</div>
|
||||
|
||||
<section v-if="isMobile" class="layout-mobile">
|
||||
<div class="mobile-tabs">
|
||||
<button
|
||||
v-for="tab in mobileTabs"
|
||||
:key="tab.key"
|
||||
:class="{ active: mobileTab === tab.key }"
|
||||
@click="mobileTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel" v-if="mobileTab === 'map'">
|
||||
<PanelCard title="지도">
|
||||
<MapViewer :map-data="worldMap" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel" v-if="mobileTab === 'commands'">
|
||||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
|
||||
<CommandListPanel :command-table="commandTable" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel" v-if="mobileTab === 'status'">
|
||||
<PanelCard title="장수 스탯">
|
||||
<GeneralBasicCard :general="generalInfo" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="도시 정보">
|
||||
<CityBasicCard :city="cityInfo" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="국가 정보">
|
||||
<NationBasicCard :nation="nationInfo" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel" v-if="mobileTab === 'world'">
|
||||
<PanelCard title="장수 동향">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
||||
</PanelCard>
|
||||
<PanelCard title="개인 기록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">개인 기록 영역</div>
|
||||
</PanelCard>
|
||||
<PanelCard title="중원 정세">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">
|
||||
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
||||
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel" v-if="mobileTab === 'messages'">
|
||||
<PanelCard title="메시지함">
|
||||
<MessagePanel :messages="messages" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="layout-desktop">
|
||||
<div class="stack">
|
||||
<PanelCard title="지도" subtitle="실시간 지도 + 도시 상황">
|
||||
<MapViewer :map-data="worldMap" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="중원 정세">
|
||||
<SkeletonLines v-if="loading" :lines="3" />
|
||||
<div v-else class="placeholder">
|
||||
<div>유저 {{ lobbyInfo?.userCnt ?? '-' }} / {{ lobbyInfo?.maxUserCnt ?? '-' }}</div>
|
||||
<div>NPC {{ lobbyInfo?.npcCnt ?? '-' }}</div>
|
||||
<div>세력 {{ lobbyInfo?.nationCnt ?? '-' }}</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<PanelCard title="메시지함">
|
||||
<MessagePanel :messages="messages" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역">
|
||||
<CommandListPanel :command-table="commandTable" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 스탯">
|
||||
<GeneralBasicCard :general="generalInfo" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="장수 동향">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">장수 동향은 실시간 스트림으로 연결 예정</div>
|
||||
</PanelCard>
|
||||
<PanelCard title="도시 정보">
|
||||
<CityBasicCard :city="cityInfo" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="국가 정보">
|
||||
<NationBasicCard :nation="nationInfo" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="개인 기록">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else class="placeholder">개인 기록 영역</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.toggle,
|
||||
.ghost {
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: rgba(16, 16, 16, 0.6);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f5b7b1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #f5d08a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.layout-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.layout-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-tabs button {
|
||||
padding: 6px 4px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-tabs button.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
.mobile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
"@sammo-ts/game-engine": ["../../app/game-engine/src/index.ts"],
|
||||
"@sammo-ts/game-engine/*": ["../../app/game-engine/src/*"],
|
||||
"@sammo-ts/game-api": ["../../app/game-api/src/index.ts"],
|
||||
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"]
|
||||
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"],
|
||||
"@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"],
|
||||
"@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user