Files
core2026/app/gateway-frontend/src/views/AccountView.vue
T

1173 lines
39 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 { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from '../composables/useToast';
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);
const loading = ref(true);
const busy = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const { success: showSuccessToast, error: showErrorToast } = useToast();
watch(successMessage, (value) => value && showSuccessToast(value), { flush: 'sync' });
watch(errorMessage, (value) => value && showErrorToast(value), { flush: 'sync' });
const currentPassword = ref('');
const newPassword = ref('');
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');
const gradeLabel = computed(() => {
if (!account.value) return '-';
if (account.value.roles.some((role) => role.includes('admin') || role === 'superuser')) return '관리자';
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;
errorMessage.value = '';
successMessage.value = '';
try {
await action();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '요청을 처리하지 못했습니다.';
} finally {
busy.value = false;
}
};
const loadAccount = async (): Promise<void> => {
const token = sessionToken();
if (!token) {
await router.replace('/');
return;
}
try {
account.value = await trpc.account.get.query({ sessionToken: token });
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '계정 정보를 불러오지 못했습니다.';
} finally {
loading.value = false;
}
};
const changePassword = async (): Promise<void> => {
if (newPassword.value !== newPasswordConfirm.value) {
errorMessage.value = '새 비밀번호 확인이 일치하지 않습니다.';
return;
}
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
const [currentCredential, newCredential] = await Promise.all([
sealPassword(currentPassword.value),
sealPassword(newPassword.value),
]);
await trpc.account.changePassword.mutate({
sessionToken: token,
currentCredential,
newCredential,
});
currentPassword.value = '';
newPassword.value = '';
newPasswordConfirm.value = '';
successMessage.value = '비밀번호를 변경했습니다.';
});
};
const disallowThirdPartyUse = async (): Promise<void> => {
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.disallowThirdPartyUse.mutate({ sessionToken: token });
if (account.value) account.value = { ...account.value, thirdPartyUse: false };
successMessage.value = '개인정보 제3자 제공 동의를 철회했습니다.';
});
};
const scheduleDeletion = async (): Promise<void> => {
if (!window.confirm('탈퇴를 신청하면 현재 세션이 종료됩니다. 계속하시겠습니까?')) return;
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
const currentCredential = await sealPassword(deletePassword.value);
const result = await trpc.account.scheduleDeletion.mutate({
sessionToken: token,
currentCredential,
});
window.localStorage.removeItem('sammo-session-token');
successMessage.value = `${formatServerDateTime(result.deleteAfter, { format: 'date' })}까지 정보가 보존됩니다.`;
await router.replace('/');
});
};
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];
if (!file) return;
if (file.size > 50 * 1024) {
errorMessage.value = '아이콘 파일은 50KB 이하여야 합니다.';
input.value = '';
return;
}
iconFilename.value = file.name;
iconData.value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(new Error('아이콘 파일을 읽지 못했습니다.'));
reader.readAsDataURL(file);
});
};
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('로그인이 필요합니다.');
if (!iconData.value) throw new Error('아이콘 파일을 선택해주세요.');
const result = await trpc.account.changeIcon.mutate({ sessionToken: token, imageData: iconData.value });
if (account.value) account.value = { ...account.value, iconUrl: result.iconUrl };
iconData.value = '';
iconFilename.value = '';
successMessage.value = result.flushPublished
? '전용 아이콘을 변경했습니다.'
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
await loadAccount();
openIconServerModal(result.profiles, returnFocus);
});
};
const setPreferredIcon = async (iconId: string): Promise<void> => {
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.setPreferredIcon.mutate({ sessionToken: token, iconId });
successMessage.value = '대표 전용 아이콘을 변경했습니다.';
await loadAccount();
});
};
const retireIcon = async (iconId: string): Promise<void> => {
if (
!window.confirm('목록에서 내린 아이콘은 다시 선택할 수 없습니다. 과거 기록의 이미지는 보존됩니다. 계속할까요?')
) {
return;
}
await runAction(async () => {
const token = sessionToken();
if (!token) throw new Error('로그인이 필요합니다.');
await trpc.account.retireIcon.mutate({ sessionToken: token, iconId });
successMessage.value = '전용 아이콘을 목록에서 내렸습니다. 기존 URL과 과거 기록은 보존됩니다.';
await loadAccount();
});
};
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('로그인이 필요합니다.');
const result = await trpc.account.deleteIcon.mutate({ sessionToken: token });
if (account.value) account.value = { ...account.value, iconUrl: null };
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>
<DefaultLayout>
<div id="account-container">
<table id="account-table" class="legacy-bg0">
<caption class="section-title legacy-bg2">
<RouterLink class="skin-button back-button" to="/lobby">돌아가기</RouterLink>
</caption>
<colgroup>
<col class="label-column" />
<col span="5" />
</colgroup>
<thead>
<tr>
<th colspan="6" class="legacy-bg1"> </th>
</tr>
</thead>
<tbody v-if="account">
<tr>
<th class="legacy-bg1">ID</th>
<td colspan="5">{{ account.username }}</td>
</tr>
<tr>
<th class="legacy-bg1">닉네임</th>
<td colspan="5">{{ account.displayName }}</td>
</tr>
<tr>
<th class="legacy-bg1">등급</th>
<td colspan="2">{{ gradeLabel }}</td>
<td colspan="3">{{ account.roles.join(', ') || '-' }}</td>
</tr>
<tr>
<th class="legacy-bg1">가입일시</th>
<td colspan="2">{{ formatServerDateTime(account.createdAt) }}</td>
<td colspan="3">
개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
<button
v-if="account.thirdPartyUse"
class="skin-button compact"
type="button"
:disabled="busy"
@click="disallowThirdPartyUse"
>
철회
</button>
</td>
</tr>
<tr>
<th class="legacy-bg1">인증 방식</th>
<td colspan="5">{{ account.oauthType }}</td>
</tr>
<tr>
<th class="legacy-bg1"></th>
<th class="legacy-bg1" colspan="2">회원 탈퇴</th>
<th class="legacy-bg1" colspan="3">비밀번호 변경</th>
</tr>
<tr>
<th class="legacy-bg1">정보<br />수정</th>
<td class="action-cell" colspan="2">
<form @submit.prevent="scheduleDeletion">
<label for="delete-password">현재 비밀번호</label>
<input
id="delete-password"
v-model="deletePassword"
class="skin-input"
type="password"
autocomplete="current-password"
/>
<button class="skin-button full-button" type="submit" :disabled="busy">탈퇴신청</button>
</form>
</td>
<td colspan="3">
<form class="password-form" @submit.prevent="changePassword">
<label for="current-password">현재 비밀번호</label>
<input
id="current-password"
v-model="currentPassword"
class="skin-input"
type="password"
autocomplete="current-password"
/>
<label for="new-password"> 비밀번호</label>
<input
id="new-password"
v-model="newPassword"
class="skin-input"
type="password"
autocomplete="new-password"
/>
<label for="confirm-password">비밀번호 확인</label>
<input
id="confirm-password"
v-model="newPasswordConfirm"
class="skin-input"
type="password"
autocomplete="new-password"
/>
<button class="skin-button full-button" type="submit" :disabled="busy">
비밀번호 변경
</button>
</form>
</td>
</tr>
<tr>
<th class="legacy-bg1"></th>
<th class="legacy-bg1" colspan="2">현재 / 신규</th>
<th class="legacy-bg1" colspan="3">전용 아이콘 변경</th>
</tr>
<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="현재 아이콘"
/>
<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="선택한 아이콘"
/>
<label class="skin-button file-button">
찾아보기
<input type="file" accept=".avif,.webp,.jpg,.jpeg,.png,.gif" @change="selectIcon" />
</label>
<button
class="skin-button half-button"
type="button"
:disabled="busy || iconServerBusy"
@click="changeIcon"
>
아이콘 변경
</button>
<button
class="skin-button half-button"
type="button"
:disabled="busy || iconServerBusy"
@click="deleteIcon"
>
아이콘 제거
</button>
</td>
</tr>
<tr>
<th class="legacy-bg1">전콘<br />목록</th>
<td colspan="5">
<div class="account-icon-library">
<div v-for="icon in account.icons" :key="icon.id" class="account-icon-card">
<img :src="icon.url" width="64" height="64" alt="전용 아이콘" />
<span v-if="icon.picture === account.preferredPicture" class="preferred-label"
>대표</span
>
<button
v-else
class="skin-button compact"
type="button"
:disabled="busy"
@click="setPreferredIcon(icon.id)"
>
대표로 설정
</button>
<button
class="skin-button compact"
type="button"
:disabled="busy"
@click="retireIcon(icon.id)"
>
목록에서 내리기
</button>
</div>
<span v-if="account.icons.length === 0">등록한 전용 아이콘이 없습니다.</span>
</div>
<p class="icon-policy">
{{ account.icons.length }} / {{ account.maxActiveIcons }} · 업로드는 24시간에 1 ·
목록에서 내리기는 7일에 1
</p>
</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td colspan="6" class="status-cell">{{ loading ? '불러오는 중...' : '계정 정보 없음' }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<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>
</tfoot>
</table>
<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>
<style scoped>
#account-container {
width: 550px;
min-height: 575px;
margin: 106px auto 30px;
color: #fff;
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
font-size: 14px;
}
#account-table {
width: 100%;
border: 1px solid gray;
border-spacing: 0;
table-layout: fixed;
text-align: center;
}
.legacy-bg0 {
background-color: #302016;
background-image: var(--sammo-texture-walnut, url('https://sam-image.hided.net/game/back_walnut.jpg'));
}
.legacy-bg1 {
background-color: #14241b;
background-image: var(--sammo-texture-green, url('https://sam-image.hided.net/game/back_green.jpg'));
}
.legacy-bg2 {
background-color: #172a52;
background-image: var(--sammo-texture-blue, url('https://sam-image.hided.net/game/back_blue.jpg'));
}
#account-table caption {
caption-side: top;
}
#account-table th,
#account-table td {
border: 1px solid;
border-color: gray #000 #000 gray;
padding: 4px;
font-size: 14px;
}
.label-column {
width: 90px;
}
.section-title {
position: relative;
height: 50px;
border: 1px solid gray;
color: #fff;
font-size: 30px;
font-weight: 700;
line-height: 50px;
text-align: center;
}
.back-button {
position: absolute;
top: 6px;
right: 6px;
height: 40px;
font-size: 14px;
font-weight: 400;
line-height: 38px;
}
.skin-button,
.skin-input {
box-sizing: border-box;
border: 1px solid;
border-color: gray #000 #000 gray;
background: #191919;
color: #fff;
font: inherit;
}
.skin-button {
display: inline-block;
padding: 0 4px;
cursor: pointer;
text-decoration: none;
}
.skin-button:hover,
.skin-button:focus {
background: #303030;
}
.skin-button:focus-visible,
.skin-input:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
.skin-button:disabled {
cursor: default;
opacity: 0.65;
}
.compact {
width: 40px;
margin-left: 10px;
}
.action-cell,
.icon-actions {
position: relative;
}
.action-cell form {
min-height: 76px;
}
.action-cell label {
display: block;
}
.password-form {
display: grid;
grid-template-columns: 1fr 120px;
justify-content: end;
gap: 2px 6px;
text-align: right;
}
.full-button {
width: 100%;
min-height: 26px;
}
.password-form .full-button {
grid-column: 1 / -1;
}
.icon-preview {
height: 72px;
}
.icon-preview img {
width: 64px;
height: 64px;
object-fit: cover;
vertical-align: middle;
}
.icon-actions {
height: 72px;
}
.filename {
position: absolute;
top: 12px;
left: 10px;
width: 130px;
height: 22px;
}
.file-button {
position: absolute;
top: 12px;
right: 10px;
height: 22px;
line-height: 20px;
}
.file-button input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.half-button {
position: absolute;
bottom: 0;
width: 50%;
min-height: 26px;
}
.half-button:first-of-type {
left: 0;
}
.half-button:last-of-type {
right: 0;
}
.help-cell {
padding: 8px !important;
text-align: left;
}
.help-cell p {
margin: 0;
line-height: 1.2;
}
.help-cell .warning {
margin-top: 1em;
color: #f0f;
}
.status-cell {
height: 280px;
}
.feedback {
margin: 8px 0;
padding: 8px;
border: 1px solid gray;
background: #191919;
text-align: center;
}
.success {
color: #9cff9c;
}
.error {
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;
}
.account-icon-library {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}
.account-icon-card {
display: grid;
width: 104px;
justify-items: center;
gap: 4px;
}
.preferred-label {
color: #ffbf00;
font-weight: 700;
}
.icon-policy {
margin: 0 8px 8px;
}
@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>