관리자 글 목록 고정 기능 추가

This commit is contained in:
2026-07-03 15:19:41 +09:00
parent 55efae810f
commit 288e73c0dc
11 changed files with 160 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS admin_pinned BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS posts_admin_pinned_sort_idx
ON posts (admin_pinned DESC, updated_at DESC);

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-07-03 v1.10.26 — 관리자 고정은 공개 추천과 분리한다
비공개 글을 작성 대기 목록처럼 상단에 유지하고 싶은 요구는 공개 홈의 추천 글 노출과 목적이 다르다. 추천(`isFeatured`)을 재사용하면 공개 화면 노출 의도와 관리자 작업 큐가 섞이므로, 게시물에는 관리자 전용 `adminPinned` 플래그를 별도로 둔다. 글 목록은 정렬 방식과 무관하게 고정 글을 먼저 보여 주고, 토글은 목록 행 메뉴에서만 제공해 글쓰기 자동 저장 payload와 충돌하지 않게 한다.
## 2026-07-03 v1.10.25 — 원본 사용 여부는 파생 이미지 사용까지 포함한다
본문 이미지는 공개 화면에서 원본 대신 `body-1600`, `body-720`, `body-2560` 파생 이미지를 사용한다. 하지만 운영자는 원본을 백업·다운로드 기준으로 보관하므로, 원본 URL이 직접 본문에 없다는 이유만으로 미사용으로 분류하면 실제 사용 중인 이미지 묶음을 일괄 삭제할 위험이 생긴다. 따라서 관리자 미디어 사용처 계산은 원본 URL 직접 참조와 함께 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 참조를 원본 사용처로 합산한다. 반대로 원본이 정말 미사용이라 삭제될 때는 연결된 자동 생성 파생 이미지까지 함께 정리해 시스템 파일이 남지 않게 한다.

View File

@@ -83,6 +83,7 @@
| db/migrations/061_page_listing_sections.sql | 페이지 홈 진열장 섹션 관리 테이블 추가 |
| db/migrations/062_site_settings_display_counts.sql | 사이트 설정 공개 목록 표시개수 컬럼 추가 |
| db/migrations/063_site_settings_upload_limits.sql | 사이트 설정 관리자 업로드 제한 컬럼 추가 |
| db/migrations/064_posts_admin_pinned.sql | 관리자 글 목록 전용 상단 고정 컬럼·정렬 인덱스 추가 |
## 서버 미들웨어
@@ -186,7 +187,7 @@
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천 전용 API·삭제) |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `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 | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |

View File

@@ -199,7 +199,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를 사용한다.
- 관리자 글 목록 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/:id/featured` 전용 PUT API에 `{ isFeatured }`만 보내 수정한다.
- 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 태그 추천 목록을 열 수 있다. 입력이 비어 있으면 공개 발행글 기준 최근 사용 태그 5개를 먼저 표시한 뒤 메인 태그를 이어서 보여 주고, 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천한다. 추천 목록은 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.

View File

@@ -5,10 +5,6 @@
## 2차 관리자 개발
- 관리자 글 목록 전용 고정 표시 검토.
- 비공개 글 중 발행 예정·보류 중인 글만 전체 글 목록 상단에 유지할 수 있도록 공개 추천(`isFeatured`)과 별개의 관리자 전용 고정 플래그가 필요하다.
- 단순히 비공개 글 전체를 최신순보다 위로 올리면 작성 완료 후 비공개 보관한 글까지 섞이므로, 별도 `adminPinned` 같은 상태와 목록 정렬·필터 UI를 함께 설계한다.
## 프론트엔드 개발
- 업로드 총량 제한 및 메모리 사용량 개선.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.10.26
- 관리자 글 목록 전용 상단 고정 플래그(`adminPinned`) 추가.
- 관리자 글 목록 행 메뉴에서 `상단 고정`·`고정 해제`를 토글하도록 추가.
- 관리자 글 목록 정렬에서 고정 글을 항상 먼저 표시하고 제목 옆에 고정 배지를 표시하도록 수정.
## v1.10.25
- 관리자 미디어 원본 이미지 사용처 계산에 카드·페이지 커버·본문 파생 이미지 사용처를 함께 반영하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.25",
"version": "1.10.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.25",
"version": "1.10.26",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.25",
"version": "1.10.26",
"private": true,
"type": "module",
"imports": {

View File

@@ -5,6 +5,7 @@ definePageMeta({
const deletingId = ref('')
const togglingFeaturedId = ref('')
const togglingPinnedId = ref('')
const openPostMenuId = ref('')
const postMenuStyle = ref({})
const errorMessage = ref('')
@@ -51,7 +52,8 @@ const { data: postsResponse, refresh: refreshPosts } = await useFetch('/admin/ap
},
counts: {
total: 0,
featured: 0
featured: 0,
pinned: 0
}
})
})
@@ -325,7 +327,8 @@ const postPagination = computed(() => postsResponse.value?.pagination || {
/** 관리자 글 목록 통계 정보 */
const postCounts = computed(() => postsResponse.value?.counts || {
total: 0,
featured: 0
featured: 0,
pinned: 0
})
/** 게시물에 사용 중인 태그 옵션 */
@@ -370,6 +373,9 @@ const totalPostCount = computed(() => Number(postCounts.value.total || 0))
/** 추천(`isFeatured`) 게시물 수 */
const featuredPostCount = computed(() => Number(postCounts.value.featured || 0))
/** 관리자 목록 상단 고정 게시물 수 */
const pinnedPostCount = computed(() => Number(postCounts.value.pinned || 0))
/** 필터 적용 후 총 게시물 수 */
const filteredPostCount = computed(() => Number(postPagination.value.total || 0))
@@ -790,6 +796,31 @@ const togglePostFeatured = async (post) => {
}
}
/**
* 게시물 관리자 목록 고정 여부를 토글한다.
* @param {Object} post - 게시물
* @returns {Promise<void>}
*/
const togglePostPinned = async (post) => {
closePostMenu()
togglingPinnedId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}/pinned`, {
method: 'PUT',
body: {
adminPinned: !post.adminPinned
}
})
await refreshPosts()
} catch (error) {
errorMessage.value = error?.data?.message || '고정 설정을 변경하지 못했습니다.'
} finally {
togglingPinnedId.value = ''
}
}
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
@@ -871,6 +902,10 @@ watch(postPageCount, (pageCount) => {
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-featured">추천 {{ featuredPostCount }}개</span>
</template>
<template v-if="pinnedPostCount > 0">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-pinned">고정 {{ pinnedPostCount }}개</span>
</template>
<template v-if="hasActiveListFilters && filteredPostCount !== totalPostCount">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-filtered">표시 {{ filteredPostCount }}개</span>
@@ -1062,6 +1097,12 @@ watch(postPageCount, (pageCount) => {
<span class="admin-posts__comment-count text-xs font-medium text-muted">
댓글 {{ post.commentCount || 0 }}
</span>
<span
v-if="post.adminPinned"
class="admin-posts__pinned-badge inline-flex h-5 items-center rounded bg-[#15171a] px-1.5 text-[10px] font-semibold text-white"
>
고정
</span>
</div>
<div class="admin-posts__slug-row mt-1 flex items-center gap-1 text-xs text-muted">
<span class="admin-posts__slug truncate">/post/{{ post.slug }}</span>
@@ -1133,14 +1174,14 @@ watch(postPageCount, (pageCount) => {
:ref="(element) => setPostMenuTriggerRef(post.id, element)"
class="admin-posts__row-menu-trigger inline-flex size-9 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#eceff2] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
type="button"
:disabled="deletingId === post.id || togglingFeaturedId === post.id"
:disabled="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
:aria-expanded="openPostMenuId === post.id"
aria-haspopup="menu"
:aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
@click.stop="togglePostMenu(post.id)"
>
<span
v-if="deletingId === post.id || togglingFeaturedId === post.id"
v-if="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
class="text-[10px] font-semibold text-muted"
aria-hidden="true"
>…</span>
@@ -1163,6 +1204,15 @@ watch(postPageCount, (pageCount) => {
role="menu"
data-admin-post-row-menu
>
<button
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
role="menuitem"
:disabled="togglingPinnedId === post.id"
@click="togglePostPinned(post)"
>
{{ post.adminPinned ? '고정 해제' : '상단 고정' }}
</button>
<button
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"

View File

@@ -59,6 +59,7 @@ const mapPostRow = (row) => ({
featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
showFeaturedImage: Boolean(row.show_featured_image),
isFeatured: Boolean(row.is_featured),
adminPinned: Boolean(row.admin_pinned),
commentCount: Number(row.comment_count || 0),
totalViews: Number(row.total_views || 0),
seoTitle: row.seo_title || '',
@@ -98,6 +99,7 @@ const mapAdminPostSummaryRow = (row) => ({
featuredImageThumbnail: getExistingPostThumbnailUrl(row.featured_image),
showFeaturedImage: Boolean(row.show_featured_image),
isFeatured: Boolean(row.is_featured),
adminPinned: Boolean(row.admin_pinned),
commentCount: Number(row.comment_count || 0),
totalViews: Number(row.total_views || 0),
status: row.status,
@@ -574,6 +576,10 @@ export const listAdminPosts = async (options = {}) => {
return matchesStatus && matchesTag && matchesFeatured && matchesSearch
})
samplePosts.sort((left, right) => {
if (Boolean(left.adminPinned) !== Boolean(right.adminPinned)) {
return left.adminPinned ? -1 : 1
}
const leftTime = new Date(left.sortAt || 0).getTime()
const rightTime = new Date(right.sortAt || 0).getTime()
@@ -597,7 +603,8 @@ export const listAdminPosts = async (options = {}) => {
},
counts: {
total: getSamplePosts().length,
featured: getSamplePosts().filter((post) => post.isFeatured).length
featured: getSamplePosts().filter((post) => post.isFeatured).length,
pinned: getSamplePosts().filter((post) => post.adminPinned).length
}
}
}
@@ -612,6 +619,7 @@ export const listAdminPosts = async (options = {}) => {
posts.featured_image,
posts.show_featured_image,
posts.is_featured,
posts.admin_pinned,
posts.status,
posts.published_at,
posts.created_at,
@@ -680,11 +688,13 @@ export const listAdminPosts = async (options = {}) => {
posts.featured_image,
posts.show_featured_image,
posts.is_featured,
posts.admin_pinned,
posts.status,
posts.published_at,
posts.created_at,
posts.updated_at
ORDER BY
posts.admin_pinned DESC,
CASE WHEN ${normalizedSortOrder} = 'oldest' THEN COALESCE(posts.published_at, posts.updated_at) END ASC,
CASE WHEN ${normalizedSortOrder} <> 'oldest' THEN COALESCE(posts.published_at, posts.updated_at) END DESC,
posts.id DESC
@@ -699,7 +709,8 @@ export const listAdminPosts = async (options = {}) => {
const countsRows = await sql`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE posts.is_featured = true)::int AS featured
COUNT(*) FILTER (WHERE posts.is_featured = true)::int AS featured,
COUNT(*) FILTER (WHERE posts.admin_pinned = true)::int AS pinned
FROM posts
`
@@ -759,7 +770,8 @@ export const listAdminPosts = async (options = {}) => {
},
counts: {
total: Number(countsRows[0]?.total || 0),
featured: Number(countsRows[0]?.featured || 0)
featured: Number(countsRows[0]?.featured || 0),
pinned: Number(countsRows[0]?.pinned || 0)
}
}
}
@@ -944,6 +956,31 @@ export const updateAdminPostFeatured = async (id, isFeatured) => {
return rows[0] ? getAdminPostById(rows[0].id) : null
}
/**
* 관리자 게시물 고정 상태 수정
* @param {string} id - 게시물 ID
* @param {boolean} adminPinned - 관리자 목록 상단 고정 여부
* @returns {Promise<Object | null>} 수정된 게시물
*/
export const updateAdminPostPinned = async (id, adminPinned) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
UPDATE posts
SET
admin_pinned = ${Boolean(adminPinned)},
updated_at = now()
WHERE id = ${id}
RETURNING id
`
return rows[0] ? getAdminPostById(rows[0].id) : null
}
/**
* 관리자 게시물 삭제
* @param {string} id - 게시물 ID

View File

@@ -0,0 +1,45 @@
import { createError, getRouterParam, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../../utils/admin-auth'
import { updateAdminPostPinned } from '../../../../../repositories/content-repository'
const pinnedInputSchema = z.object({
adminPinned: z.boolean()
})
/**
* 관리자 게시물 목록 고정 상태 변경 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = String(getRouterParam(event, 'id') || '').trim()
const parsedBody = pinnedInputSchema.safeParse(await readBody(event))
if (!id) {
throw createError({
statusCode: 400,
message: '게시물 ID가 필요합니다.'
})
}
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '고정 상태 입력 형식이 올바르지 않습니다.'
})
}
const post = await updateAdminPostPinned(id, parsedBody.data.adminPinned)
if (!post) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return post
})