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:
2026-01-16 15:57:56 +00:00
parent 15625f2c28
commit f8159728ca
20 changed files with 1342 additions and 18 deletions
+103
View File
@@ -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({
+1
View File
@@ -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>
+10
View File
@@ -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;
}
+6 -1
View File
@@ -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');
+21 -3
View File
@@ -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
View File
@@ -4,6 +4,7 @@ declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean;
requiresGeneral?: boolean;
requiresNoGeneral?: boolean;
publicOnly?: boolean;
}
}
+165 -2
View File
@@ -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 } : {};
},
}),
],
});
+5 -5
View File
@@ -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}` } : {};
},
}),
],
+10
View File
@@ -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>
+364 -6
View File
@@ -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>
+3 -1
View File
@@ -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": [