Compare commits

...

1 Commits

Author SHA1 Message Date
a8019add16 설정 액션 정리 2026-04-07 15:23:38 +09:00
9 changed files with 81 additions and 45 deletions

View File

@@ -4,6 +4,7 @@
- 설정 화면은 자주 바꾸지 않는 계정 정보를 상시 입력 폼으로 펼쳐두기보다, 현재 상태를 먼저 보여주고 필요할 때만 모달로 수정하는 편이 더 차분하고 완성도 높게 보인다고 정리했다.
- 닉네임은 공개 작성자 이름에 직접 반영되는 정보라 악용 가능성을 줄이기 위해 2주 제한을 두는 편이 맞다고 판단했다. 초기 가입 시점의 닉네임도 같은 규칙에 포함되도록 가입 시각을 기본 기준선으로 삼는다.
- 다만 이 제한은 테스트와 운영 상황에 따라 조절할 수 있어야 하므로, 기간 자체는 코드 고정보다 환경변수로 바꾸는 편이 맞다고 정리했다. `0`으로 꺼서 QA하거나, `1일` 같은 짧은 값으로 운영 실험을 할 수 있어야 한다.
- 프로필 이미지는 자주 다루지 않는 항목이고 변경 직후 결과를 바로 확인할 수 있으므로, 별도 저장 버튼보다 자동 저장이 더 자연스럽다고 정리했다. 대신 닉네임/비밀번호/로그아웃처럼 명시적 행위가 필요한 액션은 작은 아이콘 버튼으로 분리한다.
## 2026-04-07 v1.1.17
- 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다.

View File

@@ -62,7 +62,7 @@
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/저장, 닉네임 현재 상태와 변경 제한 안내, 닉네임 변경 모달, 비밀번호 변경 모달, 이메일 읽기 전용 표시, 설정 화면 로그아웃 처리
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/삭제 즉시 저장, 닉네임 현재 상태와 변경 제한 안내 및 아이콘 버튼 기반 닉네임 변경 모달, 비밀번호 변경 아이콘 버튼, 이메일 읽기 전용 표시, 로그아웃 아이콘 버튼 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
## 공통 레이아웃

View File

@@ -55,7 +55,7 @@
- `L/ㅣ`: 리스트 보기
- `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. 프로필 이미지는 아바타 원형 버튼 자체를 눌러 변경한다.
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. 프로필 이미지는 아바타 원형 버튼 자체를 눌러 변경하며, 선택/삭제 시 즉시 자동 저장한다.
- 닉네임 변경 제한 기간은 기본 14일이지만, 서버 환경변수 `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있다. `0`이면 제한을 끈다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`, `nicknameChangeIntervalMs`, `nicknameChangeIntervalLabel`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
- 홈: 전체 공개 티어표

View File

@@ -3,8 +3,10 @@
## 단기 확인
- `v1.1.18` 이후 설정 화면이 데스크톱/태블릿/모바일에서 너무 넓게 퍼지지 않고, 요약 카드 간 간격과 제목/설명 밀도가 다른 대시보드 화면과 자연스럽게 맞는지 확인한다.
- 프로필 이미지 변경 후 저장, 이미지 제거 후 저장, 저장하지 않고 페이지 이탈 세 경우가 모두 의도대로 동작하는지 확인한다.
- 프로필 이미지 자동 저장으로 바뀐 뒤, 파일 선택 직후와 삭제 직후에 즉시 반영되고 연속 클릭 시 중복 저장 요청이 과도하게 쌓이지 않는지 확인한다.
- 닉네임 변경 모달에서 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다.
- `NICKNAME_CHANGE_INTERVAL_DAYS=1`, `NICKNAME_CHANGE_INTERVAL_MS=0` 같은 운영/테스트 값에서 설정 화면 문구와 백엔드 차단 동작이 함께 바뀌는지 확인한다.
- 설정 화면의 아이콘 버튼(`닉네임 변경`, `비밀번호 변경`, `로그아웃`)이 좁은 화면에서도 겹치지 않고, `title/aria-label` 기준 접근성도 자연스러운지 확인한다.
- 오래전에 가입한 기존 계정은 `nickname_updated_at` 백필 후에도 바로 변경 가능하고, 최근 가입/최근 변경 계정은 정확히 14일 제한이 걸리는지 서버 기준으로 확인한다.
- 비밀번호 변경이 요약 카드의 작은 액션으로만 열리더라도 접근성이 떨어지지 않는지, 모달 `Esc` 닫기와 포커스 이동이 자연스러운지 확인한다.
- `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다.

View File

@@ -7,6 +7,8 @@
- 인증 직렬화 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 포함해 프런트가 설정 화면에서 남은 제한 상태를 직접 보여줄 수 있게 했다.
- 프로필 이미지는 아바타 원형 버튼 자체를 누르면 파일 선택기가 열리므로, 중복 동작이던 별도 `프로필 이미지 변경` 버튼은 제거했다.
- 닉네임 변경 제한 기간은 고정 14일 상수가 아니라 환경변수로 바꿨다. `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있고, `0`이면 제한을 끌 수 있다. 프런트 문구도 응답의 `nicknameChangeIntervalLabel`을 따라가도록 맞췄다.
- 프로필 이미지 요약 카드는 제거하고, 아바타 선택/삭제 시 즉시 저장되는 자동 저장 흐름으로 바꿨다. 비밀번호 변경과 로그아웃 액션은 좁은 화면에서도 문구가 뭉개지지 않도록 아이콘 버튼(`key.svg`, `logout.svg`)으로 바꿨다.
- 닉네임은 `open_in_new.svg` 아이콘 버튼으로 모달을 여는 구조로 정리했고, 이메일은 현재 백엔드 기능 범위를 유지해 읽기 전용 상태임을 카드 안에서 더 명확히 표시했다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/auth.js`, `npm run build`
## 2026-04-07 v1.1.17

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M223.5-423.5Q200-447 200-480t23.5-56.5Q247-560 280-560t56.5 23.5Q360-513 360-480t-23.5 56.5Q313-400 280-400t-56.5-23.5ZM280-240q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/></svg>

After

Width:  |  Height:  |  Size: 508 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h280v80H200Zm440-160-55-58 102-102H360v-80h327L585-622l55-58 200 200-200 200Z"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"/></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@@ -3,10 +3,14 @@ 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()
@@ -63,7 +67,6 @@ const nicknameUpdatedAt = computed(() => Number(auth.user?.nicknameUpdatedAt ||
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 hasPendingAvatarChange = computed(() => !!avatarFile.value || removeAvatar.value)
const canChangeNicknameNow = computed(() => {
if (nicknameChangeIntervalMs.value <= 0) return true
if (!nicknameUpdatedAt.value) return true
@@ -74,12 +77,6 @@ const nicknameCooldownText = computed(() => {
if (!nicknameUpdatedAt.value || canChangeNicknameNow.value) return `닉네임은 ${nicknameChangeIntervalLabel.value}에 한 번만 변경할 수 있어요.`
return `다음 변경 가능 시점: ${formatDateTime(nicknameChangeAvailableAt.value)}`
})
const profileImageSummary = computed(() => {
if (avatarFile.value) return '새 프로필 이미지를 저장 대기 중이에요.'
if (removeAvatar.value) return '현재 프로필 이미지를 삭제 대기 중이에요.'
return avatarUrl.value ? '프로필 이미지가 설정되어 있어요.' : '아직 프로필 이미지가 없어요.'
})
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
@@ -131,6 +128,7 @@ function onAvatarChange(e) {
avatarFile.value = file
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(file)
saveAvatarChanges()
}
function clearProfileFieldErrors() {
@@ -152,6 +150,7 @@ function clearAvatar() {
previewUrl.value = ''
}
if (fileInput.value) fileInput.value.value = ''
saveAvatarChanges()
}
async function saveProfile(nextNickname = nickname.value) {
@@ -358,32 +357,32 @@ async function logout() {
<div class="settingsSummaryList">
<div class="settingsSummaryItem">
<div class="settingsSummaryItem__label">닉네임</div>
<div class="settingsSummaryItem__value">{{ nickname || '미설정' }}</div>
<div class="settingsSummaryItem__valueRow">
<div class="settingsSummaryItem__value">{{ nickname || '미설정' }}</div>
<button
class="settingsIconAction"
type="button"
:disabled="!canChangeNicknameNow"
aria-label="닉네임 변경"
title="닉네임 변경"
@click="openNicknameModal"
>
<SvgIcon :src="openInNewIcon" :size="18" />
</button>
</div>
<div class="settingsSummaryItem__meta">{{ nicknameCooldownText }}</div>
<button class="settingsTextAction" type="button" :disabled="!canChangeNicknameNow" @click="openNicknameModal">
{{ canChangeNicknameNow ? '닉네임 변경' : '2주 제한 ' }}
</button>
</div>
<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 class="settingsSummaryItem__valueRow">
<div class="settingsSummaryItem__value">{{ authEmail }}</div>
<span class="settingsSummaryItem__status">읽기 전용</span>
</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>
</div>
</div>
</article>
@@ -396,7 +395,9 @@ async function logout() {
<div class="settingsCompactRow__title">비밀번호</div>
<div class="settingsCompactRow__desc">현재 비밀번호 확인 비밀번호로 변경합니다.</div>
</div>
<button class="settingsTextAction" type="button" @click="openPasswordModal">비밀번호 변경</button>
<button class="settingsIconAction settingsIconAction--solid" type="button" aria-label="비밀번호 변경" title="비밀번호 변경" @click="openPasswordModal">
<SvgIcon :src="keyIcon" :size="18" />
</button>
</div>
</article>
@@ -415,7 +416,9 @@ async function logout() {
<div class="settingsCompactRow__title">로그아웃</div>
<div class="settingsCompactRow__desc"> 기기에서 현재 세션을 종료합니다.</div>
</div>
<button class="btn btn--ghost" type="button" @click="logout">로그아웃</button>
<button class="settingsIconAction settingsIconAction--solid" type="button" aria-label="로그아웃" title="로그아웃" @click="logout">
<SvgIcon :src="logoutIcon" :size="18" />
</button>
</div>
</div>
</article>
@@ -681,12 +684,30 @@ async function logout() {
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;
@@ -770,22 +791,6 @@ async function logout() {
font-weight: 700;
}
.settingsTextAction {
width: fit-content;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-accent-bg);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.settingsTextAction:disabled {
color: var(--theme-text-soft);
cursor: default;
}
.btn {
display: inline-flex;
align-items: center;
@@ -816,6 +821,29 @@ async function logout() {
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;