Files
core2026/app/game-frontend/src/views/MySettingsView.vue
T

590 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { usePageExit } from '../composables/usePageExit';
import { useClockDisplay } from '../composables/useClockDisplay';
const { mode: clockDisplayMode } = useClockDisplay();
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import SortableStringList from '../components/ui/SortableStringList';
import { useGameFeedback } from '../composables/useGameFeedback';
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 { pageExitLabel, exitPage } = usePageExit();
const { success: showSuccessToast, error: showErrorToast } = useGameFeedback();
type MyGeneralResponse = Awaited<ReturnType<typeof trpc.general.me.query>>;
const data = ref<MyGeneralResponse | null>(null);
const selectedIconId = ref('');
const iconLoading = ref(false);
const iconSaving = ref(false);
const iconError = ref<string | null>(null);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause));
const loadIcons = async () => {
iconLoading.value = true;
iconError.value = null;
try {
const general = await trpc.general.me.query();
data.value = general;
selectedIconId.value =
iconChoices.value.find((icon) => icon.picture === general?.general.picture)?.id ??
iconChoices.value[0]?.id ??
'';
} catch (cause) {
iconError.value = errorText(cause);
} finally {
iconLoading.value = false;
}
};
const changeGeneralIcon = async () => {
if (!selectedIconId.value || iconSaving.value) return;
if (!confirm('선택한 전용 아이콘으로 바꿀까요? 변경 후 24시간 동안 다시 바꿀 수 없습니다.')) return;
iconSaving.value = true;
try {
await trpc.general.adjustIcon.mutate({
iconId: selectedIconId.value,
clientRequestId: crypto.randomUUID(),
});
showSuccessToast('전용 아이콘을 변경했습니다.');
await loadIcons();
} catch (cause) {
showErrorToast(`전용 아이콘 변경에 실패했습니다: ${errorText(cause)}`);
} finally {
iconSaving.value = false;
}
};
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 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('모바일 메인 레이아웃 순서를 저장했습니다.');
};
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(() => {
void loadIcons();
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 id="interface-settings" class="legacy-page bg0 interface-settings-page">
<div class="title-row">
<span>환 경 설 정</span>
<button class="legacy-button legacy-button--navigation" type="button" @click="exitPage">
{{ pageExitLabel }}
</button>
</div>
<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>
<div class="screen-mode-row">
<span
>가속 시 시간 표시 기준<br /><small
>이 기기에 저장하며, 2배속 중에는 시계를 눌러 바꿀 수 있습니다.</small
></span
>
<div class="button-group" role="radiogroup" aria-label="가속 시 시간 표시 기준">
<label><input v-model="clockDisplayMode" type="radio" value="game" />게임 시간 기준</label>
<label><input v-model="clockDisplayMode" type="radio" value="real" />실제 시간 기준</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>
</article>
<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>
<article
v-if="iconLoading || iconError || (data?.canChangeIcon && iconChoices.length)"
class="settings-column"
>
<h2 class="section-title">전용 아이콘</h2>
<p v-if="iconLoading" class="settings-content" role="status">아이콘 목록을 불러오는 중입니다.</p>
<div v-else-if="iconError" class="settings-content" role="alert">
<p>{{ iconError }}</p>
<button class="legacy-button legacy-button--secondary" type="button" @click="loadIcons">
다시 불러오기
</button>
</div>
<div v-else-if="data?.canChangeIcon && iconChoices.length" class="settings-content general-icon-action">
전용 아이콘 변경 (24시간에 1회)<br />
<span v-if="data.iconChangeAvailableAt" class="css-hint">
다음 변경 가능: {{ formatSeoulDateTime(data.iconChangeAvailableAt) }}
</span>
<div v-if="selectedIcon" class="selected-general-icon" aria-live="polite">
<img
:src="resolveGeneralIconUrl(selectedIcon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
<strong>{{ data.general.name }}</strong>
</div>
<div class="general-icon-list" role="radiogroup" aria-label="장수 전용 아이콘 선택">
<label v-for="icon in iconChoices" :key="icon.id" class="general-icon-choice">
<input v-model="selectedIconId" type="radio" :value="icon.id" />
<img
:src="resolveGeneralIconUrl(icon)"
width="64"
height="64"
alt=""
@error="useDefaultGeneralIcon"
/>
</label>
</div>
<button
class="legacy-button legacy-button--primary"
type="button"
:disabled="iconSaving || iconLoading"
@click="changeGeneralIcon"
>
아이콘 변경
</button>
</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>
/* UI 보조 글자는 부모의 80%가 아니라 공통 소형 크기를 사용한다. */
small {
font-size: var(--sammo-font-size-small);
}
.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: var(--sammo-font-size-normal);
line-height: 1.3;
}
.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: center;
border-bottom: 1px solid #666;
background-color: #14241b;
background-image: var(--sammo-texture-green);
color: skyblue;
font-size: var(--sammo-font-size-emphasis);
font-weight: 500;
}
.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;
}
.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;
}
.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: var(--sammo-font-size-normal)/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: var(--sammo-font-size-emphasis);
}
.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: var(--sammo-font-size-title);
}
.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: var(--sammo-font-size-title);
}
.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: var(--sammo-font-size-small);
}
.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;
}
.general-icon-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin: 6px 0;
}
.selected-general-icon {
display: flex;
max-width: 260px;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px auto;
padding: 6px 10px;
border: 1px solid #666;
background: rgb(23 42 82 / 70%);
}
.selected-general-icon img {
flex: 0 0 var(--sammo-general-icon-size);
width: var(--sammo-general-icon-size);
height: var(--sammo-general-icon-size);
object-fit: cover;
}
.general-icon-choice {
display: flex;
align-items: center;
gap: 2px;
}
.general-icon-action {
text-align: center;
}
@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>