feat: synchronize account icons across game profiles

This commit is contained in:
2026-07-31 11:21:08 +00:00
parent c8adeeb47b
commit 5f20413552
87 changed files with 5755 additions and 280 deletions
+554 -15
View File
@@ -1,12 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number];
type IconSyncState = 'idle' | 'pending' | 'success' | 'error';
type IconSyncRow = IconSyncProfile & {
selected: boolean;
state: IconSyncState;
errorMessage: string;
};
const router = useRouter();
const account = ref<Account | null>(null);
@@ -20,6 +28,15 @@ const newPasswordConfirm = ref('');
const deletePassword = ref('');
const iconData = ref('');
const iconFilename = ref('');
const iconServerModalOpen = ref(false);
const iconServerBusy = ref(false);
const iconServerStaticFeedback = ref(false);
const iconServerMessage = ref('');
const iconServerRows = ref<IconSyncRow[]>([]);
const iconServerDialog = ref<HTMLElement | null>(null);
let iconServerReturnFocus: HTMLElement | null = null;
let previousBodyOverflow = '';
let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null;
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
@@ -29,6 +46,11 @@ const gradeLabel = computed(() => {
return '일반회원';
});
const selectedIconServerCount = computed(
() => iconServerRows.value.filter((row) => row.selected && row.state !== 'success').length
);
const failedIconServerCount = computed(() => iconServerRows.value.filter((row) => row.state === 'error').length);
const runAction = async (action: () => Promise<void>): Promise<void> => {
if (busy.value) return;
busy.value = true;
@@ -108,6 +130,148 @@ const scheduleDeletion = async (): Promise<void> => {
});
};
const focusIconServerModal = async (preferDialog = false): Promise<void> => {
await nextTick();
if (preferDialog) {
iconServerDialog.value?.focus();
return;
}
const target =
iconServerDialog.value?.querySelector<HTMLElement>('input:not(:disabled)') ??
iconServerDialog.value?.querySelector<HTMLElement>('button:not(:disabled)');
(target ?? iconServerDialog.value)?.focus();
};
const handleIconServerFocusIn = (event: FocusEvent): void => {
if (!iconServerModalOpen.value || !iconServerDialog.value) return;
if (event.target instanceof Node && iconServerDialog.value.contains(event.target)) return;
void focusIconServerModal(iconServerBusy.value);
};
const openIconServerModal = (profiles: IconSyncProfile[], returnFocus?: HTMLElement | null): void => {
iconServerReturnFocus =
returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
iconServerRows.value = profiles.map((profile) => ({
...profile,
selected: true,
state: 'idle',
errorMessage: '',
}));
iconServerMessage.value = profiles.length === 0 ? '현재 아이콘을 적용할 수 있는 실행 중인 서버가 없습니다.' : '';
iconServerModalOpen.value = true;
previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.addEventListener('focusin', handleIconServerFocusIn);
void focusIconServerModal(true);
};
const closeIconServerModal = (): void => {
if (iconServerBusy.value) return;
iconServerModalOpen.value = false;
document.removeEventListener('focusin', handleIconServerFocusIn);
document.body.style.overflow = previousBodyOverflow;
const returnFocus = iconServerReturnFocus;
iconServerReturnFocus = null;
void nextTick(() => returnFocus?.focus());
};
const showIconServerStaticFeedback = async (): Promise<void> => {
if (iconServerStaticTimer) {
clearTimeout(iconServerStaticTimer);
}
iconServerStaticFeedback.value = false;
await nextTick();
iconServerStaticFeedback.value = true;
iconServerStaticTimer = setTimeout(() => {
iconServerStaticFeedback.value = false;
iconServerStaticTimer = null;
}, 300);
};
const handleIconServerModalKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
void showIconServerStaticFeedback();
return;
}
if (event.key !== 'Tab' || !iconServerDialog.value) return;
const focusable = Array.from(
iconServerDialog.value.querySelectorAll<HTMLElement>('input:not(:disabled), button:not(:disabled)')
);
if (focusable.length === 0) {
event.preventDefault();
iconServerDialog.value.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (!iconServerDialog.value.contains(document.activeElement)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (
event.shiftKey &&
(document.activeElement === first || document.activeElement === iconServerDialog.value)
) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const syncIconToServer = async (row: IconSyncRow, token: string): Promise<void> => {
row.state = 'pending';
row.errorMessage = '';
try {
const issued = await trpc.auth.issueGameSession.mutate({
sessionToken: token,
profile: row.profileName,
});
const publicGameTrpc = createGameTrpc(row.profile, row.apiPort);
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
gatewayToken: issued.gameToken,
});
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
await gameTrpc.general.adjustIcon.mutate();
row.state = 'success';
} catch (error) {
row.state = 'error';
row.errorMessage = error instanceof Error ? error.message : '서버 적용에 실패했습니다.';
}
};
const applyIconToSelectedServers = async (retryFailedOnly = false): Promise<void> => {
if (iconServerBusy.value) return;
const targets = iconServerRows.value.filter(
(row) => row.selected && row.state !== 'success' && (!retryFailedOnly || row.state === 'error')
);
if (targets.length === 0) {
iconServerMessage.value = retryFailedOnly ? '재시도할 실패 서버가 없습니다.' : '적용할 서버를 선택해 주세요.';
return;
}
const token = sessionToken();
if (!token) {
iconServerMessage.value = '로그인이 필요합니다.';
return;
}
iconServerBusy.value = true;
iconServerMessage.value = '';
await focusIconServerModal(true);
try {
await Promise.all(targets.map((row) => syncIconToServer(row, token)));
const failed = targets.filter((row) => row.state === 'error').length;
iconServerMessage.value =
failed === 0
? '선택한 서버에 아이콘을 적용했습니다.'
: `${failed}개 서버에 적용하지 못했습니다. 실패한 서버만 다시 시도할 수 있습니다.`;
} finally {
iconServerBusy.value = false;
await focusIconServerModal(true);
}
};
const selectIcon = async (event: Event): Promise<void> => {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
@@ -126,7 +290,8 @@ const selectIcon = async (event: Event): Promise<void> => {
});
};
const changeIcon = async (): Promise<void> => {
const changeIcon = async (event?: Event): Promise<void> => {
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
@@ -135,23 +300,51 @@ const changeIcon = async (): Promise<void> => {
if (account.value) account.value = { ...account.value, iconUrl: result.iconUrl };
iconData.value = '';
iconFilename.value = '';
successMessage.value = '전용 아이콘을 변경했습니다.';
successMessage.value = result.flushPublished
? '전용 아이콘을 변경했습니다.'
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
openIconServerModal(result.profiles, returnFocus);
});
};
const deleteIcon = async (): Promise<void> => {
const deleteIcon = async (event?: Event): Promise<void> => {
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (!window.confirm('아이콘을 제거할까요?')) return;
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.deleteIcon.mutate({ sessionToken: token });
const result = await trpc.account.deleteIcon.mutate({ sessionToken: token });
if (account.value) account.value = { ...account.value, iconUrl: null };
successMessage.value = '전용 아이콘을 제거했습니다.';
successMessage.value = result.flushPublished
? '전용 아이콘을 제거했습니다.'
: '전용 아이콘을 제거했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
openIconServerModal(result.profiles, returnFocus);
});
};
const prepareIconSync = async (event?: Event): Promise<void> => {
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
const result = await trpc.account.prepareIconSync.query({ sessionToken: token });
openIconServerModal(result.profiles, returnFocus);
});
};
onMounted(() => {
void loadAccount();
});
onBeforeUnmount(() => {
document.removeEventListener('focusin', handleIconServerFocusIn);
if (iconServerModalOpen.value) {
document.body.style.overflow = previousBodyOverflow;
}
if (iconServerStaticTimer) {
clearTimeout(iconServerStaticTimer);
}
});
</script>
<template>
@@ -265,24 +458,41 @@ onMounted(() => {
<tr>
<th class="legacy-bg1">전용<br />아이콘</th>
<td class="icon-preview" colspan="2">
<img v-if="account.iconUrl" :src="account.iconUrl" width="64" height="64" alt="현재 아이콘" />
<img
v-if="account.iconUrl"
:src="account.iconUrl"
width="64"
height="64"
alt="현재 아이콘"
/>
<span v-else>기본 아이콘</span>
<img v-if="iconData" :src="iconData" width="64" height="64" alt="새 아이콘 미리보기" />
</td>
<td class="icon-actions" colspan="3">
<input class="skin-input filename" :value="iconFilename" readonly aria-label="선택한 아이콘" />
<input
class="skin-input filename"
:value="iconFilename"
readonly
aria-label="선택한 아이콘"
/>
<label class="skin-button file-button">
찾아보기
<input
type="file"
accept=".avif,.webp,.jpg,.jpeg,.png,.gif"
@change="selectIcon"
/>
<input type="file" accept=".avif,.webp,.jpg,.jpeg,.png,.gif" @change="selectIcon" />
</label>
<button class="skin-button half-button" type="button" :disabled="busy" @click="changeIcon">
<button
class="skin-button half-button"
type="button"
:disabled="busy || iconServerBusy"
@click="changeIcon"
>
아이콘 변경
</button>
<button class="skin-button half-button" type="button" :disabled="busy" @click="deleteIcon">
<button
class="skin-button half-button"
type="button"
:disabled="busy || iconServerBusy"
@click="deleteIcon"
>
아이콘 제거
</button>
</td>
@@ -298,6 +508,16 @@ onMounted(() => {
<th class="legacy-bg1">도움말</th>
<td colspan="5" class="help-cell">
<p>아이콘은 64 x 64픽셀 ~ 128 x 128픽셀 사이, 50KB 이하 파일만 가능합니다.</p>
<p>
<button
class="skin-button"
type="button"
:disabled="busy || iconServerBusy"
@click="prepareIconSync"
>
현재 아이콘 서버 적용
</button>
</p>
<p class="warning">탈퇴시 1개월간 정보가 보존되며, 1개월간 재가입이 불가능합니다.</p>
</td>
</tr>
@@ -306,6 +526,109 @@ onMounted(() => {
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
</div>
<Teleport to="body">
<Transition name="icon-server-modal">
<div
v-if="iconServerModalOpen"
class="icon-server-backdrop"
:class="{ 'is-static': iconServerStaticFeedback }"
data-testid="icon-server-modal"
@click.self="showIconServerStaticFeedback"
@keydown="handleIconServerModalKeydown"
>
<section
ref="iconServerDialog"
class="icon-server-dialog"
role="dialog"
tabindex="-1"
:aria-busy="iconServerBusy"
aria-modal="true"
aria-labelledby="icon-server-title"
>
<header class="icon-server-header">
<h2 id="icon-server-title">완료되었습니다.<br /> 아이콘을 적용할 서버를 선택하세요.</h2>
<button
class="icon-server-dismiss"
type="button"
aria-label="닫기"
data-testid="icon-server-close"
:disabled="iconServerBusy"
@click="closeIconServerModal"
>
&times;
</button>
</header>
<div class="icon-server-body">
<form class="icon-server-form" @submit.prevent="applyIconToSelectedServers(false)">
<label
v-for="(row, index) in iconServerRows"
:key="row.profileName"
class="icon-server-option"
:for="`icon-server-${index}`"
>
<input
:id="`icon-server-${index}`"
v-model="row.selected"
type="checkbox"
:disabled="iconServerBusy || row.state === 'success'"
:data-testid="`icon-server-option-${row.profileName}`"
/>
<span>{{ row.korName }}</span>
<span
class="icon-server-result"
:class="`is-${row.state}`"
:data-testid="`icon-server-result-${row.profileName}`"
>
<template v-if="row.state === 'pending'">적용 중...</template>
<template v-else-if="row.state === 'success'">적용됨</template>
<template v-else-if="row.state === 'error'">
실패<span v-if="row.errorMessage">: {{ row.errorMessage }}</span>
</template>
</span>
</label>
</form>
<p
v-if="iconServerMessage"
class="icon-server-message"
:class="{ 'is-error': failedIconServerCount > 0 }"
role="status"
>
{{ iconServerMessage }}
</p>
</div>
<footer class="icon-server-footer">
<button
class="modal-button secondary"
type="button"
:disabled="iconServerBusy"
@click="closeIconServerModal"
>
닫기
</button>
<button
v-if="failedIconServerCount > 0"
class="modal-button retry"
type="button"
data-testid="icon-server-retry"
:disabled="iconServerBusy"
@click="applyIconToSelectedServers(true)"
>
실패 서버 재시도
</button>
<button
class="modal-button primary"
type="button"
data-testid="icon-server-apply"
:disabled="iconServerBusy || selectedIconServerCount === 0"
@click="applyIconToSelectedServers(false)"
>
{{ iconServerBusy ? '적용 중...' : '서버 적용' }}
</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
</DefaultLayout>
</template>
@@ -535,9 +858,225 @@ onMounted(() => {
color: #ff9c9c;
}
.icon-server-backdrop {
position: fixed;
z-index: 1050;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
background: rgb(0 0 0 / 50%);
padding: 0 16px;
transition: opacity 0.15s linear;
}
.icon-server-modal-enter-from,
.icon-server-modal-leave-to {
opacity: 0;
}
.icon-server-dialog {
width: 500px;
max-width: calc(100vw - 32px);
margin: 28px auto;
border: 1px solid #444;
border-radius: 8px;
background: #303030;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
font-size: 16px;
line-height: 24px;
text-align: left;
transform: none;
transition: transform 0.3s ease-out;
}
.icon-server-modal-enter-from .icon-server-dialog,
.icon-server-modal-leave-to .icon-server-dialog {
transform: translateY(-50px);
}
.icon-server-backdrop.is-static .icon-server-dialog {
transform: scale(1.02);
}
.icon-server-header {
position: relative;
display: flex;
align-items: flex-start;
border-bottom: 1px solid #444;
padding: 16px;
}
.icon-server-header h2 {
width: 297px;
flex: 0 0 297px;
margin: 0;
font-size: 20px;
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
}
.icon-server-dismiss {
width: 26px;
height: 30px;
margin: auto 0 auto auto;
border: 2px outset #fff;
border-radius: 0;
background: #6b6b6b;
padding: 1px 6px;
color: #fff;
font: inherit;
font-weight: 400;
line-height: 24px;
opacity: 1;
}
.icon-server-dismiss:disabled {
cursor: default;
opacity: 0.2;
}
.icon-server-body {
padding: 16px;
}
.icon-server-form {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.icon-server-option {
display: inline-flex;
align-items: flex-start;
gap: 5px;
margin-right: 7px;
cursor: pointer;
line-height: 24px;
}
.icon-server-option input {
width: 16px;
height: 16px;
margin: 3px 0 0;
}
.icon-server-option:has(input:disabled) {
cursor: default;
}
.icon-server-result {
max-width: 260px;
color: #aaa;
font-size: 13px;
line-height: 22px;
}
.icon-server-result.is-success {
color: #9cff9c;
}
.icon-server-result.is-error,
.icon-server-message.is-error {
color: #ff9c9c;
}
.icon-server-message {
margin: 14px 0 0;
color: #ddd;
font-size: 14px;
}
.icon-server-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid #444;
padding: 16px;
}
.modal-button {
border: 1px solid transparent;
min-height: 40px;
border-radius: 6px;
padding: 6px 12px;
color: #fff;
font: inherit;
font-weight: 700;
line-height: 1.5;
white-space: nowrap;
transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.modal-button.secondary {
width: 54px;
border-color: #3d3d3d;
background: #444;
}
.modal-button.primary {
width: 86px;
border-color: #325172;
background: #375a7f;
}
.modal-button.retry {
border-color: #a65f00;
background: #a65f00;
}
.modal-button:disabled {
cursor: default;
opacity: 0.65;
}
.modal-button:focus,
.icon-server-dismiss:focus,
.icon-server-option input:focus {
outline: 0;
}
@media (max-width: 600px) {
#account-container {
margin-left: 0;
}
.icon-server-backdrop {
padding-right: 8px;
padding-left: 8px;
}
.icon-server-dialog {
max-width: calc(100vw - 16px);
margin: 8px auto;
}
.icon-server-footer {
flex-wrap: wrap;
}
.icon-server-header h2 {
width: auto;
min-width: 0;
flex: 1 1 auto;
white-space: normal;
}
.icon-server-dismiss {
flex: 0 0 26px;
}
.icon-server-result,
.icon-server-message {
min-width: 0;
overflow-wrap: anywhere;
}
}
</style>
+9 -8
View File
@@ -10,7 +10,6 @@ type AdminUserSanctions = {
warningCount?: number;
flags?: string[];
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<
string,
{
@@ -29,7 +28,6 @@ type AdminSanctionsPatch = {
warningCount?: number | null;
flags?: string[] | null;
notes?: string | null;
profileIconResetAt?: string | null;
serverRestrictions?: Record<
string,
{
@@ -50,6 +48,7 @@ type AdminUser = {
oauthType: string;
oauthId?: string;
email?: string;
profileIconResetAt?: string;
createdAt: string;
};
@@ -188,7 +187,10 @@ type AdminClient = {
}) => Promise<{ sanctions: AdminUserSanctions }>;
};
resetProfileIcon: {
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
mutate: (input: { userId: string }) => Promise<{
profileIconResetAt: string;
flushPublished: boolean;
}>;
};
forceDelete: {
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
@@ -971,12 +973,11 @@ const resetProfileIcon = async () => {
});
userResult.value = {
...userResult.value,
sanctions: {
...userResult.value.sanctions,
profileIconResetAt: result.profileIconResetAt,
},
profileIconResetAt: result.profileIconResetAt,
};
profileIconStatus.value = '아이콘 초기화 요청 완료';
profileIconStatus.value = result.flushPublished
? '아이콘 초기화 요청 완료'
: '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.';
} catch (error) {
profileIconStatus.value = '아이콘 초기화 실패';
}
+12 -3
View File
@@ -43,18 +43,27 @@ const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gat
const formatGraceEndsAt = (value: string | null | undefined): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const encodeLegacyIconPath = (value: string): string =>
value
.split('/')
.map((segment) => {
if (segment === '.') return '%2E';
if (segment === '..') return '%2E%2E';
return encodeURIComponent(segment);
})
.join('/');
const resolveGeneralPicture = (general: LobbyGeneral): string => {
const picture = general.picture?.trim() || 'default.jpg';
return general.imageServer
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
: `/image/icons/${encodeURIComponent(picture)}`;
: `/image/icons/${encodeLegacyIconPath(picture)}`;
};
const handleGeneralPictureError = (event: Event): void => {
const image = event.currentTarget as HTMLImageElement;
if (image.dataset.fallbackApplied === 'true') {
if (image.dataset.generalIconFallbackSource === image.currentSrc) {
return;
}
image.dataset.fallbackApplied = 'true';
image.dataset.generalIconFallbackSource = image.currentSrc;
image.src = '/image/icons/default.jpg';
};