diff --git a/docs/deploy.md b/docs/deploy.md index 0e9be1e..1fcb2ff 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.10.68에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.70에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 diff --git a/docs/map.md b/docs/map.md index 2d263b5..62e7d48 100644 --- a/docs/map.md +++ b/docs/map.md @@ -54,6 +54,7 @@ | 파일 | 용도 | |------|------| | docs/shortcuts.md | 사이트 단축키, 작성 약어, 슬래시 명령, Markdown 약식 문법 정리 | +| docs/update-log/v1.0.md | 블로그 업데이트 공지 초안(v0.0~v1.0 회고 기록) | ## Nuxt 모듈 @@ -192,7 +193,7 @@ |------|------| | pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·디바이스, IP·열린 페이지 수·열린 페이지 목록 포함 접속자 목록) | | pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 | -| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제) | +| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·링크 확인·삭제 전 참조 경고·삭제) | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 | | pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 | | pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 | @@ -272,6 +273,7 @@ | server/routes/admin/api/posts.get.js | 관리자 게시물 목록 API, 기본 배열 응답과 `paged=true` 페이지 응답 지원 | | server/routes/admin/api/posts.post.js | 관리자 게시물 생성 API | | server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API | +| server/routes/admin/api/posts/[id]/references.get.js | 관리자 게시물 링크 참조 목록 API | | server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API | | server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API | | server/routes/admin/api/posts/export-jobs.get.js | 관리자 게시물 Export 작업 목록 API | diff --git a/docs/spec.md b/docs/spec.md index 9243063..2467a3d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -201,9 +201,10 @@ layouts/ - 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다. - 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다. - 관리자 글 목록의 태그 필터는 태그 증가에 대비해 검색형 combobox로 표시한다. 후보는 전체 태그 API의 사용 중 태그 기준으로 보여 주며, 검색어가 없으면 `전체 태그`와 태그명을 정렬한 목록을, 입력 중에는 태그명·슬러그 부분 일치 결과를 표시한다. 방향키와 Enter로 선택할 수 있고, 선택된 태그는 토큰의 `×`로 전체 태그 상태로 되돌린다. -- 관리자 글 목록 API(`/admin/api/posts`)는 목록 최적화를 위해 게시물 본문 `content`를 반환하지 않는다. 기본 호출은 기존 호환용 배열을 반환하고, `paged=true` 쿼리를 보내면 `{ items, pagination, counts }` 구조를 반환한다. `page`, `pageSize`, `q`, `status`, `tag`, `featured`, `sort` 쿼리로 서버 검색·상태·태그·추천·정렬 필터를 적용한다. 목록 검색은 제목·슬러그·요약·태그 기준으로 수행하고, 본문이 필요한 편집 화면은 `/admin/api/posts/:id` 상세 API를 사용한다. 관리자 전용 `adminPinned` 글은 공개 추천과 별개로 글 목록 정렬 최상단에 표시하며, 행 메뉴의 `상단 고정`·`고정 해제`에서 `/admin/api/posts/:id/pinned`로 토글한다. +- 관리자 글 목록 API(`/admin/api/posts`)는 목록 최적화를 위해 게시물 본문 `content`를 반환하지 않는다. 기본 호출은 기존 호환용 배열을 반환하고, `paged=true` 쿼리를 보내면 `{ items, pagination, counts }` 구조를 반환한다. `page`, `pageSize`, `q`, `status`, `tag`, `featured`, `sort` 쿼리로 서버 검색·상태·태그·추천·정렬 필터를 적용한다. 목록 검색은 제목·슬러그·요약·태그 기준으로 수행하고, 본문이 필요한 편집 화면은 `/admin/api/posts/:id` 상세 API를 사용한다. 관리자 전용 `adminPinned` 글은 공개 추천과 별개로 글 목록 정렬 최상단에 표시하며, 행 메뉴의 `상단 고정`·`고정 해제`에서 `/admin/api/posts/:id/pinned`로 토글한다. 행 메뉴의 `링크 확인`은 `/admin/api/posts/:id/references`로 해당 글의 `/post/:slug` 공개 주소를 본문에 링크한 게시물 목록을 조회한다. - 관리자 글 목록의 추천 토글은 전체 게시물 저장 API가 아니라 `/admin/api/posts/:id/featured` 전용 PUT API에 `{ isFeatured }`만 보내 수정한다. - 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다. +- 관리자 글 목록에서 입력창·모달 포커스가 아닐 때 `F` 키를 누르면 글 검색 입력창으로 포커스를 이동한다. - 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 태그 추천 목록을 열 수 있다. 입력이 비어 있으면 공개 발행글 기준 최근 사용 태그 5개를 먼저 표시한 뒤 메인 태그를 이어서 보여 주고, 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천한다. 추천 목록은 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다. - 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 초안 자동 저장이 중복 슬러그 등으로 실패하면 같은 입력값은 반복 재시도하지 않고, 사용자가 슬러그나 본문을 바꿔 저장 payload가 달라졌을 때 다시 자동 저장을 시도한다. 중복 슬러그 오류는 헤더 오류나 토스트가 아니라 Post URL 입력창 아래 빨간 텍스트로 표시하고, 슬러그를 다시 입력하면 숨긴다. 중복 슬러그 오류가 표시된 동안 Publish/Update/최종 저장 버튼은 비활성화한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다. - 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. 미디어 그리드와 글쓰기 이미지 선택 모달은 원본 삽입·상세 확인은 유지하되, 목록 미리보기에는 가능한 작은 본문 720px 파생 이미지나 카드 썸네일을 우선 사용한다. 글쓰기 이미지 선택 모달의 라이브러리 탭도 `/admin/api/media?paged=1&tab=library` 서버 페이지 응답을 사용하며 파일명과 사용처 제목(게시물·페이지 제목) 검색, 번호형 페이지 이동을 제공한다. 갤러리 미디어 선택 항목에는 선택 순서를 오른쪽 상단 배지로 표시하며, 선택 해제 시 뒤 항목 순서를 자동으로 당긴다. 게시글 대표 이미지 선택 모달도 `/admin/api/media?paged=1&tab=library&kind=image` 서버 페이지 응답, 파일명/사용처 검색, 번호형 페이지 이동, 작은 파생 이미지 우선 미리보기를 사용한다. 두 글쓰기 미디어 모달은 검색 결과 개수와 무관하게 패널 높이를 고정하고 내부 목록만 스크롤한다. @@ -638,6 +639,7 @@ components/content/ - `GET /admin/api/posts` - 글 목록. 기본은 본문 없는 배열 응답이며, `paged=true&page=1&pageSize=50&q=검색어&status=published&tag=slug&featured=featured&sort=newest|oldest` 쿼리를 보내면 서버 필터링된 `{ items, pagination, counts }` 페이지 응답을 반환한다. - `POST /admin/api/posts` - 글 작성 - `GET /admin/api/posts/:id` - 글 상세 +- `GET /admin/api/posts/:id/references` - 해당 글의 `/post/:slug` 공개 주소를 본문에 링크한 게시물 목록 - `PUT /admin/api/posts/:id` - 글 수정 - `DELETE /admin/api/posts/:id` - 글 삭제 - `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록 diff --git a/docs/update-log/v1.0.md b/docs/update-log/v1.0.md new file mode 100644 index 0000000..7994ada --- /dev/null +++ b/docs/update-log/v1.0.md @@ -0,0 +1,36 @@ +# [업데이트 기록] v1.0 + +안녕하세요. +뒤늦게 정리하는 `sori.studio` 업데이트 기록입니다. + +v1.0에서는 사이트를 오픈하면서, 이후 업데이트 내용을 꾸준히 기록할 수 있도록 운영 기반을 먼저 정리했습니다. +이번 버전은 새로운 기능을 크게 늘리기보다, 블로그를 안정적으로 운영하고 관리할 수 있는 기본 흐름을 맞추는 데 집중했습니다. + +## 이번 업데이트에서 반영된 내용 + +- 블로그 공개 오픈 + 사이트를 실제로 공개하고, 기본 페이지가 안정적으로 보이도록 초기 구조를 정리했습니다. + +- 관리자 화면 중심의 운영 기능 정리 + 운영자가 필요한 메뉴를 한 곳에서 확인하고 처리할 수 있도록 관리자 화면 구성을 다듬었습니다. + 글 관리, 설정, 계정 관련 흐름이 자연스럽게 이어지도록 정리해 운영 동선을 단순하게 맞췄습니다. + +- 게시물 작성 기능 적용 및 작성 흐름 보완 + 글을 작성하고 저장하는 기본 기능을 적용하고, 작성 중 끊기지 않도록 입력/저장 흐름을 반복 보완했습니다. + 초안 관리와 발행 전후 흐름도 함께 정리해 실제 운영에서 바로 사용할 수 있도록 맞췄습니다. + +- 이미지/갤러리 등 미디어 삽입 흐름 정리 + 글 작성 중 이미지를 넣는 과정이 복잡하지 않도록 업로드와 선택 흐름을 정리했습니다. + 미디어를 선택했을 때 화면에 어떻게 보이는지도 함께 다듬어 작성 경험을 안정화했습니다. + +- 사이트 설정, 계정/권한, 배포 관련 기본 구조 정비 + 사이트 운영에 필요한 기본 설정 항목을 정리하고, 계정/권한 처리 흐름을 점검했습니다. + 배포 과정에서도 필수 설정이 누락되지 않도록 기본 점검 기준을 함께 맞췄습니다. + +- 초기 운영 중 발견된 불편 사항 수정 + 실제 사용 중 발견된 불편 요소를 작은 단위로 빠르게 수정해, 전체 사용감을 안정적으로 맞췄습니다. + +## 한 줄 요약 + +v1.0은 화려한 확장보다, +블로그를 꾸준히 운영할 수 있는 첫 기반을 다진 버전입니다. diff --git a/docs/update.md b/docs/update.md index c871853..79fd131 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,16 @@ # 업데이트 이력 +## v1.10.70 + +- 관리자 글 목록 행 메뉴에 게시물을 링크하고 있는 게시물 확인 기능 추가. +- 게시물 삭제 전 해당 글을 링크한 게시물이 있으면 확인 메시지에 참조 목록을 표시하도록 수정. +- 관리자 글 목록에서 `F` 키로 검색창에 바로 포커스되도록 수정. + +## v1.10.69 + +- `docs/update-log/` 폴더 추가. +- 블로그 공지용 업데이트 기록 초안 `docs/update-log/v1.0.md` 작성(v0.0~v1.0 회고 포함). + ## v1.10.68 - 현재 접속자가 여러 페이지를 열었을 때 대표 페이지와 열린 페이지 목록이 중복 표시되던 문제 수정. diff --git a/package.json b/package.json index 1a4c09e..846963d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.68", + "version": "1.10.70", "private": true, "type": "module", "imports": { diff --git a/pages/admin/posts/index.vue b/pages/admin/posts/index.vue index a93197d..618fc0e 100644 --- a/pages/admin/posts/index.vue +++ b/pages/admin/posts/index.vue @@ -26,8 +26,13 @@ const analyticsModalMetric = ref('views') const analyticsModalChartPageOffset = ref(0) const analyticsModalLoading = ref(false) const analyticsModalError = ref('') +const referenceModalPost = ref(null) +const referenceModalData = ref(createDefaultPostReferences()) +const referenceModalLoading = ref(false) +const referenceModalError = ref('') const postMenuTriggerRefs = new Map() const tagFilterRef = ref(null) +const postSearchInputRef = ref(null) const { displayValue: searchInput, @@ -122,6 +127,17 @@ function createDefaultPostAnalytics() { } } +/** + * 게시물 참조 목록 기본값을 생성한다. + * @returns {{ target: null, posts: Array }} 게시물 참조 목록 기본값 + */ +function createDefaultPostReferences() { + return { + target: null, + posts: [] + } +} + /** * 숫자를 관리자 목록 표시 형식으로 변환한다. * @param {number} value - 숫자 값 @@ -448,6 +464,12 @@ const getPostThumbnailSrc = (post) => post.featuredImageThumbnail || post.featur /** 게시물 통계 모달이 열려 있는지 */ const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value)) +/** 게시물 참조 모달이 열려 있는지 */ +const isReferenceModalOpen = computed(() => Boolean(referenceModalPost.value)) + +/** 게시물 참조 목록 */ +const referenceModalPosts = computed(() => referenceModalData.value?.posts || []) + /** 통계 모달 검색 유입 조회수 */ const analyticsModalSearchViews = computed(() => getAnalyticsSourceGroupViews(['search'])) @@ -570,6 +592,81 @@ const closeAnalyticsModal = () => { analyticsModalData.value = createDefaultPostAnalytics() } +/** + * 게시물 참조 목록을 조회한다. + * @param {Object} post - 게시물 + * @returns {Promise<{ target: Object | null, posts: Array }>} 참조 목록 + */ +const fetchPostReferences = async (post) => { + return await $fetch(`/admin/api/posts/${post.id}/references`) +} + +/** + * 게시물 참조 모달을 연다. + * @param {Object} post - 게시물 + * @returns {Promise} 모달 열기 완료 + */ +const openReferenceModal = async (post) => { + closePostMenu() + referenceModalPost.value = post + referenceModalData.value = createDefaultPostReferences() + referenceModalError.value = '' + referenceModalLoading.value = true + + try { + referenceModalData.value = await fetchPostReferences(post) + } catch (error) { + referenceModalError.value = error?.data?.message || '링크한 게시물을 불러오지 못했습니다.' + } finally { + referenceModalLoading.value = false + } +} + +/** + * 게시물 참조 모달을 닫는다. + * @returns {void} + */ +const closeReferenceModal = () => { + referenceModalPost.value = null + referenceModalData.value = createDefaultPostReferences() + referenceModalError.value = '' + referenceModalLoading.value = false +} + +/** + * 삭제 확인 메시지를 생성한다. + * @param {Object} post - 삭제 대상 게시물 + * @param {{ posts?: Array }} references - 참조 목록 + * @param {boolean} didReferenceCheckFail - 참조 확인 실패 여부 + * @returns {string} 삭제 확인 메시지 + */ +const createDeletePostMessage = (post, references, didReferenceCheckFail = false) => { + const title = post.title || '(제목 없음)' + const referencePosts = references?.posts || [] + + if (didReferenceCheckFail) { + return `"${title}" 글을 삭제할까요?\n\n이 글을 링크한 게시물 목록은 확인하지 못했습니다.` + } + + if (!referencePosts.length) { + return `"${title}" 글을 삭제할까요?` + } + + const referenceList = referencePosts + .slice(0, 5) + .map((item) => `- ${item.title || '(제목 없음)'}`) + .join('\n') + const moreCount = Math.max(referencePosts.length - 5, 0) + const moreText = moreCount ? `\n- 외 ${moreCount}개` : '' + + return [ + `"${title}" 글을 삭제할까요?`, + '', + `이 글을 링크한 게시물 ${referencePosts.length}개가 있습니다.`, + referenceList + moreText + ].join('\n') +} + /** * 통계 모달 집계 단위를 변경한다. * @param {string} period - 집계 단위 @@ -722,7 +819,7 @@ const updatePostMenuPosition = () => { const rect = trigger.getBoundingClientRect() const menuWidth = 176 - const estimatedHeight = 112 + const estimatedHeight = 152 const margin = 8 const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin)) const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight @@ -767,6 +864,28 @@ const onPostMenuDocumentPointerDown = (event) => { closePostMenu() } +/** + * 키 입력 대상이 편집 가능한 요소인지 확인한다. + * @param {EventTarget | null} target - 이벤트 대상 + * @returns {boolean} 편집 가능 여부 + */ +const isEditableKeyboardTarget = (target) => { + if (!(target instanceof HTMLElement)) { + return false + } + + return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) +} + +/** + * 글 검색 입력창으로 포커스를 이동한다. + * @returns {void} + */ +const focusPostSearchInput = () => { + postSearchInputRef.value?.focus() + postSearchInputRef.value?.select?.() +} + /** * 문서 키 입력을 처리한다. * @param {KeyboardEvent} event - 키보드 이벤트 @@ -780,6 +899,30 @@ const onDocumentKeydown = (event) => { if (event.key === 'Escape' && isAnalyticsModalOpen.value) { closeAnalyticsModal() + return + } + + if (event.key === 'Escape' && isReferenceModalOpen.value) { + closeReferenceModal() + return + } + + if ( + event.key.toLowerCase() === 'f' + && !event.metaKey + && !event.ctrlKey + && !event.altKey + && !event.shiftKey + && !event.isComposing + && !event.defaultPrevented + && !isAnalyticsModalOpen.value + && !isReferenceModalOpen.value + && !isEditableKeyboardTarget(event.target) + ) { + event.preventDefault() + closePostMenu() + closeTagFilter() + focusPostSearchInput() } } @@ -841,7 +984,16 @@ const togglePostPinned = async (post) => { const deletePost = async (post) => { closePostMenu() - if (!confirm(`"${post.title || '(제목 없음)'}" 글을 삭제할까요?`)) { + let references = createDefaultPostReferences() + let didReferenceCheckFail = false + + try { + references = await fetchPostReferences(post) + } catch { + didReferenceCheckFail = true + } + + if (!confirm(createDeletePostMessage(post, references, didReferenceCheckFail))) { return } @@ -933,6 +1085,7 @@ watch(postPageCount, (pageCount) => { { > {{ post.isFeatured ? '추천 제거' : '게시글 추천' }} + + + + +
({ tags: row.tags || [] }) +/** + * 정규식 특수 문자를 이스케이프한다. + * @param {string} value - 원본 문자열 + * @returns {string} 이스케이프된 문자열 + */ +const escapeRegExp = (value) => String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** + * 게시물 공개 링크 참조 정규식을 생성한다. + * @param {string} slug - 게시물 슬러그 + * @returns {RegExp} 게시물 링크 정규식 + */ +const createPostLinkReferencePattern = (slug) => { + return new RegExp(`(?:^|[\\s("'\\[])(?:https?:\\/\\/[^/\\s)"'<>]+)?\\/post\\/${escapeRegExp(slug)}(?=$|[\\s)"'<>?#/])`, 'gi') +} + +/** + * 본문에서 특정 게시물 링크 참조 개수를 계산한다. + * @param {string} content - 게시물 본문 + * @param {string} slug - 참조 대상 슬러그 + * @returns {number} 참조 개수 + */ +const countPostLinkReferences = (content, slug) => { + if (!slug) { + return 0 + } + + return [...String(content || '').matchAll(createPostLinkReferencePattern(slug))].length +} + /** * 관리자 글 목록 페이지 표시 개수를 정규화한다. * @param {unknown} value - 요청 표시 개수 @@ -816,6 +846,76 @@ export const listMediaReferencePosts = async () => { })) } +/** + * 게시물을 링크하고 있는 관리자 게시물 목록을 조회한다. + * @param {string} id - 참조 대상 게시물 ID + * @returns {Promise<{ target: { id: string, title: string, slug: string }, posts: Array<{ id: string, title: string, slug: string, status: string, publishedAt: string | null, updatedAt: string, referenceCount: number }> } | null>} 참조 게시물 목록 + */ +export const listAdminPostReferences = async (id) => { + const sql = getPostgresClient() + const target = await getAdminPostById(id) + + if (!target) { + return null + } + + const mapReferenceRow = (post) => ({ + id: post.id, + title: toAdminPostFormTitle(post.title), + slug: post.slug, + status: post.status, + publishedAt: post.published_at + ? post.published_at.toISOString() + : post.publishedAt || null, + updatedAt: post.updated_at + ? post.updated_at.toISOString() + : post.updatedAt || '', + referenceCount: countPostLinkReferences(post.content, target.slug) + }) + + if (!sql) { + const posts = getSamplePosts() + .filter((post) => post.id !== target.id) + .map(mapReferenceRow) + .filter((post) => post.referenceCount > 0) + + return { + target: { + id: target.id, + title: target.title, + slug: target.slug + }, + posts + } + } + + const rows = await sql` + SELECT + id, + title, + slug, + status, + published_at, + updated_at, + content + FROM posts + WHERE id <> ${target.id} + AND content ILIKE ${`%/post/${target.slug}%`} + ORDER BY updated_at DESC + ` + + return { + target: { + id: target.id, + title: target.title, + slug: target.slug + }, + posts: rows + .map(mapReferenceRow) + .filter((post) => post.referenceCount > 0) + } +} + /** * 관리자 게시물 상세 조회 * @param {string} id - 게시물 ID diff --git a/server/routes/admin/api/posts/[id]/references.get.js b/server/routes/admin/api/posts/[id]/references.get.js new file mode 100644 index 0000000..29fad25 --- /dev/null +++ b/server/routes/admin/api/posts/[id]/references.get.js @@ -0,0 +1,24 @@ +import { createError, getRouterParam } from 'h3' +import { requireAdminSession } from '../../../../../utils/admin-auth' +import { listAdminPostReferences } from '../../../../../repositories/content-repository' + +/** + * 관리자 게시물 참조 목록 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise<{ target: { id: string, title: string, slug: string }, posts: Array }>} 참조 목록 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const id = getRouterParam(event, 'id') + const references = await listAdminPostReferences(id) + + if (!references) { + throw createError({ + statusCode: 404, + message: '게시물을 찾을 수 없습니다.' + }) + } + + return references +})