diff --git a/components/site/RightSidebar.vue b/components/site/RightSidebar.vue index bf1d967..7b650f8 100644 --- a/components/site/RightSidebar.vue +++ b/components/site/RightSidebar.vue @@ -30,7 +30,7 @@ const { data: navigation } = await useFetch('/api/navigation', { /** * 공개 추천 사이트 목록(비가시 제외) - * @returns {Array<{ id: string, label: string, url: string }>} + * @returns {Array<{ id: string, label: string, url: string, descriptionText?: string, thumbnailUrl?: string }>} */ const recommendedSites = computed(() => { const list = navigation.value?.recommended @@ -47,6 +47,25 @@ const recommendedSites = computed(() => { */ const isExternalNavUrl = (url) => /^https?:\/\//i.test(String(url || '').trim()) +/** + * 추천 사이트 보조 문구를 반환한다. + * @param {Object} item - 추천 사이트 항목 + * @returns {string} 표시 문구 + */ +const getRecommendedDisplayText = (item) => { + return String(item?.descriptionText || '').trim() || String(item?.url || '').trim() +} + +/** + * 추천 사이트 이미지 URL을 반환한다. + * @param {Object} item - 추천 사이트 항목 + * @returns {string} 이미지 URL + */ +const getRecommendedImageUrl = (item) => { + const thumbnailUrl = String(item?.thumbnailUrl || '').trim() + return thumbnailUrl || getExternalFaviconUrl(item?.url, 64) +} + /** 소개 영역 공개 여부 */ const showAboutSection = false @@ -201,12 +220,12 @@ const showAboutSection = false > @@ -214,7 +233,7 @@ const showAboutSection = false {{ item.label }} - {{ item.url }} + {{ getRecommendedDisplayText(item) }} diff --git a/db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql b/db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql new file mode 100644 index 0000000..161c817 --- /dev/null +++ b/db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS page_analytics_daily ( + day DATE NOT NULL, + page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + views INTEGER NOT NULL DEFAULT 0, + visitors INTEGER NOT NULL DEFAULT 0, + engaged_views INTEGER NOT NULL DEFAULT 0, + total_engaged_seconds INTEGER NOT NULL DEFAULT 0, + scroll_25 INTEGER NOT NULL DEFAULT 0, + scroll_50 INTEGER NOT NULL DEFAULT 0, + scroll_75 INTEGER NOT NULL DEFAULT 0, + scroll_100 INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, page_id) +); + +CREATE INDEX IF NOT EXISTS page_analytics_daily_day_idx + ON page_analytics_daily (day DESC); + +ALTER TABLE analytics_daily_visitors + ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE CASCADE; + +ALTER TABLE analytics_daily_visitors + DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check; + +ALTER TABLE analytics_daily_visitors + ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page')); + +CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_page_uidx + ON analytics_daily_visitors (day, page_id, visitor_hash) + WHERE scope = 'page'; + +ALTER TABLE analytics_active_sessions + ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS page_slug TEXT NOT NULL DEFAULT ''; + +ALTER TABLE navigation_items + ADD COLUMN IF NOT EXISTS description_text TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS thumbnail_url TEXT NOT NULL DEFAULT ''; diff --git a/docs/changelog.md b/docs/changelog.md index 293b9f3..63f7324 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # 업데이트 요약 +## v1.5.9 + +- 관리자 대시보드에서 인기 페이지 통계를 볼 수 있게 했다. +- HTML 문서 모드 페이지도 서버에서 조회수를 기록하도록 보강했다. +- 추천 사이트에 대체 텍스트와 썸네일 URL을 추가하고, 공개 사이드바 표시에도 반영했다. + ## v1.5.8 - 소유자가 본인 권한을 직접 낮춰 소유자가 사라지는 상황을 막았다. diff --git a/docs/history.md b/docs/history.md index f8c977a..c1e6f2b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-05-27 v1.5.9 — 페이지 통계와 추천 사이트 메타데이터 확장 + +고정 페이지는 HTML 랜딩 페이지처럼 단독 URL로 쓰이기 때문에 게시물처럼 조회 추이를 볼 수 있어야 한다. 일반 Nuxt 페이지는 기존 클라이언트 통계 플러그인으로 pageSlug를 함께 보내고, 원문 HTML 문서 모드는 Nuxt 앱이 실행되지 않으므로 서버 미들웨어에서 GET 조회를 직접 기록한다. 추천 사이트는 URL 자체보다 운영자가 지정한 짧은 문구와 썸네일이 더 명확한 경우가 있으므로, 기존 파비콘 fallback은 유지하되 대체 텍스트와 썸네일 URL을 선택적으로 저장하게 했다. + ## 2026-05-27 v1.5.8 — 소유자 권한 보호와 멤버 목록 등급 표시 소유자는 시스템을 복구할 수 있는 최상위 권한이므로 본인 계정을 관리자 이하로 직접 낮출 수 없게 한다. 기존의 마지막 소유자 보호는 동시에 여러 사용자가 권한을 바꾸는 상황을 막기 위한 장치로 유지하고, 이미 소유자가 0명이 된 개발 DB는 마이그레이션으로 가장 오래된 관리자 계정을 소유자로 되돌릴 수 있게 했다. 멤버 목록은 운영자가 등급을 빠르게 스캔해야 하므로 별도 열을 추가하지 않고 기존 상태 열에 등급을 먼저 보여주며, 활성 상태는 기본값이라 숨기고 비활성만 보조 상태로 표시한다. diff --git a/docs/map.md b/docs/map.md index 0279b93..ce567e1 100644 --- a/docs/map.md +++ b/docs/map.md @@ -65,7 +65,7 @@ | components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 | | components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 | | components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 | -| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation`의 `recommended` 카드 목록(외부 URL은 Google 파비콘 프록시 썸네일), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 | +| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 | | components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 | | components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 | | components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 | @@ -123,7 +123,7 @@ | 파일 | 화면 | |------|------| -| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) | +| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물·인기 페이지 참여 지표) | | pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 | | pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 | @@ -133,7 +133,7 @@ | pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 | | pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | -| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | +| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) | | composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 | @@ -259,6 +259,7 @@ | 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/pages.get.js | 관리자 인기 페이지 API | | server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API | | plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 | @@ -294,6 +295,7 @@ | db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 | | db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 | | db/migrations/038_restore_owner_when_missing.sql | 소유자가 없는 경우 기존 관리자 중 가장 오래된 계정을 소유자로 복구 | +| db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql | 페이지 통계 테이블·추천 사이트 대체 텍스트/썸네일 컬럼 추가 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index 302685f..34b9d7c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -396,8 +396,9 @@ components/content/ |--------|------|------| | site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 | | post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 | -| analytics_daily_visitors | day, scope(`site`/`post`), post_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) | -| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) | +| page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 | +| analytics_daily_visitors | day, scope(`site`/`post`/`page`), post_id?, page_id?, visitor_hash | 일별 방문자 해시 등록(중복 방문 제거용) | +| 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`는 제외. - 봇 User-Agent는 서버에서 무시. @@ -438,8 +439,8 @@ components/content/ - `GET /api/tags` - 태그 목록 - `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만) - `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함) -- `POST /api/analytics/pageview` - 공개 방문·게시물 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `read`(읽음 이벤트). 발행된 게시물만 `postSlug` 집계. 응답 `{ ok: true }`. -- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결. +- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다. +- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결. - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절) - `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` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다. - `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다. @@ -473,6 +474,7 @@ components/content/ - `GET /admin/api/auth/me` - 현재 관리자 세션 조회 - `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다. - `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간) +- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간) - `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함) - `GET /admin/api/posts` - 글 목록 - `POST /admin/api/posts` - 글 작성 @@ -670,10 +672,10 @@ components/content/ - 네비게이션은 `navigation_items` 테이블로 관리한다. - 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`|`recommended`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다. - 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다. -- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다. +- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible`을 내려준다. - `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다. - URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. -- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, `https://` URL은 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). +- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). - `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다). - 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴). - 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다. diff --git a/docs/update.md b/docs/update.md index 02fd606..6236cff 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v1.5.9 + +- 관리자 대시보드: 페이지별 조회·방문자·체류·스크롤 통계 수집 및 인기 페이지 목록 추가. +- 공개 HTML 문서 페이지: Nuxt 클라이언트 통계를 거치지 않는 원문 HTML 응답도 서버에서 페이지 조회를 기록하도록 수정. +- 관리자 네비게이션 추천 사이트: 대체 텍스트와 썸네일 URL 입력 추가. +- 공개 오른쪽 사이드바 추천 사이트: 대체 텍스트가 있으면 URL 대신 표시하고, 썸네일이 있으면 파비콘 대신 표시하도록 수정. +- DB: 페이지 통계 테이블과 추천 사이트 메타데이터 컬럼 추가. + ## v1.5.8 - 관리자 멤버 권한 변경: 소유자가 본인 권한을 직접 낮출 수 없도록 수정. diff --git a/lib/analytics-shared.js b/lib/analytics-shared.js index b57403f..20ae202 100644 --- a/lib/analytics-shared.js +++ b/lib/analytics-shared.js @@ -65,6 +65,13 @@ export const isBotUserAgent = (userAgent) => { */ export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim() +/** + * 페이지 slug 정규화 + * @param {string} slug - slug + * @returns {string} 정규화된 slug + */ +export const normalizePageSlugForAnalytics = (slug) => (slug || '').trim() + /** @type {number} heartbeat 체류시간 상한(초) */ export const ANALYTICS_MAX_DURATION_SECONDS = 1800 diff --git a/lib/analytics.js b/lib/analytics.js index f5bcc14..d829a31 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -15,6 +15,7 @@ export { getNewScrollBucketColumns, isBotUserAgent, isTrackableAnalyticsPath, + normalizePageSlugForAnalytics, normalizePostSlugForAnalytics } from './analytics-shared.js' diff --git a/package-lock.json b/package-lock.json index 2b48caf..1147090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.5.8", + "version": "1.5.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.5.8", + "version": "1.5.9", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 6d15dfb..9070e55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.5.8", + "version": "1.5.9", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index c6f2ceb..2a34fda 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -43,6 +43,11 @@ const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/ default: () => [] }) +const { data: topPages, refresh: refreshTopPages } = await useFetch('/admin/api/analytics/pages', { + query: topPostsQuery, + default: () => [] +}) + const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/analytics/realtime', { query: { limit: 20 }, default: () => ({ @@ -250,6 +255,10 @@ const getSessionViewingTitle = (session) => { return session.postTitle } + if (session.pageTitle) { + return session.pageTitle + } + if (session.path === '/') { return '홈' } @@ -263,6 +272,7 @@ onMounted(() => { refreshTimer = window.setInterval(() => { refreshSummary() refreshTopPosts() + refreshTopPages() refreshRealtime() }, 30000) }) @@ -276,6 +286,7 @@ onUnmounted(() => { watch(selectedAnalyticsDays, () => { refreshSummary() refreshTopPosts() + refreshTopPages() }) @@ -435,74 +446,145 @@ watch(selectedAnalyticsDays, () => {

-
-

- 인기 게시물 ({{ analyticsRangeLabel }}) -

- +

+ 아직 집계된 페이지 조회 데이터가 없습니다. +

+
+ diff --git a/pages/admin/navigation/index.vue b/pages/admin/navigation/index.vue index 09fba04..cb03183 100644 --- a/pages/admin/navigation/index.vue +++ b/pages/admin/navigation/index.vue @@ -17,6 +17,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', { const items = ref(navigationItems.value.map((item) => ({ ...item, + descriptionText: item.descriptionText || '', + thumbnailUrl: item.thumbnailUrl || '', parentId: item.parentId ?? null, isFolder: Boolean(item.isFolder), isVisible: true @@ -50,6 +52,8 @@ const serializeNavigationItems = (list) => JSON.stringify( id: String(item.id || '').trim(), label: String(item.label || '').trim(), url: String(item.url || '').trim(), + descriptionText: String(item.descriptionText || '').trim(), + thumbnailUrl: String(item.thumbnailUrl || '').trim(), location: item.location, sortOrder: Number(item.sortOrder || 0), parentId: item.parentId ? String(item.parentId).trim() : null @@ -601,6 +605,8 @@ const addPrimaryRoot = () => { id: crypto.randomUUID(), label: '새 메뉴', url: '/', + descriptionText: '', + thumbnailUrl: '', location: 'primary', parentId: null, sortOrder: maxOrder + 10, @@ -620,6 +626,8 @@ const addFooterItem = () => { id: crypto.randomUUID(), label: '', url: '/', + descriptionText: '', + thumbnailUrl: '', location: 'footer', parentId: null, sortOrder: maxOrder + 10, @@ -639,6 +647,8 @@ const addRecommendedItem = () => { id: crypto.randomUUID(), label: '', url: 'https://', + descriptionText: '', + thumbnailUrl: '', location: 'recommended', parentId: null, sortOrder: maxOrder + 10, @@ -668,6 +678,8 @@ const saveNavigation = async () => { id: item.id, label: item.label, url: item.url, + descriptionText: item.descriptionText || '', + thumbnailUrl: item.thumbnailUrl || '', location: item.location, sortOrder: Number(item.sortOrder || 0), isVisible: true, @@ -679,6 +691,8 @@ const saveNavigation = async () => { items.value = savedItems.map((item) => ({ ...item, + descriptionText: item.descriptionText || '', + thumbnailUrl: item.thumbnailUrl || '', parentId: item.parentId ?? null, isFolder: Boolean(item.isFolder), isVisible: true @@ -941,7 +955,7 @@ const saveNavigation = async () => {