236 Commits
v0.0.1 ... main

Author SHA1 Message Date
28d95129c2 게시물보내기 내비 아이콘 식별자 누락 수정 (v1.5.76)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:11:18 +09:00
60ca6e0930 관리자 사이트 설정 좌측 내비 아이콘 보강 (v1.5.76)
사이트 정보·SNS·POST·브랜드·사이트 코드·Ads·게시물보내기·가져오기 메뉴에 Material 아이콘을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 17:09:29 +09:00
ccb6db5f89 게시물 작성 통계 표시 추가 2026-06-05 16:54:41 +09:00
629ef8c4c6 게시물 광고 배치 조정 2026-06-05 16:32:29 +09:00
cc9e5949fa 게시물 인아티클 광고 슬롯 추가 2026-06-05 16:09:47 +09:00
5c93643949 메인 인피드 광고 슬롯 추가 2026-06-05 16:02:50 +09:00
9a4820e69c 사이트 광고 슬롯 설정 추가 2026-06-05 15:43:57 +09:00
928b8446b4 라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (v1.5.70)
Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 15:27:06 +09:00
4c0875446b 라이브 콜아웃 줄바꿈 보존 보강 2026-06-05 11:33:48 +09:00
16a12d304d 라이브 콜아웃 빈 줄 보존 수정 2026-06-05 11:09:33 +09:00
6daf9ca15e 라이브 블록 상단 이탈과 인용 해제 보정 2026-06-05 11:03:42 +09:00
56a2c23471 라이브 편집 인용과 멀티라인 입력 보정 2026-06-05 10:50:56 +09:00
09b6c51048 게시물 라이브 편집 블록 동작 개선 2026-06-05 10:38:00 +09:00
264f551cb4 작성 줄 삭제 단축키 복구 2026-06-04 15:54:37 +09:00
94226423c7 콜아웃 제목과 기본 아이콘 정리 2026-06-04 15:40:43 +09:00
67fbba3814 라이브 콜아웃 선택 정렬 보정 2026-06-04 15:29:45 +09:00
f048eaac2b 연속 콜아웃 편집 범위 보정 2026-06-04 15:18:57 +09:00
648ce5fbab 라이브 한글 Enter 중복 보정 2026-06-04 15:12:46 +09:00
35b9893eab 라이브 인용 콜아웃 입력 보정 2026-06-04 15:00:39 +09:00
675e6bca78 라이브 에디터 콜아웃 줄 삭제 수정 2026-06-04 11:09:40 +09:00
83f66a4b93 게시물 작성 상단 상태 링크 제거 2026-06-04 10:52:26 +09:00
accd933e99 RSS 피드 썸네일 정보 추가 2026-06-04 10:46:17 +09:00
24611af8b6 콜아웃 색상과 모바일 설정 패널 정리 2026-06-04 10:38:49 +09:00
b38fc9f154 인용 블록 색상과 라이브 설정 패널 정리 2026-06-04 10:28:43 +09:00
2cb1ff4651 사이트 설정 읽기 화면 정리 2026-06-02 18:59:10 +09:00
b5970c8ada RSS 피드 기능 추가 2026-06-02 18:02:31 +09:00
79009b21e6 SNS 직접 SVG 아이콘 정렬 수정 v1.5.42 2026-06-02 17:52:54 +09:00
21d01632be SNS 링크 저장 복구 v1.5.41 2026-06-02 17:47:15 +09:00
b3c7f26d10 SNS 아이콘 직접 설정 개선 v1.5.40 2026-06-02 17:22:38 +09:00
4da1ade2cf SNS 링크 설정 추가 v1.5.39 2026-06-02 17:06:02 +09:00
e3b8087b09 어나운스 바 설정 확장 v1.5.38 2026-06-02 16:31:30 +09:00
ba17e3aa18 블록 설정 패널 확장 v1.5.37 2026-06-02 16:13:38 +09:00
093d09c8bf 브랜드 컬러 설정 추가 v1.5.36 2026-06-02 15:39:08 +09:00
1bcd2f6898 관리자 유입 통계 추가 v1.5.35 2026-06-02 14:46:56 +09:00
5b78a8c92f 사이트 코드와 홈페이지 위젯 추가 v1.5.34 2026-06-02 14:21:47 +09:00
600b0fd1d9 내보내기 완료 배지 숨김 v1.5.33 2026-06-02 11:55:25 +09:00
1a670e237f 내보내기 선택 다운로드 정리 v1.5.32 2026-06-02 11:40:13 +09:00
0e2b701862 내보내기 작업 결과 카드 분리 v1.5.31 2026-06-02 11:29:00 +09:00
e2df9d55ac 설정 내보내기 가져오기 분리 v1.5.30 2026-06-02 11:03:41 +09:00
c2e69d9048 설정 화면 메인 커버 UI 정리 v1.5.29 2026-06-02 10:46:43 +09:00
9d91355c81 게시물 Import 호환성 보강 v1.5.28 2026-06-02 10:30:11 +09:00
ef1a9d9032 게시물 Export ZIP Import 추가 v1.5.27 2026-06-02 10:20:43 +09:00
5732a27498 설정 Import Export 영역 접기 정리 v1.5.26 2026-06-02 10:08:34 +09:00
212bd3f34f 게시물 Export 용량 기준 분할 추가 v1.5.25 2026-06-01 16:20:35 +09:00
5735fd5046 게시물 Export 일괄 다운로드와 재시도 추가 v1.5.24 2026-06-01 16:02:24 +09:00
a4c1b42369 게시물 Export 기간 선택과 삭제 추가 v1.5.23 2026-06-01 15:35:45 +09:00
f8621d49d8 게시물 Export ZIP 생성 연결 v1.5.22 2026-06-01 13:33:41 +09:00
7c8245c4e9 게시물 export 진행도 표시 추가 v1.5.21 2026-06-01 12:45:56 +09:00
11203ba251 게시물 export 작업 기반 추가 v1.5.20 2026-06-01 12:26:24 +09:00
abce690546 대용량 게시물 export 정책 정리 v1.5.19 2026-06-01 12:13:32 +09:00
052ce316df 게시물 백업 번들 방향 정리 v1.5.18 2026-06-01 12:06:54 +09:00
71046ed883 설정 토글 비활성 상태 정리 v1.5.17 2026-06-01 12:04:02 +09:00
dc50780ff8 게시글 목차와 댓글 등록 상태 정리 v1.5.16 2026-06-01 11:43:36 +09:00
edbbd3c83c 기본 사용자 아이콘 표시 정리 v1.5.15 2026-05-27 15:54:22 +09:00
ac57ff458d 미디어 선택 삭제와 모바일 목차 정리 v1.5.14 2026-05-27 15:43:27 +09:00
cb92b32f9c 게시글 목차 스크롤 위치 보정 v1.5.13 2026-05-27 11:34:09 +09:00
602106ac9d 게시글 상세 목차 사이드바 추가 v1.5.12 2026-05-27 11:29:33 +09:00
7f017a03a5 멤버 상세 수정 모드 정리 v1.5.11 2026-05-27 11:00:05 +09:00
8ca63c0d00 권한 UI와 글 목록 검색 보정 v1.5.10 2026-05-27 10:42:51 +09:00
fd9416c0e4 인기 페이지 통계와 추천 사이트 메타데이터 추가 v1.5.9 2026-05-27 10:34:07 +09:00
d7a3149ea1 소유자 권한 보호와 멤버 목록 등급 표시 v1.5.8 2026-05-27 10:15:34 +09:00
e78e09f3fd 페이지 형식 선택과 접속 IP 기록 수정 v1.5.7 2026-05-26 16:44:52 +09:00
a5ae2c3fce 멤버 등급 변경 권한 규칙 수정 v1.5.6 2026-05-26 16:31:56 +09:00
3843e16d9f VIP 멤버십 공개 범위 적용 v1.5.5 2026-05-26 16:22:05 +09:00
6333c4254f 게시물과 페이지 공개 상태 확장 v1.5.4 2026-05-26 16:07:10 +09:00
b989193dab 페이지 HTML 작성 기본값과 자산 업로드 개선 v1.5.3 2026-05-26 11:36:01 +09:00
62ceaa3591 페이지 작성 화면을 게시글 작성 화면과 통일 v1.5.2 2026-05-26 11:18:44 +09:00
a25306389b 고정 페이지 HTML 문서 모드 추가 v1.5.1 2026-05-26 11:03:33 +09:00
0ad2ab3f9d 관리자 태그와 목록 메뉴 개선 v1.5.0 2026-05-26 10:56:57 +09:00
6536465b12 v1.4.7: 라이브 인라인 서식·인용 배경·소스→라이브 스크롤 보정
- 라이브 모드 blur 시 인라인 마크다운(**·*)이 사라지던 문제 수정
- 인용 블록에 > [!bg=색상] 옵션으로 콜아웃과 동일한 배경 프리셋 지정
- 소스 모드에서 라이브 전환 시 현재 커서 줄을 화면 중앙에 가깝게 스크롤
2026-05-26 10:07:01 +09:00
dcd1060ec7 v1.4.6: 사이트 설정 이미지 저장 흐름·홈 커버 라이트/다크 분리
- 로고 업로드는 파일 URL만 폼에 반영하고 기타 설정 저장 시 DB에 반영
- 메인 화면 커버 라이트·다크 이미지 필드 추가 및 테마별 HomeHero 교체
- home_cover_dark_image_url 마이그레이션 및 미디어 사용 현황 보정
2026-05-22 17:05:34 +09:00
38ca3a4709 v1.4.5: 게시물 작성자·편집 링크·목록 요약 보정
- posts.author_id 마이그레이션 및 owner/admin 단일 계정 환경에서만 기존 글 backfill
- 공개 상세: 글쓴이 본인일 때만 공유 옆 수정 링크 표시, 수정 시각 제거
- 목록 요약: excerpt 없을 때 본문 fallback, post-summary-clamp로 말줄임 처리
- 회원 세션 API에 isAdmin·role 추가
2026-05-22 14:43:22 +09:00
8f53210756 v1.4.4: 메인 화면 커버 미리보기·오버레이 줄바꿈 수정
- 설정 미리보기를 HomeHero와 동일한 오버레이로 표시하고 편집 모드에서 크게 보이도록 조정
- 오버레이 본문 줄바꿈이 홈·미리보기에서 보이도록 whitespace-pre-line 적용
2026-05-21 19:01:17 +09:00
9e5728074a docs: 백업/복구 시스템 다음달 TODO 추가
운영 백업·복구 설계 및 구현을 배포 할 일에 등록하고 업데이트 이력에 반영한다.
2026-05-21 18:41:39 +09:00
10c5a099fc v1.4.3: 관리자 UI·홈·미디어 개선
- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬
- 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정
- 미디어 종류·미사용 필터, 비디오 프레임 썸네일
- NAS 운영 업데이트 절차 문서 추가
2026-05-21 18:30:50 +09:00
6919669330 v1.4.2: 라이브 이미지·갤러리 편집 UX와 공개 화면 색상 정리
라이브 모드 이미지·갤러리 드래그 병합·분리, 갤러리 개별 편집, 블록 패널 유지, 다크모드 인용·사이드바·리스트 마커 색상을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 17:07:52 +09:00
095a8fa5f0 v1.4.1: 관리자 미디어 업로드 한도·라이브 에디터 UX 개선
종류별 업로드 크기 한도와 413 안내를 추가하고, 임베드·미디어 라이브 프리뷰·제목 Enter 포커스·스크롤 동작을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:33:23 +09:00
f8e04003fd v1.3.9: NAS 마이그레이션 루프 stdin 소비 버그 수정
psql이 파이프 입력을 읽어 baseline·migrate가 첫 파일만 처리되던 문제를 /dev/null 연결과 for 루프로 해결한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:49:59 +09:00
a396d1d022 v1.3.8: NAS 마이그레이션 환경 파일 없을 때 보정
.env.production이 없으면 .env 또는 실행 중 DB 컨테이너 환경 변수로 migrate-production-db.sh가 동작한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:44:43 +09:00
cc34db40f2 v1.3.7: NAS용 마이그레이션 셸 명령 추가
운영 호스트에 npm이 없어도 Docker Compose와 DB 컨테이너 psql만으로 상태 확인, baseline, 미적용 SQL 실행을 처리한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:39:44 +09:00
0e70d4482d v1.3.6: 운영 DB 마이그레이션 적용 이력 및 NAS 명령 추가
schema_migrations로 적용 파일을 추적하고, 기존 운영 DB는 001부터 자동 실행하지 않도록 baseline 흐름을 둔다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 14:33:13 +09:00
c43873ce5f v1.3.5: 관리자 로그인·대시보드 차트·통계 보관 정리
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 13:54:38 +09:00
abb77dbb4d v1.3.4: 통계 확장(체류·스크롤·실시간 접속자)
- 031 마이그레이션: 체류·스크롤 집계, analytics_active_sessions
- heartbeat API, 관리자 realtime API, 클라이언트 heartbeat
- 대시보드: 현재 접속자 목록(로그인 닉네임·아바타), 참여 지표
2026-05-20 12:26:39 +09:00
3623305119 v1.3.3: 자체 최소 통계 및 스플래시 localStorage 정리
- 일별 익명 방문자 해시·사이트/게시물 통계(030 마이그레이션)
- POST /api/analytics/pageview, 관리자 analytics API, 클라이언트 트래커
- 관리자 대시보드 통계 카드·인기 게시물 Top 5
- 스플래시: SITE_BRAND_LOGO_TEXT localStorage 제거
2026-05-20 12:15:13 +09:00
b6a3228b09 v1.3.2: 어나운스 바 슬라이드·설정 내비 아이콘
어나운스 바는 숨김 확인 후 슬라이드 인/아웃하고 7일간 보지 않기를 지원한다. 설정 좌측 내비에 타임존·메인 화면·어나운스·Import/Export·스팸 필터 아이콘을 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 16:23:20 +09:00
b77f37a94e v1.3.1: 어나운스 바·가입 금지 닉네임·설정 UI 개선
공개 상단 어나운스 바와 관리자 맞춤 설정을 추가하고, 스팸 필터에서 가입 금지 닉네임을 관리·검증한다. POST 설정 읽기 모드 비활성 토글과 설정 내비 아이콘 틀을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 15:50:47 +09:00
02d33996c5 관리자 목록 more vert 메뉴 통일 및 태그 메뉴 정렬 수정
AdminRowMoreMenu 공통 컴포넌트로 글·태그·페이지·미디어·네비게이션 행 액션을 ⋮ 팝오버로 통일.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:14:28 +09:00
797a6dd5a0 테마 깜빡임·로딩 스플래시 및 메인 커버 저장 흐름 수정
head 인라인 스크립트로 data-theme 선적용, 로고 캐시 스플래시 추가.
메인 커버는 업로드 후 저장 버튼에서 이미지·텍스트 일괄 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 18:44:17 +09:00
3fb8a40031 v1.2.9: 라이브 에디터·홈 피드·메인 커버 개선
라이브 모드 코드/콜아웃/토글 편집, 슬래시 명령, 홈 Latest List·Compact·Cards 보기,
사이트 설정 메인 화면 커버(720px) 및 HomeHero 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 16:57:30 +09:00
666bd304fc v1.2.8: 라이브 모드 인라인 편집 및 목록·인용 동작 개선
관리자 미리보기에서 문단·목록·인용을 contenteditable로 편집하고, Cmd+E 전환·사용자 지정 순서 번호·줄 삭제·화살표 줄 이동을 지원한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 12:01:11 +09:00
0c051cbe3b v1.2.5: 갤러리 드롭 위치 표시 및 파일명 캡션 토글 정리
미리보기 갤러리 드래그 시 드롭 대상 셀을 시각적으로 표시하고, 파일명 토글을 캡션(figcaption) 표시로 맞춤. 미리보기 클릭→작성 모드 전환은 제거.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 19:00:26 +09:00
c9b484e4c8 v1.2.4: 이미지 캡션 표시 수정 및 미리보기 갤러리 드래그 정렬
파일명 alt와 캡션을 분리해 공개·미리보기에 캡션이 보이도록 하고, 관리자 미리보기에서 갤러리 순서를 드래그로 바꿀 수 있게 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:44:56 +09:00
a867269d9b v1.2.3: 마크다운 에디터 외부 스크롤 및 줄 번호 정렬
textarea 내부 스크롤을 없애고 본문 높이를 자동으로 늘려, 글 편집 영역 스크롤과 줄 번호가 어긋나지 않도록 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:34:38 +09:00
c474a8b9a3 v1.2.2: 이미지 파일명 alt 판별 및 미리보기 캡션 분리 수정
대괄호 내용이 URL 파일명과 일치할 때만 useAlt로 처리해, 캡션과 대체 텍스트가 미리보기에서 혼동되지 않도록 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:31:25 +09:00
47620ab24c v1.2.1: 블록 설정 패널·이미지 alt 토글 및 포커스 수정
게시물 설정 사이드바 오버레이로 이미지·갤러리·임베드를 편집하고, 파일명 alt 토글과 패널 입력 중 닫힘 문제를 해결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 18:22:30 +09:00
14ce897bf8 v1.2.0: 관리자 글 목록·슬러그·예약 시각 UX 정리
발행일 기준 목록 정렬, 추천 필터·별 표시, 슬러그 자동/수동 구분, 예약 날짜·시간 클릭 영역 수정.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 17:48:19 +09:00
6fd61911fd 관리자 UX·본문 스타일 개선 및 발행일 보존(v1.1.19)
글쓰기 헤더 모드 전환·미디어 검색, Update 시 발행일 유지, 설정 카드 편집/저장 분리, 인용·인라인 코드 스타일 반영.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 17:15:15 +09:00
2074b0b93a 관리자 글쓰기·목록 UX 개선 및 POST 설정 추가(v1.1.14~v1.1.18)
Ghost형 툴바·초안 자동 저장·발행 모달, private 제거, 미디어 모달 통합,
발행일·수정일 표시 설정과 DB 마이그레이션 025·026을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 16:26:48 +09:00
ca1e17890b 메뉴 관리 개편, 추천 사이트·1뎁스 네비, 우측 Recommended 연동(v1.1.13)
상단 네비는 평면 테이블·드래그로 편집하고 한 단계 하위만 허용한다.
추천 사이트 탭·location recommended·공개 API와 우측 사이드 카드·파비콘 URL 유틸을 추가한다.
문서·배포 마이그레이션 안내·관리자 레이아웃·설정 화면 등 누적 변경을 반영한다.
2026-05-15 14:20:27 +09:00
2768975752 게시물 추천과 관리자 목록 필터 정리 2026-05-15 11:49:12 +09:00
59a50a0c97 태그 관리 자동 저장 정리 2026-05-15 11:21:57 +09:00
b4e4e37f5a 사이트 로고 캐시 갱신 보강 2026-05-15 11:00:48 +09:00
536ee7079e 일반 태그 배지 목록 정리 2026-05-15 10:50:25 +09:00
9e544d97fa 운영 업로드 파일 제공 경로 보강 2026-05-15 10:27:25 +09:00
20b901d4a1 관리자 멤버 썸네일 업로드 경로 수정 2026-05-15 10:11:02 +09:00
0ed848a2eb 사이드바 행 호버를 #F7F4EF로 완화하고 v1.1.3으로 갱신
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 18:51:27 +09:00
08f0aa0efa 태그 없는 게시물에서 POST 더미 표시 제거(v1.1.2) 2026-05-14 18:42:01 +09:00
17dcd04339 공개 문단에서 leading-7 제거, text-base만 유지(v1.1.1) 2026-05-14 17:56:05 +09:00
36625de1eb 타이포 조정 및 v1.1.0
관리자 제목 text-3xl, 공개 문단 text-base·leading-7, ProseHeading mt-12 제거.
문서·맵·명세 반영.
2026-05-14 17:52:34 +09:00
62e501f8d0 수정 모드 줄바꿈 표식 개선 2026-05-14 17:09:10 +09:00
849e86802f 미리보기 집중 모드와 빈 줄 공백 보존 2026-05-14 17:01:04 +09:00
5da93b9aa4 글쓰기 문단 입력과 편집 영역 정리 2026-05-14 16:51:30 +09:00
113c974ee5 본문 문단과 줄바꿈 처리 정리 2026-05-14 16:33:30 +09:00
f5bfb560e2 본문 빈 줄 간격 보존 2026-05-14 16:24:40 +09:00
941355cae9 레거시 본문 저장 형식 정규화 2026-05-14 16:12:22 +09:00
91b7369a07 마크다운 에디터 붙여넣기와 미디어 편집 개선 2026-05-14 16:03:12 +09:00
5eb6c88381 AdminMarkdownEditor: 논리 줄 번호 거터·현재 줄 강조(v1.0.12)
textarea 왼쪽 거터, 스크롤 동기화, 미리보기 문구 오타 수정.
명세·맵·이력 반영.
2026-05-14 15:49:44 +09:00
eab81697e5 관리자 에디터를 마크다운 우선 방식으로 개편 2026-05-14 15:12:23 +09:00
88a0860078 관리자 블록 에디터를 태그 v1.0.5 시점으로 복원(v1.0.10)
v1.0.6 이후 붙여넣기 분할·Cmd+A MD 복사·블록 범위 선택 등 제거.
명세·맵·이력·업데이트 동기화.
2026-05-14 14:53:55 +09:00
35c378c8f5 블록 범위 레인 드래그: 행 간 margin에서도 인덱스 추적(v1.0.9)
elementFromPoint 실패 시 루트 내 행 박스와 clientY 거리로 스냅.
레인 히트 폭 소폭 확대. 문서 반영.
2026-05-14 14:49:08 +09:00
bd0e2ad120 관리자 블록 에디터 범위 선택 보완 및 복사 시 네이티브 우선(v1.0.8)
블록 범위가 있어도 contenteditable 비접힘 선택·textarea/input 선택 시 copy 가로채기 생략.
문서·버전 v1.0.8 반영.
2026-05-14 14:42:08 +09:00
1b035de16c Docker 런타임 환경 변수 우선 적용 2026-05-14 13:48:23 +09:00
4862b52b3a 관리자 부트스트랩 복구 보강 2026-05-14 13:36:51 +09:00
6367e62ef0 관리자 최초 로그인 보강 2026-05-14 12:39:55 +09:00
1487e9da76 Docker 네트워크 충돌 대응 2026-05-14 12:26:10 +09:00
3b331b8fe6 운영 시작 버전 v1.0.0 정리 2026-05-14 10:49:25 +09:00
069d1bfbd4 게시글 저장 버튼과 태그 삭제 아이콘 정리 2026-05-13 16:35:59 +09:00
965a8fd1f6 갤러리 선택과 드래그 순서 개선 2026-05-13 16:12:51 +09:00
020471a1b8 게시글 제목 입력과 태그 표시 수정 2026-05-13 15:58:22 +09:00
52f22b4ff1 사이트 설정 로고와 사용자 설정 레이아웃 정리 2026-05-13 15:42:03 +09:00
bebf7ee1c9 관리자와 회원 설정 계정 작업 정리 2026-05-13 15:26:26 +09:00
6481f958f5 멤버 필터와 썸네일 편집 개선 2026-05-13 11:43:38 +09:00
79d0a30475 미저장 변경 이탈 확인 추가 2026-05-13 11:29:11 +09:00
fb0dadb7b9 관리자 멤버 상세와 추가 화면 구현 2026-05-13 11:18:06 +09:00
b4f3fdb77d 관리자 멤버 목록 테이블 정리 2026-05-13 10:48:11 +09:00
6cb6268b43 관리자 사이드바 하단 사용자 메뉴 정리 2026-05-13 10:38:10 +09:00
b490d5b90f 관리자 레이아웃과 네비게이션 정리 2026-05-13 10:23:18 +09:00
ec9f9ea57f docs/map: 메뉴 관리 화면 설명 동기화 (v0.0.103)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 15:46:29 +09:00
6e25cdfd60 블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (v0.0.102)
- 빈 문단 마커 직렬화·공개 렌더 파싱
- 슬래시 메뉴 스크롤·하이라이트·블록 간 이동
- 헤더 검색 버튼 min-width, 네비 관리 안내 문구 정리

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 15:45:48 +09:00
5031b9de22 공개 primary 네비 트리 중복 방지 (v0.0.101)
- 동일 id 중복 제거, 자식으로 붙은 항목은 루트에서 제외
- spec·history·update 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 15:45:17 +09:00
003fb86fad EMAIL_OTP_PEPPER 역할·권장값 문서 보강 (v0.0.100)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:12:18 +09:00
6a059a9a59 헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 13:34:21 +09:00
996965740f 사이드바 푸터 링크 줄바꿈·상단 네비 호버 행 전체 너비 (v0.0.98)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 12:33:10 +09:00
4e1056311d 사이드바 상단 네비: 부모 행 통합 토글·chevron·높이 애니메이션 (v0.0.96)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 12:16:50 +09:00
8b8a80034d 메뉴 관리: parent_id 오류 안내·표시/폴더 제거·태그형 드래그 UX (v0.0.95)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:53:52 +09:00
bcff96aa4c 메뉴 관리: 탭 분리·드래그 정렬·상단 트리·공개 접기 (v0.0.94)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:17:00 +09:00
4de5589bcb 관리자 미디어 오류 피드백을 useAdminToast 토스트로 통일 (v0.0.93)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 11:03:23 +09:00
c1242e1409 프로필 썸네일 해제 시 메타 분리 통일·미디어 모달 다운로드(v0.0.92)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 10:56:26 +09:00
16bb9370fa 썸네일 미참조 삭제 허용·원본명 업로드·미디어 검색 정리(v0.0.91)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 10:52:57 +09:00
21024602b0 관리자 미디어 라이브러리·썸네일 탭 분리 및 논리 폴더 정책(v0.0.90)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 10:40:43 +09:00
05176609ee v0.0.89: 미디어 선택 토글 가시성, posts·미분류·썸네일 경로 명세 2026-05-12 10:26:24 +09:00
9974e0d137 v0.0.88: 미디어 선택·미리보기 분리, 폴더 모달·삭제 API 2026-05-12 10:19:37 +09:00
1d9a3e4527 v0.0.87: 저장·로그인 버튼 비활성 기본, 글 목록 삭제 아이콘, 푸시 지침 2026-05-12 10:08:18 +09:00
79fb354d91 v0.0.86: 미리보기 패딩, 태그 한글 유지, SEO 자동, 태그 관리 토스트 2026-05-12 09:56:52 +09:00
bd71ca860c 태그 관리 화면을 메인/일반 전환 중심으로 단순화하고 삭제 동선을 재정리.
글쓰기 Post URL 슬러그는 한글 입력 시 발음 기반 영문 소문자로 자동 생성되도록 개선.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:50:40 +09:00
cdc16c72b2 태그를 관리용/일반용으로 분리하고 관리자 드래그 정렬을 추가.
댓글/회원/관리자 인증·프로필 흐름 보완과 관련 마이그레이션 및 문서를 함께 반영해 운영 동선을 안정화.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 18:34:23 +09:00
b18aca4dcc fix(media): 회원 썸네일을 관리자 미디어 폴더에서 다시 노출
회원 썸네일 경로 필터를 제거해 관리자 미디어의 회원/썸네일 카테고리에서 업로드 결과를 확인할 수 있게 하고, 설정 프로필 썸네일 UI 개편 및 문서 버전 업데이트를 함께 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:40:32 +09:00
080f76799a feat(settings): 회원 썸네일을 미리보기 중심 UI로 개편
설정 화면에서 썸네일 URL 텍스트 노출을 제거하고 아바타 미리보기와 이미지 변경/제거 액션을 중심으로 재구성해 계정 정보 확인 흐름을 직관적으로 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:35:12 +09:00
a314c96c4d feat(member): 회원 썸네일 중앙 1대1 크롭 강제
아바타 업로드 시 원본 비율과 무관하게 중앙 기준 정사각형으로 크롭해 헤더와 설정 화면에서 일관된 1:1 썸네일이 노출되도록 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:29:51 +09:00
ede272e7b1 feat(member): 회원 썸네일 최소 해상도와 설정 보정 추가
아바타 업로드 시 최소 해상도 조건을 검증하고 리사이즈/품질 설정값을 안전 범위로 보정해 운영 설정 오입력에도 안정적으로 동작하도록 개선한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:27:47 +09:00
65af30724c feat(member): 회원 썸네일 업로드를 WebP 리사이즈로 표준화
회원 아바타 업로드 시 원본 포맷을 WebP로 변환하고 최대 해상도/품질을 환경변수로 제어해 저장 용량과 전송 비용을 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:24:29 +09:00
eab800b6c1 feat(member): 썸네일 교체/삭제 시 자산 자동 정리 추가
회원 아바타를 교체·삭제·탈퇴하는 흐름에서 이전 썸네일 파일과 메타데이터가 남지 않도록 공통 정리 로직을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:20:50 +09:00
6e8ca97779 feat(member): 회원 썸네일 업로드를 작가 미디어와 분리
회원 아바타 자산을 전용 경로로 분리해 작가용 미디어 목록과 섞이지 않게 하고, 설정 화면에서 파일 업로드로 바로 반영할 수 있게 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:16:21 +09:00
f5cd73b223 feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가
로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:10:48 +09:00
91573a31d6 fix(layout): 공개 페이지 2중 패딩 제거
- 레이아웃 그리드의 px만 남기고 메인/섹션의 중복 px를 제거.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:38:18 +09:00
2cb5c1a281 fix(post): 상세 섹션 패딩 중복 제거
- 레이아웃 그리드 패딩과 섹션 px 중복을 제거하고, 댓글 구분선은 full-bleed로 표시.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:34:01 +09:00
1b00dac21c fix(search): 업로드 파일명 매칭 제거
- 본문 검색에서 /uploads 경로와 마크다운 이미지 토큰을 제거해 파일명/해시 노이즈를 제외.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:26:21 +09:00
ff6526c997 feat(search): / 단축키 검색 모달 및 통합 검색 API 추가
- / 및 헤더 검색 클릭으로 모달을 열고 태그·게시물 검색을 제공.
- 태그 검색 범위를 name/slug로 제한하고 IME 조합 입력 대응을 보강.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:12:31 +09:00
bcf3acd432 fix(build): Tailwind 엔트리를 main.css로 단일화
- tailwindcss.cssPath로 패키지 tailwind.css 중복 주입 방지
- tailwind content에 composables·modules·plugins 추가
- v0.0.63 문서 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 14:39:43 +09:00
5141a63294 fix(auth): 다크 폼 입력·비밀번호 토글 스타일 보정
- .auth-form-input 전역 클래스(글자색·캐럿·placeholder·autofill)
- 토글 버튼 scoped CSS로 고정, signup 패널 보더·배경·color-scheme
- v0.0.62 문서 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 13:50:12 +09:00
3f7f51ff86 feat(auth): 비밀번호 표시 토글을 SVG 아이콘으로 통일
- AuthPasswordVisibilityToggle 공통 컴포넌트 추가
- signin·signup(비밀번호·확인)에 적용, 접근성 field-name 구분
- v0.0.61 문서 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:37:30 +09:00
fd55d8af08 feat(home): Featured 모바일 터치 스크롤·화살표 끝 비활성
- 트랙에 touch-pan-x·webkit 가로 스크롤·overscroll-x-contain 적용
- scroll·ResizeObserver로 이전/다음 disabled 동기화
- v0.0.60 문서 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:29:27 +09:00
ed7709ab59 fix(nuxt): Node용 #internal/nuxt/paths 해석 및 paths.mjs 디스크 기록
- app:templates에서 paths.mjs에 write: true를 부여하는 로컬 모듈 추가
- 루트 package.json imports로 .nuxt/paths.mjs 매핑
- nuxt ^3.21.2로 명시, 문서(v0.0.59) 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:25:19 +09:00
2f7ce64391 v0.0.58: 메인·우측 사이드 간격 및 가로 넘침 수정
그리드 중앙을 1fr로 두고 column-gap을 적용하며, site-main 고정 720px 제거로 패딩·gap이 있을 때 본문이 오른쪽으로 삐져나가지 않게 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 12:08:42 +09:00
8daec2806b v0.0.57: 사이드바 하단 푸터 여백 보정
좌측 사이드 푸터 px-1을 px-4·sm:px-5로 올려 링크·테마 토글이 가장자리에 붙지 않게 하고, 우측 카피라이트 푸터에 pr-3을 추가했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:50:01 +09:00
e769595c5d v0.0.56: lg 구간 헤더 간격·검색창 반응형 폭
약 1024~1280px에서 검색창 고정 폭과 lg:px-0 때문에 헤더 요소가 밀집되던 문제를 패딩·gap·max-w 조정으로 완화하고, 본문 그리드 패딩을 헤더와 맞췄다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:42:42 +09:00
94a37f451d v0.0.55: 모바일 슬라이드 메뉴·우측 사이드 하단 배치
lg 미만에서 좌측 내비를 오버레이 슬라이드로 전환하고, 본문 아래에 우측 사이드를 두며 헤더·패널 여백을 보정했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:38:03 +09:00
3916bcb284 v0.0.54: 사용자 인증 화면 UX 보정
회원가입/로그인 공개 화면의 모바일 가독성과 입력 피드백을 다듬고, 비밀번호 보기 토글과 상태 메시지 분리로 인증 전환 흐름을 명확히 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:29:09 +09:00
f3f971ab1b v0.0.53: 공유 모달·헤더 사용자 메뉴·회원가입·로그인 화면
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 11:09:26 +09:00
add0fa51c0 v0.0.52: Featured 정렬·상세 메타 구분자
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:28:58 +09:00
4b1ab9e00e v0.0.51: 사이드바 열 높이 고정·발행일 YYYY.MM.DD 통일
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:18:47 +09:00
3cb1290711 v0.0.50 문서 스크롤로 통일하고 사이드바 스티키·무스크롤바
중앙 main 단독 스크롤을 제거하고 sticky 사이드+숨김 스크롤바로 Thred에 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 10:04:12 +09:00
4704748582 v0.0.49 데스크톱 3단 스크롤 — 사이드 푸터 고정
그리드 높이를 뷰포트에 맞추고 중앙만 스크롤해 좌우 사이드 하단이 항상 보이도록 한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 09:59:35 +09:00
082c6a9619 v0.0.48 Thred형 북마크·회원가입 카드와 X 임베드 보강
북마크·뉴스레터 CTA 마크다운 블록과 컴포넌트를 추가하고, Twitter/X URL은 공식 embed iframe으로 렌더링한다.
Callout 강조선과 이미지 캡션 색을 테마 변수에 맞춘다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 09:47:49 +09:00
5f2b2b8c4f v0.0.47 공개 본문 스타일 가이드 기반 정의
Ordered list, 멀티라인/대체 인용구 문법을 추가하고 Prose 컴포넌트(리스트/인용/이미지/카드/임베드) 기본 스타일을 Thred 톤으로 통일했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:59:12 +09:00
41406ca852 v0.0.46 공개 화면 피드/포스트 UI 정리
Latest 보기 방식 토글과 아이콘을 SVG 기반으로 정리하고, 게시물 상세 헤더를 Thred 패턴으로 재구성했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:51:16 +09:00
a439af5b62 홈 Featured 슬라이드 폭을 원본 비율 기준으로 세부 조정.
브레이크포인트별 카드 노출 비율(1.4/1.6/2.6)에 맞춘 폭 계산식을 적용하고, 좌우 이동량도 실제 카드 폭 기준으로 계산해 슬라이드 이동 감각을 원본에 가깝게 보정했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:16:20 +09:00
e9161676e9 홈 Featured 영역을 가로 슬라이드 트랙으로 전환.
원본 패턴에 맞춰 Featured를 3열 그리드에서 가로 스크롤 슬라이드 구조로 바꾸고, 좌우 버튼으로 트랙 이동이 가능하도록 상호작용을 추가했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:15:12 +09:00
9363c10451 카드 우하단 액션 화살표를 SVG 아이콘으로 교체.
홈 Latest와 태그 상세 목록의 hover 액션 화살표를 텍스트 기호 대신 원본 패턴에 가까운 SVG 아이콘으로 바꿔 시각 일관성을 보정했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:13:22 +09:00
1a4086336f 카드 hover 우하단 액션 화살표를 홈과 태그 상세에 추가.
원본 패턴에 맞춰 목록 카드의 우하단 hover 액션 버튼을 홈 Latest와 태그 상세 목록에 동일하게 반영해 상호작용 피드백을 보강했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:11:36 +09:00
5e485eb3ec 홈 중앙 메인 영역을 Thred 간격 기준으로 재구성.
Hero/Featured/Latest 섹션을 내부 컨테이너 기준 보더 정렬로 바꾸고, Latest 목록 카드를 원본 패턴의 리스트 메타 구조로 정리해 중앙 메인 영역의 시각 리듬을 맞췄다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:09:54 +09:00
4554801294 태그 배지 구분자와 우측 Follow 아이콘을 원본 패턴으로 보정.
태그 상세 메타에서 복수 태그 글은 첫 태그만 배지로 표시하고 구분자 겹침을 제거했으며, 우측 사이드바 Follow 영역을 소셜 아이콘 링크 행으로 교체해 시각 구성을 정리했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:03:56 +09:00
34314a5c7d 태그 상세 페이지 메타 표현을 원본 패턴으로 세부 보정.
featured 강조, 태그 컬러 배지, 메타 구분자 스타일을 정리해 tag 상세 게시물 리스트의 시각 밀도를 원본 Thred 화면에 가깝게 맞췄다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 18:00:53 +09:00
4d7aaa90ca 태그 상세 페이지 레이아웃을 Thred 스타일로 재구성.
상단 헤더 간격과 본문 리스트형 게시물 카드를 원본 구조에 맞춰 정리하고, 썸네일·타이포·메타 배치를 통일해 tag 상세 화면의 시각 흐름을 보정했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 17:59:14 +09:00
59ea51e550 태그 목록 페이지를 Thred 카드 레이아웃으로 재구성.
원본 구조에 맞춰 중앙 히어로와 3열 태그 카드를 적용하고, 태그 컬러 보더와 hover 오버레이/화살표 인터랙션을 반영해 사용자 화면의 시각 일관성을 맞췄다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 17:56:32 +09:00
d47134c46d 사용자 화면 사이드바 스타일을 Thred 기준으로 정렬.
좌측 네비게이션과 카테고리의 간격 및 hover 인터랙션을 원본 패턴에 맞게 보정하고, 테마 전환/사이드바 전환 애니메이션과 샘플 폴더 Git 제외 설정을 함께 반영해 사용자 화면 일관성을 높였다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 17:52:18 +09:00
97d2d8ffb3 대표 이미지 선택 흐름 정리 2026-05-07 16:02:50 +09:00
f757c3db78 글 설정 태그와 대표 이미지 흐름 정리 2026-05-07 15:55:20 +09:00
0f60039126 블록 메뉴와 드래그 이동 안정화 2026-05-07 15:31:57 +09:00
4e5ccb2726 글쓰기 스크롤과 블록 드롭 피드백 보정 2026-05-07 15:22:50 +09:00
38f8abb1ff 블록 에디터 줄바꿈과 핸들 표시 보정 2026-05-07 15:14:32 +09:00
5bda4d5472 글쓰기 에디터 문단 처리와 설정 패널 액션 보정 2026-05-07 15:02:41 +09:00
398877fd92 블록 에디터 한글 입력과 코드 블록 보정 2026-05-07 11:06:36 +09:00
60c5c3d5c9 할 일 문서 정리 항목 반영 2026-05-07 10:54:10 +09:00
9054c9625c 관리자 글쓰기 전체 화면 레이아웃 보정 2026-05-07 10:53:08 +09:00
1ef50c111b 관리자 글쓰기 화면과 개발 환경 문서 정리 2026-05-07 10:36:01 +09:00
e506a343bc 게시물 미리보기 기능 추가 2026-05-03 10:18:22 +09:00
8c5ccc94ec 게시물 OG 이미지 설정 추가 2026-05-03 10:10:09 +09:00
fc5f41b9cc 게시물 SEO 설정 추가 2026-05-03 10:03:53 +09:00
60f9fd52f0 예약 발행 기능 추가 2026-05-03 09:58:27 +09:00
db87542096 미디어 폴더 트리 관리 추가 2026-05-02 20:35:28 +09:00
dd0a643d73 미디어 카테고리 관리 추가 2026-05-02 17:56:00 +09:00
04b8a7006a 메뉴 관리 기능 추가 2026-05-02 16:45:52 +09:00
27cf05aba6 사이트 설정 관리 추가 2026-05-02 16:37:11 +09:00
d5666fdcc3 관리자 페이지 관리 추가 2026-05-02 16:24:57 +09:00
792460b27a 글쓰기 입력 흐름과 저장 피드백 보정 2026-05-02 10:45:44 +09:00
722e027f18 글쓰기 로컬 자동 저장 추가 2026-05-02 10:35:28 +09:00
6bc697bd95 글쓰기 확장 블록 추가 2026-05-02 10:31:17 +09:00
77191ef7da 글쓰기 입력과 썸네일 표시 보정 2026-05-02 10:18:35 +09:00
f3db10f015 공개 상세 경로와 새 글 에디터 보정 2026-05-02 10:02:50 +09:00
a7fcd7dce5 대표 이미지와 미디어 화면 개선 2026-05-02 09:45:37 +09:00
e1254c6b5f 미디어 사용 현황 표시 추가 2026-05-01 23:49:47 +09:00
bc531f81db 관리자 미디어 라이브러리 기본 기능 추가 2026-05-01 23:42:03 +09:00
83ac51fd11 관리자 이미지와 갤러리 블록 추가 2026-05-01 23:29:12 +09:00
18ca11f9bb 개발 서버 실행 로그 정리 2026-05-01 23:19:21 +09:00
49de0e277c 관리자 제목 입력 흐름 보정 2026-05-01 23:11:51 +09:00
3afef9d0d2 관리자 블록 에디터 키보드 흐름 보정 2026-05-01 23:04:50 +09:00
c2b3e3a204 관리자 블록 에디터 입력 안정화 2026-05-01 18:28:26 +09:00
10bf6b422e 관리자 블록형 글쓰기 추가 2026-05-01 18:17:12 +09:00
0fd18bfb48 관리자 마크다운 미리보기 추가 2026-05-01 18:07:36 +09:00
787747aa7f 관리자 기능과 태그 표시 설정 추가 2026-05-01 18:00:22 +09:00
237eb2990f 환경 변수 예시 정리 2026-04-29 15:30:56 +09:00
5ee6fcd54b PostgreSQL 데이터 계층 추가 2026-04-29 15:22:54 +09:00
cbf5ed6c8c 사이드바 메뉴 토글 추가 2026-04-29 15:08:04 +09:00
a3acd9320a 공개 화면 테마와 패널 구조 보정 2026-04-29 15:01:59 +09:00
37f6c38caa Nuxt 초기 세팅 추가 2026-04-29 14:54:44 +09:00
324 changed files with 71514 additions and 125 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
.nuxt
.output
dist
coverage
.git
.env
.env.development
.env.production
*.log

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# Database
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
DATABASE_NAME=sori_studio
POSTGRES_DB=sori_studio
POSTGRES_USER=sori_studio
POSTGRES_PASSWORD=replace-with-random-password
DB_PORT=43119
# Auth
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-random-password
MEMBER_SESSION_SECRET=replace-with-random-password
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
MAX_VIDEO_FILE_SIZE=209715200
MAX_AUDIO_FILE_SIZE=52428800
MAX_DOCUMENT_FILE_SIZE=52428800
POST_EXPORT_MAX_FILE_SIZE_BYTES=524288000
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512
AVATAR_MAX_HEIGHT=512
AVATAR_WEBP_QUALITY=82
# Site
NUXT_PUBLIC_SITE_URL=https://sori.studio
NUXT_PUBLIC_SITE_TITLE=sori.studio
# Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기)
# RESEND_API_KEY=
# RESEND_FROM_EMAIL=noreply@yourdomain.com
# EMAIL_OTP_PEPPER= ← 선택. OTP를 DB에 해시해 저장할 때 섞는 서버 전용 비밀(긴 난문자열 권장, 예: openssl rand -hex 32). 비우면 MEMBER_SESSION_SECRET을 대신 사용.
# Server
APP_PORT=43118
DOCKER_SUBNET=10.250.50.0/24

7
.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules/
.output/ .output/
.nuxt/ .nuxt/
dist/ dist/
public/uploads/
# Environment # Environment
.env .env
@@ -26,4 +27,8 @@ Thumbs.db
npm-debug.log* npm-debug.log*
# Test # Test
coverage/ coverage/
# Reference theme sample (do not commit)
ZCF-v1.0.5/
sample 깃에 올리지말것/

View File

@@ -122,6 +122,7 @@
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다. - 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다. - 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다. - 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
민감 정보 예시: 민감 정보 예시:
- 실명 - 실명
@@ -157,6 +158,7 @@
- 기존 API 호출 패턴을 따른다. - 기존 API 호출 패턴을 따른다.
- 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다. - 응답 구조 변경 시 docs/spec.md를 반드시 갱신한다.
- 하드코딩된 값 사용을 금지한다. - 하드코딩된 값 사용을 금지한다.
- 초기 백엔드는 별도 앱으로 분리하지 않고 Nuxt `server/api`를 사용한다.
- 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다. - 개발 환경과 운영 환경의 데이터베이스 연결 문자열을 혼용하지 않는다.
- 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다. - 운영 DB는 로컬 개발 서버에서 직접 사용하지 않는다.

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

26
app.html Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
<head {{ HEAD_ATTRS }}>
{{ HEAD }}
</head>
<body {{ BODY_ATTRS }}>
<div
id="site-splash"
class="site-splash"
role="status"
aria-live="polite"
aria-label="사이트를 불러오는 중"
>
<img
id="site-splash-logo"
class="site-splash__logo"
alt=""
width="80"
height="80"
hidden
>
<span id="site-splash-text" class="site-splash__text">sori.studio</span>
</div>
{{ APP }}
</body>
</html>

41
app.vue Normal file
View File

@@ -0,0 +1,41 @@
<script setup>
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from './lib/brand-color.js'
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
key: 'site-settings-public',
default: () => ({
title: 'sori.studio',
faviconUrl: '',
logoUrl: '',
logoText: '井',
brandColor: DEFAULT_BRAND_COLOR
})
})
const siteAccentStyle = computed(() => ({
'--site-accent': normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR)
}))
useHead(() => ({
titleTemplate: (titleChunk) => titleChunk
? `${titleChunk} · ${appSiteSettings.value.title}`
: appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
? [
{
rel: 'icon',
type: 'image/png',
href: appSiteSettings.value.faviconUrl
}
]
: []
}))
</script>
<template>
<div class="site-app" :style="siteAccentStyle">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>

364
assets/css/main.css Normal file
View File

@@ -0,0 +1,364 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--site-bg: #fcfcfc;
--site-panel: #fcfcfc;
--site-panel-strong: #fcfcfc;
--site-text: #111111;
--site-muted: #454545;
--site-soft: #6f7480;
--site-line: #e2e2e0;
--site-input: #fcfcfc;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #111111;
--site-invert-text: #ffffff;
}
:root[data-theme='dark'] {
--site-bg: #050505;
--site-panel: #080808;
--site-panel-strong: #0d0d0d;
--site-text: #f4f4f2;
--site-muted: #c7c7c2;
--site-soft: #8b8e96;
--site-line: #252525;
--site-input: #171717;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #f4f4f2;
--site-invert-text: #111111;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--site-bg: #050505;
--site-panel: #080808;
--site-panel-strong: #0d0d0d;
--site-text: #f4f4f2;
--site-muted: #c7c7c2;
--site-soft: #8b8e96;
--site-line: #252525;
--site-input: #171717;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #f4f4f2;
--site-invert-text: #111111;
}
}
html {
font-family: Pretendard, ui-sans-serif, system-ui, sans-serif;
color: var(--site-text);
background: var(--site-bg);
}
.site-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: var(--site-bg);
transition: opacity 0.22s ease, visibility 0.22s ease;
}
html.site-app-ready .site-splash {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.site-splash__logo {
width: 5rem;
height: 5rem;
border-radius: 1rem;
object-fit: cover;
}
.site-splash__text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--site-text);
}
html.site-mobile-nav-open {
overflow: hidden;
}
html.site-search-open {
overflow: hidden;
}
body {
min-width: 320px;
margin: 0;
background: var(--site-bg);
}
html.admin-post-editor-document,
body.admin-post-editor-document {
height: 100%;
overflow: hidden;
background: #ffffff;
}
html.admin-settings-document,
body.admin-settings-document {
height: 100%;
overflow: hidden;
background: #f7f8fa;
}
}
@layer components {
@keyframes site-search-modal-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.site-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
color: var(--site-text);
background: var(--site-bg);
}
.site-section {
border-bottom: 1px solid var(--site-line);
background: var(--site-bg);
}
.site-section-header {
@apply px-6 py-8;
}
.site-section-body {
@apply px-6 py-4;
}
.site-search-modal__panel--animate {
animation: site-search-modal-in 0.18s ease-out;
}
.post-summary-clamp {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.post-summary-clamp--one {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.post-summary-clamp--two {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.post-summary-clamp--three {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.post-prose {
@apply max-w-none text-[17px] leading-8;
color: var(--site-text);
}
.site-header {
height: 57px;
border-bottom: 1px solid var(--site-line);
background: var(--site-panel);
color: var(--site-text);
}
.site-main {
min-height: 0;
background: var(--site-bg);
}
.site-main--menu-closed {
border-left: 0;
}
.site-sidebar {
min-height: 0;
background: var(--site-bg);
color: var(--site-text);
}
/**
* 사이드바 내부 스크롤 영역 — 스크롤바만 숨기고 스크롤 동작은 유지한다.
*/
.site-sidebar-scroll {
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.site-sidebar-scroll::-webkit-scrollbar {
display: none;
}
.site-sidebar-section {
border-bottom: 1px solid var(--site-line);
}
.site-muted {
color: var(--site-muted);
}
.site-soft {
color: var(--site-soft);
}
.site-input {
border: 1px solid var(--site-line);
background: var(--site-input);
color: var(--site-text);
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.site-button {
background: var(--site-invert);
color: var(--site-invert-text);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.site-button:hover {
opacity: 0.9;
}
.site-accent-button {
background: var(--site-accent);
color: var(--site-accent-text);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.site-accent-button:hover {
opacity: 0.92;
}
.site-interactive {
transition: color 0.2s ease, background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.site-interactive:hover {
color: var(--site-text);
}
.site-input:hover,
.site-input:focus-visible {
border-color: color-mix(in srgb, var(--site-text) 24%, var(--site-line));
}
.site-panel-hover {
transition: background-color 0.2s ease;
}
.site-panel-hover:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
/**
* 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 등 행 호버 — 라이트 테마에서 밝은 크림 톤, 다크는 패널 대비 유지
*/
.site-sidebar-nav-row {
transition: background-color 0.2s ease;
}
.site-sidebar-nav-row:hover {
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 {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) .site-sidebar-nav-row:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
}
/**
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
*/
.auth-form-input {
color: #f5f7fa;
caret-color: #2f6feb;
}
.auth-form-input::placeholder {
color: #5c6570;
}
.auth-form-input:-webkit-autofill,
.auth-form-input:-webkit-autofill:hover,
.auth-form-input:-webkit-autofill:focus {
-webkit-text-fill-color: #f5f7fa;
transition: background-color 9999s ease-out;
}
}
@layer utilities {
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}

View File

@@ -0,0 +1,173 @@
<script setup>
const adSlots = [
{
key: 'adHomeFeedCode',
label: '메인 피드',
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adHomeInfeedCode',
label: '메인 인피드',
description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adSidebarCode',
label: '오른쪽 사이드',
description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostSidebarCode',
label: '게시물 왼쪽 사이드',
description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostTopCode',
label: '게시물 본문 상단',
description: '게시물 상세 본문 렌더링 직전에 표시됩니다.',
placeholder: '<script async src="..."><' + '/script>\n<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostInArticleCode',
label: '게시물 인아티클',
description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostBottomCode',
label: '게시물 본문 하단',
description: '게시물 상세 본문 렌더링 직후에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>\n<script>(adsbygoogle = window.adsbygoogle || []).push({})<' + '/script>'
}
]
/**
* 광고 슬롯 코드 등록 여부를 반환한다.
* @param {Object} form - 사이트 설정 폼
* @param {string} key - 슬롯 키
* @returns {boolean} 등록 여부
*/
const hasSlotCode = (form, key) => Boolean(String(form?.[key] || '').trim())
/**
* 광고 설정 카드
* @property {Object} form - 사이트 설정 폼 객체
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} hasChanges - 변경 여부
*/
defineProps({
form: {
type: Object,
required: true
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
}
})
defineEmits(['begin', 'cancel', 'save'])
</script>
<template>
<section
id="admin-settings-section-ads"
class="admin-ads-settings-card admin-settings-screen__card admin-settings-screen__card--ads relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
Ads
</h2>
<p
v-if="!editing"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
위치별 광고 코드를 관리합니다. 비어 있는 슬롯은 공개 화면에 표시되지 않습니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editing">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="$emit('begin')"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="saving"
@click="$emit('cancel')"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="saving || !hasChanges"
@click="$emit('save')"
>
{{ saving ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div
v-if="!editing"
class="admin-ads-settings-card__readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div
v-for="slot in adSlots"
:key="slot.key"
class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[9rem_minmax(0,1fr)] md:items-center md:gap-5"
>
<p class="font-normal text-[#3f4650]">
{{ slot.label }}
</p>
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
{{ hasSlotCode(form, slot.key) ? '등록됨' : '미등록' }}
</p>
</div>
</div>
<div
v-else
class="admin-ads-settings-card__edit grid gap-5 border-t border-[#eceff2] pt-5"
>
<label
v-for="slot in adSlots"
:key="slot.key"
class="admin-settings-screen__field grid gap-2 text-sm"
>
<span class="font-medium text-[#3f4650]">{{ slot.label }}</span>
<p class="text-xs leading-relaxed text-[#657080]">
{{ slot.description }} 애드센스에서 제공한 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣습니다.
</p>
<textarea
v-model="form[slot.key]"
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
rows="7"
spellcheck="false"
:placeholder="slot.placeholder"
/>
</label>
</div>
</section>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,478 @@
<script setup>
import { getImageAltAttribute, getImageDefaultAltLabel } from '../../lib/markdown-image.js'
import {
CALLOUT_BACKGROUND_OPTIONS,
CALLOUT_BACKGROUND_SWATCHES,
CALLOUT_EMOJI_OPTIONS,
QUOTE_BACKGROUND_LABELS,
QUOTE_BACKGROUND_OPTIONS,
QUOTE_BACKGROUND_SWATCHES
} from '../../lib/markdown-callout.js'
const props = defineProps({
/** 패널 표시 여부 */
open: {
type: Boolean,
default: false
},
/** 활성 블록 컨텍스트 */
panel: {
type: Object,
default: null
}
})
const emit = defineEmits([
'panel-focus-in',
'panel-focus-out',
'update-media-image',
'set-media-use-alt',
'move-gallery-image',
'remove-media-image',
'add-gallery-images',
'update-embed-url',
'update-quote-background',
'update-callout-options',
'update-code-options',
'update-toggle-options'
])
const backgroundLabels = {
gray: '회색',
blue: '파랑',
green: '초록',
yellow: '노랑',
red: '빨강',
purple: '보라'
}
const backgroundSwatches = {
...CALLOUT_BACKGROUND_SWATCHES
}
const languageOptions = ['', 'javascript', 'html', 'css', 'vue', 'json', 'bash', 'markdown', 'sql']
/**
* 배경 라벨을 반환한다.
* @param {string} background - 배경 키
* @returns {string} 배경 라벨
*/
const getBackgroundLabel = (background) => backgroundLabels[background] || background
/**
* 배경 스와치를 반환한다.
* @param {string} background - 배경 키
* @returns {string} CSS 배경
*/
const getBackgroundSwatch = (background) => backgroundSwatches[background] || 'rgba(100,116,139,0.28)'
/**
* 인용 배경 라벨을 반환한다.
* @param {string} background - 배경 키
* @returns {string} 배경 라벨
*/
const getQuoteBackgroundLabel = (background) => QUOTE_BACKGROUND_LABELS[background] || background
/**
* 인용 배경 스와치를 반환한다.
* @param {string} background - 배경 키
* @returns {string} CSS 배경
*/
const getQuoteBackgroundSwatch = (background) => QUOTE_BACKGROUND_SWATCHES[background] || QUOTE_BACKGROUND_SWATCHES.gray
/**
* 블록 종류 라벨
* @returns {string}
*/
const panelTitle = computed(() => {
if (!props.panel) {
return ''
}
if (props.panel.kind === 'gallery') {
return '갤러리'
}
if (props.panel.kind === 'embed') {
return '임베드'
}
if (props.panel.kind === 'quote') {
return '인용'
}
if (props.panel.kind === 'callout') {
return '콜아웃'
}
if (props.panel.kind === 'code') {
return '코드 블록'
}
if (props.panel.kind === 'toggle') {
return '토글'
}
return '이미지'
})
/**
* 블록 종류 부제
* @returns {string}
*/
const panelMeta = computed(() => {
if (!props.panel) {
return ''
}
if (props.panel.kind === 'gallery') {
return `${props.panel.images.length}개 이미지`
}
if (props.panel.kind === 'embed') {
return 'YouTube·X 등 URL'
}
if (props.panel.kind === 'quote') {
return '인용 배경색'
}
if (props.panel.kind === 'callout') {
return '제목·아이콘·배경색'
}
if (props.panel.kind === 'code') {
return '언어·줄번호'
}
if (props.panel.kind === 'toggle') {
return '기본 펼침 상태'
}
return '커서가 위치한 이미지 줄'
})
/**
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
* @param {FocusEvent} event - focusout 이벤트
* @returns {void}
*/
const onPanelFocusOut = (event) => {
const root = event.currentTarget
const next = event.relatedTarget
if (next instanceof Node && root.contains(next)) {
return
}
emit('panel-focus-out')
}
</script>
<template>
<aside
class="admin-editor-block-panel absolute inset-0 z-20 flex flex-col bg-white shadow-[-8px_0_24px_rgba(15,23,42,0.08)] transition-transform duration-300 ease-out"
:class="open ? 'translate-x-0' : 'translate-x-full pointer-events-none'"
:aria-hidden="!open"
@focusin="emit('panel-focus-in')"
@focusout="onPanelFocusOut"
>
<div v-if="panel" class="admin-editor-block-panel__inner flex h-full flex-col">
<header class="admin-editor-block-panel__header flex h-[56px] shrink-0 items-center justify-between border-b border-[#e3e6e8] px-6">
<div>
<h2 class="admin-editor-block-panel__title text-xl font-bold text-black">
{{ panelTitle }}
</h2>
<p class="admin-editor-block-panel__meta mt-1 text-xs text-[#6b7280]">
{{ panelMeta }}
</p>
</div>
<button
v-if="panel.kind === 'gallery'"
class="admin-editor-block-panel__add rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white"
type="button"
@click="emit('add-gallery-images')"
>
이미지 추가
</button>
</header>
<div class="admin-editor-block-panel__body flex-1 overflow-y-auto px-6 py-6">
<template v-if="panel.kind === 'embed'">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">임베드 URL</span>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
:value="panel.url"
type="url"
placeholder="https://www.youtube.com/watch?v=..."
@input="emit('update-embed-url', $event.target.value)"
>
</label>
<p class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
YouTube·YouTube Shorts, X(트위터) 게시물 URL을 지원합니다. URL은 링크 카드로 표시됩니다.
</p>
</template>
<template v-else-if="panel.kind === 'quote'">
<div class="admin-editor-block-panel__quote-settings grid gap-4">
<div>
<p class="text-sm font-semibold text-[#394047]">
배경색
</p>
<p class="mt-1 text-xs leading-relaxed text-[#8e9cac]">
현재 인용 블록의 줄에 배경 옵션을 추가합니다.
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<button
v-for="background in QUOTE_BACKGROUND_OPTIONS"
:key="`quote-background-${background}`"
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
:class="panel.quoteBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-quote-background', background)"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getQuoteBackgroundSwatch(background) }"
aria-hidden="true"
/>
<span>{{ getQuoteBackgroundLabel(background) }}</span>
</button>
</div>
</div>
</template>
<template v-else-if="panel.kind === 'callout'">
<div class="admin-editor-block-panel__callout-settings grid gap-5">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">제목</span>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
:value="panel.title"
type="text"
placeholder="주의사항"
@input="emit('update-callout-options', { title: $event.target.value })"
>
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
<span>아이콘 표시</span>
<input
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
type="checkbox"
:checked="panel.calloutEmojiEnabled"
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
>
</label>
<div class="grid gap-2">
<p class="text-sm font-semibold text-[#394047]">
아이콘
</p>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac] disabled:opacity-50"
:value="panel.calloutEmoji"
type="text"
maxlength="4"
placeholder="💡"
:disabled="!panel.calloutEmojiEnabled"
@input="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: $event.target.value })"
>
<div class="grid grid-cols-5 gap-2">
<button
v-for="emoji in CALLOUT_EMOJI_OPTIONS"
:key="`callout-emoji-${emoji}`"
class="grid h-10 place-items-center rounded border text-lg transition"
:class="panel.calloutEmoji === emoji && panel.calloutEmojiEnabled ? 'border-[#15171a] bg-white' : 'border-[#dce0e5] bg-[#fafafa] hover:bg-white'"
type="button"
:disabled="!panel.calloutEmojiEnabled"
@click="emit('update-callout-options', { calloutEmojiEnabled: true, calloutEmoji: emoji })"
>
{{ emoji }}
</button>
</div>
</div>
<div class="grid gap-2">
<p class="text-sm font-semibold text-[#394047]">
배경색
</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="background in CALLOUT_BACKGROUND_OPTIONS"
:key="`callout-background-${background}`"
class="flex items-center gap-2 rounded border px-3 py-2 text-left text-xs font-semibold transition"
:class="panel.calloutBackground === background ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-callout-options', { calloutBackground: background })"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getBackgroundSwatch(background) }"
aria-hidden="true"
/>
<span>{{ getBackgroundLabel(background) }}</span>
</button>
</div>
</div>
</div>
</template>
<template v-else-if="panel.kind === 'code'">
<div class="admin-editor-block-panel__code-settings grid gap-4">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">언어</span>
<input
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
:value="panel.language"
type="text"
list="admin-editor-block-panel-languages"
placeholder="javascript"
@input="emit('update-code-options', { language: $event.target.value })"
>
<datalist id="admin-editor-block-panel-languages">
<option
v-for="language in languageOptions"
:key="`code-language-${language || 'plain'}`"
:value="language"
/>
</datalist>
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 rounded border border-[#edf0f2] bg-[#fafafa] px-3 py-3 text-sm font-semibold text-[#394047]">
<span>줄번호 표시</span>
<input
class="size-4 rounded border-[#c8ced3] text-[#15171a]"
type="checkbox"
:checked="panel.showLineNumbers"
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
>
</label>
</div>
</template>
<template v-else-if="panel.kind === 'toggle'">
<div class="admin-editor-block-panel__toggle-settings grid gap-3">
<button
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
:class="panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-toggle-options', { defaultOpen: true })"
>
<span>기본 펼침</span>
<span v-if="panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
</button>
<button
class="flex items-center justify-between rounded border px-3 py-3 text-left text-sm font-semibold transition"
:class="!panel.defaultOpen ? 'border-[#15171a] bg-white text-[#15171a]' : 'border-[#dce0e5] bg-[#fafafa] text-[#657080] hover:bg-white'"
type="button"
@click="emit('update-toggle-options', { defaultOpen: false })"
>
<span>기본 닫힘</span>
<span v-if="!panel.defaultOpen" class="text-xs text-[#15171a]">선택됨</span>
</button>
</div>
</template>
<template v-else>
<div class="admin-editor-block-panel__media-list grid gap-3">
<div
v-for="(image, imageIndex) in panel.images"
:key="`block-panel-image-${imageIndex}`"
class="admin-editor-block-panel__media-row grid gap-3 rounded border border-[#edf0f2] bg-[#fafafa] p-3"
:class="panel.selectedImageIndex === imageIndex ? 'admin-editor-block-panel__media-row--selected' : ''"
>
<img
class="aspect-[16/10] w-full rounded bg-[#eff1f2] object-cover"
:src="image.url"
:alt="getImageAltAttribute(image)"
>
<div class="grid gap-2">
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
캡션
<input
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a] placeholder:font-normal placeholder:text-[#8e9cac]"
:value="image.caption || ''"
type="text"
placeholder="비우면 표시하지 않음"
@input="emit('update-media-image', imageIndex, { caption: $event.target.value })"
>
</label>
<div class="flex flex-wrap items-center justify-between gap-2">
<label class="inline-flex cursor-pointer items-center gap-2 text-xs font-semibold text-[#394047]">
<input
class="size-3.5 rounded border-[#c8ced3] text-[#15171a]"
type="checkbox"
:checked="image.useAlt"
@change="emit('set-media-use-alt', imageIndex, $event.target.checked)"
>
파일명을 캡션으로 사용
</label>
</div>
<p
v-if="image.useAlt"
class="text-[11px] font-normal text-[#8e9cac]"
>
이미지 아래에 {{ getImageDefaultAltLabel(image.url) || '파일명 없음' }} 표시합니다.
</p>
<p v-else class="text-[11px] font-normal text-[#8e9cac]">
캡션을 비우면 이미지 아래에 아무 것도 표시하지 않습니다.
</p>
<label class="grid gap-1 text-xs font-semibold text-[#394047]">
이미지 URL
<input
class="rounded border border-[#d7dde2] bg-white px-3 py-2 text-sm font-normal text-[#15171a]"
:value="image.url"
type="text"
@input="emit('update-media-image', imageIndex, { url: $event.target.value })"
>
</label>
<div v-if="panel.kind === 'gallery'" class="flex flex-wrap gap-2">
<button
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
type="button"
:disabled="imageIndex === 0"
@click="emit('move-gallery-image', imageIndex, -1)"
>
위로
</button>
<button
class="rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:opacity-40"
type="button"
:disabled="imageIndex === panel.images.length - 1"
@click="emit('move-gallery-image', imageIndex, 1)"
>
아래로
</button>
<button
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
type="button"
@click="emit('remove-media-image', imageIndex)"
>
삭제
</button>
</div>
<button
v-else
class="rounded px-2.5 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50"
type="button"
@click="emit('remove-media-image', imageIndex)"
>
삭제
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</aside>
</template>
<style scoped>
.admin-editor-block-panel__media-row--selected {
border-color: #2eb6ea;
box-shadow: 0 0 0 2px rgba(46, 182, 234, 0.18);
}
</style>

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,828 @@
<script setup>
const props = defineProps({
member: {
type: Object,
default: () => null
},
mode: {
type: String,
default: 'edit'
}
})
const emit = defineEmits(['saved', 'deleted'])
const { toast, showToast } = useAdminToast()
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
default: () => ({
userId: '',
roleCode: ''
})
})
const isNewMember = computed(() => props.mode === 'new')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const avatarInputRef = ref(null)
const isUploadingAvatar = ref(false)
const isEditingMember = ref(props.mode === 'new')
const actionMenuOpen = ref(false)
const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false)
const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false)
const actionError = ref('')
const roleOptions = [
{ value: 'owner', label: '소유자' },
{ value: 'admin', label: '관리자' },
{ value: 'vip', label: 'VIP' },
{ value: 'member', label: '멤버' }
]
const form = reactive({
username: '',
email: '',
avatarUrl: '',
roleCode: 'member',
labelsText: '',
note: ''
})
const passwordForm = reactive({
password: '',
passwordConfirm: ''
})
const deleteForm = reactive({
confirmText: ''
})
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @returns {void}
*/
const syncMemberForm = () => {
const member = props.member || {}
form.username = member.username || ''
form.email = member.email || ''
form.avatarUrl = member.avatarUrl || ''
form.roleCode = member.roleCode || 'member'
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
form.note = member.note || ''
}
watch(() => props.member, syncMemberForm, { immediate: true })
const pageTitle = computed(() => {
if (isNewMember.value) {
return '새 멤버'
}
return form.username || props.member?.email || '멤버'
})
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
const noteLength = computed(() => form.note.length)
const normalizedLabels = computed(() => [...new Set(
form.labelsText
.split(',')
.map((label) => label.trim())
.filter(Boolean)
)])
const isFormEditable = computed(() => isNewMember.value || isEditingMember.value)
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
&& String(props.member.id) === String(adminSession.value.userId))
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode))
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
const canEditRoleSelect = computed(() => {
if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) {
return false
}
if (currentAdminRoleCode.value === 'owner') {
return !isEditingSelf.value
}
if (currentAdminRoleCode.value === 'admin') {
return !isTargetPrivilegedRole.value
}
return false
})
const availableRoleOptions = computed(() => {
if (!canEditRoleSelect.value) {
return roleOptions
}
if (currentAdminRoleCode.value === 'admin') {
return roleOptions.filter((option) => ['vip', 'member'].includes(option.value))
}
return roleOptions
})
const roleHelpText = computed(() => {
if (!isFormEditable.value) {
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
}
if (shouldRenderRoleAsText.value) {
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
}
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin') {
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
}
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
})
/**
* 회원 저장 요청 본문을 문자열로 직렬화한다.
* @returns {string} 직렬화된 회원 입력값
*/
const serializeMemberPayload = () => JSON.stringify({
...getMemberPayload(),
roleCode: form.roleCode
})
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
* @returns {string} 화면 표시 날짜
*/
const formatDate = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
/**
* 최근 활동 시각을 상대 시간으로 표시한다.
* @param {string | null} value - ISO 시각
* @returns {string} 상대 시간
*/
const formatRelativeTime = (value) => {
if (!value) {
return '최근 활동 없음'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '최근 활동 없음'
}
const diffMs = Date.now() - date.getTime()
const minute = 1000 * 60
const hour = minute * 60
const day = hour * 24
if (diffMs < minute) {
return '방금 전'
}
if (diffMs < hour) {
return `${Math.floor(diffMs / minute)}분 전`
}
if (diffMs < day) {
return `${Math.floor(diffMs / hour)}시간 전`
}
if (diffMs < day * 30) {
return `${Math.floor(diffMs / day)}일 전`
}
return formatDate(value)
}
/**
* 회원 저장 요청 본문을 만든다.
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
*/
const getMemberPayload = () => ({
username: form.username.trim(),
email: form.email.trim(),
avatarUrl: form.avatarUrl.trim(),
labels: normalizedLabels.value,
note: form.note
})
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
const shouldBlockUnsavedMemberChanges = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value)
const canSubmitMemberForm = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value && !isSaving.value)
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage
} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges)
/**
* 회원 상세를 수정 모드로 전환한다.
* @returns {void}
*/
const enterEditMode = () => {
isEditingMember.value = true
}
/**
* 썸네일 파일 선택창을 연다.
* @returns {void}
*/
const openAvatarFilePicker = () => {
if (!isFormEditable.value) {
return
}
avatarInputRef.value?.click()
}
/**
* 회원 썸네일 파일을 업로드하고 폼에 반영한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
*/
const uploadAvatar = async (event) => {
const target = event.target instanceof HTMLInputElement ? event.target : null
const file = target?.files?.[0]
if (!file || isUploadingAvatar.value) {
return
}
if (!isFormEditable.value) {
return
}
isUploadingAvatar.value = true
try {
const formData = new FormData()
formData.append('file', file)
const result = isNewMember.value
? await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
method: 'POST',
body: formData
})
form.avatarUrl = result.avatarUrl || ''
if (!isNewMember.value) {
emit('saved', result)
savedMemberSnapshot.value = serializeMemberPayload()
showToast('success', '썸네일이 변경되었습니다.')
}
} catch (error) {
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
} finally {
isUploadingAvatar.value = false
if (target) {
target.value = ''
}
}
}
/**
* 회원 썸네일 연결을 제거한다.
* @returns {void}
*/
const removeAvatar = () => {
if (!isFormEditable.value) {
return
}
form.avatarUrl = ''
}
/**
* 회원 작업 메뉴를 토글한다.
* @returns {void}
*/
const toggleActionMenu = () => {
actionMenuOpen.value = !actionMenuOpen.value
}
/**
* 회원 작업 메뉴를 닫는다.
* @returns {void}
*/
const closeActionMenu = () => {
actionMenuOpen.value = false
}
/**
* 비밀번호 변경 모달을 연다.
* @returns {void}
*/
const openPasswordModal = () => {
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionError.value = ''
passwordModalOpen.value = true
closeActionMenu()
}
/**
* 회원 삭제 모달을 연다.
* @returns {void}
*/
const openDeleteModal = () => {
deleteForm.confirmText = ''
actionError.value = ''
deleteModalOpen.value = true
closeActionMenu()
}
/**
* 비밀번호 변경 모달을 닫는다.
* @returns {void}
*/
const closePasswordModal = () => {
if (isUpdatingPassword.value) {
return
}
passwordModalOpen.value = false
}
/**
* 회원 삭제 모달을 닫는다.
* @returns {void}
*/
const closeDeleteModal = () => {
if (isDeletingMember.value) {
return
}
deleteModalOpen.value = false
}
/**
* 관리자 권한으로 회원 비밀번호를 변경한다.
* @returns {Promise<void>}
*/
const updateMemberPassword = async () => {
actionError.value = ''
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
return
}
if (passwordForm.password !== passwordForm.passwordConfirm) {
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
return
}
isUpdatingPassword.value = true
try {
await $fetch(`/admin/api/members/${props.member.id}/password`, {
method: 'PUT',
body: {
password: passwordForm.password
}
})
passwordModalOpen.value = false
passwordForm.password = ''
passwordForm.passwordConfirm = ''
showToast('success', '비밀번호가 변경되었습니다.')
} catch (error) {
const message = error?.data?.message || '비밀번호 변경에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isUpdatingPassword.value = false
}
}
/**
* 관리자 권한으로 회원을 삭제한다.
* @returns {Promise<void>}
*/
const deleteMember = async () => {
actionError.value = ''
if (deleteForm.confirmText !== form.email) {
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
return
}
isDeletingMember.value = true
try {
await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'DELETE'
})
emit('deleted')
} catch (error) {
const message = error?.data?.message || '회원 삭제에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isDeletingMember.value = false
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
*/
const saveMember = async () => {
if (isSaving.value) {
return
}
if (!isFormEditable.value) {
enterEditMode()
return
}
if (!hasUnsavedMemberChanges.value) {
return
}
isSaving.value = true
try {
const payload = getMemberPayload()
if (!isNewMember.value && canEditRoleSelect.value && form.roleCode !== props.member?.roleCode) {
await $fetch(`/admin/api/members/${props.member.id}/role`, {
method: 'PUT',
body: {
role: form.roleCode
}
})
}
const saved = isNewMember.value
? await $fetch('/admin/api/members', {
method: 'POST',
body: payload
})
: await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'PUT',
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
isEditingMember.value = isNewMember.value
showToast('success', '저장되었습니다.')
} catch (error) {
showToast('error', error?.data?.message || '저장에 실패했습니다.')
} finally {
isSaving.value = false
}
}
watch(() => props.member, () => {
savedMemberSnapshot.value = serializeMemberPayload()
}, { immediate: true, flush: 'post' })
</script>
<template>
<section class="admin-member-form bg-paper p-6">
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="admin-member-form__title-block">
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
멤버
</NuxtLink>
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
</div>
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
{{ pageTitle }}
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
비밀번호 변경
</button>
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
멤버 삭제
</button>
</div>
</div>
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<button
class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
type="button"
:disabled="isFormEditable && !canSubmitMemberForm"
@click="saveMember"
>
{{ !isFormEditable ? '수정하기' : isSaving ? '저장 중' : '저장' }}
</button>
</div>
</div>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
<button
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
type="button"
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
:disabled="!isFormEditable"
:class="{ 'cursor-default': !isFormEditable }"
@click="openAvatarFilePicker"
>
<img
v-if="form.avatarUrl"
class="admin-member-form__avatar h-full w-full object-cover"
:src="form.avatarUrl"
:alt="pageTitle"
>
<span v-else class="admin-member-form__avatar flex h-full w-full items-center justify-center text-2xl font-semibold">
{{ memberInitial }}
</span>
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
</span>
</button>
<button
v-if="form.avatarUrl && isFormEditable"
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
aria-label="썸네일 제거"
@click.stop="removeAvatar"
>
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
<input ref="avatarInputRef" class="sr-only" type="file" accept="image/*" @change="uploadAvatar">
</div>
<div class="min-w-0">
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
</div>
</div>
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
<p class="flex items-center gap-2">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ member?.lastSeenIp || '접속 IP 없음' }}
</p>
<p class="flex items-center gap-2">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ formatRelativeTime(member?.lastSeenAt) }}
</p>
</div>
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
생성됨 <strong>{{ formatDate(member?.createdAt) }}</strong>
</p>
</div>
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
댓글 작성 {{ member?.commentCount || 0 }}
</p>
</div>
</aside>
<div class="admin-member-form__content space-y-8 xl:col-span-2">
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
<div class="grid gap-5 md:grid-cols-2">
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.username || '-' }}
</span>
<input v-else v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
</label>
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.email || '-' }}
</span>
<input v-else v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
</label>
</div>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
<span
v-if="shouldRenderRoleAsText"
class="admin-member-form__role-text flex min-h-12 w-full items-center text-sm font-semibold text-[#15171a]"
:class="{ 'rounded-md border border-[#d7dce0] bg-white px-4': isFormEditable }"
>
{{ currentRoleLabel }}
</span>
<span v-else class="admin-member-form__select-wrap relative block">
<select
v-model="form.roleCode"
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-[#d7dce0] bg-white px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
:disabled="!canEditRoleSelect"
>
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-4 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
<span class="admin-member-form__hint mt-2 block text-sm text-[#8a95a5]">
{{ roleHelpText }}
</span>
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.labelsText || '-' }}
</span>
<input v-else v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
</span>
<p v-if="!isFormEditable" class="admin-member-form__readonly min-h-24 whitespace-pre-wrap text-sm leading-6 text-[#15171a]">
{{ form.note || '-' }}
</p>
<textarea v-else v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-[#d7dce0] bg-white px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
최대 500. 현재 {{ noteLength }}
</span>
</label>
</form>
<section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
<span class="flex items-center gap-3 text-[#3f4650]">
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
</svg>
로그인
</span>
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
</div>
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
<span class="flex items-center gap-3 text-[#3f4650]">
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
가입
</span>
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
</div>
</div>
</section>
</div>
</div>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
<Teleport to="body">
<div
v-if="toast"
class="admin-member-form__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</Teleport>
<Teleport to="body">
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closePasswordModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 비밀번호를 설정합니다.
</p>
<label class="grid gap-2 text-sm font-semibold">
비밀번호
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
</label>
<label class="grid gap-2 text-sm font-semibold">
비밀번호 확인
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
</label>
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closePasswordModal">
취소
</button>
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
{{ isUpdatingPassword ? '변경 ' : '변경' }}
</button>
</footer>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="deleteModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">멤버 삭제</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeDeleteModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 <strong class="text-[#15171a]">{{ form.email }}</strong> 입력해 주세요.
</p>
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeDeleteModal">
취소
</button>
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
{{ isDeletingMember ? '삭제 ' : '삭제' }}
</button>
</footer>
</section>
</div>
</Teleport>
</section>
</template>

View File

@@ -0,0 +1,548 @@
<script setup>
const props = defineProps({
initialPage: {
type: Object,
default: () => ({})
},
saving: {
type: Boolean,
default: false
},
deleting: {
type: Boolean,
default: false
},
canViewPage: {
type: Boolean,
default: false
},
publicUrl: {
type: String,
default: ''
},
showDelete: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'delete'])
const slugTouched = ref(Boolean(props.initialPage.slug))
const blockEditor = ref(null)
const htmlEditor = ref(null)
const editorMode = ref('write')
const isUploadingPageAsset = ref(false)
const isSettingsOpen = ref(true)
const savedPageSnapshot = ref('')
const htmlCursorRange = reactive({
start: 0,
end: 0
})
const defaultHtmlDocument = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Landing</title>
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
</style>
</head>
<body>
</body>
</html>`
const form = reactive({
title: props.initialPage.title || '',
slug: props.initialPage.slug || '',
status: props.initialPage.status || 'published',
renderMode: props.initialPage.renderMode || 'html_document',
content: props.initialPage.content || ''
})
/**
* 한글 음절 1자를 영문 표기로 변환
* @param {string} char - 변환할 문자
* @returns {string} 영문 표기
*/
const romanizeHangulSyllable = (char) => {
const syllableCode = char.charCodeAt(0)
const hangulBase = 0xac00
const hangulLast = 0xd7a3
if (syllableCode < hangulBase || syllableCode > hangulLast) {
return char
}
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
const offset = syllableCode - hangulBase
const choseongIndex = Math.floor(offset / 588)
const jungseongIndex = Math.floor((offset % 588) / 28)
const jongseongIndex = offset % 28
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
}
/**
* 문자열을 영문 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 영문 슬러그
*/
const toSlug = (value) => value
.normalize('NFC')
.split('')
.map((char) => romanizeHangulSyllable(char))
.join('')
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
const pageSlug = computed(() => toSlug(form.slug || form.title))
const viewPageUrl = computed(() => props.publicUrl || (pageSlug.value ? `/pages/${pageSlug.value}` : ''))
const pageUrlHint = computed(() => `/pages/${pageSlug.value || 'page-slug'}/`)
/**
* 페이지 폼의 저장 비교용 문자열을 생성한다.
* @returns {string} 직렬화된 폼 상태
*/
const serializePageForm = () => JSON.stringify({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.content
})
const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value)
const headerStatusText = computed(() => {
if (props.saving) {
return 'Saving...'
}
if (hasUnsavedPageChanges.value) {
return 'Unsaved changes'
}
if (props.initialPage.id) {
return 'Saved'
}
return 'New page'
})
watch(() => form.title, (title) => {
if (!slugTouched.value) {
form.slug = toSlug(title)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
/**
* 페이지 설정 패널을 토글한다.
* @returns {void}
*/
const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 제목 입력 후 본문 에디터로 이동
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const focusContentEditor = (event) => {
event?.preventDefault()
if (form.renderMode === 'html_document') {
htmlEditor.value?.focus()
return
}
blockEditor.value?.focusFirstBlock()
}
/**
* 페이지 작성 모드를 변경한다.
* @param {'markdown'|'html_document'} mode - 페이지 작성 모드
* @returns {void}
*/
const setRenderMode = (mode) => {
form.renderMode = mode
}
/**
* HTML textarea 커서 위치를 기억한다.
* @returns {void}
*/
const rememberHtmlCursor = () => {
if (!htmlEditor.value) {
return
}
htmlCursorRange.start = htmlEditor.value.selectionStart ?? form.content.length
htmlCursorRange.end = htmlEditor.value.selectionEnd ?? htmlCursorRange.start
}
/**
* HTML 본문 커서 위치에 텍스트를 삽입한다.
* @param {string} text - 삽입할 텍스트
* @returns {Promise<void>}
*/
const insertTextAtHtmlCursor = async (text) => {
const start = Math.max(0, htmlCursorRange.start)
const end = Math.max(start, htmlCursorRange.end)
form.content = `${form.content.slice(0, start)}${text}${form.content.slice(end)}`
await nextTick()
const nextCursor = start + text.length
htmlEditor.value?.focus()
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
htmlCursorRange.start = nextCursor
htmlCursorRange.end = nextCursor
}
/**
* HTML 기본 문서 골격을 현재 본문에 채운다.
* @returns {Promise<void>}
*/
const completeHtmlDocumentSkeleton = async () => {
form.content = defaultHtmlDocument
await nextTick()
const bodyIndex = form.content.indexOf('</body>')
const nextCursor = bodyIndex > -1 ? bodyIndex : form.content.length
htmlEditor.value?.focus()
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
htmlCursorRange.start = nextCursor
htmlCursorRange.end = nextCursor
}
/**
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {Promise<void>}
*/
const handleHtmlEditorKeydown = async (event) => {
if (event.key !== 'Tab' || form.renderMode !== 'html_document') {
return
}
const content = form.content.trim()
if (content !== '' && content !== '!') {
return
}
event.preventDefault()
await completeHtmlDocumentSkeleton()
}
/**
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadPageAsset = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
rememberHtmlCursor()
const formData = new FormData()
formData.append('files', files[0])
isUploadingPageAsset.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
const uploadedUrl = result.files?.[0]?.url || ''
if (uploadedUrl && form.renderMode === 'html_document') {
await insertTextAtHtmlCursor(uploadedUrl)
}
} finally {
event.target.value = ''
isUploadingPageAsset.value = false
}
}
/**
* 페이지 입력값을 생성한다.
* @returns {Object} 페이지 입력값
*/
const createPayload = () => ({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.content,
featuredImage: null
})
/**
* 페이지 입력값 제출
* @returns {void}
*/
const submitPage = () => {
emit('submit', createPayload())
}
/**
* 현재 폼 상태를 저장 완료 기준점으로 표시한다.
* @returns {void}
*/
const markSaved = () => {
savedPageSnapshot.value = serializePageForm()
}
onMounted(markSaved)
defineExpose({
markSaved
})
</script>
<template>
<form class="admin-page-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPage">
<div class="admin-page-form__workspace flex min-w-0 flex-1 flex-col bg-white">
<header class="admin-page-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
<div class="admin-page-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
<div class="admin-page-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
<NuxtLink class="admin-page-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black" to="/admin/pages">
<span class="admin-page-form__toolbar-back text-lg leading-none" aria-hidden="true">&lt;</span>
<span>Pages</span>
</NuxtLink>
<NuxtLink
v-if="canViewPage && viewPageUrl"
class="admin-page-form__toolbar-status-link inline-flex items-center gap-1 truncate rounded px-2 py-1.5 text-sm font-medium text-[#8E9CAC] transition-colors hover:bg-[#f1f3f4] hover:text-[#394047]"
:to="viewPageUrl"
target="_blank"
rel="noopener noreferrer"
>
<span>View page</span>
<span aria-hidden="true"></span>
</NuxtLink>
<span v-else class="admin-page-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8E9CAC]">
{{ headerStatusText }}
</span>
</div>
<div class="admin-page-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
<div
v-show="form.renderMode === 'markdown'"
id="admin-page-form-mode-toggle-host"
class="admin-page-form__mode-toggle-host flex shrink-0 items-center"
/>
<button
class="admin-page-form__toolbar-save rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="submit"
:disabled="saving || !form.title.trim() || !hasUnsavedPageChanges"
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button
class="admin-page-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black"
type="button"
:aria-pressed="isSettingsOpen"
aria-label="페이지 설정 패널 전환"
@click="toggleSettingsPanel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2.16699C14.3242 2.16699 14.4998 2.39365 14.5 2.57129V13.4287C14.5 13.606 14.3242 13.834 14 13.834H11.5V2.16699H14ZM2 2.16699H10.5V13.834H2C1.6756 13.834 1.5 13.6064 1.5 13.4287V2.57129C1.50024 2.39409 1.67607 2.16699 2 2.16699Z" stroke="currentColor" />
</svg>
</button>
</div>
</div>
</header>
<main class="admin-page-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
<section class="admin-page-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-24">
<input
v-model="form.title"
class="admin-page-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="제목"
@keydown.enter="focusContentEditor"
>
<div v-if="form.renderMode === 'markdown'" class="admin-page-form__field admin-page-form__content-editor text-sm">
<AdminMarkdownEditor
ref="blockEditor"
v-model="form.content"
v-model:editor-mode="editorMode"
mode-toggle-teleport-to="#admin-page-form-mode-toggle-host"
/>
</div>
<label v-else class="admin-page-form__field admin-page-form__html-field grid gap-2 text-sm">
<span class="admin-page-form__label sr-only">HTML 문서</span>
<textarea
ref="htmlEditor"
v-model="form.content"
class="admin-page-form__html-editor min-h-[68vh] w-full resize-y rounded border border-[#e3e6e8] bg-white px-4 py-4 font-mono text-sm leading-6 text-[#15171a] outline-none placeholder:text-[#8e9cac] focus:border-[#8e9cac]"
spellcheck="false"
@blur="rememberHtmlCursor"
@click="rememberHtmlCursor"
@focus="rememberHtmlCursor"
@input="rememberHtmlCursor"
@keydown="handleHtmlEditorKeydown"
@keyup="rememberHtmlCursor"
@select="rememberHtmlCursor"
:placeholder="defaultHtmlDocument"
/>
</label>
</section>
</main>
</div>
<aside
class="admin-page-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out"
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
:aria-hidden="!isSettingsOpen"
>
<div class="admin-page-form__settings-inner relative flex h-full w-[420px] flex-col">
<div class="admin-page-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
<h2 class="admin-page-form__settings-title text-xl font-bold text-black">
페이지 설정
</h2>
<button class="admin-page-form__settings-close grid size-8 place-items-center rounded text-neutral-900 transition-colors hover:bg-[#eff1f2] hover:text-neutral-500" type="button" aria-label="페이지 설정 닫기" @click="toggleSettingsPanel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16" class="h-[1.1rem] w-[1.1rem]" aria-hidden="true">
<path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" />
</svg>
</button>
</div>
<div class="admin-page-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
<div class="admin-page-form__field grid gap-1 text-sm">
<div class="admin-page-form__page-url-header flex h-[22px] items-center justify-between">
<span class="admin-page-form__label font-bold text-[#15171a]">Page URL</span>
<NuxtLink
v-if="canViewPage && viewPageUrl"
class="admin-page-form__view-page inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
:to="viewPageUrl"
target="_blank"
>
<span>View Page</span>
<span aria-hidden="true"></span>
</NuxtLink>
</div>
<label class="admin-page-form__page-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<span class="admin-page-form__page-url-icon text-sm text-[#394047]" aria-hidden="true"></span>
<input
v-model="form.slug"
class="admin-page-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
type="text"
pattern="[a-z0-9]+(-[a-z0-9]+)*"
required
@input="touchSlug"
>
</label>
<p class="admin-page-form__page-url-hint text-xs text-[#7c8b9a]">
{{ pageUrlHint }}
</p>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">상태</span>
<span class="admin-page-form__select-wrap relative block">
<select v-model="form.status" class="admin-page-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">공개</option>
<option value="private">비공개</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<span v-if="form.status === 'draft'" class="admin-page-form__hint text-xs text-muted">
초안 페이지는 공개 URL에서 보이지 않습니다.
</span>
<span v-else-if="form.status === 'private'" class="admin-page-form__hint text-xs text-muted">
비공개 페이지는 공개 URL에서 보이지 않습니다.
</span>
</label>
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">페이지 형식</span>
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'markdown' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
type="button"
@click="setRenderMode('markdown')"
>
일반 텍스트
</button>
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'html_document' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
type="button"
@click="setRenderMode('html_document')"
>
HTML
</button>
</div>
</div>
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">HTML 자산</span>
<label
class="admin-page-form__asset-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"
:class="{ 'pointer-events-none opacity-50': isUploadingPageAsset }"
>
{{ isUploadingPageAsset ? '업로드 중' : '파일 업로드' }}
<input
class="sr-only"
type="file"
accept="image/*,video/*,audio/*,.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx"
@change="uploadPageAsset"
>
</label>
<p class="admin-page-form__asset-upload-hint text-xs leading-5 text-[#7c8b9a]">
HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. : &lt;img src=&quot;여기&quot;&gt;
</p>
</div>
</div>
<div v-if="showDelete" class="admin-page-form__settings-footer border-t border-[#e3e6e8] p-6">
<button
class="admin-page-form__delete-button flex h-[44px] w-full items-center justify-center gap-2 rounded border border-[#d7dce0] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
type="button"
:disabled="deleting"
@click="emit('delete')"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 14h10l1-14M9 7V4h6v3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7" />
</svg>
<span>{{ deleting ? 'Deleting page' : 'Delete page' }}</span>
</button>
</div>
</div>
</aside>
</form>
</template>

View File

@@ -0,0 +1,57 @@
<script setup>
/**
* 게시물 Export 파일 선택 행
* @property {Object} file - Export 파일
* @property {boolean} selected - 선택 여부
* @property {boolean} disabled - 선택 비활성 여부
*/
defineProps({
file: {
type: Object,
required: true
},
selected: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
})
defineEmits(['toggle'])
</script>
<template>
<div class="admin-post-export-file-row grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-[#edf0f3] px-3 py-2 last:border-b-0">
<button
class="admin-post-export-file-row__check inline-flex size-4 items-center justify-center rounded border border-[#cfd6de] bg-white transition focus:outline-none focus:ring-2 focus:ring-[#15171a] focus:ring-offset-1 disabled:cursor-not-allowed disabled:bg-[#f4f6f8]"
type="button"
role="checkbox"
:aria-checked="selected"
:disabled="disabled"
:aria-label="`${file.fileName} 선택`"
@click="$emit('toggle')"
>
<span
v-if="selected"
class="admin-post-export-file-row__check-mark block size-2 rounded-sm bg-[#15171a]"
/>
</button>
<div class="admin-post-export-file-row__body min-w-0">
<p class="admin-post-export-file-row__name truncate text-sm font-medium text-[#15171a]">
{{ file.fileName }}
</p>
<p class="admin-post-export-file-row__range mt-0.5 text-xs text-[#9aa3ad]">
{{ file.postStart }}-{{ file.postEnd }}
</p>
</div>
<span
v-if="file.status !== 'ready' || !file.filePath"
class="admin-post-export-file-row__status inline-flex h-8 items-center justify-center rounded px-2 text-xs font-semibold text-[#a6b0bb]"
>
{{ file.status === 'processing' ? '생성 중' : file.status === 'failed' ? '실패' : '다운로드 대기' }}
</span>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
<script setup>
const openMenuId = defineModel('openMenuId', {
type: String,
default: ''
})
const props = defineProps({
/** 이 메뉴 인스턴스의 고유 id */
itemId: {
type: String,
required: true
},
/** 트리거 비활성화 */
disabled: {
type: Boolean,
default: false
},
/** 처리 중(… 표시) */
busy: {
type: Boolean,
default: false
},
/** 접근성 라벨 접두사 */
menuLabel: {
type: String,
default: '메뉴'
},
/** 트리거 크기: md(테이블) | sm(배지·사이드) */
size: {
type: String,
default: 'md'
},
/** 어두운 배경 위 트리거(미디어 폴더 선택 행 등) */
inverse: {
type: Boolean,
default: false
}
})
const triggerRef = ref(null)
const popoverStyle = ref({})
/** @type {import('vue').ComputedRef<boolean>} */
const isOpen = computed(() => openMenuId.value === props.itemId)
/** @type {import('vue').ComputedRef<string>} */
const triggerSizeClass = computed(() => (props.size === 'sm' ? 'size-7' : 'size-9'))
/** @type {import('vue').ComputedRef<string>} */
const iconSizeClass = computed(() => (props.size === 'sm' ? 'size-5' : 'size-6'))
/** @type {import('vue').ComputedRef<string>} */
const triggerToneClass = computed(() => (
props.inverse
? 'text-white hover:bg-white/15 focus-visible:ring-white/40'
: 'text-[#394047] hover:bg-[#eceff2]'
))
/**
* 행 메뉴 위치를 화면 기준으로 계산한다.
* @returns {void}
*/
const updatePopoverPosition = () => {
if (!import.meta.client || !triggerRef.value) {
return
}
const rect = triggerRef.value.getBoundingClientRect()
const menuWidth = props.size === 'sm' ? 176 : 176
const estimatedHeight = 112
const margin = 8
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
const top = opensUp
? Math.max(margin, rect.top - estimatedHeight - 4)
: rect.bottom + 4
popoverStyle.value = {
left: `${left}px`,
top: `${top}px`,
width: `${menuWidth}px`
}
}
/**
* 메뉴 열기/닫기
* @returns {void}
*/
const toggleMenu = () => {
if (props.disabled) {
return
}
openMenuId.value = isOpen.value ? '' : props.itemId
}
watch(isOpen, async (open) => {
if (!open) {
return
}
await nextTick()
updatePopoverPosition()
})
onMounted(() => {
window.addEventListener('resize', updatePopoverPosition)
window.addEventListener('scroll', updatePopoverPosition, true)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePopoverPosition)
window.removeEventListener('scroll', updatePopoverPosition, true)
})
</script>
<template>
<div
class="admin-row-more-menu relative inline-flex justify-end"
data-admin-row-menu
@mousedown.stop
>
<button
ref="triggerRef"
class="admin-row-more-menu__trigger inline-flex items-center justify-center rounded transition-colors focus-visible:outline focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
:class="[triggerSizeClass, triggerToneClass]"
type="button"
:disabled="disabled"
:aria-expanded="isOpen"
aria-haspopup="menu"
:aria-label="isOpen ? `${menuLabel} 닫기` : menuLabel"
@mousedown.stop
@click.stop="toggleMenu"
>
<span
v-if="busy"
class="text-[10px] font-semibold text-muted"
aria-hidden="true"
></span>
<svg
v-else
class="admin-row-more-menu__icon shrink-0"
:class="iconSizeClass"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
</svg>
</button>
<Teleport to="body">
<div
v-if="isOpen"
class="admin-row-more-menu__popover fixed z-[80] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
:style="popoverStyle"
role="menu"
data-admin-row-menu
>
<slot />
</div>
</Teleport>
</div>
</template>
<style scoped>
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item) {
display: block;
width: 100%;
padding: 0.625rem 1rem;
text-align: left;
font-size: 0.875rem;
line-height: 1.25rem;
color: #3f4650;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:hover) {
background: #f3f5f7;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger) {
color: #c0392b;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger:hover) {
background: #fef2f2;
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup>
/**
* 관리자 사이트 설정 좌측 내비 아이콘
* @property {string} [iconId] - 아이콘 식별자. 미지정·미구현 시 자리 표시(placeholder)만 렌더
*/
defineProps({
iconId: {
type: String,
default: ''
}
})
</script>
<template>
<span
class="admin-settings-nav-icon inline-flex shrink-0 items-center justify-center text-current"
:class="iconId ? `admin-settings-nav-icon--${iconId}` : 'admin-settings-nav-icon--placeholder'"
aria-hidden="true"
>
<!-- 블로그 제목·설명 -->
<svg
v-if="iconId === 'title-desc'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="-0.75 -0.75 24 24"
fill="none"
>
<path d="M2.109375 6.32625h18.28125s1.40625 0 1.40625 1.40625v7.03125s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-7.03125s0 -1.40625 1.40625 -1.40625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m16.171875 17.57625 0 -12.65625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M11.953125 21.795a4.21875 4.21875 0 0 0 4.21875 -4.21875 4.21875 4.21875 0 0 0 4.21875 4.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M11.953125 0.70125a4.21875 4.21875 0 0 1 4.21875 4.21875 4.21875 4.21875 0 0 1 4.21875 -4.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 타임존 -->
<svg
v-else-if="iconId === 'timezone'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="-0.75 -0.75 24 24"
fill="none"
>
<path d="M10.546875 16.171875a5.625 5.625 0 1 0 11.25 0 5.625 5.625 0 1 0 -11.25 0Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m18.658125000000002 16.171875 -2.48625 0 0 -2.4853125" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M9.838125 21.703125a10.5478125 10.5478125 0 1 1 11.866875 -11.85375" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M8.7084375 21.4884375C7.2825 19.3959375 6.328125 15.593437499999999 6.328125 11.25S7.2825 3.105 8.7084375 1.0115625" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m0.7265625 10.546875 8.9278125 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M2.8115625 4.921875 19.6875 4.921875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m1.92 16.171875 5.814375 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M13.7915625 1.0115625a15.9215625 15.9215625 0 0 1 2.15625 6.69" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 메인 화면 -->
<svg
v-else-if="iconId === 'home-cover'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M3 2.25h18s1.5 0 1.5 1.5v16.5s0 1.5 -1.5 1.5H3s-1.5 0 -1.5 -1.5V3.75s0 -1.5 1.5 -1.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m1.5 6.75 21 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m9 6.75 0 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m9 14.25 13.5 0" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 어나운스 -->
<svg
v-else-if="iconId === 'announcement'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="-0.75 -0.75 24 24"
fill="none"
>
<path d="M6.328125 14.296875H4.21875a3.515625 3.515625 0 0 1 0 -7.03125h2.109375Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M6.328125 14.296875a20.90625 20.90625 0 0 1 11.593125 3.5100000000000002l1.0631249999999999 0.70875V3.046875l-1.0631249999999999 0.70875A20.90625 20.90625 0 0 1 6.328125 7.265625Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m21.796875 9.375 0 2.8125" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M6.328125 14.296875A6.7865625 6.7865625 0 0 0 8.4375 19.21875" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- 사이트 정보 -->
<svg
v-else-if="iconId === 'site-info'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M324-111.5Q251-143 197-197t-85.5-127Q80-397 80-480t31.5-156Q143-709 197-763t127-85.5Q397-880 480-880t156 31.5Q709-817 763-763t85.5 127Q880-563 880-480t-31.5 156Q817-251 763-197t-127 85.5Q563-80 480-80t-156-31.5ZM440-162v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q41-45 62.5-100.5T800-480q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z" />
</svg>
<!-- SNS 정보 -->
<svg
v-else-if="iconId === 'social'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z" />
</svg>
<!-- POST 설정 -->
<svg
v-else-if="iconId === 'post-settings'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M280-280h280v-80H280v80Zm0-160h400v-80H280v80Zm0-160h400v-80H280v80Zm-80 480q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z" />
</svg>
<!-- 브랜드 -->
<svg
v-else-if="iconId === 'brand'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="m260-520 220-360 220 360H260ZM700-80q-75 0-127.5-52.5T520-260q0-75 52.5-127.5T700-440q75 0 127.5 52.5T880-260q0 75-52.5 127.5T700-80Zm-580-20v-320h320v320H120Zm580-60q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Zm-500-20h160v-160H200v160Zm202-420h156l-78-126-78 126Zm78 0ZM360-340Zm340 80Z" />
</svg>
<!-- 사이트 코드 -->
<svg
v-else-if="iconId === 'site-code'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M80-680v-80q0-33 23.5-56.5T160-840h640q33 0 56.5 23.5T880-760v80h-80v-80H160v80H80Zm240 560v-80H160q-33 0-56.5-23.5T80-280v-80h80v80h640v-80h80v80q0 33-23.5 56.5T800-200H640v80H320Zm160-400Zm-288 0 104-104-56-56L80-520l160 160 56-56-104-104Zm576 0L664-416l56 56 160-160-160-160-56 56 104 104Z" />
</svg>
<!-- Ads -->
<svg
v-else-if="iconId === 'ads'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M468-240q-96-5-162-74t-66-166q0-100 70-170t170-70q97 0 166 66t74 162l-84-25q-13-54-56-88.5T480-640q-66 0-113 47t-47 113q0 57 34.5 100t88.5 56l25 84Zm48 158q-9 2-18 2h-18q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480v18q0 9-2 18l-78-24v-12q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93h12l24 78Zm305 22L650-231 600-80 480-480l400 120-151 50 171 171-79 79Z" />
</svg>
<!-- 게시물보내기 -->
<svg
v-else-if="iconId === 'post-export'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z" />
</svg>
<!-- 게시물 가져오기 -->
<svg
v-else-if="iconId === 'post-import'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M440-200h80v-167l64 64 56-57-160-160-160 160 57 56 63-63v167ZM240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z" />
</svg>
<!-- 스팸 필터 -->
<svg
v-else-if="iconId === 'spam'"
class="admin-settings-nav-icon__svg pointer-events-none size-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
>
<path d="M19.0902 4.90918L4.9082 19.0912" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span
v-else
class="admin-settings-nav-icon__placeholder size-4 rounded-sm border border-dashed border-[#c8ced3]"
/>
</span>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
/**
* 사이트 코드 설정 카드
* @property {Object} form - 사이트 설정 폼 객체
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} hasChanges - 변경 여부
*/
defineProps({
form: {
type: Object,
required: true
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
}
})
defineEmits(['begin', 'cancel', 'save'])
</script>
<template>
<section
id="admin-settings-section-site-code"
class="admin-site-code-settings-card admin-settings-screen__card admin-settings-screen__card--site-code relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
사이트 코드
</h2>
<p
v-if="!editing"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
광고·검색엔진·외부 위젯 검증에 필요한 ads.txt와 공통 헤더·푸터 코드를 관리합니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editing">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="$emit('begin')"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="saving"
@click="$emit('cancel')"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="saving || !hasChanges"
@click="$emit('save')"
>
{{ saving ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div
v-if="!editing"
class="admin-site-code-settings-card__readonly admin-settings-screen__site-code-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[7.5rem_minmax(0,1fr)] md:items-center md:gap-5">
<p class="font-normal text-[#3f4650]">
ads.txt
</p>
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.adsTxt.trim() ? '등록됨' : '미등록' }}
</p>
</div>
<div class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[7.5rem_minmax(0,1fr)] md:items-center md:gap-5">
<p class="font-normal text-[#3f4650]">
헤더 코드
</p>
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.customHeadCode.trim() ? '등록됨' : '미등록' }}
</p>
</div>
<div class="admin-settings-screen__readonly-row grid gap-1 md:grid-cols-[7.5rem_minmax(0,1fr)] md:items-center md:gap-5">
<p class="font-normal text-[#3f4650]">
푸터 코드
</p>
<p class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.customFooterCode.trim() ? '등록됨' : '미등록' }}
</p>
</div>
</div>
<div
v-else
class="admin-site-code-settings-card__edit admin-settings-screen__site-code-edit grid gap-5 border-t border-[#eceff2] pt-5"
>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">ads.txt</span>
<p class="text-xs leading-relaxed text-[#657080]">
루트 /ads.txt에서 text/plain으로 응답됩니다. 애드센스에서 제공한 줄을 그대로 붙여 넣습니다.
</p>
<textarea
v-model="form.adsTxt"
class="min-h-[7rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
rows="5"
spellcheck="false"
placeholder="google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0"
/>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">헤더 코드</span>
<p class="text-xs leading-relaxed text-[#657080]">
공개 페이지의 head 끝에 삽입됩니다. 애드센스 자동 광고, 사이트 검증 meta/script 코드에 사용합니다.
</p>
<textarea
v-model="form.customHeadCode"
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
rows="7"
spellcheck="false"
placeholder="헤더에 삽입할 meta 또는 script 코드를 붙여 넣습니다."
/>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">푸터 코드</span>
<p class="text-xs leading-relaxed text-[#657080]">
공개 페이지의 body 끝에 삽입됩니다. 하단 추적 스크립트나 지연 로딩 코드에 사용합니다.
</p>
<textarea
v-model="form.customFooterCode"
class="min-h-[9rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-[13px] text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
rows="7"
spellcheck="false"
placeholder="푸터에 삽입할 script 코드를 붙여 넣습니다."
/>
</label>
</div>
</section>
</template>

View File

@@ -0,0 +1,135 @@
<script setup>
/**
* 슬래시 명령 메뉴 아이콘 (Ghost 스타일 라인 아이콘)
*/
const props = defineProps({
/** @type {import('vue').PropType<string>} */
commandId: {
type: String,
required: true
}
})
</script>
<template>
<svg
class="admin-slash-command-icon"
:class="`admin-slash-command-icon--${commandId}`"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
width="20"
height="20"
aria-hidden="true"
>
<!-- image -->
<template v-if="commandId === 'image'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="m19.642 16.276-3.85-7a.517.517 0 0 0-.181-.189.585.585 0 0 0-.749.115l-4.533 5.494-2.307-2.516a.548.548 0 0 0-.206-.14.598.598 0 0 0-.499.031.529.529 0 0 0-.183.164l-2.75 4a.468.468 0 0 0-.015.507.526.526 0 0 0 .202.189c.084.045.18.069.28.069H19.15a.594.594 0 0 0 .268-.063.532.532 0 0 0 .2-.174.462.462 0 0 0 .024-.487ZM9.25 9c.911 0 1.65-.672 1.65-1.5S10.161 6 9.25 6c-.91 0-1.65.672-1.65 1.5S8.34 9 9.25 9Z"></path>
</template>
<!-- gallery -->
<template v-else-if="commandId === 'gallery'">
<g clip-path="url(#a)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 5H3.2C1.985 5 1 5.806 1 6.8v14.4c0 .994.985 1.8 2.2 1.8h17.6c1.215 0 2.2-.806 2.2-1.8V6.8c0-.994-.985-1.8-2.2-1.8ZM6 1h12"></path><path fill="currentColor" d="M15.142 10.264a.75.75 0 0 1 .529.4l4 8A.75.75 0 0 1 19 19.75H6a.75.75 0 0 1-.498-1.31l9-8a.75.75 0 0 1 .64-.176ZM7 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"></path></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h24v24H0z"></path></clipPath></defs>
</template>
<!-- h1 -->
<template v-else-if="commandId === 'h1'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M17.926 6.578v10.898c0 .602.33.963.862.963.541 0 .862-.351.862-.963V5.726c0-.682-.451-1.153-1.093-1.153-.39 0-.742.15-1.373.622l-2.026 1.504c-.4.29-.591.561-.591.852 0 .38.3.692.672.692.22 0 .43-.08.721-.291l1.885-1.374zM4.42 4.903a.77.77 0 0 1 .77.77v5.35h6.168v-5.35a.77.77 0 1 1 1.54 0v12.242a.77.77 0 0 1-1.54 0v-5.351H5.19v5.351a.77.77 0 1 1-1.54 0V5.673a.77.77 0 0 1 .77-.77"
clip-rule="evenodd"
/>
</template>
<!-- h2 -->
<template v-else-if="commandId === 'h2'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M14.159 16c-.562.638-.725.905-.725 1.248 0 .553.439.886 1.135.886h6.289c.524 0 .829-.276.829-.724 0-.457-.324-.734-.83-.734H15.57v-.114l3.526-4.031c1.715-1.953 2.201-2.859 2.201-4.098 0-2.096-1.648-3.583-3.993-3.583-2.515 0-4.088 1.697-4.088 3.317 0 .514.305.867.772.867.39 0 .658-.258.791-.763.286-1.238 1.191-1.972 2.42-1.972 1.449 0 2.411.896 2.411 2.24 0 .895-.41 1.695-1.486 2.925zM3.419 5.364c.404 0 .731.327.731.731v5.087h5.863V6.095a.732.732 0 1 1 1.464 0v11.637a.732.732 0 0 1-1.464 0v-5.087H4.15v5.087a.732.732 0 1 1-1.463 0V6.095c0-.404.327-.731.732-.731"
clip-rule="evenodd"
/>
</template>
<!-- h3 -->
<template v-else-if="commandId === 'h3'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4.05 5.856a.75.75 0 0 0-1.5 0v11.921a.75.75 0 1 0 1.5 0v-5.21h6.006v5.21a.75.75 0 0 0 1.5 0V5.856a.75.75 0 0 0-1.5 0v5.21H4.05zm9.479 9.234c-.418 0-.713.304-.713.732 0 1.454 1.796 2.936 4.248 2.936 2.642 0 4.486-1.558 4.486-3.782 0-1.635-1.226-3.041-2.832-3.222v-.095c1.321-.228 2.395-1.596 2.395-3.031 0-1.977-1.692-3.393-4.068-3.393-2.338 0-3.925 1.425-3.925 2.898 0 .476.285.79.723.79.37 0 .608-.2.798-.723.38-.979 1.235-1.54 2.366-1.54 1.454 0 2.433.875 2.433 2.177s-1.007 2.242-2.395 2.242h-1.121c-.456 0-.76.295-.76.713 0 .409.323.723.76.723h1.188c1.654 0 2.765.978 2.765 2.432s-1.083 2.386-2.784 2.386c-1.293 0-2.281-.57-2.775-1.587-.247-.485-.456-.656-.789-.656"
clip-rule="evenodd"
/>
</template>
<!-- h4 -->
<template v-else-if="commandId === 'h4'">
<path fill="currentColor" d="M140-290v-380h60v160h180v-160h60v380h-60v-160H200v160h-60Zm580 0v-120H520v-260h60v200h140v-200h60v200h80v60h-80v120h-60Z"/>
</template>
<!-- quote -->
<template v-else-if="commandId === 'quote'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M5 10.966v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024H6.704A3.35 3.35 0 0 1 9.845 7.75v-1.5A4.845 4.845 0 0 0 5 10.966m8 0v4.095c0 .565.458 1.024 1.024 1.024h4.095c.566 0 1.024-.459 1.024-1.024v-4.095c0-.566-.459-1.024-1.024-1.024h-3.415a3.35 3.35 0 0 1 3.141-2.192v-1.5A4.845 4.845 0 0 0 13 10.966"
clip-rule="evenodd"
/>
</template>
<!-- list -->
<template v-else-if="commandId === 'list'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4.3 7.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2m4.033-1.75a.75.75 0 0 0 0 1.5l11.917.001a.75.75 0 0 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 1 0 0-1.5zm0 5.5a.75.75 0 1 0 0 1.5l11.917.002a.75.75 0 0 0 0-1.5zM5.3 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1 6.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2"
clip-rule="evenodd"
/>
</template>
<!-- code -->
<template v-else-if="commandId === 'code'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M2.25 6A2.75 2.75 0 0 1 5 3.25h14A2.75 2.75 0 0 1 21.75 6v12A2.75 2.75 0 0 1 19 20.75H5A2.75 2.75 0 0 1 2.25 18zM5 4.75c-.69 0-1.25.56-1.25 1.25v12c0 .69.56 1.25 1.25 1.25h14c.69 0 1.25-.56 1.25-1.25V6c0-.69-.56-1.25-1.25-1.25zm5.53 4.62a.75.75 0 0 1 0 1.06l-1.59 1.591 1.59 1.591a.75.75 0 0 1-1.06 1.06l-2.122-2.12a.75.75 0 0 1 0-1.061L9.47 9.37a.75.75 0 0 1 1.06 0m2.94 1.06a.75.75 0 1 1 1.06-1.06l2.122 2.12a.75.75 0 0 1 0 1.062l-2.122 2.12a.75.75 0 1 1-1.06-1.06l1.59-1.59z"
clip-rule="evenodd"
/>
</template>
<!-- divider -->
<template v-else-if="commandId === 'divider'">
<path
fill="currentColor"
fill-rule="evenodd"
d="M4 4.25a.75.75 0 0 1 .75.75v1c0 .69.56 1.25 1.25 1.25h12c.69 0 1.25-.56 1.25-1.25V5a.75.75 0 0 1 1.5 0v1A2.75 2.75 0 0 1 18 8.75H6A2.75 2.75 0 0 1 3.25 6V5A.75.75 0 0 1 4 4.25m0 15.5a.75.75 0 0 0 .75-.75v-1c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v1a.75.75 0 0 0 1.5 0v-1A2.75 2.75 0 0 0 18 15.25H6A2.75 2.75 0 0 0 3.25 18v1c0 .414.336.75.75.75m-1-8.5a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5H3m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5H7.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5h1.2a.75.75 0 0 0 0-1.5h-1.2m3.45.75a.75.75 0 0 1 .75-.75h1.2a.75.75 0 0 1 0 1.5h-1.2a.75.75 0 0 1-.75-.75m4.95-.75a.75.75 0 0 0 0 1.5H21a.75.75 0 0 0 0-1.5h-1.2"
clip-rule="evenodd"
/>
</template>
<!-- callout -->
<template v-else-if="commandId === 'callout'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path fill="currentColor" d="M12 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.5v6"></path>
</template>
<!-- toggle -->
<template v-else-if="commandId === 'toggle'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
</template>
<!-- embed -->
<template v-else-if="commandId === 'embed'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
</template>
<!-- fallback -->
<template v-else>
<path
fill="currentColor"
fill-rule="evenodd"
d="M12 4.5c.46 0 .833.373.833.833v5.834h5.834a.833.833 0 0 1 0 1.666h-5.834v5.834a.833.833 0 0 1-1.666 0v-5.834H5.333a.833.833 0 0 1 0-1.666h5.834V5.333c0-.46.373-.833.833-.833"
clip-rule="evenodd"
/>
</template>
</svg>
</template>

View File

@@ -0,0 +1,141 @@
<script setup>
const props = defineProps({
initialTag: {
type: Object,
default: () => ({})
},
submitLabel: {
type: String,
default: '저장'
},
saving: {
type: Boolean,
default: false
},
defaultTagType: {
type: String,
default: 'general'
}
})
const emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialTag.slug))
const form = reactive({
name: props.initialTag.name || '',
slug: props.initialTag.slug || '',
description: props.initialTag.description || '',
color: props.initialTag.color || '#15171a'
})
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 슬러그
*/
const toSlug = (value) => value
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
watch(() => form.name, (name) => {
if (!slugTouched.value) {
form.slug = toSlug(name)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
/**
* 태그 입력값 제출
* @returns {void}
*/
const submitTag = () => {
emit('submit', {
name: form.name.trim(),
slug: toSlug(form.slug || form.name),
description: form.description.trim(),
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
})
}
</script>
<template>
<form class="admin-tag-form grid gap-6" @submit.prevent="submitTag">
<section class="admin-tag-form__panel grid gap-5 border border-line bg-white p-5">
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">이름</span>
<input
v-model="form.name"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">슬러그</span>
<input
v-model="form.slug"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
required
@input="touchSlug"
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">설명</span>
<textarea
v-model="form.description"
class="admin-tag-form__textarea min-h-28 rounded border border-line bg-white px-3 py-2"
/>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">색상 코드</span>
<span class="admin-tag-form__color-row flex items-center gap-3">
<input
v-model="form.color"
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
type="color"
>
<input
v-model="form.color"
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
type="text"
pattern="#[0-9a-fA-F]{6}"
required
>
</span>
</label>
</section>
<div class="admin-tag-form__actions flex justify-end gap-3">
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
취소
</NuxtLink>
<button
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
>
{{ saving ? '저장 중' : submitLabel }}
</button>
</div>
</form>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
defineProps({
open: {
type: Boolean,
default: false
}
})
defineEmits(['stay', 'leave'])
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/40 px-5 pb-8 pt-10"
role="dialog"
aria-modal="true"
aria-labelledby="admin-unsaved-modal-title"
>
<div class="admin-unsaved-modal__content relative w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-2xl">
<header class="admin-unsaved-modal__header border-b border-[#e3e6e8] px-8 py-6">
<h1 id="admin-unsaved-modal-title" class="admin-unsaved-modal__title text-xl font-semibold tracking-[-0.01em]">
페이지를 떠날까요?
</h1>
</header>
<button
class="admin-unsaved-modal__close absolute right-5 top-5 grid size-8 place-items-center rounded-md text-[#4d5663] transition hover:bg-[#eff1f2] hover:text-black"
type="button"
title="닫기"
aria-label="닫기"
@click="$emit('stay')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
<div class="admin-unsaved-modal__body space-y-3 px-8 py-7 text-sm leading-6 text-[#4d5663]">
<p>저장하지 않은 변경사항이 있습니다.</p>
<p>떠나기 전에 저장해 주세요.</p>
</div>
<footer class="admin-unsaved-modal__footer flex justify-end gap-3 border-t border-[#e3e6e8] px-8 py-5">
<button
class="admin-unsaved-modal__stay h-10 rounded-md border border-[#d7dce0] bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]"
type="button"
@click="$emit('stay')"
>
머무르기
</button>
<button
class="admin-unsaved-modal__leave h-10 rounded-md bg-[#e5484d] px-4 text-sm font-semibold text-white transition hover:bg-[#d21a26]"
type="button"
@click="$emit('leave')"
>
나가기
</button>
</footer>
</div>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,106 @@
<script setup>
/**
* 비밀번호 필드 표시/숨김 토글(Material 스타일 눈 아이콘 SVG)
*/
const props = defineProps({
/** 비밀번호를 평문으로 표시할 때 true */
modelValue: {
type: Boolean,
required: true
},
/**
* 스크린 리더용 필드 이름(예: 비밀번호 확인)
*/
fieldName: {
type: String,
default: '비밀번호'
}
})
const emit = defineEmits(['update:modelValue'])
/**
* 접근성용 레이블 문자열
* @param {'show' | 'hide'} kind - 보기 또는 숨기기
* @returns {string}
*/
const labelFor = (kind) => {
if (kind === 'show') {
return `${props.fieldName} 보기`
}
return `${props.fieldName} 숨기기`
}
/**
* 표시 상태를 반전한다.
* @returns {void}
*/
const toggle = () => {
emit('update:modelValue', !props.modelValue)
}
</script>
<template>
<button
class="auth-password-visibility-toggle"
type="button"
:aria-label="modelValue ? labelFor('hide') : labelFor('show')"
:aria-pressed="modelValue"
@click="toggle"
>
<svg
v-if="!modelValue"
class="auth-password-visibility-toggle__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</svg>
<svg
v-else
class="auth-password-visibility-toggle__icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 22 19.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
</svg>
</button>
</template>
<style scoped>
.auth-password-visibility-toggle {
display: flex;
height: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding-left: 0.625rem;
padding-right: 0.625rem;
margin: 0;
border: none;
background: transparent;
color: #9ba3af;
cursor: pointer;
outline: none;
}
.auth-password-visibility-toggle:hover {
opacity: 0.85;
}
.auth-password-visibility-toggle:focus-visible {
outline: 2px solid rgba(47, 111, 235, 0.55);
outline-offset: 2px;
}
.auth-password-visibility-toggle__icon {
display: block;
width: 1.25rem;
height: 1.25rem;
}
</style>

View File

@@ -0,0 +1,547 @@
<script setup>
const props = defineProps({
slug: {
type: String,
required: true
}
})
const comments = ref([])
const member = ref(null)
const loadingComments = ref(false)
const submitting = ref(false)
const submittingReplyId = ref('')
const likingCommentIds = ref([])
const errorMessage = ref('')
const replyErrorMessage = ref('')
const newCommentBody = ref('')
const replyBody = ref('')
const activeReplyTargetId = ref('')
const sortOption = ref('best')
const brokenAvatarCommentIds = ref([])
const canSubmitComment = computed(() => Boolean(newCommentBody.value.trim()) && !submitting.value)
const canSubmitReply = computed(() => Boolean(replyBody.value.trim()) && !submittingReplyId.value)
/**
* 댓글 시간을 상대 시간 형식으로 변환한다.
* @param {string} value - ISO 날짜 문자열
* @returns {string} 표시용 문자열
*/
const formatCommentDate = (value) => {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const now = Date.now()
const diffMs = now - date.getTime()
if (diffMs < 60 * 1000) {
return '방금 전'
}
if (diffMs < 60 * 60 * 1000) {
return `${Math.max(1, Math.floor(diffMs / (60 * 1000)))}분 전`
}
if (diffMs < 24 * 60 * 60 * 1000) {
return `${Math.max(1, Math.floor(diffMs / (60 * 60 * 1000)))}시간 전`
}
const sameYear = date.getFullYear() === new Date(now).getFullYear()
return date.toLocaleDateString('ko-KR', sameYear
? { month: 'short', day: 'numeric' }
: { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* 댓글 작성자 아바타 이니셜을 생성한다.
* @param {{ username?: string, email?: string }} user - 작성자 정보
* @returns {string} 아바타 이니셜
*/
const getAvatarInitials = (user) => {
const baseText = String(user?.username || user?.email || '').trim()
if (!baseText) {
return '@'
}
const tokens = baseText.split(/\s+/g).filter(Boolean)
if (tokens.length >= 2) {
return `${tokens[0].slice(0, 1)}${tokens[1].slice(0, 1)}`.toUpperCase()
}
return baseText.slice(0, 2).toUpperCase()
}
/**
* 댓글 작성 시각 숫자값 반환
* @param {string} value - ISO 날짜 문자열
* @returns {number} 시간 숫자값
*/
const toTimeValue = (value) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return 0
}
return date.getTime()
}
/**
* 아바타 이미지 깨짐 여부 확인
* @param {string} commentId - 댓글 ID
* @returns {boolean} 깨짐 여부
*/
const isAvatarBroken = (commentId) => brokenAvatarCommentIds.value.includes(commentId)
/**
* 아바타 이미지 로드 실패를 기록한다.
* @param {string} commentId - 댓글 ID
* @returns {void}
*/
const markAvatarBroken = (commentId) => {
if (isAvatarBroken(commentId)) {
return
}
brokenAvatarCommentIds.value = [...brokenAvatarCommentIds.value, commentId]
}
/**
* 회원 세션을 조회한다.
* @returns {Promise<void>}
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
} catch {
member.value = null
}
}
/**
* 댓글 목록을 조회한다.
* @returns {Promise<void>}
*/
const fetchComments = async () => {
loadingComments.value = true
errorMessage.value = ''
try {
const response = await $fetch(`/api/posts/${props.slug}/comments`)
comments.value = response.comments || []
} catch (error) {
comments.value = []
errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.'
} finally {
loadingComments.value = false
}
}
/**
* 루트 댓글을 작성한다.
* @returns {Promise<void>}
*/
const submitComment = async () => {
const body = newCommentBody.value.trim()
if (!body || submitting.value) {
return
}
submitting.value = true
errorMessage.value = ''
try {
await $fetch(`/api/posts/${props.slug}/comments`, {
method: 'POST',
body: {
body
}
})
newCommentBody.value = ''
await fetchComments()
} catch (error) {
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
} finally {
submitting.value = false
}
}
/**
* 답글 작성 UI를 연다.
* @param {string} commentId - 대상 댓글 ID
* @returns {void}
*/
const openReplyForm = (commentId) => {
activeReplyTargetId.value = commentId
replyBody.value = ''
replyErrorMessage.value = ''
}
/**
* 답글 작성 UI를 닫는다.
* @returns {void}
*/
const closeReplyForm = () => {
activeReplyTargetId.value = ''
replyBody.value = ''
replyErrorMessage.value = ''
}
/**
* 대댓글을 작성한다.
* @param {string} parentId - 부모 댓글 ID
* @returns {Promise<void>}
*/
const submitReply = async (parentId) => {
const body = replyBody.value.trim()
if (!body || submittingReplyId.value) {
return
}
submittingReplyId.value = parentId
replyErrorMessage.value = ''
try {
await $fetch(`/api/posts/${props.slug}/comments`, {
method: 'POST',
body: {
body,
parentId
}
})
closeReplyForm()
await fetchComments()
} catch (error) {
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
} finally {
submittingReplyId.value = ''
}
}
/**
* 특정 댓글의 좋아요 요청 진행 여부
* @param {string} commentId - 댓글 ID
* @returns {boolean} 진행 여부
*/
const isLikingComment = (commentId) => likingCommentIds.value.includes(commentId)
/**
* 댓글 좋아요를 토글한다.
* @param {string} commentId - 댓글 ID
* @returns {Promise<void>}
*/
const toggleLike = async (commentId) => {
if (!member.value || isLikingComment(commentId)) {
return
}
likingCommentIds.value = [...likingCommentIds.value, commentId]
errorMessage.value = ''
try {
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
method: 'POST'
})
comments.value = comments.value.map((item) => {
if (item.id !== commentId) {
return item
}
return {
...item,
likeCount: Number(result.likeCount || 0),
likedByMe: Boolean(result.liked)
}
})
} catch (error) {
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
} finally {
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
}
}
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
const sortedRootComments = computed(() => {
const copied = [...rootComments.value]
if (sortOption.value === 'latest') {
return copied.sort((left, right) => toTimeValue(right.createdAt) - toTimeValue(left.createdAt))
}
if (sortOption.value === 'oldest') {
return copied.sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
}
return copied.sort((left, right) => {
const likeDiff = Number(right.likeCount || 0) - Number(left.likeCount || 0)
if (likeDiff !== 0) {
return likeDiff
}
return toTimeValue(left.createdAt) - toTimeValue(right.createdAt)
})
})
const repliesByParent = computed(() => {
/** @type {Record<string, Array<any>>} */
const grouped = {}
for (const item of comments.value) {
if (!item.parentId) {
continue
}
if (!grouped[item.parentId]) {
grouped[item.parentId] = []
}
grouped[item.parentId].push(item)
}
for (const parentId of Object.keys(grouped)) {
grouped[parentId] = grouped[parentId].sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
}
return grouped
})
onMounted(async () => {
await Promise.all([fetchMember(), fetchComments()])
})
</script>
<template>
<div class="post-comments text-sm">
<div class="flex items-center justify-between gap-2">
<p class="font-medium"><span class="site-muted">{{ comments.length }}</span> Comments</p>
</div>
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
<label for="comment-sort">정렬:</label>
<select
id="comment-sort"
v-model="sortOption"
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
>
<option value="best">인기순</option>
<option value="latest">최신순</option>
<option value="oldest">오래된순</option>
</select>
</div>
<div class="mt-4">
<div v-if="member" class="rounded-[10px] p-3">
<p class="mb-2 text-xs site-muted">
{{ member.username || member.email }} 님으로 댓글 작성
</p>
<textarea
v-model="newCommentBody"
rows="4"
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
placeholder="댓글을 입력해 주세요."
/>
<div class="mt-2 flex justify-end">
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!canSubmitComment"
@click="submitComment"
>
{{ submitting ? '등록 중...' : '댓글 등록' }}
</button>
</div>
</div>
<div v-else class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
<p class="site-muted">
댓글은 로그인한 회원만 작성할 있습니다.
</p>
<NuxtLink to="/signin" class="mt-2 inline-flex rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs font-semibold hover:opacity-80">
로그인하러 가기
</NuxtLink>
</div>
</div>
<p v-if="errorMessage" class="mt-3 text-xs text-red-500">
{{ errorMessage }}
</p>
<div class="mt-5">
<p v-if="loadingComments" class="text-xs site-muted">
댓글을 불러오는 중입니다.
</p>
<ul v-else-if="sortedRootComments.length > 0" class="flex flex-col divide-y divide-[var(--site-line)]">
<li
v-for="comment in sortedRootComments"
:key="comment.id"
class="py-4"
>
<div class="flex gap-3">
<div class="flex w-8 flex-none flex-col items-center">
<div class="h-8 w-8 min-h-8 min-w-8 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]">
<img
v-if="comment.user.avatarUrl && !isAvatarBroken(comment.id)"
:src="comment.user.avatarUrl"
:alt="`${comment.user.username} 아바타`"
class="block h-full w-full object-cover"
@error="markAvatarBroken(comment.id)"
>
<span
v-else
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
>
{{ getAvatarInitials(comment.user) }}
</span>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<strong class="text-sm">{{ comment.user.username }}</strong>
<span class="text-xs site-muted">{{ formatCommentDate(comment.createdAt) }}</span>
</div>
<p class="mt-2 whitespace-pre-line leading-relaxed">
{{ comment.body }}
</p>
<div class="mt-2 flex items-center gap-3 text-xs">
<button
type="button"
class="group inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
:disabled="!member || isLikingComment(comment.id)"
@click="toggleLike(comment.id)"
>
<svg
viewBox="0 0 16 16"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
:fill="comment.likedByMe ? 'currentColor' : 'none'"
stroke="currentColor"
stroke-width="1.2"
/>
</svg>
<span>{{ comment.likeCount || 0 }}</span>
</button>
<button
v-if="member"
type="button"
class="group inline-flex items-center gap-1 site-muted hover:opacity-75"
@click="openReplyForm(comment.id)"
>
<svg
viewBox="0 0 16 16"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m1.5 7 4.7-4.2v2.6c4.7 0 8 2 8.3 6.5-1.5-2.3-3.3-3.1-8.3-3.1v2.4L1.5 7Z"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
답글
</button>
</div>
</div>
</div>
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] p-2">
<textarea
v-model="replyBody"
rows="3"
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
placeholder="답글을 입력해 주세요."
/>
<p v-if="replyErrorMessage" class="mt-2 text-xs text-red-500">
{{ replyErrorMessage }}
</p>
<div class="mt-2 flex justify-end gap-2">
<button type="button" class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs" @click="closeReplyForm">
취소
</button>
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!canSubmitReply"
@click="submitReply(comment.id)"
>
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
</button>
</div>
</div>
<ul
v-if="repliesByParent[comment.id]?.length"
class="mt-3 ml-4 flex flex-col gap-3 border-l border-[var(--site-line)]/90 pl-4"
>
<li
v-for="reply in repliesByParent[comment.id]"
:key="reply.id"
class="relative rounded-[10px] bg-[var(--site-panel)] p-2.5"
>
<span class="absolute -left-4 top-5 h-px w-3 bg-[var(--site-line)]/90" />
<div class="flex gap-2.5">
<div class="h-8 w-8 min-h-8 min-w-8 flex-none overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]">
<img
v-if="reply.user.avatarUrl && !isAvatarBroken(reply.id)"
:src="reply.user.avatarUrl"
:alt="`${reply.user.username} 아바타`"
class="block h-full w-full object-cover"
@error="markAvatarBroken(reply.id)"
>
<span
v-else
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
>
{{ getAvatarInitials(reply.user) }}
</span>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<strong class="text-sm">{{ reply.user.username }}</strong>
<span class="text-xs site-muted">{{ formatCommentDate(reply.createdAt) }}</span>
</div>
<p class="mt-1 whitespace-pre-line leading-relaxed">
{{ reply.body }}
</p>
<div class="mt-1.5 text-xs">
<button
type="button"
class="inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
:disabled="!member || isLikingComment(reply.id)"
@click="toggleLike(reply.id)"
>
<svg
viewBox="0 0 16 16"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
:fill="reply.likedByMe ? 'currentColor' : 'none'"
stroke="currentColor"
stroke-width="1.2"
/>
</svg>
<span>{{ reply.likeCount || 0 }}</span>
</button>
</div>
</div>
</div>
</li>
</ul>
</li>
</ul>
<p v-else class="text-xs site-muted">
댓글을 남겨보세요.
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,128 @@
<script setup>
import { buildCalloutOpenerLine } from '../../lib/markdown-callout.js'
import ProseCallout from './ProseCallout.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
const props = defineProps({
/** 콜아웃 본문 */
modelValue: {
type: String,
default: ''
},
calloutEmojiEnabled: {
type: Boolean,
default: true
},
calloutEmoji: {
type: String,
default: '💡'
},
calloutTitle: {
type: String,
default: ''
},
calloutBackground: {
type: String,
default: 'blue'
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
},
/** 콜아웃 선언 줄 source-line(0-based) */
blockSourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'delete-line', 'insert-above', 'insert-below', 'merge-with-previous', 'leave-block'])
const bodyLines = computed(() => {
const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n')
return lines.length ? lines : ['']
})
/**
* 콜아웃 마크다운 줄을 반영한다.
* @param {string[]} contentLines - 본문 줄
* @returns {void}
*/
const commitCalloutLines = (contentLines) => {
emit('commit', [
buildCalloutOpenerLine({
calloutEmojiEnabled: props.calloutEmojiEnabled,
calloutEmoji: props.calloutEmoji,
calloutBackground: props.calloutBackground,
title: props.calloutTitle
}),
...contentLines,
':::'
])
}
/**
* 콜아웃 본문 문자열을 줄 목록으로 정규화한다.
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {string[]} 본문 줄
*/
const normalizeBodyLines = (payload) => {
const value = typeof payload === 'string'
? payload
: String(payload?.value ?? '')
const lines = String(value ?? '').replace(/\r/g, '').split('\n')
return lines.length ? lines : ['']
}
/**
* 본문 편집 반영
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {void}
*/
const onBodyCommit = (payload) => {
commitCalloutLines(normalizeBodyLines(payload))
}
/**
* 본문 입력 중 마크다운을 동기화한다.
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {void}
*/
const onBodyInput = (payload) => {
commitCalloutLines(normalizeBodyLines(payload))
}
</script>
<template>
<div
class="content-markdown-callout-editor relative"
:data-source-line="blockSourceLine"
>
<ProseCallout
:emoji-enabled="calloutEmojiEnabled"
:emoji="calloutEmoji"
:background="calloutBackground"
:title="calloutTitle"
>
<ContentMarkdownEditableInline
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="multiline"
plain-text
arrow-exit-creates-line
preserve-empty-line-on-full-delete
:source-line="bodySourceLine"
:source-line-count="bodyLines.length"
:model-value="modelValue"
@input="onBodyInput"
@commit="onBodyCommit"
@delete-line="emit('delete-line', $event)"
@insert-above="emit('insert-above', $event)"
@insert-below="emit('insert-below', $event)"
@merge-with-previous="emit('merge-with-previous', bodySourceLine, $event)"
@leave-block="emit('leave-block', $event)"
/>
</ProseCallout>
</div>
</template>

View File

@@ -0,0 +1,190 @@
<script setup>
import { buildCodeBlockLines } from '../../lib/markdown-code-block.js'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
const props = defineProps({
/** 코드 본문 */
modelValue: {
type: String,
default: ''
},
/** 언어(slug) */
language: {
type: String,
default: ''
},
/** 줄 번호 표시 */
showLineNumbers: {
type: Boolean,
default: true
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'insert-above', 'insert-below', 'delete-line'])
const languageDraft = ref(props.language)
const lineNumbersEnabled = ref(props.showLineNumbers)
const liveBody = ref(props.modelValue)
watch(() => props.language, (value) => {
languageDraft.value = value
})
watch(() => props.showLineNumbers, (value) => {
lineNumbersEnabled.value = value
})
watch(() => props.modelValue, (value) => {
liveBody.value = value
})
/** @type {import('vue').ComputedRef<string[]>} */
const bodyLines = computed(() => {
const text = String(liveBody.value ?? '')
if (!text.length) {
return ['']
}
return text.split('\n')
})
/** @type {import('vue').ComputedRef<number[]>} */
const gutterLines = computed(() => bodyLines.value.map((_, index) => index + 1))
/**
* 마크다운에 코드 블록을 반영한다.
* @param {string} body - 본문
* @returns {void}
*/
const commitCodeBlock = (body) => {
emit('commit', buildCodeBlockLines({
language: languageDraft.value,
showLineNumbers: lineNumbersEnabled.value,
body
}))
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
liveBody.value = body
commitCodeBlock(body)
}
/**
* 입력 중 줄 번호 갱신용 본문 동기화
* @param {string} body - 본문
* @returns {void}
*/
const onBodyInput = (body) => {
liveBody.value = body
commitCodeBlock(body)
}
/**
* 코드 블록 아래로 이탈(다음 문단 생성)
* @param {Object} payload - insert-below 페이로드
* @returns {void}
*/
const onExitBelow = (payload) => {
emit('insert-below', payload)
}
/**
* 언어 입력 반영
* @returns {void}
*/
const onLanguageCommit = () => {
commitCodeBlock(props.modelValue)
}
/**
* 줄 번호 표시를 토글한다.
* @returns {void}
*/
const toggleLineNumbers = () => {
lineNumbersEnabled.value = !lineNumbersEnabled.value
commitCodeBlock(props.modelValue)
}
</script>
<template>
<ProseCodeBlock
class="content-markdown-code-block-editor"
:show-line-numbers="lineNumbersEnabled"
:line-numbers="gutterLines"
>
<template #header-tools>
<div
class="content-markdown-code-block-editor__toolbar pointer-events-none flex items-center gap-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
<button
class="content-markdown-code-block-editor__line-numbers pointer-events-auto rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/15 hover:text-white"
type="button"
:aria-pressed="lineNumbersEnabled"
:title="lineNumbersEnabled ? '줄 번호 숨기기' : '줄 번호 표시'"
@mousedown.prevent
@click="toggleLineNumbers"
>
{{ lineNumbersEnabled ? '줄번호' : '줄번호 끔' }}
</button>
<input
v-model="languageDraft"
class="content-markdown-code-block-editor__language pointer-events-auto w-[7.5rem] rounded border border-white/15 bg-white/10 px-2 py-0.5 text-xs text-white outline-none transition-colors placeholder:text-white/35 focus:border-white/30 focus:bg-white/15"
type="text"
placeholder="Language..."
spellcheck="false"
@mousedown.stop
@keydown.stop
@blur="onLanguageCommit"
@keydown.enter.prevent="onLanguageCommit"
>
</div>
</template>
<ContentMarkdownEditableInline
tag="pre"
block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none"
enter-mode="multiline"
plain-text
arrow-exit-creates-line
:source-line="bodySourceLine"
:source-line-count="bodyLines.length"
:model-value="modelValue"
@input="onBodyInput"
@commit="onBodyCommit"
@insert-above="emit('insert-above', $event)"
@insert-below="onExitBelow"
@delete-line="emit('delete-line', $event)"
/>
</ProseCodeBlock>
</template>
<style scoped>
.content-markdown-code-block-editor :deep(.prose-code-block__content) {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.content-markdown-code-block-editor :deep(.prose-code-block__header) {
pointer-events: none;
}
.content-markdown-code-block-editor__toolbar {
pointer-events: none;
}
.content-markdown-code-block-editor__toolbar > * {
pointer-events: auto;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,164 @@
<script setup>
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
import ProseToggle from './ProseToggle.vue'
import ContentMarkdownEditableInline from './ContentMarkdownEditableInline.vue'
const props = defineProps({
/** 토글 제목 */
title: {
type: String,
default: ''
},
/** 토글 본문 */
modelValue: {
type: String,
default: ''
},
/** 기본 펼침 여부 */
defaultOpen: {
type: Boolean,
default: false
},
/** 선언 줄 source-line(0-based) */
titleSourceLine: {
type: Number,
required: true
},
/** 본문 첫 줄 source-line(0-based) */
bodySourceLine: {
type: Number,
required: true
}
})
const emit = defineEmits(['commit', 'insert-above', 'insert-below', 'delete-line'])
const titleEditorRef = ref(null)
const bodyEditorRef = ref(null)
const titleDraft = ref(props.title)
watch(() => props.title, (value) => {
titleDraft.value = value
})
/**
* 토글 마크다운을 반영한다.
* @param {{ title?: string, body?: string }} options - 옵션
* @returns {void}
*/
const commitToggle = (options = {}) => {
emit('commit', buildToggleBlockLines({
title: options.title ?? titleDraft.value,
body: options.body ?? props.modelValue,
defaultOpen: options.defaultOpen ?? props.defaultOpen
}))
}
/**
* 제목 편집 반영
* @param {string} value - 제목
* @returns {void}
*/
const onTitleCommit = (value) => {
titleDraft.value = String(value ?? '').trim()
commitToggle({ title: titleDraft.value })
}
/**
* 제목 필드 이탈 전 로컬 초안 동기화(한글 조합·↓ 이동 시 본문 오염 방지)
* @returns {void}
*/
const syncTitleDraft = () => {
titleDraft.value = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
}
/**
* 제목 Enter — 본문으로 포커스 이동
* @returns {void}
*/
const onTitleEnterAdvance = () => {
const nextTitle = String(titleEditorRef.value?.readEditorValue?.() ?? titleDraft.value).trim()
titleDraft.value = nextTitle
commitToggle({ title: titleDraft.value })
nextTick(() => {
bodyEditorRef.value?.focusEditor('start')
})
}
/**
* 본문 편집 반영
* @param {string} body - 본문
* @returns {void}
*/
const onBodyCommit = (body) => {
commitToggle({ body })
}
/**
* 토글 아래로 이탈
* @param {Object} payload - insert-below 페이로드
* @returns {void}
*/
const onExitBelow = (payload) => {
emit('insert-below', payload)
}
</script>
<template>
<ProseToggle
class="content-markdown-toggle-editor"
data-editable-scope
:title="titleDraft"
:default-open="true"
animated
>
<template #title>
<ContentMarkdownEditableInline
ref="titleEditorRef"
tag="div"
block-class="content-markdown-toggle-editor__title min-h-[1.75rem] outline-none"
enter-mode="focus-next"
navigation-scope="parent"
:source-line="titleSourceLine"
:model-value="titleDraft"
@mousedown.stop
@click.stop
@commit="onTitleCommit"
@enter-advance="onTitleEnterAdvance"
@leave-block="syncTitleDraft"
/>
</template>
<ContentMarkdownEditableInline
ref="bodyEditorRef"
tag="div"
block-class="content-markdown-toggle-editor__body min-h-[3rem] outline-none"
enter-mode="multiline"
navigation-scope="parent"
plain-text
arrow-exit-creates-line
:source-line="bodySourceLine"
:source-line-count="String(modelValue ?? '').split('\n').length"
:model-value="modelValue"
@commit="onBodyCommit"
@insert-above="emit('insert-above', $event)"
@insert-below="onExitBelow"
@delete-line="emit('delete-line', $event)"
/>
</ProseToggle>
</template>
<style scoped>
.content-markdown-toggle-editor__title:empty::before {
content: '토글 제목';
color: var(--site-muted);
pointer-events: none;
}
.content-markdown-toggle-editor__body:empty::before {
content: '펼쳤을 때 보일 내용을 입력하세요';
color: var(--site-muted);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<article class="content-renderer post-prose">
<slot />
</article>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
const props = defineProps({
src: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
}
})
/**
* 표시용 오디오 제목을 반환한다.
* @returns {string} 오디오 제목
*/
const displayTitle = computed(() => props.title || 'Audio')
/**
* 재생 가능한 오디오 URL인지 확인한다.
* @returns {boolean} 오디오 URL 여부
*/
const hasAudioSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
</script>
<template>
<section class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 shadow-[0_14px_36px_rgba(15,23,42,0.06)] sm:p-5">
<div class="prose-audio__inner flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="prose-audio__icon flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[var(--site-accent)] text-white sm:h-[86px] sm:w-[86px]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-9 w-9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 18V5l10-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="16" cy="16" r="3" />
</svg>
</div>
<div class="prose-audio__body min-w-0 flex-1">
<p class="prose-audio__title mb-2 text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
{{ displayTitle }}
</p>
<p v-if="description" class="prose-audio__description mb-3 text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</p>
<audio
v-if="hasAudioSource"
class="prose-audio__player w-full accent-[var(--site-accent)]"
:src="src"
controls
preload="metadata"
/>
<p v-else class="prose-audio__empty text-sm font-semibold text-[var(--site-muted)]">
오디오 URL이 없습니다.
</p>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
const props = defineProps({
variant: {
type: String,
default: 'default'
},
background: {
type: String,
default: 'gray'
}
})
const backgroundClass = computed(() => {
if (props.background === 'gray') {
return 'prose-blockquote--gray'
}
if (props.background === 'blue') {
return 'prose-blockquote--blue'
}
if (props.background === 'green') {
return 'prose-blockquote--green'
}
if (props.background === 'yellow') {
return 'prose-blockquote--yellow'
}
if (props.background === 'red') {
return 'prose-blockquote--red'
}
if (props.background === 'purple') {
return 'prose-blockquote--purple'
}
return 'prose-blockquote--gray'
})
</script>
<template>
<blockquote
class="prose-blockquote mb-5 text-[15px] leading-8"
:class="variant === 'alt'
? 'rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] px-6 py-5 italic text-[var(--site-text)]'
: ['border-l-[3px] bg-transparent py-1 pl-5 pr-0 font-normal text-[#15171a]', backgroundClass]"
>
<span class="block whitespace-pre-line">
<slot />
</span>
</blockquote>
</template>
<style scoped>
.prose-blockquote--gray {
border-color: #050505;
}
.prose-blockquote--blue {
border-color: #0055ff;
}
.prose-blockquote--green {
border-color: #16ae68;
}
.prose-blockquote--yellow {
border-color: #ffff00;
}
.prose-blockquote--red {
border-color: #ff0000;
}
.prose-blockquote--purple {
border-color: #8800ff;
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup>
const props = defineProps({
url: {
type: String,
required: true
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
thumbnail: {
type: String,
default: ''
}
})
/**
* 북마크 카드에 표시할 호스트명을 반환한다.
* @returns {string} www 없는 호스트 또는 빈 문자열
*/
const displayHost = computed(() => {
try {
return new URL(props.url).hostname.replace(/^www\./, '')
} catch {
return ''
}
})
/**
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
* @returns {string} favicon 요청 URL
*/
const faviconUrl = computed(() => {
if (!displayHost.value) {
return ''
}
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
})
/**
* 실제로 표시할 이미지 주소
* @returns {string}
*/
const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
/**
* 표시 제목(없으면 호스트·URL)
* @returns {string}
*/
const displayTitle = computed(() => props.title || displayHost.value || props.url)
/**
* 외부 링크로 열어도 되는 URL인지 확인한다.
* @returns {boolean} 허용 여부
*/
const isSafeBookmarkUrl = computed(() => {
try {
const parsedUrl = new URL(props.url)
return ['http:', 'https:'].includes(parsedUrl.protocol)
} catch {
return false
}
})
</script>
<template>
<a
v-if="isSafeBookmarkUrl"
class="prose-bookmark group prose-bookmark-card my-8 flex max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] no-underline transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:flex-row"
:href="url"
target="_blank"
rel="noopener noreferrer"
>
<div class="prose-bookmark__media relative h-36 w-full shrink-0 overflow-hidden bg-[color-mix(in_srgb,var(--site-line)_40%,var(--site-panel))] sm:h-auto sm:w-[min(44%,220px)] sm:min-h-[9rem]">
<img
v-if="imageSrc"
class="prose-bookmark__thumb h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
:src="imageSrc"
alt=""
loading="lazy"
>
</div>
<div class="prose-bookmark__body flex min-w-0 flex-1 flex-col justify-center gap-1 px-4 py-4 sm:px-5 sm:py-5">
<p v-if="displayHost" class="prose-bookmark__host text-[11px] font-semibold uppercase tracking-[0.06em] text-[var(--site-muted)]">
{{ displayHost }}
</p>
<p class="prose-bookmark__title text-[15px] font-semibold leading-snug text-[var(--site-text)]">
{{ displayTitle }}
</p>
<p v-if="description" class="prose-bookmark__desc line-clamp-2 text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</p>
<p class="prose-bookmark__meta mt-1 flex items-center gap-1.5 text-xs font-medium text-[var(--site-soft)]">
<svg class="shrink-0 opacity-80" 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" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6" />
<path d="M11 13l9 -9" />
<path d="M15 4h5v5" />
</svg>
<span class="truncate">{{ url }}</span>
</p>
</div>
</a>
<p v-else class="prose-bookmark prose-bookmark-invalid my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5 text-sm font-semibold text-[var(--site-muted)]">
지원하지 않는 북마크 URL입니다.
</p>
</template>

View File

@@ -0,0 +1,23 @@
<script setup>
defineProps({
href: {
type: String,
default: '#'
},
align: {
type: String,
default: 'left'
}
})
</script>
<template>
<p class="prose-button my-8" :class="{ 'text-center': align === 'center' }">
<NuxtLink
class="prose-button__link inline-flex items-center justify-center rounded-full bg-[var(--site-text)] px-5 py-2.5 text-sm font-semibold text-[var(--site-bg)] transition-opacity hover:opacity-80"
:to="href"
>
<slot />
</NuxtLink>
</p>
</template>

View File

@@ -0,0 +1,93 @@
<script setup>
const props = defineProps({
emojiEnabled: {
type: Boolean,
default: true
},
emoji: {
type: String,
default: '💡'
},
title: {
type: String,
default: ''
},
background: {
type: String,
default: 'blue'
}
})
const backgroundClass = computed(() => {
if (props.background === 'gray') {
return 'prose-callout--gray'
}
if (props.background === 'green') {
return 'prose-callout--green'
}
if (props.background === 'yellow') {
return 'prose-callout--yellow'
}
if (props.background === 'blue') {
return 'prose-callout--blue'
}
if (props.background === 'purple') {
return 'prose-callout--purple'
}
return 'prose-callout--red'
})
</script>
<template>
<aside
class="prose-callout prose-callout-card mb-2.5 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
:class="backgroundClass"
>
<div v-if="emojiEnabled || title" class="prose-callout-card__header mb-2.5 flex items-start gap-2">
<span v-if="emojiEnabled" class="prose-callout-card__emoji inline-flex shrink-0 pt-0.5 text-[20px] leading-none">{{ emoji || '💡' }}</span>
<strong v-if="title" class="prose-callout-card__title min-w-0 text-[18px] leading-[1.35] font-bold text-[#050505]">
{{ title }}
</strong>
</div>
<div class="prose-callout-card__body min-w-0 whitespace-pre-line">
<slot />
</div>
</aside>
</template>
<style scoped>
.prose-callout--gray {
background: color-mix(in srgb, #050505 10%, #ffffff);
border: 1px solid #050505;
}
.prose-callout--blue {
background: color-mix(in srgb, #0055ff 10%, #ffffff);
border: 1px solid #0055ff;
}
.prose-callout--green {
background: color-mix(in srgb, #16ae68 10%, #ffffff);
border: 1px solid #16ae68;
}
.prose-callout--yellow {
background: color-mix(in srgb, #ffff00 10%, #ffffff);
border: 1px solid #ffff00;
}
.prose-callout--red {
background: color-mix(in srgb, #ff0000 10%, #ffffff);
border: 1px solid #ff0000;
}
.prose-callout--purple {
background: color-mix(in srgb, #8800ff 10%, #ffffff);
border: 1px solid #8800ff;
}
</style>

View File

@@ -0,0 +1,122 @@
<script setup>
const props = defineProps({
/** 언어 라벨(공개 화면 표시) */
language: {
type: String,
default: ''
},
/** 줄 번호 표시 */
showLineNumbers: {
type: Boolean,
default: false
},
/** 복사 버튼 표시(공개 화면) */
showCopy: {
type: Boolean,
default: false
},
/** 복사할 코드 본문 */
copyText: {
type: String,
default: ''
},
/** 줄 번호 목록 */
lineNumbers: {
type: Array,
default: () => [1]
}
})
const copyDone = ref(false)
let copyDoneTimer = null
/**
* 코드 본문을 클립보드에 복사한다.
* @returns {Promise<void>}
*/
const copyToClipboard = async () => {
const text = String(props.copyText ?? '')
if (!import.meta.client || !text) {
return
}
try {
await navigator.clipboard.writeText(text)
copyDone.value = true
window.clearTimeout(copyDoneTimer)
copyDoneTimer = window.setTimeout(() => {
copyDone.value = false
}, 1600)
} catch {
copyDone.value = false
}
}
onBeforeUnmount(() => {
window.clearTimeout(copyDoneTimer)
})
</script>
<template>
<div
class="prose-code-block group relative mb-2.5 overflow-x-auto rounded bg-[#15171a] text-sm leading-6 text-white"
>
<div
v-if="$slots['header-tools'] || showCopy || language"
class="prose-code-block__header absolute right-3 top-2 z-10 flex items-center gap-2"
>
<slot name="header-tools" />
<span
v-if="language && !$slots['header-tools']"
class="prose-code-block__language text-xs text-white/50"
>{{ language }}</span>
<button
v-if="showCopy"
class="prose-code-block__copy rounded px-2 py-0.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
type="button"
@click="copyToClipboard"
>
{{ copyDone ? '복사됨' : '복사' }}
</button>
</div>
<div class="prose-code-block__body flex">
<div
v-if="showLineNumbers"
class="prose-code-block__gutter shrink-0 select-none border-r border-white/10 py-3 pl-3 pr-2 font-mono text-xs leading-6 text-white/40 tabular-nums"
aria-hidden="true"
>
<div
v-for="lineNumber in lineNumbers"
:key="`prose-code-gutter-${lineNumber}`"
class="prose-code-block__gutter-line"
>
{{ lineNumber }}
</div>
</div>
<div class="prose-code-block__content min-w-0 flex-1 px-4 py-3 font-mono text-sm leading-6">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.prose-code-block:focus-within {
outline: 2px solid rgb(255 255 255 / 0.22);
outline-offset: 0;
}
.prose-code-block__content :deep(code) {
display: block;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
line-height: inherit;
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup>
const props = defineProps({
url: {
type: String,
default: ''
}
})
const { theme } = useThemeMode()
/**
* YouTube 영상 ID를 추출한다.
* @param {string} value - 임베드 URL
* @returns {string} YouTube 영상 ID
*/
const getYouTubeId = (value) => {
try {
const parsedUrl = new URL(value)
if (parsedUrl.hostname.includes('youtu.be')) {
return parsedUrl.pathname.replace('/', '')
}
if (parsedUrl.hostname.includes('youtube.com')) {
return parsedUrl.searchParams.get('v') || parsedUrl.pathname.split('/').pop() || ''
}
} catch {
return ''
}
return ''
}
/**
* Twitter/X 게시물 ID를 추출한다.
* @param {string} value - 트윗 URL
* @returns {string} 상태 ID
*/
const getTweetId = (value) => {
try {
const trimmed = value.trim()
const parsedUrl = new URL(trimmed)
const host = parsedUrl.hostname.replace(/^www\./, '')
if (!['twitter.com', 'x.com', 'mobile.twitter.com'].includes(host)) {
return ''
}
const parts = parsedUrl.pathname.split('/').filter(Boolean)
const statusIdx = parts.indexOf('status')
if (statusIdx >= 0 && parts[statusIdx + 1]) {
return parts[statusIdx + 1].split(/[?#]/)[0] || ''
}
} catch {
return ''
}
return ''
}
/**
* Mastodon 공개 게시물 URL인지 확인하고 embed URL을 반환한다.
* @param {string} value - Mastodon 게시물 URL
* @returns {string} Mastodon embed URL
*/
const getMastodonEmbedUrl = (value) => {
try {
const parsedUrl = new URL(value.trim())
const path = parsedUrl.pathname.replace(/\/$/, '')
const isKnownNonMastodonHost = [
'twitter.com',
'x.com',
'mobile.twitter.com',
'youtube.com',
'www.youtube.com',
'youtu.be'
].includes(parsedUrl.hostname.replace(/^www\./, ''))
if (
isKnownNonMastodonHost ||
!['http:', 'https:'].includes(parsedUrl.protocol)
) {
return ''
}
if (/^\/@[^/]+\/\d+$/.test(path) || /^\/users\/[^/]+\/statuses\/\d+$/.test(path)) {
return `${parsedUrl.origin}${path}/embed`
}
} catch {
return ''
}
return ''
}
const youtubeId = computed(() => getYouTubeId(props.url))
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
const tweetId = computed(() => getTweetId(props.url))
const mastodonEmbedUrl = computed(() => getMastodonEmbedUrl(props.url))
const mastodonIframeRef = ref(null)
const mastodonEmbedHeight = ref(640)
const mastodonEmbedId = ref(0)
/**
* 외부 링크로 열어도 되는 URL인지 확인한다.
* @param {string} value - 검사할 URL
* @returns {boolean} 허용 여부
*/
const isSafeExternalUrl = (value) => {
try {
const parsedUrl = new URL(value)
return ['http:', 'https:'].includes(parsedUrl.protocol)
} catch {
return false
}
}
const safeExternalUrl = computed(() => isSafeExternalUrl(props.url) ? props.url : '')
/**
* Twitter 공식 embed iframe 주소
* @returns {string}
*/
const tweetEmbedUrl = computed(() => {
if (!tweetId.value) {
return ''
}
const twitterTheme = theme.value === 'dark' ? 'dark' : 'light'
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
})
/**
* Mastodon embed iframe에 실제 콘텐츠 높이 계산을 요청한다.
* @returns {void}
*/
const requestMastodonEmbedHeight = () => {
if (!mastodonIframeRef.value?.contentWindow || !mastodonEmbedId.value) {
return
}
mastodonIframeRef.value.contentWindow.postMessage({
type: 'setHeight',
id: mastodonEmbedId.value
}, '*')
}
/**
* Mastodon embed 높이 응답을 반영한다.
* @param {MessageEvent} event - iframe 메시지 이벤트
* @returns {void}
*/
const handleMastodonEmbedMessage = (event) => {
const data = event.data || {}
if (
!mastodonIframeRef.value ||
event.source !== mastodonIframeRef.value.contentWindow ||
typeof data !== 'object' ||
data.type !== 'setHeight' ||
data.id !== mastodonEmbedId.value ||
typeof data.height !== 'number'
) {
return
}
try {
const expectedOrigin = new URL(mastodonEmbedUrl.value).origin
if (event.origin !== expectedOrigin) {
return
}
} catch {
return
}
mastodonEmbedHeight.value = Math.max(320, Math.ceil(data.height))
}
onMounted(() => {
mastodonEmbedId.value = Math.floor(Math.random() * 1000000000) + 1
window.addEventListener('message', handleMastodonEmbedMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMastodonEmbedMessage)
})
watch(mastodonEmbedUrl, () => {
mastodonEmbedHeight.value = 640
requestMastodonEmbedHeight()
})
</script>
<template>
<div
class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px]"
:class="tweetEmbedUrl ? 'mx-auto max-w-[550px]' : mastodonEmbedUrl ? 'mx-auto max-w-[560px] border border-[var(--site-line)] bg-[var(--site-panel)]' : 'border border-[var(--site-line)] bg-[var(--site-panel)]'"
>
<iframe
v-if="youtubeEmbedUrl"
class="prose-embed__frame aspect-video w-full"
:src="youtubeEmbedUrl"
title="Embedded video"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
/>
<iframe
v-else-if="tweetEmbedUrl"
:key="tweetEmbedUrl"
class="prose-embed__tweet block min-h-[560px] w-full border-0 sm:min-h-[620px]"
:src="tweetEmbedUrl"
title="Embedded post"
loading="lazy"
/>
<iframe
v-else-if="mastodonEmbedUrl"
:key="mastodonEmbedUrl"
ref="mastodonIframeRef"
class="prose-embed__mastodon block w-full border-0"
:src="mastodonEmbedUrl"
:height="mastodonEmbedHeight"
title="Embedded Mastodon post"
allow="fullscreen"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups"
scrolling="no"
@load="requestMastodonEmbedHeight"
/>
<a
v-else-if="safeExternalUrl"
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
:href="safeExternalUrl"
target="_blank"
rel="noreferrer"
>
{{ url }}
</a>
<p v-else class="prose-embed__invalid p-5 text-sm font-semibold text-[var(--site-muted)]">
지원하지 않는 임베드 URL입니다.
</p>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup>
const props = defineProps({
href: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
fileName: {
type: String,
default: ''
},
size: {
type: String,
default: ''
}
})
/**
* 다운로드 가능한 파일 URL인지 확인한다.
* @returns {boolean} 파일 URL 여부
*/
const isSafeFileUrl = computed(() => Boolean(props.href && (props.href.startsWith('/') || /^https?:\/\//i.test(props.href))))
/**
* 파일 확장자를 제거한 표시명을 반환한다.
* @param {string} filename - 파일명
* @returns {string} 확장자를 제외한 이름
*/
const stripFileExtension = (filename) => String(filename || '').replace(/\.[^.]+$/, '')
/**
* 카드 제목을 반환한다.
* @returns {string} 제목
*/
const displayTitle = computed(() => {
if (props.fileName && (!props.title || props.title === stripFileExtension(props.fileName))) {
return props.fileName
}
return props.title || props.fileName || 'File'
})
/**
* 표시 파일명을 반환한다.
* @returns {string} 파일명
*/
const displayFileName = computed(() => {
if (props.fileName) {
return props.fileName
}
try {
const parsedUrl = props.href.startsWith('/') ? new URL(props.href, 'https://local.invalid') : new URL(props.href)
const lastSegment = parsedUrl.pathname.split('/').filter(Boolean).pop()
return lastSegment ? decodeURIComponent(lastSegment) : ''
} catch {
return ''
}
})
/**
* 파일 카드 보조 정보를 반환한다.
* @returns {string} 보조 정보
*/
const displayMeta = computed(() => {
const title = String(displayTitle.value || '').trim()
const fileName = String(displayFileName.value || '').trim()
if (title && fileName && title === fileName) {
return props.size || ''
}
if (fileName) {
return props.size ? `${fileName} · ${props.size}` : fileName
}
return props.size || props.href
})
</script>
<template>
<a
v-if="isSafeFileUrl"
class="prose-file group my-8 flex items-center gap-4 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 no-underline shadow-[0_14px_36px_rgba(15,23,42,0.06)] transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:p-5"
:href="href"
download
>
<span class="prose-file__body min-w-0 flex-1">
<span class="prose-file__title block text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
{{ displayTitle }}
</span>
<span v-if="description" class="prose-file__description mt-1 block text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</span>
<span v-if="displayMeta" class="prose-file__meta mt-3 block truncate text-sm font-semibold text-[var(--site-text)]">
{{ displayMeta }}
</span>
</span>
<span class="prose-file__download flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[color-mix(in_srgb,var(--site-line)_36%,var(--site-panel))] text-[var(--site-accent)] transition-transform group-hover:scale-[1.02] sm:h-[86px] sm:w-[86px]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4v11" />
<path d="m8 11 4 4 4-4" />
<path d="M5 20h14" />
</svg>
</span>
</a>
<p v-else class="prose-file prose-file-invalid my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5 text-sm font-semibold text-[var(--site-muted)]">
파일 URL이 없습니다.
</p>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
defineProps({
variant: {
type: String,
default: 'simple'
}
})
</script>
<template>
<header
class="prose-header-card my-8 overflow-hidden rounded-[14px] border border-[var(--site-line)] bg-[var(--site-panel)] p-8 text-[var(--site-text)]"
:class="`prose-header-card--${variant}`"
>
<slot />
</header>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
const props = defineProps({
level: {
type: Number,
default: 2
},
id: {
type: String,
default: ''
}
})
const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
</script>
<template>
<component
:is="tagName"
:id="id || undefined"
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
style="scroll-margin-top: calc(var(--site-top-chrome-height, 57px) + 24px)"
:class="{
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,
'text-[clamp(1.1rem,1.05rem+0.25vw,1.25rem)]': level === 3,
'text-[clamp(1.025rem,1rem+0.2vw,1.15rem)]': level === 4,
'text-[clamp(0.95rem,0.925rem+0.15vw,1.05rem)]': level === 5,
'text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]': level === 6
}"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,126 @@
<script setup>
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
const props = defineProps({
src: {
type: String,
default: ''
},
alt: {
type: String,
default: ''
},
/** 이미지 아래 표시용 캡션 */
caption: {
type: String,
default: ''
},
variant: {
type: String,
default: 'regular'
}
})
const loadFailed = ref(false)
const hasRenderableSrc = computed(() => String(props.src || '').trim().length > 0)
const errorLabel = computed(() => {
const trimmed = String(props.src || '').trim()
if (!trimmed) {
return '이미지 URL이 비어 있습니다'
}
const filename = getImageDefaultAltLabel(trimmed)
return filename ? `이미지를 불러올 수 없습니다 · ${filename}` : '이미지를 불러올 수 없습니다'
})
watch(() => props.src, () => {
loadFailed.value = false
})
/**
* 이미지 로드 실패 시 placeholder를 표시한다.
* @returns {void}
*/
const onImageError = () => {
loadFailed.value = true
}
/**
* 이미지 로드 성공 시 오류 상태를 해제한다.
* @returns {void}
*/
const onImageLoad = () => {
loadFailed.value = false
}
</script>
<template>
<figure
class="prose-image mb-2.5"
:class="{
'prose-image--wide lg:-mx-10 lg:max-w-none': variant === 'wide',
'prose-image--full lg:-mx-20 lg:max-w-none': variant === 'full'
}"
>
<div
class="prose-image__frame overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]"
:class="{
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
'prose-image__frame--broken': loadFailed
}"
>
<img
v-if="hasRenderableSrc && !loadFailed"
class="prose-image__media w-full object-cover"
:src="src"
:alt="alt"
@load="onImageLoad"
@error="onImageError"
>
<div
v-else
class="prose-image__placeholder flex min-h-[180px] flex-col items-center justify-center gap-2 px-4 py-6 text-center"
role="img"
:aria-label="errorLabel"
>
<span class="prose-image__placeholder-icon text-2xl text-[var(--site-muted)]" aria-hidden="true">!</span>
<p class="prose-image__placeholder-text max-w-full break-all text-sm text-[var(--site-muted)]">
{{ errorLabel }}
</p>
</div>
</div>
<figcaption
v-if="caption"
class="prose-image__caption mt-1.5 text-center text-sm text-[var(--site-muted)]"
>
{{ caption }}
</figcaption>
</figure>
</template>
<style scoped>
.prose-image__frame--empty,
.prose-image__frame--broken {
min-height: 180px;
}
.prose-image__frame:not(.prose-image__frame--empty):not(.prose-image__frame--broken) {
min-height: 120px;
}
.prose-image__placeholder-icon {
display: inline-flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: 1px dashed var(--site-line);
background: color-mix(in srgb, var(--site-panel) 88%, #fff 12%);
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup>
defineProps({
ordered: {
type: Boolean,
default: false
}
})
</script>
<template>
<component
:is="ordered ? 'ol' : 'ul'"
class="prose-list mb-2.5 list-none space-y-2 pl-0 text-[15px] leading-8 text-[var(--site-text)]"
>
<slot />
</component>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="prose-product my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<slot />
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
defineProps({
title: {
type: String,
default: '뉴스레터에 가입하세요'
},
description: {
type: String,
default: '새 글이 올라오면 받아보실 수 있어요.'
},
buttonLabel: {
type: String,
default: '구독하기'
},
placeholder: {
type: String,
default: 'you@example.com'
}
})
</script>
<template>
<section class="prose-signup prose-signup-card my-8 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-8 text-center sm:px-8">
<h3 class="prose-signup__title text-[clamp(1rem,0.95rem+0.25vw,1.125rem)] font-semibold leading-snug text-[var(--site-text)]">
{{ title }}
</h3>
<p class="prose-signup__desc mt-2 text-[15px] leading-7 text-[var(--site-muted)]">
{{ description }}
</p>
<form class="prose-signup__form mt-6 flex flex-col items-stretch gap-2.5 sm:flex-row sm:justify-center" action="#" @submit.prevent>
<input
class="site-input prose-signup__input min-h-[44px] w-full rounded-full px-4 text-sm sm:max-w-[300px] sm:flex-1"
type="email"
:placeholder="placeholder"
readonly
tabindex="-1"
aria-label="이메일"
>
<button class="site-accent-button prose-signup__submit min-h-[44px] shrink-0 rounded-full px-8 text-sm font-semibold" type="button">
{{ buttonLabel }}
</button>
</form>
</section>
</template>

View File

@@ -0,0 +1,112 @@
<script setup>
const props = defineProps({
/** 접힌 상태 제목 */
title: {
type: String,
default: ''
},
/** 초기 펼침 여부 */
defaultOpen: {
type: Boolean,
default: false
},
/** 본문 열림·닫힘 애니메이션 */
animated: {
type: Boolean,
default: true
}
})
const isOpen = ref(props.defaultOpen)
watch(() => props.defaultOpen, (value) => {
isOpen.value = value
})
/**
* 토글 펼침 상태를 전환한다.
* @returns {void}
*/
const toggleOpen = () => {
isOpen.value = !isOpen.value
}
</script>
<template>
<div
class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5"
:class="{ 'prose-toggle--open': isOpen }"
>
<div class="prose-toggle__header flex items-start gap-2">
<button
class="prose-toggle__trigger mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--site-muted)] transition-colors hover:bg-black/5 hover:text-[var(--site-text)]"
type="button"
:aria-expanded="isOpen"
aria-label="토글 펼치기·접기"
@click="toggleOpen"
>
<svg
class="prose-toggle__chevron size-4 transition-transform duration-300 ease-out"
:class="{ 'prose-toggle__chevron--open': isOpen }"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M6 4l4 4-4 4"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<div class="prose-toggle__title min-w-0 flex-1 text-[15px] font-semibold leading-7 text-[var(--site-text)]">
<slot name="title">
{{ title || '더 보기' }}
</slot>
</div>
</div>
<div
class="prose-toggle__body-shell"
:class="[
animated ? 'prose-toggle__body-shell--animated' : '',
isOpen ? 'prose-toggle__body-shell--open' : ''
]"
>
<div class="prose-toggle__body-inner min-h-0 overflow-hidden">
<div
class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]"
>
<slot />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.prose-toggle__chevron--open {
transform: rotate(90deg);
}
.prose-toggle__body-shell--animated {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.32s ease;
}
.prose-toggle__body-shell--animated.prose-toggle__body-shell--open {
grid-template-rows: 1fr;
}
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated) .prose-toggle__body-inner {
display: none;
}
.prose-toggle__body-shell:not(.prose-toggle__body-shell--animated).prose-toggle__body-shell--open .prose-toggle__body-inner {
display: block;
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup>
const props = defineProps({
src: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
poster: {
type: String,
default: ''
},
caption: {
type: String,
default: ''
}
})
/**
* 재생 가능한 비디오 URL인지 확인한다.
* @returns {boolean} 비디오 URL 여부
*/
const hasVideoSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
</script>
<template>
<figure class="prose-video my-8">
<div class="prose-video__shell overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
<video
v-if="hasVideoSource"
class="prose-video__media aspect-video w-full bg-black object-cover"
:src="src"
:poster="poster || undefined"
:title="title || 'Video'"
controls
preload="metadata"
/>
<div
v-else
class="prose-video__empty flex aspect-video w-full items-center justify-center bg-[color-mix(in_srgb,var(--site-line)_45%,var(--site-panel))] text-sm font-semibold text-[var(--site-muted)]"
>
비디오 URL이 없습니다.
</div>
</div>
<figcaption
v-if="caption || title"
class="prose-video__caption mt-3 text-center text-sm leading-relaxed text-[var(--site-muted)]"
>
{{ caption || title }}
</figcaption>
</figure>
</template>

View File

@@ -0,0 +1,109 @@
<script setup>
const props = defineProps({
/** 커버 이미지 URL */
imageUrl: {
type: String,
default: ''
},
/** 다크모드 커버 이미지 URL */
darkImageUrl: {
type: String,
default: ''
},
/** 오버레이 제목 */
title: {
type: String,
default: ''
},
/** 오버레이 본문 */
text: {
type: String,
default: ''
}
})
/** @type {import('vue').ComputedRef<boolean>} */
const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.trim()))
/** @type {import('vue').ComputedRef<boolean>} */
const lightImageUrl = computed(() => props.imageUrl?.trim() || props.darkImageUrl?.trim() || '')
/** @type {import('vue').ComputedRef<boolean>} */
const hasDarkImage = computed(() => Boolean(props.imageUrl?.trim() && props.darkImageUrl?.trim()))
</script>
<template>
<section
v-if="lightImageUrl"
class="home-hero relative mx-auto w-full max-w-[720px] overflow-hidden"
data-home-hero
>
<div class="home-hero__frame relative aspect-[720/215] w-full bg-[var(--site-panel)]">
<img
:class="[
'home-hero__cover home-hero__cover--light absolute inset-0 h-full w-full object-cover',
hasDarkImage ? '' : 'home-hero__cover--single'
]"
:src="lightImageUrl"
alt=""
loading="eager"
>
<img
v-if="hasDarkImage"
class="home-hero__cover home-hero__cover--dark absolute inset-0 h-full w-full object-cover"
:src="darkImageUrl"
alt=""
loading="eager"
>
<div
v-if="hasOverlay"
class="home-hero__overlay pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent px-4 pb-4 pt-12 sm:px-5 sm:pb-5"
>
<div class="home-hero__overlay-inner flex flex-col items-start gap-1 text-left">
<h2
v-if="title"
class="home-hero__title text-base font-semibold leading-snug text-white sm:text-lg"
>
{{ title }}
</h2>
<p
v-if="text"
class="home-hero__text max-w-[32rem] whitespace-pre-line text-sm leading-relaxed text-white/85"
>
{{ text }}
</p>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.home-hero__cover--dark {
display: none;
}
@media (prefers-color-scheme: dark) {
.home-hero__cover--light:not(.home-hero__cover--single) {
display: none;
}
.home-hero__cover--dark {
display: block;
}
}
html[data-theme='light'] .home-hero__cover--light {
display: block;
}
html[data-theme='light'] .home-hero__cover--dark {
display: none;
}
html[data-theme='dark'] .home-hero__cover--light:not(.home-hero__cover--single) {
display: none;
}
html[data-theme='dark'] .home-hero__cover--dark {
display: block;
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup>
defineProps({
menuOpen: {
type: Boolean,
required: true
}
})
const { isDarkMode, toggleTheme } = useThemeMode()
const route = useRoute()
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
adPostSidebarCode: ''
})
})
const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
const { data: navigation } = await useFetch('/api/navigation', {
default: () => ({
primary: [],
footer: [],
recommended: []
})
})
/** 저자 영역 공개 여부 */
const showAuthorSection = false
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const STORAGE_KEY = 'sori-primary-nav-expanded'
/**
* 트리에서 하위가 있는 노드 id를 모은다.
* @param {Array<Object>} list - 노드 목록
* @returns {string[]} id 목록
*/
const collectBranchIds = (list) => {
const out = []
for (const node of list || []) {
if (node.children?.length) {
out.push(String(node.id))
out.push(...collectBranchIds(node.children))
}
}
return out
}
/**
* localStorage에서 펼침 상태를 읽는다.
* @returns {string[]|null} id 배열
*/
const readStoredExpanded = () => {
if (typeof window === 'undefined') {
return null
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.map(String) : null
} catch {
return null
}
}
/**
* 펼침 상태를 localStorage에 저장한다.
* @param {Set<string>} set - id 집합
* @returns {void}
*/
const persistExpanded = (set) => {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
}
const primaryNavExpandedSet = ref(new Set())
/**
* 트리 구조에 맞게 펼침 집합을 맞춘다.
* @param {Array<Object>} nodes - primary 트리
* @param {boolean} useStorage - 최초·복원 시 저장값 반영
* @returns {void}
*/
const syncPrimaryNavExpanded = (nodes, useStorage = false) => {
const allBranch = new Set(collectBranchIds(nodes))
if (useStorage) {
const stored = readStoredExpanded()
if (stored && stored.length) {
const next = new Set()
for (const id of stored) {
if (allBranch.has(id)) {
next.add(id)
}
}
primaryNavExpandedSet.value = next.size ? next : allBranch
return
}
}
const next = new Set()
for (const id of primaryNavExpandedSet.value) {
if (allBranch.has(id)) {
next.add(id)
}
}
primaryNavExpandedSet.value = next.size ? next : allBranch
}
/**
* 상단 네비 폴더 펼침 토글
* @param {string} id - 노드 id
* @returns {void}
*/
const togglePrimaryNavBranch = (id) => {
const key = String(id)
const next = new Set(primaryNavExpandedSet.value)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
primaryNavExpandedSet.value = next
persistExpanded(next)
}
provide('sidebarPrimaryNavExpandedSet', primaryNavExpandedSet)
provide('sidebarPrimaryNavToggle', togglePrimaryNavBranch)
watch(
() => navigation.value?.primary,
(nodes) => {
syncPrimaryNavExpanded(nodes || [], false)
},
{ deep: true }
)
onMounted(() => {
syncPrimaryNavExpanded(navigation.value?.primary || [], true)
})
</script>
<template>
<aside
id="menu"
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:top-[57px] max-lg:z-[60] max-lg:h-[calc(100dvh-57px)] max-lg:max-h-[calc(100dvh-57px)] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start"
:class="menuOpen
? 'max-lg:translate-x-0 max-lg:pointer-events-auto lg:w-[287px] lg:opacity-100'
: 'max-lg:-translate-x-full max-lg:pointer-events-none lg:w-0 lg:opacity-0 lg:border-transparent'"
>
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
<nav class="left-sidebar__nav" data-nav="menu">
<SidebarPrimaryNavList :nodes="navigation.primary" />
</nav>
</div>
<div class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0">
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
<span>Categories</span>
<span class="text-sm"></span>
</div>
<div class="left-sidebar__category-grid mt-1.5 grid grid-cols-2 gap-x-2 gap-y-[2px] text-[0.8rem] font-medium">
<NuxtLink
v-for="tag in tags"
:key="tag.id"
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
:to="`/tag/${tag.slug}`"
>
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="left-sidebar__category-name flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ tag.name }}</span>
<span
v-if="tag.postCount"
class="left-sidebar__category-count invisible text-xs font-medium opacity-0 transition-opacity duration-200 group-hover:visible group-hover:opacity-100"
>
{{ tag.postCount }}
</span>
</NuxtLink>
</div>
</div>
<div v-if="showAuthorSection" class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
<span>Authors</span>
<span></span>
</div>
<div class="left-sidebar__authors mt-4 grid gap-4 text-sm">
<div class="left-sidebar__author flex items-center gap-3">
<span class="h-8 w-8 rounded-full bg-[#e7c49d]" />
<span><strong class="block">sori</strong><span class="site-soft">Editor</span></span>
</div>
<div class="left-sidebar__author flex items-center gap-3">
<span class="h-8 w-8 rounded-full bg-[#98b7d5]" />
<span><strong class="block">zenn</strong><span class="site-soft">Writer</span></span>
</div>
</div>
</div>
<SiteAdSlot
v-if="isPostDetailRoute"
class="left-sidebar__post-ad-slot site-sidebar-section px-5 py-5 pr-3 max-lg:hidden xl:pl-0"
:code="siteSettings?.adPostSidebarCode"
location="post-sidebar-left"
/>
</div>
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
<nav
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
aria-label="하단 링크"
>
<NuxtLink
v-for="item in navigation.footer"
:key="item.id"
class="left-sidebar__footer-link site-interactive shrink-0"
:to="item.url"
>
{{ item.label }}
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
@click="toggleTheme"
>
<span v-if="isDarkMode"></span>
<span v-else></span>
</button>
</footer>
</aside>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="main-column w-full max-w-full lg:max-w-[720px]">
<slot />
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
defineProps({
post: {
type: Object,
required: true
}
})
</script>
<template>
<article class="post-card site-section site-panel-hover group">
<div class="post-card__body site-section-body flex gap-4">
<PostCardMedia
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
link-class="h-20 w-36 shrink-0"
aspect-class="h-full w-full"
/>
<div class="post-card__content min-w-0">
<h2 class="post-card__title text-base font-semibold leading-tight">
<NuxtLink class="post-card__title-link site-interactive hover:opacity-70" :to="post.to">
{{ post.title }}
</NuxtLink>
</h2>
<p
v-if="post.excerpt"
class="post-card__excerpt post-summary-clamp post-summary-clamp--two mt-2 text-sm leading-6 site-muted"
>
{{ post.excerpt }}
</p>
<p class="post-card__meta mt-2 text-xs site-muted">
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
</p>
</div>
</div>
</article>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
defineProps({
/** 게시물 링크 */
to: {
type: String,
required: true
},
/** 게시물 제목 */
title: {
type: String,
required: true
},
/** 대표 이미지 URL */
featuredImage: {
type: String,
default: ''
},
/** 썸네일 비율·크기 Tailwind 클래스 */
aspectClass: {
type: String,
default: 'aspect-square sm:aspect-video'
},
/** 링크 래퍼 추가 클래스 */
linkClass: {
type: String,
default: ''
},
/** 이미지 추가 클래스 */
imageClass: {
type: String,
default: ''
}
})
</script>
<template>
<NuxtLink
:to="to"
class="post-card-media relative block"
:class="linkClass"
data-post-card-media
>
<figure class="post-card-media__figure overflow-hidden rounded-[10px]">
<img
v-if="featuredImage"
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="[aspectClass, imageClass]"
:src="featuredImage"
:alt="title"
loading="lazy"
>
<span
v-else
class="post-card-media__placeholder flex w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)] transition-opacity duration-200 group-hover:opacity-90"
:class="aspectClass"
:aria-label="title"
>
<span class="post-card-media__placeholder-text line-clamp-4">{{ title }}</span>
</span>
</figure>
</NuxtLink>
</template>

View File

@@ -0,0 +1,514 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
import { getVisibleSocialLinks } from '~/lib/social-links.js'
const route = useRoute()
const postToc = useState('post-detail-toc', () => [])
const tocNavRef = ref(null)
const activeTocId = ref('')
let tocScrollFrame = 0
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
logoText: '井',
logoUrl: '',
socialLinks: [],
copyrightText: '©2026 sori.studio'
})
})
const { data: navigation } = await useFetch('/api/navigation', {
default: () => ({
primary: [],
footer: [],
recommended: []
})
})
/**
* 공개 추천 사이트 목록(비가시 제외)
* @returns {Array<{ id: string, label: string, url: string, descriptionText?: string, thumbnailUrl?: string }>}
*/
const recommendedSites = computed(() => {
const list = navigation.value?.recommended
if (!Array.isArray(list)) {
return []
}
return list.filter((x) => x?.isVisible !== false)
})
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const sidebarAdCode = computed(() => isPostDetailRoute.value ? '' : siteSettings.value?.adSidebarCode)
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
/**
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
* @returns {number} 뷰포트 상단 기준 오프셋
*/
const getTocActivationOffset = () => {
if (!import.meta.client) {
return 96
}
const topChromeHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--site-top-chrome-height'))
return (Number.isFinite(topChromeHeight) ? topChromeHeight : 57) + 28
}
/**
* 현재 본문 스크롤 위치에 해당하는 TOC 항목 ID를 계산한다.
* @returns {string} 활성 제목 ID
*/
const findActiveTocId = () => {
if (!import.meta.client || !postTocItems.value.length) {
return ''
}
const offset = getTocActivationOffset()
const currentY = window.scrollY + offset
let activeId = postTocItems.value[0].id
for (const item of postTocItems.value) {
const target = document.getElementById(item.id)
if (!target) {
continue
}
const targetY = target.getBoundingClientRect().top + window.scrollY
if (targetY <= currentY) {
activeId = item.id
} else {
break
}
}
return activeId
}
/**
* 활성 TOC 링크가 내부 스크롤 영역 안에 보이도록 보정한다.
* @param {string} id - 활성 제목 ID
* @returns {void}
*/
const scrollActiveTocIntoView = (id) => {
if (!import.meta.client || !id || !(tocNavRef.value instanceof HTMLElement)) {
return
}
const nav = tocNavRef.value
const link = nav.querySelector(`[data-toc-id="${id}"]`)
if (!(link instanceof HTMLElement)) {
return
}
const navTop = nav.scrollTop
const navBottom = navTop + nav.clientHeight
const linkTop = link.offsetTop
const linkBottom = linkTop + link.offsetHeight
const buffer = 24
if (linkTop < navTop + buffer) {
nav.scrollTo({
top: Math.max(0, linkTop - buffer),
behavior: 'smooth'
})
return
}
if (linkBottom > navBottom - buffer) {
nav.scrollTo({
top: linkBottom - nav.clientHeight + buffer,
behavior: 'smooth'
})
}
}
/**
* 스크롤 이벤트에서 TOC 활성 항목을 갱신한다.
* @returns {void}
*/
const updateActiveToc = () => {
if (!import.meta.client || tocScrollFrame) {
return
}
tocScrollFrame = window.requestAnimationFrame(() => {
tocScrollFrame = 0
const nextActiveId = findActiveTocId()
if (!nextActiveId || nextActiveId === activeTocId.value) {
return
}
activeTocId.value = nextActiveId
scrollActiveTocIntoView(nextActiveId)
})
}
/**
* 새 탭으로 열 외부 URL인지
* @param {string} url - 링크
* @returns {boolean}
*/
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)
}
/**
* 게시글 목차 링크를 부드럽게 이동한다.
* @param {MouseEvent} event - 클릭 이벤트
* @param {string} id - 이동할 제목 ID
* @returns {void}
*/
const scrollToTocItem = (event, id) => {
if (!import.meta.client) {
return
}
const target = document.getElementById(id)
if (!target) {
return
}
event.preventDefault()
activeTocId.value = id
scrollActiveTocIntoView(id)
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
window.history.replaceState(null, '', `${route.path}#${id}`)
}
/** 소개 영역 공개 여부 */
const showAboutSection = false
onMounted(() => {
if (!import.meta.client) {
return
}
window.addEventListener('scroll', updateActiveToc, { passive: true })
window.addEventListener('resize', updateActiveToc)
nextTick(updateActiveToc)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
window.removeEventListener('scroll', updateActiveToc)
window.removeEventListener('resize', updateActiveToc)
if (tocScrollFrame) {
window.cancelAnimationFrame(tocScrollFrame)
tocScrollFrame = 0
}
})
watch([postTocItems, () => route.fullPath], async () => {
activeTocId.value = ''
await nextTick()
updateActiveToc()
})
</script>
<template>
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
<div class="right-sidebar__profile flex items-center gap-3">
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
<img
v-if="siteSettings.logoUrl"
class="h-full w-full object-cover"
:src="siteSettings.logoUrl"
:alt="siteSettings.title"
>
<span v-else>{{ siteSettings.logoText }}</span>
</div>
<div>
<p class="right-sidebar__title font-semibold">
{{ siteSettings.title }}
</p>
<p class="right-sidebar__description text-sm site-muted">
{{ siteSettings.description }}
</p>
</div>
</div>
</div>
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Follow
</p>
<nav class="right-sidebar__social relative z-10 flex flex-wrap items-center gap-1 text-sm text-[var(--site-text)]">
<a
v-for="item in followLinks"
:key="item.id"
class="right-sidebar__social-link site-interactive inline-flex h-5 w-5 items-center justify-center p-0.5 leading-none hover:opacity-75"
:href="item.href"
:aria-label="item.label"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noreferrer' : undefined"
>
<svg
v-if="item.icon === 'facebook'"
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"
class="block h-4 w-4 shrink-0"
>
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
</svg>
<svg
v-else-if="item.icon === 'x'"
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"
class="block h-4 w-4 shrink-0"
>
<path d="M4 4l11.733 16H20L8.267 4z" />
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
</svg>
<svg
v-else-if="item.icon === 'github'"
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"
class="block h-4 w-4 shrink-0"
>
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0c-2.4-1.6-3.5-1.3-3.5-1.3a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21" />
</svg>
<svg
v-else-if="item.icon === 'instagram'"
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"
class="block h-4 w-4 shrink-0"
>
<rect x="4" y="4" width="16" height="16" rx="4" />
<circle cx="12" cy="12" r="3" />
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501" />
</svg>
<svg
v-else-if="item.icon === 'linkedin'"
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"
class="block h-4 w-4 shrink-0"
>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</svg>
<svg
v-else-if="item.icon === 'youtube'"
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"
class="block h-4 w-4 shrink-0"
>
<path d="M2.5 17a24.1 24.1 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
<path d="m10 15 5-3-5-3z" />
</svg>
<svg
v-else-if="item.icon === 'rss'"
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"
class="block h-4 w-4 shrink-0"
>
<circle cx="5" cy="19" r="1" />
<path d="M4 4a16 16 0 0 1 16 16" />
<path d="M4 11a9 9 0 0 1 9 9" />
</svg>
<span
v-else-if="item.icon === 'custom' && item.iconSvg"
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center fill-[var(--site-text)]"
aria-hidden="true"
v-html="item.iconSvg"
/>
<svg
v-else
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"
class="block h-4 w-4 shrink-0"
>
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.15-1.15" />
</svg>
<span class="sr-only">{{ item.label }}</span>
</a>
</nav>
</div>
</div>
<div
v-if="isPostDetailRoute"
class="right-sidebar__block right-sidebar__toc site-sidebar-section py-5 pl-5 pr-0 max-lg:hidden"
>
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
TOC
</p>
</div>
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 max-h-[min(28rem,calc(100vh-18rem))] overflow-y-auto pr-2" aria-label="게시글 목차">
<ul v-if="postTocItems.length" class="right-sidebar__toc-list list-none space-y-2 p-0">
<li v-for="item in postTocItems" :key="item.id">
<a
class="right-sidebar__toc-link site-interactive block rounded-md border-l-2 py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
:class="{
'border-[var(--site-accent)] bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
'border-transparent text-[var(--site-text)]': activeTocId !== item.id,
'pl-2 font-semibold': item.level === 1,
'pl-5': item.level === 2,
'pl-8 text-xs': item.level === 3,
'site-muted': item.level === 3 && activeTocId !== item.id
}"
:href="`#${item.id}`"
:aria-current="activeTocId === item.id ? 'location' : undefined"
:data-toc-id="item.id"
@click="scrollToTocItem($event, item.id)"
>
{{ item.text }}
</a>
</li>
</ul>
<p v-else class="right-sidebar__toc-empty text-sm site-muted">
목차로 표시할 제목이 없습니다.
</p>
</nav>
</div>
<div
v-else-if="recommendedSites.length"
class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0"
>
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Recommended
</p>
</div>
<ul class="right-sidebar__recommended-list mt-4 list-none flex flex-col gap-2.5 p-0">
<li v-for="item in recommendedSites" :key="item.id">
<a
class="right-sidebar__recommended-card site-interactive flex items-center gap-3 rounded-xl border border-[var(--site-line)] bg-[var(--site-panel)] px-3 py-2.5 transition-colors hover:border-[var(--site-accent)]"
:href="item.url"
:target="isExternalNavUrl(item.url) ? '_blank' : undefined"
:rel="isExternalNavUrl(item.url) ? 'nofollow noopener noreferrer' : undefined"
>
<span class="right-sidebar__recommended-icon grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-[var(--site-paper)] text-xs font-bold text-[var(--site-text)] ring-1 ring-[var(--site-line)]">
<img
v-if="getRecommendedImageUrl(item)"
class="h-full w-full object-cover"
:src="getRecommendedImageUrl(item)"
width="36"
height="36"
:alt="item.thumbnailUrl ? item.label : ''"
loading="lazy"
referrerpolicy="no-referrer"
>
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-[var(--site-text)]">{{ item.label }}</span>
<span class="mt-0.5 block truncate text-[11px] site-muted" :class="item.descriptionText ? '' : 'font-mono'">{{ getRecommendedDisplayText(item) }}</span>
</span>
<span class="shrink-0 text-xs site-muted" aria-hidden="true"></span>
</a>
</li>
</ul>
</div>
<div v-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<p class="right-sidebar__about text-sm leading-6 site-muted">
{{ siteSettings.description }}
</p>
<NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about">
About {{ siteSettings.title }}
</NuxtLink>
</div>
<SiteAdSlot
class="right-sidebar__ad-slot site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0"
:code="sidebarAdCode"
location="sidebar"
/>
</div>
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
{{ siteSettings.copyrightText }}
</footer>
</aside>
</template>
<style scoped>
.right-sidebar__custom-social-icon :deep(svg) {
display: block;
width: 1rem !important;
height: 1rem !important;
max-width: 1rem;
max-height: 1rem;
flex-shrink: 0;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup>
defineProps({
/** 공개 API `primary` 트리 노드 */
nodes: {
type: Array,
default: () => []
}
})
const route = useRoute()
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
const toggleBranch = inject('sidebarPrimaryNavToggle')
/** 세로바·호버 시 원형으로 바뀌는 공통 before 스타일(리프 링크와 동일) */
const navBarBeforeBase =
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
/** 비활성 경로: 테두리 톤에 가깝게 밝게 섞인 막대, 호버 시 원형·믹스 색 */
const navBarBeforeInactive =
`${navBarBeforeBase} before:bg-[color:color-mix(in_srgb,var(--site-line)_88%,var(--site-panel)_12%)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
const navBarBeforeActive =
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
/** 행 공통: site-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
const navRowShell =
'site-sidebar-nav-row flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
/**
* 노드가 펼쳐져 있는지
* @param {string} id - 노드 id
* @returns {boolean}
*/
const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
/**
* 부모 행(이름·행 전체) 클릭으로 하위 접기/펼치기
* @param {string} id - 노드 id
* @returns {void}
*/
const onBranchClick = (id) => {
toggleBranch(id)
}
/**
* 외부 URL 여부
* @param {string} raw - 네비 URL
* @returns {boolean}
*/
const isExternalUrl = (raw) => /^https?:\/\//i.test(String(raw || '').trim())
/**
* 내부 링크이고 현재 경로와 일치하는지(쿼리 무시, 끝 슬래시 정규화)
* @param {string} raw - 네비 URL
* @returns {boolean}
*/
const isInternalNavActive = (raw) => {
const u = String(raw || '').trim()
if (!u || u === '#' || !u.startsWith('/') || u.startsWith('//')) {
return false
}
if (isExternalUrl(u)) {
return false
}
const path = (route.path || '/').split('?')[0] || '/'
/**
* 경로 정규화
* @param {string} s - 경로
* @returns {string}
*/
const norm = (s) => {
let x = s || '/'
if (x.length > 1 && x.endsWith('/')) {
x = x.slice(0, -1)
}
return x || '/'
}
return norm(path) === norm(u)
}
/**
* 리프 `NuxtLink`용 클래스
* @param {string} url - 노드 URL
* @returns {string}
*/
const navLinkClass = (url) => {
const active = isInternalNavActive(url)
const bar = active ? navBarBeforeActive : navBarBeforeInactive
return `sidebar-primary-nav-list__nav-link ${navRowShell} ${bar}`
}
</script>
<template>
<ul class="sidebar-primary-nav-list flex w-full flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
<template v-for="node in nodes" :key="node.id">
<li
v-if="node.children?.length"
class="sidebar-primary-nav-list__branch w-full"
>
<button
type="button"
class="sidebar-primary-nav-list__branch-toggle group flex w-full max-w-full text-left text-[var(--site-text)]"
:class="`${navRowShell} ${navBarBeforeInactive}`"
:aria-expanded="isExpanded(node.id)"
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
@click="onBranchClick(node.id)"
>
<span class="sidebar-primary-nav-list__branch-label min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
<span
class="sidebar-primary-nav-list__chevron-wrap grid h-5 w-5 shrink-0 place-items-center text-[var(--site-muted)] transition-transform duration-200 ease-out group-hover:text-[var(--site-text)]"
:class="{ '-rotate-180': isExpanded(node.id) }"
aria-hidden="true"
>
<svg class="sidebar-primary-nav-list__chevron-svg h-3.5 w-3.5" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 4.25L6 7.75L9.5 4.25" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
<div
class="sidebar-primary-nav-list__sub-grid grid min-h-0 w-full max-w-full transition-[grid-template-rows] duration-200 ease-out"
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
<ul class="sidebar-primary-nav-list__sub ml-0 mt-1 pl-3 pt-0">
<SidebarPrimaryNavList :nodes="node.children" />
</ul>
</div>
</div>
</li>
<li
v-else
class="sidebar-primary-nav-list__leaf group relative flex w-full max-w-full items-center"
>
<NuxtLink
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
:class="navLinkClass(node.url)"
:to="node.url"
:aria-current="isInternalNavActive(node.url) ? 'page' : undefined"
>
<span class="sidebar-primary-nav-list__label min-w-0 flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</NuxtLink>
<span
v-else
class="sidebar-primary-nav-list__leaf-static group text-[var(--site-text)]"
:class="`${navRowShell} ${navBarBeforeInactive}`"
>
<span class="min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</span>
</li>
</template>
</ul>
</template>

View File

@@ -0,0 +1,79 @@
<script setup>
const props = defineProps({
code: {
type: String,
default: ''
},
location: {
type: String,
default: ''
}
})
const slotRef = ref(null)
const mounted = ref(false)
const normalizedCode = computed(() => String(props.code || '').trim())
/**
* v-html로 삽입된 광고 스크립트를 브라우저에서 실행 가능한 노드로 교체한다.
* @returns {void}
*/
const executeAdScripts = () => {
if (!import.meta.client || !(slotRef.value instanceof HTMLElement)) {
return
}
const scripts = Array.from(slotRef.value.querySelectorAll('script'))
scripts.forEach((script) => {
const nextScript = document.createElement('script')
Array.from(script.attributes).forEach((attribute) => {
nextScript.setAttribute(attribute.name, attribute.value)
})
nextScript.text = script.text || script.textContent || ''
script.replaceWith(nextScript)
})
}
watch(normalizedCode, async () => {
await nextTick()
executeAdScripts()
})
onMounted(async () => {
mounted.value = true
await nextTick()
executeAdScripts()
})
</script>
<template>
<div
v-if="mounted && normalizedCode"
ref="slotRef"
class="site-ad-slot"
role="complementary"
aria-label="광고"
:data-ad-location="location || undefined"
v-html="normalizedCode"
/>
</template>
<style scoped>
.site-ad-slot {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.site-ad-slot :deep(ins.adsbygoogle) {
display: block;
max-width: 100%;
}
.site-ad-slot :deep(iframe) {
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,205 @@
<script setup>
import {
ANNOUNCEMENT_SNOOZE_DAYS,
dismissAnnouncementForDays,
dismissAnnouncementForSession,
getAnnouncementBarTextColor,
isAnnouncementDismissed,
normalizeAnnouncementUrl
} from '~/lib/announcement-bar.js'
/** @type {number} 슬라이드 애니메이션 시간(ms) */
const SLIDE_MS = 320
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
announcementEnabled: false,
announcementText: '',
announcementUrl: '',
announcementBackgroundColor: '#15171a',
announcementAlignment: 'center',
updatedAt: null
})
})
/** DOM에 바를 둘지(애니메이션 종료 전까지 유지) */
const inDom = ref(false)
/** 펼침(아래로 슬라이드) 애니메이션 상태 */
const expanded = ref(false)
/** 숨김 처리 완료 여부 */
const dismissed = ref(false)
let closeTimer = null
/**
* 설정상 어나운스 바를 켤 수 있는지
* @returns {boolean} 가능 여부
*/
const isEligible = computed(() => {
if (!siteSettings.value?.announcementEnabled) {
return false
}
return Boolean((siteSettings.value?.announcementText || '').trim())
})
const announcementText = computed(() => (siteSettings.value?.announcementText || '').trim())
const announcementLink = computed(() => normalizeAnnouncementUrl(siteSettings.value?.announcementUrl || ''))
const snoozeLabel = computed(() => `${ANNOUNCEMENT_SNOOZE_DAYS}일간 보지 않기`)
const announcementAlignment = computed(() => siteSettings.value?.announcementAlignment === 'left' ? 'left' : 'center')
const barStyle = computed(() => {
const backgroundColor = siteSettings.value?.announcementBackgroundColor || '#15171a'
return {
backgroundColor,
color: getAnnouncementBarTextColor(backgroundColor)
}
})
/**
* 어나운스 바를 펼친다.
* @returns {Promise<void>}
*/
const openBar = async () => {
inDom.value = true
expanded.value = false
await nextTick()
requestAnimationFrame(() => {
requestAnimationFrame(() => {
expanded.value = true
})
})
}
/**
* 어나운스 바를 접고 DOM에서 제거한다.
* @param {() => void} persistDismiss - localStorage·sessionStorage 저장
* @returns {void}
*/
const closeBar = (persistDismiss) => {
if (!inDom.value) {
return
}
persistDismiss()
expanded.value = false
if (closeTimer) {
clearTimeout(closeTimer)
}
closeTimer = setTimeout(() => {
inDom.value = false
dismissed.value = true
closeTimer = null
}, SLIDE_MS)
}
/**
* 이번 방문(세션) 동안만 닫는다.
* @returns {void}
*/
const dismissForSession = () => {
closeBar(() => dismissAnnouncementForSession(siteSettings.value))
}
/**
* N일간 보지 않기로 닫는다.
* @returns {void}
*/
const dismissForSnooze = () => {
closeBar(() => dismissAnnouncementForDays(siteSettings.value))
}
watch(() => siteSettings.value?.updatedAt, async () => {
if (!import.meta.client) {
return
}
const hidden = isAnnouncementDismissed(siteSettings.value)
dismissed.value = hidden
if (!isEligible.value || hidden) {
expanded.value = false
inDom.value = false
return
}
await openBar()
})
onMounted(() => {
dismissed.value = isAnnouncementDismissed(siteSettings.value)
if (isEligible.value && !dismissed.value) {
openBar()
}
})
onBeforeUnmount(() => {
if (closeTimer) {
clearTimeout(closeTimer)
}
})
</script>
<template>
<div
v-if="inDom"
class="site-announcement-bar-shell grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
:style="{ transitionDuration: `${SLIDE_MS}ms` }"
>
<div class="min-h-0 overflow-hidden">
<div
class="site-announcement-bar relative z-30 w-full text-center text-sm font-medium"
:style="barStyle"
role="region"
aria-label="사이트 공지"
:aria-hidden="(!expanded).toString()"
>
<div
class="site-announcement-bar__inner relative mx-auto flex min-h-9 max-w-[1294px] items-center gap-3 px-4 py-2.5 sm:px-6 lg:px-8"
:class="announcementAlignment === 'left' ? 'justify-start' : 'justify-center'"
>
<component
:is="announcementLink ? 'a' : 'span'"
class="site-announcement-bar__text min-w-0 line-clamp-2 px-6 sm:px-8"
:class="[
announcementLink ? 'hover:underline' : '',
announcementAlignment === 'left' ? 'flex-1 text-left' : 'flex-1 text-center'
]"
:href="announcementLink || undefined"
:target="announcementLink ? '_blank' : undefined"
:rel="announcementLink ? 'noreferrer' : undefined"
>
{{ announcementText }}
</component>
<div class="site-announcement-bar__actions absolute top-1/2 right-3 flex shrink-0 -translate-y-1/2 items-center gap-2 sm:right-4 lg:right-5">
<button
class="site-announcement-bar__snooze whitespace-nowrap text-xs font-medium underline-offset-2 opacity-90 transition hover:underline hover:opacity-100 sm:text-[13px]"
type="button"
@click="dismissForSnooze"
>
{{ snoozeLabel }}
</button>
<button
class="site-announcement-bar__close inline-flex size-7 items-center justify-center rounded-full opacity-80 transition hover:opacity-100"
type="button"
aria-label="이번 방문 동안 닫기"
@click="dismissForSession"
>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,316 @@
<script setup>
const { menuOpen, toggleMenu, closeMenu } = useMenuState()
const menuUserOpen = ref(false)
const userMenuRef = ref(null)
const userMenuToggleRef = ref(null)
const searchOpen = ref(false)
const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio'
})
})
/**
* 사용자 메뉴를 닫는다.
* @returns {void}
*/
const closeUserMenu = () => {
menuUserOpen.value = false
}
/**
* 통합 검색 모달을 연다.
* @returns {void}
*/
const openSearchModal = () => {
searchOpen.value = true
}
/**
* 입력 필드에 포커스가 있으면 `/` 검색 단축키를 무시한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {boolean} 무시할 때 true
*/
const shouldIgnoreSearchHotkey = (event) => {
if (event.ctrlKey || event.metaKey || event.altKey) {
return true
}
const target = event.target
if (!(target instanceof HTMLElement)) {
return false
}
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
return true
}
if (target.isContentEditable) {
return true
}
return false
}
/**
* 사용자 메뉴를 토글한다.
* @returns {void}
*/
const toggleUserMenu = () => {
menuUserOpen.value = !menuUserOpen.value
}
/**
* 회원 세션 정보를 조회한다.
* @returns {Promise<void>}
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
} catch {
member.value = null
}
}
/**
* 회원 로그아웃을 처리한다.
* @returns {Promise<void>}
*/
const logoutMember = async () => {
await $fetch('/api/auth/logout', {
method: 'POST'
})
member.value = null
closeUserMenu()
await navigateTo('/')
}
/**
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const onDocumentClick = (event) => {
const target = /** @type {Node | null} */ (event.target instanceof Node ? event.target : null)
if (!target) {
closeUserMenu()
return
}
const isInsideMenu = userMenuRef.value instanceof HTMLElement && userMenuRef.value.contains(target)
const isToggleButton = userMenuToggleRef.value instanceof HTMLElement && userMenuToggleRef.value.contains(target)
if (!isInsideMenu && !isToggleButton) {
closeUserMenu()
}
}
/**
* Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기).
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const onGlobalKeydown = (event) => {
if (event.key === 'Escape') {
if (searchOpen.value) {
searchOpen.value = false
event.preventDefault()
return
}
if (menuUserOpen.value) {
closeUserMenu()
return
}
if (!menuOpen.value) {
return
}
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
closeMenu()
}
return
}
if (event.key !== '/') {
return
}
if (shouldIgnoreSearchHotkey(event)) {
return
}
event.preventDefault()
openSearchModal()
}
onMounted(() => {
fetchMember()
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onGlobalKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
document.removeEventListener('keydown', onGlobalKeydown)
})
</script>
<template>
<header class="site-header backdrop-blur">
<div class="site-header__inner mx-auto grid h-full max-w-[1294px] grid-cols-3 items-center gap-2 px-4 sm:gap-3 lg:gap-4 lg:px-5 xl:gap-5 xl:px-6 2xl:px-0">
<div class="site-header__brand-slot flex min-w-0 justify-self-start">
<NuxtLink class="site-header__brand flex min-w-0 max-w-full items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,28vw)] xl:max-w-[min(300px,26vw)]" to="/">
<button
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
type="button"
data-menu-toggle
aria-label="Menu toggle"
aria-haspopup="true"
aria-controls="menu"
:aria-expanded="menuOpen.toString()"
@click.prevent="toggleMenu"
>
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" 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="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
<path d="M9 4v16" />
</svg>
<svg class="hidden h-6 w-6 group-hover:block" 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="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
<path d="M9 4v16" />
<path d="m14 10 2 2-2 2" />
</svg>
</span>
<span v-else class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
</svg>
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
</svg>
</span>
</button>
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink>
</div>
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
<button
type="button"
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
aria-label="검색 열기"
@click="openSearchModal"
>
<span class="site-header__search-icon mr-2 text-lg leading-none">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="10" cy="10" r="7"></circle>
<line x1="21" y1="21" x2="15" y2="15"></line>
</svg>
</span>
<span class="site-header__search-text site-soft">Search</span>
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
</button>
</div>
<nav class="site-header__nav site-header__actions flex min-w-0 shrink-0 items-center justify-end justify-self-end gap-3 text-sm sm:gap-3.5">
<div class="site-header__user-menu relative">
<button
ref="userMenuToggleRef"
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full border transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
type="button"
aria-label="Toggle user menu"
:aria-expanded="menuUserOpen.toString()"
@click="toggleUserMenu"
>
<img
v-if="member?.avatarUrl"
:src="member.avatarUrl"
:alt="member.username || '회원 아바타'"
class="h-full w-full rounded-full object-cover"
>
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-[var(--site-text)]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M367-527q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm296.5-343.5Q560-607 560-640t-23.5-56.5Q513-720 480-720t-56.5 23.5Q400-673 400-640t23.5 56.5Q447-560 480-560t56.5-23.5ZM480-640Zm0 400Z" />
</svg>
</span>
</button>
<Transition
enter-active-class="transition-[transform,opacity,visibility] duration-200 ease-out"
enter-from-class="-translate-y-2 scale-95 opacity-0"
enter-to-class="translate-y-0 scale-100 opacity-100"
leave-active-class="transition-[transform,opacity,visibility] duration-150 ease-in"
leave-from-class="translate-y-0 scale-100 opacity-100"
leave-to-class="-translate-y-2 scale-95 opacity-0"
>
<div
v-if="menuUserOpen"
ref="userMenuRef"
class="site-header__user-dropdown absolute top-12 right-2 z-30 flex min-w-[200px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3 pb-2 text-sm font-medium shadow-[0_12px_30px_rgba(0,0,0,0.12)] sm:right-0 sm:max-w-xs"
>
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
<img
v-if="member?.avatarUrl"
:src="member.avatarUrl"
:alt="member.username || '회원 아바타'"
class="h-full w-full object-cover"
>
<span v-else class="text-base font-normal uppercase md:text-lg">
{{ (member?.username || member?.email || '@').slice(0, 1) }}
</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="max-w-xs truncate leading-[1.15]">
{{ member?.username || 'Guest' }}
</div>
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
{{ member.email }}
</div>
</div>
</div>
<template v-if="member">
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
</svg>
<span>설정</span>
</NuxtLink>
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>로그아웃</span>
</button>
</template>
<template v-else>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M15 9l-6 6" />
<path d="M15 15v-6h-6" />
</svg>
<span>Sign up</span>
</NuxtLink>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>Sign in</span>
</NuxtLink>
</template>
</div>
</Transition>
</div>
</nav>
</div>
</header>
<SiteSearchModal v-model="searchOpen" />
</template>

View File

@@ -0,0 +1,284 @@
<script setup>
/**
* @typedef {{ name: string, slug: string }} SearchTagHit
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit
*/
const open = defineModel({ type: Boolean, default: false })
const searchInputRef = ref(null)
const query = ref('')
const debouncedQuery = ref('')
const isComposing = ref(false)
/** @type {ReturnType<typeof setTimeout> | null} */
let debounceTimer = null
/**
* 입력값을 디바운스로 검색어에 반영한다.
* @param {string} value - 현재 입력값
* @returns {void}
*/
const scheduleDebouncedQuery = (value) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debouncedQuery.value = value
}, 200)
}
watch(query, (value) => {
scheduleDebouncedQuery(value)
})
const fetchKey = computed(() => `public-search:${debouncedQuery.value}`)
const { data, pending } = await useFetch('/api/search', {
key: fetchKey,
query: { q: debouncedQuery },
watch: [debouncedQuery],
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
})
/**
* 정규식 특수 문자 이스케이프
* @param {string} value - 원문
* @returns {string} 이스케이프된 문자열
*/
const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
/**
* 검색어 일치 구간을 나누어 하이라이트 표시에 사용한다.
* @param {string} text - 표시할 문자열
* @param {string} needle - 검색어
* @returns {Array<{ text: string, hit: boolean }>} 구간 목록
*/
const highlightParts = (text, needle) => {
const base = String(text ?? '')
const q = String(needle ?? '').trim()
if (!q) {
return [{ text: base, hit: false }]
}
const parts = base.split(new RegExp(`(${escapeRegExp(q)})`, 'gi'))
return parts
.filter((part) => part !== '')
.map((part, index) => ({
text: part,
hit: index % 2 === 1
}))
}
/**
* 목록용 요약 문자열을 한 줄로 자른다.
* @param {string} text - 요약 원문
* @param {number} max - 최대 글자 수
* @returns {string} 잘린 문자열
*/
const clipExcerpt = (text, max = 140) => {
const line = String(text ?? '').replace(/\s+/g, ' ').trim()
if (line.length <= max) {
return line
}
return `${line.slice(0, max)}`
}
/**
* 배경 클릭 시 모달을 닫는다.
* @returns {void}
*/
const onBackdropPointerDown = () => {
open.value = false
}
/**
* 검색어를 비운다(입력 포커스 유지).
* @returns {void}
*/
const clearQuery = () => {
query.value = ''
debouncedQuery.value = ''
nextTick(() => {
const el = searchInputRef.value
if (el instanceof HTMLInputElement) {
el.focus()
}
})
}
/**
* 한글/일본어 등 IME 조합 시작 처리
* @returns {void}
*/
const onCompositionStart = () => {
isComposing.value = true
}
/**
* IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다.
* 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다.
* @param {CompositionEvent} event - 조합 업데이트 이벤트
* @returns {void}
*/
const onCompositionUpdate = (event) => {
const target = event.target
if (!(target instanceof HTMLInputElement)) {
return
}
const value = target.value
query.value = value
scheduleDebouncedQuery(value)
}
/**
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
* @returns {void}
*/
const onCompositionEnd = () => {
isComposing.value = false
clearTimeout(debounceTimer)
debouncedQuery.value = query.value
}
/**
* 모달 열림·닫힘에 따라 스크롤 잠금과 입력을 동기화한다.
* @returns {void}
*/
watch(open, (isOpen) => {
if (typeof document === 'undefined') {
return
}
document.documentElement.classList.toggle('site-search-open', Boolean(isOpen))
if (isOpen) {
nextTick(() => {
const el = searchInputRef.value
if (el instanceof HTMLInputElement) {
el.focus()
}
})
return
}
query.value = ''
debouncedQuery.value = ''
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('site-search-open')
}
clearTimeout(debounceTimer)
})
</script>
<template>
<Teleport to="body">
<div
v-show="open"
class="site-search-modal fixed inset-0 z-[60] flex justify-center bg-black/35 px-3 pt-14 pb-8 backdrop-blur-sm sm:px-4 sm:pt-20"
role="dialog"
aria-modal="true"
aria-label="사이트 검색"
@pointerdown.self="onBackdropPointerDown"
>
<div
class="site-search-modal__panel site-search-modal__panel--animate flex h-[min(78vh,640px)] w-full max-w-[95vw] flex-col overflow-hidden rounded-lg border border-[var(--site-line)] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_20px_50px_rgba(0,0,0,0.18)] sm:max-w-lg"
@pointerdown.stop
>
<div class="site-search-modal__header flex shrink-0 items-center gap-2 border-b border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-3 sm:gap-3 sm:px-5 sm:py-4">
<button
type="button"
class="site-search-modal__icon flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-[var(--site-text)] transition-colors hover:bg-[var(--site-panel)]"
:aria-label="query.trim() ? '검색어 지우기' : '검색'"
:disabled="!query.trim()"
@click="query.trim() ? clearQuery() : null"
>
<svg v-if="!query.trim()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M23.38 21.62l-6.53-6.53a9.15 9.15 0 0 0 1.9-5.59 9.27 9.27 0 1 0-3.66 7.36l6.53 6.53a1.26 1.26 0 0 0 1.76 0 1.25 1.25 0 0 0 0-1.77ZM2.75 9.5A6.75 6.75 0 1 1 9.5 16.25 6.76 6.76 0 0 1 2.75 9.5Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
<input
ref="searchInputRef"
v-model="query"
type="text"
inputmode="search"
enterkeyhint="search"
autocomplete="off"
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
placeholder="글 제목, 본문, 태그 검색"
@compositionstart="onCompositionStart"
@compositionupdate="onCompositionUpdate"
@compositionend="onCompositionEnd"
/>
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
취소
</button>
</div>
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
</p>
<template v-else>
<div v-if="pending" class="text-sm text-[var(--site-soft)]">
검색
</div>
<template v-else>
<section v-if="(data?.tags?.length ?? 0) > 0" class="site-search-modal__section mb-6">
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
Tags
</h2>
<ul class="space-y-0.5">
<li v-for="tag in data.tags" :key="tag.slug">
<NuxtLink
class="site-search-modal__tag-link flex items-baseline gap-1 rounded-md px-2 py-2 text-[15px] transition-colors hover:bg-[var(--site-panel)]"
:to="`/tag/${tag.slug}/`"
@click="open = false"
>
<span class="text-[var(--site-soft)]">#</span>
<span class="font-medium text-[var(--site-text)]">{{ tag.name }}</span>
</NuxtLink>
</li>
</ul>
</section>
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
Posts
</h2>
<ul class="space-y-1">
<li v-for="post in data.posts" :key="post.slug">
<NuxtLink
class="site-search-modal__post-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
:to="`/post/${post.slug}/`"
@click="open = false"
>
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
<template v-for="(seg, i) in highlightParts(post.title, query)" :key="`t-${post.slug}-${i}`">
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
<span v-else>{{ seg.text }}</span>
</template>
</div>
<div v-if="clipExcerpt(post.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
<template v-for="(seg, i) in highlightParts(clipExcerpt(post.excerpt), query)" :key="`e-${post.slug}-${i}`">
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
<span v-else>{{ seg.text }}</span>
</template>
</div>
</NuxtLink>
</li>
</ul>
</section>
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
일치하는 결과가 없습니다.
</p>
</template>
</template>
</div>
</div>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,48 @@
<script setup>
let resizeObserver = null
/**
* 상단 크롬(어나운스 바·헤더) 높이를 CSS 변수로 반영한다.
* @returns {void}
*/
const syncTopChromeHeight = () => {
if (!import.meta.client) {
return
}
const chrome = document.querySelector('.site-top-chrome')
const height = chrome instanceof HTMLElement ? chrome.offsetHeight : 57
document.documentElement.style.setProperty('--site-top-chrome-height', `${height}px`)
}
onMounted(() => {
syncTopChromeHeight()
window.addEventListener('resize', syncTopChromeHeight)
const chrome = document.querySelector('.site-top-chrome')
if (chrome instanceof HTMLElement && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
syncTopChromeHeight()
})
resizeObserver.observe(chrome)
}
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
window.removeEventListener('resize', syncTopChromeHeight)
resizeObserver?.disconnect()
resizeObserver = null
document.documentElement.style.removeProperty('--site-top-chrome-height')
})
</script>
<template>
<div class="site-top-chrome sticky top-0 z-20 shrink-0">
<SiteAnnouncementBar />
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
}
})
</script>
<template>
<section class="tag-header site-section">
<div class="tag-header__inner site-section-header">
<h1 class="tag-header__title mt-3 text-xl font-semibold leading-tight">
{{ title }}
</h1>
<p v-if="description" class="tag-header__description mt-3 text-sm leading-6 text-muted">
{{ description }}
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,39 @@
/**
* 게시물 요약 또는 본문에서 목록·메타용 짧은 텍스트를 만든다.
* @param {string} excerpt - 게시물 요약
* @param {string} content - 게시물 본문(마크다운)
* @param {Object} [options] - 옵션
* @param {number} [options.maxLength=160] - 최대 글자 수
* @param {boolean} [options.appendEllipsis=true] - 잘린 문자열 끝에 말줄임 추가 여부
* @returns {string} 화면 표시용 요약
*/
export function createPostSummary(excerpt = '', content = '', options = {}) {
const maxLength = Number(options.maxLength) > 0 ? Number(options.maxLength) : 160
const appendEllipsis = options.appendEllipsis !== false
const source = String(excerpt || '').trim() || String(content || '')
const plainText = source
.replace(/```[\s\S]*?```/g, ' ')
.replace(/:::[\s\S]*?:::/g, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/[#>*_`~|-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plainText) {
return ''
}
if (plainText.length <= maxLength) {
return plainText
}
if (!appendEllipsis) {
return plainText.slice(0, maxLength).trim()
}
return `${plainText.slice(0, maxLength - 3).trim()}...`
}

View File

@@ -0,0 +1,69 @@
/**
* 공개 화면용 게시 날짜를 YYYY.MM.DD 형식으로 변환한다.
* @param {string | null | undefined} value - ISO 8601 등 파싱 가능한 날짜 문자열
* @returns {string} 빈 문자열 또는 YYYY.MM.DD
*/
export function formatPostDate(value) {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
/**
* 관리자·상세 메타용 날짜·시각을 YYYY.MM.DD 오전/오후 HH:MM 형식으로 변환한다.
* @param {string | null | undefined} value - ISO 8601 등 파싱 가능한 날짜 문자열
* @returns {string} 빈 문자열 또는 포맷된 날짜·시각
*/
export function formatPostDateTime(value) {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = date.getHours()
const minutes = String(date.getMinutes()).padStart(2, '0')
const period = hours < 12 ? '오전' : '오후'
const hour12 = String(hours % 12 || 12).padStart(2, '0')
return `${year}.${month}.${day} ${period} ${hour12}:${minutes}`
}
/**
* 발행 이후 본문·메타 수정이 있었는지 판별한다(동일 초 갱신은 제외).
* @param {{ publishedAt?: string | null, updatedAt?: string | null }} post - 게시물
* @returns {boolean} 발행 후 수정 여부
*/
export function wasPostUpdatedAfterPublish(post) {
if (!post?.publishedAt || !post?.updatedAt) {
return false
}
const publishedMs = new Date(post.publishedAt).getTime()
const updatedMs = new Date(post.updatedAt).getTime()
if (Number.isNaN(publishedMs) || Number.isNaN(updatedMs)) {
return false
}
return updatedMs - publishedMs > 60_000
}

View File

@@ -0,0 +1,45 @@
/**
* 관리자 테이블·목록 행 more vert 메뉴 열림 상태
* @returns {{ openMenuId: import('vue').Ref<string>, closeMenu: () => void }}
*/
export const useAdminRowMenu = () => {
const openMenuId = ref('')
/**
* 열린 메뉴를 닫는다.
* @returns {void}
*/
const closeMenu = () => {
openMenuId.value = ''
}
/**
* 문서 바깥 클릭 시 메뉴를 닫는다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onDocumentPointerDown = (event) => {
if (!openMenuId.value || !(event.target instanceof HTMLElement)) {
return
}
if (event.target.closest('[data-admin-row-menu]')) {
return
}
closeMenu()
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
return {
openMenuId,
closeMenu
}
}

View File

@@ -0,0 +1,51 @@
import { onUnmounted, ref } from 'vue'
const TOAST_AUTO_HIDE_MS = 4000
/**
* 관리자 화면 우측 상단 피드백 토스트. 모달(z-50 등)보다 위에 보이도록 사용처에서 `z-[100]` 클래스를 둔다.
* @returns {{ toast: import('vue').Ref<null | { type: string, message: string }>, showToast: (type: string, message: string) => void, clearToast: () => void }}
*/
export const useAdminToast = () => {
const toast = ref(null)
let toastTimer = null
/**
* 표시 중인 토스트를 즉시 제거한다.
* @returns {void}
*/
const clearToast = () => {
window.clearTimeout(toastTimer)
toastTimer = null
toast.value = null
}
/**
* 토스트를 표시한다. 이전 타이머가 있으면 취소한다.
* @param {'success' | 'error' | 'info'} type - 토스트 종류
* @param {string} message - 본문
* @returns {void}
*/
const showToast = (type, message) => {
window.clearTimeout(toastTimer)
const text = String(message || '').trim() || '알림'
toast.value = {
type,
message: text
}
toastTimer = window.setTimeout(() => {
toast.value = null
toastTimer = null
}, TOAST_AUTO_HIDE_MS)
}
onUnmounted(() => {
window.clearTimeout(toastTimer)
})
return {
toast,
showToast,
clearToast
}
}

View File

@@ -0,0 +1,100 @@
/**
* 관리자 편집 화면의 미저장 변경 이탈을 막는다.
* @param {import('vue').Ref<boolean> | import('vue').ComputedRef<boolean>} isDirty - 변경 여부
* @param {{ onLeaveConfirmed?: () => void | Promise<void> }} options - 이탈 승인 옵션
* @returns {{
* isUnsavedModalOpen: import('vue').Ref<boolean>,
* stayOnUnsavedPage: () => void,
* leaveUnsavedPage: () => Promise<void>,
* allowNextRouteLeave: () => void
* }} 이탈 확인 상태와 동작
*/
export const useAdminUnsavedChangesGuard = (isDirty, options = {}) => {
const isUnsavedModalOpen = ref(false)
const pendingRoute = ref(null)
const isNextRouteAllowed = ref(false)
/**
* 현재 이탈을 막아야 하는지 확인한다.
* @returns {boolean} 이탈 차단 여부
*/
const shouldBlockLeave = () => Boolean(unref(isDirty)) && !isNextRouteAllowed.value
/**
* 다음 라우트 이동을 한 번 허용한다.
* @returns {void}
*/
const allowNextRouteLeave = () => {
isNextRouteAllowed.value = true
}
/**
* 현재 페이지에 머문다.
* @returns {void}
*/
const stayOnUnsavedPage = () => {
pendingRoute.value = null
isUnsavedModalOpen.value = false
}
/**
* 미저장 변경을 버리고 이동한다.
* @returns {Promise<void>}
*/
const leaveUnsavedPage = async () => {
const route = pendingRoute.value
pendingRoute.value = null
isUnsavedModalOpen.value = false
isNextRouteAllowed.value = true
await options.onLeaveConfirmed?.()
if (route?.fullPath) {
await navigateTo(route.fullPath)
}
}
onBeforeRouteLeave((to) => {
if (isNextRouteAllowed.value) {
isNextRouteAllowed.value = false
return true
}
if (!shouldBlockLeave()) {
return true
}
pendingRoute.value = to
isUnsavedModalOpen.value = true
return false
})
/**
* 브라우저 탭 닫기와 새로고침을 기본 확인창으로 막는다.
* @param {BeforeUnloadEvent} event - 브라우저 이탈 이벤트
* @returns {void}
*/
const handleBeforeUnload = (event) => {
if (!shouldBlockLeave()) {
return
}
event.preventDefault()
event.returnValue = ''
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
return {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage,
allowNextRouteLeave
}
}

View File

@@ -0,0 +1,44 @@
const menuStorageKey = 'MENU_STATE'
/**
* 좌측 메뉴 열림 상태 관리
* @returns {{menuOpen: import('vue').Ref<boolean>, toggleMenu: Function, closeMenu: Function}} 메뉴 상태와 토글·닫기
*/
export const useMenuState = () => {
const menuOpen = useState('site-menu-open', () => true)
onMounted(() => {
const savedState = localStorage.getItem(menuStorageKey)
if (savedState) {
menuOpen.value = savedState === 'open'
}
})
/**
* 좌측 메뉴 열림 상태 토글
* @returns {void}
*/
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
localStorage.setItem(menuStorageKey, menuOpen.value ? 'open' : 'closed')
}
/**
* 좌측 메뉴를 닫는다(모바일 오버레이·백드롭에서 사용).
* @returns {void}
*/
const closeMenu = () => {
if (!menuOpen.value) {
return
}
menuOpen.value = false
localStorage.setItem(menuStorageKey, 'closed')
}
return {
menuOpen,
toggleMenu,
closeMenu
}
}

View File

@@ -0,0 +1,73 @@
import {
SITE_THEME_STORAGE_KEY,
resolveSiteTheme
} from '~/lib/site-theme-init.js'
/**
* HTML 루트 요소에 현재 테마를 반영한다.
* @param {'light' | 'dark'} theme - 적용할 테마
* @returns {void}
*/
const applyThemeToDocument = (theme) => {
if (!import.meta.client) {
return
}
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
}
/**
* 사용자의 시스템 테마를 조회한다.
* @returns {'light' | 'dark'} 시스템 기준 기본 테마
*/
const getSystemTheme = () => {
if (!import.meta.client) {
return 'light'
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
/**
* 사이트 라이트/다크 테마 상태를 관리한다.
* @returns {{theme: import('vue').Ref<'light' | 'dark'>, isDarkMode: import('vue').ComputedRef<boolean>, toggleTheme: Function}} 테마 상태와 제어 함수
*/
export const useThemeMode = () => {
const theme = useState('site-theme-mode', () => 'light')
const isDarkMode = computed(() => theme.value === 'dark')
if (import.meta.client) {
const fromDocument = document.documentElement.dataset.theme
if (fromDocument === 'light' || fromDocument === 'dark') {
theme.value = fromDocument
} else {
const savedTheme = localStorage.getItem(SITE_THEME_STORAGE_KEY)
theme.value = resolveSiteTheme(savedTheme, getSystemTheme() === 'dark')
applyThemeToDocument(theme.value)
}
}
watch(theme, (nextTheme) => {
if (!import.meta.client) {
return
}
localStorage.setItem(SITE_THEME_STORAGE_KEY, nextTheme)
applyThemeToDocument(nextTheme)
})
/**
* 라이트/다크 테마를 전환한다.
* @returns {void}
*/
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
return {
theme,
isDarkMode,
toggleTheme
}
}

View File

@@ -0,0 +1,58 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
featured_image TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'private'))
);
CREATE INDEX IF NOT EXISTS posts_status_published_at_idx
ON posts (status, published_at DESC);
CREATE TABLE IF NOT EXISTS pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL DEFAULT '',
featured_image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#15171a',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
ON tags (sort_order ASC, name ASC);
CREATE TABLE IF NOT EXISTS post_tags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX IF NOT EXISTS post_tags_tag_id_idx
ON post_tags (tag_id);

View File

@@ -0,0 +1,65 @@
INSERT INTO tags (id, name, slug, description, sort_order, color)
VALUES
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.', 10, '#f97316'),
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.', 20, '#06b6d4')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO posts (
id,
title,
slug,
content,
excerpt,
status,
published_at,
created_at,
updated_at
)
VALUES
(
'11111111-1111-4111-8111-111111111111',
'sori.studio를 직접 만들기 시작하며',
'hello-sori-studio',
'개인 블로그와 포털 역할을 한 공간에 담기 위한 첫 글입니다.',
'블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
'published',
'2026-04-29T00:00:00.000Z',
'2026-04-29T00:00:00.000Z',
'2026-04-29T00:00:00.000Z'
),
(
'22222222-2222-4222-8222-222222222222',
'글쓰기 도구는 왜 직접 만들게 되는가',
'custom-writing-tool',
'기존 도구를 거치며 남은 취향의 빈칸을 직접 채우는 과정입니다.',
'네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
'published',
'2026-04-29T00:00:00.000Z',
'2026-04-29T00:00:00.000Z',
'2026-04-29T00:00:00.000Z'
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO post_tags (post_id, tag_id)
VALUES
('11111111-1111-4111-8111-111111111111', '44444444-4444-4444-8444-444444444444'),
('22222222-2222-4222-8222-222222222222', '55555555-5555-4555-8555-555555555555')
ON CONFLICT (post_id, tag_id) DO NOTHING;
INSERT INTO pages (
id,
title,
slug,
content,
created_at,
updated_at
)
VALUES (
'33333333-3333-4333-8333-333333333333',
'About',
'about',
'sori.studio 소개 페이지입니다.',
'2026-04-29T00:00:00.000Z',
'2026-04-29T00:00:00.000Z'
)
ON CONFLICT (slug) DO NOTHING;

View File

@@ -0,0 +1,18 @@
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
UPDATE tags
SET sort_order = 10,
color = '#f97316'
WHERE slug = 'note';
UPDATE tags
SET sort_order = 20,
color = '#06b6d4'
WHERE slug = 'dev';
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
ON tags (sort_order ASC, name ASC);

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
title TEXT NOT NULL DEFAULT 'sori.studio',
description TEXT NOT NULL DEFAULT 'sori.studio 개인 블로그',
site_url TEXT NOT NULL DEFAULT 'https://sori.studio',
logo_text TEXT NOT NULL DEFAULT '',
copyright_text TEXT NOT NULL DEFAULT '©2026 sori.studio',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT site_settings_singleton_check CHECK (id = 1)
);
INSERT INTO site_settings (
id,
title,
description,
site_url,
logo_text,
copyright_text
)
VALUES (
1,
'sori.studio',
'sori.studio 개인 블로그',
'https://sori.studio',
'',
'©2026 sori.studio'
)
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS navigation_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label TEXT NOT NULL,
url TEXT NOT NULL,
location TEXT NOT NULL DEFAULT 'primary',
sort_order INTEGER NOT NULL DEFAULT 0,
is_visible BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (location, label, url),
CONSTRAINT navigation_items_location_check CHECK (location IN ('primary', 'footer'))
);
CREATE INDEX IF NOT EXISTS navigation_items_location_sort_order_idx
ON navigation_items (location, sort_order ASC, label ASC);
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
SELECT seed.label, seed.url, seed.location, seed.sort_order, seed.is_visible
FROM (
VALUES
('Home pages', '/', 'primary', 10, true),
('Tags', '/tags', 'primary', 20, true),
('Authors', '/pages/about', 'primary', 30, true),
('Style', '/post/hello-sori-studio', 'primary', 40, true),
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
('Members', '/pages/contact', 'primary', 60, true),
('Landing pages', '/pages/projects', 'primary', 70, true),
('Portal', '/pages/links', 'footer', 10, true),
('Docs', '/pages/about', 'footer', 20, true),
('Projects', '/pages/projects', 'footer', 30, true)
) AS seed(label, url, location, sort_order, is_visible)
WHERE NOT EXISTS (
SELECT 1
FROM navigation_items existing
WHERE existing.location = seed.location
AND existing.label = seed.label
AND existing.url = seed.url
);

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS media_metadata (
url TEXT PRIMARY KEY,
category TEXT NOT NULL DEFAULT '미분류',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS media_metadata_category_idx
ON media_metadata (category ASC);

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS media_folders (
path TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO media_folders (path)
VALUES ('미분류')
ON CONFLICT (path) DO NOTHING;

View File

@@ -0,0 +1,11 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS seo_title TEXT NOT NULL DEFAULT '';
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS seo_description TEXT NOT NULL DEFAULT '';
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS canonical_url TEXT NOT NULL DEFAULT '';
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS noindex BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS og_image TEXT;

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS users_email_idx
ON users (email);
CREATE TABLE IF NOT EXISTS comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT comments_status_check CHECK (status IN ('published', 'pending', 'blocked'))
);
CREATE INDEX IF NOT EXISTS comments_post_id_created_at_idx
ON comments (post_id, created_at ASC);
CREATE INDEX IF NOT EXISTS comments_parent_id_idx
ON comments (parent_id);

View File

@@ -0,0 +1,25 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS last_seen_ip TEXT NOT NULL DEFAULT '';
WITH deduplicated_users AS (
SELECT
id,
username,
ROW_NUMBER() OVER (PARTITION BY lower(username) ORDER BY created_at ASC, id ASC) AS row_number
FROM users
)
UPDATE users
SET username = deduplicated_users.username || '-' || deduplicated_users.row_number
FROM deduplicated_users
WHERE users.id = deduplicated_users.id
AND deduplicated_users.row_number > 1;
CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique_idx
ON users (lower(username));

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS comment_likes (
comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS comment_likes_user_id_idx
ON comment_likes (user_id);

View File

@@ -0,0 +1,12 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;
WITH first_user AS (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
)
UPDATE users
SET is_admin = true
WHERE id IN (SELECT id FROM first_user);

View File

@@ -0,0 +1,27 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS user_role TEXT NOT NULL DEFAULT 'member';
UPDATE users
SET user_role = CASE
WHEN is_admin THEN 'admin'
ELSE 'member'
END
WHERE user_role NOT IN ('owner', 'admin', 'member');
WITH first_user AS (
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
)
UPDATE users
SET user_role = 'owner'
WHERE id IN (SELECT id FROM first_user)
AND is_admin = true;
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_user_role_check;
ALTER TABLE users
ADD CONSTRAINT users_user_role_check
CHECK (user_role IN ('owner', 'admin', 'member'));

View File

@@ -0,0 +1,13 @@
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS tag_type TEXT NOT NULL DEFAULT 'managed';
UPDATE tags
SET tag_type = 'managed'
WHERE tag_type NOT IN ('managed', 'general');
ALTER TABLE tags
DROP CONSTRAINT IF EXISTS tags_tag_type_check;
ALTER TABLE tags
ADD CONSTRAINT tags_tag_type_check
CHECK (tag_type IN ('managed', 'general'));

View File

@@ -0,0 +1,12 @@
-- 게시물 업로드 경로 기본 분류(posts) 및 구 프로필 경로(회원/썸네일)를 논리 폴더 정책에 맞게 정리한다.
UPDATE media_metadata
SET
category = '미분류',
updated_at = now()
WHERE category = 'posts';
UPDATE media_metadata
SET
category = '썸네일',
updated_at = now()
WHERE category = '회원/썸네일';

View File

@@ -0,0 +1,9 @@
-- 상단(primary) 네비게이션 계층·폴더(접기) 지원, 하단(footer)은 평면 유지
ALTER TABLE navigation_items
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES navigation_items (id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS is_folder BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE navigation_items DROP CONSTRAINT IF EXISTS navigation_items_location_label_url_key;
CREATE INDEX IF NOT EXISTS navigation_items_location_parent_sort_idx
ON navigation_items (location, parent_id, sort_order ASC, label ASC);

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS email_otp_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
verify_attempt_count INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT email_otp_challenges_purpose_check CHECK (purpose IN ('signup', 'password_reset'))
);
CREATE INDEX IF NOT EXISTS email_otp_challenges_email_purpose_created_idx
ON email_otp_challenges (lower(email), purpose, created_at DESC);

View File

@@ -0,0 +1,23 @@
-- 반복 마이그레이션 실행으로 생긴 동일 위치·상위·라벨·URL 메뉴 중복 정리
WITH ranked_navigation AS (
SELECT
id,
row_number() OVER (
PARTITION BY location, COALESCE(parent_id::text, ''), label, url
ORDER BY
CASE WHEN is_folder THEN 0 ELSE 1 END,
sort_order ASC,
created_at ASC,
id ASC
) AS row_rank
FROM navigation_items
)
DELETE FROM navigation_items
WHERE id IN (
SELECT id
FROM ranked_navigation
WHERE row_rank > 1
);
CREATE UNIQUE INDEX IF NOT EXISTS navigation_items_location_parent_label_url_unique_idx
ON navigation_items (location, COALESCE(parent_id::text, ''), label, url);

View File

@@ -0,0 +1,5 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS member_labels TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
ALTER TABLE users
ADD COLUMN IF NOT EXISTS member_note TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,12 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS previous_last_seen_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS previous_last_seen_ip TEXT NOT NULL DEFAULT '';
UPDATE users
SET
previous_last_seen_at = last_seen_at,
previous_last_seen_ip = last_seen_ip
WHERE previous_last_seen_at IS NULL
AND last_seen_at IS NOT NULL;

View File

@@ -0,0 +1,5 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS logo_url TEXT NOT NULL DEFAULT '';
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS favicon_url TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,5 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS posts_is_featured_status_published_at_idx
ON posts (is_featured, status, published_at DESC);

View File

@@ -0,0 +1,7 @@
-- 추천 사이트용 location 값 추가(우측 사이드 Recommended, 관리자 메뉴 탭과 동일 저장소)
ALTER TABLE navigation_items
DROP CONSTRAINT IF EXISTS navigation_items_location_check;
ALTER TABLE navigation_items
ADD CONSTRAINT navigation_items_location_check
CHECK (location IN ('primary', 'footer', 'recommended'));

View File

@@ -0,0 +1,10 @@
-- 비공개(private) 상태를 초안으로 통합하고 CHECK 제약을 published/draft만 허용하도록 변경한다.
UPDATE posts
SET status = 'draft'
WHERE status = 'private';
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_status_check;
ALTER TABLE posts
ADD CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft'));

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS show_post_updated_at BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,4 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS home_cover_image_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS home_cover_title TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS home_cover_text TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,5 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS announcement_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS announcement_text TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS announcement_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS announcement_background_color TEXT NOT NULL DEFAULT '#15171a';

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS signup_blocked_usernames TEXT NOT NULL DEFAULT '["admin","master","zenn","sori","sori.studio"]';

View File

@@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS site_analytics_daily (
day DATE PRIMARY KEY,
page_views INTEGER NOT NULL DEFAULT 0,
visitors INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS post_analytics_daily (
day DATE NOT NULL,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
views INTEGER NOT NULL DEFAULT 0,
reads INTEGER NOT NULL DEFAULT 0,
visitors INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, post_id)
);
CREATE INDEX IF NOT EXISTS post_analytics_daily_day_idx
ON post_analytics_daily (day DESC);
CREATE TABLE IF NOT EXISTS analytics_daily_visitors (
id BIGSERIAL PRIMARY KEY,
day DATE NOT NULL,
scope TEXT NOT NULL,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
visitor_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post'))
);
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_site_uidx
ON analytics_daily_visitors (day, visitor_hash)
WHERE scope = 'site';
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_post_uidx
ON analytics_daily_visitors (day, post_id, visitor_hash)
WHERE scope = 'post';

View File

@@ -0,0 +1,30 @@
ALTER TABLE site_analytics_daily
ADD COLUMN IF NOT EXISTS engaged_views INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_engaged_seconds INTEGER NOT NULL DEFAULT 0;
ALTER TABLE post_analytics_daily
ADD COLUMN IF NOT EXISTS engaged_views INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_engaged_seconds INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS scroll_25 INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS scroll_50 INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS scroll_75 INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS scroll_100 INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS analytics_active_sessions (
session_hash TEXT PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
path TEXT NOT NULL,
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
post_slug TEXT NOT NULL DEFAULT '',
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
duration_seconds INTEGER NOT NULL DEFAULT 0,
max_scroll_ratio REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS analytics_active_sessions_last_seen_idx
ON analytics_active_sessions (last_seen_at DESC);
CREATE INDEX IF NOT EXISTS analytics_active_sessions_user_idx
ON analytics_active_sessions (user_id)
WHERE user_id IS NOT NULL;

View File

@@ -0,0 +1,24 @@
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS author_id UUID REFERENCES users(id) ON DELETE SET NULL;
UPDATE posts
SET author_id = (
SELECT id
FROM (
SELECT id
FROM users
WHERE user_role IN ('owner', 'admin')
OR is_admin = true
) privileged_users
LIMIT 1
)
WHERE author_id IS NULL
AND (
SELECT COUNT(*)
FROM users
WHERE user_role IN ('owner', 'admin')
OR is_admin = true
) = 1;
CREATE INDEX IF NOT EXISTS posts_author_id_idx
ON posts (author_id);

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS home_cover_dark_image_url TEXT NOT NULL DEFAULT '';

Some files were not shown because too many files have changed in this diff Show More