From 8ca63c0d0084dd8df246f17fc6c1006da9eff91f Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 27 May 2026 10:42:51 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B6=8C=ED=95=9C=20UI=EC=99=80=20=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B2=80=EC=83=89=20=EB=B3=B4=EC=A0=95=20?= =?UTF-8?q?v1.5.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMemberForm.vue | 89 +++++++++++++++++++++++--- components/admin/AdminPageForm.vue | 67 +++++++++++++++---- docs/changelog.md | 7 ++ docs/history.md | 4 ++ docs/map.md | 6 +- docs/spec.md | 8 +-- docs/update.md | 9 +++ package-lock.json | 4 +- package.json | 2 +- pages/admin/posts/index.vue | 89 +++++++++++++++++++++++--- server/routes/admin/api/auth/me.get.js | 14 +++- 11 files changed, 255 insertions(+), 44 deletions(-) diff --git a/components/admin/AdminMemberForm.vue b/components/admin/AdminMemberForm.vue index 371c6f9..dbe6ed3 100644 --- a/components/admin/AdminMemberForm.vue +++ b/components/admin/AdminMemberForm.vue @@ -12,6 +12,13 @@ const props = defineProps({ const emit = defineEmits(['saved', 'deleted']) +const { data: adminSession } = await useFetch('/admin/api/auth/me', { + default: () => ({ + userId: '', + roleCode: '' + }) +}) + const isNewMember = computed(() => props.mode === 'new') const saveMessage = ref('') const saveError = ref('') @@ -87,6 +94,57 @@ const normalizedLabels = computed(() => [...new Set( )]) const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버') +const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '') +const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value)) +const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId) + && String(props.member.id) === String(adminSession.value.userId)) +const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode)) +const shouldRenderRoleAsText = computed(() => isNewMember.value || !isCurrentAdminPrivileged.value) +const canEditRoleSelect = computed(() => { + if (shouldRenderRoleAsText.value || isSaving.value) { + return false + } + + if (currentAdminRoleCode.value === 'owner') { + return !isEditingSelf.value + } + + if (currentAdminRoleCode.value === 'admin') { + return !isTargetPrivilegedRole.value + } + + return false +}) +const availableRoleOptions = computed(() => { + if (!canEditRoleSelect.value) { + return roleOptions + } + + if (currentAdminRoleCode.value === 'admin') { + return roleOptions.filter((option) => ['vip', 'member'].includes(option.value)) + } + + return roleOptions +}) +const roleHelpText = computed(() => { + if (shouldRenderRoleAsText.value) { + return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.' + } + + if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') { + return '소유자는 본인 권한을 직접 낮출 수 없습니다.' + } + + if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) { + return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.' + } + + if (currentAdminRoleCode.value === 'admin') { + return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.' + } + + return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.' +}) /** * 회원 저장 요청 본문을 문자열로 직렬화한다. @@ -383,7 +441,7 @@ const saveMember = async () => { try { const payload = getMemberPayload() - if (!isNewMember.value && form.roleCode !== props.member?.roleCode) { + if (!isNewMember.value && canEditRoleSelect.value && form.roleCode !== props.member?.roleCode) { await $fetch(`/admin/api/members/${props.member.id}/role`, { method: 'PUT', body: { @@ -558,17 +616,28 @@ watch(() => props.member, () => { diff --git a/components/admin/AdminPageForm.vue b/components/admin/AdminPageForm.vue index 51ff148..0065fe3 100644 --- a/components/admin/AdminPageForm.vue +++ b/components/admin/AdminPageForm.vue @@ -40,6 +40,23 @@ const htmlCursorRange = reactive({ end: 0 }) +const defaultHtmlDocument = ` + + + + + Landing + + + + +` + const form = reactive({ title: props.initialPage.title || '', slug: props.initialPage.slug || '', @@ -203,6 +220,42 @@ const insertTextAtHtmlCursor = async (text) => { htmlCursorRange.end = nextCursor } +/** + * HTML 기본 문서 골격을 현재 본문에 채운다. + * @returns {Promise} + */ +const completeHtmlDocumentSkeleton = async () => { + form.content = defaultHtmlDocument + + await nextTick() + + const bodyIndex = form.content.indexOf('') + const nextCursor = bodyIndex > -1 ? bodyIndex : form.content.length + htmlEditor.value?.focus() + htmlEditor.value?.setSelectionRange(nextCursor, nextCursor) + htmlCursorRange.start = nextCursor + htmlCursorRange.end = nextCursor +} + +/** + * HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {Promise} + */ +const handleHtmlEditorKeydown = async (event) => { + if (event.key !== 'Tab' || form.renderMode !== 'html_document') { + return + } + + const content = form.content.trim() + if (content !== '' && content !== '!') { + return + } + + event.preventDefault() + await completeHtmlDocumentSkeleton() +} + /** * 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다. * @param {Event} event - 파일 입력 이벤트 @@ -355,20 +408,10 @@ defineExpose({ @click="rememberHtmlCursor" @focus="rememberHtmlCursor" @input="rememberHtmlCursor" + @keydown="handleHtmlEditorKeydown" @keyup="rememberHtmlCursor" @select="rememberHtmlCursor" - placeholder=" - - - - Landing - - - - -" + :placeholder="defaultHtmlDocument" /> diff --git a/docs/changelog.md b/docs/changelog.md index 63f7324..25c990a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,12 @@ # 업데이트 요약 +## v1.5.10 + +- 멤버 상세에서 변경할 수 없는 등급 셀렉트를 화면에서도 잠그도록 정리했다. +- 글 목록에 검색과 작은 대표 이미지 썸네일을 추가했다. +- 글 목록 필터 셀렉트 화살표 간격과 아이콘을 통일했다. +- 페이지 HTML 문서 모드에서 빈 본문 또는 `!`+Tab으로 기본 HTML 골격을 자동 완성할 수 있게 했다. + ## v1.5.9 - 관리자 대시보드에서 인기 페이지 통계를 볼 수 있게 했다. diff --git a/docs/history.md b/docs/history.md index c1e6f2b..3445425 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-05-27 v1.5.10 — 권한 UI와 글 목록 스캔성 보정 + +권한 변경은 서버에서 거부되더라도 사용자가 바꿀 수 있는 것처럼 보이면 운영 실수와 혼란이 생긴다. 따라서 소유자 본인 강등, 관리자의 소유자·관리자 조작처럼 서버 규칙상 막히는 상황은 멤버 상세 화면에서도 셀렉트를 비활성화하고, 등급 변경 권한 자체가 없는 세션은 일반 텍스트로만 보여 준다. 글 목록은 필터만으로는 특정 글을 빠르게 찾기 어려우므로 검색을 추가하고, 대표 이미지는 제목 옆 작은 썸네일로만 보여 목록 높이를 키우지 않으면서 이미지 존재 여부를 알 수 있게 했다. 페이지 HTML 문서는 VS Code식 `!`+Tab 습관을 살려 빈 본문에서 기본 골격을 빠르게 채우도록 했다. + ## 2026-05-27 v1.5.9 — 페이지 통계와 추천 사이트 메타데이터 확장 고정 페이지는 HTML 랜딩 페이지처럼 단독 URL로 쓰이기 때문에 게시물처럼 조회 추이를 볼 수 있어야 한다. 일반 Nuxt 페이지는 기존 클라이언트 통계 플러그인으로 pageSlug를 함께 보내고, 원문 HTML 문서 모드는 Nuxt 앱이 실행되지 않으므로 서버 미들웨어에서 GET 조회를 직접 기록한다. 추천 사이트는 URL 자체보다 운영자가 지정한 짧은 문구와 썸네일이 더 명확한 경우가 있으므로, 기존 파비콘 fallback은 유지하되 대체 텍스트와 썸네일 URL을 선택적으로 저장하게 했다. diff --git a/docs/map.md b/docs/map.md index ce567e1..85e6b44 100644 --- a/docs/map.md +++ b/docs/map.md @@ -80,7 +80,7 @@ | components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) | | components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 | -| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 | +| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 | | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 | | components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | @@ -125,7 +125,7 @@ |------|------| | pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물·인기 페이지 참여 지표) | | pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 | -| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) | +| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 | | pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 | | pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) | @@ -143,7 +143,7 @@ | lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 | | pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) | | pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) | -| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 저장 버튼 기반 멤버 등급 변경, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) | +| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 저장 버튼 기반 멤버 등급 변경, 권한 변경 불가 상황 셀렉트 잠금/텍스트 표시, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) | ## 공개 페이지 diff --git a/docs/spec.md b/docs/spec.md index 34b9d7c..0ba0a52 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -517,8 +517,8 @@ components/content/ > 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다. > 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다. > 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일). -> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다. -> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. +> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다. +> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다. > 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다. > 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다. > 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. @@ -643,7 +643,7 @@ components/content/ - 고정 페이지 작성/수정 화면은 게시글 작성 화면과 같은 전체 화면 에디터 구조를 사용한다. 상단 툴바에 목록 이동, 저장 상태, 저장 버튼, 설정 패널 토글을 두고 오른쪽 설정 패널은 접고 펼칠 수 있다. - 고정 페이지 작성/수정 화면의 기본 모드는 HTML 문서 모드이며, `markdown` 모드는 `일반 텍스트`로 표시한다. -- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다. +- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다. HTML 문서 textarea에서 본문이 비어 있거나 `!`만 입력된 상태로 Tab을 누르면 기본 HTML 골격을 실제 본문에 자동 완성한다. - 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다. - 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다. - 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다. @@ -694,7 +694,7 @@ components/content/ - 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. 상태 열은 멤버 등급을 먼저 표시하고, 비활성 회원만 작은 보조 상태로 표시한다. - 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다. - 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다. -- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다. +- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 소유자 본인이나 관리자가 다른 관리자·소유자를 보는 등 서버에서 변경 불가한 경우 등급 셀렉트는 비활성화하고, 등급 변경 권한이 없는 세션에서는 등급을 일반 텍스트로 표시한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다. - 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다. - `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다. - 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 본인 권한을 직접 낮출 수 없고, 시스템에는 최소 1명의 소유자가 항상 남아야 한다. diff --git a/docs/update.md b/docs/update.md index 6236cff..14f9fc1 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v1.5.10 + +- 관리자 멤버 상세: 소유자 본인, 관리자끼리 등 변경 불가한 등급 셀렉트는 UI에서 비활성화하도록 수정. +- 관리자 멤버 상세: 등급 변경 권한이 없는 세션에서는 멤버 등급을 일반 텍스트로 표시하도록 수정. +- 관리자 글 목록: 제목·슬러그·요약·본문·태그 기준 검색 입력 추가. +- 관리자 글 목록: 필터 셀렉트 화살표를 공통 SVG 아이콘과 오른쪽 여백으로 통일. +- 관리자 글 목록: 추천 표시와 제목 사이에 대표 이미지 썸네일 열 추가. +- 관리자 페이지 작성: HTML 문서 모드에서 빈 본문 또는 `!` 입력 후 Tab으로 기본 HTML 골격을 자동 완성하도록 추가. + ## v1.5.9 - 관리자 대시보드: 페이지별 조회·방문자·체류·스크롤 통계 수집 및 인기 페이지 목록 추가. diff --git a/package-lock.json b/package-lock.json index 1147090..a16d7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.9", + "version": "1.5.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.9", + "version": "1.5.10", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 9070e55..a21260c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.9", + "version": "1.5.10", "private": true, "type": "module", "imports": { diff --git a/pages/admin/posts/index.vue b/pages/admin/posts/index.vue index cf45cf5..8ac46ef 100644 --- a/pages/admin/posts/index.vue +++ b/pages/admin/posts/index.vue @@ -14,6 +14,7 @@ const statusFilter = ref('all') const tagFilter = ref('all') const featuredFilter = ref('all') const sortOrder = ref('newest') +const searchQuery = ref('') const postMenuTriggerRefs = new Map() const { data: posts, refresh } = await useFetch('/admin/api/posts', { @@ -205,7 +206,35 @@ const featuredPostCount = computed(() => posts.value.filter((post) => post.isFea /** 필터가 적용됐는지 */ const hasActiveListFilters = computed(() => statusFilter.value !== 'all' || tagFilter.value !== 'all' - || featuredFilter.value !== 'all') + || featuredFilter.value !== 'all' + || Boolean(searchQuery.value.trim())) + +/** + * 게시물이 검색어와 일치하는지 확인한다. + * @param {Object} post - 게시물 + * @param {string} query - 검색어 + * @returns {boolean} 일치 여부 + */ +const doesPostMatchSearch = (post, query) => { + const keyword = query.trim().toLowerCase() + if (!keyword) { + return true + } + + const tagText = (post.tags || []) + .map((tag) => `${tag} ${getTagName(tag)}`) + .join(' ') + + const haystack = [ + post.title, + post.slug, + post.excerpt, + post.content, + tagText + ].join(' ').toLowerCase() + + return haystack.includes(keyword) +} const filteredPosts = computed(() => { const filtered = posts.value.filter((post) => { @@ -213,8 +242,9 @@ const filteredPosts = computed(() => { const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value) const matchesFeatured = featuredFilter.value === 'all' || (featuredFilter.value === 'featured' && post.isFeatured) + const matchesSearch = doesPostMatchSearch(post, searchQuery.value) - return matchesStatus && matchesTag && matchesFeatured + return matchesStatus && matchesTag && matchesFeatured && matchesSearch }) return [...filtered].sort((a, b) => { @@ -424,39 +454,64 @@ watch(openPostMenuId, async (postId) => {
+
@@ -477,6 +532,9 @@ watch(openPostMenuId, async (postId) => { 추천 + + 썸네일 + 제목 상태 태그 @@ -500,6 +558,17 @@ watch(openPostMenuId, async (postId) => { + + + + +
diff --git a/server/routes/admin/api/auth/me.get.js b/server/routes/admin/api/auth/me.get.js index d6fbd30..a8a876f 100644 --- a/server/routes/admin/api/auth/me.get.js +++ b/server/routes/admin/api/auth/me.get.js @@ -1,8 +1,18 @@ import { requireAdminSession } from '../../../../utils/admin-auth' +import { getMemberForAdmin } from '../../../../repositories/member-repository' /** * 관리자 세션 조회 API * @param {import('h3').H3Event} event - 요청 이벤트 - * @returns {{ userId: string, email: string, role: 'admin' }} 관리자 세션 정보 + * @returns {Promise<{ userId: string, email: string, role: 'admin', roleCode: string, roleLabel: string }>} 관리자 세션 정보 */ -export default defineEventHandler((event) => requireAdminSession(event)) +export default defineEventHandler(async (event) => { + const session = requireAdminSession(event) + const member = await getMemberForAdmin(session.userId) + + return { + ...session, + roleCode: member?.roleCode || 'admin', + roleLabel: member?.role || '관리자' + } +})