173 Commits

Author SHA1 Message Date
0bb07b5e03 게시물 상세 목차 항목 높이를 최소 24px로 조정 (v1.5.104)
2줄 이상 제목에서 활성 표시선과 텍스트 정렬이 틀어지지 않도록 목차 항목과 활성 라인 높이 처리를 수정했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 15:59:43 +09:00
06271b3674 게시물 상세 사이드바 목차·광고 재배치와 세션 확인 개선
게시물 상세에서는 오른쪽 사이드에 목차와 광고를 배치하고, 비로그인 세션 확인 시 콘솔 401 로그가 나지 않도록 정리했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 15:40:35 +09:00
eba7704ab8 게시물 상세 TOC가 사이드바 남은 높이를 사용하도록 수정
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:21:21 +09:00
95d234a625 글쓰기 태그 제한과 표 기능 추가
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 17:10:16 +09:00
ed30926250 미디어 업로드와 태그 표시 수정 2026-06-09 16:14:47 +09:00
e6669439f3 라이브 코드 블록 패널 상태 수정 2026-06-09 15:59:51 +09:00
4b18ee78f0 코드 블록 슬래시와 되돌리기 수정 2026-06-09 15:24:42 +09:00
080be1ef15 라이브 인용문 아래 이동 수정 2026-06-09 14:30:37 +09:00
34b2d0a4c0 라이브 코드 블록 설정 패널 닫힘 수정 2026-06-09 14:19:26 +09:00
b69039c7ff 다크 모드 인용문 텍스트 색상 수정 2026-06-09 14:09:16 +09:00
f7f09ba3aa 검색 제목 사이트명 자동 추가 제거 2026-06-09 10:12:32 +09:00
eee65a3b43 모바일 회원가입 인증번호 입력 높이 수정 2026-06-08 18:25:18 +09:00
4599e7630d 오른쪽 사이드바 로고 크기 고정 2026-06-08 18:20:58 +09:00
61a872315b 동적 사이트맵과 robots 추가 2026-06-08 16:56:42 +09:00
03cd95fbd0 대표 이미지 표시 토글 상시 활성화 2026-06-08 16:00:05 +09:00
806b181d1f 대표 이미지 전용 카드 썸네일로 정리 2026-06-08 15:54:39 +09:00
eb4018f92c 관리자 미디어 카드 썸네일 탭 분리 2026-06-08 14:57:38 +09:00
664d2f98aa 게시물 목록 카드 썸네일 생성 추가 2026-06-08 14:43:09 +09:00
7a357dcabc 메인 목록 썸네일 높이 안정화 2026-06-08 10:56:09 +09:00
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
263 changed files with 45585 additions and 3099 deletions

View File

@@ -10,10 +10,15 @@ DB_PORT=43119
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
@@ -24,5 +29,11 @@ AVATAR_WEBP_QUALITY=82
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

View File

@@ -122,6 +122,7 @@
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
민감 정보 예시:
- 실명

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>

40
app.vue
View File

@@ -1,5 +1,39 @@
<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 || appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
? [
{
rel: 'icon',
type: 'image/png',
href: appSiteSettings.value.faviconUrl
}
]
: []
}))
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<div class="site-app" :style="siteAccentStyle">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>

View File

@@ -56,6 +56,36 @@
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;
}
@@ -76,6 +106,13 @@
overflow: hidden;
background: #ffffff;
}
html.admin-settings-document,
body.admin-settings-document {
height: 100%;
overflow: hidden;
background: #f7f8fa;
}
}
@layer components {
@@ -116,6 +153,27 @@
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);
@@ -139,7 +197,7 @@
.site-sidebar {
min-height: 0;
background: var(--site-panel);
background: var(--site-bg);
color: var(--site-text);
}
@@ -216,6 +274,63 @@
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를 상속하지 않는 경우 대비
*/
@@ -236,3 +351,14 @@
}
}
@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: '게시물 상세 화면의 오른쪽 사이드바 TOC 아래에 표시됩니다.',
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

@@ -4,45 +4,141 @@ const props = defineProps({
type: Object,
default: () => ({})
},
submitLabel: {
type: String,
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'])
const emit = defineEmits(['submit', 'delete'])
const slugTouched = ref(Boolean(props.initialPage.slug))
const blockEditor = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isUploadingFeaturedImage = ref(false)
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 || '',
content: props.initialPage.content || '',
featuredImage: props.initialPage.featuredImage || ''
status: props.initialPage.status || 'published',
renderMode: props.initialPage.renderMode || 'html_document',
content: props.initialPage.content || ''
})
/**
* 문자열을 URL 슬러그로 변환
* 한글 음절 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} 슬러그
* @returns {string} 영문 슬러그
*/
const toSlug = (value) => value
.normalize('NFC')
.split('')
.map((char) => romanizeHangulSyllable(char))
.join('')
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.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)
@@ -59,220 +155,394 @@ const touchSlug = () => {
}
/**
* 미디어 라이브러리 목록 조회
* @returns {Promise<void>}
* 페이지 설정 패널을 토글한다.
* @returns {void}
*/
const fetchMediaItems = async () => {
isLoadingMedia.value = true
const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
try {
mediaItems.value = await $fetch('/admin/api/media')
} finally {
isLoadingMedia.value = false
/**
* 제목 입력 후 본문 에디터로 이동
* @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 openMediaPicker = async () => {
isMediaPickerOpen.value = true
await fetchMediaItems()
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
}
/**
* 대표 이미지 선택 창 닫기
* @returns {void}
* HTML 기본 문서 골격을 현재 본문에 채운다.
* @returns {Promise<void>}
*/
const closeMediaPicker = () => {
isMediaPickerOpen.value = false
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
}
/**
* 대표 이미지 선택
* @param {Object} item - 미디어 항목
* @returns {void}
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {Promise<void>}
*/
const selectFeaturedImage = (item) => {
form.featuredImage = item.url
closeMediaPicker()
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()
}
/**
* 대표 이미지 삭제
* @returns {void}
*/
const removeFeaturedImage = () => {
form.featuredImage = ''
}
/**
* 대표 이미지 파일 업로드
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadFeaturedImage = async (event) => {
const uploadPageAsset = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
rememberHtmlCursor()
const formData = new FormData()
formData.append('files', files[0])
isUploadingFeaturedImage.value = true
isUploadingPageAsset.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.featuredImage = result.files?.[0]?.url || ''
const uploadedUrl = result.files?.[0]?.url || ''
if (uploadedUrl && form.renderMode === 'html_document') {
await insertTextAtHtmlCursor(uploadedUrl)
}
} finally {
event.target.value = ''
isUploadingFeaturedImage.value = false
isUploadingPageAsset.value = false
}
}
/**
* 제목 입력 후 본문 에디터로 이동
* @returns {void}
* 페이지 입력값을 생성한다.
* @returns {Object} 페이지 입력값
*/
const focusContentEditor = () => {
blockEditor.value?.focusFirstBlock()
}
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', {
title: form.title.trim(),
slug: toSlug(form.slug || form.title),
content: form.content,
featuredImage: form.featuredImage.trim() || null
})
emit('submit', createPayload())
}
/**
* 현재 폼 상태를 저장 완료 기준점으로 표시한다.
* @returns {void}
*/
const markSaved = () => {
savedPageSnapshot.value = serializePageForm()
}
onMounted(markSaved)
defineExpose({
markSaved
})
</script>
<template>
<form class="admin-page-form grid gap-6" @submit.prevent="submitPage">
<div class="admin-page-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<section class="admin-page-form__content grid gap-4">
<input
v-model="form.title"
class="admin-page-form__title-input border-0 bg-transparent px-0 py-2 text-5xl font-semibold leading-tight text-ink outline-none placeholder:text-soft"
type="text"
placeholder="페이지 제목"
required
@keydown.enter.prevent="focusContentEditor"
>
<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__field grid gap-2 text-sm">
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
</div>
</section>
<aside class="admin-page-form__settings grid content-start gap-4">
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">슬러그</span>
<input
v-model="form.slug"
class="admin-page-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>
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">대표 이미지</span>
<figure v-if="form.featuredImage" class="admin-page-form__featured overflow-hidden rounded border border-line bg-white">
<img class="admin-page-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
<figcaption class="admin-page-form__featured-actions grid gap-2 p-3">
<p class="admin-page-form__featured-url break-all text-xs text-muted">
{{ form.featuredImage }}
</p>
<div class="admin-page-form__featured-buttons flex flex-wrap gap-2">
<button class="admin-page-form__featured-change rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
변경
</button>
<label class="admin-page-form__featured-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold">
업로드
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
</label>
<button class="admin-page-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
삭제
</button>
</div>
</figcaption>
</figure>
<div v-else class="admin-page-form__featured-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
<button class="admin-page-form__featured-select rounded border border-line px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
미디어에서 선택
<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>
<label class="admin-page-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
</label>
</div>
</div>
</aside>
</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>
<div class="admin-page-form__actions flex justify-end gap-3 border-t border-line pt-5">
<NuxtLink class="admin-page-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/pages">
취소
</NuxtLink>
<button
class="admin-page-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>
<div
v-if="isMediaPickerOpen"
class="admin-page-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
@click.self="closeMediaPicker"
<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"
>
<section class="admin-page-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
<div class="admin-page-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
<h2 class="admin-page-form__media-picker-title text-lg font-semibold">
대표 이미 선택
<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__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
닫기
<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__media-picker-body max-h-[62vh] overflow-y-auto p-5">
<p v-if="isLoadingMedia" class="admin-page-form__media-picker-loading text-sm text-muted">
미디어를 불러오는 중입니다.
</p>
<div v-else-if="mediaItems.length" class="admin-page-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-page-form__media-picker-item overflow-hidden border border-line bg-white text-left"
type="button"
@click="selectFeaturedImage(item)"
>
<img class="admin-page-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<span class="admin-page-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
</button>
<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>
<p v-else class="admin-page-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
선택할 미디어가 없습니다.
</p>
</div>
</section>
</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,141 @@
<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>
<!-- table -->
<template v-else-if="commandId === 'table'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 5.5h16M4 11.5h16M4 17.5h16M8.5 5.5v12M15.5 5.5v12" />
<rect x="3" y="4" width="18" height="15" rx="1.5" stroke="currentColor" stroke-width="1.8" />
</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

@@ -11,6 +11,14 @@ const props = defineProps({
saving: {
type: Boolean,
default: false
},
requireChanges: {
type: Boolean,
default: false
},
defaultTagType: {
type: String,
default: 'general'
}
})
@@ -25,6 +33,58 @@ const form = reactive({
color: props.initialTag.color || '#15171a'
})
/**
* 태그 입력값을 저장 비교용 형태로 정규화한다.
* @param {Object} tag - 태그 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
*/
const normalizeTagPayload = (tag) => ({
name: String(tag.name || '').trim(),
slug: toSlug(tag.slug || tag.name || ''),
description: String(tag.description || '').trim(),
sortOrder: Number(tag.sortOrder ?? 0),
color: String(tag.color || '#15171a'),
tagType: String(tag.tagType || props.defaultTagType)
})
/**
* 현재 폼 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
*/
const currentPayload = computed(() => normalizeTagPayload({
name: form.name,
slug: form.slug || form.name,
description: form.description,
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 최초 태그 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
*/
const initialPayload = computed(() => normalizeTagPayload({
name: props.initialTag.name || '',
slug: props.initialTag.slug || props.initialTag.name || '',
description: props.initialTag.description || '',
sortOrder: props.initialTag.sortOrder ?? 0,
color: props.initialTag.color || '#15171a',
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 태그 입력값 변경 여부
* @returns {boolean} 변경 여부
*/
const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.stringify(initialPayload.value))
/**
* 태그 저장 가능 여부
* @returns {boolean} 저장 가능 여부
*/
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value))
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
@@ -58,14 +118,11 @@ const touchSlug = () => {
* @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 || 'general'
})
if (!canSubmit.value) {
return
}
emit('submit', currentPayload.value)
}
</script>
@@ -128,7 +185,7 @@ const submitTag = () => {
<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"
:disabled="!canSubmit"
>
{{ saving ? '저장 중' : submitLabel }}
</button>

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

@@ -19,6 +19,8 @@ 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)
/**
* 댓글 시간을 상대 시간 형식으로 변환한다.
@@ -111,7 +113,7 @@ const markAvatarBroken = (commentId) => {
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
member.value = await $fetch('/api/auth/me?optional=1')
} catch {
member.value = null
}
@@ -313,15 +315,15 @@ onMounted(async () => {
</div>
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
<label for="comment-sort">Sort by:</label>
<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">Best</option>
<option value="latest">Latest</option>
<option value="oldest">Oldest</option>
<option value="best">인기순</option>
<option value="latest">최신순</option>
<option value="oldest">오래된순</option>
</select>
</div>
@@ -339,8 +341,8 @@ onMounted(async () => {
<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:opacity-60"
:disabled="submitting"
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 ? '등록 중...' : '댓글 등록' }}
@@ -448,7 +450,7 @@ onMounted(async () => {
</div>
</div>
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] p-2">
<textarea
v-model="replyBody"
rows="3"
@@ -464,8 +466,8 @@ onMounted(async () => {
</button>
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submittingReplyId === comment.id"
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 ? '등록 중...' : '답글 등록' }}
@@ -543,4 +545,3 @@ onMounted(async () => {
</div>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<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', 'focus-line'])
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)"
@focus-line="emit('focus-line', $event)"
/>
</ProseCallout>
</div>
</template>

View File

@@ -0,0 +1,193 @@
<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', 'focus-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"
:data-source-line="bodySourceLine - 1"
:data-source-line-end="bodySourceLine + bodyLines.length"
>
<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)"
@focus-line="emit('focus-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

@@ -1,5 +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>
<div class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<slot />
</div>
<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

@@ -1,21 +1,79 @@
<script setup>
defineProps({
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 my-8 text-[15px] leading-8 text-[var(--site-text)]"
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'
: 'rounded-[10px] border-l-2 border-[var(--site-text)] bg-[var(--site-panel)] px-5 py-4 font-medium'"
? '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-[var(--site-text)]', backgroundClass]"
>
<span class="whitespace-pre-line">
<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

@@ -53,10 +53,24 @@ const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
* @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"
@@ -92,4 +106,7 @@ const displayTitle = computed(() => props.title || displayHost.value || props.ur
</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

@@ -1,7 +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 my-8 rounded-[10px] border border-[var(--site-line)] border-l-[3px] border-l-[var(--site-accent)] bg-[var(--site-panel)] p-5 pl-4 text-[15px] leading-8 text-[var(--site-text)]">
<div class="whitespace-pre-line">
<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

@@ -59,9 +59,64 @@ const getTweetId = (value) => {
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 주소
@@ -76,10 +131,74 @@ const tweetEmbedUrl = computed(() => {
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] border border-[var(--site-line)] bg-[var(--site-panel)]">
<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"
@@ -92,19 +211,36 @@ const tweetEmbedUrl = computed(() => {
<iframe
v-else-if="tweetEmbedUrl"
:key="tweetEmbedUrl"
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
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
v-else-if="safeExternalUrl"
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
:href="url"
: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

@@ -1,5 +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>
<div class="prose-file my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<slot />
</div>
<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

@@ -3,6 +3,10 @@ const props = defineProps({
level: {
type: Number,
default: 2
},
id: {
type: String,
default: ''
}
})
@@ -12,7 +16,9 @@ const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
<template>
<component
:is="tagName"
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
: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,

View File

@@ -1,33 +1,126 @@
<script setup>
defineProps({
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
const props = defineProps({
src: {
type: String,
required: true
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 my-8"
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="overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<img class="prose-image__media w-full object-cover" :src="src" :alt="alt">
<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="$slots.default" class="prose-image__caption mt-3 text-center text-sm text-[var(--site-muted)]">
<slot />
<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

@@ -10,8 +10,7 @@ defineProps({
<template>
<component
:is="ordered ? 'ol' : 'ul'"
class="prose-list my-6 space-y-2 pl-5 text-[15px] leading-8 text-[var(--site-text)] marker:text-[var(--site-muted)]"
:class="ordered ? 'list-decimal' : 'list-disc'"
class="prose-list mb-2.5 list-none space-y-2 pl-0 text-[15px] leading-8 text-[var(--site-text)]"
>
<slot />
</component>

View File

@@ -1,19 +1,112 @@
<script setup>
defineProps({
const props = defineProps({
/** 접힌 상태 제목 */
title: {
type: String,
required: true
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>
<details class="prose-toggle my-6 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<summary class="prose-toggle__summary cursor-pointer text-[15px] font-semibold leading-7 text-[var(--site-text)]">
{{ title }}
</summary>
<div class="prose-toggle__body mt-4 whitespace-pre-line text-[15px] leading-8 text-[var(--site-muted)]">
<slot />
<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>
</details>
<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

@@ -1,5 +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>
<div class="prose-video my-8 aspect-video overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<slot />
</div>
<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

@@ -15,9 +15,127 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: navigation } = await useFetch('/api/navigation', {
default: () => ({
primary: [],
footer: []
footer: [],
recommended: []
})
})
/** 저자 영역 공개 여부 */
const showAuthorSection = false
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>
@@ -31,20 +149,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
<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">
<ul class="flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
<li
v-for="item in navigation.primary"
:key="item.id"
class="group relative flex w-full items-center"
>
<NuxtLink
class="left-sidebar__nav-link site-panel-hover flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] 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 hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
:to="item.url"
>
<span class="left-sidebar__nav-link-label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ item.label }}</span>
</NuxtLink>
</li>
</ul>
<SidebarPrimaryNavList :nodes="navigation.primary" />
</nav>
</div>
@@ -57,7 +162,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
<NuxtLink
v-for="tag in tags"
:key="tag.id"
class="left-sidebar__category site-panel-hover 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"
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 }" />
@@ -72,7 +177,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
</div>
</div>
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
<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>
@@ -90,19 +195,22 @@ const { data: navigation } = await useFetch('/api/navigation', {
</div>
</div>
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between px-4 py-4 text-xs sm:px-5">
<nav class="left-sidebar__footer-nav flex gap-4">
<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="site-interactive"
class="left-sidebar__footer-link site-interactive shrink-0"
:to="item.url"
>
{{ item.label }}
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 place-items-center rounded-full"
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 ? '라이트 모드' : '다크 모드'"

View File

@@ -8,27 +8,30 @@ defineProps({
</script>
<template>
<article class="post-card site-section site-panel-hover">
<article class="post-card site-section site-panel-hover group">
<div class="post-card__body site-section-body flex gap-4">
<img
v-if="post.featuredImage"
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
:src="post.featuredImage"
:alt="post.title"
loading="lazy"
>
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
<PostCardMedia
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
:thumbnail-image="post.featuredImageThumbnail"
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 class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
<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 }} / {{ post.tag }}
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
</p>
</div>
</div>

View File

@@ -0,0 +1,69 @@
<script setup>
const props = defineProps({
/** 게시물 링크 */
to: {
type: String,
required: true
},
/** 게시물 제목 */
title: {
type: String,
required: true
},
/** 대표 이미지 URL */
featuredImage: {
type: String,
default: ''
},
/** 목록 표시용 대표 이미지 썸네일 URL */
thumbnailImage: {
type: String,
default: ''
},
/** 썸네일 비율·크기 Tailwind 클래스 */
aspectClass: {
type: String,
default: 'aspect-square sm:aspect-video'
},
/** 링크 래퍼 추가 클래스 */
linkClass: {
type: String,
default: ''
},
/** 이미지 추가 클래스 */
imageClass: {
type: String,
default: ''
}
})
const displayImage = computed(() => props.thumbnailImage || props.featuredImage)
</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="displayImage"
class="post-card-media__image w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90"
:class="[aspectClass, imageClass]"
:src="displayImage"
: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 max-w-full break-words line-clamp-3 sm:line-clamp-4">{{ title }}</span>
</span>
</figure>
</NuxtLink>
</template>

View File

@@ -1,30 +1,260 @@
<script setup>
const followLinks = [
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
]
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: [],
adSidebarCode: '',
adPostSidebarCode: '',
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?.adPostSidebarCode : 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 scrollContainer = nav.closest('.site-sidebar-scroll')
const link = nav.querySelector(`[data-toc-id="${id}"]`)
if (!(link instanceof HTMLElement) || !(scrollContainer instanceof HTMLElement)) {
return
}
const containerRect = scrollContainer.getBoundingClientRect()
const linkRect = link.getBoundingClientRect()
const navTop = scrollContainer.scrollTop
const navBottom = navTop + scrollContainer.clientHeight
const linkTop = navTop + linkRect.top - containerRect.top
const linkBottom = linkTop + link.offsetHeight
const buffer = 24
if (linkTop < navTop + buffer) {
scrollContainer.scrollTo({
top: Math.max(0, linkTop - buffer),
behavior: 'smooth'
})
return
}
if (linkBottom > navBottom - buffer) {
scrollContainer.scrollTo({
top: linkBottom - scrollContainer.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-0 max-lg:px-0">
<div class="right-sidebar__scroll site-sidebar-scroll flex min-h-0 flex-1 flex-col">
<div v-if="!isPostDetailRoute" 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 rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
{{ siteSettings.logoText }}
<div class="right-sidebar__logo grid h-12 w-12 shrink-0 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">
@@ -35,15 +265,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</p>
</div>
</div>
<form class="right-sidebar__subscribe mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input class="right-sidebar__input min-w-0 w-full flex-1 rounded-lg px-3 py-2 text-sm site-input sm:min-w-0" placeholder="Your email">
<button class="right-sidebar__button shrink-0 rounded-lg px-4 py-2 text-sm font-semibold site-button sm:self-auto" type="button">
Subscribe
</button>
</form>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div v-if="!isPostDetailRoute && 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
@@ -52,11 +276,11 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
<a
v-for="item in followLinks"
:key="item.id"
class="site-interactive p-0.5 hover:opacity-75"
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="_blank"
rel="noreferrer"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noreferrer' : undefined"
>
<svg
v-if="item.icon === 'facebook'"
@@ -67,7 +291,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
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>
@@ -80,7 +304,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
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" />
@@ -94,7 +318,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
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>
@@ -107,7 +331,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
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" />
@@ -122,12 +346,47 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
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"
@@ -137,11 +396,10 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
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" />
<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>
@@ -149,27 +407,89 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div
v-if="isPostDetailRoute"
class="right-sidebar__block right-sidebar__toc py-5 pl-5 pr-0 max-lg:hidden"
>
<div class="right-sidebar__row flex shrink-0 items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
목차
</p>
</div>
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 pr-2" aria-label="게시글 목차">
<ul v-if="postTocItems.length" class="right-sidebar__toc-list flex list-none flex-col gap-2 border-l border-[var(--site-line)] p-0">
<li
v-for="item in postTocItems"
:key="item.id"
class="right-sidebar__toc-item relative flex min-h-6 items-center transition-colors"
:class="activeTocId === item.id ? 'right-sidebar__toc-item--active' : ''"
>
<a
class="right-sidebar__toc-link site-interactive flex min-h-6 w-full items-center rounded-md pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
:class="{
'bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
'text-[var(--site-text)]': activeTocId !== item.id,
'pl-4 font-semibold': item.level === 1,
'pl-7': item.level === 2,
'pl-10': 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>
<span></span>
</div>
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/post/hello-sori-studio">
sori.studio 글과 방향
</NuxtLink>
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/projects">
Projects and services
</NuxtLink>
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/links">
Links and portal
</NuxtLink>
</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 class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<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>
@@ -177,6 +497,12 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
About {{ siteSettings.title }}
</NuxtLink>
</div>
<SiteAdSlot
class="right-sidebar__ad-slot py-5 pl-5 pr-0 max-lg:px-0"
:code="sidebarAdCode"
:location="isPostDetailRoute ? 'post-sidebar-right' : 'sidebar'"
/>
</div>
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
@@ -184,3 +510,26 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</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;
}
.right-sidebar__toc-item--active::before {
content: "";
position: absolute;
left: -2px;
top: 50%;
width: 3px;
height: 100%;
background: var(--site-accent);
transform: translateY(-50%);
}
</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

@@ -65,7 +65,7 @@ const toggleUserMenu = () => {
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
member.value = await $fetch('/api/auth/me?optional=1')
} catch {
member.value = null
}
@@ -151,9 +151,10 @@ onBeforeUnmount(() => {
</script>
<template>
<header class="site-header sticky top-0 z-20 backdrop-blur">
<div class="site-header__inner mx-auto flex h-full max-w-[1294px] items-center justify-between gap-3 px-4 sm:gap-4 lg:gap-5 lg:px-5 xl:gap-6 xl:px-6 2xl:px-0">
<NuxtLink class="site-header__brand flex min-w-0 shrink-1 items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,32vw)] xl:max-w-[min(320px,28vw)] 2xl:max-w-none 2xl:flex-1" to="/">
<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"
@@ -165,14 +166,6 @@ onBeforeUnmount(() => {
@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="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>
<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="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" />
@@ -183,20 +176,37 @@ onBeforeUnmount(() => {
<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>
<button
</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 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
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"></span>
<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>
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
</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"
@@ -213,7 +223,15 @@ onBeforeUnmount(() => {
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">
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
<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>
@@ -244,7 +262,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex flex-col gap-0.5">
<div class="max-w-xs truncate leading-[1.15]">
{{ member?.username || 'Anonymous' }}
{{ member?.username || 'Guest' }}
</div>
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
{{ member.email }}

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,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

@@ -20,3 +20,50 @@ export function formatPostDate(value) {
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

@@ -1,4 +1,7 @@
const themeStorageKey = 'SITE_THEME'
import {
SITE_THEME_STORAGE_KEY,
resolveSiteTheme
} from '~/lib/site-theme-init.js'
/**
* HTML 루트 요소에 현재 테마를 반영한다.
@@ -34,19 +37,23 @@ export const useThemeMode = () => {
const theme = useState('site-theme-mode', () => 'light')
const isDarkMode = computed(() => theme.value === 'dark')
onMounted(() => {
const savedTheme = localStorage.getItem(themeStorageKey)
const nextTheme = savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : getSystemTheme()
theme.value = nextTheme
applyThemeToDocument(nextTheme)
})
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(themeStorageKey, nextTheme)
localStorage.setItem(SITE_THEME_STORAGE_KEY, nextTheme)
applyThemeToDocument(nextTheme)
})

View File

@@ -15,15 +15,24 @@ 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)
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)
ON CONFLICT DO NOTHING;
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,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 '';

View File

@@ -0,0 +1,14 @@
ALTER TABLE pages
ADD COLUMN IF NOT EXISTS render_mode TEXT NOT NULL DEFAULT 'markdown';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'pages_render_mode_check'
) THEN
ALTER TABLE pages
ADD CONSTRAINT pages_render_mode_check CHECK (render_mode IN ('markdown', 'html_document'));
END IF;
END $$;

View File

@@ -0,0 +1,2 @@
ALTER TABLE pages
ALTER COLUMN render_mode SET DEFAULT 'html_document';

View File

@@ -0,0 +1,15 @@
ALTER TABLE posts DROP CONSTRAINT IF EXISTS posts_status_check;
ALTER TABLE posts
ADD CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'members', 'private'));
ALTER TABLE pages
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'published';
ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_status_check;
ALTER TABLE pages
ADD CONSTRAINT pages_status_check CHECK (status IN ('published', 'draft', 'private'));
CREATE INDEX IF NOT EXISTS pages_status_updated_at_idx
ON pages (status, updated_at DESC);

View File

@@ -0,0 +1,6 @@
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', 'vip', 'member'));

View File

@@ -0,0 +1,18 @@
WITH fallback_owner AS (
SELECT id
FROM users
WHERE user_role = 'admin'
ORDER BY created_at ASC, id ASC
LIMIT 1
)
UPDATE users
SET
user_role = 'owner',
is_admin = true,
updated_at = now()
WHERE id IN (SELECT id FROM fallback_owner)
AND NOT EXISTS (
SELECT 1
FROM users
WHERE user_role = 'owner'
);

View File

@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS page_analytics_daily (
day DATE NOT NULL,
page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
views INTEGER NOT NULL DEFAULT 0,
visitors INTEGER NOT NULL DEFAULT 0,
engaged_views INTEGER NOT NULL DEFAULT 0,
total_engaged_seconds INTEGER NOT NULL DEFAULT 0,
scroll_25 INTEGER NOT NULL DEFAULT 0,
scroll_50 INTEGER NOT NULL DEFAULT 0,
scroll_75 INTEGER NOT NULL DEFAULT 0,
scroll_100 INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, page_id)
);
CREATE INDEX IF NOT EXISTS page_analytics_daily_day_idx
ON page_analytics_daily (day DESC);
ALTER TABLE analytics_daily_visitors
ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE CASCADE;
ALTER TABLE analytics_daily_visitors
DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check;
ALTER TABLE analytics_daily_visitors
ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page'));
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_page_uidx
ON analytics_daily_visitors (day, page_id, visitor_hash)
WHERE scope = 'page';
ALTER TABLE analytics_active_sessions
ADD COLUMN IF NOT EXISTS page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS page_slug TEXT NOT NULL DEFAULT '';
ALTER TABLE navigation_items
ADD COLUMN IF NOT EXISTS description_text TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS thumbnail_url TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS post_export_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
requested_by UUID REFERENCES users(id) ON DELETE SET NULL,
requested_email TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'queued',
scope TEXT NOT NULL DEFAULT 'all',
post_count INTEGER NOT NULL DEFAULT 0,
chunk_size INTEGER NOT NULL DEFAULT 100,
retention_days INTEGER NOT NULL DEFAULT 100,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '100 days'),
message TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT post_export_jobs_status_check CHECK (status IN ('queued', 'processing', 'ready', 'failed', 'expired')),
CONSTRAINT post_export_jobs_scope_check CHECK (scope IN ('all', 'author')),
CONSTRAINT post_export_jobs_chunk_size_check CHECK (chunk_size > 0),
CONSTRAINT post_export_jobs_retention_days_check CHECK (retention_days > 0 AND retention_days <= 100)
);
CREATE INDEX IF NOT EXISTS post_export_jobs_status_created_at_idx
ON post_export_jobs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS post_export_jobs_expires_at_idx
ON post_export_jobs (expires_at);
CREATE TABLE IF NOT EXISTS post_export_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES post_export_jobs(id) ON DELETE CASCADE,
part_index INTEGER NOT NULL,
post_start INTEGER NOT NULL,
post_end INTEGER NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '',
file_size_bytes BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT post_export_files_status_check CHECK (status IN ('pending', 'processing', 'ready', 'failed', 'expired')),
CONSTRAINT post_export_files_range_check CHECK (post_start > 0 AND post_end >= post_start),
CONSTRAINT post_export_files_part_index_check CHECK (part_index > 0),
CONSTRAINT post_export_files_job_part_unique UNIQUE (job_id, part_index)
);
CREATE INDEX IF NOT EXISTS post_export_files_job_id_part_index_idx
ON post_export_files (job_id, part_index ASC);

View File

@@ -0,0 +1,15 @@
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS processed_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS current_part_index INTEGER;
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS progress_message TEXT NOT NULL DEFAULT '';
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
ALTER TABLE post_export_jobs
ADD CONSTRAINT post_export_jobs_processed_count_check
CHECK (processed_count >= 0);

View File

@@ -0,0 +1,8 @@
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS date_from TIMESTAMPTZ;
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS date_to TIMESTAMPTZ;
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS range_label TEXT NOT NULL DEFAULT '전체';

View File

@@ -0,0 +1,9 @@
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS max_file_size_bytes BIGINT NOT NULL DEFAULT 524288000;
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS error_detail TEXT NOT NULL DEFAULT '';
ALTER TABLE post_export_jobs
ADD CONSTRAINT post_export_jobs_max_file_size_bytes_check
CHECK (max_file_size_bytes >= 10485760);

View File

@@ -0,0 +1,4 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS ads_txt TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS custom_head_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS custom_footer_code TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS analytics_traffic_daily (
day DATE NOT NULL,
source_group TEXT NOT NULL,
source_name TEXT NOT NULL,
device_type TEXT NOT NULL,
os_name TEXT NOT NULL,
keyword TEXT NOT NULL DEFAULT '',
page_views INTEGER NOT NULL DEFAULT 0,
visitors INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, source_group, source_name, device_type, os_name, keyword)
);
CREATE INDEX IF NOT EXISTS analytics_traffic_daily_day_idx
ON analytics_traffic_daily (day DESC);
CREATE INDEX IF NOT EXISTS analytics_traffic_daily_source_idx
ON analytics_traffic_daily (source_group, source_name);
ALTER TABLE analytics_daily_visitors
ADD COLUMN IF NOT EXISTS source_group TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS source_name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS device_type TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS os_name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS keyword TEXT NOT NULL DEFAULT '';
ALTER TABLE analytics_daily_visitors
DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check;
ALTER TABLE analytics_daily_visitors
ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page', 'traffic'));
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_traffic_uidx
ON analytics_daily_visitors (
day,
visitor_hash,
source_group,
source_name,
device_type,
os_name,
keyword
)
WHERE scope = 'traffic';

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS social_links JSONB NOT NULL DEFAULT '[]'::jsonb;

View File

@@ -0,0 +1,5 @@
UPDATE site_settings
SET social_links = (social_links #>> '{}')::jsonb
WHERE jsonb_typeof(social_links) = 'string'
AND (social_links #>> '{}') IS NOT NULL
AND (social_links #>> '{}') ~ '^\s*\[';

View File

@@ -0,0 +1,5 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS ad_home_feed_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ad_sidebar_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ad_post_top_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS ad_post_bottom_code TEXT NOT NULL DEFAULT '';

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
ALTER TABLE post_tags
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
WITH ordered_post_tags AS (
SELECT
post_id,
tag_id,
(ROW_NUMBER() OVER (
PARTITION BY post_id
ORDER BY created_at ASC, tag_id ASC
) - 1) * 10 AS next_sort_order
FROM post_tags
)
UPDATE post_tags
SET sort_order = ordered_post_tags.next_sort_order
FROM ordered_post_tags
WHERE post_tags.post_id = ordered_post_tags.post_id
AND post_tags.tag_id = ordered_post_tags.tag_id;
CREATE INDEX IF NOT EXISTS post_tags_post_id_sort_order_idx
ON post_tags (post_id, sort_order ASC, created_at ASC);

View File

@@ -0,0 +1,10 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS post_tag_limit INTEGER NOT NULL DEFAULT 5;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_post_tag_limit_range_check
CHECK (post_tag_limit BETWEEN 1 AND 10)
NOT VALID;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_post_tag_limit_range_check;

View File

@@ -12,6 +12,8 @@ services:
- ./public/uploads:/app/public/uploads
depends_on:
- sori-studio-db
networks:
- sori-studio-network
restart: unless-stopped
sori-studio-db:
@@ -27,8 +29,18 @@ services:
- "${DB_PORT:-43119}:5432"
volumes:
- sori-studio-postgres:/var/lib/postgresql/data
# NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인.
- ./db/migrations:/docker-entrypoint-initdb.d:ro
networks:
- sori-studio-network
restart: unless-stopped
volumes:
sori-studio-postgres:
networks:
sori-studio-network:
driver: bridge
ipam:
config:
- subnet: ${DOCKER_SUBNET:-10.250.50.0/24}

View File

@@ -1,5 +1,684 @@
# 업데이트 요약
## v1.5.104
- 게시물 상세 목차에서 긴 제목이 2줄 이상으로 표시되어도 활성 왼쪽 라인이 항목 높이에 맞게 표시되도록 조정했다.
## v1.5.103
- 게시물 상세 목차의 활성 왼쪽 라인을 scoped CSS로 직접 그려 표시 안정성을 높였다.
## v1.5.102
- 게시물 상세 목차 항목의 높이와 세로 정렬을 맞추고, 활성 표시선이 브랜드 컬러로 다시 보이도록 조정했다.
## v1.5.101
- 게시물 상세 목차의 활성 표시선이 생겨도 항목 텍스트 시작점이 흔들리지 않게 조정했다.
## v1.5.100
- 게시물 상세 목차의 왼쪽 라인을 항목별 보더로 바꿔 활성 위치가 브랜드 컬러로 더 명확하게 보이게 했다.
## v1.5.99
- 게시물 상세 목차 라벨을 한글로 바꾸고, 활성 목차 항목을 브랜드 컬러로 더 또렷하게 표시했다.
## v1.5.98
- 게시물 상세 TOC가 별도 높이 제한 없이 전체 목차를 먼저 보여 주고, 게시물 사이드 광고는 그 아래에 이어지도록 바꿨다.
## v1.5.97
- 게시물 상세 오른쪽 사이드바에서 소개·Follow를 숨기고 TOC를 최상단으로 올렸다.
- 게시물 사이드 광고는 왼쪽 사이드바에서 오른쪽 TOC 아래로 이동했다.
- TOC에 세로 기준선과 활성 항목 표시선을 추가하고, 광고 영역을 위해 높이를 제한했다.
## v1.5.96
- 비로그인 상태로 공개 사이트를 볼 때 회원/관리자 세션 확인 요청의 401 콘솔 로그가 반복 표시되지 않게 했다.
## v1.5.95
- 게시물 상세의 왼쪽 사이드 광고와 오른쪽 TOC 영역에서 내용 없이 구분선만 보이는 상황을 줄였다.
## v1.5.94
- 게시물 상세 오른쪽 사이드바 TOC가 아래 빈 공간까지 사용해 긴 목차를 더 많이 볼 수 있다.
## v1.5.93
- 글쓰기 태그는 기본 최대 5개까지 선택할 수 있으며, 사이트 설정에서 1~10개 범위로 조절할 수 있게 했다.
- 게시물 저장 API도 설정된 태그 최대 개수를 초과하면 저장을 막는다.
- `/표` 또는 `/table` 슬래시 명령으로 기본 표를 삽입할 수 있고, 공개 본문에서 마크다운 표가 표 형태로 렌더링된다.
## v1.5.92
- 새 태그 저장 후 태그 목록으로 돌아가고, 저장·삭제 결과를 토스트로 확인할 수 있게 했다.
- 태그 수정의 변경 저장 버튼은 실제 변경사항이 있을 때만 활성화된다.
- 글쓰기 미디어 모달은 업로드 완료 후 자동 삽입·자동 닫기를 하지 않고, 목록에서 직접 선택해 삽입하도록 했다.
- 미디어 업로드 중에는 추가 업로드와 닫기를 막는다.
## v1.5.91
- 글쓰기 본문 미디어 선택 창에서 카드 썸네일 파생 이미지가 중복으로 보이지 않게 했다.
- 미디어 업로드 중에는 추가 드롭·파일 선택을 막고 업로드 중 로딩 표시를 보여 주도록 했다.
- 공개 게시물 목록은 첫 번째 태그를 기준으로 표시하고, 관리자 게시물 목록은 적용된 태그 전체를 보여 주도록 정리했다.
## v1.5.90
- 라이브 글쓰기에서 코드블럭을 빠져나온 뒤에도 오른쪽 코드블럭 설정 패널이 남아 있던 문제를 다시 수정했다.
- 마지막 코드블럭 아래로 이동해 새 문단을 만들 때 패널 상태가 즉시 일반 문단으로 바뀌도록 보강했다.
## v1.5.89
- 코드블럭 안의 `/volume1/...` 같은 경로가 슬래시 명령으로 오인되던 문제를 수정했다.
- 글쓰기 에디터에서 `Cmd/Ctrl+Z` 되돌리기와 다시 실행을 지원하도록 보강했다.
## v1.5.88
- 라이브 글쓰기에서 마지막 인용문 아래 방향키 입력 시 일반 문단으로 빠져나가지 못하던 문제를 수정했다.
## v1.5.87
- 라이브 글쓰기에서 코드블럭을 벗어난 뒤에도 오른쪽 코드블럭 설정 패널이 남아 있던 문제를 수정했다.
## v1.5.86
- 다크 모드에서 인용문 글자가 검게 표시되어 읽기 어려운 문제를 수정했다.
## v1.5.85
- 검색 결과용 페이지 제목에서 게시물 제목 뒤 사이트 이름이 자동으로 붙지 않도록 정리했다.
## v1.5.84
- 모바일 회원가입 화면에서 이메일 인증번호 입력창 높이가 작게 보이던 문제를 수정했다.
## v1.5.83
- 오른쪽 사이드바 사이트 로고가 좁게 눌려 보이던 문제를 수정했다.
## v1.5.82
- `/sitemap.xml`을 추가해 공개 게시물·페이지·태그 URL을 검색엔진에 전달할 수 있게 했다.
- `/robots.txt`에서 sitemap 위치를 안내하도록 했다.
- sitemap은 요청 시점 기준으로 자동 생성되므로 새 게시물마다 파일을 직접 갱신하지 않아도 된다.
## v1.5.81
- 글쓰기 오른쪽 사이드에서 `대표 이미지 표시` 토글이 항상 보이고 동작하도록 정리했다.
- 대표 이미지를 나중에 추가해도 미리 정한 표시 설정이 유지된다.
## v1.5.80
- 본문 첨부 이미지는 업로드만으로 카드 썸네일을 만들지 않도록 정리했다.
- 게시물 대표 이미지로 저장된 이미지에만 목록 카드용 썸네일을 생성한다.
- 관리자 미디어에서 누락된 대표 이미지 카드 썸네일을 다시 생성할 수 있게 했다.
- 게시물 상세의 제목 아래 대표 이미지는 글쓰기 옵션을 켠 경우에만 표시된다.
## v1.5.79
- 관리자 미디어에서 카드 썸네일을 별도 탭으로 분리했다.
- 카드 썸네일 사용 여부를 원본 대표 이미지 사용처와 연결해 미사용으로 잘못 표시되지 않게 했다.
- 썸네일이 없어 목록에서 원본을 불러오는 대표 이미지를 구분해 표시하도록 했다.
## v1.5.78
- 게시물 목록 카드에서 원본 대표 이미지 대신 생성된 카드용 썸네일을 우선 사용하도록 개선했다.
- 기존 업로드 이미지도 한 번에 카드용 썸네일로 변환할 수 있는 백필 명령을 추가했다.
## v1.5.77
- 메인 화면 Latest 목록에서 긴 설명 때문에 메타 정보가 잘리는 문제를 줄였다.
## v1.5.76
- 관리자 사이트 설정 좌측 메뉴에 아이콘이 없던 항목에 아이콘을 추가했다.
## v1.5.75
- 게시물 작성 화면에서 단어 수, 문자 수, 공백 수, 읽기 시간, 블록 수, 이미지 수를 확인할 수 있게 했다.
## v1.5.70
- 라이브 모드 마지막 줄에서 `!!!`로 콜아웃을 만들 때 본문 줄을 안정적으로 확보하도록 수정했다.
- 콜아웃·인용 내부 전체 선택 후 Delete가 본문 삭제로 반영되도록 수정했다.
- 콜아웃·인용에서 줄바꿈 직후 한글 첫 글자가 자모로 분리되는 문제를 줄였다.
## v1.5.69
- 라이브 모드 인용·콜아웃에서 Enter 줄바꿈과 `Cmd+Shift+K` 줄 삭제가 다시 안정적으로 반영되도록 수정했다.
- 구조 변경 직후 이전 contenteditable DOM이 다시 저장되는 문제를 줄였다.
## v1.5.68
- 라이브 모드에서 Shift+위/아래로 인접 문단을 선택하는 동작을 다시 보강했다.
- `$H_2O$`, `$2^8$` 같은 Obsidian식 아래첨자·위첨자 표시를 추가했다.
## v1.5.67
- 라이브 모드에서 Shift+방향키로 다음 문단까지 범위 선택이 더 안정적으로 동작하도록 수정했다.
- 라이브 모드에서 여러 블록을 선택한 뒤 삭제·잘라내기가 소스 모드처럼 본문에 반영되도록 수정했다.
## v1.5.66
- 라이브 모드에서 Shift+방향키로 여러 문단·블록을 한 번에 범위 선택할 수 있게 했다.
- 라이브 모드 `Cmd/Ctrl+A`를 현재 블록 전체 선택과 본문 전체 선택으로 나눴다.
## v1.5.65
- 라이브 모드 인용·콜아웃 같은 본문 블록에서 한글 마지막 글자 입력 후 Enter 한 번으로 줄바꿈되도록 보강했다.
## v1.5.64
- 라이브 모드 편집 영역에서 Shift 범위 선택과 전체 선택이 다시 동작하도록 수정했다.
## v1.5.63
- 라이브 모드에서 한글 입력 중 Enter를 눌렀을 때 글자 확정 뒤 줄바꿈·블록 분리가 바로 이어지도록 수정했다.
- 문단, 제목, 목록, 인용, 콜아웃, 코드, 토글 편집에서 같은 Enter 동작을 쓰도록 보강했다.
## v1.5.62
- 라이브 모드에서 문단을 합친 뒤 Enter로 다시 나눌 때 아래 줄 내용이 복제되던 문제를 수정했다.
## v1.5.61
- 콜아웃 본문 첫 줄이 비어 있는 상태에서 소스·라이브 모드를 오갈 때 본문이 사라지던 문제를 수정했다.
## v1.5.55
- 소스 모드와 라이브 모드에서 `Cmd+Shift+K` 줄 삭제 단축키가 다시 동작하도록 보강했다.
- 소스 모드에서 여러 줄을 선택한 뒤 `Cmd+Shift+K`를 누르면 선택 범위 줄을 함께 삭제한다.
## v1.5.54
- 콜아웃을 아이콘·제목 헤더와 아래 본문 구조로 정리했다.
- 콜아웃 제목을 오른쪽 블록 설정 패널에서 지정할 수 있게 했다.
- 새 콜아웃은 기본적으로 아이콘을 표시하지 않는다.
## v1.5.53
- 라이브 콜아웃 본문에서 여러 줄을 `Shift+방향키`로 자연스럽게 선택할 수 있게 했다.
- 콜아웃 아이콘을 라이브·사용자 화면 모두 왼쪽 상단에 맞췄다.
- 아이콘을 사용하지 않는 콜아웃은 라이브 편집 화면에서도 자리 표시자를 남기지 않는다.
## v1.5.52
- 연속 콜아웃에서 위 콜아웃을 편집하면 아래 콜아웃 선언 줄이 사라지던 문제를 수정했다.
- `:::` fenced 블록의 원본 줄 범위를 닫는 줄까지만 잡도록 보정했다.
- 한글 조합 직후 Enter 중복 입력 차단을 더 강하게 적용했다.
## v1.5.51
- 라이브 인용·콜아웃에서 한글 입력 후 Enter가 줄을 2개 만들던 문제를 다시 보정했다.
- 콜아웃 마지막 줄에서 아래 방향키를 눌러도 새 본문 줄이 생기지 않도록 했다.
- 인용은 마지막 줄 아래 방향키에서만 외부 문단을 만들도록 동작을 분리했다.
## v1.5.50
- 라이브 작성 모드에서 한글 인용문 Enter가 외부 문단으로 빠지지 않고 다음 인용 줄을 만들도록 보강했다.
- 마지막 인용 줄에서 아래 방향키로 외부 문단을 만들며 빠져나갈 수 있게 했다.
- 콜아웃 본문을 줄 단위로 편집해 현재 줄 삭제와 한글 Enter 줄 추가가 더 안정적으로 동작하도록 했다.
## v1.5.49
- 라이브 작성 모드에서 코드·콜아웃·토글 내부 `Cmd+Shift+K` 줄 삭제가 동작하도록 수정했다.
- 콜아웃 내부 줄 삭제와 아래 방향키 이탈 동작을 보강했다.
- `/콜아웃` Enter 생성 시 한글 조합 잔여 문자가 남을 가능성을 줄였다.
## v1.5.48
- 게시물 작성 화면 상단의 상태 표시는 텍스트만 남기고, 게시물 보기 링크는 오른쪽 `View Post` 기능으로 통일했다.
## v1.5.47
- RSS 피드에 게시물 썸네일 정보를 직접 포함해 RSS 리더에서 대표 이미지가 더 안정적으로 보이도록 했다.
- RSS의 상대 이미지 경로를 절대 URL로 변환한다.
## v1.5.46
- 콜아웃 배경색도 인용 블록과 같은 6색 팔레트로 맞추고 분홍 선택지를 제거했다.
- 라이브 작성 모드에서 방향키로 위에서 아래로 이동할 때 콜아웃·인용 블록에 진입하지 못하던 문제를 수정했다.
- 작은 화면에서 게시물 설정 패널이 본문을 압축하지 않고 오른쪽 오버레이로 뜨도록 정리했다.
## v1.5.45
- 인용 블록 기본 색상을 회색으로 바꾸고 분홍 선택지를 제거했다.
- 인용 색상 선택 배지를 실제 인용 블록 색상과 맞췄다.
- 라이브 작성 모드의 콜아웃·인용 설정을 소스 모드와 같은 오른쪽 패널 방식으로 통일했다.
## v1.5.44
- 관리자 사이트 설정의 제목·설명, 사이트 정보, 사이트 코드 읽기 화면을 긴 문구가 잘리지 않는 14px 라벨/값 행 레이아웃으로 정리했다.
- 사이트 로고 미등록 상태는 별도 “등록됨” 문구 없이 점선 미등록 박스로 표시한다.
## v1.5.43
- `/rss.xml`, `/feed.xml`, `/rss`에서 최근 공개 발행글 RSS 피드를 제공한다.
- SNS 설정의 RSS 프리셋 기본 주소를 `/rss.xml`로 맞췄다.
## v1.5.42
- 직접 SVG로 등록한 SNS 아이콘도 기존 SNS 아이콘과 같은 크기와 중앙 정렬로 표시되게 정리했다.
## v1.5.41
- SNS 링크가 저장 후 사라져 보이던 문제를 수정했다.
- SNS 링크 편집 화면을 아이콘과 주소 중심으로 단순화했다.
## v1.5.40
- SNS 링크 주소 입력 시 `https://`를 생략해도 자동으로 보정된다.
- SNS 아이콘 프리셋에 없는 서비스는 직접 SVG 아이콘을 등록해 사용할 수 있게 했다.
## v1.5.39
- 관리자 사이트 설정에서 SNS 링크를 아이콘 프리셋과 주소 목록으로 관리할 수 있게 했다.
- 공개 오른쪽 사이드바 FOLLOW 영역은 등록된 SNS 링크가 있을 때만 표시된다.
## v1.5.38
- 어나운스 바 배경색을 직접 선택·입력할 수 있게 했다.
- 어나운스 바 문구를 중앙 또는 왼쪽 정렬로 표시할 수 있게 했다.
- 한글 입력 중 코드 블록·콜아웃·토글 설정 패널이 줄바꿈 뒤 닫히던 문제를 보강했다.
## v1.5.37
- 게시물 글쓰기 오른쪽 블록 설정 패널에서 콜아웃, 코드 블록, 토글 옵션을 수정할 수 있게 했다.
- 토글 블록은 기본 펼침 또는 기본 닫힘 상태를 저장할 수 있게 했다.
- 파일 다운로드 카드에서 반복되는 파일명 표시를 줄이고 용량 중심으로 보이게 정리했다.
## v1.5.36
- 관리자 사이트 설정에서 브랜드 포인트 컬러를 지정할 수 있게 했다.
- 지정한 브랜드 컬러가 공개 화면의 활성 네비게이션, TOC, 댓글 버튼 등에 반영된다.
- 게시물 글쓰기에서 인용문 블록 배경색을 오른쪽 블록 패널로 선택할 수 있게 했다.
## v1.5.35
- 관리자 대시보드에 방문자 유입 정보, 디바이스 통계, 유입 키워드 영역을 추가했다.
- 인기 게시물 목록에서 월간 조회수와 작성일을 함께 확인할 수 있게 했다.
- 페이지뷰 통계가 유입원과 디바이스를 일별 축약 집계하도록 개선했다.
## v1.5.34
- 공개 404/오류 페이지를 추가했다.
- 관리자 사이트 설정에서 ads.txt, 공통 헤더 코드, 공통 푸터 코드를 저장하고 공개 페이지에 반영할 수 있게 했다.
- gethomepage 커스텀 위젯용 사이트 통계 API를 추가했다.
## v1.5.33
- 준비 완료된 내보내기 작업에서는 상태 배지를 표시하지 않도록 정리했다.
## v1.5.32
- 최근 내보내기 작업 카드에서 불필요한 요청일·분할 설정 정보를 줄이고 만료일 중심으로 정리했다.
- 완료된 백업은 진행도 박스를 숨기고, 파일 체크 선택 후 선택한 ZIP만 내려받을 수 있게 했다.
## v1.5.31
- 게시물 내보내기 설정 카드와 다운로드 가능한 최근 작업 카드를 분리해 백업 요청과 결과 확인을 명확히 구분했다.
- 내보내기 작업이 없을 때는 최근 작업 카드가 표시되지 않도록 정리했다.
## v1.5.30
- 관리자 설정의 메인 화면 커버 읽기 모드에서 라이트·다크 프리뷰가 카드 밖으로 넘치지 않게 정리했다.
- 게시물 백업 도구를 `게시물 내보내기``게시물 가져오기` 카드로 분리했다.
- 가져오기는 ZIP 파일 선택 후 `적용`을 눌러야 실행되도록 바꿔 실수 실행을 줄였다.
## v1.5.29
- 관리자 설정의 메인 화면 커버를 라이트모드와 다크모드로 나누어 각각 확인하고 변경할 수 있게 했다.
- 커버 이미지가 없는 경우 점선 드롭존에서 파일 선택 또는 드래그 앤 드롭으로 업로드할 수 있게 했다.
- 사용하지 않는 타임존 설정을 제거하고 `기타 설정``사이트 정보`로 정리했다.
## v1.5.28
- 게시물 Import가 Obsidian식 YAML 블록 배열 태그를 읽을 수 있게 했다.
- Import 중 ZIP 내부 자산 누락이 있으면 완료 결과에 경고로 표시한다.
## v1.5.27
- 게시물 Export ZIP을 관리자 설정에서 다시 Import할 수 있게 했다.
- Import 시 Markdown frontmatter를 게시물 메타데이터로 복원하고, ZIP 내부 이미지·파일은 새 업로드 URL로 재매핑한다.
- 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 새 슬러그로 가져오도록 정리했다.
## v1.5.26
- 관리자 사이트 설정의 게시물 Import/Export 영역을 기본적으로 접힌 액션 중심 UI로 정리했다.
- Export 설정과 Import 안내는 각각 버튼을 눌렀을 때만 펼쳐지도록 개선했다.
- 설정 화면 셀렉트 화살표 아이콘과 간격을 통일했다.
## v1.5.25
- 게시물 Export 분할을 고정 개수 대신 목표 ZIP 용량 기준으로 나누도록 개선했다.
- Export 요청 시 목표 ZIP 용량과 ZIP당 최대 게시물 수를 지정할 수 있게 했다.
- 실패한 Export 작업의 상세 오류를 관리자 화면에서 확인할 수 있게 했다.
## v1.5.24
- 게시물 Export 준비 완료 파일을 한 번에 순차 다운로드할 수 있게 개선했다.
- 실패한 Export 작업은 이미 만들어진 파일을 유지하고 실패 지점부터 다시 생성할 수 있게 정리했다.
- Export 완료 시 Resend 설정이 있으면 요청 관리자에게 이메일 알림을 보낸다.
- 만료된 Export 백업 파일은 작업 목록 조회나 새 요청 시 자동으로 정리된다.
## v1.5.23
- 게시물 Export를 전체뿐 아니라 특정년, 특정월, 직접 지정 날짜 범위로 요청할 수 있게 개선했다.
- 완료되었거나 실패한 Export 백업 파일을 관리자 화면에서 바로 삭제할 수 있게 정리했다.
## v1.5.22
- 게시물 Export가 실제 분할 ZIP 파일을 생성하고, 준비 완료된 파일을 관리자 화면에서 내려받을 수 있도록 개선했다.
- Export 생성 중에는 새 요청 버튼이 비활성화되어 중복 작업을 만들지 않도록 정리했다.
## v1.5.21
- 게시물 Export 작업 카드에 진행 숫자와 진행률 바를 추가하고, 진행 중 작업이 있으면 자동으로 상태를 새로고침하도록 개선했다.
## v1.5.20
- 게시물 Export 작업을 관리자 설정에서 요청하고, 생성될 분할 zip 파일 계획을 확인할 수 있는 1차 기반을 추가했다.
## v1.5.19
- 게시물 Export를 대량 게시물에서도 안전하게 처리하도록 백그라운드 분할 생성과 다운로드 만료 정책 기준을 정리했다.
## v1.5.18
- 게시물 Import/Export 방향을 URL 유지 방식이 아니라 게시물별 폴더와 로컬 이미지·파일 폴더를 포함하는 백업 번들 구조로 정정했다.
## v1.5.17
- 사이트 설정 읽기 모드의 토글이 켜져 있어도 편집 전에는 조작 불가 상태처럼 보이도록 시각 톤을 낮췄다.
- 게시물 Import/Export는 Obsidian에서 바로 읽기 쉬운 Markdown frontmatter 형식으로 진행할 수 있도록 구현 방향을 정리했다.
## v1.5.16
- 게시글을 직접 스크롤해도 오른쪽 TOC에서 현재 읽는 제목 위치가 강조되도록 개선했다.
- TOC 항목이 많을 때 활성 항목을 따라 TOC 영역이 내부 스크롤되도록 정리했다.
- 댓글과 답글 등록 버튼은 내용을 입력했을 때만 활성화되도록 정리했다.
- 댓글 정렬 라벨을 한글 기준으로 정리하고 답글 입력 영역 스타일을 가볍게 맞췄다.
## v1.5.15
- 로그아웃 상태의 사용자 메뉴 버튼도 `?` 대신 사람 아이콘으로 보이도록 수정했다.
## v1.5.14
- 모바일 게시글 화면에서는 하단으로 내려간 오른쪽 사이드바의 TOC를 숨겼다.
- 로그인 회원의 기본 아바타를 사람 아이콘으로 바꿨다.
- 미디어 라이브러리에서 파일을 직접 추가하고, 현재 검색·필터 결과를 전체 선택하거나 선택 삭제할 수 있게 했다.
- 미디어 검색창을 글·멤버 검색창과 같은 스타일로 맞췄다.
## v1.5.13
- 게시글 목차 클릭 이동을 부드러운 스크롤로 바꿨다.
- 목차 이동 시 제목이 고정 헤더에 걸리지 않도록 위치를 보정했다.
## v1.5.12
- 게시글 상세 오른쪽 사이드바에서 추천 사이트 대신 본문 목차를 보여주도록 바꿨다.
- 본문 H1~H3 제목에 목차 이동용 앵커를 자동으로 부여했다.
- 로컬 개발 DB의 소유자 계정을 `zenn`으로 보정했다.
## v1.5.11
- 멤버 상세 화면을 보기 모드와 수정 모드로 분리했다.
- 멤버 상세 저장 버튼은 변경 사항이 있을 때만 활성화되도록 정리했다.
- 멤버 상세 저장 결과를 우측 상단 토스트로 통일했다.
- 멤버 목록 검색창을 글 목록 검색창과 같은 스타일로 맞췄다.
## v1.5.10
- 멤버 상세에서 변경할 수 없는 등급 셀렉트를 화면에서도 잠그도록 정리했다.
- 글 목록에 검색과 작은 대표 이미지 썸네일을 추가했다.
- 글 목록 필터 셀렉트 화살표 간격과 아이콘을 통일했다.
- 페이지 HTML 문서 모드에서 빈 본문 또는 `!`+Tab으로 기본 HTML 골격을 자동 완성할 수 있게 했다.
## v1.5.9
- 관리자 대시보드에서 인기 페이지 통계를 볼 수 있게 했다.
- HTML 문서 모드 페이지도 서버에서 조회수를 기록하도록 보강했다.
- 추천 사이트에 대체 텍스트와 썸네일 URL을 추가하고, 공개 사이드바 표시에도 반영했다.
## v1.5.8
- 소유자가 본인 권한을 직접 낮춰 소유자가 사라지는 상황을 막았다.
- 멤버 목록의 상태 열에 등급을 함께 표시하고, 비활성 회원만 보조 상태로 보이도록 정리했다.
- 소유자가 없는 DB 상태를 복구하는 마이그레이션을 추가했다.
## v1.5.7
- 일반 텍스트 페이지에서도 페이지 형식 선택을 다시 HTML로 되돌릴 수 있게 수정했다.
- 일반 텍스트 페이지에서는 HTML 자산 업로드 UI가 보이지 않도록 정리했다.
- 멤버 접속 IP 기록이 프록시 헤더를 읽도록 보정했다.
## v1.5.6
- 멤버 등급 변경이 저장 버튼을 눌렀을 때만 반영되도록 수정했다.
- 관리자 권한 변경 규칙을 강화해 관리자끼리 조작하거나 마지막 소유자를 없앨 수 없도록 막았다.
## v1.5.5
- 멤버 등급에 VIP를 추가하고, 멤버십 게시물은 VIP 이상 등급에게만 공개되도록 정리했다.
- 관리자 멤버 상세에서 회원 등급을 직접 변경할 수 있게 했다.
## v1.5.4
- 게시물에 멤버십·비공개 상태를 추가하고, 공개 화면에서는 상태에 맞는 글만 보이도록 정리했다.
- 페이지에도 초안·공개·비공개 상태를 추가하고, 공개 상태 페이지만 `/pages/:slug`에서 응답하도록 바꿨다.
## v1.5.3
- 페이지 작성 기본값을 HTML 문서 모드로 바꾸고, 페이지 슬러그도 한글 제목에서 영문으로 자동 생성되도록 개선했다.
- 페이지 작성 화면에서 대표 이미지를 제거하고, HTML 자산 업로드 시 업로드 URL을 현재 커서 위치에 바로 넣을 수 있게 했다.
## v1.5.2
- 페이지 작성/수정 화면을 게시글 작성 화면처럼 전체 화면 에디터, 상단 저장 툴바, 오른쪽 설정 패널 구조로 변경했다.
## v1.5.1
- 고정 페이지에서 전체 HTML 문서를 붙여넣어 `/pages/:slug`에서 단일 랜딩 페이지처럼 보여줄 수 있는 HTML 문서 모드를 추가했다.
## v1.5.0
- 관리자 글쓰기 태그 입력을 검색형 선택으로 개선하고, 태그별 색상을 배지에 반영했다.
- 관리자 글·페이지 목록의 더보기 메뉴가 테이블 밖에서 잘리지 않도록 수정했다.
## v1.4.7
- 글쓰기 라이브 모드에서 문단 이동 시 인라인 마크다운 서식이 사라지던 문제를 수정했다.
- 인용 블록에서 `> [!bg=yellow]` 형식으로 배경색을 지정할 수 있다.
- 소스 모드에서 라이브 모드로 전환할 때 현재 커서 줄 주변으로 스크롤되도록 보정했다.
## v1.4.6
- 관리자 사이트 설정에서 로고와 메인 커버 이미지가 저장 버튼을 통해 반영되도록 정리했다.
- 홈 커버 이미지를 라이트모드·다크모드용으로 따로 등록할 수 있다.
## v1.4.3
- 관리자 화면이 공개 사이트 다크모드 영향을 받지 않도록 라이트 UI를 분리했다.
- 관리자 미디어 라이브러리에 종류·미사용 필터와 비디오 썸네일 미리보기를 추가했다.
## v1.4.2
- 글쓰기 소스 모드에서 긴 줄이 자동 줄바꿈될 때 라인 번호가 실제 줄 높이와 어긋나던 문제를 수정했다.
- 소스 모드에서 라이브 모드로 전환한 직후에도 현재 줄에 포커스가 유지되도록 보정했다.
- 라이브 모드에서 소스 모드로 돌아올 때 현재 작성 위치와 가까운 줄로 커서·스크롤을 복원하도록 보정했다.
- 이미지 파일 URL 한 줄을 입력했을 때 임베드가 아니라 이미지로 표시되도록 수정했다.
- 라이브 모드 이미지 블록에 편집·삭제 버튼을 추가하고, 편집 버튼을 기존 이미지 설정 패널과 연결했다.
- 잘못된 이미지 URL·로드 실패 시에도 최소 높이와 오류 안내 placeholder를 표시한다.
- 라이브 모드에서 이미지 블록끼리 드래그해 갤러리로 합치고, 갤러리 이미지를 블록 사이에 드롭해 단일 이미지로 분리할 수 있다.
- 단독 이미지 URL 줄도 드래그 갤러리 병합·추가 대상으로 처리한다.
- 라이브 모드에서 단일 이미지를 기존 갤러리에 드래그로 추가할 수 있다.
- 갤러리는 이미지 수와 실제 비율에 따라 행 너비를 자동 조정한다.
- 라이브 모드 갤러리 블록도 키보드 이동과 편집·삭제 버튼 접근을 지원한다.
- 라이브 모드 갤러리에서는 개별 이미지별 편집·삭제 버튼을 제공한다.
- 갤러리 이미지 추가 모달을 열어도 오른쪽 블록 패널 상태가 유지되며, 패널 바깥 클릭 시 닫힌다.
- 다크모드 기본 인용 블록과 공개 본문 리스트 마커 색상을 글쓰기 화면 기준으로 정리했다.
- 다크모드에서 좌우 사이드바 배경이 본문 배경과 다르게 튀어 보이지 않도록 통일했다.
## v1.4.1
- 관리자에서 비디오 등 대용량 미디어 업로드 시 적용되던 10MB 공통 한도를 종류별로 분리했다(비디오 기본 200MB).
- 새 임베드 저장 형식을 단독 URL 한 줄로 통일해 `:::embed`와 URL-only가 섞이는 문제를 줄였다.
- 글쓰기 라이브 모드에서 임베드·업로드 미디어 카드를 바로 프리뷰로 표시하고 방향키 이동, 버튼/키보드 삭제를 할 수 있게 했다.
- 글쓰기 라이브 모드에서 제목 입력 후 Enter가 원문 편집처럼 보이던 흐름을 수정했다.
## v1.4.0
- 본문에서 비디오, 오디오, 파일 다운로드 카드를 렌더링할 수 있도록 확장했다.
- X/Twitter 임베드 카드 폭을 조정하고 Mastodon 공개 게시물 임베드의 높이 자동 조절을 추가했다.
- 관리자 글쓰기에서 비디오·오디오·파일 업로드를 바로 연결하고, 단독 URL 한 줄을 자동 임베드로 표시한다.
## v1.2.0
- 관리자 글 목록 정렬·개수·추천 필터·별 표시, 슬러그·예약 시각 UX를 정리했다.
## v1.1.19
- 관리자 글쓰기 헤더에 작성/미리보기 전환, Update 시 발행일 유지, 미디어 검색.
- 사이트 설정 기타·POST 카드를 섹션별 편집·저장으로 분리.
- 본문 인용·인라인 코드 스타일과 블록 여백을 조정했다.
## v1.1.18
- 마크다운 에디터 이미지·갤러리 삽입을 단일 모달(미디어 라이브러리·업로드 탭)로 통합하고, 이미지 너비 툴바를 제거했다.
- POST 설정에서 발행 후 수정일 표시를 켜고 끌 수 있으며, 관리자 글 목록·공개 상세에 반영된다.
## v1.1.16
- 게시 상태를 초안·발행·예약만 쓰도록 정리하고, 신규 초안 임시 슬러그·발행 UI·툴바 저장 동작을 맞췄다.
## v1.1.15
- 신규 초안 서버 자동 저장, 초안 이탈 확인 모달 제거, 글 목록 헤더 필터 배치를 정리했다.
## v1.1.14
- 관리자 글쓰기 상단을 Ghost에 가깝게 바꾸고, 초안 자동 저장은 서버 PUT만 쓰며 발행·예약 글은 Update로만 저장되게 정리했다.
## v1.1.13
- 상단 메뉴 깊이를 한 단계로 제한하고, 추천 사이트를 DB·관리자 탭·우측 Recommended 카드(외부 파비콘 프록시)로 연결했다.
## v1.1.12
- 관리자 상단 메뉴에서 드래그 시 형제 끼움과 하위 편입을 색·문구로 구분하고, 왼쪽 번호를 계층형 개요(`2.1` 등)로 바꿨다.
## v1.1.11
- 공개 사이드바 1차 네비 비활성 표시·하위 간격을 정리하고, 관리자 상단 메뉴는 추가 후 드래그만으로 형제 순서·하위 편입을 바꾸도록 단순화했다.
## v1.1.10
- 관리자 사이트 설정 화면을 Ghost형 전체 화면(좌측 내비·스크롤 스파이·ESC 닫기)으로 바꾸고, 블로그 제목·설명은 읽기 전용 + 편집 시에만 입력하도록 정리. 상단 헤더 없이 우측 상단 고정 닫기, 사이드·본문 중앙 정렬 레이아웃을 적용한다.
## v1.1.9
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
## v1.1.8
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
## v1.1.7
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
## v1.1.6
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
## v1.1.5
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
## v1.1.4
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
- 태그 목록 카드 그리드 여백 수정 반영.
## v1.0.19
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
- 기존 공백 2개 hard break도 계속 렌더링되도록 호환 처리.
## v1.0.18
- 여러 줄을 비워둔 경우 미리보기와 공개 본문에서도 비운 만큼 공백이 보이도록 보강.
- 미리보기 모드에서 편집 툴바와 카드형 패널 외곽을 숨겨 본문만 보이게 정리.
- 줄 번호 영역의 스크롤바를 숨겨 작성 화면을 더 차분하게 정리.
## v1.0.17
- 글쓰기 영역의 보더와 카드형 배경을 제거해 본문 편집 화면을 더 가볍게 정리.
- 줄 번호를 본문 바깥에 띄우고 현재 줄 액센트 배경을 제거.
- Enter는 한 줄만 내려가는 새 문단으로, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 조정.
- 문단과 제목 아래 기본 간격을 10px 기준으로 정리.
## v1.0.16
- 글쓰기에서 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 정리.
- 미리보기 전환 후 작성 모드로 돌아오면 기존 커서 위치에서 계속 입력할 수 있도록 개선.
- 공개 본문과 관리자 미리보기의 문단 간격을 24px 기준으로 통일.
## v1.0.15
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
## v1.0.14
- Markdown-first 전환 후 레거시 블록 본문이나 기존 자동 저장본 때문에 게시물 발행이 막히는 문제를 보강.
## v1.0.13
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
## v1.0.11
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
## v1.0.5
- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강.
## v1.0.4
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
## v1.0.3
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
## v1.0.2
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
- 배포 문서의 운영 환경 변수 생성 안내를 정리.
## v1.0.1
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
## v1.0.0
- 운영 시작 기준 버전.
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
- Nitro 보안 권고 반영 및 취약점 0건 확인.
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
## v0.0.6
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.

View File

@@ -24,9 +24,12 @@
## 스타일
- TailwindCSS 기본 사용
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
- 다크 인증(`signin`/`signup`/`admin/login`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음). 모바일 세로 flex 그룹 안의 input에는 `flex-1`을 직접 쓰지 않고, 필요한 경우 `sm:flex-1`처럼 가로 배치 이상에서만 적용한다.
- 관리자 레이아웃(`admin-layout`)은 공개 사이트 테마와 분리된 라이트 UI로 고정하며, 글쓰기 화면을 제외한 관리자 일반 폼 컨트롤은 `main.css``.admin-layout--light-controls input/textarea/select` 스코프 기준을 따른다.
- Tailwind 엔트리는 `nuxt.config.js``tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다.
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
```html
<main class="site-main w-full max-w-full lg:max-w-[720px]">
@@ -53,3 +56,10 @@
- 하드코딩 금지
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
- 운영 환경에서는 `DATABASE_URL``MEMBER_SESSION_SECRET` 누락을 허용하지 않음
## 검증
- `npm run lint`: JavaScript 파일 문법 점검
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
- `npm run verify`: 문법 점검과 빌드를 함께 실행

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
> 로컬 기준 v1.5.104에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -8,6 +8,7 @@
|------|--------|------|
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
@@ -15,6 +16,174 @@
## 로컬 개발
### v1.5.104 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 목차에서 2줄 이상 제목의 활성 표시선이 항목 높이에 맞춰 표시되는지 확인한다.
### v1.5.103 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 목차 활성 항목의 왼쪽 브랜드 컬러 막대가 실제 화면에서 표시되는지 확인한다.
### v1.5.102 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 목차 항목이 24px 높이에서 세로 중앙 정렬되는지 확인한다.
- 활성 목차 항목의 왼쪽 표시선이 브랜드 컬러로 정상 표시되는지 확인한다.
### v1.5.101 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 목차 활성 항목이 바뀌어도 항목 텍스트 시작점이 좌우로 흔들리지 않는지 확인한다.
### v1.5.100 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 목차에서 각 항목 왼쪽 라인이 이어져 보이고, 활성 항목 라인이 브랜드 컬러와 더 두꺼운 보더로 표시되는지 확인한다.
### v1.5.99 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 오른쪽 사이드바 목차 라벨이 `목차`로 표시되는지 확인한다.
- 활성 목차 항목의 텍스트와 왼쪽 표시선에 브랜드 컬러가 적용되는지 확인한다.
### v1.5.98 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 오른쪽 사이드바에서 긴 TOC가 별도 내부 스크롤 없이 전체 목록으로 펼쳐지는지 확인한다.
- 게시물 사이드 광고가 긴 TOC 아래에 이어서 표시되는지 확인한다.
- 본문 스크롤 중 활성 TOC 항목이 오른쪽 사이드바 전체 스크롤 기준으로 따라오는지 확인한다.
### v1.5.97 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 데스크톱 오른쪽 사이드바에서 블로그 소개·Follow가 숨겨지고 TOC가 최상단에 표시되는지 확인한다.
- 게시물 사이드 광고 코드가 있으면 왼쪽 사이드바가 아니라 오른쪽 TOC 아래에 표시되는지 확인한다.
- 긴 목차에서 세로 기준선과 활성 항목 표시선이 정상 표시되는지 확인한다.
### v1.5.93 참고
- DB 마이그레이션 `056_site_settings_post_tag_limit.sql` 적용이 필요하다.
- 관리자 사이트 설정의 POST 설정에서 태그 최대 개수를 1~10개 사이로 저장할 수 있는지 확인한다.
- 게시물 작성에서 설정된 개수 이상 태그를 추가할 수 없고, 저장 API도 초과 태그를 거부하는지 확인한다.
- 게시물 작성 본문에서 `/표` 또는 `/table`로 기본 표가 삽입되고 공개/미리보기 본문에서 표로 렌더링되는지 확인한다.
### v1.5.92 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 새 태그 저장 후 `/admin/tags` 목록으로 이동하고 저장 완료 토스트가 표시되는지 확인한다.
- 태그 수정 화면에서 변경 전에는 `변경 저장` 버튼이 비활성화되고, 변경·저장 후 다시 비활성화되는지 확인한다.
- 게시물 작성에서 `/이미지` 미디어 모달 업로드 후 본문에 자동 삽입되지 않고 라이브러리 목록에서 직접 선택해 삽입할 수 있는지 확인한다.
- 미디어 모달 업로드 중 닫기·추가 업로드가 막히는지 확인한다.
### v1.5.91 참고
- DB 마이그레이션 `055_add_post_tag_sort_order.sql` 적용이 필요하다.
- 게시물 작성 본문에서 `/이미지`로 미디어 선택 모달을 열었을 때 `thumbs/*-card.webp` 카드 썸네일이 보이지 않는지 확인한다.
- 미디어 모달 업로드 탭에 파일을 드롭한 뒤 업로드 중 표시가 나오고 추가 드롭·파일 선택이 막히는지 확인한다.
- 태그를 여러 개 가진 공개 게시물 목록에서 첫 번째 태그가 고정 표시되고, 관리자 게시물 목록에서는 모든 태그가 표시되는지 확인한다.
### v1.5.90 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 작성 라이브 모드에서 코드 블록 아래 일반 문단으로 커서를 이동하면 오른쪽 코드 블록 설정 패널이 닫히는지 확인한다.
- 문서 마지막 코드 블록 본문 끝에서 아래 방향키를 눌렀을 때 코드 블록 밖 일반 문단이 생성되고 커서가 이동하는지 확인한다.
### v1.5.89 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 작성 소스 모드에서 코드 블록 본문에 `/volume1/...` 같은 경로를 입력해도 슬래시 명령 메뉴가 열리지 않는지 확인한다.
- 소스·라이브 모드에서 줄 삭제나 블록 변환 후 `Cmd/Ctrl+Z`로 되돌리고 `Cmd/Ctrl+Shift+Z`로 다시 실행되는지 확인한다.
### v1.5.88 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 작성 라이브 모드에서 문서 마지막 인용문 끝에 커서를 두고 아래 방향키를 누르면 인용 밖 일반 문단이 생성되고 커서가 이동하는지 확인한다.
### v1.5.87 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 작성 라이브 모드에서 코드 블록에 커서를 두면 코드 설정 패널이 열리고, 일반 문단으로 커서를 옮기면 패널이 닫히는지 확인한다.
### v1.5.86 참고
- 추가 DB 마이그레이션은 없다.
- 다크 모드 게시물 본문에서 기본 인용문 텍스트가 밝은 사이트 텍스트 색상으로 표시되는지 확인한다.
### v1.5.85 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 상세 HTML `<title>``게시물 제목`만 출력하는지 확인한다. 구글 검색 결과 반영은 재크롤링 이후 시간이 걸릴 수 있다.
### v1.5.84 참고
- 추가 DB 마이그레이션은 없다.
- 모바일 회원가입 2단계에서 이메일 인증번호 입력창이 다른 입력창과 같은 높이로 표시되는지 확인한다.
### v1.5.83 참고
- 추가 DB 마이그레이션은 없다.
- 공개 오른쪽 사이드바에서 사이트 로고가 48px 정사각형으로 유지되는지 확인한다.
### v1.5.82 참고
- 추가 DB 마이그레이션은 없다.
- 배포 후 `/sitemap.xml``application/xml`로 응답하고 공개 발행글·고정 페이지·태그 URL을 포함하는지 확인한다.
- `noindex` 글, 멤버십·비공개·초안·예약 대기 글이 `/sitemap.xml`에 포함되지 않는지 확인한다.
- `/robots.txt``Sitemap: https://.../sitemap.xml` 절대 URL을 포함하는지 확인한다.
- Google Search Console에는 `https://도메인/sitemap.xml`을 제출한다. 이후 새 게시물 발행 시 sitemap 파일을 수동 갱신할 필요는 없다.
### v1.5.81 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 작성·수정 오른쪽 사이드에 `대표 이미지 표시` 토글이 항상 보이는지 확인한다.
- 대표 이미지가 없어도 해당 토글을 켜고 저장할 수 있으며, 이후 대표 이미지를 추가하면 상세 제목 아래 표시 설정이 유지되는지 확인한다.
### v1.5.80 참고
- DB 마이그레이션 `054_add_post_show_featured_image.sql` 적용 필요.
- 새 게시물 본문 이미지를 업로드해도 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp`가 바로 생성되지 않는지 확인한다.
- 게시물 대표 이미지를 설정하고 저장하면 해당 원본의 카드 썸네일이 생성되는지 확인한다.
- 대표 이미지 원본에 카드 썸네일이 없으면 관리자 미디어 상세에서 `카드 썸네일 생성` 버튼으로 다시 만들 수 있는지 확인한다.
- 게시물 상세 제목 아래 대표 이미지는 글쓰기 화면의 `본문 상단 대표 이미지` 옵션이 켜진 글에서만 표시되는지 확인한다.
- 운영 기존 업로드 중 대표 이미지 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다. 이 명령은 게시물 대표 이미지 URL만 대상으로 한다.
### v1.5.79 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 미디어 화면에서 일반 미디어 라이브러리에 `thumbs/*-card.webp` 파일이 섞이지 않고, 카드 썸네일 탭에만 표시되는지 확인한다.
- 대표 이미지로 사용 중인 원본에 카드 썸네일이 있으면 카드 썸네일 탭에서 사용 중으로 표시되고 삭제가 차단되는지 확인한다.
- 대표 이미지로 사용 중인 원본에 카드 썸네일이 없으면 원본 항목에 `원본` 배지와 fallback 상태가 표시되는지 확인한다.
- 운영 기존 업로드 중 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다.
### v1.5.78 참고
- 추가 DB 마이그레이션은 없다.
- 새 게시물 이미지 업로드 시 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp` 카드 썸네일이 함께 생성되는지 확인한다.
- 기존 업로드 이미지가 많은 운영 환경에서는 배포 후 `npm run images:backfill-post-thumbnails`를 한 번 실행해 누락된 카드 썸네일을 생성한다.
- 메인 Featured·Latest, 게시물 목록, 태그 목록에서 생성된 썸네일 URL을 우선 요청하고, 썸네일이 없는 외부/기존 이미지는 원본으로 대체되는지 확인한다.
### v1.5.77 참고
- 추가 DB 마이그레이션은 없다.
- 메인 화면 Latest compact/list 목록에서 긴 요약이 최대 2줄로 표시되고 발행일·댓글 메타가 잘리지 않는지 확인한다.
- 모바일 메인 화면 Latest compact 목록에서 썸네일이 80px 정사각형으로 표시되고, `sm` 이상에서는 기존 비율형 썸네일이 유지되는지 확인한다.
- 대표 이미지 없는 placeholder 썸네일의 긴 제목이 모바일에서 썸네일 밖으로 넘치지 않는지 확인한다.
### v1.5.75 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 작성·수정 화면 오른쪽 설정 패널 하단에 본문 통계가 표시되는지 확인한다.
- 본문 입력 시 단어 수, 공백 제외 문자 수, 공백 수, 읽기 시간, 블록 수, 이미지 수가 갱신되는지 확인한다.
### v1.5.74 참고
- DB 마이그레이션 `053_site_settings_post_sidebar_ad.sql` 적용 필요.
- 사이트 설정 Ads에서 게시물 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 오른쪽 사이드바 TOC 아래에 표시되는지 확인한다.
- 게시물 상세 오른쪽 사이드바에서는 일반 오른쪽 사이드 광고가 아니라 게시물 사이드 광고가 표시되는지 확인한다.
- 긴 게시물에서 인아티클 광고가 본문 길이에 따라 0~2회로 제한되는지 확인한다.
### 필수 조건
- Node.js 22 LTS 권장
@@ -44,6 +213,28 @@ openssl rand -hex 32
npm run dev
```
### v1.5.55 참고
- 추가 DB 마이그레이션은 없다.
- 소스 모드 textarea에서 `Cmd+Shift+K`로 현재 줄이 삭제되는지 확인한다.
- 소스 모드 textarea에서 여러 줄 선택 후 `Cmd+Shift+K`로 선택 범위 줄들이 삭제되는지 확인한다.
- 라이브 모드에서 preview 루트 또는 카드형 블록에 포커스된 상태에서도 `Cmd+Shift+K`로 현재 줄 또는 블록이 삭제되는지 확인한다.
### v1.5.54 참고
- 추가 DB 마이그레이션은 없다.
- `/콜아웃` 삽입 시 기본 선언부가 `emoji=none`으로 들어가고 아이콘이 표시되지 않는지 확인한다.
- 오른쪽 블록 설정 패널에서 콜아웃 제목을 입력하면 선언부 `title` 옵션과 라이브·공개 렌더링에 반영되는지 확인한다.
- 콜아웃 아이콘 또는 제목이 있을 때 헤더가 왼쪽 상단에 표시되고 본문은 아래 줄에서 시작하는지 확인한다.
### v1.5.53 참고
- 추가 DB 마이그레이션은 없다.
- 라이브 콜아웃 본문 5줄 이상에서 `Shift+방향키` 범위 선택이 여러 줄에 걸쳐 유지되는지 확인한다.
- 라이브 콜아웃 선택 범위를 삭제하거나 붙여넣을 때 콜아웃 본문 줄만 갱신되는지 확인한다.
- 콜아웃 아이콘 사용 시 라이브·사용자 화면 모두 왼쪽 상단에 아이콘이 붙는지 확인한다.
- 콜아웃 아이콘 미사용 시 라이브 편집 화면과 사용자 화면 모두 아이콘 자리 표시자가 남지 않는지 확인한다.
### 로컬 개발 DB
로컬 개발 DB는 Docker Compose의 `sori-studio-db` 서비스만 실행한다.
@@ -67,6 +258,111 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
### v1.5.52 참고
- 추가 DB 마이그레이션은 없다.
- 연속 콜아웃을 만들고 위 콜아웃에서 한글 입력 후 Enter 시 아래 콜아웃 선언 줄이 유지되는지 확인한다.
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
- 갤러리·토글·임베드 등 다른 `:::` fenced 블록 편집 시 다음 블록 첫 줄이 교체 범위에 포함되지 않는지 확인한다.
### v1.5.51 참고
- 추가 DB 마이그레이션은 없다.
- 라이브 인용 안에서 한글 입력 후 Enter 시 인용 줄이 1줄만 추가되는지 확인한다.
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 콜아웃 본문 줄이 1줄만 추가되는지 확인한다.
- 라이브 콜아웃 마지막 줄에서 아래 방향키 입력 시 새 본문 줄이 생성되지 않는지 확인한다.
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
### v1.5.50 참고
- 추가 DB 마이그레이션은 없다.
- 라이브 모드에서 한글 `> 텍스트` 입력 후 Enter 시 다음 인용 줄이 생성되고 커서가 내부에 있는지 확인한다.
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
- 라이브 콜아웃 본문 여러 줄에서 2번째·3번째 줄 `Cmd+Shift+K`가 해당 줄만 삭제하는지 확인한다.
- 라이브 콜아웃에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 한글 조합 글자가 남지 않는지 확인한다.
### v1.5.49 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 글쓰기 라이브 모드에서 코드·콜아웃·토글 내부 커서 위치별 `Cmd+Shift+K` 줄 삭제를 확인한다.
- 코드·콜아웃·토글 본문 마지막 1줄에서 `Cmd+Shift+K` 입력 시 블록 전체가 삭제되는지 확인한다.
- 라이브 콜아웃 마지막 줄에서 아래 방향키로 다음 블록으로 빠져나가는지 확인한다.
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 조합 글자가 남지 않는지 확인한다.
### v1.5.48 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 게시물 작성 상단 왼쪽 상태 표시가 텍스트만 표시하고, 외부 이동 아이콘이나 링크 동작이 없는지 확인한다.
- 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서 확인한다.
### v1.5.47 참고
- 추가 DB 마이그레이션은 없다.
- 대표 이미지가 있는 공개 게시물이 RSS item에 `media:thumbnail``media:content`를 포함하는지 확인한다.
- 상대 이미지 URL이 RSS에서 절대 URL로 변환되는지 확인한다.
### v1.5.46 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 글쓰기 라이브 모드에서 위에서 아래 방향키로 콜아웃·인용 블록에 진입되는지 확인한다.
- 콜아웃·인용 배경 프리셋에 분홍이 보이지 않고 같은 6색 팔레트를 쓰는지 확인한다.
- 작은 화면에서 게시물 설정 패널을 열어도 본문 폭이 압축되지 않는지 확인한다.
### v1.5.45 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 글쓰기에서 라이브 모드 콜아웃·인용 블록 포커스 시 오른쪽 블록 설정 패널이 열리는지 확인한다.
- 인용 블록 기본 배경이 회색이고 분홍 옵션이 노출되지 않는지 확인한다.
### v1.5.44 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 사이트 설정의 블로그 제목·설명, 사이트 정보, 사이트 코드 읽기 화면만 레이아웃 변경되었다.
### v1.5.43 참고
- 추가 DB 마이그레이션은 없다.
- 배포 후 `/rss.xml`, `/feed.xml`, `/rss``application/rss+xml`로 응답하고 최근 공개 발행글을 포함하는지 확인한다.
- 관리자 SNS 정보에서 RSS 프리셋을 사용할 경우 주소는 `/rss.xml`을 권장한다.
### v1.5.42 참고
- 추가 DB 마이그레이션은 없다.
- 공개 오른쪽 사이드바 FOLLOW 영역의 직접 SVG 아이콘 정렬만 수정한다.
### v1.5.41 마이그레이션
- `049_fix_social_links_jsonb_string.sql`: 기존에 JSONB 문자열로 잘못 저장된 `site_settings.social_links` 값을 JSONB 배열로 복구한다.
- 적용 후 관리자 사이트 설정의 SNS 정보 저장값이 읽기 모드에서 유지되는지 확인한다.
### v1.5.40 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 사이트 설정의 SNS 정보에서 프리셋이 없는 서비스는 `직접 SVG`를 선택해 SVG 아이콘과 주소를 함께 저장한다.
- `https://`를 생략한 SNS 주소는 저장 시 자동 보정된다.
### v1.5.39 마이그레이션
- `048_site_settings_social_links.sql`: `site_settings``social_links` JSONB 컬럼을 추가한다.
- 적용 후 관리자 사이트 설정의 SNS 정보와 공개 오른쪽 사이드바 FOLLOW 노출이 정상 동작하는지 확인한다.
### v1.5.38 마이그레이션
- `047_site_settings_announcement_alignment.sql`: `site_settings``announcement_alignment` 컬럼을 추가한다.
- 적용 후 관리자 사이트 설정의 어나운스 바 정렬(중앙/왼쪽)이 공개 화면에 반영되는지 확인한다.
### v1.5.35 마이그레이션
- `045_analytics_traffic_sources.sql`: 방문자 유입원·디바이스·검색 키워드 일별 축약 집계 테이블과 중복 방문 제거용 컬럼을 추가한다.
- 적용 이후부터 수집되는 페이지뷰에 대해서만 유입 정보가 쌓인다. 과거 방문 데이터는 소급 집계하지 않는다.
- 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 표시된다.
### v1.5.34 마이그레이션
- `044_site_settings_custom_code.sql`: `site_settings``ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼을 추가한다.
- 배포 후 `/ads.txt`와 공개 페이지 HTML head/body 하단 코드 삽입이 정상 동작하는지 확인한다.
### 확인 주소
- 개발 서버: http://127.0.0.1:43117
@@ -116,7 +412,7 @@ ssh [NAS_IP]
```bash
# 프로젝트 디렉토리로 이동
cd /volume1/docker/sori.studio
cd /volume1/docker/projects/apps/
# 프로젝트 클론
git clone https://git.sori.studio/zenn/sori.studio.git
@@ -128,21 +424,70 @@ cd sori.studio
# .env.production은 Git에 올리지 않는 운영 전용 파일
cp .env.example .env.production
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
# Docker 빌드 및 실행
docker compose --env-file .env.production up -d --build
# 운영 DB 마이그레이션 상태 확인
sh scripts/migrate-production-db.sh status
# schema_migrations 도입 전 운영 DB가 이미 최신이면 최초 1회 기준점 기록(실제 SQL 실행 없음)
sh scripts/migrate-production-db.sh baseline
# 이후 배포에서는 아직 적용되지 않은 SQL만 순서대로 실행
sh scripts/migrate-production-db.sh migrate
```
### 운영 업데이트 (코드 반영)
이미 한 번 올려 둔 NAS에서 **새 커밋을 받아 반영**할 때는 보통 아래 순서를 따른다. 최초 설치 절차와 달리 `git clone`은 하지 않는다.
```bash
# 프로젝트 루트로 이동 (경로는 NAS 환경에 맞게 조정)
cd /volume1/docker/projects/apps/sori.studio
# 원격 저장소 최신 코드 받기
git pull
# DB 스키마 변경이 포함된 배포면 미적용 SQL만 적용 (npm 없이 실행 가능)
sh scripts/migrate-production-db.sh status
sh scripts/migrate-production-db.sh migrate
# 앱 이미지 재빌드 후 컨테이너 재기동
docker compose --env-file .env.production up -d --build
```
| 단계 | 설명 |
|------|------|
| `git pull` | 애플리케이션·Dockerfile·`db/migrations` 등 Git에 있는 변경을 받는다. |
| `migrate` | `db/migrations/`에 새 SQL이 있으면 운영 DB에만 적용한다. 스키마 변경이 없으면 생략해도 된다. |
| `up -d --build` | Nuxt 프로덕션 빌드가 Docker 이미지 안에서 수행되므로, **NAS 호스트에 Node/npm이 없어도** 앱 코드 반영이 가능하다. |
주의:
- `.env.production`은 Git에 포함하지 않는다. `git pull`로 덮어쓰이지 않는다. 값을 바꿀 때만 파일을 직접 수정한다.
- `public/uploads/` 업로드 파일은 Docker 볼륨(`./public/uploads`)에 있으므로, **이미지 파일만 추가·수정한 경우** 앱 재빌드 없이도 URL로 바로 보인다.
- 로컬에서 미리 확인하려면 `npm run verify` 후 NAS에서 위 명령을 실행하면 된다.
컨테이너만 재시작하고 이미지는 그대로 두려면(환경 변수만 바꾼 경우 등):
```bash
docker compose --env-file .env.production up -d
```
### 프로덕션 빌드 (NAS에서)
코드 변경 없이 `.env.production`만 수정했다면 `--build` 없이 `up -d`만으로 충분하다.
### Docker 네트워크 충돌 대응
NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.
```bash
# 프로덕션 빌드
npm run build
# 또는 Docker로 빌드
docker build -t sori.studio:latest .
docker run -d -p 3000:3000 sori.studio:latest
DOCKER_SUBNET=10.250.51.0/24
docker compose --env-file .env.production up -d --build
```
### 포트
@@ -151,6 +496,7 @@ docker run -d -p 3000:3000 sori.studio:latest
- NAS Docker 외부: 43118
- 컨테이너 내부: 3000
- PostgreSQL 외부: 43119
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
- HTTPS: 3001 (SSL 설정 시)
---
@@ -163,12 +509,52 @@ docker run -d -p 3000:3000 sori.studio:latest
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다.
### 이메일 인증(Resend, 선택)
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev``018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
| 변수 | 설명 |
|------|------|
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
| `MEMBER_SESSION_SECRET` | 회원 세션 쿠키 서명용 비밀값. 운영에서는 필수이며 `ADMIN_PASSWORD`와 분리된 긴 난수 문자열을 사용한다. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. |
| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. |
`RESEND_API_KEY``RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
### 게시물 Export 설정(선택)
| 변수 | 설명 |
|------|------|
| `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. |
- 게시물 Import는 관리자 설정의 Import 패널에서 Export ZIP 파일을 업로드해 실행한다. 1회 업로드 파일은 300MB 이하, Markdown 게시물은 최대 1000개까지 처리한다.
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
- NAS 운영 DB 마이그레이션은 NAS 호스트에 npm이 없어도 실행할 수 있도록 `sh scripts/migrate-production-db.sh status`로 적용 상태를 확인하고, `sh scripts/migrate-production-db.sh migrate`로 미적용 파일만 실행한다.
- 운영 환경 파일은 프로젝트 루트의 `.env.production`을 우선 사용한다. 없으면 `.env`를 읽고, 둘 다 없으면 실행 중인 `sori-studio-db` 컨테이너의 `POSTGRES_DB`·`POSTGRES_USER`를 사용한다.
- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `migrate`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `sh scripts/migrate-production-db.sh baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `sh scripts/migrate-production-db.sh baseline 031`.
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
### 통계 데이터 보관 정책
- `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다.
- `analytics_traffic_daily`: 유입원·디바이스·키워드 축약 집계 원본이므로 자동 삭제하지 않는다.
- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다.
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다.
- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
### 개발/운영 DB 분리 검증 절차
@@ -202,6 +588,7 @@ test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_P
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
- `APP_PORT=43118`
- `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
3. 로컬 개발 DB 연결 확인.
@@ -240,13 +627,70 @@ git diff -- . ':!package-lock.json'
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
### 컨테이너가 `Restarting`일 때
`Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다.
1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`).
```bash
docker ps -a --filter "name=sori-studio"
```
2. **해당 컨테이너 로그** (가장 중요).
```bash
docker logs sori-studio --tail 150
docker logs sori-studio-db --tail 150
```
Compose로 올렸다면:
```bash
docker compose --env-file .env.production logs sori-studio --tail 200
docker compose --env-file .env.production logs sori-studio-db --tail 200
```
3. **자주 나오는 원인**
- **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료.
- **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제.
- **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다.
4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다.
### NAS·호스트에서 `db/migrations` 권한
`docker-compose.yml``./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`**읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다.
프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다.
```bash
cd /volume1/docker/projects/apps/sori.studio
# 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능
sudo chmod -R a+rX db/migrations
# 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용
sudo chmod a+x . db db/migrations
```
그다음 DB 컨테이너만 재시작한다.
```bash
docker compose --env-file .env.production restart sori-studio-db
```
여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다.
## 업로드 파일
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
- 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다.
- 완료·실패한 게시물 Export 작업을 관리자 화면에서 삭제하면 연결된 ZIP 파일도 함께 삭제된다.
- `public/uploads/`는 Git에 포함하지 않는다.
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를한다.
- NAS 운영에서는 `docker-compose.yml``./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접한다.
- `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 최대 크기를 제한한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다.
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
## 사용자 액션 필요 항목

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