관리자 글 목록 백링크 열 추가
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.70에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.71에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·디바이스, IP·열린 페이지 수·열린 페이지 목록 포함 접속자 목록) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `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 | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
|
||||
|
||||
@@ -201,7 +201,7 @@ 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`로 토글한다. 행 메뉴의 `링크 확인`은 `/admin/api/posts/:id/references`로 해당 글의 `/post/:slug` 공개 주소를 본문에 링크한 게시물 목록을 조회한다.
|
||||
- 관리자 글 목록 API(`/admin/api/posts`)는 목록 최적화를 위해 게시물 본문 `content`를 반환하지 않는다. 기본 호출은 기존 호환용 배열을 반환하고, `paged=true` 쿼리를 보내면 `{ items, pagination, counts }` 구조를 반환한다. `page`, `pageSize`, `q`, `status`, `tag`, `featured`, `sort` 쿼리로 서버 검색·상태·태그·추천·정렬 필터를 적용하며 각 항목에 해당 글의 `/post/:slug` 공개 주소를 본문에 링크한 게시물 수 `backlinkCount`를 포함한다. 목록 검색은 제목·슬러그·요약·태그 기준으로 수행하고, 본문이 필요한 편집 화면은 `/admin/api/posts/:id` 상세 API를 사용한다. 관리자 전용 `adminPinned` 글은 공개 추천과 별개로 글 목록 정렬 최상단에 표시하며, 행 메뉴의 `상단 고정`·`고정 해제`에서 `/admin/api/posts/:id/pinned`로 토글한다. 백링크 열의 개수를 클릭하면 `/admin/api/posts/:id/references`로 링크한 게시물 목록을 조회해 모달로 표시한다.
|
||||
- 관리자 글 목록의 추천 토글은 전체 게시물 저장 API가 아니라 `/admin/api/posts/:id/featured` 전용 PUT API에 `{ isFeatured }`만 보내 수정한다.
|
||||
- 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다.
|
||||
- 관리자 글 목록에서 입력창·모달 포커스가 아닐 때 `F` 키를 누르면 글 검색 입력창으로 포커스를 이동한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.71
|
||||
|
||||
- 관리자 글 목록에 백링크 열 추가.
|
||||
- 백링크 개수를 클릭하면 해당 글을 링크한 게시물 목록 모달을 표시하도록 수정.
|
||||
- 백링크 모달 항목 오른쪽에 관리자 글 수정 화면 이동 버튼 추가.
|
||||
- 관리자 글 목록 행 메뉴의 링크 확인 항목 제거.
|
||||
|
||||
## v1.10.70
|
||||
|
||||
- 관리자 글 목록 행 메뉴에 게시물을 링크하고 있는 게시물 확인 기능 추가.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.70",
|
||||
"version": "1.10.71",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -819,7 +819,7 @@ const updatePostMenuPosition = () => {
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const menuWidth = 176
|
||||
const estimatedHeight = 152
|
||||
const estimatedHeight = 112
|
||||
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
|
||||
@@ -1227,6 +1227,7 @@ watch(postPageCount, (pageCount) => {
|
||||
<th class="admin-posts__cell px-4 py-3">제목</th>
|
||||
<th class="admin-posts__cell px-4 py-3">상태</th>
|
||||
<th class="admin-posts__cell px-4 py-3">태그</th>
|
||||
<th class="admin-posts__cell px-4 py-3">백링크</th>
|
||||
<th class="admin-posts__cell px-4 py-3">조회수</th>
|
||||
<th class="admin-posts__cell px-4 py-3">발행일</th>
|
||||
<th class="admin-posts__cell admin-posts__cell-actions w-12 px-2 py-3 text-right">
|
||||
@@ -1314,6 +1315,17 @@ watch(postPageCount, (pageCount) => {
|
||||
</div>
|
||||
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
||||
</td>
|
||||
<td class="admin-posts__cell admin-posts__cell-backlinks px-4 py-4">
|
||||
<button
|
||||
v-if="Number(post.backlinkCount || 0) > 0"
|
||||
class="admin-posts__backlink-count inline-flex min-w-8 justify-center rounded border border-[#d7dce0] bg-white px-2 py-1 text-xs font-semibold text-[#394047] transition-colors hover:border-[#8e9cac] hover:bg-paper"
|
||||
type="button"
|
||||
@click="openReferenceModal(post)"
|
||||
>
|
||||
{{ formatAdminNumber(post.backlinkCount) }}
|
||||
</button>
|
||||
<span v-else class="admin-posts__backlink-empty text-muted">0</span>
|
||||
</td>
|
||||
<td class="admin-posts__cell admin-posts__cell-views px-4 py-4">
|
||||
<p class="admin-posts__view-count text-[#394047]">
|
||||
{{ formatAdminNumber(post.totalViews) }}
|
||||
@@ -1392,14 +1404,6 @@ watch(postPageCount, (pageCount) => {
|
||||
>
|
||||
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7]"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="openReferenceModal(post)"
|
||||
>
|
||||
링크 확인
|
||||
</button>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
|
||||
type="button"
|
||||
@@ -1478,7 +1482,7 @@ watch(postPageCount, (pageCount) => {
|
||||
<header class="admin-posts__reference-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
링크 확인
|
||||
백링크
|
||||
</p>
|
||||
<h2 id="admin-posts-reference-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
|
||||
{{ referenceModalPost?.title || '(제목 없음)' }}
|
||||
@@ -1514,16 +1518,21 @@ watch(postPageCount, (pageCount) => {
|
||||
class="admin-posts__reference-item bg-white px-4 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<NuxtLink
|
||||
class="min-w-0 font-semibold text-ink hover:opacity-70"
|
||||
:to="`/admin/posts/${item.id}`"
|
||||
@click="closeReferenceModal"
|
||||
>
|
||||
<span class="min-w-0 font-semibold text-ink">
|
||||
{{ item.title || '(제목 없음)' }}
|
||||
</NuxtLink>
|
||||
<span class="shrink-0 text-xs text-muted">
|
||||
{{ item.referenceCount }}회 링크
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<span class="text-xs text-muted">
|
||||
{{ item.referenceCount }}회 링크
|
||||
</span>
|
||||
<NuxtLink
|
||||
class="admin-posts__reference-move inline-flex h-8 items-center border border-line px-3 text-xs font-semibold text-[#394047] transition-colors hover:bg-paper"
|
||||
:to="`/admin/posts/${item.id}`"
|
||||
@click="closeReferenceModal"
|
||||
>
|
||||
이동
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>{{ getPostStatusLabel(item) }}</span>
|
||||
|
||||
@@ -106,6 +106,7 @@ const mapAdminPostSummaryRow = (row) => ({
|
||||
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
backlinkCount: Number(row.backlinkCount || row.backlink_count || 0),
|
||||
tags: row.tags || []
|
||||
})
|
||||
|
||||
@@ -139,6 +140,57 @@ const countPostLinkReferences = (content, slug) => {
|
||||
return [...String(content || '').matchAll(createPostLinkReferencePattern(slug))].length
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 게시물별 백링크 게시물 개수 맵을 생성한다.
|
||||
* @param {Array<{ id: string, slug: string }>} targetPosts - 백링크 개수를 계산할 게시물 목록
|
||||
* @param {Array<{ id: string, content?: string }>} sourcePosts - 링크 출처 게시물 목록
|
||||
* @returns {Map<string, number>} 게시물 ID별 백링크 게시물 개수
|
||||
*/
|
||||
const createAdminPostBacklinkCountMap = (targetPosts, sourcePosts) => {
|
||||
const countMap = new Map(targetPosts.map((post) => [post.id, 0]))
|
||||
|
||||
for (const targetPost of targetPosts) {
|
||||
if (!targetPost?.id || !targetPost?.slug) {
|
||||
continue
|
||||
}
|
||||
|
||||
const backlinkCount = sourcePosts.filter((sourcePost) => {
|
||||
return sourcePost.id !== targetPost.id
|
||||
&& countPostLinkReferences(sourcePost.content, targetPost.slug) > 0
|
||||
}).length
|
||||
|
||||
countMap.set(targetPost.id, backlinkCount)
|
||||
}
|
||||
|
||||
return countMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 글 목록 대상의 백링크 게시물 개수를 조회한다.
|
||||
* @param {Array<{ id: string, slug: string }>} targetPosts - 백링크 개수를 계산할 게시물 목록
|
||||
* @returns {Promise<Map<string, number>>} 게시물 ID별 백링크 게시물 개수
|
||||
*/
|
||||
const getAdminPostBacklinkCountMap = async (targetPosts) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!targetPosts.length) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
if (!sql) {
|
||||
return createAdminPostBacklinkCountMap(targetPosts, getSamplePosts())
|
||||
}
|
||||
|
||||
const sourceRows = await sql`
|
||||
SELECT id, content
|
||||
FROM posts
|
||||
WHERE content IS NOT NULL
|
||||
AND content <> ''
|
||||
`
|
||||
|
||||
return createAdminPostBacklinkCountMap(targetPosts, sourceRows)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 글 목록 페이지 표시 개수를 정규화한다.
|
||||
* @param {unknown} value - 요청 표시 개수
|
||||
@@ -617,14 +669,23 @@ export const listAdminPosts = async (options = {}) => {
|
||||
})
|
||||
|
||||
if (!paged) {
|
||||
return samplePosts.map(({ sortAt, ...post }) => post)
|
||||
const items = samplePosts.map(({ sortAt, ...post }) => post)
|
||||
const backlinkCounts = createAdminPostBacklinkCountMap(items, getSamplePosts())
|
||||
return items.map((post) => ({
|
||||
...post,
|
||||
backlinkCount: backlinkCounts.get(post.id) || 0
|
||||
}))
|
||||
}
|
||||
|
||||
const total = samplePosts.length
|
||||
const items = samplePosts.slice(offset, offset + pageSize).map(({ sortAt, ...post }) => post)
|
||||
const backlinkCounts = createAdminPostBacklinkCountMap(items, getSamplePosts())
|
||||
|
||||
return {
|
||||
items,
|
||||
items: items.map((post) => ({
|
||||
...post,
|
||||
backlinkCount: backlinkCounts.get(post.id) || 0
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
@@ -732,8 +793,14 @@ export const listAdminPosts = async (options = {}) => {
|
||||
OFFSET ${paged ? offset : 0}
|
||||
`
|
||||
|
||||
const backlinkCounts = await getAdminPostBacklinkCountMap(rows)
|
||||
const mapAdminPostSummaryRowWithBacklinks = (row) => ({
|
||||
...mapAdminPostSummaryRow(row),
|
||||
backlinkCount: backlinkCounts.get(row.id) || 0
|
||||
})
|
||||
|
||||
if (!paged) {
|
||||
return rows.map(mapAdminPostSummaryRow)
|
||||
return rows.map(mapAdminPostSummaryRowWithBacklinks)
|
||||
}
|
||||
|
||||
const countsRows = await sql`
|
||||
@@ -791,7 +858,7 @@ export const listAdminPosts = async (options = {}) => {
|
||||
const total = Number(totalRows[0]?.total || 0)
|
||||
|
||||
return {
|
||||
items: rows.map(mapAdminPostSummaryRow),
|
||||
items: rows.map(mapAdminPostSummaryRowWithBacklinks),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
Reference in New Issue
Block a user