v1.4.3: 관리자 UI·홈·미디어 개선

- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬
- 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정
- 미디어 종류·미사용 필터, 비디오 프레임 썸네일
- NAS 운영 업데이트 절차 문서 추가
This commit is contained in:
2026-05-21 18:30:50 +09:00
parent 6919669330
commit 10c5a099fc
15 changed files with 523 additions and 84 deletions

View File

@@ -264,6 +264,42 @@
background-color: #f7f4ef; background-color: #f7f4ef;
} }
/**
* 관리자 화면은 공개 사이트 테마와 분리된 라이트 UI로 고정한다.
*/
.admin-layout {
--site-bg: #f7f8fa;
--site-panel: #ffffff;
--site-panel-strong: #ffffff;
--site-text: #15171a;
--site-muted: #6b7280;
--site-soft: #657080;
--site-line: #e5e7eb;
--site-input: #ffffff;
color-scheme: light;
}
.admin-layout--light-controls input:not(.auth-form-input),
.admin-layout--light-controls textarea,
.admin-layout--light-controls select {
color: #15171a;
background-color: #ffffff;
caret-color: #15171a;
color-scheme: light;
}
.admin-layout--light-controls input:not(.auth-form-input)::placeholder,
.admin-layout--light-controls textarea::placeholder {
color: #8a94a3;
}
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill,
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill:hover,
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill:focus {
-webkit-text-fill-color: #15171a;
box-shadow: 0 0 0 1000px #ffffff inset;
}
:root[data-theme='dark'] .site-sidebar-nav-row:hover { :root[data-theme='dark'] .site-sidebar-nav-row:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text)); background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
} }

View File

@@ -0,0 +1,107 @@
<script setup>
const props = defineProps({
/** 비디오 파일 URL */
src: {
type: String,
required: true
},
/** 접근성 대체 텍스트 */
alt: {
type: String,
default: '비디오 썸네일'
}
})
const videoRef = ref(null)
const canvasRef = ref(null)
const thumbnailUrl = ref('')
const failed = ref(false)
/**
* 비디오 프레임을 캔버스 이미지로 변환한다.
* @returns {void}
*/
const captureVideoFrame = () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas || !video.videoWidth || !video.videoHeight) {
failed.value = true
return
}
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
if (!context) {
failed.value = true
return
}
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height)
thumbnailUrl.value = canvas.toDataURL('image/jpeg', 0.78)
} catch {
failed.value = true
}
}
/**
* 메타데이터 로드 후 초반 프레임으로 이동한다.
* @returns {void}
*/
const seekPreviewFrame = () => {
const video = videoRef.value
if (!video) {
failed.value = true
return
}
const duration = Number.isFinite(video.duration) ? video.duration : 0
const targetTime = duration > 1 ? Math.min(1, duration * 0.1) : 0
try {
video.currentTime = targetTime
} catch {
captureVideoFrame()
}
}
</script>
<template>
<span class="admin-media-video-thumbnail relative block aspect-square w-full overflow-hidden bg-surface">
<img
v-if="thumbnailUrl"
class="admin-media-video-thumbnail__image h-full w-full object-cover"
:src="thumbnailUrl"
:alt="alt"
loading="lazy"
>
<span
v-else
class="admin-media-video-thumbnail__fallback flex h-full w-full items-center justify-center text-xs font-bold uppercase tracking-[0.18em] text-muted"
>
video
</span>
<span class="admin-media-video-thumbnail__badge absolute bottom-1.5 left-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white">
video
</span>
<video
ref="videoRef"
class="hidden"
:src="src"
muted
playsinline
preload="metadata"
crossorigin="anonymous"
@loadedmetadata="seekPreviewFrame"
@seeked="captureVideoFrame"
@error="failed = true"
/>
<canvas ref="canvasRef" class="hidden" aria-hidden="true" />
<span v-if="failed && !thumbnailUrl" class="sr-only">
{{ alt }}
</span>
</span>
</template>

View File

@@ -1,5 +1,10 @@
# 업데이트 요약 # 업데이트 요약
## v1.4.3
- 관리자 화면이 공개 사이트 다크모드 영향을 받지 않도록 라이트 UI를 분리했다.
- 관리자 미디어 라이브러리에 종류·미사용 필터와 비디오 썸네일 미리보기를 추가했다.
## v1.4.2 ## v1.4.2
- 글쓰기 소스 모드에서 긴 줄이 자동 줄바꿈될 때 라인 번호가 실제 줄 높이와 어긋나던 문제를 수정했다. - 글쓰기 소스 모드에서 긴 줄이 자동 줄바꿈될 때 라인 번호가 실제 줄 높이와 어긋나던 문제를 수정했다.

View File

@@ -24,7 +24,8 @@
## 스타일 ## 스타일
- TailwindCSS 기본 사용 - TailwindCSS 기본 사용
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음). - 다크 인증(`signin`/`signup`/`admin/login`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
- 관리자 레이아웃(`admin-layout`)은 공개 사이트 테마와 분리된 라이트 UI로 고정하며, 글쓰기 화면을 제외한 관리자 일반 폼 컨트롤은 `main.css``.admin-layout--light-controls input/textarea/select` 스코프 기준을 따른다.
- Tailwind 엔트리는 `nuxt.config.js``tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음). - Tailwind 엔트리는 `nuxt.config.js``tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지 - 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다. - 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다.

View File

@@ -147,6 +147,45 @@ sh scripts/migrate-production-db.sh baseline
sh scripts/migrate-production-db.sh migrate sh scripts/migrate-production-db.sh migrate
``` ```
### 운영 업데이트 (코드 반영)
이미 한 번 올려 둔 NAS에서 **새 커밋을 받아 반영**할 때는 보통 아래 순서를 따른다. 최초 설치 절차와 달리 `git clone`은 하지 않는다.
```bash
# 프로젝트 루트로 이동 (경로는 NAS 환경에 맞게 조정)
cd /volume1/docker/projects/apps/sori.studio
# 원격 저장소 최신 코드 받기
git pull
# DB 스키마 변경이 포함된 배포면 미적용 SQL만 적용 (npm 없이 실행 가능)
sh scripts/migrate-production-db.sh status
sh scripts/migrate-production-db.sh migrate
# 앱 이미지 재빌드 후 컨테이너 재기동
docker compose --env-file .env.production up -d --build
```
| 단계 | 설명 |
|------|------|
| `git pull` | 애플리케이션·Dockerfile·`db/migrations` 등 Git에 있는 변경을 받는다. |
| `migrate` | `db/migrations/`에 새 SQL이 있으면 운영 DB에만 적용한다. 스키마 변경이 없으면 생략해도 된다. |
| `up -d --build` | Nuxt 프로덕션 빌드가 Docker 이미지 안에서 수행되므로, **NAS 호스트에 Node/npm이 없어도** 앱 코드 반영이 가능하다. |
주의:
- `.env.production`은 Git에 포함하지 않는다. `git pull`로 덮어쓰이지 않는다. 값을 바꿀 때만 파일을 직접 수정한다.
- `public/uploads/` 업로드 파일은 Docker 볼륨(`./public/uploads`)에 있으므로, **이미지 파일만 추가·수정한 경우** 앱 재빌드 없이도 URL로 바로 보인다.
- 로컬에서 미리 확인하려면 `npm run verify` 후 NAS에서 위 명령을 실행하면 된다.
컨테이너만 재시작하고 이미지는 그대로 두려면(환경 변수만 바꾼 경우 등):
```bash
docker compose --env-file .env.production up -d
```
코드 변경 없이 `.env.production`만 수정했다면 `--build` 없이 `up -d`만으로 충분하다.
### Docker 네트워크 충돌 대응 ### Docker 네트워크 충돌 대응
NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다. NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-21 v1.4.2 — 관리자 레이아웃 라이트 테마 격리
공개 사이트의 라이트/다크 테마는 `html[data-theme]`와 CSS 변수로 전역에 적용된다. 같은 앱 안의 관리자 화면이 이 변수를 그대로 상속하면, 공개 화면을 다크모드로 둔 상태에서 관리자 네비게이션 입력처럼 별도 배경색을 명시하지 않은 폼 컨트롤이 어두운 색으로 바뀌어 관리 UI 가독성이 깨진다. 관리자 로그인은 별도 다크 인증 화면으로 유지하되, 로그인 이후 `admin-layout`은 운영 도구 성격에 맞춰 라이트 UI로 고정한다. 다만 글쓰기 에디터는 별도 입력 UX가 있으므로, 폼 컨트롤 `color-scheme` 재정의는 `admin-layout--light-controls`가 붙은 일반 관리자 화면에만 적용한다.
## 2026-05-21 v1.4.1 — 라이브 모드 임베드 즉시 프리뷰 전환 ## 2026-05-21 v1.4.1 — 라이브 모드 임베드 즉시 프리뷰 전환
단독 URL 붙여넣기가 임베드 작성의 기본 흐름이 되면서 라이브 모드의 별도 URL 입력 카드는 같은 값을 다시 입력하게 만드는 중복 UI가 됐다. 임베드 블록은 즉시 실제 프리뷰로 보여주고, iframe 때문에 일반 텍스트처럼 커서를 둘 수 없는 문제는 프리뷰 래퍼 자체를 포커스 가능한 블록으로 만들어 삭제·아래 줄 추가 키보드 조작을 받도록 했다. 같은 문제가 업로드 비디오·오디오·파일 카드에도 적용되므로 공통 프리뷰 카드 동작으로 확장한다. iframe·audio 컨트롤 등이 내부 포커스를 가져갈 수 있으므로 hover/focus 삭제 버튼을 둔다. 선택된 프리뷰 카드에서 방향키가 페이지 스크롤로 빠지지 않도록 `ArrowUp`·`ArrowDown`은 이전/다음 편집 줄 포커스 이동으로 처리한다. 임베드 전용 왼쪽 핸들은 전체 블록 공통 정책이 아니므로 제거한다. 단독 URL 붙여넣기가 임베드 작성의 기본 흐름이 되면서 라이브 모드의 별도 URL 입력 카드는 같은 값을 다시 입력하게 만드는 중복 UI가 됐다. 임베드 블록은 즉시 실제 프리뷰로 보여주고, iframe 때문에 일반 텍스트처럼 커서를 둘 수 없는 문제는 프리뷰 래퍼 자체를 포커스 가능한 블록으로 만들어 삭제·아래 줄 추가 키보드 조작을 받도록 했다. 같은 문제가 업로드 비디오·오디오·파일 카드에도 적용되므로 공통 프리뷰 카드 동작으로 확장한다. iframe·audio 컨트롤 등이 내부 포커스를 가져갈 수 있으므로 hover/focus 삭제 버튼을 둔다. 선택된 프리뷰 카드에서 방향키가 페이지 스크롤로 빠지지 않도록 `ArrowUp`·`ArrowDown`은 이전/다음 편집 줄 포커스 이동으로 처리한다. 임베드 전용 왼쪽 핸들은 전체 블록 공통 정책이 아니므로 제거한다.

View File

@@ -8,7 +8,7 @@
|------|------| |------|------|
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 | | layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
| layouts/post.vue | 개별 게시물 — `default`와 동일 | | layouts/post.vue | 개별 게시물 — `default`와 동일 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 활성 ·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** | | layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
| layouts/page.vue | 고정 페이지 전체 화면 | | layouts/page.vue | 고정 페이지 전체 화면 |
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template | | app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
@@ -76,6 +76,7 @@
| 파일 | 화면 위치 | | 파일 | 화면 위치 |
|------|-----------| |------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) | | components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 | | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
@@ -121,7 +122,7 @@
| 파일 | 화면 | | 파일 | 화면 |
|------|------| |------|------|
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) | | pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) |
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) | | pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) | | pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 | | pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
@@ -129,7 +130,7 @@
| pages/admin/pages/index.vue | 페이지 목록, 행 more vert 메뉴(수정·삭제) | | pages/admin/pages/index.vue | 페이지 목록, 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | | pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 목록, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·팝오버) | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 | | composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
@@ -146,7 +147,7 @@
| 파일 | 화면 | | 파일 | 화면 |
|------|------| |------|------|
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 `HomeHero`, Featured/Latest, Latest 피드 List·Compact·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 모바일 터치 가로 스크롤·스냅 끝에서 화살표 비활성 | | pages/index.vue | 홈, `site_settings` 커버가 있을 때만 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 | | pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 | | pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 | | pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
@@ -290,7 +291,7 @@
| package.json | Nuxt 실행 스크립트와 의존성 | | package.json | Nuxt 실행 스크립트와 의존성 |
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath``main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 | | nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath``main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
| tailwind.config.js | Tailwind 테마 설정 | | tailwind.config.js | Tailwind 테마 설정 |
| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 | | assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 관리자 라이트 테마 격리(`admin-layout`), 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) | | composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 | | composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 | | middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |

View File

@@ -52,9 +52,16 @@
- 가로 카드 트랙은 `overflow-x-auto``snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다. - 가로 카드 트랙은 `overflow-x-auto``snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다. - Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화. - 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다. - 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
### 홈 Latest 피드
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- `compact`는 썸네일을 포함한 짧은 행 형태, `list`는 텍스트 중심 목록 형태, `cards`는 카드 그리드 형태로 표시한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
### Post 페이지 ### Post 페이지
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다. - 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
@@ -101,7 +108,8 @@
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다. - 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다. - 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다. - 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다. - 관리자 로그인 화면도 같은 다크 톤 폼 레이아웃을 사용하되, 일반 로그인과 구분되도록 폼을 화면 오른쪽에 배치하고 내부 타이틀·설명·필드·버튼도 오른쪽 정렬한다.
- 로그인·회원가입(2단계)·관리자 로그인 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다. - 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings``title`, `description` 값을 우선 사용한다. - 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings``title`, `description` 값을 우선 사용한다.
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다. - 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
@@ -118,8 +126,10 @@ layouts/
### 관리자 레이아웃 ### 관리자 레이아웃
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다. - 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
- 관리자 로그인(`/admin/login`)을 제외한 관리자 화면은 공개 사이트 라이트/다크 테마와 분리된 라이트 UI로 고정한다. `admin-layout` 스코프에서 공개 테마 CSS 변수를 재정의하고, 글쓰기 화면을 제외한 일반 관리자 화면에만 폼 컨트롤 `color-scheme`을 라이트 값으로 재정의해 사용자 페이지 다크모드가 관리자 입력·테이블·패널 색상에 영향을 주지 않게 한다.
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다. - 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
- 관리자 우측 캔버스는 기본 `min-h-screen``bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다. - 관리자 우측 캔버스는 기본 `min-h-screen``bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
- 대시보드 메뉴는 관리자 기본 페이지(`/admin`)로 이동하는 활성 링크로 표시한다.
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다. - 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 메뉴 관리 항목은 `네비게이션`으로 표시한다. - 메뉴 관리 항목은 `네비게이션`으로 표시한다.
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다. - 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
@@ -191,6 +201,7 @@ components/content/
- 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드 - 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드
- 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue` - 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue`
- 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정 - 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정
- 관리자 미디어 화면은 미디어 라이브러리 탭에서 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다.
- 문단과 줄바꿈 - 문단과 줄바꿈
- 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다. - 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다.
- 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거). - 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거).
@@ -382,6 +393,7 @@ components/content/
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송. - 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. - `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다. - 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다.
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 7일은 일자별로 표시하고, 30일 이상은 선택 기간에 따라 7일·14일·30일 단위로 묶어 카드 폭을 넘지 않게 한다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
- 관리자 차트는 최대 365일 범위를 조회한다. - 관리자 차트는 최대 365일 범위를 조회한다.
- `site_analytics_daily`, `post_analytics_daily`는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다. - `site_analytics_daily`, `post_analytics_daily`는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다.
- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다. - `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.

View File

@@ -1,5 +1,15 @@
# 업데이트 이력 # 업데이트 이력
## v1.4.3
- 관리자 레이아웃: 공개 다크모드가 글쓰기 에디터 입력에 영향을 주지 않도록 라이트 폼 보정 스코프 축소.
- 관리자 레이아웃: 대시보드 메뉴를 `/admin` 활성 링크로 연결.
- 관리자 로그인: 일반 로그인과 동일한 다크 인증 스타일, 폼을 화면 오른쪽·내부 오른쪽 정렬.
- 관리자 대시보드: 통계 추이 막대 차트 높이·툴팁·기간별 묶음 집계 보정.
- 홈 Latest: 컴팩트 기본 보기, 리스트/컴팩트 표시 조건 수정, Featured·Latest UI 보정.
- 관리자 미디어: 종류 필터(전체·이미지·영상·음악·파일), 미사용 필터, 비디오 프레임 썸네일 추가.
- 배포 문서: NAS 운영 업데이트 절차 추가.
## v1.4.2 ## v1.4.2
- 관리자 글쓰기: 소스 모드 긴 문장 자동 줄바꿈 시 라인 번호 높이를 실제 wrap 높이에 맞춰 보정. - 관리자 글쓰기: 소스 모드 긴 문장 자동 줄바꿈 시 라인 번호 높이를 실제 wrap 높이에 맞춰 보정.
@@ -19,6 +29,20 @@
- 콘텐츠 렌더러: 다크모드 기본 인용 블록 텍스트를 어두운 색으로 고정해 가독성 보정. - 콘텐츠 렌더러: 다크모드 기본 인용 블록 텍스트를 어두운 색으로 고정해 가독성 보정.
- 콘텐츠 렌더러: 공개 본문 리스트 번호·점 색상을 글쓰기 화면과 같은 파란 계열로 통일. - 콘텐츠 렌더러: 공개 본문 리스트 번호·점 색상을 글쓰기 화면과 같은 파란 계열로 통일.
- 공개 레이아웃: 다크모드에서 좌우 사이드바 배경을 홈페이지 기본 배경(`--site-bg`)과 동일하게 통일. - 공개 레이아웃: 다크모드에서 좌우 사이드바 배경을 홈페이지 기본 배경(`--site-bg`)과 동일하게 통일.
- 배포 문서: NAS 운영 업데이트 절차(`git pull` → DB 마이그레이션 → `docker compose up -d --build`) 추가.
- 홈 Latest: 기본·Default 보기 방식이 컴팩트 형태로 보이도록 리스트/컴팩트 썸네일 표시 조건 수정.
- 홈 Featured: 대표 이미지 없는 추천 글 썸네일을 게시물 제목 placeholder로 통일.
- 홈 Latest: 게시물이 적을 때 보기 방식 메뉴가 잘리지 않도록 섹션 최소 높이 추가.
- 관리자 로그인: 일반 로그인과 같은 다크 인증 스타일을 적용하고, 관리자 구분용으로 폼을 오른쪽 배치 및 내부 오른쪽 정렬.
- 관리자 대시보드: 통계 추이 막대 차트가 보이지 않던 CSS 높이 비율 문제 수정.
- 관리자 대시보드: 통계 추이 막대 hover/focus 시 날짜와 정확한 값을 표시하는 툴팁 추가.
- 관리자 대시보드: 30일 이상 통계 추이 차트는 기간별 묶음 집계로 표시해 카드 폭 넘침 방지.
- 관리자 레이아웃: 사이드바 대시보드 항목을 비활성 표시에서 `/admin` 활성 링크로 변경.
- 관리자 레이아웃: 공개 사이트 다크모드가 관리자 입력·패널 색상에 영향을 주지 않도록 `admin-layout` 라이트 테마 스코프 추가.
- 관리자 글쓰기: 라이트 폼 컨트롤 보정이 글쓰기 에디터 입력까지 덮지 않도록 스코프 축소.
- 관리자 미디어: 미디어 라이브러리에 전체·이미지·영상·음악·파일 종류 필터 추가.
- 관리자 미디어: 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 미사용 미디어만 보는 필터 추가.
- 관리자 미디어: 비디오 항목은 초반 프레임을 브라우저에서 추출해 목록 썸네일로 표시.
## v1.4.1 ## v1.4.1

View File

@@ -39,6 +39,7 @@ const adminDisplayEmail = computed(() => adminMember.value?.email || '')
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '') const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A') const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members') const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members')
const isAdminDashboardRoute = computed(() => route.path === '/admin')
/** /**
* 관리자 내비게이션 활성 경로 확인 * 관리자 내비게이션 활성 경로 확인
@@ -131,7 +132,10 @@ const logoutAdmin = async () => {
<template> <template>
<div <div
class="admin-layout bg-[#f7f8fa] text-ink" class="admin-layout bg-[#f7f8fa] text-ink"
:class="(isPostEditorRoute || isAdminSettingsRoute) ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'" :class="[
(isPostEditorRoute || isAdminSettingsRoute) ? 'h-screen overflow-hidden bg-white' : 'min-h-screen',
{ 'admin-layout--light-controls': !isPostEditorRoute }
]"
> >
<aside <aside
v-if="!isPostEditorRoute && !isAdminSettingsRoute" v-if="!isPostEditorRoute && !isAdminSettingsRoute"
@@ -144,16 +148,16 @@ const logoutAdmin = async () => {
<span>sori.studio</span> <span>sori.studio</span>
</NuxtLink> </NuxtLink>
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]"> <nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
<div <NuxtLink
class="admin-layout__nav-link admin-layout__nav-link--disabled flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-[#9aa3ad] select-none" class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
aria-disabled="true" :class="isAdminDashboardRoute ? 'bg-[#e9ecef] text-[#15171a]' : ''"
title="준비 중" to="/admin"
> >
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0 opacity-60" viewBox="0 0 24 24" aria-hidden="true"> <svg class="admin-layout__nav-icon h-4 w-4 shrink-0 opacity-60" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.272 23.247a.981.981 0 00.978-.978V9.747a1.181 1.181 0 00-.377-.8L12 .747l-10.873 8.2a1.181 1.181 0 00-.377.8v12.522a.981.981 0 00.978.978z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" /> <path d="M22.272 23.247a.981.981 0 00.978-.978V9.747a1.181 1.181 0 00-.377-.8L12 .747l-10.873 8.2a1.181 1.181 0 00-.377.8v12.522a.981.981 0 00.978.978z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg> </svg>
<span>대시보드</span> <span>대시보드</span>
</div> </NuxtLink>
<a <a
class="admin-layout__nav-link admin-layout__nav-link--external flex items-center gap-3 rounded-md px-3 py-2 text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]" class="admin-layout__nav-link admin-layout__nav-link--external flex items-center gap-3 rounded-md px-3 py-2 text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
:href="publicBlogBaseUrl" :href="publicBlogBaseUrl"

View File

@@ -111,7 +111,22 @@ const formatTrendValue = (key, value) => {
return formatEngagedDuration(value) return formatEngagedDuration(value)
} }
return `${Number(value || 0)}` if (key === 'visitors') {
return `${Number(value || 0)}`
}
return `${Number(value || 0)}`
}
/**
* 추세 막대 툴팁 문구를 반환한다.
* @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보
* @param {Object} row - 추세 행
* @returns {string} 툴팁 문구
*/
const getTrendTooltipLabel = (metric, row) => {
const dayLabel = row.label || formatTrendDayLabel(row.day)
return `${dayLabel} · ${metric.title} ${formatTrendValue(metric.key, row[metric.key])}`
} }
const chartMetrics = computed(() => [ const chartMetrics = computed(() => [
@@ -139,7 +154,7 @@ const chartMetrics = computed(() => [
* @returns {number} 높이 % * @returns {number} 높이 %
*/ */
const getTrendBarHeight = (key, row) => { const getTrendBarHeight = (key, row) => {
const rows = trendRows.value const rows = chartTrendRows.value
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0) const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
if (maxValue <= 0) { if (maxValue <= 0) {
@@ -164,6 +179,67 @@ const formatTrendDayLabel = (day) => {
return `${month}.${date}` return `${month}.${date}`
} }
/**
* 선택 기간에 맞는 차트 표시 집계 단위를 반환한다.
* @param {number} days - 선택 기간
* @returns {number} 묶을 일수
*/
const getTrendChartBucketSize = (days) => {
if (days <= 14) {
return 1
}
if (days <= 60) {
return 7
}
if (days <= 180) {
return 14
}
return 30
}
/**
* 차트 표시용 추세 묶음 행을 만든다.
* @param {Array<Object>} rows - 일자별 추세 행
* @param {number} bucketSize - 묶을 일수
* @returns {Array<Object>} 차트 표시 행
*/
const buildTrendChartRows = (rows, bucketSize) => {
if (bucketSize <= 1) {
return rows.map((row) => ({
...row,
label: formatTrendDayLabel(row.day)
}))
}
const buckets = []
for (let index = 0; index < rows.length; index += bucketSize) {
const bucketRows = rows.slice(index, index + bucketSize)
const firstRow = bucketRows[0]
const lastRow = bucketRows[bucketRows.length - 1]
const engagedRows = bucketRows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
const totalEngagedSeconds = engagedRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
const startLabel = formatTrendDayLabel(firstRow?.day)
const endLabel = formatTrendDayLabel(lastRow?.day)
buckets.push({
day: firstRow?.day || '',
label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`,
visitors: bucketRows.reduce((sum, row) => sum + Number(row.visitors || 0), 0),
avgEngagedSeconds: engagedRows.length ? Math.round(totalEngagedSeconds / engagedRows.length) : 0,
scroll50Reach: bucketRows.reduce((sum, row) => sum + Number(row.scroll50Reach || 0), 0)
})
}
return buckets
}
const chartTrendRows = computed(() => {
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.value))
})
/** /**
* 현재 접속자가 보고 있는 화면명을 반환한다. * 현재 접속자가 보고 있는 화면명을 반환한다.
* @param {Object} session - 접속 세션 * @param {Object} session - 접속 세션
@@ -268,19 +344,35 @@ watch(selectedAnalyticsDays, () => {
{{ metric.label }} {{ metric.label }}
</strong> </strong>
</div> </div>
<div class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"> <div
class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"
role="img"
:aria-label="`${metric.title} ${analyticsRangeLabel} 추이`"
>
<div <div
v-for="row in trendRows" v-for="row in chartTrendRows"
:key="`${metric.key}-${row.day}`" :key="`${metric.key}-${row.day}`"
class="admin-dashboard__chart-bar-wrap flex min-w-[3px] flex-1 items-end" class="admin-dashboard__chart-bar-wrap group relative flex h-full min-w-[3px] flex-1 items-end"
:title="`${row.day} · ${formatTrendValue(metric.key, row[metric.key])}`" tabindex="0"
:aria-label="getTrendTooltipLabel(metric, row)"
> >
<div <div
class="admin-dashboard__chart-bar w-full bg-[#15171a]/80" class="admin-dashboard__chart-tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[#15171a] px-2 py-1 text-[11px] font-medium text-white shadow-lg group-hover:block group-focus-visible:block"
>
{{ getTrendTooltipLabel(metric, row) }}
</div>
<div
class="admin-dashboard__chart-bar w-full rounded-t-sm bg-[#15171a] transition-colors group-hover:bg-[#2f6feb] group-focus-visible:bg-[#2f6feb]"
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }" :style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
/> />
</div> </div>
</div> </div>
<p
v-if="!chartTrendRows.length"
class="admin-dashboard__chart-empty mt-4 text-center text-xs text-muted"
>
선택한 기간에 표시할 추이 데이터가 없습니다.
</p>
<div class="mt-2 flex justify-between text-[11px] text-muted"> <div class="mt-2 flex justify-between text-[11px] text-muted">
<span>{{ formatTrendDayLabel(trendStartDay) }}</span> <span>{{ formatTrendDayLabel(trendStartDay) }}</span>
<span>{{ analyticsRangeLabel }}</span> <span>{{ analyticsRangeLabel }}</span>

View File

@@ -10,6 +10,7 @@ const form = reactive({
const pending = ref(false) const pending = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const showPassword = ref(false)
const emailInput = ref(null) const emailInput = ref(null)
const passwordInput = ref(null) const passwordInput = ref(null)
@@ -20,6 +21,12 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
}) })
}) })
/**
* 관리자 로그인 제출 가능 여부
* @returns {boolean} 제출 가능 여부
*/
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
/** /**
* 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다. * 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
* @returns {void} * @returns {void}
@@ -85,64 +92,76 @@ onMounted(() => {
</script> </script>
<template> <template>
<main class="admin-login flex min-h-screen items-center justify-center bg-[#f5f5f2] px-5 text-ink"> <main class="admin-login min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<section class="admin-login__panel w-full max-w-sm border border-line bg-paper p-8"> <div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center justify-end px-5 py-12 sm:px-10 lg:px-16">
<p class="admin-login__eyebrow text-xs font-semibold uppercase text-muted"> <section class="admin-login__panel w-full max-w-[430px] p-5 sm:p-8">
Admin <p class="admin-login__eyebrow text-right text-xs font-semibold uppercase text-[#9ba3af]">
</p> Admin
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
로그인
</h1>
<p
v-if="bootstrapStatus?.needsAdminSetup"
class="mt-3 rounded border border-[#ff4f2e]/30 bg-[#ff4f2e]/10 px-3 py-2 text-xs text-[#b63a23]"
>
등록된 관리자가 없습니다.
<NuxtLink class="font-semibold underline-offset-2 hover:underline" to="/signup">
관리자 등록으로 이동
</NuxtLink>
</p>
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
<label class="admin-login__field grid gap-2 text-sm">
<span class="admin-login__label font-medium">이메일</span>
<input
ref="emailInput"
v-model="form.email"
class="admin-login__input rounded border border-line bg-white px-3 py-2"
type="email"
autocomplete="username"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
</label>
<label class="admin-login__field grid gap-2 text-sm">
<span class="admin-login__label font-medium">비밀번호</span>
<input
ref="passwordInput"
v-model="form.password"
class="admin-login__input rounded border border-line bg-white px-3 py-2"
type="password"
autocomplete="current-password"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
</label>
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
{{ errorMessage }}
</p> </p>
<button <h1 class="admin-login__title text-right mt-2 text-2xl font-semibold leading-tight">
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50" 관리자 로그인
type="submit" </h1>
:disabled="pending" <p class="mt-2 text-right text-sm text-[#9ba3af]">
관리자 계정으로 로그인해 콘텐츠와 사이트 설정을 관리하세요.
</p>
<p
v-if="bootstrapStatus?.needsAdminSetup"
class="mt-5 rounded-[10px] border border-[#b03b43]/50 bg-[#b03b43]/10 px-3 py-2 text-right text-xs text-[#e5acb1]"
> >
{{ pending ? '확인 중' : '로그인' }} 등록된 관리자가 없습니다.
</button> <NuxtLink class="font-semibold text-[#7eb8ff] underline-offset-2 hover:underline" to="/signup">
</form> 관리자 등록으로 이동
</section> </NuxtLink>
</p>
<form class="admin-login__form mt-8 space-y-5" @submit.prevent="submitLogin">
<label class="admin-login__field block space-y-1.5">
<span class="admin-login__label text-xs text-[#d8dee6]">이메일</span>
<input
ref="emailInput"
v-model="form.email"
class="auth-form-input admin-login__input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
type="email"
autocomplete="username"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
</label>
<label class="admin-login__field block space-y-1.5">
<span class="admin-login__label text-xs text-[#d8dee6]">비밀번호</span>
<span class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
<input
ref="passwordInput"
v-model="form.password"
class="auth-form-input admin-login__input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
<AuthPasswordVisibilityToggle v-model="showPassword" />
</span>
</label>
<p v-if="errorMessage" class="admin-login__error text-right text-xs text-[#e5acb1]" aria-live="polite">
{{ errorMessage }}
</p>
<button
class="admin-login__button ml-auto block h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
:disabled="pending || !canSubmitAdminLogin"
>
{{ pending ? '확인 중' : '로그인' }}
</button>
</form>
<NuxtLink class="mt-6 flex justify-end text-xs text-[#9ba3af] hover:opacity-80" to="/">
사이트로 돌아가기
</NuxtLink>
</section>
</div>
</main> </main>
</template> </template>

View File

@@ -16,6 +16,8 @@ const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || Str
const activeTab = ref('library') const activeTab = ref('library')
const searchText = ref('') const searchText = ref('')
const activeFolder = ref('') const activeFolder = ref('')
const activeMediaKind = ref('all')
const showUnusedOnly = ref(false)
const isCreateFolderModalOpen = ref(false) const isCreateFolderModalOpen = ref(false)
const createFolderModalName = ref('') const createFolderModalName = ref('')
const deletingFolder = ref('') const deletingFolder = ref('')
@@ -70,6 +72,38 @@ const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value)) const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
const mediaKindFilterOptions = computed(() => {
const baseItems = scopeItems.value.filter((item) => {
const folder = activeFolder.value
return activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
})
const countByKind = baseItems.reduce((counts, item) => {
const kind = getMediaItemKind(item)
counts[kind] = (counts[kind] || 0) + 1
return counts
}, {})
return [
{ id: 'all', label: '전체', count: baseItems.length },
{ id: 'image', label: '이미지', count: countByKind.image || 0 },
{ id: 'video', label: '영상', count: countByKind.video || 0 },
{ id: 'audio', label: '음악', count: countByKind.audio || 0 },
{ id: 'file', label: '파일', count: countByKind.file || 0 }
]
})
const unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
const folder = activeFolder.value
const matchesFolder = activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
return matchesFolder && !isMediaItemLocked(item)
}).length)
/** /**
* 상단 탭 전환 시 목록 상태를 초기화한다. * 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'thumbnails'} tab - 선택 탭 * @param {'library' | 'thumbnails'} tab - 선택 탭
@@ -83,10 +117,31 @@ const setActiveTab = (tab) => {
activeTab.value = tab activeTab.value = tab
activeFolder.value = '' activeFolder.value = ''
searchText.value = '' searchText.value = ''
activeMediaKind.value = 'all'
showUnusedOnly.value = false
clearMediaSelection() clearMediaSelection()
closeMediaDetail() closeMediaDetail()
} }
/**
* 미디어 종류 필터를 선택한다.
* @param {'all'|'image'|'video'|'audio'|'file'} kind - 선택할 미디어 종류
* @returns {void}
*/
const setMediaKindFilter = (kind) => {
activeMediaKind.value = kind
clearMediaSelection()
}
/**
* 미사용 미디어만 보기 필터를 토글한다.
* @returns {void}
*/
const toggleUnusedMediaFilter = () => {
showUnusedOnly.value = !showUnusedOnly.value
clearMediaSelection()
}
/** /**
* ISO 시각을 짧은 로캘 문자열로 표시한다. * ISO 시각을 짧은 로캘 문자열로 표시한다.
* @param {string | null} iso - ISO 시각 * @param {string | null} iso - ISO 시각
@@ -164,6 +219,8 @@ const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts,
const filteredMediaItems = computed(() => { const filteredMediaItems = computed(() => {
const query = searchText.value.trim().toLowerCase() const query = searchText.value.trim().toLowerCase()
const folder = activeFolder.value const folder = activeFolder.value
const mediaKind = activeMediaKind.value
const unusedOnly = showUnusedOnly.value
const base = scopeItems.value const base = scopeItems.value
return base.filter((item) => { return base.filter((item) => {
@@ -175,8 +232,10 @@ const filteredMediaItems = computed(() => {
item.name, item.name,
...usageTitles ...usageTitles
].some((value) => String(value || '').toLowerCase().includes(query)) ].some((value) => String(value || '').toLowerCase().includes(query))
const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind
const matchesUsage = !unusedOnly || !isMediaItemLocked(item)
return matchesFolder && matchesQuery return matchesFolder && matchesQuery && matchesKind && matchesUsage
}) })
}) })
@@ -225,6 +284,8 @@ const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
*/ */
const selectFolder = (folder) => { const selectFolder = (folder) => {
activeFolder.value = folder activeFolder.value = folder
activeMediaKind.value = 'all'
showUnusedOnly.value = false
selectedMediaUrls.value = [] selectedMediaUrls.value = []
lastSelectedIndex.value = -1 lastSelectedIndex.value = -1
} }
@@ -583,6 +644,32 @@ const deleteMedia = async (item) => {
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'" :placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
> >
</div> </div>
<div
v-if="activeTab === 'library'"
class="admin-media__filters flex flex-wrap gap-1.5"
>
<button
v-for="option in mediaKindFilterOptions"
:key="option.id"
class="admin-media__kind-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="activeMediaKind === option.id ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
@click="setMediaKindFilter(option.id)"
>
{{ option.label }}
<span class="ml-1 opacity-70">{{ option.count }}</span>
</button>
<button
class="admin-media__unused-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="showUnusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
:aria-pressed="showUnusedOnly"
@click="toggleUnusedMediaFilter"
>
미사용
<span class="ml-1 opacity-70">{{ unusedMediaCount }}</span>
</button>
</div>
</div> </div>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]"> <div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
@@ -733,6 +820,11 @@ const deleteMedia = async (item) => {
:src="item.url" :src="item.url"
:alt="item.title" :alt="item.title"
> >
<AdminMediaVideoThumbnail
v-else-if="getMediaItemKind(item) === 'video'"
:src="item.url"
:alt="item.title"
/>
<span <span
v-else v-else
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted" class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"

View File

@@ -49,7 +49,7 @@ const setPostFeedStyle = (value) => {
const isPostFeedCards = computed(() => postFeedStyle.value === 'cards') const isPostFeedCards = computed(() => postFeedStyle.value === 'cards')
/** @type {import('vue').ComputedRef<boolean>} */ /** @type {import('vue').ComputedRef<boolean>} */
const showPostFeedMedia = computed(() => postFeedStyle.value === 'list' || postFeedStyle.value === 'cards') const showPostFeedMedia = computed(() => postFeedStyle.value === 'compact' || postFeedStyle.value === 'cards')
/** /**
* Latest 피드 컨테이너 클래스 * Latest 피드 컨테이너 클래스
@@ -335,9 +335,12 @@ const scrollFeatured = (direction) => {
> >
<div <div
v-else v-else
class="h-full w-full bg-[linear-gradient(135deg,#071b22,#5f6f85)]" class="post-card-media__placeholder flex h-full w-full items-center justify-center bg-[#F7F4EF] p-5 text-center text-sm font-medium leading-snug text-[var(--site-muted)] transition-all duration-200 group-hover:opacity-90"
/> :aria-label="post.title"
<h3 class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2"> >
<span class="post-card-media__placeholder-text line-clamp-4">{{ post.title }}</span>
</div>
<h3 v-if="post.featuredImage" class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
{{ post.title }} {{ post.title }}
</h3> </h3>
</NuxtLink> </NuxtLink>
@@ -345,7 +348,7 @@ const scrollFeatured = (direction) => {
</div> </div>
</section> </section>
<section class="py-4 px-6"> <section class="latest-posts-section min-h-[360px] py-4 px-6">
<div class="mx-auto max-w-[720px]"> <div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2"> <div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2> <h2 class="text-sm font-medium uppercase site-muted">Latest</h2>

View File

@@ -247,7 +247,7 @@ const goPreviousStep = () => {
<template> <template>
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]"> <section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24"> <div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24">
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]"> <div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
<div> <div>
<template v-if="currentStep === 1"> <template v-if="currentStep === 1">
<p class="text-[32px] font-semibold leading-tight sm:text-[40px]"> <p class="text-[32px] font-semibold leading-tight sm:text-[40px]">