Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8019add16 | |||
| 51170b2ff7 | |||
| 923a9af83d |
@@ -90,6 +90,7 @@ function mapUserRow(row) {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
nickname: row.nickname || '',
|
||||
nicknameUpdatedAt: Number(row.nickname_updated_at || 0),
|
||||
emailVerified: row.email_verified == null ? true : !!row.email_verified,
|
||||
isAdmin: !!row.is_admin,
|
||||
avatarSrc: row.avatar_src || '',
|
||||
@@ -412,6 +413,7 @@ async function ensureSchema() {
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
nickname VARCHAR(80) NOT NULL DEFAULT '',
|
||||
nickname_updated_at BIGINT NOT NULL DEFAULT 0,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
@@ -494,6 +496,8 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_updated_at BIGINT NOT NULL DEFAULT 0 AFTER nickname`)
|
||||
await query(`UPDATE users SET nickname_updated_at = created_at WHERE nickname_updated_at = 0`)
|
||||
await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`)
|
||||
await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
|
||||
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`)
|
||||
@@ -696,7 +700,7 @@ async function countUsers() {
|
||||
|
||||
async function findUserByEmail(email) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
|
||||
[email]
|
||||
)
|
||||
const row = rows[0]
|
||||
@@ -710,7 +714,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
const rows = excludeUserId
|
||||
? await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
|
||||
LIMIT 1
|
||||
@@ -719,7 +723,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
)
|
||||
: await query(
|
||||
`
|
||||
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
|
||||
FROM users
|
||||
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
|
||||
LIMIT 1
|
||||
@@ -733,7 +737,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
|
||||
|
||||
async function findUserById(id) {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
'SELECT id, email, nickname, nickname_updated_at, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
|
||||
[id]
|
||||
)
|
||||
return mapUserRow(rows[0])
|
||||
@@ -743,10 +747,10 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
|
||||
const createdAt = now()
|
||||
await query(
|
||||
`
|
||||
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
|
||||
[id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
|
||||
)
|
||||
return findUserById(id)
|
||||
}
|
||||
@@ -875,11 +879,21 @@ async function consumePasswordResetToken(tokenId) {
|
||||
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
|
||||
}
|
||||
|
||||
async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
async function updateUserProfile({ id, nickname, avatarSrc, touchNicknameUpdatedAt = false }) {
|
||||
if (typeof avatarSrc === 'string') {
|
||||
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET nickname = ?, avatar_src = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END
|
||||
WHERE id = ?`,
|
||||
[nickname || '', avatarSrc, touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id]
|
||||
)
|
||||
} else {
|
||||
await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id])
|
||||
await query(
|
||||
`UPDATE users
|
||||
SET nickname = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END
|
||||
WHERE id = ?`,
|
||||
[nickname || '', touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id]
|
||||
)
|
||||
}
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,35 @@ const router = express.Router()
|
||||
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
function resolveNicknameChangeIntervalMs() {
|
||||
const rawMs = String(process.env.NICKNAME_CHANGE_INTERVAL_MS || '').trim()
|
||||
if (rawMs) {
|
||||
const parsed = Number(rawMs)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||
}
|
||||
|
||||
const rawDays = String(process.env.NICKNAME_CHANGE_INTERVAL_DAYS || '').trim()
|
||||
if (rawDays) {
|
||||
const parsed = Number(rawDays)
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
return 14 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
function formatNicknameChangeIntervalLabel(intervalMs) {
|
||||
if (!intervalMs || intervalMs <= 0) return '제한 없음'
|
||||
const oneDayMs = 24 * 60 * 60 * 1000
|
||||
const wholeDays = intervalMs / oneDayMs
|
||||
if (Number.isInteger(wholeDays) && wholeDays >= 7 && wholeDays % 7 === 0) {
|
||||
return `${wholeDays / 7}주`
|
||||
}
|
||||
if (Number.isInteger(wholeDays)) {
|
||||
return `${wholeDays}일`
|
||||
}
|
||||
return `${Math.ceil(wholeDays)}일`
|
||||
}
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
nickname: z.string().trim().min(2).max(40),
|
||||
@@ -60,7 +89,7 @@ const changePasswordSchema = z.object({
|
||||
})
|
||||
|
||||
const profileSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(40),
|
||||
nickname: z.string().trim().min(2).max(40),
|
||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
||||
})
|
||||
|
||||
@@ -82,11 +111,17 @@ async function serializeUser(user) {
|
||||
if (!user) return null
|
||||
const primaryAdmin = await findPrimaryAdminUser()
|
||||
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
|
||||
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
|
||||
const nicknameChangeAvailableAt = nicknameChangeIntervalMs > 0 ? (user.nicknameUpdatedAt || 0) + nicknameChangeIntervalMs : 0
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nickname: user.nickname || '',
|
||||
nicknameUpdatedAt: user.nicknameUpdatedAt || 0,
|
||||
nicknameChangeAvailableAt,
|
||||
nicknameChangeIntervalMs,
|
||||
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
|
||||
isAdmin: !!user.isAdmin,
|
||||
isPrimaryAdmin,
|
||||
isOperator: !!user.isAdmin && !isPrimaryAdmin,
|
||||
@@ -358,8 +393,21 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id)
|
||||
const normalizedNickname = parsed.data.nickname.trim()
|
||||
const nicknameChanged = normalizedNickname !== (user.nickname || '').trim()
|
||||
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
|
||||
|
||||
if (isReservedNickname(normalizedNickname)) return res.status(400).json({ error: 'nickname_reserved' })
|
||||
if (nicknameChanged && nicknameChangeIntervalMs > 0 && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + nicknameChangeIntervalMs) {
|
||||
return res.status(429).json({
|
||||
error: 'nickname_change_locked',
|
||||
nicknameChangeAvailableAt: user.nicknameUpdatedAt + nicknameChangeIntervalMs,
|
||||
nicknameChangeIntervalMs,
|
||||
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
|
||||
})
|
||||
}
|
||||
|
||||
const nicknameExists = await findUserByNickname(normalizedNickname, user.id)
|
||||
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
|
||||
|
||||
const optimized = req.file
|
||||
@@ -377,8 +425,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
|
||||
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
|
||||
const updated = await updateUserProfile({
|
||||
id: user.id,
|
||||
nickname: parsed.data.nickname,
|
||||
nickname: normalizedNickname,
|
||||
avatarSrc: nextAvatarSrc,
|
||||
touchNicknameUpdatedAt: nicknameChanged,
|
||||
})
|
||||
|
||||
res.json({ user: await serializeUser(updated) })
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다.
|
||||
- 정적 파일 URL 조합은 `toApiUrl()`로 처리한다.
|
||||
- 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다.
|
||||
- 설정/계정처럼 자주 바꾸지 않는 정보는 상시 입력 폼보다 `현재 상태 요약 + 필요 시 모달 편집` 흐름을 우선한다.
|
||||
|
||||
## 백엔드
|
||||
- 라우트 검증은 `zod`로 처리한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-07 v1.1.18
|
||||
- 설정 화면은 자주 바꾸지 않는 계정 정보를 상시 입력 폼으로 펼쳐두기보다, 현재 상태를 먼저 보여주고 필요할 때만 모달로 수정하는 편이 더 차분하고 완성도 높게 보인다고 정리했다.
|
||||
- 닉네임은 공개 작성자 이름에 직접 반영되는 정보라 악용 가능성을 줄이기 위해 2주 제한을 두는 편이 맞다고 판단했다. 초기 가입 시점의 닉네임도 같은 규칙에 포함되도록 가입 시각을 기본 기준선으로 삼는다.
|
||||
- 다만 이 제한은 테스트와 운영 상황에 따라 조절할 수 있어야 하므로, 기간 자체는 코드 고정보다 환경변수로 바꾸는 편이 맞다고 정리했다. `0`으로 꺼서 QA하거나, `1일` 같은 짧은 값으로 운영 실험을 할 수 있어야 한다.
|
||||
- 프로필 이미지는 자주 다루지 않는 항목이고 변경 직후 결과를 바로 확인할 수 있으므로, 별도 저장 버튼보다 자동 저장이 더 자연스럽다고 정리했다. 대신 닉네임/비밀번호/로그아웃처럼 명시적 행위가 필요한 액션은 작은 아이콘 버튼으로 분리한다.
|
||||
|
||||
## 2026-04-07 v1.1.17
|
||||
- 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다.
|
||||
- 설명 길이 때문에 페이지 전환마다 미디어 영역이 출렁이면 완성도가 떨어져 보이므로, 설명 블록에 최소 높이를 두는 방식으로 페이지 간 높이를 최대한 통일하기로 했다.
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리
|
||||
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/삭제 즉시 저장, 닉네임 현재 상태와 변경 제한 안내 및 아이콘 버튼 기반 닉네임 변경 모달, 비밀번호 변경 아이콘 버튼, 이메일 읽기 전용 표시, 로그아웃 아이콘 버튼 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
|
||||
|
||||
## 공통 레이아웃
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
- `L/ㅣ`: 리스트 보기
|
||||
- `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
|
||||
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
|
||||
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. 프로필 이미지는 아바타 원형 버튼 자체를 눌러 변경하며, 선택/삭제 시 즉시 자동 저장한다.
|
||||
- 닉네임 변경 제한 기간은 기본 14일이지만, 서버 환경변수 `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있다. `0`이면 제한을 끈다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`, `nicknameChangeIntervalMs`, `nicknameChangeIntervalLabel`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
|
||||
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
|
||||
- 홈: 전체 공개 티어표
|
||||
- 템플릿: 공개 템플릿
|
||||
@@ -98,6 +100,10 @@
|
||||
- `id`: string
|
||||
- `email`: string
|
||||
- `nickname`: string
|
||||
- `nicknameUpdatedAt`: number
|
||||
- `nicknameChangeAvailableAt`: number
|
||||
- `nicknameChangeIntervalMs`: number
|
||||
- `nicknameChangeIntervalLabel`: string
|
||||
- `passwordHash`: string
|
||||
- `emailVerified`: boolean
|
||||
- `isAdmin`: boolean
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.1.18` 이후 설정 화면이 데스크톱/태블릿/모바일에서 너무 넓게 퍼지지 않고, 요약 카드 간 간격과 제목/설명 밀도가 다른 대시보드 화면과 자연스럽게 맞는지 확인한다.
|
||||
- 프로필 이미지 변경 후 저장, 이미지 제거 후 저장, 저장하지 않고 페이지 이탈 세 경우가 모두 의도대로 동작하는지 확인한다.
|
||||
- 프로필 이미지 자동 저장으로 바뀐 뒤, 파일 선택 직후와 삭제 직후에 즉시 반영되고 연속 클릭 시 중복 저장 요청이 과도하게 쌓이지 않는지 확인한다.
|
||||
- 닉네임 변경 모달에서 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다.
|
||||
- `NICKNAME_CHANGE_INTERVAL_DAYS=1`, `NICKNAME_CHANGE_INTERVAL_MS=0` 같은 운영/테스트 값에서 설정 화면 문구와 백엔드 차단 동작이 함께 바뀌는지 확인한다.
|
||||
- 설정 화면의 아이콘 버튼(`닉네임 변경`, `비밀번호 변경`, `로그아웃`)이 좁은 화면에서도 겹치지 않고, `title/aria-label` 기준 접근성도 자연스러운지 확인한다.
|
||||
- 오래전에 가입한 기존 계정은 `nickname_updated_at` 백필 후에도 바로 변경 가능하고, 최근 가입/최근 변경 계정은 정확히 14일 제한이 걸리는지 서버 기준으로 확인한다.
|
||||
- 비밀번호 변경이 요약 카드의 작은 액션으로만 열리더라도 접근성이 떨어지지 않는지, 모달 `Esc` 닫기와 포커스 이동이 자연스러운지 확인한다.
|
||||
- `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다.
|
||||
- `v1.1.17` 이후 가이드 하단 `다음` 버튼이 사라지고, 좌우 화살표/점 네비게이션만으로도 단계 이동이 충분히 자연스러운지 확인한다.
|
||||
- `v1.1.16` 이후 `S/ㄴ`이 편집 화면에서는 아이템 검색, 일반 목록 화면에서는 왼쪽 공통 검색창 포커스로 정확히 나뉘어 동작하는지 확인한다.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-07 v1.1.18
|
||||
- 설정(`/profile`) 화면을 전면 재구성했다. 기존처럼 넓은 2단 입력 폼을 상시 노출하지 않고, `settingsThemePanel` 톤을 참고한 요약 카드 레이아웃으로 바꿔 더 차분하고 통일된 계정 화면으로 정리했다.
|
||||
- 프로필 영역은 `닉네임 / 이메일 / 프로필 이미지`의 현재 상태를 먼저 보여주고, 자주 바꾸지 않는 정보는 필요할 때만 모달을 열어 변경하도록 바꿨다. 비밀번호 변경도 별도 카드 전체를 차지하지 않고 작은 액션으로 열리는 모달 흐름으로 정리했다.
|
||||
- 닉네임 변경에는 2주 제한을 추가했다. 백엔드 `users` 테이블에 `nickname_updated_at`을 저장하고, 기존 DB는 서버 시작 시 자동 보강·백필한다. 닉네임이 실제로 바뀐 경우에만 갱신 시각을 다시 찍고, 14일이 지나기 전에는 `nickname_change_locked` 오류와 다음 변경 가능 시각을 돌려준다.
|
||||
- 인증 직렬화 응답에는 `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
|
||||
- 설정의 `가이드 보기` 모달 9페이지 단축키 설명은 최근 추가된 전역 단축키 기준으로 최신 상태를 유지하도록 다시 확인했다.
|
||||
- 가이드 하단 `다음` 버튼은 좌우 화살표와 역할이 겹쳐 제거했다. 이제 단계 이동은 좌우 화살표, 점 네비게이션, 좌측 단계 목록만 사용한다.
|
||||
|
||||
@@ -157,7 +157,7 @@ const guideSteps = [
|
||||
title: '단축키로 빠른 조작',
|
||||
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
|
||||
description:
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기, A/ㅁ은 관리자 계정일 때 관리자 화면으로 이동합니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기입니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있습니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -1779,6 +1779,7 @@ function reloadApp() {
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.guideModal__mobilePicker {
|
||||
|
||||
1
frontend/src/assets/icons/key.svg
Normal file
1
frontend/src/assets/icons/key.svg
Normal 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 |
1
frontend/src/assets/icons/logout.svg
Normal file
1
frontend/src/assets/icons/logout.svg
Normal 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 |
1
frontend/src/assets/icons/open_in_new.svg
Normal file
1
frontend/src/assets/icons/open_in_new.svg
Normal 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 |
@@ -1,11 +1,16 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
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()
|
||||
@@ -25,6 +30,11 @@ 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
|
||||
@@ -32,6 +42,17 @@ watch(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 ''
|
||||
@@ -40,12 +61,22 @@ const avatarUrl = computed(() => {
|
||||
})
|
||||
|
||||
const authReady = computed(() => auth.hydrated)
|
||||
|
||||
const displayInitial = computed(() => {
|
||||
const email = auth.user?.email || 'U'
|
||||
return email[0].toUpperCase()
|
||||
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
|
||||
})
|
||||
const nicknameCooldownText = computed(() => {
|
||||
if (nicknameChangeIntervalMs.value <= 0) return '닉네임 변경 제한이 없습니다.'
|
||||
if (!nicknameUpdatedAt.value || canChangeNicknameNow.value) return `닉네임은 ${nicknameChangeIntervalLabel.value}에 한 번만 변경할 수 있어요.`
|
||||
return `다음 변경 가능 시점: ${formatDateTime(nicknameChangeAvailableAt.value)}`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (!auth.user) {
|
||||
@@ -53,13 +84,37 @@ onMounted(async () => {
|
||||
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()
|
||||
}
|
||||
@@ -73,10 +128,12 @@ function onAvatarChange(e) {
|
||||
avatarFile.value = file
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
saveAvatarChanges()
|
||||
}
|
||||
|
||||
function clearProfileFieldErrors() {
|
||||
nicknameError.value = ''
|
||||
nicknameDraftError.value = ''
|
||||
}
|
||||
|
||||
function clearPasswordFieldErrors() {
|
||||
@@ -93,22 +150,24 @@ function clearAvatar() {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
saveAvatarChanges()
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
async function saveProfile(nextNickname = nickname.value) {
|
||||
error.value = ''
|
||||
clearProfileFieldErrors()
|
||||
|
||||
if (nickname.value.trim().length < 2) {
|
||||
if (String(nextNickname || '').trim().length < 2) {
|
||||
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
|
||||
nicknameDraftError.value = nicknameError.value
|
||||
error.value = '닉네임을 확인해주세요.'
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('nickname', nickname.value)
|
||||
fd.append('nickname', String(nextNickname || '').trim())
|
||||
if (avatarFile.value) fd.append('avatar', avatarFile.value)
|
||||
if (removeAvatar.value) fd.append('removeAvatar', '1')
|
||||
|
||||
@@ -125,6 +184,8 @@ async function saveProfile() {
|
||||
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) {
|
||||
@@ -133,22 +194,40 @@ async function saveProfile() {
|
||||
}
|
||||
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()
|
||||
@@ -172,10 +251,8 @@ async function savePassword() {
|
||||
nextPassword: nextPassword.value,
|
||||
})
|
||||
auth.user = data.user
|
||||
currentPassword.value = ''
|
||||
nextPassword.value = ''
|
||||
nextPasswordConfirm.value = ''
|
||||
toast.success('비밀번호를 변경했어요.')
|
||||
closePasswordModal()
|
||||
} catch (e2) {
|
||||
if (e2?.data?.error === 'invalid_current_password') {
|
||||
currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.'
|
||||
@@ -188,6 +265,41 @@ async function savePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
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('로그아웃했어요.')
|
||||
@@ -201,7 +313,7 @@ async function logout() {
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Account</div>
|
||||
<h2 class="pageHead__title">설정</h2>
|
||||
<div class="pageHead__desc">계정 정보를 간결하게 정리하고, 프로필 이미지를 클릭해서 바로 변경할 수 있어요.</div>
|
||||
<div class="pageHead__desc">수시로 바꾸지 않는 정보는 요약해서 보여주고, 필요할 때만 모달로 열어 바꾸는 흐름으로 정리했어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -210,116 +322,186 @@ async function logout() {
|
||||
</section>
|
||||
|
||||
<section v-else-if="auth.user" class="settingsScreen">
|
||||
<div class="settingsGrid">
|
||||
<article class="settingsPanel">
|
||||
<div class="settingsIdentity">
|
||||
<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 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
|
||||
class="settingsIconAction"
|
||||
type="button"
|
||||
:disabled="!canChangeNicknameNow"
|
||||
aria-label="닉네임 변경"
|
||||
title="닉네임 변경"
|
||||
@click="openNicknameModal"
|
||||
>
|
||||
<SvgIcon :src="openInNewIcon" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="settingsSummaryItem__meta">{{ nicknameCooldownText }}</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 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 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 class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</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>
|
||||
</article>
|
||||
|
||||
<article class="settingsPanel">
|
||||
<div class="identityMeta__eyebrow">Password</div>
|
||||
<div class="identityMeta__title">비밀번호 변경</div>
|
||||
<div class="identityMeta__desc">현재 비밀번호를 확인한 뒤 새 비밀번호로 바꿀 수 있어요.</div>
|
||||
|
||||
<div class="settingsFields settingsFields--password">
|
||||
<label class="field">
|
||||
<span class="field__label">현재 비밀번호</span>
|
||||
<input
|
||||
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="settingsActions">
|
||||
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
|
||||
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
|
||||
<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>
|
||||
|
||||
@@ -340,48 +522,78 @@ async function logout() {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.settingsIdentity {
|
||||
.settingsDeck {
|
||||
width: min(100%, 1040px);
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.settingsGrid {
|
||||
.settingsThemeCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
|
||||
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;
|
||||
}
|
||||
|
||||
.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);
|
||||
.settingsAvatarColumn {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatarButtonWrap {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
border-radius: 9999px;
|
||||
background: var(--theme-pill-bg);
|
||||
background: var(--theme-surface);
|
||||
color: var(--theme-text);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.avatarButton__image {
|
||||
@@ -403,7 +615,7 @@ async function logout() {
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--theme-text);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatarButton__remove {
|
||||
@@ -412,16 +624,13 @@ async function logout() {
|
||||
right: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--theme-shell-bg);
|
||||
background: var(--theme-surface);
|
||||
color: var(--theme-text);
|
||||
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 {
|
||||
@@ -438,37 +647,100 @@ async function logout() {
|
||||
color: var(--theme-accent-text);
|
||||
}
|
||||
|
||||
.identityMeta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.identityMeta__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.identityMeta__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.identityMeta__desc {
|
||||
color: var(--theme-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
.hiddenInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settingsFields {
|
||||
.settingsHero__body {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
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;
|
||||
}
|
||||
|
||||
.field {
|
||||
@@ -483,22 +755,18 @@ async function logout() {
|
||||
|
||||
.field__input {
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--theme-border-strong);
|
||||
background: transparent;
|
||||
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: 18px;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.field__input:focus {
|
||||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
|
||||
.field__input--readonly {
|
||||
color: var(--theme-text-muted);
|
||||
border-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
|
||||
.field__hint {
|
||||
@@ -523,59 +791,136 @@ async function logout() {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settingsActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.settingsFields--password {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
padding: 12px 18px;
|
||||
.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;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
border: 1px solid rgba(76, 133, 245, 0.96);
|
||||
.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);
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
background: var(--theme-surface-soft);
|
||||
.btn--ghost {
|
||||
border-color: var(--theme-border-strong);
|
||||
background: var(--theme-surface);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settingsGrid {
|
||||
.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);
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
.settingsHero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settingsIdentity {
|
||||
grid-template-columns: 1fr;
|
||||
.settingsAvatarColumn {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settingsThemeCard {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.avatarButtonWrap {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
.settingsCompactRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatarButton {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
.settingsModalOverlay {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settingsModalCard {
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user