설정 화면 정리

This commit is contained in:
2026-04-07 15:03:30 +09:00
parent 6fdd780859
commit 923a9af83d
10 changed files with 588 additions and 222 deletions

View File

@@ -157,7 +157,7 @@ const guideSteps = [
title: '단축키로 빠른 조작',
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
description:
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기, A/ㅁ은 관리자 계정일 때 관리자 화면으로 이동합니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있습니다.',
},
]
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
@@ -1779,6 +1779,7 @@ function reloadApp() {
font-weight: 900;
line-height: 1.1;
letter-spacing: -0.04em;
word-break: keep-all;
}
.guideModal__mobilePicker {

View File

@@ -1,8 +1,9 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { displayInitialFrom } from '../lib/display'
import { homePath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
@@ -25,6 +26,11 @@ const nextPassword = ref('')
const nextPasswordConfirm = ref('')
const currentPasswordError = ref('')
const nextPasswordError = ref('')
const activeModal = ref('')
const nicknameDraft = ref('')
const nicknameDraftError = ref('')
const nicknameInput = ref(null)
const currentPasswordInput = ref(null)
watch(error, (message) => {
if (!message) return
@@ -32,6 +38,17 @@ watch(error, (message) => {
error.value = ''
})
watch(
() => auth.user,
(user) => {
nickname.value = user?.nickname || ''
if (!activeModal.value || activeModal.value !== 'nickname') {
nicknameDraft.value = user?.nickname || ''
}
},
{ immediate: true }
)
const avatarUrl = computed(() => {
if (previewUrl.value) return previewUrl.value
if (removeAvatar.value) return ''
@@ -40,10 +57,23 @@ const avatarUrl = computed(() => {
})
const authReady = computed(() => auth.hydrated)
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
const displayInitial = computed(() => displayInitialFrom(auth.user?.nickname, auth.user?.email, 'U'))
const authEmail = computed(() => auth.user?.email || '')
const nicknameUpdatedAt = computed(() => Number(auth.user?.nicknameUpdatedAt || 0))
const nicknameChangeAvailableAt = computed(() => Number(auth.user?.nicknameChangeAvailableAt || 0))
const hasPendingAvatarChange = computed(() => !!avatarFile.value || removeAvatar.value)
const canChangeNicknameNow = computed(() => {
if (!nicknameUpdatedAt.value) return true
return Date.now() >= nicknameChangeAvailableAt.value
})
const nicknameCooldownText = computed(() => {
if (!nicknameUpdatedAt.value || canChangeNicknameNow.value) return '닉네임은 2주에 한 번만 변경할 수 있어요.'
return `다음 변경 가능 시점: ${formatDateTime(nicknameChangeAvailableAt.value)}`
})
const profileImageSummary = computed(() => {
if (avatarFile.value) return '새 프로필 이미지를 저장 대기 중이에요.'
if (removeAvatar.value) return '현재 프로필 이미지를 삭제 대기 중이에요.'
return avatarUrl.value ? '프로필 이미지가 설정되어 있어요.' : '아직 프로필 이미지가 없어요.'
})
onMounted(async () => {
@@ -53,13 +83,37 @@ onMounted(async () => {
return
}
nickname.value = auth.user?.nickname || ''
nicknameDraft.value = auth.user?.nickname || ''
removeAvatar.value = false
window.addEventListener('keydown', handleWindowKeydown)
})
onBeforeUnmount(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
window.removeEventListener('keydown', handleWindowKeydown)
})
function formatDateTime(timestamp) {
if (!timestamp) return '제한 없음'
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp)
}
function handleWindowKeydown(event) {
if (event.key !== 'Escape' || !activeModal.value) return
closeActiveModal()
}
function closeActiveModal() {
if (activeModal.value === 'nickname') closeNicknameModal()
if (activeModal.value === 'password') closePasswordModal()
}
function openAvatarPicker() {
fileInput.value?.click()
}
@@ -77,6 +131,7 @@ function onAvatarChange(e) {
function clearProfileFieldErrors() {
nicknameError.value = ''
nicknameDraftError.value = ''
}
function clearPasswordFieldErrors() {
@@ -95,20 +150,21 @@ function clearAvatar() {
if (fileInput.value) fileInput.value.value = ''
}
async function saveProfile() {
async function saveProfile(nextNickname = nickname.value) {
error.value = ''
clearProfileFieldErrors()
if (nickname.value.trim().length < 2) {
if (String(nextNickname || '').trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
nicknameDraftError.value = nicknameError.value
error.value = '닉네임을 확인해주세요.'
return
return false
}
saving.value = true
try {
const fd = new FormData()
fd.append('nickname', nickname.value)
fd.append('nickname', String(nextNickname || '').trim())
if (avatarFile.value) fd.append('avatar', avatarFile.value)
if (removeAvatar.value) fd.append('removeAvatar', '1')
@@ -125,6 +181,8 @@ async function saveProfile() {
throw requestError
}
auth.user = data.user
nickname.value = data.user?.nickname || ''
nicknameDraft.value = data.user?.nickname || ''
avatarFile.value = null
removeAvatar.value = false
if (previewUrl.value) {
@@ -133,22 +191,39 @@ async function saveProfile() {
}
if (fileInput.value) fileInput.value.value = ''
toast.success('프로필을 저장했어요.')
return true
} catch (e2) {
const code = e2?.data?.error
if (code === 'nickname_taken') {
nicknameError.value = '이미 사용 중인 닉네임입니다.'
nicknameDraftError.value = nicknameError.value
error.value = '닉네임이 이미 사용 중이에요.'
} else if (code === 'nickname_reserved') {
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
nicknameDraftError.value = nicknameError.value
error.value = '사용할 수 없는 닉네임이에요.'
} else if (code === 'nickname_change_locked') {
nicknameDraftError.value = `닉네임은 2주에 한 번만 바꿀 수 있어요. ${formatDateTime(e2?.data?.nicknameChangeAvailableAt)} 이후 다시 시도해주세요.`
error.value = '닉네임 변경 가능 시점이 아직 아니에요.'
} else {
error.value = '프로필 저장에 실패했어요.'
}
return false
} finally {
saving.value = false
}
}
async function saveNickname() {
const ok = await saveProfile(nicknameDraft.value)
if (!ok) return
closeNicknameModal()
}
async function saveAvatarChanges() {
await saveProfile(nickname.value)
}
async function savePassword() {
error.value = ''
clearPasswordFieldErrors()
@@ -172,10 +247,8 @@ async function savePassword() {
nextPassword: nextPassword.value,
})
auth.user = data.user
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
toast.success('비밀번호를 변경했어요.')
closePasswordModal()
} catch (e2) {
if (e2?.data?.error === 'invalid_current_password') {
currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.'
@@ -188,6 +261,41 @@ async function savePassword() {
}
}
function openNicknameModal() {
clearProfileFieldErrors()
nicknameDraft.value = nickname.value
activeModal.value = 'nickname'
nextTick(() => {
nicknameInput.value?.focus()
nicknameInput.value?.select?.()
})
}
function closeNicknameModal() {
activeModal.value = ''
nicknameDraftError.value = ''
nicknameDraft.value = nickname.value
}
function openPasswordModal() {
clearPasswordFieldErrors()
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
activeModal.value = 'password'
nextTick(() => {
currentPasswordInput.value?.focus()
})
}
function closePasswordModal() {
activeModal.value = ''
clearPasswordFieldErrors()
currentPassword.value = ''
nextPassword.value = ''
nextPasswordConfirm.value = ''
}
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
@@ -201,7 +309,7 @@ async function logout() {
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">설정</h2>
<div class="pageHead__desc">계정 정보 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 어요.</div>
<div class="pageHead__desc">수시로 바꾸지 않는 정보 요약해서 보여주고, 필요할 때만 모달로 열어 바꾸는 흐름으로 정리했어요.</div>
</div>
</header>
@@ -210,116 +318,182 @@ async function logout() {
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsGrid">
<article class="settingsPanel">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
<div class="settingsDeck">
<article class="settingsThemeCard settingsThemeCard--hero">
<div class="settingsThemeCard__eyebrow">Profile</div>
<div class="settingsThemeCard__title">기본 계정 정보</div>
<div class="settingsThemeCard__desc">아바타, 닉네임, 이메일처럼 자주 바뀌지 않는 정보는 현재 상태만 먼저 보여주고 필요한 순간에만 열어 수정합니다.</div>
<div class="settingsHero">
<div class="settingsAvatarColumn">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
<div class="avatarButton__overlay">
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<button class="btn btn--ghost settingsInlineAction" type="button" @click="openAvatarPicker">프로필 이미지 변경</button>
</div>
<div class="settingsHero__body">
<div class="settingsSummaryList">
<div class="settingsSummaryItem">
<div class="settingsSummaryItem__label">닉네임</div>
<div class="settingsSummaryItem__value">{{ nickname || '미설정' }}</div>
<div class="settingsSummaryItem__meta">{{ nicknameCooldownText }}</div>
<button class="settingsTextAction" type="button" :disabled="!canChangeNicknameNow" @click="openNicknameModal">
{{ canChangeNicknameNow ? '닉네임 변경' : '2주 제한 ' }}
</button>
</div>
</button>
<button
v-if="avatarUrl || previewUrl"
class="avatarButton__remove"
type="button"
aria-label="프로필 이미지 삭제"
@click="clearAvatar"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<div class="settingsSummaryItem">
<div class="settingsSummaryItem__label">이메일</div>
<div class="settingsSummaryItem__value">{{ authEmail }}</div>
<div class="settingsSummaryItem__meta">현재 로그인에 사용하는 계정 이메일입니다.</div>
</div>
<div class="settingsSummaryItem">
<div class="settingsSummaryItem__label">프로필 이미지</div>
<div class="settingsSummaryItem__value">{{ profileImageSummary }}</div>
<div class="settingsSummaryItem__meta">이미지를 선택한 뒤에만 저장 버튼이 활성화됩니다.</div>
</div>
</div>
<div class="settingsActionRow">
<button v-if="hasPendingAvatarChange" class="btn btn--save" type="button" :disabled="saving" @click="saveAvatarChanges">
{{ saving ? '저장 중...' : '프로필 이미지 저장' }}
</button>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
</div>
<div class="identityMeta">
<div class="identityMeta__eyebrow">Profile Photo</div>
<div class="identityMeta__title">프로필 정보</div>
<div class="identityMeta__desc">아바타와 닉네임을 정리하고, 현재 계정 이메일을 확인할 있어요.</div>
</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
</div>
<div class="settingsFields">
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40</span>
</label>
<label class="field">
<span class="field__label">이메일</span>
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
</label>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
<div class="settingsActions">
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
</div>
</article>
<article class="settingsPanel">
<div class="identityMeta__eyebrow">Password</div>
<div class="identityMeta__title">비밀번호 변경</div>
<div class="identityMeta__desc">현재 비밀번호를 확인한 비밀번호로 바꿀 있어요.</div>
<div class="settingsFields settingsFields--password">
<label class="field">
<span class="field__label">현재 비밀번호</span>
<input
v-model="currentPassword"
class="field__input"
type="password"
autocomplete="current-password"
maxlength="120"
placeholder="현재 비밀번호"
/>
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="nextPassword"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호"
/>
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
<span class="field__hint">6~120 입력 가능 · {{ nextPassword.length }}/120</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호 확인</span>
<input
v-model="nextPasswordConfirm"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호 확인"
/>
</label>
<article class="settingsThemeCard">
<div class="settingsThemeCard__eyebrow">Security</div>
<div class="settingsThemeCard__title">보안 설정</div>
<div class="settingsThemeCard__desc">비밀번호는 자주 노출할 필요가 없으니, 필요할 때만 열리는 작은 액션으로 정리했습니다.</div>
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">비밀번호</div>
<div class="settingsCompactRow__desc">현재 비밀번호 확인 비밀번호로 변경합니다.</div>
</div>
<button class="settingsTextAction" type="button" @click="openPasswordModal">비밀번호 변경</button>
</div>
</article>
<div class="settingsActions">
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
</button>
<article class="settingsThemeCard">
<div class="settingsThemeCard__eyebrow">Session</div>
<div class="settingsThemeCard__title">계정 상태</div>
<div class="settingsThemeCard__desc">지금 로그인한 계정 정보를 확인하고 세션을 정리할 있어요.</div>
<div class="settingsCompactList">
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">가입일</div>
<div class="settingsCompactRow__desc">{{ formatDateTime(auth.user.createdAt) }}</div>
</div>
</div>
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">로그아웃</div>
<div class="settingsCompactRow__desc"> 기기에서 현재 세션을 종료합니다.</div>
</div>
<button class="btn btn--ghost" type="button" @click="logout">로그아웃</button>
</div>
</div>
</article>
</div>
</section>
<div v-if="activeModal === 'nickname'" class="settingsModalOverlay" @click.self="closeNicknameModal">
<div class="settingsModalCard" role="dialog" aria-modal="true" aria-labelledby="nicknameModalTitle">
<div id="nicknameModalTitle" class="settingsModalCard__title">닉네임 변경</div>
<div class="settingsModalCard__desc">닉네임은 공개 티어표의 작성자 이름으로 보이며, 악용 방지를 위해 2주에 번만 변경할 있어요.</div>
<label class="field">
<span class="field__label"> 닉네임</span>
<input ref="nicknameInput" v-model="nicknameDraft" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
<span v-if="nicknameDraftError" class="field__error">{{ nicknameDraftError }}</span>
<span class="field__hint">{{ nicknameDraft.length }}/40</span>
</label>
<div class="settingsModalCard__actions">
<button class="btn btn--ghost" type="button" @click="closeNicknameModal">취소</button>
<button class="btn btn--save" type="button" :disabled="saving || !canChangeNicknameNow" @click="saveNickname">
{{ saving ? '저장 중...' : '닉네임 저장' }}
</button>
</div>
</div>
</div>
<div v-if="activeModal === 'password'" class="settingsModalOverlay" @click.self="closePasswordModal">
<div class="settingsModalCard" role="dialog" aria-modal="true" aria-labelledby="passwordModalTitle">
<div id="passwordModalTitle" class="settingsModalCard__title">비밀번호 변경</div>
<div class="settingsModalCard__desc">현재 비밀번호를 확인한 비밀번호로 바꿉니다.</div>
<div class="settingsModalFields">
<label class="field">
<span class="field__label">현재 비밀번호</span>
<input
ref="currentPasswordInput"
v-model="currentPassword"
class="field__input"
type="password"
autocomplete="current-password"
maxlength="120"
placeholder="현재 비밀번호"
/>
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="nextPassword"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호"
/>
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
<span class="field__hint">6~120 입력 가능 · {{ nextPassword.length }}/120</span>
</label>
<label class="field">
<span class="field__label"> 비밀번호 확인</span>
<input
v-model="nextPasswordConfirm"
class="field__input"
type="password"
autocomplete="new-password"
maxlength="120"
placeholder="새 비밀번호 확인"
/>
</label>
</div>
<div class="settingsModalCard__actions">
<button class="btn btn--ghost" type="button" @click="closePasswordModal">취소</button>
<button class="btn btn--save" type="button" :disabled="passwordSaving" @click="savePassword">
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
</button>
</div>
</div>
</div>
</section>
</template>
@@ -340,48 +514,78 @@ async function logout() {
font-size: 15px;
}
.settingsIdentity {
.settingsDeck {
width: min(100%, 1040px);
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 24px;
align-items: center;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.settingsGrid {
.settingsThemeCard {
display: grid;
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
gap: 12px;
min-width: 0;
padding: 22px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.settingsThemeCard--hero {
grid-column: 1 / -1;
}
.settingsThemeCard__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.settingsThemeCard__title {
font-size: 24px;
font-weight: 800;
color: var(--theme-text-strong);
letter-spacing: -0.03em;
}
.settingsThemeCard__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsHero {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 24px;
align-items: start;
padding-top: 4px;
}
.settingsPanel {
min-width: 0;
padding: 28px;
border: 1px solid var(--theme-border);
border-radius: 28px;
background: var(--theme-surface);
box-shadow: var(--theme-card-shadow);
.settingsAvatarColumn {
display: grid;
justify-items: center;
gap: 12px;
}
.avatarButtonWrap {
position: relative;
width: 120px;
height: 120px;
width: 132px;
height: 132px;
}
.avatarButton {
position: relative;
width: 120px;
height: 120px;
width: 132px;
height: 132px;
border: 1px solid var(--theme-border-strong);
border-radius: 9999px;
background: var(--theme-pill-bg);
background: var(--theme-surface);
color: var(--theme-text);
overflow: hidden;
cursor: pointer;
display: grid;
place-items: center;
box-shadow: var(--theme-card-shadow);
}
.avatarButton__image {
@@ -403,7 +607,7 @@ async function logout() {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
font-size: 12px;
font-weight: 700;
color: var(--theme-text);
color: #fff;
}
.avatarButton__remove {
@@ -412,16 +616,13 @@ async function logout() {
right: 0;
width: 30px;
height: 30px;
border: 0;
border: 1px solid var(--theme-border-strong);
border-radius: 999px;
background: var(--theme-shell-bg);
background: var(--theme-surface);
color: var(--theme-text);
display: grid;
place-items: center;
cursor: pointer;
z-index: 2;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(10px);
}
.avatarButton__remove svg {
@@ -438,37 +639,82 @@ async function logout() {
color: var(--theme-accent-text);
}
.identityMeta {
display: grid;
gap: 6px;
}
.identityMeta__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.identityMeta__title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
}
.identityMeta__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.hiddenInput {
display: none;
}
.settingsFields {
.settingsHero__body {
display: grid;
gap: 20px;
gap: 16px;
}
.settingsSummaryList {
display: grid;
gap: 12px;
}
.settingsSummaryItem {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
}
.settingsSummaryItem__label {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.settingsSummaryItem__value {
font-size: 18px;
font-weight: 700;
color: var(--theme-text-strong);
word-break: break-word;
}
.settingsSummaryItem__meta {
font-size: 13px;
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsActionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.settingsCompactList {
display: grid;
gap: 10px;
}
.settingsCompactRow {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
}
.settingsCompactRow__title {
font-size: 16px;
font-weight: 700;
color: var(--theme-text-strong);
}
.settingsCompactRow__desc {
margin-top: 4px;
color: var(--theme-text-muted);
line-height: 1.6;
}
.field {
@@ -483,22 +729,18 @@ async function logout() {
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
padding: 14px 16px;
border: 1px solid var(--theme-border-strong);
border-radius: 16px;
background: var(--theme-surface);
color: var(--theme-text);
outline: none;
font-size: 18px;
font-size: 16px;
letter-spacing: -0.02em;
}
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__input--readonly {
color: var(--theme-text-muted);
border-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
@@ -523,59 +765,134 @@ async function logout() {
font-weight: 700;
}
.settingsActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.settingsFields--password {
padding-top: 24px;
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;
border-radius: 999px;
.settingsTextAction {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-accent-bg);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
.settingsTextAction:disabled {
color: var(--theme-text-soft);
cursor: default;
}
.settingsInlineAction {
width: 100%;
justify-content: center;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 700;
cursor: pointer;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.btn--save {
border-color: rgba(76, 133, 245, 0.96);
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.secondaryAction {
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
.btn--ghost {
border-color: var(--theme-border-strong);
background: var(--theme-surface);
color: var(--theme-text);
}
@media (max-width: 720px) {
.settingsGrid {
.settingsModalOverlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 24px;
background: rgba(8, 11, 18, 0.54);
backdrop-filter: blur(8px);
}
.settingsModalCard {
width: min(100%, 520px);
display: grid;
gap: 16px;
padding: 24px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
}
.settingsModalCard__title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--theme-text-strong);
}
.settingsModalCard__desc {
color: var(--theme-text-muted);
line-height: 1.6;
}
.settingsModalFields {
display: grid;
gap: 14px;
}
.settingsModalCard__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 860px) {
.settingsDeck {
grid-template-columns: minmax(0, 1fr);
}
.settingsPanel {
padding: 22px;
border-radius: 24px;
.settingsHero {
grid-template-columns: minmax(0, 1fr);
}
.settingsIdentity {
grid-template-columns: 1fr;
.settingsAvatarColumn {
justify-items: start;
}
}
@media (max-width: 640px) {
.settingsThemeCard {
padding: 18px;
border-radius: 20px;
}
.avatarButtonWrap {
width: 108px;
height: 108px;
.settingsCompactRow {
flex-direction: column;
align-items: flex-start;
}
.avatarButton {
width: 108px;
height: 108px;
.settingsModalOverlay {
padding: 16px;
}
.settingsModalCard {
padding: 20px;
border-radius: 20px;
}
}
</style>