922 lines
25 KiB
Vue
922 lines
25 KiB
Vue
<script setup>
|
|
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 SvgIcon from '../components/SvgIcon.vue'
|
|
import { displayInitialFrom } from '../lib/display'
|
|
import { homePath, loginPath } from '../lib/paths'
|
|
import { toApiUrl } from '../lib/runtime'
|
|
import { useToast } from '../composables/useToast'
|
|
import keyIcon from '../assets/icons/key.svg'
|
|
import logoutIcon from '../assets/icons/logout.svg'
|
|
import openInNewIcon from '../assets/icons/open_in_new.svg'
|
|
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
const toast = useToast()
|
|
|
|
const error = ref('')
|
|
const saving = ref(false)
|
|
const passwordSaving = ref(false)
|
|
const nickname = ref('')
|
|
const nicknameError = ref('')
|
|
const previewUrl = ref('')
|
|
const avatarFile = ref(null)
|
|
const removeAvatar = ref(false)
|
|
const fileInput = ref(null)
|
|
const currentPassword = ref('')
|
|
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
|
|
toast.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 ''
|
|
if (!auth.user?.avatarSrc) return ''
|
|
return toApiUrl(auth.user.avatarSrc)
|
|
})
|
|
|
|
const authReady = computed(() => auth.hydrated)
|
|
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 nicknameChangeIntervalMs = computed(() => Number(auth.user?.nicknameChangeIntervalMs || 0))
|
|
const nicknameChangeIntervalLabel = computed(() => String(auth.user?.nicknameChangeIntervalLabel || '2주'))
|
|
const canChangeNicknameNow = computed(() => {
|
|
if (nicknameChangeIntervalMs.value <= 0) return true
|
|
if (!nicknameUpdatedAt.value) return true
|
|
return Date.now() >= nicknameChangeAvailableAt.value
|
|
})
|
|
onMounted(async () => {
|
|
if (!auth.hydrated) await auth.refresh()
|
|
if (!auth.user) {
|
|
router.replace(loginPath())
|
|
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()
|
|
}
|
|
|
|
function onAvatarChange(e) {
|
|
const file = e.target.files && e.target.files[0]
|
|
if (!file) return
|
|
|
|
error.value = ''
|
|
removeAvatar.value = false
|
|
avatarFile.value = file
|
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
|
previewUrl.value = URL.createObjectURL(file)
|
|
saveAvatarChanges()
|
|
}
|
|
|
|
function clearProfileFieldErrors() {
|
|
nicknameError.value = ''
|
|
nicknameDraftError.value = ''
|
|
}
|
|
|
|
function clearPasswordFieldErrors() {
|
|
currentPasswordError.value = ''
|
|
nextPasswordError.value = ''
|
|
}
|
|
|
|
function clearAvatar() {
|
|
error.value = ''
|
|
avatarFile.value = null
|
|
removeAvatar.value = true
|
|
if (previewUrl.value) {
|
|
URL.revokeObjectURL(previewUrl.value)
|
|
previewUrl.value = ''
|
|
}
|
|
if (fileInput.value) fileInput.value.value = ''
|
|
saveAvatarChanges()
|
|
}
|
|
|
|
async function saveProfile(nextNickname = nickname.value) {
|
|
error.value = ''
|
|
clearProfileFieldErrors()
|
|
|
|
if (String(nextNickname || '').trim().length < 2) {
|
|
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
|
|
nicknameDraftError.value = nicknameError.value
|
|
error.value = '닉네임을 확인해주세요.'
|
|
return false
|
|
}
|
|
|
|
saving.value = true
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('nickname', String(nextNickname || '').trim())
|
|
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
|
if (removeAvatar.value) fd.append('removeAvatar', '1')
|
|
|
|
const res = await fetch(toApiUrl('/api/auth/profile'), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: fd,
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
const requestError = new Error('profile_update_failed')
|
|
requestError.data = data
|
|
requestError.status = res.status
|
|
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) {
|
|
URL.revokeObjectURL(previewUrl.value)
|
|
previewUrl.value = ''
|
|
}
|
|
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') {
|
|
const intervalLabel = String(e2?.data?.nicknameChangeIntervalLabel || nicknameChangeIntervalLabel.value || '2주')
|
|
nicknameDraftError.value = `닉네임은 ${intervalLabel}에 한 번만 바꿀 수 있어요. ${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()
|
|
|
|
if (nextPassword.value.length < 6) {
|
|
nextPasswordError.value = '새 비밀번호는 6자 이상 입력해주세요.'
|
|
error.value = '새 비밀번호를 확인해주세요.'
|
|
return
|
|
}
|
|
|
|
if (nextPassword.value !== nextPasswordConfirm.value) {
|
|
nextPasswordError.value = '비밀번호 확인이 일치하지 않아요.'
|
|
error.value = '비밀번호 확인이 일치하지 않아요.'
|
|
return
|
|
}
|
|
|
|
passwordSaving.value = true
|
|
try {
|
|
const data = await api.changePassword({
|
|
currentPassword: currentPassword.value,
|
|
nextPassword: nextPassword.value,
|
|
})
|
|
auth.user = data.user
|
|
toast.success('비밀번호를 변경했어요.')
|
|
closePasswordModal()
|
|
} catch (e2) {
|
|
if (e2?.data?.error === 'invalid_current_password') {
|
|
currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.'
|
|
error.value = '현재 비밀번호가 일치하지 않아요.'
|
|
} else {
|
|
error.value = '비밀번호 변경에 실패했어요.'
|
|
}
|
|
} finally {
|
|
passwordSaving.value = false
|
|
}
|
|
}
|
|
|
|
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('로그아웃했어요.')
|
|
router.push(homePath())
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="pageWrap">
|
|
<header class="pageHead">
|
|
<div class="pageHead__main">
|
|
<div class="pageHead__eyebrow">Account</div>
|
|
<h2 class="pageHead__title">설정</h2>
|
|
<div class="pageHead__desc">수시로 바꾸지 않는 정보는 요약해서 보여주고, 필요할 때만 모달로 열어 바꾸는 흐름으로 정리했어요.</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section v-if="!authReady" class="settingsScreen settingsScreen--loading">
|
|
<div class="settingsLoading">계정 정보를 불러오고 있어요.</div>
|
|
</section>
|
|
|
|
<section v-else-if="auth.user" class="settingsScreen">
|
|
<div class="settingsDeck">
|
|
<article class="settingsThemeCard settingsThemeCard--hero">
|
|
<div class="settingsThemeCard__eyebrow">Profile</div>
|
|
<div class="settingsThemeCard__title">기본 계정 정보</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>
|
|
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
|
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
|
</div>
|
|
|
|
<div class="settingsHero__body">
|
|
<div class="settingsSummaryList">
|
|
<div class="settingsSummaryItem">
|
|
<div class="settingsSummaryItem__label">닉네임</div>
|
|
<div class="settingsSummaryItem__valueRow">
|
|
<div class="settingsSummaryItem__value">{{ nickname || '미설정' }}</div>
|
|
<button
|
|
v-if="canChangeNicknameNow"
|
|
class="settingsIconAction"
|
|
type="button"
|
|
aria-label="닉네임 변경"
|
|
title="닉네임 변경"
|
|
@click="openNicknameModal"
|
|
>
|
|
<SvgIcon :src="openInNewIcon" :size="18" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settingsSummaryItem">
|
|
<div class="settingsSummaryItem__label">이메일</div>
|
|
<div class="settingsSummaryItem__valueRow">
|
|
<div class="settingsSummaryItem__value">{{ authEmail }}</div>
|
|
<span class="settingsSummaryItem__status">읽기 전용</span>
|
|
</div>
|
|
<div class="settingsSummaryItem__meta">현재 로그인에 사용하는 계정 이메일이며, 설정 화면에서는 변경할 수 없습니다.</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="settingsThemeCard">
|
|
<div class="settingsThemeCard__eyebrow">Security</div>
|
|
<div class="settingsThemeCard__title">보안 설정</div>
|
|
<div class="settingsCompactRow">
|
|
<div>
|
|
<div class="settingsCompactRow__title">비밀번호</div>
|
|
<div class="settingsCompactRow__desc">현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</div>
|
|
</div>
|
|
<button class="settingsIconAction settingsIconAction--solid" type="button" aria-label="비밀번호 변경" title="비밀번호 변경" @click="openPasswordModal">
|
|
<SvgIcon :src="keyIcon" :size="18" />
|
|
</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="settingsThemeCard">
|
|
<div class="settingsThemeCard__eyebrow">Session</div>
|
|
<div class="settingsThemeCard__title">계정 상태</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="settingsIconAction settingsIconAction--solid" type="button" aria-label="로그아웃" title="로그아웃" @click="logout">
|
|
<SvgIcon :src="logoutIcon" :size="18" />
|
|
</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">
|
|
닉네임은 공개 티어표의 작성자 이름으로 보이며,
|
|
{{ nicknameChangeIntervalMs > 0 ? `한 번 변경하면 ${nicknameChangeIntervalLabel}이 지나야 다시 바꿀 수 있어요.` : '현재는 변경 주기 제한이 없습니다.' }}
|
|
</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>
|
|
|
|
<style scoped>
|
|
.settingsScreen {
|
|
display: grid;
|
|
gap: 24px;
|
|
padding-top: 4px;
|
|
}
|
|
|
|
.settingsScreen--loading {
|
|
min-height: 240px;
|
|
align-items: center;
|
|
}
|
|
|
|
.settingsLoading {
|
|
color: var(--theme-text-muted);
|
|
font-size: 15px;
|
|
}
|
|
|
|
.settingsDeck {
|
|
width: min(100%, 1040px);
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.settingsThemeCard {
|
|
display: grid;
|
|
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;
|
|
}
|
|
|
|
.settingsAvatarColumn {
|
|
display: grid;
|
|
justify-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.avatarButtonWrap {
|
|
position: relative;
|
|
width: 132px;
|
|
height: 132px;
|
|
}
|
|
|
|
.avatarButton {
|
|
position: relative;
|
|
width: 132px;
|
|
height: 132px;
|
|
border: 1px solid var(--theme-border-strong);
|
|
border-radius: 9999px;
|
|
background: var(--theme-surface);
|
|
color: var(--theme-text);
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.avatarButton__image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.avatarButton__fallback {
|
|
font-size: 34px;
|
|
font-weight: 900;
|
|
color: var(--theme-text);
|
|
}
|
|
|
|
.avatarButton__overlay {
|
|
position: absolute;
|
|
inset: auto 0 0 0;
|
|
padding: 12px 10px;
|
|
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
}
|
|
|
|
.avatarButton__remove {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 30px;
|
|
height: 30px;
|
|
border: 1px solid var(--theme-border-strong);
|
|
border-radius: 999px;
|
|
background: var(--theme-surface);
|
|
color: var(--theme-text);
|
|
display: grid;
|
|
place-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.avatarButton__remove svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
stroke: currentColor;
|
|
stroke-width: 2.1;
|
|
fill: none;
|
|
stroke-linecap: round;
|
|
}
|
|
|
|
.avatarButton__remove:hover {
|
|
background: rgba(190, 24, 24, 0.88);
|
|
color: var(--theme-accent-text);
|
|
}
|
|
|
|
.hiddenInput {
|
|
display: none;
|
|
}
|
|
|
|
.settingsHero__body {
|
|
display: grid;
|
|
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__valueRow {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.settingsSummaryItem__meta {
|
|
font-size: 13px;
|
|
color: var(--theme-text-muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.settingsSummaryItem__status {
|
|
flex-shrink: 0;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--theme-border);
|
|
background: var(--theme-pill-bg);
|
|
color: var(--theme-text-soft);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.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;
|
|
word-break: keep-all;
|
|
}
|
|
|
|
.field {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.field__label {
|
|
font-size: 13px;
|
|
color: var(--theme-text-muted);
|
|
}
|
|
|
|
.field__input {
|
|
width: 100%;
|
|
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: 16px;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.field__input:focus {
|
|
border-color: rgba(96, 165, 250, 0.9);
|
|
}
|
|
|
|
.field__hint {
|
|
font-size: 12px;
|
|
color: var(--theme-text-soft);
|
|
}
|
|
|
|
.field__error {
|
|
font-size: 12px;
|
|
color: #ff7b7b;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.roleBadge {
|
|
width: fit-content;
|
|
padding: 6px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(96, 165, 250, 0.28);
|
|
background: rgba(96, 165, 250, 0.1);
|
|
color: var(--theme-text);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.btn--ghost {
|
|
border-color: var(--theme-border-strong);
|
|
background: var(--theme-surface);
|
|
color: var(--theme-text);
|
|
}
|
|
|
|
.settingsIconAction {
|
|
width: 42px;
|
|
height: 42px;
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--theme-border-strong);
|
|
background: var(--theme-surface);
|
|
color: var(--theme-text);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.settingsIconAction:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
.settingsIconAction--solid {
|
|
background: var(--theme-pill-bg);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.settingsHero {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
}
|
|
|
|
.settingsAvatarColumn {
|
|
justify-items: start;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.settingsThemeCard {
|
|
padding: 18px;
|
|
border-radius: 20px;
|
|
}
|
|
|
|
.settingsCompactRow {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.settingsModalOverlay {
|
|
padding: 16px;
|
|
}
|
|
|
|
.settingsModalCard {
|
|
padding: 20px;
|
|
border-radius: 20px;
|
|
}
|
|
}
|
|
</style>
|