diff --git a/docs/history.md b/docs/history.md index caa3309..39f38af 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-13 v0.0.110 + +### 관리자 멤버 목록 정보 밀도 정리 + +멤버 목록에서 이름, 이메일, 접속일, 권한 변경 컨트롤을 모두 별도 컬럼으로 두면 한 사람의 정보가 가로로 흩어지고 목록이 지나치게 넓어진다. Ghost 관리자처럼 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 묶어 읽는 구조로 바꾸고, 권한 변경은 사용자를 선택한 뒤 처리하는 후속 화면의 책임으로 분리한다. 뉴스레터 지표는 이 프로젝트에 없으므로 같은 위치에는 댓글 작성 개수를 표시한다. + ## 2026-05-13 v0.0.109 ### 관리자 사이드바 하단 사용자 영역 정리 diff --git a/docs/map.md b/docs/map.md index fa5c5bc..01c5de2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -102,7 +102,7 @@ | pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/[id].vue | 태그 수정 | | pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 | -| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) | +| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태) | ## 공개 페이지 diff --git a/docs/spec.md b/docs/spec.md index 533e08c..3b1abe6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -414,7 +414,7 @@ components/content/ - `PUT /admin/api/settings` - 사이트 설정 수정 - `GET /admin/api/navigation` - 네비게이션 항목 목록 - `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장 -- `GET /admin/api/members` - 회원 목록(권한, 최근 접속, 접속 IP, 댓글 수 포함) +- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함) - `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`) > 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다. @@ -550,7 +550,10 @@ components/content/ - `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. - 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다. - 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다. -- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다. +- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. +- 관리자 멤버 목록은 멤버 검색과 멤버 추가 버튼을 제공한다. 멤버 추가 버튼의 실제 생성 화면 연결은 후속 작업에서 처리한다. +- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다. +- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다. - 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다. - 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다. - `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다. diff --git a/docs/update.md b/docs/update.md index bef07e9..a7dc987 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v0.0.110 + +- 관리자 멤버 목록을 Ghost형 테이블 구조로 재정리. +- 멤버 이름 아래 이메일, 가입일 아래 최근 활동을 함께 표시하도록 수정. +- 멤버 목록에서 권한 변경 선택·저장 UI 제거. +- 멤버 검색 입력과 멤버 추가 버튼 추가. +- 뉴스레터 Open rate 대체 컬럼으로 댓글 작성 개수 표시 유지. +- 패키지 버전 `0.0.110`으로 갱신. + ## v0.0.109 - 관리자 사이드바 `메뉴` 항목을 `네비게이션`으로 변경하고 전용 아이콘 적용. diff --git a/package-lock.json b/package-lock.json index 8edd97d..08d5853 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.109", + "version": "0.0.110", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.109", + "version": "0.0.110", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 88ec64f..c24251e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.109", + "version": "0.0.110", "private": true, "type": "module", "imports": { diff --git a/pages/admin/members/index.vue b/pages/admin/members/index.vue index 1b8450d..46fc391 100644 --- a/pages/admin/members/index.vue +++ b/pages/admin/members/index.vue @@ -3,24 +3,45 @@ definePageMeta({ layout: 'admin' }) +const memberSearchQuery = ref('') + const { data: members } = await useFetch('/admin/api/members', { default: () => [] }) -const roleSavingIds = ref([]) -const roleMessage = ref('') -const roleOptions = [ - { value: 'owner', label: '소유자' }, - { value: 'admin', label: '관리자' }, - { value: 'member', label: '멤버' } -] +const filteredMembers = computed(() => { + const query = memberSearchQuery.value.trim().toLowerCase() + + if (!query) { + return members.value + } + + return members.value.filter((member) => [ + member.username, + member.email, + member.lastSeenIp, + member.activityStatus + ].some((value) => String(value || '').toLowerCase().includes(query))) +}) + +const memberCountLabel = computed(() => { + const count = filteredMembers.value.length + return `${count}명` +}) /** - * 최근 접속 시각 표시 문자열을 반환한다. - * @param {string | null} value - ISO 시각 - * @returns {string} 표시 문자열 + * 회원 이니셜을 반환한다. + * @param {Object} member - 회원 정보 + * @returns {string} 이니셜 */ -const formatLastSeen = (value) => { +const getMemberInitial = (member) => String(member?.username || member?.email || '?').slice(0, 1).toUpperCase() + +/** + * 날짜 표시 형식 변환 + * @param {string | null} value - ISO 날짜 문자열 + * @returns {string} 화면 표시 날짜 + */ +const formatDate = (value) => { if (!value) { return '-' } @@ -30,154 +51,147 @@ const formatLastSeen = (value) => { return '-' } - return date.toLocaleString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${year}.${month}.${day}` } /** - * 권한 저장 진행 여부를 확인한다. - * @param {string} memberId - 회원 ID - * @returns {boolean} 진행 여부 + * 최근 활동 시각을 상대 시간으로 표시한다. + * @param {string | null} value - ISO 시각 + * @returns {string} 상대 시간 */ -const isSavingRole = (memberId) => roleSavingIds.value.includes(memberId) - -/** - * 회원 권한을 변경한다. - * @param {Object} member - 회원 정보 - * @returns {Promise} - */ -const updateRole = async (member) => { - if (!member?.id || isSavingRole(member.id)) { - return +const formatRelativeTime = (value) => { + if (!value) { + return '최근 활동 없음' } - roleSavingIds.value = [...roleSavingIds.value, member.id] - roleMessage.value = '' - - try { - const result = await $fetch(`/admin/api/members/${member.id}/role`, { - method: 'PUT', - body: { - role: member.roleCode - } - }) - - members.value = members.value.map((item) => { - if (item.id !== member.id) { - return item - } - - return { - ...item, - role: result.role, - roleCode: result.roleCode, - isAdmin: Boolean(result.isAdmin) - } - }) - - roleMessage.value = '권한이 변경되었습니다.' - } catch (error) { - roleMessage.value = error?.data?.message || '권한 변경에 실패했습니다.' - } finally { - roleSavingIds.value = roleSavingIds.value.filter((id) => id !== member.id) + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return '최근 활동 없음' } + + const diffMs = Date.now() - date.getTime() + const minute = 1000 * 60 + const hour = minute * 60 + const day = hour * 24 + + if (diffMs < minute) { + return '방금 전' + } + + if (diffMs < hour) { + return `${Math.floor(diffMs / minute)}분 전` + } + + if (diffMs < day) { + return `${Math.floor(diffMs / hour)}시간 전` + } + + if (diffMs < day * 30) { + return `${Math.floor(diffMs / day)}일 전` + } + + return formatDate(value) } -