관리자 통계 제외와 콘텐츠 링크 정리
This commit is contained in:
@@ -92,7 +92,7 @@ const romanizeHangulSyllable = (char) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 영문 URL 슬러그로 변환
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 영문 슬러그
|
||||
*/
|
||||
@@ -103,10 +103,10 @@ const toSlug = (value) => value
|
||||
.join('')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/[^a-z0-9\s_-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
|
||||
.replace(/^[-_]|[-_]$/g, '')
|
||||
|
||||
const pageSlug = computed(() => toSlug(form.slug || form.title))
|
||||
const viewPageUrl = computed(() => props.publicUrl || (pageSlug.value ? `/pages/${pageSlug.value}` : ''))
|
||||
|
||||
@@ -405,10 +405,10 @@ const toSlug = (value) => value
|
||||
.join('')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/[^a-z0-9\s_-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
|
||||
.replace(/^[-_]|[-_]$/g, '')
|
||||
|
||||
/**
|
||||
* 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로)
|
||||
|
||||
@@ -31,7 +31,17 @@ defineProps({
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<p class="post-card__meta mt-2 text-xs site-muted">
|
||||
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
|
||||
{{ post.publishedAt }}<template v-if="post.tag"> / </template>
|
||||
<NuxtLink
|
||||
v-if="post.tag && post.tagSlug"
|
||||
class="post-card__tag-link site-interactive hover:text-[var(--site-accent)]"
|
||||
:to="`/tag/${post.tagSlug}`"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</NuxtLink>
|
||||
<template v-else-if="post.tag">
|
||||
{{ post.tag }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.9.10
|
||||
|
||||
- 관리자 로그인 상태에서 공개 글·페이지를 열어도 조회수, 읽음, 체류, 스크롤, 실시간 접속 통계에 포함되지 않게 했다.
|
||||
|
||||
## v1.9.9
|
||||
|
||||
- 관리자 글·페이지 목록의 공개 상태 표기를 `공개`로 맞췄다.
|
||||
- 관리자 글·페이지 목록 주소 옆에 공개 화면으로 바로 여는 아이콘을 hover/focus 시 표시한다.
|
||||
|
||||
## v1.9.8
|
||||
|
||||
- 게시물·페이지 슬러그 입력 규칙을 통일하고 언더바를 사용할 수 있게 했다.
|
||||
- 게시물 본문 제목의 `~` 문자가 오른쪽 목차에서 사라지지 않도록 수정했다.
|
||||
- 메인·목록·태그 화면의 태그 배지와 카드 화살표 링크 동작을 통일했다.
|
||||
|
||||
## v1.9.0
|
||||
|
||||
- 관리자 대시보드 통계 추이를 일일·주간·월간·년간 기준으로 볼 수 있게 정리했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.9.4에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.9.10에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -264,6 +264,7 @@ docker compose --env-file .env.production up -d --build
|
||||
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다.
|
||||
- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
|
||||
- 관리자 통계 집계 시작일은 `ANALYTICS_COLLECTED_SINCE` 환경 변수로 설정한다. 기본값은 `2026-06-01`이다.
|
||||
- 관리자 세션으로 공개 글·페이지를 확인하는 요청은 통계 수집에서 제외된다. 운영자가 직접 방문자처럼 검증해야 할 때는 관리자 로그아웃 또는 별도 브라우저 프로필을 사용한다.
|
||||
|
||||
### 개발/운영 DB 분리 검증 절차
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-30 v1.9.10 — 관리자 세션은 공개 통계에서 제외한다
|
||||
|
||||
운영자가 글 작성·수정·확인을 위해 공개 게시물과 페이지를 반복해서 열면 실제 방문자 행동보다 관리자 검수 동작이 조회수와 체류 통계에 크게 반영된다. 따라서 관리자 세션 쿠키가 있는 요청은 공개 경로라도 페이지뷰, 읽음, heartbeat, HTML 문서 페이지 직접 집계에서 제외해 통계가 방문자 기준에 더 가깝게 유지되도록 한다.
|
||||
|
||||
## 2026-06-30 v1.9.9 — 관리자 글·페이지 목록의 공개 상태와 보기 액션을 맞춘다
|
||||
|
||||
게시물과 페이지는 공개 URL을 가진 같은 콘텐츠 관리 대상이므로 목록에서 상태 용어가 다르면 운영자가 서로 다른 의미로 오해할 수 있다. 따라서 공개 상태 표기는 `공개`로 통일하고, 공개 화면 확인은 제목 편집 링크와 분리해 주소 줄 오른쪽의 외부 링크 아이콘으로 제공한다.
|
||||
|
||||
## 2026-06-30 v1.9.8 — 게시물과 페이지 슬러그 규칙을 통일한다
|
||||
|
||||
게시물과 페이지는 공개 URL 안에서 같은 성격의 식별자로 쓰이므로 입력 허용 범위가 다르면 운영자가 화면마다 다른 규칙을 기억해야 한다. 따라서 두 관리자 폼과 서버 검증 모두 영문 소문자, 숫자, 한글, 하이픈, 언더바를 같은 방식으로 허용한다. 하이픈과 언더바는 URL 가독성을 위한 구분자로 유지하되 앞뒤 끝에는 두지 않는 규칙을 유지한다.
|
||||
|
||||
## 2026-06-29 v1.9.0 — 대시보드 추이는 서버 버킷 집계로 전환한다
|
||||
|
||||
대시보드 차트에서 30일 이상을 프론트엔드에서 임의 일수로 다시 묶으면 주간의 일요일~토요일 기준, 이번 주·이번 달·올해의 부분 집계 기준을 명확히 맞추기 어렵다. 따라서 `GET /admin/api/analytics/summary`가 `period`를 받아 일간·주간·월간·년간 버킷을 서버에서 직접 만들고, 화면은 응답된 `startDay`·`endDay`·`label`을 그대로 표시한다. 년간은 빈 과거 연도를 과하게 보여 주지 않도록 실제 집계 시작 연도부터 올해까지 최대 8년만 표시한다.
|
||||
|
||||
17
docs/map.md
17
docs/map.md
@@ -101,7 +101,7 @@
|
||||
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시하고 태그 페이지 링크 제공 |
|
||||
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시) |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
@@ -169,12 +169,12 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 제목 아래 주소·공개 보기 hover 아이콘, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 모든 탭 공통 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일 사용 배지(카드 썸네일 탭의 카드 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
@@ -194,13 +194,13 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Compact 모바일 썸네일 80px 정사각형·Compact/List 설명 2줄 제한, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Compact 모바일 썸네일 80px 정사각형·Compact/List 설명 2줄 제한, Latest 메타는 태그가 있을 때만 링크 배지 표시, 카드 오른쪽 아래 화살표는 게시물 상세 링크, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 링크로 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 해당 URL 재진입 |
|
||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
|
||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||
@@ -296,6 +296,7 @@
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/analytics-admin-exclusion.js | 관리자 세션 요청의 공개 통계 수집 제외 판별 |
|
||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/request-ip.js | 프록시 헤더 포함 요청 IP 조회 유틸리티 |
|
||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||
@@ -316,12 +317,12 @@
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
| server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 |
|
||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·기록 |
|
||||
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
|
||||
| server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 |
|
||||
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
|
||||
| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
|
||||
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 |
|
||||
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·관리자 세션 제외·기록 |
|
||||
| server/routes/admin/api/analytics/summary.get.js | 관리자 통계 요약 API |
|
||||
| server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API |
|
||||
| server/routes/admin/api/analytics/posts/[id].get.js | 관리자 게시물별 통계 API |
|
||||
|
||||
11
docs/spec.md
11
docs/spec.md
@@ -81,6 +81,7 @@
|
||||
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
|
||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 공개 게시물 오른쪽 목차는 H1~H3 제목 표시 텍스트에서 링크·굵게·기울임·인라인 코드 같은 마크다운 장식만 제거하고 `~` 같은 일반 제목 문자는 보존한다.
|
||||
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다.
|
||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
|
||||
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
|
||||
@@ -90,7 +91,7 @@
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
- 공유·SEO 설명은 SEO 설명이 있으면 우선 사용하고, 없으면 게시물 요약, 요약도 없으면 본문에서 마크다운 기호를 제거한 짧은 텍스트를 사용한다.
|
||||
- 공개 페이지 `<title>`은 개별 페이지 제목이 있으면 제목만 사용하고, 제목이 없는 기본 화면에서만 사이트 이름을 사용한다. 게시물 상세 제목 뒤에 사이트 이름을 자동으로 붙이지 않는다.
|
||||
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 이때 코드·HTML/iframe 블록, HTML 태그, 인라인 색상 토큰 같은 표시용 문법은 목록 설명에 노출하지 않는다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다.
|
||||
- 홈 Latest·게시물 목록·태그 목록의 카드 설명도 동일하게 요약이 비어 있으면 본문에서 `createPostSummary`로 짧은 텍스트를 만든다. 이때 코드·HTML/iframe 블록, HTML 태그, 인라인 색상 토큰 같은 표시용 문법은 목록 설명에 노출하지 않는다. 목록용 설명은 문자열에 수동 말줄임을 붙이지 않고 `post-summary-clamp` 전용 클래스가 실제 표시 줄 끝에서 말줄임을 처리한다. 목록 카드의 제목·썸네일·오른쪽 아래 화살표는 게시물 상세로 이동하고, 태그 배지는 `/tag/:slug`로 이동한다.
|
||||
- 게시물 상세 제목 아래 대표 이미지는 관리자 글쓰기 오른쪽 사이드의 `대표 이미지 표시` 옵션이 켜진 게시물에서만 표시한다. 해당 토글은 대표 이미지 유무와 상관없이 항상 활성 상태이며, 대표 이미지를 나중에 추가할 경우에도 미리 저장한 표시 설정을 유지한다. 대표 이미지는 목록·공유용으로는 계속 사용할 수 있으며, 본문 상단 표시 기본값은 꺼짐이다.
|
||||
|
||||
### 게시물 업로드 이미지 썸네일
|
||||
@@ -132,6 +133,7 @@
|
||||
- `/signin` - 로그인
|
||||
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||
- 게시물·페이지 관리자 슬러그는 같은 입력 규칙을 사용한다. 허용 문자는 영문 소문자, 숫자, 한글, 하이픈(`-`), 언더바(`_`)이며 하이픈·언더바는 문자 사이 구분자로만 사용할 수 있다.
|
||||
|
||||
### 사이트 광고 슬롯
|
||||
|
||||
@@ -496,6 +498,7 @@ components/content/
|
||||
| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) |
|
||||
|
||||
- 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외.
|
||||
- 관리자 세션(`sori_admin_session`)이 있는 요청은 공개 경로라도 페이지뷰, 게시물 읽음, heartbeat 체류·스크롤, 실시간 접속자 집계에 포함하지 않는다.
|
||||
- 봇 User-Agent는 서버에서 무시.
|
||||
- 페이지뷰 수집은 탭 최초 referrer와 현재 URL을 서버로 보내며, 서버는 원문 referrer를 저장하지 않고 검색·SNS·직접·기타, 디바이스·OS, 검색 키워드로 축약해 `analytics_traffic_daily`에 집계한다.
|
||||
- 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다.
|
||||
@@ -541,8 +544,8 @@ components/content/
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
|
||||
- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다.
|
||||
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다.
|
||||
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결.
|
||||
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다.
|
||||
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다.
|
||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
- `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다.
|
||||
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
||||
@@ -632,6 +635,7 @@ components/content/
|
||||
|
||||
- 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
||||
- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
|
||||
- 관리자 글 목록의 `published` 상태 표시와 상태 필터 문구는 페이지 목록과 같은 `공개`로 표시한다. 제목 링크는 관리자 수정 화면으로 이동하고, 공개 가능한 글은 제목 아래 주소 오른쪽에 hover/focus 시 공개 보기 아이콘을 표시해 `/post/:slug`를 새 탭으로 연다.
|
||||
- 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
|
||||
- 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
|
||||
- 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
|
||||
@@ -646,6 +650,7 @@ components/content/
|
||||
- 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
||||
- 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 저장 후 수정 화면이 아니라 태그 관리 목록으로 이동해 일반 태그 목록에 바로 표시한다.
|
||||
- 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
- 관리자 페이지 목록의 공개 상태는 `공개`로 표시한다. 제목 링크는 관리자 수정 화면으로 이동하고, 공개 페이지는 제목 아래 주소 오른쪽에 hover/focus 시 공개 보기 아이콘을 표시해 `/pages/:slug`를 새 탭으로 연다.
|
||||
- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제, 태그 생성·수정 저장 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. 태그 수정 화면의 `변경 저장` 버튼은 실제 변경사항이 있을 때만 활성화한다.
|
||||
- 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.9.10
|
||||
|
||||
- 관리자 세션으로 공개 게시물·페이지를 조회할 때 페이지뷰·읽음·체류·스크롤·실시간 접속 통계에 포함되지 않도록 수정.
|
||||
|
||||
## v1.9.9
|
||||
|
||||
- 관리자 글 목록의 공개 게시물 상태 표시와 상태 필터 문구를 `발행`에서 `공개`로 통일.
|
||||
- 관리자 글·페이지 목록 주소 줄에 hover/focus 시 보이는 공개 보기 바로가기 아이콘 추가.
|
||||
|
||||
## v1.9.8
|
||||
|
||||
- 게시물·페이지 관리자 슬러그 입력 규칙을 한글·영문 소문자·숫자·하이픈·언더바 허용으로 통일.
|
||||
- 게시물 본문 제목 `1~3` 같은 텍스트가 오른쪽 목차에서 `13`으로 보이던 문제 수정.
|
||||
- 메인·태그 게시물 목록의 오른쪽 아래 화살표를 게시물 링크로 연결.
|
||||
- 메인·게시물 전체·태그 게시물 목록의 태그 배지를 태그 페이지 링크로 통일.
|
||||
|
||||
## v1.9.7
|
||||
|
||||
- 관리자 통계 차트: 현재 구간 축 라벨(이번 주 등) 줄바꿈·hover 시 글자 안 보임 문제 수정.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export const stripMarkdownHeadingText = (value) => String(value || '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[`*_~]/g, '')
|
||||
.replace(/[`*_]/g, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.10",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.9.7",
|
||||
"version": "1.9.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -159,6 +159,13 @@ const getPageStatusClass = (page) => {
|
||||
return 'text-[#99A3AD]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 공개 여부 확인
|
||||
* @param {Object} page - 페이지
|
||||
* @returns {boolean} 공개 여부
|
||||
*/
|
||||
const isPublicPage = (page) => page.status === 'published'
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
@@ -302,14 +309,29 @@ 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">
|
||||
<tr v-for="page in pages" :key="page.id" class="admin-pages__row group">
|
||||
<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 }}
|
||||
</NuxtLink>
|
||||
<p class="admin-pages__slug mt-1 text-xs text-muted">
|
||||
/pages/{{ page.slug }}
|
||||
</p>
|
||||
<div class="admin-pages__slug-row mt-1 flex items-center gap-1 text-xs text-muted">
|
||||
<span class="admin-pages__slug truncate">/pages/{{ page.slug }}</span>
|
||||
<NuxtLink
|
||||
v-if="isPublicPage(page)"
|
||||
class="admin-pages__preview-link inline-flex size-5 items-center justify-center rounded text-[#8e9cac] opacity-0 transition-colors hover:text-[#394047] focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#8e9cac] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
:to="`/pages/${page.slug}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="공개 페이지 보기"
|
||||
title="공개 페이지 보기"
|
||||
@click.stop
|
||||
>
|
||||
<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" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</td>
|
||||
<td class="admin-pages__cell px-4 py-4">
|
||||
<span class="admin-pages__status-text text-xs" :class="getPageStatusClass(page)">
|
||||
|
||||
@@ -224,7 +224,7 @@ const getPostStatusLabel = (post) => {
|
||||
}
|
||||
|
||||
if (statusKey === 'published') {
|
||||
return '발행'
|
||||
return '공개'
|
||||
}
|
||||
|
||||
if (statusKey === 'members') {
|
||||
@@ -728,7 +728,7 @@ watch(openPostMenuId, async (postId) => {
|
||||
<span class="admin-posts__filter-select-wrap relative block">
|
||||
<select v-model="statusFilter" class="admin-posts__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="published">공개</option>
|
||||
<option value="draft">초안</option>
|
||||
<option value="scheduled">예약</option>
|
||||
<option value="members">멤버십</option>
|
||||
@@ -802,7 +802,7 @@ watch(openPostMenuId, async (postId) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
|
||||
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row group">
|
||||
<td class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-4 text-center">
|
||||
<span
|
||||
v-if="post.isFeatured"
|
||||
@@ -835,9 +835,24 @@ watch(openPostMenuId, async (postId) => {
|
||||
댓글 {{ post.commentCount || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
||||
/post/{{ post.slug }}
|
||||
</p>
|
||||
<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>
|
||||
<NuxtLink
|
||||
v-if="isPublicPost(post)"
|
||||
class="admin-posts__preview-link inline-flex size-5 items-center justify-center rounded text-[#8e9cac] opacity-0 transition-colors hover:text-[#394047] focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#8e9cac] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
:to="`/post/${post.slug}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="공개 글 보기"
|
||||
title="공개 글 보기"
|
||||
@click.stop
|
||||
>
|
||||
<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" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<span
|
||||
|
||||
@@ -156,6 +156,7 @@ const mapLatestPost = (post) => {
|
||||
featuredImage: post.featuredImage,
|
||||
featuredImageThumbnail: post.featuredImageThumbnail || '',
|
||||
tagName: tagMeta.name,
|
||||
tagSlug: primaryTagSlug || '',
|
||||
tagColor: tagMeta.color,
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
publishedAtIso: post.publishedAt || '',
|
||||
@@ -539,7 +540,16 @@ watch([latestPosts, hasHomeInfeedAd], () => {
|
||||
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
|
||||
<template v-if="post.tagName">
|
||||
<span v-if="post.publishedAt" class="text-[var(--site-line)]">/</span>
|
||||
<NuxtLink
|
||||
v-if="post.tagSlug"
|
||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
:to="`/tag/${post.tagSlug}`"
|
||||
>
|
||||
{{ post.tagName }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-else
|
||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
>
|
||||
@@ -556,11 +566,11 @@ watch([latestPosts, hasHomeInfeedAd], () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<NuxtLink
|
||||
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75"
|
||||
:class="isPostFeedCards ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
|
||||
type="button"
|
||||
aria-label="Share this post"
|
||||
:to="post.to"
|
||||
aria-label="게시물 보기"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -575,7 +585,7 @@ watch([latestPosts, hasHomeInfeedAd], () => {
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -8,30 +8,43 @@ const { data: tags } = await useFetch('/api/tags?tagType=all', {
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 슬러그로 공개 태그 이름을 찾는다.
|
||||
* 태그 슬러그로 공개 태그 정보를 찾는다.
|
||||
* @param {string | undefined} slug - 태그 슬러그
|
||||
* @returns {string} 태그 이름
|
||||
* @returns {{ name: string, slug: string }} 태그 정보
|
||||
*/
|
||||
const getTagName = (slug) => {
|
||||
const getTagMeta = (slug) => {
|
||||
if (!slug) {
|
||||
return ''
|
||||
return {
|
||||
name: '',
|
||||
slug: ''
|
||||
}
|
||||
}
|
||||
|
||||
return tags.value.find((tag) => tag.slug === slug)?.name || String(slug).toUpperCase()
|
||||
const matchedTag = tags.value.find((tag) => tag.slug === slug)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || String(slug).toUpperCase(),
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
const postCards = computed(() => posts.value.map((post) => ({
|
||||
title: post.title,
|
||||
excerpt: createPostSummary(post.excerpt, post.content, {
|
||||
maxLength: 320,
|
||||
appendEllipsis: false
|
||||
}),
|
||||
featuredImage: post.featuredImage,
|
||||
featuredImageThumbnail: post.featuredImageThumbnail || '',
|
||||
tag: getTagName(post.tags?.[0]),
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})))
|
||||
const postCards = computed(() => posts.value.map((post) => {
|
||||
const primaryTag = getTagMeta(post.tags?.[0])
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
excerpt: createPostSummary(post.excerpt, post.content, {
|
||||
maxLength: 320,
|
||||
appendEllipsis: false
|
||||
}),
|
||||
featuredImage: post.featuredImage,
|
||||
featuredImageThumbnail: post.featuredImageThumbnail || '',
|
||||
tag: primaryTag.name,
|
||||
tagSlug: primaryTag.slug,
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -35,6 +35,7 @@ const tagPosts = computed(() => posts.value
|
||||
featuredImage: post.featuredImage,
|
||||
featuredImageThumbnail: post.featuredImageThumbnail || '',
|
||||
tag: primaryTag?.name || primaryTagSlug.toUpperCase(),
|
||||
tagSlug: primaryTagSlug,
|
||||
tagColor: primaryTag?.color || '#4d4d4d',
|
||||
isFeatured: Boolean(post.isFeatured),
|
||||
commentCount: Number(post.commentCount || 0),
|
||||
@@ -105,12 +106,13 @@ const tagPosts = computed(() => posts.value
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span
|
||||
<NuxtLink
|
||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
:to="`/tag/${post.tagSlug}`"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-0.75">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
@@ -120,10 +122,10 @@ const tagPosts = computed(() => posts.value
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<NuxtLink
|
||||
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75 md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100"
|
||||
type="button"
|
||||
aria-label="Share this post"
|
||||
:to="post.to"
|
||||
aria-label="게시물 보기"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -138,7 +140,7 @@ const tagPosts = computed(() => posts.value
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
recordAnalyticsPageview
|
||||
} from '../repositories/analytics-repository'
|
||||
import { getPageBySlug } from '../repositories/content-repository'
|
||||
import { shouldExcludeAdminAnalytics } from '../utils/analytics-admin-exclusion'
|
||||
|
||||
/**
|
||||
* 공개 페이지가 HTML 문서 모드일 때 Nuxt 렌더링 대신 원문 HTML을 응답한다.
|
||||
@@ -32,7 +33,11 @@ export default defineEventHandler(async (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'GET' && !isBotUserAgent(String(getRequestHeader(event, 'user-agent') || ''))) {
|
||||
if (
|
||||
method === 'GET'
|
||||
&& !isBotUserAgent(String(getRequestHeader(event, 'user-agent') || ''))
|
||||
&& !shouldExcludeAdminAnalytics(event)
|
||||
) {
|
||||
await recordAnalyticsPageview({
|
||||
visitorHash: createVisitorHashFromEvent(event),
|
||||
pageId: page.id,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { pageStatusSchema } from './content-schema.js'
|
||||
|
||||
export const adminPageInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:[-_][a-z0-9가-힣]+)*$/),
|
||||
renderMode: z.enum(['markdown', 'html_document']).default('html_document'),
|
||||
status: pageStatusSchema.default('published'),
|
||||
content: z.string().default(''),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const adminPostInputSchema = z.object({
|
||||
const t = String(val ?? '').trim()
|
||||
return t.length ? t : ADMIN_POST_PLACEHOLDER_TITLE
|
||||
}, z.string().min(1)),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:[-_][a-z0-9가-힣]+)*$/),
|
||||
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
|
||||
excerpt: z.string().default(''),
|
||||
featuredImage: z.string()
|
||||
|
||||
8
server/utils/analytics-admin-exclusion.js
Normal file
8
server/utils/analytics-admin-exclusion.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { getAdminSession } from './admin-auth.js'
|
||||
|
||||
/**
|
||||
* 관리자 세션의 통계 수집 제외 여부를 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {boolean} 통계 수집 제외 여부
|
||||
*/
|
||||
export const shouldExcludeAdminAnalytics = (event) => Boolean(getAdminSession(event))
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createSessionHashFromEvent,
|
||||
recordAnalyticsHeartbeat
|
||||
} from '../repositories/analytics-repository.js'
|
||||
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
|
||||
|
||||
const heartbeatInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
@@ -46,6 +47,10 @@ export const handleAnalyticsHeartbeat = async (event) => {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (shouldExcludeAdminAnalytics(event)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
|
||||
const pageSlug = normalizePageSlugForAnalytics(body.pageSlug)
|
||||
let postId = null
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createVisitorHashFromEvent,
|
||||
recordAnalyticsPageview
|
||||
} from '../repositories/analytics-repository.js'
|
||||
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
|
||||
|
||||
const pageviewInputSchema = z.object({
|
||||
path: z.string().trim().min(1).max(500),
|
||||
@@ -50,6 +51,10 @@ export const handleAnalyticsPageview = async (event) => {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
if (shouldExcludeAdminAnalytics(event)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const postSlug = normalizePostSlugForAnalytics(body.postSlug)
|
||||
const pageSlug = normalizePageSlugForAnalytics(body.pageSlug)
|
||||
let postId = null
|
||||
|
||||
Reference in New Issue
Block a user