관리자 목록과 이미지 설정 개선

This commit is contained in:
2026-07-20 12:37:53 +09:00
parent cf7d6ca0de
commit ad38644171
19 changed files with 788 additions and 92 deletions

View File

@@ -38,6 +38,24 @@ const emit = defineEmits([
'update-inline-color'
])
/** @type {number} 단일 이미지 직접 너비 최소값 */
const IMAGE_WIDTH_MIN = 120
/** @type {number} 단일 이미지 직접 너비 최대값 */
const IMAGE_WIDTH_MAX = 1600
/** @type {Array<{ label: string, value: string, pixelWidth: number|null }>} 단일 이미지 대표 너비 옵션 */
const IMAGE_WIDTH_PRESET_OPTIONS = [
{ label: '전체 너비(자동)', value: 'auto', pixelWidth: null },
{ label: '갤러리 2장 1칸(356px)', value: '356', pixelWidth: 356 },
{ label: '갤러리 3장 1칸(235px)', value: '235', pixelWidth: 235 },
{ label: '갤러리 3장 2칸(477px)', value: '477', pixelWidth: 477 },
{ label: '직접 입력', value: 'custom', pixelWidth: null }
]
/** @type {import('vue').Ref<Set<string>>} 직접 입력으로 열어둔 이미지 너비 상태 */
const customImageWidthKeys = ref(new Set())
/** @type {import('vue').Ref<Record<string, string>>} 직접 너비 입력 중인 원본 문자열 */
const imageWidthDraftValues = ref({})
const backgroundLabels = {
gray: '회색',
blue: '파랑',
@@ -193,6 +211,190 @@ const panelMeta = computed(() => {
return '커서가 위치한 이미지 줄'
})
/**
* 이미지 너비 입력 값을 화면용 문자열로 변환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 입력 표시 값
*/
const getImagePixelWidthInputValue = (imageIndex, image) => {
const draftKey = getImageWidthStateKey(imageIndex, image)
if (Object.prototype.hasOwnProperty.call(imageWidthDraftValues.value, draftKey)) {
return imageWidthDraftValues.value[draftKey]
}
const width = Math.round(Number(image?.pixelWidth) || 0)
return width ? String(width) : ''
}
/**
* 이미지 너비 UI 상태 키를 반환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 상태 키
*/
const getImageWidthStateKey = (imageIndex, image) => `${imageIndex}:${String(image?.url || '')}`
/**
* 이미지 너비 직접 입력 UI 상태를 저장한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {boolean} enabled - 직접 입력 여부
* @returns {void}
*/
const setImageWidthCustomState = (imageIndex, image, enabled) => {
const nextKeys = new Set(customImageWidthKeys.value)
const key = getImageWidthStateKey(imageIndex, image)
if (enabled) {
nextKeys.add(key)
} else {
nextKeys.delete(key)
}
customImageWidthKeys.value = nextKeys
}
/**
* 이미지 너비 입력 임시 값을 저장한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {string} value - 입력 문자열
* @returns {void}
*/
const setImageWidthDraftValue = (imageIndex, image, value) => {
imageWidthDraftValues.value = {
...imageWidthDraftValues.value,
[getImageWidthStateKey(imageIndex, image)]: value
}
}
/**
* 이미지 너비 입력 임시 값을 지운다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {void}
*/
const clearImageWidthDraftValue = (imageIndex, image) => {
const key = getImageWidthStateKey(imageIndex, image)
const nextValues = { ...imageWidthDraftValues.value }
delete nextValues[key]
imageWidthDraftValues.value = nextValues
}
/**
* 직접 입력 너비를 허용 범위로 정리한다.
* @param {unknown} value - 입력 너비
* @returns {number|null} 정리된 너비
*/
const normalizeImageWidthInput = (value) => {
const width = Math.round(Number(value) || 0)
if (!width) {
return null
}
return Math.min(Math.max(width, IMAGE_WIDTH_MIN), IMAGE_WIDTH_MAX)
}
/**
* 이미지 너비 대표 옵션 값을 반환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 셀렉트 값
*/
const getImageWidthPresetValue = (imageIndex, image) => {
const width = Math.round(Number(image?.pixelWidth) || 0)
if (customImageWidthKeys.value.has(getImageWidthStateKey(imageIndex, image))) {
return 'custom'
}
if (!width) {
return 'auto'
}
return IMAGE_WIDTH_PRESET_OPTIONS.some((option) => option.pixelWidth === width)
? String(width)
: 'custom'
}
/**
* 이미지 너비 대표 옵션 변경을 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 변경 이벤트
* @returns {void}
*/
const emitImageWidthPresetChange = (imageIndex, image, event) => {
const value = String(event.target?.value || 'auto')
const preset = IMAGE_WIDTH_PRESET_OPTIONS.find((option) => option.value === value)
if (value === 'custom') {
setImageWidthCustomState(imageIndex, image, true)
setImageWidthDraftValue(imageIndex, image, String(Math.round(Number(image?.pixelWidth) || 0) || 356))
emit('update-media-image', imageIndex, {
pixelWidth: Math.round(Number(image?.pixelWidth) || 0) || 356
})
return
}
setImageWidthCustomState(imageIndex, image, false)
clearImageWidthDraftValue(imageIndex, image)
emit('update-media-image', imageIndex, {
pixelWidth: preset?.pixelWidth || null
})
}
/**
* 이미지 너비 입력 변경을 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const emitImagePixelWidthChange = (imageIndex, image, event) => {
const raw = String(event.target?.value || '').trim()
const width = Math.round(Number(raw) || 0)
setImageWidthCustomState(imageIndex, image, true)
setImageWidthDraftValue(imageIndex, image, raw)
if (!raw) {
emit('update-media-image', imageIndex, {
pixelWidth: null
})
return
}
if (width < IMAGE_WIDTH_MIN || width > IMAGE_WIDTH_MAX) {
return
}
emit('update-media-image', imageIndex, {
pixelWidth: width
})
}
/**
* 이미지 너비 입력 종료 시 허용 범위로 정리해 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const commitImagePixelWidthChange = (imageIndex, image, event) => {
const raw = String(event.target?.value || '').trim()
const pixelWidth = normalizeImageWidthInput(raw)
setImageWidthCustomState(imageIndex, image, true)
clearImageWidthDraftValue(imageIndex, image)
emit('update-media-image', imageIndex, {
pixelWidth
})
}
/**
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
* @param {FocusEvent} event - focusout 이벤트
@@ -505,6 +707,46 @@ const onPanelFocusOut = (event) => {
@input="emit('update-media-image', imageIndex, { url: $event.target.value })"
>
</label>
<label
v-if="panel.kind === 'image'"
class="grid gap-1 text-xs font-semibold text-[#394047]"
>
표시 너비
<span class="relative block">
<select
class="w-full appearance-none rounded border border-[#d7dde2] bg-white px-3 py-2 pr-9 text-sm font-normal text-[#15171a] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
:value="getImageWidthPresetValue(imageIndex, image)"
@change="emitImageWidthPresetChange(imageIndex, image, $event)"
>
<option
v-for="option in IMAGE_WIDTH_PRESET_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label
v-if="panel.kind === 'image' && getImageWidthPresetValue(imageIndex, image) === 'custom'"
class="grid gap-1 text-xs font-semibold text-[#394047]"
>
직접 너비
<input
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a] placeholder:font-normal placeholder:text-[#8e9cac]"
:value="getImagePixelWidthInputValue(imageIndex, image)"
type="number"
inputmode="numeric"
:min="IMAGE_WIDTH_MIN"
:max="IMAGE_WIDTH_MAX"
step="10"
placeholder="자동"
@input="emitImagePixelWidthChange(imageIndex, image, $event)"
@blur="commitImagePixelWidthChange(imageIndex, image, $event)"
>
</label>
<div v-if="panel.kind === 'gallery'" class="flex flex-wrap gap-2">
<button
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"

View File

@@ -1776,7 +1776,7 @@ const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 마크다운 이미지 줄과 단독 이미지 URL 줄을 같은 이미지 데이터로 해석한다.
* @param {string} line - 원본 마크다운 줄
* @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null} 이미지 데이터
* @returns {{ url: string, width: string, pixelWidth: number|null, caption: string, useAlt: boolean }|null} 이미지 데이터
*/
const parseEditorImageLine = (line) => {
const image = parseImageMarkdownLine(line)
@@ -1794,6 +1794,7 @@ const parseEditorImageLine = (line) => {
return {
url: trimmed,
width: 'regular',
pixelWidth: null,
caption: '',
useAlt: false
}
@@ -2041,7 +2042,7 @@ const replaceActiveMediaImages = (images) => {
/**
* 현재 미디어 블록의 특정 이미지를 수정한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Partial<{ alt: string, url: string, width: string }>} patch - 변경 값
* @param {Partial<{ alt: string, url: string, width: string, pixelWidth: number|null }>} patch - 변경 값
* @returns {void}
*/
const updateActiveMediaImage = (imageIndex, patch) => {

View File

@@ -169,6 +169,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
ordered: options.ordered || false,
listNumbers: Array.isArray(options.listNumbers) ? options.listNumbers : [],
width: options.width || 'regular',
pixelWidth: Math.round(Number(options.pixelWidth) || 0) || null,
images: options.images || [],
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled === true,
@@ -712,7 +713,8 @@ const parseMarkdownBlocks = (markdown) => {
const startLine = index
blocks.push(attachSourceRange(createBlock('image', '', null, `block-${blocks.length}`, {
url: trimmedLine,
width: 'regular'
width: 'regular',
pixelWidth: null
}), startLine, startLine))
index += 1
continue
@@ -1729,6 +1731,17 @@ const getQuoteLineEntries = (block) => {
}))
}
/**
* IME·브라우저 자동 목록 보정으로 생긴 인용 접두를 정리한다.
* @param {string} value - 인용 본문
* @returns {string} 정리된 본문
*/
const normalizeQuoteEditableValue = (value) => {
return String(value ?? '').replace(/\r/g, '').split('\n').map((line) => {
return line.replace(/^>\.\s?/, '')
}).join('\n')
}
/**
* 코드 블록 줄 번호 목록을 만든다.
* @param {Object} block - 코드 블록
@@ -2460,7 +2473,11 @@ const buildQuoteBlockLines = (block, payload) => {
* @returns {void}
*/
const onQuoteBlockCommit = (block, payload) => {
commitInlineBlockLines(block, buildQuoteBlockLines(block, payload))
const normalizedPayload = typeof payload === 'object' && payload !== null
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
: normalizeQuoteEditableValue(payload)
commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
}
/**
@@ -2486,7 +2503,7 @@ const onQuoteBlockInsertBelow = (block, payload) => {
*/
const onQuoteBlockConvertToParagraph = (block, payload) => {
const { value } = normalizeCommitPayload(payload)
const lines = String(value ?? '').replace(/\r/g, '').split('\n')
const lines = normalizeQuoteEditableValue(value).split('\n')
const replacementLines = lines.length ? lines : ['']
pendingFocusLine.value = block.meta.startLine
@@ -3306,6 +3323,7 @@ onBeforeUnmount(() => {
:alt="getImageAltAttribute(block)"
:caption="getImageDisplayCaption(block)"
:variant="block.width"
:pixel-width="block.pixelWidth"
expandable
@activate="openContentLightbox(block.id)"
/>
@@ -3318,6 +3336,7 @@ onBeforeUnmount(() => {
:alt="getImageAltAttribute(block)"
:caption="getImageDisplayCaption(block)"
:variant="block.width"
:pixel-width="block.pixelWidth"
expandable
@activate="openContentLightbox(block.id)"
/>

View File

@@ -31,6 +31,10 @@ const props = defineProps({
type: String,
default: 'regular'
},
pixelWidth: {
type: Number,
default: null
},
expandable: {
type: Boolean,
default: false
@@ -44,6 +48,18 @@ const currentSrc = ref(props.src)
const hasRenderableSrc = computed(() => String(currentSrc.value || '').trim().length > 0)
const currentSrcset = computed(() => currentSrc.value === props.src ? props.srcset : '')
const figureStyle = computed(() => {
const width = Math.round(Number(props.pixelWidth) || 0)
if (!width) {
return undefined
}
return {
maxWidth: `${width}px`,
width: '100%'
}
})
const errorLabel = computed(() => {
const trimmed = String(props.fallbackSrc || props.src || '').trim()
@@ -99,6 +115,7 @@ const activateImage = () => {
<template>
<figure
class="prose-image mb-2.5"
:style="figureStyle"
:class="{
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'

View File

@@ -1,5 +1,32 @@
# 업데이트 요약
## v1.10.77
- 단일 이미지 표시 너비 프리셋을 갤러리 2장·3장 배치 기준으로 바꿨다.
- 직접 너비 입력 중 숫자가 최소·최대값으로 즉시 바뀌던 문제를 고쳤다.
## v1.10.76
- 단일 이미지 표시 너비가 사용자 뷰어와 라이브모드에서 실제 폭으로 반영되도록 고쳤다.
## v1.10.75
- 단일 이미지 표시 너비에서 직접 입력을 선택하면 너비 입력창이 유지되도록 고쳤다.
## v1.10.74
- 페이지 목록 화면을 게시글 목록과 비슷한 검색·필터·정렬 레이아웃으로 맞췄다.
- 페이지 목록에도 추천 표시와 썸네일 열을 추가했다.
- 단일 이미지 표시 너비를 셀렉트로 쉽게 고를 수 있게 했다.
## v1.10.73
- 게시글 백링크 개수와 참조 목록에 고정 페이지에서 링크한 경우도 포함했다.
- 페이지 목록에서 검색할 수 있게 했다.
- 글쓰기 인용 입력 중 `>. `가 끼어드는 경우를 보정했다.
- 단일 이미지 표시 너비를 오른쪽 설정 패널에서 입력할 수 있게 했다.
- 대시보드에 누적 방문자를 추가하고, 오늘 방문자 라벨과 디바이스/현재 접속자 표시를 정리했다.
## v1.10.22
- 관리자 글 목록이 게시물 본문 전체를 받지 않도록 줄여 목록 진입 부담을 낮췄다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.72에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.77에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형

View File

@@ -1,3 +1,13 @@
## 2026-07-20 v1.10.74 — 페이지 목록은 게시글 목록의 작업 화면 언어를 따른다
게시글과 페이지는 노출 방식은 다르지만 관리자 목록에서 운영자가 하는 일은 검색, 상태 확인, 통계 확인, 수정 진입으로 거의 같다. 따라서 페이지 목록도 게시글 목록과 같은 상단 검색·필터·정렬 구조와 추천/썸네일 열을 사용해 두 화면의 탐색 리듬을 맞춘다. 페이지 전용 서버 페이지네이션은 아직 두지 않고, 현재 API 응답 위에서 클라이언트 필터로 UI 일관성을 먼저 확보한다.
## 2026-07-20 v1.10.73 — 백링크와 이미지 크기는 작성자가 보는 단위에 맞춘다
백링크는 게시글 목록에 표시되지만 실제 링크 출처는 게시글만이 아니라 고정 페이지도 될 수 있다. 운영자가 삭제나 수정 전에 참조 관계를 확인하는 목적이라면 콘텐츠 종류보다 `/post/:slug`를 실제로 가리키는지 여부가 더 중요하므로, 백링크 개수와 참조 모달은 게시물·페이지 본문을 함께 본다.
단일 이미지 크기는 기존 `regular/wide/full` 폭 옵션을 유지하면서 별도 픽셀 너비 `{size=숫자}`를 추가했다. 이렇게 하면 기존 문서와 호환되며, 작성자는 오른쪽 설정 패널에서 원하는 가로 크기를 입력하고 공개 화면은 `max-width`만 제한해 원본 비율을 유지한다.
## 2026-07-08 v1.10.66 — 현재 접속자는 세션보다 사람에 가깝게 묶어 보여준다
실시간 세션 ID는 같은 방문자라도 탭이나 브라우저 저장소 단위가 달라지면 서로 다른 값으로 보일 수 있다. 관리자 화면에서 SID를 그대로 노출하면 사람 식별 보조 정보처럼 보이지만, 실제로는 같은 사람이 여러 페이지를 열었을 때 여러 명처럼 보이는 혼란을 만든다.

View File

@@ -141,7 +141,7 @@
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
@@ -161,7 +161,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 인라인 색상·굵게·링크 렌더링, 라이브 인용·콜아웃·토글 기본 목록 렌더링과 멀티라인 편집 줄 범위 포커스, 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 공개 콜아웃·토글 내부 본문 블록 렌더링, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링(셀 내부 인라인 링크와 `<br>` 줄바꿈 포함), 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 인라인 색상·굵게·링크 렌더링, 라이브 인용·콜아웃·토글 기본 목록 렌더링과 멀티라인 편집 줄 범위 포커스, 인용 `>. ` 접두 커밋 정리, 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 토글 저장 기본 펼침·닫힘 상태 반영, 공개 콜아웃·토글 내부 본문 블록 렌더링, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 본문 표시용 720w/1600w WebP `srcset` 캐시 버전 우선 표시와 원본 fallback, 단일 이미지 `{size=숫자}` 최대 너비 표시, 단일 이미지·갤러리 통합 라이트박스의 2560px WebP 캐시 버전 우선 표시, 갤러리 비율 기반 행 레이아웃, 라이브 이미지·갤러리 hover 삭제 액션, 리스트 마커 파란 계열 통일과 여러 줄 항목 첫 줄 기준 정렬 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 멀티라인 인라인 마크다운 렌더링, `blockMarkdown` 옵션 기반 기본 불릿/번호 목록 표시와 공개 목록 마커 색상 적용, plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, 위/아래 인접 블록 이동 시 편집 스크롤 추적, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| components/content/ContentMarkdownInlineSegments.vue | 공개·라이브 본문 공통 인라인 세그먼트 렌더링(링크·굵게·기울임·인라인 코드·첨자·인라인 색상), 링크·굵게·기울임·색상 내부 중첩 인라인 렌더링, 사용자 화면 fragment 주석 반복을 줄이는 단일 루트 구조 |
| components/content/ContentMarkdownBlockGroup.vue | Markdown 본문 블록 단위 렌더 그룹, 라이브 삽입 영역을 조건부로만 DOM에 추가해 공개 화면 fragment 주석 감소 |
@@ -191,14 +191,14 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·디바이스, IP·열린 페이지 수·열린 페이지 목록 포함 접속자 목록) |
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스, 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/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
| pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 홈 표시·섹션·추천 배지, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/index.vue | 페이지 목록, 게시글 목록과 같은 상단 검색·상태 필터·노출 필터·정렬·새 페이지 버튼 구조, 전체·홈 표시·추천·필터 표시 개수 요약, 추천 별·페이지 커버 썸네일 열, 제목·슬러그·요약·상태·섹션 기준 검색, 제목 아래 주소·공개 보기 hover 아이콘, 홈 표시·섹션·추천 배지, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 홈 Pages 진열장 노출 메타, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 홈 Pages 진열장 노출 메타, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/본문 이미지/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 라이브러리 파일 종류 필터와 미사용 토글, 시스템 탭 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, `/admin/api/media?paged=1` 기반 50·100·200개 단위 페이지네이션과 현재 페이지 선택·선택 삭제, 표시 개수 셀렉트 SVG chevron 화살표, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일·본문 이미지 사용 배지(파생 탭의 해당 배지는 숨김), 그리드용 작은 파생 이미지 우선 표시와 원본 fallback, 대표 이미지 원본 카드 썸네일 생성·재생성, 본문 이미지 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |

View File

@@ -93,7 +93,7 @@
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 단일 이미지는 전체 너비(자동)·갤러리 2장 1칸(356px)·갤러리 3장 1칸(235px)·갤러리 3장 2칸(477px)·직접 입력 셀렉트로 표시 너비를 고를 수 있으며 저장 시 이미지 마크다운 뒤에 `{size=숫자}` 옵션을 붙인다. 공개 본문은 이 값을 최대 너비로 적용하고 원본 비율은 유지한다. 직접 입력은 120~1600px 범위로 저장하되 입력 중에는 범위 보정을 즉시 강제하지 않는다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다. 인용 편집 중 브라우저 자동 목록 보정으로 `>. ` 접두가 섞이면 커밋 시 정리한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 공개 회원 로그인 세션의 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다. 별도 관리자 세션만 있거나 비로그인 상태에서는 표시하지 않는다.
@@ -201,10 +201,11 @@ layouts/
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
- 관리자 글 목록의 태그 필터는 태그 증가에 대비해 검색형 combobox로 표시한다. 후보는 전체 태그 API의 사용 중 태그 기준으로 보여 주며, 검색어가 없으면 `전체 태그`와 태그명을 정렬한 목록을, 입력 중에는 태그명·슬러그 부분 일치 결과를 표시한다. 방향키와 Enter로 선택할 수 있고, 선택된 태그는 토큰의 `×`로 전체 태그 상태로 되돌린다.
- 관리자 글 목록 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`)는 목록 최적화를 위해 게시물 본문 `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` 또는 한글 키보드 `ㄹ` 키를 누르면 글 검색 입력창으로 포커스를 이동한다.
- 관리자 페이지 목록은 게시글 목록과 같은 상단 구조를 사용한다. 제목 아래에는 전체·홈 표시·추천·필터 적용 표시 개수를 보여 주고, 오른쪽에는 검색·상태 필터·노출 필터·정렬·새 페이지 버튼을 한 줄 흐름으로 배치한다. 테이블은 추천 별, 페이지 커버 썸네일, 제목, 상태, 조회수, 수정일, 행 메뉴 순서로 표시한다.
- 관리자 글쓰기의 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` 서버 페이지 응답, 파일명/사용처 검색, 번호형 페이지 이동, 작은 파생 이미지 우선 미리보기를 사용한다. 두 글쓰기 미디어 모달은 검색 결과 개수와 무관하게 패널 높이를 고정하고 내부 목록만 스크롤한다.
@@ -555,9 +556,9 @@ components/content/
- 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다.
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다.
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다.
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 하단 **선택한 기간 상세** 패널을 해당 날짜 또는 묶음 구간 기준으로 갱신한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
- 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 디바이스는 모바일·PC 같은 상위 기기 기준으로 한 카드에 합산하고, 카드 안에서 Android/iOS 등 OS별 방문자 수와 전체 디바이스 방문자 대비 비율을 함께 표시한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다.
- 관리자 글 목록은 게시물별 `post_analytics_daily.views` 누적 합계를 별도 조회수 열에 `1,000`처럼 숫자만 표시하고, 같은 셀 아래 작은 회색 통계 버튼으로 게시물별 통계 모달을 연다. 통계 버튼은 hover 시에만 파란색으로 표시한다.
- 관리자 게시물별 통계 모달과 상세 화면은 누적 조회수, 조회수/방문자 추이, 읽음·평균 체류·스크롤 지표, 게시물별 유입원·디바이스·유입 키워드를 표시한다. 추이는 일간(최근 14일), 주간(최근 12주, 일요일~토요일), 월간(최근 12개월) 단위로 전환한다. 게시물별 유입은 `059_post_analytics_traffic_daily.sql` 적용 이후 새로 수집되는 페이지뷰부터 집계된다.
- 관리자 대시보드 추이 차트는 일일 7개, 주간 최대 8개, 월간 12개, 년간 최대 8개 버킷을 조회한다. 게시물별 통계와 기타 인기/유입 목록 API는 기존 최대 365일 조회 제한을 유지한다.
@@ -628,7 +629,7 @@ components/content/
- `POST /admin/api/auth/login` - 로그인
- `POST /admin/api/auth/logout` - 로그아웃
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다.
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 누적 방문자·누적 조회수·공개 게시물 수, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타, IP, 열린 페이지 수와 열린 페이지 목록 포함)

View File

@@ -1,5 +1,38 @@
# 업데이트 이력
## v1.10.77
- 단일 이미지 표시 너비 프리셋에서 720px 제거.
- 단일 이미지 표시 너비 프리셋을 갤러리 2장 1칸(356px), 갤러리 3장 1칸(235px), 갤러리 3장 2칸(477px) 기준으로 정리.
- 단일 이미지 직접 너비 입력 중 최소·최대값으로 즉시 보정되어 숫자 입력이 튀던 문제 수정.
## v1.10.76
- 사용자 뷰어와 라이브모드에서 단일 이미지 `{size=}` 표시 너비가 블록 렌더링에 반영되지 않던 문제 수정.
- 레거시 블록 정규화 시 단일 이미지 픽셀 너비 옵션이 사라지지 않도록 수정.
## v1.10.75
- 단일 이미지 표시 너비에서 직접 입력 선택 시 대표 프리셋 값으로 되돌아가 입력창이 숨던 문제 수정.
## v1.10.74
- 관리자 페이지 목록 상단 레이아웃을 게시글 목록과 같은 검색·필터·정렬·새 페이지 버튼 구조로 정리.
- 관리자 페이지 목록에 홈 표시·추천·필터 적용 표시 개수 요약 추가.
- 관리자 페이지 목록 테이블에 추천 별과 썸네일 열 추가.
- 단일 이미지 오른쪽 설정 패널에 전체 너비·대표 크기·직접 입력 셀렉트 추가.
## v1.10.73
- 게시글 백링크 개수 계산에 페이지 본문 참조 포함.
- 게시글 백링크 모달과 삭제 경고에서 게시물·페이지 참조를 함께 표시하도록 수정.
- 관리자 페이지 목록 검색 추가.
- 라이브 글쓰기 인용 입력 중 `>. ` 접두가 섞이는 경우 저장 직전 `> ` 흐름으로 정리.
- 단일 이미지 오른쪽 설정 패널에 표시 너비 입력 추가.
- 관리자 대시보드 상단 요약의 `오늘 접속자` 라벨을 `오늘 방문자`로 수정하고 누적 방문자 표시 추가.
- 관리자 대시보드 현재 접속자 IP와 열린 페이지 개수 표시를 줄바꿈 처리.
- 관리자 대시보드 디바이스 통계를 모바일·PC 합계 카드와 OS별 세부 방문자 비율로 정리.
## v1.10.72
- 관리자 글 목록 백링크 개수를 버튼 박스가 아닌 숫자형 텍스트 액션으로 표시하도록 수정.

View File

@@ -1,4 +1,5 @@
import { buildCalloutOpenerLine } from './markdown-callout.js'
import { serializeImageMarkdown } from './markdown-image.js'
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
@@ -16,11 +17,7 @@ const serializeImageBlock = (image = {}) => {
return ''
}
const width = image.width && image.width !== 'regular'
? `{width=${image.width}}`
: ''
return `![${image.alt || ''}](${url})${width}`
return serializeImageMarkdown({ ...image, url })
}
/**

View File

@@ -1,5 +1,7 @@
/** @type {RegExp} 이미지 마크다운 한 줄 */
const IMAGE_MARKDOWN_LINE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)(?:\{width=(regular|wide|full)\})?$/
/** @type {RegExp} 이미지 마크다운 기본 줄 */
const IMAGE_MARKDOWN_BASE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)((?:\{[^{}]+\})*)$/
/** @type {RegExp} 이미지 옵션 토큰 */
const IMAGE_MARKDOWN_OPTION_RE = /\{([^{}=]+)=([^{}]+)\}/g
/** @type {RegExp} 이미지 파일 확장자 */
const IMAGE_URL_EXTENSION_RE = /\.(?:jpe?g|png|webp|gif|avif|svg)(?:$|[?#])/i
@@ -17,6 +19,48 @@ const unescapeImageCaption = (value) => String(value || '').replace(/\\"/g, '"')
*/
const escapeImageCaption = (value) => String(value || '').replace(/"/g, '\\"')
/**
* 이미지 픽셀 너비를 정규화한다.
* @param {unknown} value - 입력 너비
* @returns {number|null} 정규화된 너비
*/
export const normalizeImagePixelWidth = (value) => {
const width = Math.round(Number(value) || 0)
if (!width) {
return null
}
return Math.min(Math.max(width, 120), 1600)
}
/**
* 이미지 마크다운 옵션을 파싱한다.
* @param {string} rawOptions - 옵션 문자열
* @returns {{ width: string, pixelWidth: number|null }} 이미지 옵션
*/
const parseImageMarkdownOptions = (rawOptions) => {
const options = {
width: 'regular',
pixelWidth: null
}
for (const match of String(rawOptions || '').matchAll(IMAGE_MARKDOWN_OPTION_RE)) {
const key = String(match[1] || '').trim()
const value = String(match[2] || '').trim()
if (key === 'width' && ['regular', 'wide', 'full'].includes(value)) {
options.width = value
}
if (key === 'size') {
options.pixelWidth = normalizeImagePixelWidth(value)
}
}
return options
}
/**
* URL에서 기본 대체 텍스트(파일명) 추출
* @param {string} url - 이미지 URL
@@ -79,16 +123,17 @@ export const isImageUrl = (url) => {
/**
* 이미지 마크다운 한 줄 파싱
* @param {string} line - 마크다운 줄
* @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null}
* @returns {{ url: string, width: string, pixelWidth: number|null, caption: string, useAlt: boolean }|null}
*/
export const parseImageMarkdownLine = (line) => {
const match = String(line || '').trim().match(IMAGE_MARKDOWN_LINE_RE)
const match = String(line || '').trim().match(IMAGE_MARKDOWN_BASE_RE)
if (!match) {
return null
}
const url = match[2] || ''
const options = parseImageMarkdownOptions(match[4])
const altBracket = (match[1] || '').trim()
const quotedCaption = unescapeImageCaption(match[3])
const filenameAlt = getImageDefaultAltLabel(url)
@@ -105,14 +150,15 @@ export const parseImageMarkdownLine = (line) => {
caption: quotedCaption || (useAlt ? filenameAlt : ''),
/** 레거시 `![표시문구](url)` — 캡션 따옴표 없을 때 미리보기용 */
legacyBracketLabel: !quotedCaption && altBracket && !useAlt ? altBracket : '',
width: match[4] || 'regular',
width: options.width,
pixelWidth: options.pixelWidth,
useAlt
}
}
/**
* 이미지 마크다운 한 줄 생성
* @param {{ url: string, caption?: string, width?: string, useAlt?: boolean }} image - 이미지 정보
* @param {{ url: string, caption?: string, width?: string, pixelWidth?: number|null, useAlt?: boolean }} image - 이미지 정보
* @returns {string} 마크다운 줄
*/
export const serializeImageMarkdown = (image) => {
@@ -131,8 +177,10 @@ export const serializeImageMarkdown = (image) => {
const titlePart = caption ? ` "${escapeImageCaption(caption)}"` : ''
const width = image.width && image.width !== 'regular' ? `{width=${image.width}}` : ''
const pixelWidth = normalizeImagePixelWidth(image.pixelWidth)
const size = pixelWidth ? `{size=${pixelWidth}}` : ''
return `![](${url}${titlePart})${width}`
return `![](${url}${titlePart})${width}${size}`
}
/**

4
package-lock.json generated
View File

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

View File

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

View File

@@ -36,6 +36,9 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
query: analyticsQuery,
default: () => ({
todayVisitors: 0,
totalVisitors: 0,
totalPageViews: 0,
totalPosts: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
@@ -106,6 +109,29 @@ const {
const selectedTrendDetailLabel = computed(() => selectedTrendRange.value.label || '오늘')
const trafficSources = computed(() => selectedTrendDetail.value?.sources || [])
const trafficDevices = computed(() => selectedTrendDetail.value?.devices || [])
const totalDeviceVisitors = computed(() => {
return trafficDevices.value.reduce((sum, row) => sum + Number(row.visitors || 0), 0)
})
const groupedTrafficDevices = computed(() => {
const groups = new Map()
for (const row of trafficDevices.value) {
const key = row.deviceType || '기타'
const existing = groups.get(key) || {
deviceType: key,
pageViews: 0,
visitors: 0,
rows: []
}
existing.pageViews += Number(row.pageViews || 0)
existing.visitors += Number(row.visitors || 0)
existing.rows.push(row)
groups.set(key, existing)
}
return [...groups.values()].sort((a, b) => b.pageViews - a.pageViews || b.visitors - a.visitors)
})
const topPosts = computed(() => selectedTrendDetail.value?.topPosts || [])
const topPages = computed(() => selectedTrendDetail.value?.topPages || [])
const trendDetailError = computed(() => selectedTrendDetailError.value ? '선택한 기간 통계 상세를 불러오지 못했습니다.' : '')
@@ -240,6 +266,21 @@ const formatTrafficPercent = (value) => {
return `${Math.round((Number(value || 0) / total) * 100)}%`
}
/**
* 디바이스 방문자 비율을 표시한다.
* @param {number} value - 방문자 수
* @returns {string} 비율
*/
const formatDeviceVisitorPercent = (value) => {
const total = totalDeviceVisitors.value
if (!total) {
return '0%'
}
return `${Math.round((Number(value || 0) / total) * 100)}%`
}
const chartMetrics = computed(() => [
{
key: 'visitors',
@@ -487,9 +528,13 @@ watch(trendRows, () => {
<strong class="ml-2 text-lg text-ink">{{ realtime.summary.onlineNow }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
오늘 접속자
오늘 방문자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.todayVisitors }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
누적 방문자
<strong class="ml-2 text-lg text-ink">{{ analyticsSummary.totalVisitors }}</strong>
</p>
<p class="admin-dashboard__summary-item text-muted">
게시물 수
<strong class="ml-2 text-lg text-ink">{{ posts.length }}</strong>
@@ -832,27 +877,39 @@ watch(trendRows, () => {
디바이스
</h3>
<ul
v-if="trafficDevices.length"
v-if="groupedTrafficDevices.length"
class="mt-3 space-y-2"
>
<li
v-for="row in trafficDevices"
:key="`${row.deviceType}-${row.osName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
v-for="group in groupedTrafficDevices"
:key="group.deviceType"
class="border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<div>
<p class="font-medium text-ink">
{{ row.deviceType }}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{{ row.osName }}
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium text-ink">
{{ group.deviceType }}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{{ group.rows.map((row) => row.osName).filter(Boolean).join(' / ') || '-' }}
</p>
</div>
<p class="text-right text-muted">
<span class="font-semibold text-ink">{{ group.pageViews }}</span>회
<br>
{{ group.visitors }}명 · {{ formatDeviceVisitorPercent(group.visitors) }}
</p>
</div>
<p class="text-right text-muted">
<span class="font-semibold text-ink">{{ row.pageViews }}</span>회
<br>
{{ row.visitors }}명
</p>
<ul class="mt-2 space-y-1 border-t border-line pt-2 text-[11px] text-muted">
<li
v-for="row in group.rows"
:key="`${group.deviceType}-${row.osName}`"
class="flex items-center justify-between gap-2"
>
<span>{{ row.osName || '-' }}</span>
<span>{{ row.visitors }}명 · {{ formatDeviceVisitorPercent(row.visitors) }}</span>
</li>
</ul>
</li>
</ul>
<p
@@ -926,9 +983,9 @@ watch(trendRows, () => {
</p>
<p class="mt-1 text-[11px] leading-snug text-muted">
IP {{ session.ipAddress || '-' }}
<span v-if="Number(session.pageCount || 0) > 1">
· 열린 페이지 {{ session.pageCount }}개
</span>
</p>
<p v-if="Number(session.pageCount || 0) > 1" class="mt-0.5 text-[11px] leading-snug text-muted">
열린 페이지 {{ session.pageCount }}개
</p>
</div>
</li>

View File

@@ -5,6 +5,10 @@ definePageMeta({
const deletingId = ref('')
const errorMessage = ref('')
const searchQuery = ref('')
const statusFilter = ref('all')
const listedFilter = ref('all')
const sortOrder = ref('newest')
const analyticsModalPage = ref(null)
const analyticsModalData = ref(createDefaultPageAnalytics())
const analyticsModalPeriod = ref('daily')
@@ -19,6 +23,12 @@ const { data: pages, refresh } = await useFetch('/admin/api/pages', {
default: () => []
})
const {
displayValue: searchInput,
onInput: onSearchInput,
onCompositionEnd: onSearchCompositionEnd
} = useImeSearchInput(searchQuery, { debounce: 200 })
const analyticsPeriodOptions = [
{ label: '일간', value: 'daily', days: 14 },
{ label: '주간', value: 'weekly', days: 84 },
@@ -59,6 +69,58 @@ function createDefaultPageAnalytics() {
/** 페이지 통계 모달이 열려 있는지 */
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPage.value))
/** 전체 페이지 수 */
const totalPageCount = computed(() => pages.value.length)
/** 홈 표시 페이지 수 */
const listedPageCount = computed(() => pages.value.filter((page) => page.isListed).length)
/** 추천 페이지 수 */
const featuredPageCount = computed(() => pages.value.filter((page) => page.isFeatured).length)
/** 페이지 목록 필터가 적용되어 있는지 */
const hasActiveListFilters = computed(() => {
return searchQuery.value.trim()
|| statusFilter.value !== 'all'
|| listedFilter.value !== 'all'
|| sortOrder.value !== 'newest'
})
/** 검색·필터·정렬이 적용된 페이지 목록 */
const filteredPages = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase()
const filtered = pages.value.filter((page) => {
const matchesStatus = statusFilter.value === 'all'
|| page.status === statusFilter.value
const matchesListed = listedFilter.value === 'all'
|| (listedFilter.value === 'listed' && page.isListed)
|| (listedFilter.value === 'featured' && page.isFeatured)
if (!matchesStatus || !matchesListed) {
return false
}
if (!keyword) {
return true
}
return [
page.title,
page.slug,
page.listingTitle,
page.listingDescription,
page.status,
page.listingSection
].some((value) => String(value || '').toLowerCase().includes(keyword))
})
return [...filtered].sort((a, b) => {
const firstTime = new Date(sortOrder.value === 'oldest' ? a.createdAt : a.updatedAt).getTime() || 0
const secondTime = new Date(sortOrder.value === 'oldest' ? b.createdAt : b.updatedAt).getTime() || 0
return sortOrder.value === 'oldest'
? firstTime - secondTime
: secondTime - firstTime
})
})
/** 현재 필터 기준 표시 페이지 수 */
const filteredPageCount = computed(() => filteredPages.value.length)
/**
* 숫자를 관리자 목록 표시 형식으로 변환한다.
@@ -166,6 +228,15 @@ const getPageStatusClass = (page) => {
*/
const isPublicPage = (page) => page.status === 'published'
/**
* 페이지 목록 썸네일 URL을 반환한다.
* @param {Object} page - 페이지
* @returns {string} 썸네일 URL
*/
const getPageThumbnailSrc = (page) => {
return page.listingImageThumbnail || page.featuredImage || ''
}
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
@@ -277,7 +348,7 @@ const deletePage = async (page) => {
<template>
<section class="admin-pages bg-paper p-6">
<div class="admin-pages__header flex items-center justify-between gap-4">
<div class="admin-pages__header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="admin-pages__eyebrow text-xs font-semibold uppercase text-muted">
Pages
@@ -285,10 +356,78 @@ const deletePage = async (page) => {
<h1 class="admin-pages__title mt-2 text-3xl font-semibold">
페이지 목록
</h1>
<p class="admin-pages__count mt-2 text-sm text-muted">
총 {{ formatAdminNumber(totalPageCount) }}개
<template v-if="listedPageCount > 0">
<span class="admin-pages__count-separator text-[#c8ced3]"> · </span>
<span class="admin-pages__count-listed">홈 표시 {{ formatAdminNumber(listedPageCount) }}개</span>
</template>
<template v-if="featuredPageCount > 0">
<span class="admin-pages__count-separator text-[#c8ced3]"> · </span>
<span class="admin-pages__count-featured">추천 {{ formatAdminNumber(featuredPageCount) }}개</span>
</template>
<template v-if="hasActiveListFilters && filteredPageCount !== totalPageCount">
<span class="admin-pages__count-separator text-[#c8ced3]"> · </span>
<span class="admin-pages__count-filtered">표시 {{ formatAdminNumber(filteredPageCount) }}개</span>
</template>
</p>
</div>
<div class="admin-pages__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
<div class="admin-pages__filters flex min-w-0 flex-wrap items-center gap-2">
<label class="admin-pages__search relative">
<span class="sr-only">페이지 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
<input
:value="searchInput"
class="admin-pages__search-input h-10 w-52 rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
type="search"
placeholder="페이지 검색"
@input="onSearchInput"
@compositionupdate="onSearchInput"
@compositionend="onSearchCompositionEnd"
>
</label>
<label class="admin-pages__filter">
<span class="sr-only">상태 필터</span>
<span class="admin-pages__filter-select-wrap relative block">
<select v-model="statusFilter" class="admin-pages__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="all">전체 상태</option>
<option value="published">공개</option>
<option value="draft">초안</option>
<option value="private">비공개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="admin-pages__filter">
<span class="sr-only">노출 필터</span>
<span class="admin-pages__filter-select-wrap relative block">
<select v-model="listedFilter" class="admin-pages__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="all">전체 페이지</option>
<option value="listed">홈 표시</option>
<option value="featured">추천만</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="admin-pages__filter">
<span class="sr-only">정렬</span>
<span class="admin-pages__filter-select-wrap relative block">
<select v-model="sortOrder" class="admin-pages__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
</div>
<NuxtLink class="admin-pages__new shrink-0 rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/pages/new">
새 페이지
</NuxtLink>
</div>
<NuxtLink class="admin-pages__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/pages/new">
새 페이지
</NuxtLink>
</div>
<p v-if="errorMessage" class="admin-pages__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@@ -299,6 +438,13 @@ const deletePage = async (page) => {
<table class="admin-pages__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-pages__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-pages__cell admin-pages__cell-featured w-10 px-2 py-3 text-center">
<span class="sr-only">추천</span>
<span class="inline-flex size-4 items-center justify-center text-[#f5a623]" aria-hidden="true">★</span>
</th>
<th class="admin-pages__cell admin-pages__cell-thumbnail w-16 px-2 py-3">
<span class="sr-only">썸네일</span>
</th>
<th class="admin-pages__cell px-4 py-3">제목</th>
<th class="admin-pages__cell px-4 py-3">상태</th>
<th class="admin-pages__cell px-4 py-3">조회수</th>
@@ -309,7 +455,30 @@ const deletePage = async (page) => {
</tr>
</thead>
<tbody class="admin-pages__table-body divide-y divide-line bg-white">
<tr v-for="page in pages" :key="page.id" class="admin-pages__row group">
<tr v-for="page in filteredPages" :key="page.id" class="admin-pages__row group">
<td class="admin-pages__cell admin-pages__cell-featured w-10 px-2 py-4 text-center">
<span
v-if="page.isFeatured"
class="admin-pages__featured-star inline-flex size-8 items-center justify-center text-[#f5a623]"
title="추천 페이지"
aria-label="추천 페이지"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</span>
</td>
<td class="admin-pages__cell admin-pages__cell-thumbnail w-16 px-2 py-4">
<span class="admin-pages__thumbnail block h-9 w-12 overflow-hidden rounded bg-[#eef1f4]">
<img
v-if="getPageThumbnailSrc(page)"
class="h-full w-full object-cover"
:src="getPageThumbnailSrc(page)"
:alt="page.title || '페이지 썸네일'"
loading="lazy"
>
</span>
</td>
<td class="admin-pages__cell px-4 py-4">
<NuxtLink class="admin-pages__title-link font-semibold hover:opacity-70" :to="`/admin/pages/${page.id}`">
{{ page.title }}
@@ -395,6 +564,9 @@ const deletePage = async (page) => {
<p v-if="pages.length === 0" class="admin-pages__empty mt-6 text-sm text-muted">
아직 작성된 페이지가 없습니다.
</p>
<p v-else-if="filteredPages.length === 0" class="admin-pages__empty mt-6 text-sm text-muted">
검색 결과가 없습니다.
</p>
<Teleport to="body">
<div

View File

@@ -130,12 +130,13 @@ function createDefaultPostAnalytics() {
/**
* 게시물 참조 목록 기본값을 생성한다.
* @returns {{ target: null, posts: Array }} 게시물 참조 목록 기본값
* @returns {{ target: null, posts: Array, pages: Array }} 게시물 참조 목록 기본값
*/
function createDefaultPostReferences() {
return {
target: null,
posts: []
posts: [],
pages: []
}
}
@@ -470,22 +471,31 @@ const isReferenceModalOpen = computed(() => Boolean(referenceModalPost.value))
/** 게시물 참조 목록 */
const referenceModalPosts = computed(() => referenceModalData.value?.posts || [])
/** 페이지 참조 목록 */
const referenceModalPages = computed(() => referenceModalData.value?.pages || [])
/** 게시물·페이지 통합 참조 목록 */
const referenceModalItems = computed(() => [
...referenceModalPosts.value.map((item) => ({ ...item, referenceType: 'post' })),
...referenceModalPages.value.map((item) => ({ ...item, referenceType: 'page' }))
])
/** 게시물 참조 모달 검색어 */
const normalizedReferenceModalSearchQuery = computed(() => referenceModalSearchQuery.value.trim().toLowerCase())
/** 검색어가 적용된 게시물 참조 목록 */
/** 검색어가 적용된 콘텐츠 참조 목록 */
const filteredReferenceModalPosts = computed(() => {
const keyword = normalizedReferenceModalSearchQuery.value
if (!keyword) {
return referenceModalPosts.value
return referenceModalItems.value
}
return referenceModalPosts.value.filter((post) => {
return referenceModalItems.value.filter((post) => {
return [
post.title,
post.slug
post.slug,
post.status,
post.referenceType
].some((value) => String(value || '').toLowerCase().includes(keyword))
})
})
@@ -658,33 +668,38 @@ const closeReferenceModal = () => {
/**
* 삭제 확인 메시지를 생성한다.
* @param {Object} post - 삭제 대상 게시물
* @param {{ posts?: Array }} references - 참조 목록
* @param {{ posts?: Array, pages?: Array }} references - 참조 목록
* @param {boolean} didReferenceCheckFail - 참조 확인 실패 여부
* @returns {string} 삭제 확인 메시지
*/
const createDeletePostMessage = (post, references, didReferenceCheckFail = false) => {
const title = post.title || '(제목 없음)'
const referencePosts = references?.posts || []
const referencePages = references?.pages || []
const referenceItems = [
...referencePosts.map((item) => ({ ...item, referenceType: 'post' })),
...referencePages.map((item) => ({ ...item, referenceType: 'page' }))
]
if (didReferenceCheckFail) {
return `"${title}" 글을 삭제할까요?\n\n이 글을 링크한 게시물 목록은 확인하지 못했습니다.`
return `"${title}" 글을 삭제할까요?\n\n이 글을 링크한 콘텐츠 목록은 확인하지 못했습니다.`
}
if (!referencePosts.length) {
if (!referenceItems.length) {
return `"${title}" 글을 삭제할까요?`
}
const referenceList = referencePosts
const referenceList = referenceItems
.slice(0, 5)
.map((item) => `- ${item.title || '(제목 없음)'}`)
.map((item) => `- ${item.referenceType === 'page' ? '페이지' : '게시물'}: ${item.title || '(제목 없음)'}`)
.join('\n')
const moreCount = Math.max(referencePosts.length - 5, 0)
const moreCount = Math.max(referenceItems.length - 5, 0)
const moreText = moreCount ? `\n- 외 ${moreCount}개` : ''
return [
`"${title}" 글을 삭제할까요?`,
'',
`이 글을 링크한 게시물 ${referencePosts.length}개가 있습니다.`,
`이 글을 링크한 콘텐츠 ${referenceItems.length}개가 있습니다.`,
referenceList + moreText
].join('\n')
}
@@ -1529,7 +1544,7 @@ watch(postPageCount, (pageCount) => {
<div class="admin-posts__reference-modal-body flex min-h-0 flex-1 flex-col p-5">
<p v-if="referenceModalLoading" class="text-sm text-muted">
이 글을 링크한 게시물을 확인하는 중입니다.
이 글을 링크한 콘텐츠를 확인하는 중입니다.
</p>
<p v-else-if="referenceModalError" class="text-sm text-red-600">
{{ referenceModalError }}
@@ -1537,7 +1552,7 @@ watch(postPageCount, (pageCount) => {
<template v-else>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted">
이 글을 링크한 게시물 {{ referenceModalPosts.length }}개
이 글을 링크한 콘텐츠 {{ referenceModalItems.length }}개
</p>
<label class="admin-posts__reference-search relative w-full sm:w-60">
<span class="sr-only">백링크 검색</span>
@@ -1549,7 +1564,7 @@ watch(postPageCount, (pageCount) => {
v-model="referenceModalSearchQuery"
class="admin-posts__reference-search-input h-9 w-full rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
type="search"
placeholder="게시물 검색"
placeholder="콘텐츠 검색"
>
</label>
</div>
@@ -1561,14 +1576,17 @@ watch(postPageCount, (pageCount) => {
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="min-w-0 font-semibold text-ink">
<span class="mr-2 inline-flex h-5 items-center rounded bg-paper px-2 text-[11px] font-semibold text-muted">
{{ item.referenceType === 'page' ? '페이지' : '게시물' }}
</span>
{{ item.title || '(제목 없음)' }}
</span>
<div class="flex shrink-0 items-center gap-2">
<NuxtLink
class="admin-posts__reference-move inline-flex size-7 items-center justify-center rounded text-[#8e9cac] transition-colors hover:bg-paper hover:text-[#394047]"
:to="`/admin/posts/${item.id}`"
aria-label="게시물로 이동"
title="게시물로 이동"
:to="item.referenceType === 'page' ? `/admin/pages/${item.id}` : `/admin/posts/${item.id}`"
aria-label="콘텐츠로 이동"
title="콘텐츠로 이동"
@click="closeReferenceModal"
>
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -1581,17 +1599,17 @@ watch(postPageCount, (pageCount) => {
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted">
<span>{{ getPostStatusLabel(item) }}</span>
<span class="text-[#d7dce0]">|</span>
<span class="truncate">/post/{{ item.slug }}</span>
<span class="truncate">/{{ item.referenceType === 'page' ? 'pages' : 'post' }}/{{ item.slug }}</span>
<span class="text-[#d7dce0]">|</span>
<span>수정 {{ formatAdminDateTimeLabel(item.updatedAt) }}</span>
</div>
</li>
</ul>
<p v-else-if="referenceModalPosts.length" class="mt-4 border border-line bg-paper px-4 py-5 text-sm text-muted">
<p v-else-if="referenceModalItems.length" class="mt-4 border border-line bg-paper px-4 py-5 text-sm text-muted">
검색 결과가 없습니다.
</p>
<p v-else class="mt-4 border border-line bg-paper px-4 py-5 text-sm text-muted">
이 게시물을 링크한 다른 게시물이 없습니다.
이 게시물을 링크한 다른 콘텐츠가 없습니다.
</p>
</template>
</div>

View File

@@ -141,12 +141,12 @@ const countPostLinkReferences = (content, slug) => {
}
/**
* 대상 게시물별 백링크 게시물 개수 맵을 생성한다.
* 대상 게시물별 백링크 콘텐츠 개수 맵을 생성한다.
* @param {Array<{ id: string, slug: string }>} targetPosts - 백링크 개수를 계산할 게시물 목록
* @param {Array<{ id: string, content?: string }>} sourcePosts - 링크 출처 게시물 목록
* @returns {Map<string, number>} 게시물 ID별 백링크 게시물 개수
* @param {Array<{ id: string, sourceType?: 'post'|'page', content?: string }>} sourceItems - 링크 출처 콘텐츠 목록
* @returns {Map<string, number>} 게시물 ID별 백링크 콘텐츠 개수
*/
const createAdminPostBacklinkCountMap = (targetPosts, sourcePosts) => {
const createAdminPostBacklinkCountMap = (targetPosts, sourceItems) => {
const countMap = new Map(targetPosts.map((post) => [post.id, 0]))
for (const targetPost of targetPosts) {
@@ -154,9 +154,9 @@ const createAdminPostBacklinkCountMap = (targetPosts, sourcePosts) => {
continue
}
const backlinkCount = sourcePosts.filter((sourcePost) => {
return sourcePost.id !== targetPost.id
&& countPostLinkReferences(sourcePost.content, targetPost.slug) > 0
const backlinkCount = sourceItems.filter((sourceItem) => {
return !(sourceItem.sourceType === 'post' && sourceItem.id === targetPost.id)
&& countPostLinkReferences(sourceItem.content, targetPost.slug) > 0
}).length
countMap.set(targetPost.id, backlinkCount)
@@ -166,9 +166,9 @@ const createAdminPostBacklinkCountMap = (targetPosts, sourcePosts) => {
}
/**
* 관리자 글 목록 대상의 백링크 게시물 개수를 조회한다.
* 관리자 글 목록 대상의 백링크 콘텐츠 개수를 조회한다.
* @param {Array<{ id: string, slug: string }>} targetPosts - 백링크 개수를 계산할 게시물 목록
* @returns {Promise<Map<string, number>>} 게시물 ID별 백링크 게시물 개수
* @returns {Promise<Map<string, number>>} 게시물 ID별 백링크 콘텐츠 개수
*/
const getAdminPostBacklinkCountMap = async (targetPosts) => {
const sql = getPostgresClient()
@@ -178,17 +178,29 @@ const getAdminPostBacklinkCountMap = async (targetPosts) => {
}
if (!sql) {
return createAdminPostBacklinkCountMap(targetPosts, getSamplePosts())
return createAdminPostBacklinkCountMap(targetPosts, [
...getSamplePosts().map((post) => ({ ...post, sourceType: 'post' })),
...getSamplePages().map((page) => ({ ...page, sourceType: 'page' }))
])
}
const sourceRows = await sql`
SELECT id, content
SELECT id, 'post' AS source_type, content
FROM posts
WHERE content IS NOT NULL
AND content <> ''
UNION ALL
SELECT id, 'page' AS source_type, content
FROM pages
WHERE content IS NOT NULL
AND content <> ''
`
return createAdminPostBacklinkCountMap(targetPosts, sourceRows)
return createAdminPostBacklinkCountMap(targetPosts, sourceRows.map((row) => ({
id: row.id,
sourceType: row.source_type,
content: row.content
})))
}
/**
@@ -914,9 +926,9 @@ 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 }> } | null>} 참조 게시물 목록
* @returns {Promise<{ target: { id: string, title: string, slug: string }, posts: Array<{ id: string, title: string, slug: string, status: string, publishedAt: string | null, updatedAt: string }>, pages: Array<{ id: string, title: string, slug: string, status: string, updatedAt: string }> } | null>} 참조 콘텐츠 목록
*/
export const listAdminPostReferences = async (id) => {
const sql = getPostgresClient()
@@ -941,6 +953,16 @@ export const listAdminPostReferences = async (id) => {
})
const removeReferenceCount = ({ referenceCount, ...post }) => post
const mapReferencePageRow = (page) => ({
id: page.id,
title: toAdminPostFormTitle(page.title),
slug: page.slug,
status: page.status,
updatedAt: page.updated_at
? page.updated_at.toISOString()
: page.updatedAt || '',
referenceCount: countPostLinkReferences(page.content, target.slug)
})
if (!sql) {
const posts = getSamplePosts()
@@ -948,6 +970,10 @@ export const listAdminPostReferences = async (id) => {
.map(mapReferenceRow)
.filter((post) => post.referenceCount > 0)
.map(removeReferenceCount)
const pages = getSamplePages()
.map(mapReferencePageRow)
.filter((page) => page.referenceCount > 0)
.map(removeReferenceCount)
return {
target: {
@@ -955,7 +981,8 @@ export const listAdminPostReferences = async (id) => {
title: target.title,
slug: target.slug
},
posts
posts,
pages
}
}
@@ -973,6 +1000,18 @@ export const listAdminPostReferences = async (id) => {
AND content ILIKE ${`%/post/${target.slug}%`}
ORDER BY updated_at DESC
`
const pageRows = await sql`
SELECT
id,
title,
slug,
status,
updated_at,
content
FROM pages
WHERE content ILIKE ${`%/post/${target.slug}%`}
ORDER BY updated_at DESC
`
return {
target: {
@@ -983,6 +1022,10 @@ export const listAdminPostReferences = async (id) => {
posts: rows
.map(mapReferenceRow)
.filter((post) => post.referenceCount > 0)
.map(removeReferenceCount),
pages: pageRows
.map(mapReferencePageRow)
.filter((page) => page.referenceCount > 0)
.map(removeReferenceCount)
}
}

View File

@@ -1,5 +1,6 @@
import { requireAdminSession } from '../../../../utils/admin-auth.js'
import { getAnalyticsSummary } from '../../../../repositories/analytics-repository.js'
import { getPublicBlogStatistics } from '../../../../repositories/blog-statistics-repository.js'
/**
* 관리자 통계 요약 API
@@ -13,5 +14,15 @@ export default defineEventHandler(async (event) => {
const days = Number(query.days) || 30
const period = String(query.period || 'weekly')
return getAnalyticsSummary({ days, period })
const [summary, statistics] = await Promise.all([
getAnalyticsSummary({ days, period }),
getPublicBlogStatistics()
])
return {
...summary,
totalVisitors: statistics.totalVisitors,
totalPageViews: statistics.totalPageViews,
totalPosts: statistics.totalPosts
}
})