-
-
{{ group.category }}
-
-
- {{ command.name }}
-
-
-
-
국가 명령
-
-
{{ group.category }}
-
-
- {{ command.name }}
-
-
-
+
+
+
+ 선택한 명령의 상세/예약 UI는 추후 이식 예정
diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue
new file mode 100644
index 0000000..885dcfa
--- /dev/null
+++ b/app/game-frontend/src/components/main/CommandSelectForm.vue
@@ -0,0 +1,208 @@
+
+
+
+
+
+
+
diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue
index a996dc9..668dce6 100644
--- a/app/game-frontend/src/components/main/MapViewer.vue
+++ b/app/game-frontend/src/components/main/MapViewer.vue
@@ -1,21 +1,77 @@
+
+
{{ mapSummary }}
+
+
+
+
+
@@ -23,12 +79,27 @@ const props = defineProps<{
지도 데이터를 불러오지 못했습니다.
@@ -37,25 +108,101 @@ const props = defineProps<{
.map-viewer {
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 12px;
+}
+
+.map-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.map-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.map-controls {
+ display: flex;
+ gap: 6px;
+}
+
+.map-toggle {
+ border: 1px solid rgba(201, 164, 90, 0.4);
+ padding: 4px 8px;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.map-toggle.active {
+ background: rgba(201, 164, 90, 0.2);
+}
+
+.map-body {
+ display: grid;
+ gap: 12px;
+}
+
+.map-placeholder {
+ border: 1px dashed rgba(201, 164, 90, 0.4);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ background: rgba(16, 16, 16, 0.6);
+}
+
+.map-placeholder-text {
+ font-size: 0.85rem;
+ color: rgba(232, 221, 196, 0.8);
}
.map-meta {
display: flex;
- flex-wrap: wrap;
gap: 12px;
- font-size: 0.8rem;
- color: rgba(232, 221, 196, 0.8);
+ font-size: 0.75rem;
+ color: rgba(232, 221, 196, 0.6);
}
-.map-placeholder {
- height: 240px;
- border: 1px dashed rgba(201, 164, 90, 0.4);
+.city-list {
display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.city-row {
+ display: grid;
+ grid-template-columns: 14px 1fr auto auto;
+ gap: 8px;
align-items: center;
- justify-content: center;
+ padding: 4px 6px;
+ border: 1px solid rgba(201, 164, 90, 0.2);
+ font-size: 0.75rem;
+}
+
+.city-row.mine {
+ background: rgba(201, 164, 90, 0.15);
+}
+
+.city-row .nation {
+ width: 12px;
+ height: 12px;
+ border: 1px solid rgba(232, 221, 196, 0.6);
+}
+
+.city-row .name {
+ color: rgba(232, 221, 196, 0.9);
+}
+
+.city-row .meta,
+.city-row .state {
+ color: rgba(232, 221, 196, 0.6);
+}
+
+.city-empty {
+ font-size: 0.8rem;
color: rgba(232, 221, 196, 0.6);
- background: rgba(16, 16, 16, 0.6);
}
.map-empty {
diff --git a/app/game-frontend/src/components/main/MessagePanel.vue b/app/game-frontend/src/components/main/MessagePanel.vue
index 4743452..ebd8355 100644
--- a/app/game-frontend/src/components/main/MessagePanel.vue
+++ b/app/game-frontend/src/components/main/MessagePanel.vue
@@ -1,10 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
-
+
메시지를 불러오지 못했습니다.
-
-
-
개인
-
-
{{ trimText(preview(props.messages.private)?.text ?? '') }}
-
{{ preview(props.messages.private)?.time }}
+
+
메시지가 없습니다.
+
+
+
{{ message.text }}
+
{{ message.time }}
-
메시지 없음
-
-
-
공공
-
-
{{ trimText(preview(props.messages.public)?.text ?? '') }}
-
{{ preview(props.messages.public)?.time }}
-
-
메시지 없음
-
-
-
국가
-
-
{{ trimText(preview(props.messages.national)?.text ?? '') }}
-
{{ preview(props.messages.national)?.time }}
-
-
메시지 없음
-
-
-
외교
-
-
{{ trimText(preview(props.messages.diplomacy)?.text ?? '') }}
-
{{ preview(props.messages.diplomacy)?.time }}
-
-
메시지 없음
+
@@ -82,32 +122,72 @@ const trimText = (value: string): string => {
gap: 12px;
}
-.bucket {
+.message-input {
+ display: grid;
+ grid-template-columns: minmax(90px, 120px) 1fr auto;
+ gap: 6px;
+}
+
+.message-select,
+.message-text {
+ background: rgba(16, 16, 16, 0.8);
+ border: 1px solid rgba(201, 164, 90, 0.4);
+ color: inherit;
+ padding: 6px;
+ font-size: 0.75rem;
+}
+
+.message-send {
+ border: 1px solid rgba(201, 164, 90, 0.4);
+ padding: 6px 10px;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.message-tabs {
display: flex;
- flex-direction: column;
- gap: 4px;
+ flex-wrap: wrap;
+ gap: 6px;
}
-.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 {
+.message-tabs button {
+ border: 1px solid rgba(201, 164, 90, 0.4);
+ padding: 4px 8px;
font-size: 0.7rem;
+ cursor: pointer;
+}
+
+.message-tabs button.active {
+ background: rgba(201, 164, 90, 0.2);
+}
+
+.message-tabs .refresh {
+ margin-left: auto;
+}
+
+.message-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.message-item {
+ border: 1px solid rgba(201, 164, 90, 0.2);
+ padding: 6px;
+ font-size: 0.75rem;
+}
+
+.message-item .time {
+ margin-top: 4px;
+ font-size: 0.65rem;
color: rgba(232, 221, 196, 0.6);
}
-.bucket-empty {
- font-size: 0.8rem;
- color: rgba(232, 221, 196, 0.5);
+.load-older {
+ border: 1px dashed rgba(201, 164, 90, 0.3);
+ padding: 6px;
+ font-size: 0.7rem;
+ cursor: pointer;
}
.empty {
diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts
new file mode 100644
index 0000000..8f87a70
--- /dev/null
+++ b/app/game-frontend/src/stores/mainDashboard.ts
@@ -0,0 +1,202 @@
+import { computed, ref } from 'vue';
+import { defineStore } from 'pinia';
+import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType } from '@sammo-ts/logic';
+import { trpc } from '../utils/trpc';
+
+const resolveErrorMessage = (value: unknown): string => {
+ if (value instanceof Error) {
+ return value.message;
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ return 'unknown_error';
+};
+
+export const useMainDashboardStore = defineStore('mainDashboard', () => {
+ type GeneralContext = Awaited
>;
+ type LobbyInfo = Awaited>;
+ type WorldMapResult = Awaited>;
+ type CommandTable = Awaited>;
+ type MessageBundle = Awaited>;
+
+ const loading = ref(false);
+ const error = ref(null);
+ const realtimeEnabled = ref(true);
+ const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('connected');
+
+ const generalContext = ref(null);
+ const lobbyInfo = ref(null);
+ const worldMap = ref(null);
+ const commandTable = ref(null);
+ const messages = ref(null);
+
+ const messageDraftText = ref('');
+ const targetMailbox = ref(MESSAGE_MAILBOX_PUBLIC);
+
+ const general = computed(() => generalContext.value?.general ?? null);
+ const city = computed(() => generalContext.value?.city ?? null);
+ const nation = computed(() => generalContext.value?.nation ?? null);
+ const generalId = computed(() => general.value?.id ?? null);
+ const nationId = computed(() => nation.value?.id ?? null);
+
+ const mailboxOptions = computed(() => {
+ const options: Array<{ label: string; value: number; disabled?: boolean }> = [
+ { label: '공공', value: MESSAGE_MAILBOX_PUBLIC },
+ ];
+ if (nationId.value) {
+ options.push({ label: '국가', value: MESSAGE_MAILBOX_NATIONAL_BASE + nationId.value });
+ } else {
+ options.push({ label: '국가', value: -1, disabled: true });
+ }
+ options.push({ label: '외교', value: -2, disabled: true });
+ options.push({ label: '개인', value: -3, disabled: true });
+ return options;
+ });
+
+ const statusLine = computed(() => {
+ if (!lobbyInfo.value) {
+ return '상태 정보를 불러오는 중';
+ }
+ return `${lobbyInfo.value.year}년 ${lobbyInfo.value.month}월 · 턴 ${lobbyInfo.value.turnTerm}분`;
+ });
+
+ const realtimeLabel = computed(() => {
+ if (!realtimeEnabled.value) {
+ return '끔';
+ }
+ return realtimeStatus.value === 'connected' ? '연결됨' : '대기중';
+ });
+
+ const setRealtimeEnabled = (enabled: boolean) => {
+ realtimeEnabled.value = enabled;
+ realtimeStatus.value = enabled ? 'connected' : 'paused';
+ };
+
+ const loadMainData = async () => {
+ if (loading.value) {
+ return;
+ }
+ loading.value = true;
+ error.value = null;
+
+ try {
+ const context = await trpc.general.me.query();
+ generalContext.value = context;
+
+ if (!context) {
+ loading.value = false;
+ return;
+ }
+
+ const id = context.general.id;
+ const [lobby, map, commands, messageData] = await Promise.all([
+ trpc.lobby.info.query(),
+ trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
+ trpc.turns.getCommandTable.query({ generalId: id }),
+ trpc.messages.getRecent.query({ generalId: id }),
+ ]);
+
+ lobbyInfo.value = lobby;
+ worldMap.value = map;
+ commandTable.value = commands;
+ messages.value = messageData;
+ } catch (err) {
+ error.value = resolveErrorMessage(err);
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ const refreshMessages = async () => {
+ const id = generalId.value;
+ if (!id) {
+ return;
+ }
+ try {
+ messages.value = await trpc.messages.getRecent.query({ generalId: id });
+ } catch (err) {
+ error.value = resolveErrorMessage(err);
+ }
+ };
+
+ const sendMessage = async () => {
+ const id = generalId.value;
+ if (!id) {
+ return;
+ }
+ const mailbox = targetMailbox.value;
+ const text = messageDraftText.value.trim();
+ if (!text) {
+ return;
+ }
+ if (mailbox <= 0) {
+ return;
+ }
+
+ try {
+ await trpc.messages.send.mutate({
+ generalId: id,
+ mailbox,
+ text,
+ });
+ messageDraftText.value = '';
+ await refreshMessages();
+ } catch (err) {
+ error.value = resolveErrorMessage(err);
+ }
+ };
+
+ const loadOlderMessages = async (type: MessageType) => {
+ const id = generalId.value;
+ if (!id || !messages.value) {
+ return;
+ }
+
+ const bucket = messages.value[type] ?? [];
+ const oldest = bucket[bucket.length - 1];
+ if (!oldest) {
+ return;
+ }
+
+ try {
+ const older = await trpc.messages.getOld.query({
+ generalId: id,
+ type,
+ to: oldest.id,
+ });
+ const merged = {
+ ...messages.value,
+ [type]: [...bucket, ...older[type]],
+ } as MessageBundle;
+ messages.value = merged;
+ } catch (err) {
+ error.value = resolveErrorMessage(err);
+ }
+ };
+
+ return {
+ loading,
+ error,
+ realtimeEnabled,
+ realtimeStatus,
+ generalContext,
+ general,
+ city,
+ nation,
+ lobbyInfo,
+ worldMap,
+ commandTable,
+ messages,
+ messageDraftText,
+ targetMailbox,
+ mailboxOptions,
+ statusLine,
+ realtimeLabel,
+ setRealtimeEnabled,
+ loadMainData,
+ refreshMessages,
+ sendMessage,
+ loadOlderMessages,
+ };
+});
diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue
index 29ed188..fff33ca 100644
--- a/app/game-frontend/src/views/MainView.vue
+++ b/app/game-frontend/src/views/MainView.vue
@@ -1,5 +1,6 @@
@@ -131,7 +70,11 @@ watch(realtimeEnabled, (enabled) => {
{{ statusLine }}
-
+
@@ -229,17 +194,17 @@ watch(realtimeEnabled, (enabled) => {
-
+
장수 동향은 실시간 스트림으로 연결 예정
-
+
-
+