merge: 최신 main을 빈 특기 획득 나이 표시에 통합한다

This commit is contained in:
2026-08-22 05:05:53 +00:00
26 changed files with 1169 additions and 821 deletions
+1
View File
@@ -42,6 +42,7 @@ body {
/* These redesigned screens own a true handheld layout. */
#app:has(.responsive-settings-page),
#app:has(.interface-settings-page),
#app:has(#tournament-container),
#app:has(#tournament-betting-container),
#app:has(#personnel-container) {
@@ -4,9 +4,9 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor';
import MainNavigationLink from './MainNavigationLink.vue';
import {
buildGlobalNavigation,
buildNationNavigation,
isNationNavigationEnabled,
isNavigationConfigured,
nationNavigation,
quickNavigation,
type MainNavigationLink as MainNavigationLinkItem,
type MainNavigationEntry,
@@ -18,6 +18,7 @@ import { useMenuPopup } from './useMenuPopup';
const props = defineProps<{
access: NationNavigationAccess;
tournamentStage: number;
tournamentType: number | null;
nationColor: string;
npcMode: number;
realtimeEnabled: boolean;
@@ -35,9 +36,12 @@ const emit = defineEmits<{
const { setRoot, openId, close, toggle } = useMenuPopup();
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
const nationEntries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType));
const nationMenuColor = computed(() => props.nationColor || '#000000');
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
const isActive = (link: MainNavigationLinkItem) =>
link.highlightStage === props.tournamentStage ||
link.highlightStages?.some((stage) => stage === props.tournamentStage) === true;
const onQuick = (item: QuickNavigationItem) => {
close();
@@ -148,7 +152,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<span class="dropup-caret" aria-hidden="true"></span>
</button>
<ul v-if="openId === 'nation'" id="mobile-nation-menu" class="bottom-popup" role="menu">
<template v-for="entry in nationNavigation" :key="entry.id">
<template v-for="entry in nationEntries" :key="entry.id">
<li v-if="entry.kind === 'link'" role="none">
<MainNavigationLink
:link="entry"
@@ -165,6 +169,7 @@ const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
<MainNavigationLink
:link="item"
:enabled="isNationNavigationEnabled(item, access)"
:active="isActive(item)"
compact
role="menuitem"
@navigate="close()"
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import MainNavigationLink from './MainNavigationLink.vue';
import {
buildNationNavigation,
isNationNavigationEnabled,
nationNavigation,
type MainNavigationLink as MainNavigationLinkItem,
type NationNavigationAccess,
} from './mainNavigation';
@@ -12,11 +13,15 @@ import { legacyNationTextColor } from '../../utils/legacyNationColor';
const props = defineProps<{
access: NationNavigationAccess;
tournamentStage: number;
tournamentType: number | null;
nationColor: string;
}>();
const { setRoot, openId, close, toggle } = useMenuPopup();
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
const entries = computed(() => buildNationNavigation(props.tournamentStage, props.tournamentType));
const isActive = (link: MainNavigationLinkItem) =>
link.highlightStage === props.tournamentStage ||
link.highlightStages?.some((stage) => stage === props.tournamentStage) === true;
</script>
<template>
@@ -27,7 +32,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
:class="{ 'dark-label': legacyNationTextColor(nationColor) === '#000000' }"
aria-label="국가 메뉴"
>
<template v-for="entry in nationNavigation" :key="entry.id">
<template v-for="entry in entries" :key="entry.id">
<MainNavigationLink
v-if="entry.kind === 'link'"
:link="entry"
@@ -59,6 +64,7 @@ const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props
<MainNavigationLink
:link="item"
:enabled="isNationNavigationEnabled(item, access)"
:active="isActive(item)"
role="menuitem"
@navigate="close()"
/>
@@ -4,6 +4,7 @@ import type {
RuntimeNavigationLink,
} from '@sammo-ts/common/navigation/menuConfig';
import defaultNavigationJson from '../../../../../resources/navigation.json';
import { resolveTournamentMainPresentation } from '../../utils/tournamentNavigation';
export type NationAccessRule =
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
@@ -11,7 +12,8 @@ export type NationAccessRule =
export type MainNavigationLink = RuntimeNavigationLink & {
compactLabel?: string;
access?: NationAccessRule;
highlightStage?: 1 | 6;
highlightStage?: number;
highlightStages?: readonly number[];
unavailableReason?: string;
};
@@ -144,14 +146,37 @@ export const nationNavigation: MainNavigationEntry[] = [
access: 'nation-secret',
},
{
kind: 'link',
id: 'tournament',
label: '토 너 먼 트',
compactLabel: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
highlightStage: 1,
kind: 'split',
id: 'tournament-betting',
main: {
kind: 'link',
id: 'tournament',
label: '토 너 먼 트',
compactLabel: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
},
items: [
{
kind: 'link',
id: 'tournament-menu',
label: '토너먼트',
to: '/tournament',
newTab: true,
access: 'always',
highlightStage: 1,
},
{
kind: 'link',
id: 'betting',
label: '베팅장',
to: '/betting',
newTab: true,
access: 'always',
highlightStage: 6,
},
],
},
{
kind: 'link',
@@ -218,18 +243,42 @@ export const nationNavigation: MainNavigationEntry[] = [
},
],
},
{
kind: 'link',
id: 'betting',
label: '베 팅 장',
compactLabel: '베팅장',
to: '/betting',
newTab: true,
access: 'always',
highlightStage: 6,
},
{ kind: 'link', id: 'my-settings', label: '화면 설정', to: '/my-settings', access: 'always' },
];
export const buildNationNavigation = (
tournamentStage: number,
tournamentType: number | null,
source: MainNavigationEntry[] = nationNavigation
): MainNavigationEntry[] =>
source.map((entry) => {
if (entry.kind !== 'split' || entry.id !== 'tournament-betting') return entry;
const presentation = resolveTournamentMainPresentation(tournamentStage, tournamentType);
const main: MainNavigationLink = {
...entry.main,
label: presentation.label,
compactLabel: presentation.compactLabel,
to: presentation.to,
highlightStage: presentation.active ? tournamentStage : undefined,
highlightStages: undefined,
};
const items = entry.items.map((item) => {
if (item.kind !== 'link') return item;
if (item.id === 'tournament-menu') {
return {
...item,
highlightStage: presentation.active && !presentation.bettingActive ? tournamentStage : undefined,
};
}
if (item.id === 'betting') {
return { ...item, highlightStage: presentation.bettingActive ? tournamentStage : undefined };
}
return item;
});
return { ...entry, main, items };
});
export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider> = [
{ kind: 'divider', id: 'quick-nation-heading', label: '국가 정보' },
{ id: 'policy', label: '방침', tab: 'map', selector: '[data-main-target="policy"]' },
@@ -249,7 +298,8 @@ export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider>
{ id: 'diplomacy-message', label: '외교', tab: 'messages', selector: '[data-message-type="diplomacy"]' },
];
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href || link.action);
export const isNavigationConfigured = (link: MainNavigationLink): boolean =>
Boolean(link.to || link.href || link.action);
export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
const rule = link.access ?? 'always';
@@ -3,6 +3,8 @@ defineProps<{
activePage: 'tournament' | 'betting';
title: string;
}>();
const closeWindow = (): void => window.close();
</script>
<template>
@@ -35,15 +37,13 @@ defineProps<{
</button>
</RouterLink>
</nav>
<RouterLink v-slot="{ navigate }" custom to="/">
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="navigate"
>
닫기
</button>
</RouterLink>
<button
class="legacy-button legacy-button--navigation legacy-button--fixed-height close-button"
type="button"
@click="closeWindow"
>
닫기
</button>
</div>
</header>
</template>
+2
View File
@@ -3,11 +3,13 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './assets/main.css';
import { applyStoredCustomCss } from './utils/customCss';
import { installImageAssetCssVariables } from './utils/imageAssets';
import { installScreenModeViewport } from './utils/screenModeViewport';
installImageAssetCssVariables();
installScreenModeViewport();
applyStoredCustomCss();
const app = createApp(App);
+3 -1
View File
@@ -25,6 +25,7 @@ const NotFoundView = () => import('../views/NotFoundView.vue');
const TournamentView = () => import('../views/TournamentView.vue');
const BettingView = () => import('../views/BettingView.vue');
const MyPageView = () => import('../views/MyPageView.vue');
const MySettingsView = () => import('../views/MySettingsView.vue');
const BoardView = () => import('../views/BoardView.vue');
const DiplomacyView = () => import('../views/DiplomacyView.vue');
const BestGeneralView = () => import('../views/BestGeneralView.vue');
@@ -333,7 +334,8 @@ const routes = [
},
{
path: '/my-settings',
redirect: '/my-page',
name: 'my-settings',
component: MySettingsView,
meta: {
requiresAuth: true,
requiresGeneral: true,
+13 -1
View File
@@ -49,6 +49,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
type TournamentType = NonNullable<TournamentState>['type'];
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext;
@@ -76,6 +77,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
worldHistory?: RecentRecord[];
frontStatus?: FrontStatus | null;
tournamentStage?: number;
tournamentType?: TournamentType | null;
};
type DashboardTabMessage =
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
@@ -117,6 +119,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null);
const tournamentStage = ref(0);
const tournamentType = ref<TournamentType | null>(null);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
@@ -440,6 +443,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (patch.tournamentStage !== undefined) {
tournamentStage.value = patch.tournamentStage;
}
if (patch.tournamentType !== undefined) {
tournamentType.value = patch.tournamentType;
}
if (patch.contextRevision !== undefined) {
contextRevision = patch.contextRevision;
contextSourceRevision = patch.contextSourceRevision ?? null;
@@ -487,6 +493,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.worldHistory = toRaw(worldHistory.value);
patch.frontStatus = toRaw(frontStatus.value);
patch.tournamentStage = tournamentStage.value;
patch.tournamentType = tournamentType.value;
return patch;
};
@@ -637,6 +644,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
}
if (tournamentState !== undefined) {
tournamentStage.value = tournamentState?.stage ?? 0;
tournamentType.value = tournamentState?.type ?? null;
}
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
@@ -765,7 +773,10 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.worldHistory = nextWorldHistory;
}
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
if (tournamentState !== undefined) {
patch.tournamentStage = tournamentState?.stage ?? 0;
patch.tournamentType = tournamentState?.type ?? null;
}
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
@@ -1290,6 +1301,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
worldHistory,
frontStatus,
tournamentStage,
tournamentType,
surveyNotice,
messageDraftText,
targetMailbox,
+19
View File
@@ -0,0 +1,19 @@
export const CUSTOM_CSS_KEY = 'sam_customCSS';
export const CUSTOM_CSS_STYLE_ID = 'sammo-custom-css';
export const applyCustomCss = (text: string): void => {
if (typeof document === 'undefined') return;
let style = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = CUSTOM_CSS_STYLE_ID;
document.head.appendChild(style);
}
style.textContent = text;
};
export const applyStoredCustomCss = (): void => {
if (typeof window === 'undefined') return;
applyCustomCss(window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '');
};
@@ -0,0 +1,30 @@
export interface TournamentMainPresentation {
label: string;
compactLabel: string;
to: '/tournament' | '/betting';
active: boolean;
bettingActive: boolean;
}
const tournamentLabels = [
{ label: '전 력 전', compactLabel: '전력전' },
{ label: '통 솔 전', compactLabel: '통솔전' },
{ label: '일 기 토', compactLabel: '일기토' },
{ label: '설 전', compactLabel: '설전' },
] as const;
export const resolveTournamentMainPresentation = (
tournamentStage: number,
tournamentType: number | null
): TournamentMainPresentation => {
const active = tournamentStage > 0 || tournamentType !== null;
const bettingActive = tournamentStage === 6;
const tournamentLabel = tournamentType === null ? undefined : tournamentLabels[tournamentType];
return {
label: bettingActive ? '베 팅 장' : (tournamentLabel?.label ?? '토 너 먼 트'),
compactLabel: bettingActive ? '베팅장' : (tournamentLabel?.compactLabel ?? '토너먼트'),
to: bettingActive ? '/betting' : '/tournament',
active,
bettingActive,
};
};
+4
View File
@@ -83,6 +83,7 @@ const {
worldHistory,
frontStatus,
tournamentStage,
tournamentType,
surveyNotice,
messageDraftText,
targetMailbox,
@@ -301,6 +302,7 @@ watch(
class="nation-menu-middle"
:access="nationAccess"
:tournament-stage="tournamentStage"
:tournament-type="tournamentType"
:nation-color="nationColor"
/>
</div>
@@ -480,6 +482,7 @@ watch(
class="nation-menu-middle"
:access="nationAccess"
:tournament-stage="tournamentStage"
:tournament-type="tournamentType"
:nation-color="nationColor"
/>
<section class="record-zone">
@@ -570,6 +573,7 @@ watch(
v-if="isMobile"
:access="nationAccess"
:tournament-stage="tournamentStage"
:tournament-type="tournamentType"
:nation-color="nationColor"
:npc-mode="npcMode"
:realtime-enabled="realtimeEnabled"
+4 -330
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
@@ -10,19 +9,9 @@ import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import GeneralInformationPanel from '../components/main/GeneralInformationPanel.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport';
import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder,
MOBILE_MAIN_PANEL_DEFINITIONS,
moveMobileMainPanel,
saveMobileMainPanelOrder,
type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder';
const CUSTOM_CSS_KEY = 'sam_customCSS';
const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart';
const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback();
const { error: showErrorToast, showDialog } = useGameFeedback();
type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction';
type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item';
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
@@ -52,14 +41,8 @@ const dieOnPrestartStatusLoading = ref(false);
const dieOnPrestartStatusError = ref<string | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const selectedIconId = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
const session = useSessionStore();
let cssTimer: number | null = null;
const readPendingDieOnPrestartId = (): string => {
const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY);
return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID();
@@ -179,30 +162,6 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
mobileLayoutDialog.value?.showModal();
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
};
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
};
const resetMobileLayoutOrder = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
const applyMobileLayoutOrder = () => {
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
mobileLayoutDialog.value?.close();
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
};
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false);
const showVacation = computed(() => autorunUser.value.limit_minutes === 0);
@@ -233,16 +192,6 @@ const formatSelectionAvailableAt = computed(() => {
return formatSeoulDateTime(value);
});
const applyCustomCss = (text: string) => {
let style = document.getElementById('sammo-custom-css') as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = 'sammo-custom-css';
document.head.appendChild(style);
}
style.textContent = text;
};
const loadLog = async (type: LogType, beforeId?: number) => {
if (logLoading[type]) return;
logLoading[type] = true;
@@ -370,32 +319,13 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin
trpc.general.dropItem.mutate({ itemType: item.key })
);
watch(screenMode, (mode) => {
localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
}, 500);
});
onMounted(() => {
const storedMode = localStorage.getItem(SCREEN_MODE_KEY);
screenMode.value = storedMode === '500px' || storedMode === '1000px' ? storedMode : 'auto';
customCss.value = localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
void loadPage();
});
</script>
<template>
<main id="container" class="legacy-page bg0 responsive-settings-page" :class="`screen-${screenMode}`">
<main id="container" class="legacy-page bg0 responsive-settings-page">
<div class="title-row">
<span> </span>
<div class="title-actions">
@@ -613,29 +543,6 @@ onMounted(() => {
<br /><br />
</div>
<div class="screen-mode-row">
<span>500px/1000px 모드<br />(모바일 전용, 즉시 설정)</span>
<div class="button-group">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
<div class="mobile-layout-setting-row">
<span>
모바일 레이아웃 순서 바꾸기<br />
<small>500px 메인 화면의 패널 순서를 기기에 저장합니다.</small>
</span>
<button
class="legacy-button legacy-button--primary mobile-layout-open"
type="button"
@click="openMobileLayoutDialog"
>
순서 바꾸기
</button>
</div>
<div class="item-title">아이템 파기</div>
<div class="item-group">
<button
@@ -649,11 +556,6 @@ onMounted(() => {
{{ item.displayName ?? '-' }}
</button>
</div>
<label class="custom-css">
개인용 CSS <span>{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" />
</label>
</div>
</section>
@@ -691,67 +593,6 @@ onMounted(() => {
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
<form method="dialog">
<button
class="legacy-button legacy-button--secondary"
type="submit"
aria-label="모바일 레이아웃 순서 닫기"
>
×
</button>
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
기본값
</button>
<form method="dialog">
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
</form>
<button
class="legacy-button legacy-button--primary mobile-layout-apply"
type="button"
@click="applyMobileLayoutOrder"
>
적용
</button>
</div>
</dialog>
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
</template>
@@ -773,12 +614,6 @@ onMounted(() => {
.my-page-mobile-scroll-spacer {
display: none;
}
.legacy-page.screen-500px {
max-width: 500px;
}
.legacy-page.screen-1000px {
max-width: 1000px;
}
.title-row {
height: 54px;
display: flex;
@@ -815,8 +650,7 @@ textarea {
background: #6b6b6b;
font: inherit;
}
.legacy-page .legacy-button,
.mobile-layout-dialog .legacy-button {
.legacy-page .legacy-button {
letter-spacing: 0;
}
button {
@@ -924,139 +758,6 @@ button:disabled {
margin: 12px 0;
color: #f66;
}
.screen-mode-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
margin: 14px 0;
}
.mobile-layout-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
align-items: center;
gap: 8px;
margin: 14px 0;
}
.mobile-layout-setting-row small {
color: orange;
}
.mobile-layout-open {
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog {
box-sizing: border-box;
width: min(460px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
margin: auto;
overflow: auto;
border: 1px solid #777;
border-radius: 4px;
padding: 12px;
background: #171717 var(--sammo-texture-walnut);
color: #fff;
font: 14px/1.3 var(--sammo-font-sans);
}
.mobile-layout-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.mobile-layout-dialog__header,
.mobile-layout-dialog__actions,
.mobile-layout-move-buttons {
display: flex;
align-items: center;
}
.mobile-layout-dialog__header {
justify-content: space-between;
gap: 12px;
}
.mobile-layout-dialog__header h2,
.mobile-layout-dialog p {
margin: 0 0 10px;
}
.mobile-layout-dialog__header h2 {
color: skyblue;
font-size: 18px;
}
.mobile-layout-dialog__header form,
.mobile-layout-dialog__actions form {
margin: 0;
}
.mobile-layout-dialog__header button {
min-width: 32px;
min-height: 32px;
font-size: 20px;
}
.mobile-layout-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.mobile-layout-list > li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
min-height: 44px;
align-items: center;
border: 1px solid #777;
background: #172a52 var(--sammo-texture-blue);
cursor: grab;
}
.mobile-layout-list > li:active {
cursor: grabbing;
}
.mobile-layout-handle {
color: #aaa;
text-align: center;
font-size: 20px;
}
.mobile-layout-label {
min-width: 0;
font-weight: 700;
}
.mobile-layout-position {
display: inline-grid;
width: 22px;
height: 22px;
place-items: center;
margin-right: 4px;
border: 1px solid #7186a7;
border-radius: 50%;
font-size: 12px;
}
.mobile-layout-move-buttons {
gap: 4px;
padding-right: 5px;
}
.mobile-layout-move-buttons button {
width: 36px;
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog__actions {
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
}
.mobile-layout-dialog__actions button {
min-height: 34px;
padding: 4px 10px;
}
.mobile-layout-dialog__actions .mobile-layout-apply {
font-weight: 700;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.item-title {
margin-top: 12px;
}
@@ -1068,17 +769,6 @@ button:disabled {
.item-group button {
min-height: 30px;
}
.custom-css {
display: block;
}
.custom-css textarea {
display: block;
width: 420px;
max-width: 100%;
height: 150px;
color: #fff;
background: #000;
}
.log-panel {
min-height: 180px;
}
@@ -1153,24 +843,8 @@ button:disabled {
.settings-column {
padding: 10px 12px;
}
.screen-mode-row {
grid-template-columns: 1fr;
gap: 6px;
}
.mobile-layout-setting-row {
grid-template-columns: 1fr;
}
.mobile-layout-open {
width: 100%;
}
.button-group {
overflow-x: auto;
}
.item-group {
grid-template-columns: repeat(2, 1fr);
}
.custom-css textarea {
width: 100%;
}
}
</style>
+383 -402
View File
@@ -1,453 +1,434 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import PanelCard from '../components/ui/PanelCard.vue';
import SkeletonLines from '../components/ui/SkeletonLines.vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback';
import { trpc } from '../utils/trpc';
const CUSTOM_CSS_KEY = 'sammo-custom-css';
const SCREEN_MODE_KEY = 'sammo-screen-mode';
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
type WorldStateSnapshot = {
config: Record<string, unknown>;
meta: Record<string, unknown>;
} | null;
type ScreenMode = 'auto' | '500px' | '1000px';
type SettingForm = {
tnmt: number;
defence_train: number;
use_treatment: number;
use_auto_nation_turn: number;
};
const loading = ref(false);
const error = ref<string | null>(null);
const data = ref<MyGeneralResponse | null>(null);
const worldState = ref<WorldStateSnapshot>(null);
const form = reactive<SettingForm>({
tnmt: 1,
defence_train: 80,
use_treatment: 10,
use_auto_nation_turn: 1,
});
import { applyCustomCss, CUSTOM_CSS_KEY } from '../utils/customCss';
import {
DEFAULT_MOBILE_MAIN_PANEL_ORDER,
loadMobileMainPanelOrder,
MOBILE_MAIN_PANEL_DEFINITIONS,
moveMobileMainPanel,
saveMobileMainPanelOrder,
type MobileMainPanelId,
} from '../utils/mobileMainPanelOrder';
import {
normalizeScreenMode,
SCREEN_MODE_CHANGE_EVENT,
SCREEN_MODE_KEY,
type ScreenMode,
} from '../utils/screenModeViewport';
const { success: showSuccessToast } = useGameFeedback();
const screenMode = ref<ScreenMode>('auto');
const customCss = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
let cssTimer: number | null = null;
const resolveErrorMessage = (value: unknown): string => {
if (value instanceof Error) {
return value.message;
}
if (typeof value === 'string') {
return value;
}
return 'unknown_error';
};
const resolveNumber = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};
const canSave = computed(() => {
const remaining = data.value?.settings?.myset ?? null;
if (remaining === null) {
return true;
}
return remaining > 0;
});
const remainingLabel = computed(() => {
const remaining = data.value?.settings?.myset ?? null;
if (remaining === null) {
return '설정 저장 제한 없음';
}
return `설정저장은 이달중 ${remaining}회 남았습니다.`;
});
const showAutoNationTurn = computed(() => {
const meta = (worldState.value?.meta ?? {}) as Record<string, unknown>;
const autorunUser = (meta.autorun_user ?? {}) as Record<string, unknown>;
const options = (autorunUser.options ?? {}) as Record<string, unknown>;
return Boolean(options.chief);
});
const penalties = computed(() => {
const list = data.value?.penalties ?? {};
return Object.entries(list);
});
const applyCustomCss = (text: string) => {
if (typeof window === 'undefined') {
return;
}
const id = 'sammo-custom-css';
let style = document.getElementById(id) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = id;
document.head.appendChild(style);
}
style.textContent = text;
};
const saveCustomCss = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
applyCustomCss(customCss.value);
};
const loadScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
const value = window.localStorage.getItem(SCREEN_MODE_KEY);
if (value === '500px' || value === '1000px') {
screenMode.value = value;
} else {
screenMode.value = 'auto';
}
};
const saveScreenMode = () => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(SCREEN_MODE_KEY, screenMode.value);
};
const loadSettings = async () => {
if (loading.value) {
return;
}
loading.value = true;
error.value = null;
try {
const general = await trpc.general.me.query();
const world = await trpc.world.getState.query() as
| {
config?: Record<string, unknown>;
meta?: Record<string, unknown>;
}
| null;
data.value = general;
worldState.value = world
? {
config: world.config ?? {},
meta: world.meta ?? {},
}
: null;
if (general?.settings) {
form.tnmt = general.settings.tnmt;
form.defence_train = general.settings.defence_train;
form.use_treatment = general.settings.use_treatment;
form.use_auto_nation_turn = general.settings.use_auto_nation_turn;
}
} catch (err) {
error.value = resolveErrorMessage(err);
} finally {
loading.value = false;
}
};
const saveSettings = async () => {
if (!canSave.value) {
showErrorToast('설정 저장 가능 횟수가 없습니다.');
return;
}
try {
await trpc.general.setMySetting.mutate({
tnmt: resolveNumber(form.tnmt, 1),
defence_train: resolveNumber(form.defence_train, 80),
use_treatment: resolveNumber(form.use_treatment, 10),
use_auto_nation_turn: resolveNumber(form.use_auto_nation_turn, 1),
});
await loadSettings();
showSuccessToast('설정을 저장했습니다.');
} catch (err) {
showErrorToast(`설정 저장에 실패했습니다: ${resolveErrorMessage(err)}`);
}
};
watch(
() => screenMode.value,
() => {
saveScreenMode();
}
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
);
watch(
() => customCss.value,
() => {
if (cssTimer) {
window.clearTimeout(cssTimer);
}
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
saveCustomCss();
cssSaving.value = false;
}, 400);
}
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
mobileLayoutDialog.value?.showModal();
window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector<HTMLButtonElement>('button')?.focus());
};
const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
};
const resetMobileLayoutOrder = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
const applyMobileLayoutOrder = () => {
mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value);
mobileLayoutDialog.value?.close();
showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.');
};
watch(screenMode, (mode) => {
window.localStorage.setItem(SCREEN_MODE_KEY, mode);
document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT));
});
watch(customCss, (text) => {
if (cssTimer !== null) window.clearTimeout(cssTimer);
cssSaving.value = true;
cssTimer = window.setTimeout(() => {
window.localStorage.setItem(CUSTOM_CSS_KEY, text);
applyCustomCss(text);
cssSaving.value = false;
cssTimer = null;
}, 500);
});
onMounted(() => {
if (typeof window !== 'undefined') {
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
applyCustomCss(customCss.value);
}
loadScreenMode();
void loadSettings();
screenMode.value = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY));
customCss.value = window.localStorage.getItem(CUSTOM_CSS_KEY) ?? '';
});
onBeforeUnmount(() => {
if (cssTimer === null) return;
window.clearTimeout(cssTimer);
window.localStorage.setItem(CUSTOM_CSS_KEY, customCss.value);
applyCustomCss(customCss.value);
});
</script>
<template>
<main class="settings-page">
<header class="page-header">
<div>
<h1 class="page-title">게임 설정</h1>
<p class="page-subtitle"> 정보 설정을 관리합니다.</p>
</div>
<div class="header-actions">
<RouterLink class="ghost" to="/my-page"> 정보</RouterLink>
<button class="ghost" @click="loadSettings">새로고침</button>
</div>
</header>
<main id="interface-settings" class="legacy-page bg0 interface-settings-page">
<div class="title-row">
<span> </span>
<RouterLink class="legacy-button legacy-button--navigation" to="/">돌아가기</RouterLink>
</div>
<div v-if="error" class="error">{{ error }}</div>
<section class="layout-grid">
<div class="stack">
<PanelCard title="게임 옵션" subtitle="설정 저장 시 즉시 반영됩니다.">
<SkeletonLines v-if="loading" :lines="4" />
<div v-else class="form-grid">
<label class="form-field">
<span>토너먼트 참가</span>
<select v-model.number="form.tnmt">
<option :value="0">수동 참여</option>
<option :value="1">자동 참여</option>
</select>
</label>
<label class="form-field">
<span>환약 사용 기준</span>
<select v-model.number="form.use_treatment">
<option :value="10">경상</option>
<option :value="21">중상</option>
<option :value="41">심각</option>
<option :value="61">위독</option>
<option :value="100">사용 안함</option>
</select>
</label>
<label v-if="showAutoNationTurn" class="form-field">
<span>자동 사령턴 허용</span>
<select v-model.number="form.use_auto_nation_turn">
<option :value="1">허용</option>
<option :value="0">허용 안함</option>
</select>
</label>
<label class="form-field">
<span>수비 설정</span>
<select v-model.number="form.defence_train">
<option :value="90">훈련 90</option>
<option :value="80">훈련 80</option>
<option :value="60">훈련 60</option>
<option :value="40">훈련 40</option>
<option :value="999">절대 수비</option>
</select>
</label>
<div class="hint">{{ remainingLabel }}</div>
<button class="primary" type="button" :disabled="!canSave" @click="saveSettings">
설정 저장
</button>
</div>
</PanelCard>
<PanelCard title="징계 목록" subtitle="설정 저장 시 갱신됩니다.">
<SkeletonLines v-if="loading" :lines="3" />
<div v-else class="penalty-list">
<div v-if="penalties.length === 0" class="empty">징계가 없습니다.</div>
<div v-for="[key, value] in penalties" :key="key" class="penalty-item">
{{ key }}: {{ value }}
<section class="settings-grid">
<article class="settings-column">
<h2 class="section-title">화면 크기와 배치</h2>
<div class="settings-content">
<div class="screen-mode-row">
<span>500px/1000px 모드<br /><small>모바일 화면 폭을 즉시 바꿉니다.</small></span>
<div class="button-group" role="radiogroup" aria-label="화면 모드">
<label><input v-model="screenMode" type="radio" value="auto" />자동</label>
<label><input v-model="screenMode" type="radio" value="500px" />500px</label>
<label><input v-model="screenMode" type="radio" value="1000px" />1000px</label>
</div>
</div>
</PanelCard>
</div>
<div class="stack">
<PanelCard title="화면 모드" subtitle="모바일 전용 설정입니다.">
<div class="screen-mode">
<label>
<input v-model="screenMode" type="radio" value="auto" />
자동
</label>
<label>
<input v-model="screenMode" type="radio" value="500px" />
500px
</label>
<label>
<input v-model="screenMode" type="radio" value="1000px" />
1000px
</label>
<div class="mobile-layout-setting-row">
<span>
모바일 메인 레이아웃<br />
<small>500px 메인 화면의 패널 순서를 기기에 저장합니다.</small>
</span>
<button
class="legacy-button legacy-button--primary mobile-layout-open"
type="button"
@click="openMobileLayoutDialog"
>
순서 바꾸기
</button>
</div>
</PanelCard>
</div>
</article>
<PanelCard title="개인용 CSS" subtitle="변경 사항은 자동 저장됩니다.">
<textarea v-model="customCss" class="css-input" rows="8" />
<div class="hint">{{ cssSaving ? '저장 중...' : '자동 저장 완료' }}</div>
</PanelCard>
</div>
<article class="settings-column">
<h2 class="section-title">개인용 CSS</h2>
<div class="settings-content">
<label class="custom-css">
브라우저에 적용할 CSS <span aria-live="polite">{{ cssSaving ? '(저장 중)' : '' }}</span>
<textarea id="custom_css" v-model="customCss" aria-label="개인용 CSS" />
</label>
<p class="css-hint">변경 사항은 기기에 자동 저장됩니다.</p>
</div>
</article>
</section>
<footer class="legacy-credit">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer>
</main>
<dialog ref="mobileLayoutDialog" class="mobile-layout-dialog" aria-labelledby="mobile-layout-dialog-title">
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
<form method="dialog">
<button
class="legacy-button legacy-button--secondary"
type="submit"
aria-label="모바일 레이아웃 순서 닫기"
>
×
</button>
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<SortableStringList v-model:list="mobileLayoutOrder" tag="ol" class="mobile-layout-list">
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
class="legacy-button legacy-button--secondary"
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button class="legacy-button legacy-button--secondary" type="button" @click="resetMobileLayoutOrder">
기본값
</button>
<form method="dialog">
<button class="legacy-button legacy-button--secondary" type="submit">취소</button>
</form>
<button
class="legacy-button legacy-button--primary mobile-layout-apply"
type="button"
@click="applyMobileLayoutOrder"
>
적용
</button>
</div>
</dialog>
</template>
<style scoped>
.settings-page {
min-height: 100vh;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
.legacy-page {
box-sizing: border-box;
width: 100%;
max-width: 1000px;
min-height: 0;
margin: 0 auto;
padding: 0;
color: #fff;
background-color: transparent;
background-image: var(--sammo-texture-walnut);
font-family: var(--sammo-font-sans);
font-size: 14px;
line-height: 1.3;
}
.page-header {
.title-row {
min-height: 54px;
display: flex;
align-content: flex-start;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
border: 1px solid #666;
background: transparent;
}
.title-row > span {
flex-basis: 100%;
height: 18px;
}
.title-row .legacy-button {
min-width: 90px;
}
.css-hint {
margin: 0;
color: orange;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-column {
border: 1px solid #666;
background-image: var(--sammo-texture-walnut);
}
.section-title {
min-height: 34px;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: var(--sammo-texture-green);
color: skyblue;
font-size: 1.25em;
font-weight: 500;
}
.page-title {
font-size: 1.6rem;
.settings-content {
padding: 10px 18px;
}
.screen-mode-row,
.mobile-layout-setting-row {
display: grid;
align-items: center;
gap: 8px;
margin: 8px 0 14px;
}
.screen-mode-row {
grid-template-columns: 160px 1fr;
}
.mobile-layout-setting-row {
grid-template-columns: minmax(0, 1fr) 128px;
}
.screen-mode-row small,
.mobile-layout-setting-row small,
.css-hint {
color: orange;
}
.button-group {
display: flex;
}
.button-group label {
padding: 5px 8px;
border: 1px solid #666;
background: #26384d;
}
.button-group input {
margin-right: 4px;
}
.mobile-layout-open {
min-height: 34px;
font-weight: 700;
}
.page-subtitle {
color: rgba(232, 221, 196, 0.7);
.custom-css {
display: block;
}
.custom-css textarea {
box-sizing: border-box;
display: block;
width: 420px;
max-width: 100%;
height: 150px;
border: 1px solid #777;
border-radius: 0;
color: #fff;
background: #000;
font: inherit;
}
.css-hint {
margin-top: 6px;
}
.header-actions {
.legacy-credit {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
}
.mobile-layout-dialog {
box-sizing: border-box;
width: min(460px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
margin: auto;
overflow: auto;
border: 1px solid #777;
border-radius: 4px;
padding: 12px;
background: #171717 var(--sammo-texture-walnut);
color: #fff;
font: 14px/1.3 var(--sammo-font-sans);
}
.mobile-layout-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.mobile-layout-dialog__header,
.mobile-layout-dialog__actions,
.mobile-layout-move-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.layout-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-grid {
display: grid;
.mobile-layout-dialog__header {
justify-content: space-between;
gap: 12px;
}
.form-field {
.mobile-layout-dialog__header h2,
.mobile-layout-dialog p {
margin: 0 0 10px;
}
.mobile-layout-dialog__header h2 {
color: skyblue;
font-size: 18px;
}
.mobile-layout-dialog__header form,
.mobile-layout-dialog__actions form {
margin: 0;
}
.mobile-layout-dialog__header button {
min-width: 32px;
min-height: 32px;
font-size: 20px;
}
.mobile-layout-list {
display: grid;
gap: 6px;
font-size: 0.9rem;
margin: 0;
padding: 0;
list-style: none;
}
.form-field select {
padding: 6px 8px;
border: 1px solid rgba(201, 164, 90, 0.4);
background: rgba(12, 12, 12, 0.7);
color: inherit;
}
.primary {
border: 1px solid rgba(201, 164, 90, 0.6);
background: rgba(201, 164, 90, 0.2);
color: inherit;
padding: 8px 12px;
font-size: 0.85rem;
cursor: pointer;
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.screen-mode {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.85rem;
}
.css-input {
width: 100%;
background: rgba(12, 12, 12, 0.7);
color: inherit;
border: 1px solid rgba(201, 164, 90, 0.3);
padding: 8px;
}
.penalty-list {
.mobile-layout-list > li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
min-height: 44px;
align-items: center;
border: 1px solid #777;
background: #172a52 var(--sammo-texture-blue);
cursor: grab;
}
.mobile-layout-list > li:active {
cursor: grabbing;
}
.mobile-layout-handle {
color: #aaa;
text-align: center;
font-size: 20px;
}
.mobile-layout-label {
min-width: 0;
font-weight: 700;
}
.mobile-layout-position {
display: inline-grid;
width: 22px;
height: 22px;
place-items: center;
margin-right: 4px;
border: 1px solid #7186a7;
border-radius: 50%;
font-size: 12px;
}
.mobile-layout-move-buttons {
gap: 4px;
padding-right: 5px;
}
.mobile-layout-move-buttons button {
width: 36px;
min-height: 34px;
font-weight: 700;
}
.mobile-layout-dialog__actions {
justify-content: flex-end;
gap: 6px;
font-size: 0.85rem;
margin-top: 12px;
}
.penalty-item {
color: #f08a5d;
.mobile-layout-dialog__actions button {
min-height: 34px;
padding: 4px 10px;
}
.hint {
color: rgba(232, 221, 196, 0.7);
font-size: 0.8rem;
.mobile-layout-dialog__actions .mobile-layout-apply {
font-weight: 700;
}
.ghost {
border: 1px solid rgba(201, 164, 90, 0.5);
background: transparent;
color: inherit;
padding: 6px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.error {
color: #f08a5d;
font-size: 0.9rem;
}
.empty {
color: rgba(232, 221, 196, 0.6);
}
@media (max-width: 1024px) {
.layout-grid {
@media (max-width: 939.98px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.screen-mode-row,
.mobile-layout-setting-row {
grid-template-columns: 1fr;
}
.button-group {
overflow-x: auto;
}
.mobile-layout-open,
.custom-css textarea {
width: 100%;
}
}
</style>
+86 -4
View File
@@ -5,6 +5,7 @@ import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { formatLog } from '../utils/formatLog';
import { trpc } from '../utils/trpc';
import { resolveTournamentSectionVisibility, resolveTournamentStageName } from '../utils/tournamentStatus';
@@ -112,10 +113,30 @@ const gamesOf = (participant: Snapshot['participants'][number] | undefined): num
participant ? (participant.win ?? 0) + (participant.draw ?? 0) + (participant.lose ?? 0) : '';
const pointsOf = (participant: Snapshot['participants'][number] | undefined): number | '' =>
participant ? (participant.win ?? 0) * 3 + (participant.draw ?? 0) : '';
const groupFightLogsAt = (stage: 2 | 4, groupStart: 0 | 10) =>
Array.from({ length: 8 }, (_, index) =>
(snapshot.value?.matches ?? []).find(
(match) => match.stage === stage && match.groupId === groupStart + index && (match.log?.length ?? 0) > 0
)
);
const preliminaryFightLogs = computed(() => groupFightLogsAt(2, 0));
const finalFightLogs = computed(() => groupFightLogsAt(4, 10));
const showPreliminaryFightLogs = computed(
() => [2, 3].includes(snapshot.value?.state?.stage ?? -1) && preliminaryFightLogs.value.some(Boolean)
);
const showFinalFightLogs = computed(
() => [4, 5].includes(snapshot.value?.state?.stage ?? -1) && finalFightLogs.value.some(Boolean)
);
const currentMatch = computed(() => {
const state = snapshot.value?.state;
if (!state || state.stage < 7 || state.stage > 10) return null;
return matchesAt(state.stage).find((match) => !match.winnerId) ?? matchesAt(state.stage)[state.phase] ?? null;
if (!state) return null;
const logStage = state.stage === 0 && state.winnerId ? 10 : state.stage;
if (logStage < 7 || logStage > 10) return null;
return (
matchesAt(logStage)
.filter((match) => (match.log?.length ?? 0) > 0)
.at(-1) ?? null
);
});
const revealMyPreliminaryGroup = async (): Promise<number | undefined> => {
@@ -230,9 +251,11 @@ const start = async () => {
:tournament-type="snapshot?.state?.type ?? 0"
/>
<section v-if="currentMatch" class="fight bg0">
<section v-if="currentMatch" class="fight bg0" aria-label="현재 토너먼트 전투 로그">
<h2>{{ nameOf(currentMatch.attackerId) }} vs {{ nameOf(currentMatch.defenderId) }}</h2>
<p v-for="(line, index) in currentMatch.log ?? []" :key="index">{{ line }}</p>
<!-- formatLog rebuilds only the shared Ref log allowlist. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-for="(line, index) in currentMatch.log ?? []" :key="index" v-html="formatLog(line)" />
</section>
</template>
@@ -297,6 +320,21 @@ const start = async () => {
</tbody>
</table>
</section>
<section v-if="showFinalFightLogs" class="fight-log-grid bg0" aria-label="본선 조별 전투 로그">
<article
v-for="(match, groupIndex) in finalFightLogs"
:key="`final-fight-${groupIndex}`"
class="fight-log"
:data-fight-log-group="groupIndex"
>
<h3>{{ groupNames[groupIndex] }} 전투 로그</h3>
<template v-if="match">
<!-- formatLog rebuilds only the shared Ref log allowlist. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-for="(line, index) in match.log ?? []" :key="index" v-html="formatLog(line)" />
</template>
</article>
</section>
</template>
<template v-if="sectionVisibility.preliminary">
@@ -361,6 +399,21 @@ const start = async () => {
</tbody>
</table>
</section>
<section v-if="showPreliminaryFightLogs" class="fight-log-grid bg0" aria-label="예선 조별 전투 로그">
<article
v-for="(match, groupIndex) in preliminaryFightLogs"
:key="`preliminary-fight-${groupIndex}`"
class="fight-log"
:data-fight-log-group="groupIndex"
>
<h3>{{ groupNames[groupIndex] }} 전투 로그</h3>
<template v-if="match">
<!-- formatLog rebuilds only the shared Ref log allowlist. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-for="(line, index) in match.log ?? []" :key="index" v-html="formatLog(line)" />
</template>
</article>
</section>
</template>
<div class="legacy-bracket-table-signature" hidden>
@@ -509,6 +562,31 @@ button:not(.legacy-button):focus-visible {
.fight p {
margin: 2px 10px;
}
.fight-log-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: start;
gap: 8px;
padding: 8px;
text-align: left;
}
.fight-log {
min-width: 0;
border: 1px solid #555;
overflow-wrap: anywhere;
}
.fight-log h3 {
margin: 0;
padding: 4px;
background: #000;
color: orange;
font-size: 14px;
font-weight: 400;
text-align: center;
}
.fight-log p {
margin: 2px 6px;
}
.groups-title {
color: orange;
}
@@ -604,6 +682,10 @@ td {
.group-grid table.mobile-active {
display: table;
}
.fight-log-grid {
grid-template-columns: minmax(0, 1fr);
padding: 6px 0;
}
.group-grid th,
.group-grid td {
height: 31px;