릴리스: v1.2.26 관리자 회원 관리와 셸 UI 개선
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -14,6 +14,8 @@ const saving = ref(false)
|
||||
const nickname = ref('')
|
||||
const previewUrl = ref('')
|
||||
const avatarFile = ref(null)
|
||||
const removeAvatar = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -23,26 +25,53 @@ watch(error, (message) => {
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (previewUrl.value) return previewUrl.value
|
||||
if (removeAvatar.value) return ''
|
||||
if (!auth.user?.avatarSrc) return ''
|
||||
return toApiUrl(auth.user.avatarSrc)
|
||||
})
|
||||
|
||||
const displayInitial = computed(() => {
|
||||
const email = auth.user?.email || 'U'
|
||||
return email[0].toUpperCase()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
if (!auth.user) router.push('/login')
|
||||
nickname.value = auth.user?.nickname || ''
|
||||
removeAvatar.value = false
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 = ''
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
@@ -50,6 +79,8 @@ async function saveProfile() {
|
||||
const fd = new FormData()
|
||||
fd.append('nickname', nickname.value)
|
||||
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',
|
||||
@@ -59,10 +90,12 @@ async function saveProfile() {
|
||||
const data = await res.json()
|
||||
auth.user = data.user
|
||||
avatarFile.value = null
|
||||
removeAvatar.value = false
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
toast.success('프로필을 저장했어요.')
|
||||
} catch (e2) {
|
||||
error.value = '프로필 저장에 실패했어요.'
|
||||
@@ -79,145 +112,280 @@ async function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">프로필</h2>
|
||||
|
||||
<div class="card" v-if="auth.user">
|
||||
<div class="row">
|
||||
<div class="avatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
|
||||
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="email">{{ auth.user.email }}</div>
|
||||
<input v-model="nickname" class="nicknameInput" placeholder="작성자 닉네임" />
|
||||
<div class="badge" v-if="auth.user.isAdmin">admin</div>
|
||||
</div>
|
||||
<section class="pageWrap">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">Settings</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="upload">
|
||||
<label class="label">아바타 업로드</label>
|
||||
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
||||
<div class="actions">
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
<section v-if="auth.user" class="settingsScreen">
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" />
|
||||
<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>
|
||||
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
|
||||
</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>
|
||||
|
||||
<div class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</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>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
.settingsScreen {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
max-width: 620px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.settingsIdentity {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
.avatar {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
|
||||
.avatarButtonWrap {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.avatarImg {
|
||||
|
||||
.avatarButton__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatarFallback {
|
||||
|
||||
.avatarButton__fallback {
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
.meta {
|
||||
|
||||
.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: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.avatarButton__remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(10, 10, 10, 0.72);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
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 {
|
||||
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: #fff;
|
||||
}
|
||||
|
||||
.identityMeta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.email {
|
||||
font-weight: 900;
|
||||
|
||||
.identityMeta__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
}
|
||||
.nicknameInput {
|
||||
|
||||
.identityMeta__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.identityMeta__desc {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settingsFields {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.field__input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.badge {
|
||||
|
||||
.field__input:focus {
|
||||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
|
||||
.field__input--readonly {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
opacity: 0.9;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: rgba(191, 219, 254, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.upload {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.file {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 8px;
|
||||
opacity: 0.72;
|
||||
font-size: 13px;
|
||||
}
|
||||
.saveBtn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
|
||||
.settingsActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.logoutBtn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.24);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
border: 1px solid rgba(76, 133, 245, 0.96);
|
||||
background: rgba(76, 133, 245, 0.92);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settingsIdentity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.avatarButtonWrap {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user