201 lines
4.7 KiB
Vue
201 lines
4.7 KiB
Vue
<script setup>
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { toApiUrl } from '../lib/runtime'
|
|
import { useToast } from '../composables/useToast'
|
|
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
const toast = useToast()
|
|
|
|
const error = ref('')
|
|
const saving = ref(false)
|
|
const nickname = ref('')
|
|
const previewUrl = ref('')
|
|
const avatarFile = ref(null)
|
|
|
|
watch(error, (message) => {
|
|
if (!message) return
|
|
toast.error(message)
|
|
error.value = ''
|
|
})
|
|
|
|
const avatarUrl = computed(() => {
|
|
if (previewUrl.value) return previewUrl.value
|
|
if (!auth.user?.avatarSrc) return ''
|
|
return toApiUrl(auth.user.avatarSrc)
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await auth.refresh()
|
|
if (!auth.user) router.push('/login')
|
|
nickname.value = auth.user?.nickname || ''
|
|
})
|
|
|
|
function onAvatarChange(e) {
|
|
const file = e.target.files && e.target.files[0]
|
|
if (!file) return
|
|
|
|
error.value = ''
|
|
avatarFile.value = file
|
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
|
previewUrl.value = URL.createObjectURL(file)
|
|
}
|
|
|
|
async function saveProfile() {
|
|
error.value = ''
|
|
saving.value = true
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('nickname', nickname.value)
|
|
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
|
const res = await fetch(toApiUrl('/api/auth/profile'), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: fd,
|
|
})
|
|
if (!res.ok) throw new Error('upload_failed')
|
|
const data = await res.json()
|
|
auth.user = data.user
|
|
avatarFile.value = null
|
|
if (previewUrl.value) {
|
|
URL.revokeObjectURL(previewUrl.value)
|
|
previewUrl.value = ''
|
|
}
|
|
toast.success('프로필을 저장했어요.')
|
|
} catch (e2) {
|
|
error.value = '프로필 저장에 실패했어요.'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</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>
|
|
</div>
|
|
|
|
<div class="upload">
|
|
<label class="label">아바타 업로드</label>
|
|
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
|
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
|
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
|
{{ saving ? '저장중...' : '프로필 저장' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.wrap {
|
|
padding: 10px 2px;
|
|
}
|
|
.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;
|
|
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);
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
.avatarImg {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.avatarFallback {
|
|
font-weight: 900;
|
|
font-size: 20px;
|
|
opacity: 0.9;
|
|
}
|
|
.meta {
|
|
display: grid;
|
|
gap: 6px;
|
|
flex: 1;
|
|
}
|
|
.email {
|
|
font-weight: 900;
|
|
}
|
|
.nicknameInput {
|
|
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);
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
.badge {
|
|
font-size: 12px;
|
|
padding: 2px 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
|
border-radius: 999px;
|
|
width: fit-content;
|
|
opacity: 0.9;
|
|
}
|
|
.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 {
|
|
margin-top: 12px;
|
|
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;
|
|
}
|
|
</style>
|