Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b9f5f18e0 |
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
## `/profile`
|
## `/profile`
|
||||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 하단 로그아웃 처리
|
- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리
|
||||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
|
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
|
||||||
|
|
||||||
## 공통 레이아웃
|
## 공통 레이아웃
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.4.50`에서 설정 화면을 좌우 2열 카드형으로 나눴으므로, 데스크톱 폭에서는 프로필 정보가 왼쪽, 비밀번호 변경이 오른쪽에 나란히 보이고, 모바일/좁은 폭에서는 두 카드가 자연스럽게 위아래로 쌓이는지 확인한다.
|
||||||
- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다.
|
- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다.
|
||||||
- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다.
|
- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다.
|
||||||
- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다.
|
- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.50
|
||||||
|
- 설정 화면 메인 영역이 `max-width: 620px` 단일 컬럼으로 고정되어 넓은 화면에서 오른쪽 공간이 많이 비어 보였으므로, 프로필 정보 카드와 비밀번호 변경 카드를 좌우 2열 그리드로 나누고 좁은 화면에서만 1열로 내려가도록 레이아웃을 재정리했다.
|
||||||
|
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.49
|
## 2026-04-03 v1.4.49
|
||||||
- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다.
|
- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다.
|
||||||
- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다.
|
- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다.
|
||||||
|
|||||||
@@ -210,110 +210,114 @@ async function logout() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="auth.user" class="settingsScreen">
|
<section v-else-if="auth.user" class="settingsScreen">
|
||||||
<div class="settingsIdentity">
|
<div class="settingsGrid">
|
||||||
<div class="avatarButtonWrap">
|
<article class="settingsPanel">
|
||||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
<div class="settingsIdentity">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
<div class="avatarButtonWrap">
|
||||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||||
<div class="avatarButton__overlay">
|
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
||||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
<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>
|
||||||
</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 class="identityMeta">
|
<div class="identityMeta">
|
||||||
<div class="identityMeta__eyebrow">Profile Photo</div>
|
<div class="identityMeta__eyebrow">Profile Photo</div>
|
||||||
<div class="identityMeta__title">프로필 이미지</div>
|
<div class="identityMeta__title">프로필 정보</div>
|
||||||
<div class="identityMeta__desc">아바타를 클릭해서 이미지를 추가하거나 교체할 수 있습니다.</div>
|
<div class="identityMeta__desc">아바타와 닉네임을 정리하고, 현재 계정 이메일을 확인할 수 있어요.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settingsFields">
|
<div class="settingsFields">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">닉네임</span>
|
<span class="field__label">닉네임</span>
|
||||||
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||||
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
|
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
|
||||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">이메일</span>
|
<span class="field__label">이메일</span>
|
||||||
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
|
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
|
||||||
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
|
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settingsActions">
|
<div class="settingsActions">
|
||||||
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
|
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
|
||||||
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
|
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div class="passwordPanel">
|
<article class="settingsPanel">
|
||||||
<div class="identityMeta__eyebrow">Password</div>
|
<div class="identityMeta__eyebrow">Password</div>
|
||||||
<div class="identityMeta__title">비밀번호 변경</div>
|
<div class="identityMeta__title">비밀번호 변경</div>
|
||||||
<div class="identityMeta__desc">현재 비밀번호를 확인한 뒤 새 비밀번호로 바꿀 수 있어요.</div>
|
<div class="identityMeta__desc">현재 비밀번호를 확인한 뒤 새 비밀번호로 바꿀 수 있어요.</div>
|
||||||
|
|
||||||
<div class="settingsFields settingsFields--password">
|
<div class="settingsFields settingsFields--password">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">현재 비밀번호</span>
|
<span class="field__label">현재 비밀번호</span>
|
||||||
<input
|
<input
|
||||||
v-model="currentPassword"
|
v-model="currentPassword"
|
||||||
class="field__input"
|
class="field__input"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="현재 비밀번호"
|
placeholder="현재 비밀번호"
|
||||||
/>
|
/>
|
||||||
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
|
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">새 비밀번호</span>
|
<span class="field__label">새 비밀번호</span>
|
||||||
<input
|
<input
|
||||||
v-model="nextPassword"
|
v-model="nextPassword"
|
||||||
class="field__input"
|
class="field__input"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="새 비밀번호"
|
placeholder="새 비밀번호"
|
||||||
/>
|
/>
|
||||||
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
|
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
|
||||||
<span class="field__hint">6~120자 입력 가능 · {{ nextPassword.length }}/120자</span>
|
<span class="field__hint">6~120자 입력 가능 · {{ nextPassword.length }}/120자</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">새 비밀번호 확인</span>
|
<span class="field__label">새 비밀번호 확인</span>
|
||||||
<input
|
<input
|
||||||
v-model="nextPasswordConfirm"
|
v-model="nextPasswordConfirm"
|
||||||
class="field__input"
|
class="field__input"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
placeholder="새 비밀번호 확인"
|
placeholder="새 비밀번호 확인"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settingsActions">
|
<div class="settingsActions">
|
||||||
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
|
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
|
||||||
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
|
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -322,8 +326,7 @@ async function logout() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.settingsScreen {
|
.settingsScreen {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 32px;
|
gap: 24px;
|
||||||
max-width: 620px;
|
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +347,22 @@ async function logout() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settingsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
.avatarButtonWrap {
|
.avatarButtonWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
@@ -512,12 +531,7 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settingsFields--password {
|
.settingsFields--password {
|
||||||
padding-top: 20px;
|
padding-top: 24px;
|
||||||
}
|
|
||||||
|
|
||||||
.passwordPanel {
|
|
||||||
padding-top: 6px;
|
|
||||||
border-top: 1px solid var(--theme-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.primaryAction,
|
.primaryAction,
|
||||||
@@ -541,6 +555,15 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.settingsGrid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsPanel {
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.settingsIdentity {
|
.settingsIdentity {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user