515 Commits
v0.0.1 ... main

Author SHA1 Message Date
488889cee1 v1.12.32 로컬 개발 DB 복구 절차 정리 2026-10-22 10:08:41 +09:00
0cd888a427 v1.12.31 불필요 파일 정리 및 업로드·배포 안정성 보강 2026-10-21 19:10:01 +09:00
7b6fc45bf3 v1.12.30 긴 접속 시간 표시와 중단 후 새 방문 처리 개선 2026-10-21 18:45:44 +09:00
51d5de148f v1.12.29 접속자 요약과 회원 작성 이력 및 댓글 표시 정리 2026-10-21 18:17:03 +09:00
184a971863 v1.12.28 배포 문서를 상황별 복사 실행 명령어로 정리 2026-10-21 17:46:06 +09:00
2d4431e698 v1.12.27 NAS 실행 yaml 및 명시적 이미지 빌드 분리 2026-10-21 17:36:47 +09:00
345de36bd1 v1.12.26 운영 Compose 식별 통일 및 Docker 앱 배포 안내 정리 2026-10-21 17:23:03 +09:00
9e2fec6d6c v1.12.25 대시보드 숫자 표기 및 접속자 목록 간소화 2026-10-21 17:17:57 +09:00
93dc6c04bd v1.12.24 멤버 작성 이력 통합 표 및 종류 필터 추가 2026-10-21 17:03:07 +09:00
7c95797fd2 v1.12.23 대시보드 간소화 및 멤버 작성 이력 탭 추가 2026-10-21 16:37:55 +09:00
c8e00c1190 v1.12.22 관리자 멤버 댓글 목록 및 최근 댓글 확인 추가 2026-10-21 15:34:31 +09:00
6886c57d04 v1.12.21 관리자 미디어 조작 개선 및 CSP 관찰 안내 정리 2026-09-28 16:52:14 +09:00
5482318b84 NAS 외부 볼륨 선언 문법 보정 2026-08-13 16:32:42 +09:00
0fe167a1ec NAS 재배포 앱 이미지 빌드 유도 2026-08-13 16:20:09 +09:00
2b232d0b51 소유자 관리자 진입 권한 보강 2026-08-13 16:11:19 +09:00
1453026e60 공개 로그인 관리자 진입 개선 2026-08-13 15:59:14 +09:00
801ae7ae89 운영 DB 볼륨 이름 고정 2026-08-13 15:43:43 +09:00
5600a20294 NAS 배포 Buildx 기준으로 정리 2026-08-13 15:24:04 +09:00
29fba4f780 NAS 앱 이미지 pull 방지 설정 추가 2026-08-13 15:13:48 +09:00
2f1314b46e NAS Docker 앱 이미지 실행 배포 설정 추가 2026-08-13 15:11:27 +09:00
f3170f3908 NAS Docker 앱 배포 환경 변수 경고 수정 2026-08-13 15:00:21 +09:00
93eb5dd96d CSP 차단 출처 분류 보강 2026-07-29 17:34:43 +09:00
9113711a8a CSP 허용 후보 표시 추가 2026-07-29 16:12:56 +09:00
c4493bb7b2 CSP 관찰 요약 추가 2026-07-29 16:07:36 +09:00
23f0e9b3ef 통계 원장 보관 기간 확정 2026-07-29 15:53:28 +09:00
65924292b4 통계 추이 차트 원장 지표 보정 2026-07-29 15:39:09 +09:00
8a6963e8e1 인기 콘텐츠 참여 지표 원장 보정 2026-07-29 15:25:45 +09:00
378afbe79c 통계 기간 지표 원장 기준 보정 2026-07-29 14:57:51 +09:00
4e7e12d8fe 통계 콘텐츠 세션 원장 추가 2026-07-29 14:48:14 +09:00
1a5a1542b7 Google 연동 해제 오류 수정 2026-07-29 14:23:52 +09:00
3c431576b7 Google 계정 연동 관리 개선 2026-07-29 14:18:40 +09:00
52ed053876 Google 계정 로그인 연동 추가 2026-07-29 14:02:07 +09:00
99af25b8fa v1.12 기준 문서 정리 2026-07-29 13:46:11 +09:00
30f7b22340 운영 의존성 보안 업데이트 2026-07-29 13:38:50 +09:00
3698032886 미디어 카탈로그 관찰 모드 정리 2026-07-29 13:26:05 +09:00
ebc4a95b37 에디터 미디어 삽입 실행 분리 2026-07-29 12:44:23 +09:00
92e37f3ae8 에디터 소스 선택 실행 분리 2026-07-29 12:36:29 +09:00
ae9883a588 에디터 공통 포커스 실행 분리 2026-07-29 12:17:23 +09:00
77271399cc 슬래시 명령 실행 분리 2026-07-29 11:43:39 +09:00
393d173af5 슬래시 메뉴 위치 관리 분리 2026-07-29 11:24:00 +09:00
d8cfa77080 미리보기 액션 적용 분리 2026-07-29 10:30:24 +09:00
980ef92a68 블록 패널 액션 적용 분리 2026-07-29 10:23:07 +09:00
57dac3f81a 라이브 미리보기 블록 계산 분리 2026-07-29 10:10:51 +09:00
d709b5d94c 라이브 미리보기 포커스 실행 분리 2026-07-28 18:56:54 +09:00
00d58fe569 이미지 너비 패널 이벤트 전달 수정 2026-07-28 17:10:45 +09:00
737d064b06 에디터 블록 옵션 계산 분리 2026-07-28 15:59:01 +09:00
126f608808 에디터 블록 패널 상태 분리 2026-07-28 15:14:35 +09:00
db7cf62c92 에디터 textarea 포커스 실행 분리 2026-07-28 15:05:04 +09:00
bf7a0f247e 에디터 모드 전환 포커스 분리 2026-07-28 11:35:13 +09:00
b953001f32 슬래시 명령 효과 계획 분리 2026-07-28 11:23:46 +09:00
9f3ef337ef 미디어 선택 요청 흐름 분리 2026-07-28 10:27:02 +09:00
5e4ccbf2bf 슬래시 미일치 Enter 동작 보정 2026-07-28 10:00:01 +09:00
a0e126b2a9 슬래시 메뉴 재입력 억제 보정 2026-07-27 19:05:08 +09:00
e5e4febbbc 관리자 미디어 업로드 규칙 분리 2026-07-27 18:36:34 +09:00
a122ef46ac 관리자 미디어 선택 상태 분리 2026-07-27 18:30:19 +09:00
b3f3cbd0ec 관리자 슬래시 명령 적용 계획 분리 2026-07-27 18:18:55 +09:00
cb6e368375 관리자 라이브 전환 포커스 계산 분리 2026-07-27 17:45:54 +09:00
20ea9169c6 관리자 소스 선택 편집 계산 분리 2026-07-27 17:09:14 +09:00
a8119d22ca 관리자 본문 히스토리 상태 분리 2026-07-27 16:44:58 +09:00
1d15a0e4d1 공개 본문 인용 편집 분리 2026-07-27 16:39:20 +09:00
9efa6d6af5 공개 본문 목록 편집 분리 2026-07-27 16:29:23 +09:00
16ea10229e 공개 본문 문단 편집 분리 2026-07-27 16:14:56 +09:00
ff2d78ff81 공개 본문 fenced 블록 편집 분리 2026-07-27 16:03:50 +09:00
7c19447ec8 공개 본문 라이브 선택 상태 분리 2026-07-27 13:28:04 +09:00
5fb2781d72 공개 본문 이미지 드래그 상태 분리 2026-07-27 12:48:10 +09:00
8c4ad90f91 공개 본문 블록 계산 분리 2026-07-27 12:38:21 +09:00
b15282f41f 공개 본문 라이트박스 상태 분리 2026-07-27 12:22:18 +09:00
bf48af7bd3 에디터 슬래시 상태와 단축키 판별 분리 2026-07-27 12:09:47 +09:00
358199e595 슬래시 메뉴 키 입력 판별 분리 2026-07-27 12:02:28 +09:00
022cfa2ba5 슬래시 방향키 순환 계산 분리 2026-07-27 11:57:05 +09:00
5803e1927c 슬래시 하이라이트 인덱스 보정 분리 2026-07-27 11:49:05 +09:00
a39944b139 슬래시 제외 줄 상태 갱신 분리 2026-07-27 11:40:50 +09:00
3515cf0ccf 미디어 슬래시 명령 종류 계산 분리 2026-07-27 11:31:06 +09:00
675c45092b 슬래시 명령 커서 복원 계산 분리 2026-07-27 11:22:51 +09:00
736f7d24ec 슬래시 메뉴 상태 초기화 정리 2026-07-27 10:43:49 +09:00
3cc99ebeec 슬래시 메뉴 스크롤 계산 분리 2026-07-27 10:41:17 +09:00
ab1b2a32c1 슬래시 메뉴 재배치 리스너 정리 2026-07-27 10:37:43 +09:00
63c657d7a3 슬래시 메뉴 위치 계산 분리 2026-07-27 10:28:23 +09:00
ce64ab7a9f 관리자 에디터 미리보기 표면 컴포넌트 분리 2026-07-27 10:22:15 +09:00
622f19235e 관리자 에디터 작성 표면 컴포넌트 분리 2026-07-27 10:14:25 +09:00
636d4f68a4 에디터 보조 UI 컴포넌트 분리 2026-07-27 10:04:37 +09:00
f4a2f1c034 에디터 슬래시 메뉴 컴포넌트 분리 2026-07-27 09:54:26 +09:00
84cb3ad9e7 에디터 미디어 모달 컴포넌트 분리 2026-07-24 18:53:56 +09:00
48982cfa52 에디터 미디어 적용 버튼 상태 분리 2026-07-24 18:47:43 +09:00
a188229489 에디터 미디어 빈 상태 문구 계산 분리 2026-07-24 18:31:53 +09:00
b9771778dc 에디터 미디어 선택 제목 계산 분리 2026-07-24 18:26:07 +09:00
fe1a390699 에디터 미디어 선택 대상 계산 분리 2026-07-24 18:18:12 +09:00
408d4aa420 에디터 미디어 응답 상태 정규화 분리 2026-07-24 18:12:33 +09:00
1ec71cc985 에디터 미디어 페이지 계산 로직 분리 2026-07-24 18:07:22 +09:00
e3f98cb820 에디터 미디어 업로드 검증 로직 분리 2026-07-24 17:55:33 +09:00
6cc820d2a2 에디터 미디어 선택 상태 로직 분리 2026-07-24 17:51:02 +09:00
401dc64238 에디터 미디어 선택 로직 분리 2026-07-24 17:44:10 +09:00
bad5a9081a 에디터 히스토리 로직 분리 2026-07-24 17:39:29 +09:00
31cae6f90b 슬래시 명령 로직 분리 2026-07-24 17:27:24 +09:00
7bc04ec5b9 라이브 편집 패널과 이미지 명령 정리 2026-07-24 17:20:52 +09:00
4b800d6d2e 한글 입력 중 블록 패널 유지 조건 수정 2026-07-24 17:13:06 +09:00
8abe130f8d 작성 패널 컨텍스트 갱신 정리 2026-07-24 17:08:07 +09:00
540255512c 코드 블록 설정 패널 UI 정리 2026-07-24 17:00:33 +09:00
e8eaed6ff5 콜아웃 패널 토글 닫힘 방지 2026-07-24 16:50:25 +09:00
73248da016 콜아웃 아이콘 토글 UI 정리 2026-07-24 16:35:31 +09:00
d67f735978 뒤로가기 복귀 로딩 상태 정리 2026-07-24 16:26:26 +09:00
e065798b78 iframe 경고와 페이지네이션 강조 수정 2026-07-24 16:21:22 +09:00
2d40fc0574 게시물 목록 페이지 전환 로딩 수정 2026-07-24 16:08:09 +09:00
b38eb234d8 공개 페이지 전환 경고 정리 2026-07-24 16:01:11 +09:00
24dc6b8af0 본문 갤러리 이미지 계산 분리 2026-07-24 15:40:40 +09:00
544d4cb1c1 본문 이미지 URL 계산 분리 2026-07-24 15:35:11 +09:00
c833d47a2c 공개 마크다운 블록 파서 분리 2026-07-24 15:31:55 +09:00
5f6d950721 fenced 블록 탐색 helper 통합 2026-07-24 15:20:47 +09:00
578ee889ee 관리자 인용 컨텍스트 파서 통합 2026-07-24 14:58:21 +09:00
7481773085 마크다운 인용 처리 분리 2026-07-24 14:40:21 +09:00
1f68524548 인아티클 광고 위치 계산 분리 2026-07-24 14:23:46 +09:00
d764865220 마크다운 fenced 메타 파싱 분리 2026-07-24 14:04:39 +09:00
f996506a15 마크다운 표 파싱 로직 분리 2026-07-24 14:00:04 +09:00
164bc2131f 미디어 썸네일 재생성 로직 분리 2026-07-24 13:56:21 +09:00
191a9bb48d 미디어 상세 모달 렌더링 분리 2026-07-24 13:53:11 +09:00
5b6e4d33dd 미디어 그리드 렌더링 분리 2026-07-24 13:47:34 +09:00
b2537d4d14 미디어 폴더 패널 렌더링 분리 2026-07-24 13:43:47 +09:00
e6c78f8686 게시물 설정 패널 렌더링 분리 2026-07-24 13:38:59 +09:00
46c9d8d983 게시물 발행 상태 로직 분리 2026-07-24 12:35:55 +09:00
8bb6784c66 대표 이미지 선택 로직 분리 2026-07-24 12:28:48 +09:00
3c60ed48b9 게시물 태그 입력 로직 분리 2026-07-24 12:20:04 +09:00
0a6a69a843 어나운스 바 설정 정규화 로직 분리 2026-07-24 12:12:04 +09:00
f7bf73a719 브랜드 설정 정규화 로직 분리 2026-07-24 11:59:02 +09:00
526c9b9de9 스팸 필터 설정 정규화 로직 분리 2026-07-24 11:36:45 +09:00
6b8fd27423 SNS 설정 정규화 로직 분리 2026-07-24 11:33:45 +09:00
47c1b02d2b POST 설정 정규화 로직 분리 2026-07-24 11:30:19 +09:00
d885b844ad 업로드 제한 설정 위치 정리 2026-07-24 11:25:47 +09:00
ff18824176 설정 내비게이션 상태 분리 2026-07-24 11:11:17 +09:00
5bd85a100a 설정 섹션 저장 흐름 정리 2026-07-24 11:07:19 +09:00
aa01be3688 설정 내비게이션 컴포넌트 분리 2026-07-24 10:58:32 +09:00
eb27d2846f 메인 화면 설정 카드 분리 2026-07-24 10:55:32 +09:00
a76dcc8111 SNS와 어나운스 설정 카드 분리 2026-07-24 10:50:03 +09:00
ab974c8988 사이트 정보 카드 분리 2026-07-24 10:43:14 +09:00
eb140128f5 블로그 제목 설명 카드 분리 2026-07-24 10:38:54 +09:00
cc10de86b0 POST 설정 카드 분리 2026-07-24 10:07:26 +09:00
8249921306 브랜드 설정 카드 분리 2026-07-23 19:07:46 +09:00
626464df4a 스팸 필터 설정 카드 분리 2026-07-23 19:04:30 +09:00
2ca2cd5130 내보내기 작업 목록 카드 분리 2026-07-23 19:00:52 +09:00
ddebb5ce93 게시물 내보내기 요청 카드 분리 2026-07-23 18:56:47 +09:00
ac26f41abd 게시물 가져오기 카드 분리 2026-07-23 18:52:17 +09:00
c6f43bab59 사이트 용량 카드 분리 2026-07-23 18:47:22 +09:00
d9cd7bce97 가져오기 작업 센터 모달 분리 2026-07-23 18:41:38 +09:00
ef7c1216e4 게시물 가져오기 작업 센터 추가 2026-07-23 18:35:29 +09:00
85b6fe7033 게시물 가져오기 중복 복원 방지 2026-07-23 18:17:26 +09:00
f510a88e56 설정 선택 입력 공통화 2026-07-23 17:55:30 +09:00
4783881829 멤버 필터 선택 입력 공통화 2026-07-23 17:52:14 +09:00
39e05a1038 게시물 가져오기 대표 이미지 표시값 보정 2026-07-23 17:49:29 +09:00
7dc1ecef4d 관리자 팝오버 표면 공통화 2026-07-23 17:41:39 +09:00
60512ef07d 관리자 모달 프레임 공통화 2026-07-23 17:27:10 +09:00
57c4363db2 관리자 페이지네이션 계산 테스트 추가 2026-07-23 17:08:28 +09:00
30e5071cba 관리자 미디어 필터 입력 공통화 2026-07-23 17:05:54 +09:00
24e3d516b3 관리자 목록 선택 입력 공통화 2026-07-23 17:01:20 +09:00
1a8d779e35 관리자 검색 입력 공통화 2026-07-23 16:56:19 +09:00
611abb5a76 관리자 페이지네이션 공통화 2026-07-23 16:50:01 +09:00
f1bc980b61 관리자 토스트 표시 컴포넌트 공통화 2026-07-23 16:29:19 +09:00
180213f6ed 게시글 목록 행 메뉴 공통화 2026-07-23 16:25:59 +09:00
151349edc2 구형 에디터 제거와 토스트 공통화 2026-07-23 16:23:01 +09:00
b61a6a2295 관리자 미디어 대표 이미지 탭 정리 2026-07-22 15:29:46 +09:00
5e619bbe43 태그 추천 연속 선택 개선 2026-07-22 12:30:54 +09:00
89ee25bef0 v1.11.0으로 올리고 방문자용 update-log를 정리한다.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:40:33 +09:00
de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00
9bc0f0a90e AdSense 잔고 통화 안내 추가 2026-07-20 17:37:36 +09:00
8809183d69 AdSense 대시보드 헤더 정리 2026-07-20 17:27:41 +09:00
c0684737ce AdSense 대시보드 요약 정리 2026-07-20 17:11:30 +09:00
e7c4f952a6 대시보드 AdSense 요약 추가 2026-07-20 16:57:09 +09:00
14c53826c6 AdSense 토큰 발급 스크립트 추가 2026-07-20 16:29:39 +09:00
7aa3dfa185 대시보드 디바이스 통계 표시 정리 2026-07-20 13:26:13 +09:00
ad38644171 관리자 목록과 이미지 설정 개선 2026-07-20 12:37:53 +09:00
cf7d6ca0de 관리자 백링크 모달 검색 개선 2026-07-10 17:08:14 +09:00
04c03ad604 관리자 글 목록 백링크 열 추가 2026-07-10 16:32:37 +09:00
d708381e09 관리자 글 링크 참조 확인 추가 2026-07-10 15:50:13 +09:00
e48f886956 현재 접속자 열린 페이지 표시 수정 2026-07-08 16:32:47 +09:00
146487dbe6 현재 접속자 열린 페이지 목록 표시 2026-07-08 16:15:24 +09:00
e27ae1ea72 관리자 대시보드 통계 표시 정규화 2026-07-08 14:48:11 +09:00
14c7245f06 관리자 대시보드 통계 상세 UX 개선 2026-07-08 14:20:24 +09:00
eb840b4224 관리자 글쓰기 슬러그와 미디어 선택 UX 개선 2026-07-08 12:37:42 +09:00
976027c808 관리자 미디어 선택과 초안 자동 저장 개선 2026-07-08 12:12:47 +09:00
2e5a973d11 관리자 설정 사이트 용량 카드를 간결한 통계형 UI로 정리
백업 현황을 사이트 용량으로 재정의하고 파일 집계를 이미지·영상·오디오·첨부 파일·보내기로 명확히 분리해 합계 불일치를 해소했다. 설정 화면의 통계 카드 스타일을 통일해 가독성을 높였다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 16:29:03 +09:00
8c84d2536c 사이트 설정 백업 현황 추가 2026-07-06 16:32:37 +09:00
4c8f6e44f6 페이지 전환 깜빡임 완화 및 레이아웃 통합 (v1.10.50)
- 게시물 상세를 default 레이아웃으로 통합해 홈↔게시물 레이아웃 교체 제거
- CSS 전환 베일·스크롤 정리 플러그인으로 이동 중 깜빡임 완화

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:28:24 +09:00
021c1ee526 페이지 이동 오버레이 해제 시점 보강 2026-07-06 13:47:14 +09:00
7685f82c77 게시물 이동 로딩 후 진입으로 복귀 2026-07-06 13:30:21 +09:00
34d2d8e2ea 페이지 이동 전환 베일 추가 2026-07-06 12:41:08 +09:00
6cb39603bf 페이지 렌더 후 스크롤 적용 2026-07-06 11:55:58 +09:00
cb5cc68ecf 페이지 이동 스크롤 튐 완화 2026-07-06 11:51:09 +09:00
a59d17fefb 게시물 이동 스켈레톤 전환 적용 2026-07-06 11:41:48 +09:00
9952a05ec2 사용자 이동과 미디어 선택 개선 2026-07-06 11:29:12 +09:00
8a774a1874 게시물 상세 이동 지연 개선 및 전체 목록 조회 제거 (v1.10.37)
- 본문만 블로킹 조회, 태그·광고는 lazy로 전환
- 이전·다음 글을 상세 API에서 window 함수로 계산해 응답에 포함
- 전체 게시물(본문 포함) 조회 제거, 광고 site-settings 캐시 재사용

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 18:54:08 +09:00
a07d0350c2 게시물 카드 썸네일 보조 피드백 추가 및 댓글 기본 정렬 최신순 변경
- 카드 hover/누름 시 썸네일 은은한 확대 보조 피드백 추가(메인·전체·태그 공통)
- 썸네일 sticky opacity hover 제거
- 게시글 댓글 기본 정렬을 인기순에서 최신순으로 변경, 옵션 순서 조정
- v1.10.36

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 18:44:44 +09:00
393b0edd70 게시물 카드 클릭 피드백 통일 및 개선
- 메인·전체·태그 목록 카드 제목을 포인트 컬러로 강조하는 공통 인터랙션 적용
- 회색 배경 hover 제거로 모바일 sticky 배경·탭 하이라이트 해소
- 데스크톱 hover 기기 전용 색 변경, 모바일은 :active 눌림 반영
- v1.10.35

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 18:39:51 +09:00
5db41e9883 페이지 이동 중 중복 클릭 차단 추가
- 이동 중 site-navigating 클래스로 링크·버튼 pointer-events를 끄는 클라이언트 플러그인 추가
- 게시물 재클릭 시 상단 점프 문제 해결, 안전 타임아웃으로 잠금 잔존 방지
- v1.10.34

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 18:36:13 +09:00
16343b62c7 HTML 문서 페이지 뒤로가기 근본 수정 및 페이지 이동 로딩 피드백 추가
- /pages/:slug HTML 문서 페이지 클라이언트 소프트 이동을 전체 페이지 이동으로 전환하는 글로벌 미들웨어 추가
- app.vue에 브랜드 컬러 기반 NuxtLoadingIndicator 상단 진행바 추가로 이동 로딩 피드백 제공
- v1.10.33

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 18:27:32 +09:00
3566144783 HTML 문서 페이지 뒤로가기 시 화면 잔상 문제 수정
- bfcache 복원 시 라우터 경로와 실제 URL 어긋남 감지해 새로고침하는 클라이언트 플러그인 추가
- HTML 문서 모드 페이지 진입 후 뒤로가기에서 주소는 홈이나 페이지가 남던 현상 해결
- v1.10.32

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 18:13:47 +09:00
0cd691adf4 헤더 회원 메뉴·회원가입 안내 문구 한글화 및 목적 안내로 개선
- 헤더 Sign up/Sign in을 회원가입/로그인으로, Guest를 방문자로 변경
- 회원가입 안내 문구를 댓글 작성 목적 중심으로 수정
- 회원가입 제목 표기 통일(회원 가입 → 회원가입)
- v1.10.31

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:48:03 +09:00
bb8d20f138 모바일 새로고침 시 좌측 사이드바 열림→닫힘 애니메이션 문제 수정
- useMenuState에 menuMounted 추가, 모바일은 항상 닫힘 상태로 시작
- 좌측 사이드바 모바일 오버레이·백드롭을 마운트 이후에만 열리도록 게이팅
- 데스크톱 기본 열림·저장 설정은 그대로 유지
- v1.10.30

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:45:39 +09:00
b669d34e83 관리자 검색창 한글 IME 조합 검색 및 모바일 가로 트랙 세로 스크롤 문제 수정
- IME 조합 중에도 검색되는 공통 컴포저블 useImeSearchInput 추가
- 글/멤버/미디어/설정/태그 필터/미디어 모달 검색 IME 조합 대응
- 홈 추천글·페이지 섹션 가로 트랙 touch-action pan-x 제거로 모바일 세로 스크롤 복구
- v1.10.29

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 17:03:31 +09:00
58fbae5a03 공개 검색 인덱스 추가 2026-07-03 15:43:27 +09:00
33c92a6f72 미디어 본문 이미지 사용처 계산 수정 2026-07-03 15:27:39 +09:00
288e73c0dc 관리자 글 목록 고정 기능 추가 2026-07-03 15:19:41 +09:00
55efae810f 미디어 원본 사용처 집계 개선 2026-07-03 15:08:30 +09:00
243dfbc5e2 글쓰기 초안 저장 상태 보정 2026-07-03 11:23:04 +09:00
955ae90697 관리자 글 목록 서버 페이지네이션 전환 2026-07-03 11:10:44 +09:00
94782d5873 관리자 글 목록 API 응답 경량화 2026-07-03 10:55:11 +09:00
3f80bbb4ea 관리자 글 목록 태그 필터와 썸네일 개선 2026-07-03 10:23:50 +09:00
b4e408dec8 최근 태그 추천과 미디어 셀렉트 정리 2026-07-03 10:04:47 +09:00
7f20949669 라이브 작성 블록 렌더링과 발행 시각 보정 2026-07-02 16:01:45 +09:00
0576069427 관리자 미디어 API 페이지 응답 적용 2026-07-02 15:36:55 +09:00
00cb4a293d 콜아웃 인라인 링크 렌더링 개선 2026-07-02 15:24:09 +09:00
3dd30d7f76 관리자 미디어 페이지네이션과 라이브 렌더링 개선 2026-07-02 15:15:33 +09:00
7c9b5e9278 본문 이미지 파생 파일명 정리 2026-07-02 11:09:14 +09:00
486c8bfe76 파생 이미지 캐시 재검증 적용 2026-07-02 10:46:09 +09:00
ffd6cbb822 이미지 파생 파일 생성 안정화 2026-07-02 10:27:37 +09:00
3a4e39332a 페이지 커버 썸네일 최적화 2026-07-02 10:09:39 +09:00
b26ae271d2 업로드 파일 캐시 정책 개선 2026-07-01 18:12:43 +09:00
445eb39a83 미디어 본문 파생 이미지 분류 수정 2026-07-01 18:07:44 +09:00
e5d3eac2fd 관리자 업로드 제한 설정 추가 2026-07-01 17:58:30 +09:00
129b68b939 라이트박스 이미지 파생 파일 적용 2026-07-01 17:42:12 +09:00
78ab394f7a 본문 이미지 최적화 및 백필 보정 2026-07-01 17:35:19 +09:00
943794ae34 공개 Markdown 본문 fragment 주석을 줄이도록 렌더링 구조 정리 (v1.10.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:01:14 +09:00
c686b1c848 본문 이미지 최적화·라이트박스·번호형 페이지네이션 반영 (v1.10.3)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:19:45 +09:00
0eca6b288e 게시물 저장 중 추가 변경 보존 및 v1.10.0 반영 2026-07-01 15:24:55 +09:00
853bec747e Markdown 인라인 중첩 렌더링 보강 2026-07-01 15:17:47 +09:00
d3f08e743d 공개 Markdown 인라인 렌더러 fragment 주석 반복을 줄이도록 정리
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 11:50:52 +09:00
1a7b82d9fd 마크다운 표와 리스트 렌더링 개선 2026-07-01 11:38:38 +09:00
ce20ce1d6d 페이지 섹션 추천 아이콘 반영 2026-07-01 11:06:36 +09:00
60a0a6a47a 단축키와 작성 약어 문서 추가 2026-07-01 10:51:36 +09:00
c6ca46bbb1 페이지 HTML 링크 속성 정규화 2026-07-01 10:46:38 +09:00
1a95e9c2fd 홈 슬라이드 카드 클릭 처리 수정 2026-07-01 10:36:16 +09:00
7ec07bb7ea 공개 화면 카드 상호작용 수정 2026-07-01 10:28:46 +09:00
e1455e9064 공개 검색 조합 입력 반영 개선 2026-07-01 10:22:46 +09:00
946bbd5b09 공개 검색 한글 입력 처리 개선 2026-07-01 10:18:46 +09:00
00999404a4 페이지 섹션·공개 목록 설정 누적 변경 일괄 반영
페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 설정 관련 누적 로컬 변경을 기능 단위로 정리해 반영한다.
문서 이력과 패키지 버전을 v1.9.23으로 맞춰 코드와 문서 상태를 동기화한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:48:42 +09:00
6a3cc5d7ec 홈 Featured/Pages 화살표 스타일 통일 및 버전 반영
홈 Pages 섹션의 슬라이드 화살표를 Featured와 동일한 텍스트형 버튼 스타일로 맞춰 섹션 간 UI 일관성을 유지한다.
문서 이력과 패키지 버전을 v1.9.22로 갱신해 중간 배포 단위를 명확히 한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:46:34 +09:00
4e2ec3d7a2 관리자 통계 제외와 콘텐츠 링크 정리 2026-06-30 12:32:18 +09:00
901463e5f3 관리자 통계 UI 개선 v1.9.7
기간 필터 hover 스타일 충돌을 수정하고, 통계 추이 기본값을 일간으로 변경한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:34:22 +09:00
ef9efd7af5 관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:42:23 +09:00
67ea58ccc2 태그 헤더 이미지 설정 추가 2026-06-26 18:08:05 +09:00
6d74425415 일반 태그 상세 메타 표시 수정 2026-06-26 17:42:16 +09:00
a39317f25c 자동 요약 문법 노출 정리 2026-06-26 16:52:47 +09:00
066c58f311 태그 슬러그와 코드 라벨 개선 2026-06-26 16:48:56 +09:00
5247eb293c 코드 블록 줄번호 옵션명 변경 2026-06-26 15:58:41 +09:00
b7362df274 코드 블록 줄번호 기본 숨김 2026-06-26 15:51:46 +09:00
8e3bf4a874 게시물 HTML 블록 분리 2026-06-26 15:23:19 +09:00
c77cf1032b 라이브 편집 커서 스크롤 추적 수정 v1.8.0 2026-06-25 11:37:16 +09:00
a93780731c 대표 이미지 원본 선택 보강 v1.7.9 2026-06-24 10:16:40 +09:00
cab71285d8 게시글 미디어와 편집 표시 개선 v1.7.8 2026-06-24 09:48:10 +09:00
c2acecffcd 장기 방문자 통계 추가 v1.7.7 2026-06-22 17:31:56 +09:00
e2ca365fa0 블로그 통계 위젯 확장 v1.7.6 2026-06-22 17:24:29 +09:00
7cd32f62bd 서비스 포트 재조정 2026-06-22 15:27:49 +09:00
db1030bfcd 서비스 포트 충돌 해소 2026-06-22 14:33:13 +09:00
6279a046a9 로컬 개발 포트 설정 변경 2026-06-22 14:29:22 +09:00
06264e9b02 운영 포트 설정 변경 2026-06-22 14:26:35 +09:00
5b2ed97748 v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 14:32:11 +09:00
f206499f99 외부 파비콘을 faviconV2 origin 방식으로 수정해 서브도메인 아이콘이 올바르게 표시되도록 한다.
uniare.sori.studio처럼 domain 기반 API가 기본 지구본만 반환하던 문제를 해결하고, Recommended 썸네일 실패 fallback을 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:38:30 +09:00
7107d56fc7 미디어 사용 개수 배지 표시 2026-06-17 11:26:17 +09:00
d98114003d 추천 사이트 위치 조정 2026-06-17 10:36:57 +09:00
54d32af617 미디어 필터와 이미지 액션 정리 2026-06-17 10:28:53 +09:00
11f8402cb6 글쓰기 되돌리기와 미디어 관리 보정 2026-06-17 10:12:24 +09:00
d41cb71d40 게시물 HTML iframe 블록 추가 2026-06-17 09:52:56 +09:00
6c4064d8b5 인라인 텍스트 색상 패널 추가 2026-06-16 16:54:01 +09:00
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
bd71ca860c 태그 관리 화면을 메인/일반 전환 중심으로 단순화하고 삭제 동선을 재정리.
글쓰기 Post URL 슬러그는 한글 입력 시 발음 기반 영문 소문자로 자동 생성되도록 개선.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
.nuxt
.output
dist
coverage
.git
.env
.env.*
!.env.example
*.log
**/.DS_Store
public/uploads
tests
test-results
playwright-report
docs

57
.env.example Normal file
View File

@@ -0,0 +1,57 @@
# Database
DATABASE_URL=postgres://sori_studio:replace-with-random-password@sori-studio-db:5432/sori_studio
DATABASE_NAME=sori_studio
POSTGRES_DB=sori_studio
POSTGRES_USER=sori_studio
POSTGRES_PASSWORD=replace-with-random-password
DB_BIND_ADDRESS=127.0.0.1
DB_PORT=43081
# Auth
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-random-password
MEMBER_SESSION_SECRET=replace-with-random-password
# Google 로그인/계정 연동(optional)
# GOOGLE_OAUTH_CLIENT_ID=
# GOOGLE_OAUTH_CLIENT_SECRET=
# GOOGLE_OAUTH_REDIRECT_URI=https://sori.studio/api/auth/google/callback
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
# ANALYTICS_COLLECTED_SINCE=2026-06-01 ← 선택. 관리자 통계 집계 시작일(YYYY-MM-DD).
# Upload
UPLOAD_DIR=/uploads
# 마이그레이션·reconcile 완료 후 database. 신규 설치는 filesystem으로 시작한 뒤 reconcile → database.
MEDIA_CATALOG_MODE=database
MAX_FILE_SIZE=10485760
MAX_VIDEO_FILE_SIZE=209715200
MAX_AUDIO_FILE_SIZE=52428800
MAX_DOCUMENT_FILE_SIZE=52428800
POST_EXPORT_MAX_FILE_SIZE_BYTES=524288000
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512
AVATAR_MAX_HEIGHT=512
AVATAR_WEBP_QUALITY=82
# Site
NUXT_PUBLIC_SITE_URL=https://sori.studio
NUXT_PUBLIC_SITE_TITLE=sori.studio
# Google AdSense Management API (optional — 관리자 대시보드 수익 통계)
# GOOGLE_ADSENSE_CLIENT_ID=
# GOOGLE_ADSENSE_CLIENT_SECRET=
# GOOGLE_ADSENSE_REFRESH_TOKEN=
# GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-1234567890123456
# GOOGLE_ADSENSE_CURRENCY_CODE=KRW
# GOOGLE_ADSENSE_CREDENTIALS_PATH= ← 선택. 토큰 발급 스크립트에서 credentials.json을 직접 읽을 때만 사용.
# GOOGLE_ADSENSE_OAUTH_PORT=43082
# 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
COMPOSE_PROJECT_NAME=sori-studio
APP_PORT=43080
DOCKER_SUBNET=10.250.50.0/24

8
.gitignore vendored
View File

@@ -5,11 +5,13 @@ node_modules/
.output/ .output/
.nuxt/ .nuxt/
dist/ dist/
public/uploads/
# Environment # Environment
.env .env
.env.* .env.*
!.env.example !.env.example
docker-compose.yaml.backup.*
# IDE # IDE
.idea/ .idea/
@@ -27,3 +29,9 @@ npm-debug.log*
# Test # Test
coverage/ coverage/
playwright-report/
test-results/
# Reference theme sample (do not commit)
ZCF-v1.0.5/
sample 깃에 올리지말것/

View File

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

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/scripts/backfill-post-thumbnails.js ./.output/server/scripts/backfill-post-thumbnails.js
COPY --from=builder /app/scripts/backfill-post-body-thumbnails.js ./.output/server/scripts/backfill-post-body-thumbnails.js
COPY --from=builder /app/scripts/backfill-page-cover-thumbnails.js ./.output/server/scripts/backfill-page-cover-thumbnails.js
COPY --from=builder /app/server/utils/post-thumbnail-image.js ./.output/server/server/utils/post-thumbnail-image.js
COPY --from=builder /app/lib/post-card-thumbnail-url.js ./.output/server/lib/post-card-thumbnail-url.js
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

26
app.html Normal file
View File

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

58
app.vue Normal file
View File

@@ -0,0 +1,58 @@
<script setup>
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from './lib/brand-color.js'
const { data: appSiteSettings } = await usePublicSiteSettings()
const siteAccentColor = computed(() => normalizeBrandColor(appSiteSettings.value?.brandColor || DEFAULT_BRAND_COLOR))
const siteAccentStyle = computed(() => ({
'--site-accent': siteAccentColor.value
}))
const isSitePageNavigating = useState('site-page-veil', () => false)
useHead(() => ({
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
? [
{
rel: 'icon',
type: 'image/png',
href: appSiteSettings.value.faviconUrl
}
]
: []
}))
</script>
<template>
<div class="site-app" :style="siteAccentStyle">
<div
v-show="isSitePageNavigating"
class="site-page-loading pointer-events-none fixed inset-0 z-[2147483647] grid place-items-center text-[var(--site-text)]"
role="status"
aria-live="polite"
aria-busy="true"
>
<div class="site-page-loading__panel grid place-items-center gap-3 text-center">
<span class="site-page-loading__spinner size-8 rounded-full border-2 border-[var(--site-line)] border-t-[var(--site-accent)]" aria-hidden="true" />
<span class="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">Loading</span>
</div>
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<style scoped>
.site-page-loading__spinner {
animation: site-page-loading-spin 0.8s linear infinite;
}
@keyframes site-page-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>

25
app/router.options.js Normal file
View File

@@ -0,0 +1,25 @@
import { setPendingScrollRestore } from '../lib/site-route-transition.js'
/**
* 라우터 스크롤 동작
* 클라이언트에서는 scrollBehavior가 직접 스크롤하지 않고,
* 전환 베일 뒤에서 `site-page-transition.client.js`가 스크롤을 적용한다.
* @type {import('vue-router').RouterScrollBehavior}
*/
export default (to, from, savedPosition) => {
if (import.meta.server) {
return { top: 0, left: 0 }
}
if (to.hash) {
return {
el: to.hash
}
}
if (savedPosition) {
setPendingScrollRestore(savedPosition)
}
return false
}

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

@@ -0,0 +1,437 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--site-bg: #fcfcfc;
--site-panel: #fcfcfc;
--site-panel-strong: #fcfcfc;
--site-text: #111111;
--site-muted: #454545;
--site-soft: #6f7480;
--site-line: #e2e2e0;
--site-input: #fcfcfc;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #111111;
--site-invert-text: #ffffff;
}
:root[data-theme='dark'] {
--site-bg: #050505;
--site-panel: #080808;
--site-panel-strong: #0d0d0d;
--site-text: #f4f4f2;
--site-muted: #c7c7c2;
--site-soft: #8b8e96;
--site-line: #252525;
--site-input: #171717;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #f4f4f2;
--site-invert-text: #111111;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--site-bg: #050505;
--site-panel: #080808;
--site-panel-strong: #0d0d0d;
--site-text: #f4f4f2;
--site-muted: #c7c7c2;
--site-soft: #8b8e96;
--site-line: #252525;
--site-input: #171717;
--site-accent: #ff4f2e;
--site-accent-text: #ffffff;
--site-invert: #f4f4f2;
--site-invert-text: #111111;
}
}
html {
font-family: Pretendard, ui-sans-serif, system-ui, sans-serif;
color: var(--site-text);
background: var(--site-bg);
}
.site-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: var(--site-bg);
transition: opacity 0.22s ease, visibility 0.22s ease;
}
html.site-app-ready .site-splash {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.site-splash__logo {
width: 5rem;
height: 5rem;
border-radius: 1rem;
object-fit: cover;
}
.site-splash__text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--site-text);
}
html.site-mobile-nav-open {
overflow: hidden;
}
html.site-search-open {
overflow: hidden;
}
html.site-navigating {
cursor: progress;
}
html.site-navigating a,
html.site-navigating button,
html.site-navigating [role="button"] {
pointer-events: none;
}
html.site-page-veil {
overflow: hidden;
}
html.site-page-veil::before {
content: '';
position: fixed;
inset: 0;
z-index: 2147483646;
background: var(--site-bg);
pointer-events: all;
}
html.site-page-veil .site-shell,
html.site-page-veil .site-shell * {
transition: none !important;
}
body {
min-width: 320px;
margin: 0;
background: var(--site-bg);
}
html.admin-post-editor-document,
body.admin-post-editor-document {
height: 100%;
overflow: hidden;
background: #ffffff;
}
html.admin-settings-document,
body.admin-settings-document {
height: 100%;
overflow: hidden;
background: #f7f8fa;
}
}
@layer components {
@keyframes site-search-modal-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.site-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
color: var(--site-text);
background: var(--site-bg);
}
.site-section {
border-bottom: 1px solid var(--site-line);
background: var(--site-bg);
}
.site-section-header {
@apply px-6 py-8;
}
.site-section-body {
@apply px-6 py-4;
}
.site-search-modal__panel--animate {
animation: site-search-modal-in 0.18s ease-out;
}
.post-summary-clamp {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.post-summary-clamp--one {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.post-summary-clamp--two {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.post-summary-clamp--three {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.post-prose {
@apply max-w-none text-[17px] leading-8;
color: var(--site-text);
}
.site-header {
height: 57px;
border-bottom: 1px solid var(--site-line);
background: var(--site-panel);
color: var(--site-text);
}
.site-main {
min-height: 0;
background: var(--site-bg);
}
.site-main--menu-closed {
border-left: 0;
}
.site-sidebar {
min-height: 0;
background: var(--site-bg);
color: var(--site-text);
}
/**
* 사이드바 내부 스크롤 영역 — 스크롤바만 숨기고 스크롤 동작은 유지한다.
*/
.site-sidebar-scroll {
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.site-sidebar-scroll::-webkit-scrollbar {
display: none;
}
.site-sidebar-section {
border-bottom: 1px solid var(--site-line);
}
.site-muted {
color: var(--site-muted);
}
.site-soft {
color: var(--site-soft);
}
.site-input {
border: 1px solid var(--site-line);
background: var(--site-input);
color: var(--site-text);
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
}
.site-button {
background: var(--site-invert);
color: var(--site-invert-text);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.site-button:hover {
opacity: 0.9;
}
.site-accent-button {
background: var(--site-accent);
color: var(--site-accent-text);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.site-accent-button:hover {
opacity: 0.92;
}
.site-interactive {
transition: color 0.2s ease, background-color 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.site-interactive:hover {
color: var(--site-text);
}
.site-input:hover,
.site-input:focus-visible {
border-color: color-mix(in srgb, var(--site-text) 24%, var(--site-line));
}
.site-panel-hover {
transition: background-color 0.2s ease;
}
.site-panel-hover:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
/**
* 게시물 카드 제목 링크 공통 인터랙션 — 메인·전체 목록 통일.
* 데스크톱은 실제 hover 가능 기기에서만 색을 바꾸고, 모바일은 누르는 순간(:active)에만 반영해
* 탭 후 색이 남는 sticky hover와 회색 탭 하이라이트를 없앤다.
*/
.post-title-link {
color: var(--site-text);
transition: color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
.post-title-link:hover {
color: var(--site-accent);
}
}
.post-title-link:active {
color: var(--site-accent);
}
/**
* 게시물 카드 보조 피드백 — 제목 강조와 함께 썸네일을 은은하게 확대.
* 데스크톱은 카드 hover(실제 hover 기기), 모바일은 카드/썸네일 :active 눌림에만 반영.
*/
.post-card-media__image,
.post-card-media__placeholder {
transition: transform 0.3s ease;
will-change: transform;
}
@media (hover: hover) {
.group:hover .post-card-media__image,
.group:hover .post-card-media__placeholder {
transform: scale(1.04);
}
}
.group:active .post-card-media__image,
.group:active .post-card-media__placeholder,
.post-card-media:active .post-card-media__image,
.post-card-media:active .post-card-media__placeholder {
transform: scale(1.04);
}
/**
* 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 등 행 호버 — 라이트 테마에서 밝은 크림 톤, 다크는 패널 대비 유지
*/
.site-sidebar-nav-row {
transition: background-color 0.2s ease;
}
.site-sidebar-nav-row:hover {
background-color: #f7f4ef;
}
/**
* 관리자 화면은 공개 사이트 테마와 분리된 라이트 UI로 고정한다.
*/
.admin-layout {
--site-bg: #f7f8fa;
--site-panel: #ffffff;
--site-panel-strong: #ffffff;
--site-text: #15171a;
--site-muted: #6b7280;
--site-soft: #657080;
--site-line: #e5e7eb;
--site-input: #ffffff;
color-scheme: light;
}
.admin-layout--light-controls input:not(.auth-form-input),
.admin-layout--light-controls textarea,
.admin-layout--light-controls select {
color: #15171a;
background-color: #ffffff;
caret-color: #15171a;
color-scheme: light;
}
.admin-layout--light-controls input:not(.auth-form-input)::placeholder,
.admin-layout--light-controls textarea::placeholder {
color: #8a94a3;
}
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill,
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill:hover,
.admin-layout--light-controls input:not(.auth-form-input):-webkit-autofill:focus {
-webkit-text-fill-color: #15171a;
box-shadow: 0 0 0 1000px #ffffff inset;
}
:root[data-theme='dark'] .site-sidebar-nav-row:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) .site-sidebar-nav-row:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
}
/**
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
*/
.auth-form-input {
color: #f5f7fa;
caret-color: #2f6feb;
}
.auth-form-input::placeholder {
color: #5c6570;
}
.auth-form-input:-webkit-autofill,
.auth-form-input:-webkit-autofill:hover,
.auth-form-input:-webkit-autofill:focus {
-webkit-text-fill-color: #f5f7fa;
transition: background-color 9999s ease-out;
}
}
@layer utilities {
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}

View File

@@ -0,0 +1,173 @@
<script setup>
const adSlots = [
{
key: 'adHomeFeedCode',
label: '메인 피드',
description: '메인 화면 Featured와 Latest 목록 사이에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adHomeInfeedCode',
label: '메인 인피드',
description: '메인 화면 Latest 게시물 목록 사이 한 곳에 무작위로 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adSidebarCode',
label: '오른쪽 사이드',
description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
placeholder: '<ins class="adsbygoogle" ...></ins>'
},
{
key: 'adPostSidebarCode',
label: '게시물 오른쪽 사이드',
description: '게시물 상세 화면의 오른쪽 사이드바 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>

View File

@@ -0,0 +1,401 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
import {
ANALYTICS_SINGLE_BAR_COLORS,
formatAnalyticsChartHeaderLabel,
getAnalyticsPeriodLabel,
getAnalyticsTrafficStackColor,
getAnalyticsTrafficStackSegments,
getAnalyticsTrendAxisPrimaryLabel,
getAnalyticsTrendAxisSecondaryLabel,
getAnalyticsTrendDelta,
getAnalyticsTrendDetailLabel,
isAnalyticsCurrentTrendRow,
paginateAnalyticsTrendRows
} from '~/lib/analytics-chart-ui.js'
import { getAnalyticsDayKey } from '~/lib/analytics-shared.js'
const props = defineProps({
rows: {
type: Array,
default: () => []
},
period: {
type: String,
default: 'weekly'
},
metricKey: {
type: String,
required: true
},
metricLabel: {
type: String,
required: true
},
formatValue: {
type: Function,
default: (value) => formatAnalyticsCount(value)
},
formatDelta: {
type: Function,
default: null
},
pageOffset: {
type: Number,
default: 0
},
showNavigation: {
type: Boolean,
default: true
},
showHeader: {
type: Boolean,
default: true
},
compact: {
type: Boolean,
default: false
},
barHeightClass: {
type: String,
default: 'h-32'
},
emptyMessage: {
type: String,
default: '선택한 기간에 표시할 추이 데이터가 없습니다.'
},
clickable: {
type: Boolean,
default: false
},
trafficStack: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:pageOffset', 'bar-click'])
const todayKey = getAnalyticsDayKey()
const hoveredBarKey = ref(null)
const pagination = computed(() => {
return paginateAnalyticsTrendRows(props.rows, props.period, props.pageOffset)
})
const visibleRows = computed(() => pagination.value.rows)
const chartHeaderLabel = computed(() => {
return formatAnalyticsChartHeaderLabel(props.period, visibleRows.value, todayKey)
})
/**
* 추이 행의 고유 키를 반환한다.
* @param {Object} row - 추이 행
* @returns {string} 고유 키
*/
const getBarRowKey = (row) => {
return `${row.startDay || row.day}-${row.endDay || row.day}`
}
/**
* 막대가 강조 상태인지 확인한다.
* @param {Object} row - 추이 행
* @returns {boolean} 강조 여부
*/
const isBarEmphasized = (row) => {
if (!hoveredBarKey.value) {
return false
}
return getBarRowKey(row) === hoveredBarKey.value
}
/**
* 막대가 흐리게 표시되는지 확인한다.
* @param {Object} row - 추이 행
* @returns {boolean} 흐림 여부
*/
const isBarDimmed = (row) => {
return Boolean(hoveredBarKey.value && !isBarEmphasized(row))
}
/**
* 축 라벨이 강조 상태인지 확인한다.
* @param {Object} row - 추이 행
* @returns {boolean} 강조 여부
*/
const isAxisHighlighted = (row) => {
return isBarEmphasized(row) || (isAnalyticsCurrentTrendRow(props.period, row, todayKey) && !hoveredBarKey.value)
}
/**
* 막대 높이 비율을 반환한다.
* @param {Object} row - 추이 행
* @returns {number} 높이 %
*/
const getBarHeight = (row) => {
const maxValue = Math.max(...visibleRows.value.map((item) => Number(item[props.metricKey] || 0)), 0)
if (maxValue <= 0) {
return 3
}
const value = Number(row[props.metricKey] || 0)
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
}
/**
* 단일 막대 색상을 반환한다.
* @param {Object} row - 추이 행
* @returns {string} 색상
*/
const getSingleBarColor = (row) => {
const emphasized = isBarEmphasized(row)
return emphasized ? ANALYTICS_SINGLE_BAR_COLORS.active : ANALYTICS_SINGLE_BAR_COLORS.muted
}
/**
* 유입 스택 구간 목록을 반환한다.
* @param {Object} row - 추이 행
* @returns {Array<{ key: 'search' | 'sns' | 'other', value: number }>}
*/
const getStackSegments = (row) => {
return getAnalyticsTrafficStackSegments(row).segments
}
/**
* 전체 추이 행에서 현재 행 인덱스를 반환한다.
* @param {Object} row - 추이 행
* @returns {number} 인덱스
*/
const getRowIndexInAllRows = (row) => {
const key = getBarRowKey(row)
return props.rows.findIndex((item) => getBarRowKey(item) === key)
}
/**
* 증감 표시 문자열을 반환한다.
* @param {number | null} delta - 증감값
* @returns {string} 표시 문자열
*/
const formatDeltaLabel = (delta) => {
if (delta === null) {
return ''
}
if (typeof props.formatDelta === 'function') {
return props.formatDelta(delta)
}
const absolute = Math.abs(delta)
return formatAnalyticsCount(absolute)
}
/**
* 막대 hover 시작을 처리한다.
* @param {Object} row - 추이 행
* @returns {void}
*/
const handleBarEnter = (row) => {
hoveredBarKey.value = getBarRowKey(row)
}
/**
* 막대 hover 종료를 처리한다.
* @returns {void}
*/
const handleBarLeave = () => {
hoveredBarKey.value = null
}
/**
* 이전 페이지로 이동한다.
* @returns {void}
*/
const goPrevPage = () => {
if (!pagination.value.canGoPrev) {
return
}
emit('update:pageOffset', props.pageOffset + pagination.value.pageSize)
}
/**
* 다음 페이지로 이동한다.
* @returns {void}
*/
const goNextPage = () => {
if (!pagination.value.canGoNext) {
return
}
emit('update:pageOffset', Math.max(props.pageOffset - pagination.value.pageSize, 0))
}
/**
* 막대 클릭을 처리한다.
* @param {Object} row - 추이 행
* @returns {void}
*/
const handleBarClick = (row) => {
if (!props.clickable) {
return
}
emit('bar-click', row)
}
/**
* 추이 행의 증감값을 반환한다.
* @param {Object} row - 추이 행
* @returns {number | null} 증감값
*/
const getRowDelta = (row) => {
return getAnalyticsTrendDelta(props.rows, getRowIndexInAllRows(row), props.metricKey)
}
</script>
<template>
<section class="admin-analytics-trend-chart">
<header
v-if="showHeader && chartHeaderLabel"
class="admin-analytics-trend-chart__header mb-3 flex items-center justify-between gap-3"
>
<p class="admin-analytics-trend-chart__range text-sm font-semibold text-ink">
{{ chartHeaderLabel }}
</p>
</header>
<div class="admin-analytics-trend-chart__body relative" :class="showNavigation ? 'pb-8' : 'pb-0'">
<div
v-if="visibleRows.length"
class="admin-analytics-trend-chart__columns flex gap-1"
role="img"
:aria-label="`${getAnalyticsPeriodLabel(period)} ${metricLabel} 추이`"
>
<div
v-for="(row, index) in visibleRows"
:key="`${period}-${getBarRowKey(row)}`"
class="admin-analytics-trend-chart__column flex min-w-0 flex-1 flex-col transition-opacity duration-150"
:class="isBarDimmed(row) ? 'opacity-40' : 'opacity-100'"
@mouseenter="handleBarEnter(row)"
@mouseleave="handleBarLeave"
>
<component
:is="clickable ? 'button' : 'div'"
class="admin-analytics-trend-chart__bar-wrap group relative flex w-full items-end border-b border-line"
:class="[barHeightClass, clickable ? 'cursor-pointer border-0 bg-transparent p-0' : '']"
:type="clickable ? 'button' : undefined"
@focus="handleBarEnter(row)"
@click="handleBarClick(row)"
>
<div
class="admin-analytics-trend-chart__tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden min-w-max max-w-none -translate-x-1/2 whitespace-nowrap rounded border border-line bg-white px-2.5 py-1.5 text-xs shadow-lg group-hover:block group-focus-visible:block"
>
<div class="admin-analytics-trend-chart__tooltip-main flex items-center gap-2">
<span class="text-sm font-semibold leading-none text-ink">
{{ formatValue(row[metricKey]) }}
</span>
<span
v-if="getRowDelta(row) !== null"
class="inline-flex items-center gap-0.5 text-[11px] font-semibold leading-none"
:class="getRowDelta(row) >= 0 ? 'text-[#e5484d]' : 'text-[#2f6feb]'"
>
<span aria-hidden="true">{{ getRowDelta(row) >= 0 ? '▲' : '▼' }}</span>
<span>{{ formatDeltaLabel(getRowDelta(row)) }}</span>
</span>
</div>
<p
v-if="trafficStack && getStackSegments(row).length > 1"
class="admin-analytics-trend-chart__tooltip-breakdown mt-1 text-[10px] leading-relaxed text-muted"
>
<span class="text-[#e5484d]">검색 {{ formatAnalyticsCount(row.trafficSearch) }}</span>
·
<span class="text-[#12a594]">SNS {{ formatAnalyticsCount(row.trafficSns) }}</span>
·
<span class="text-[#8b8d98]">기타 {{ formatAnalyticsCount(row.trafficOther) }}</span>
</p>
<p class="admin-analytics-trend-chart__tooltip-period mt-1 text-[10px] leading-none text-muted">
{{ getAnalyticsTrendDetailLabel(period, row, todayKey) }}
</p>
</div>
<div
v-if="trafficStack && metricKey === 'visitors'"
class="admin-analytics-trend-chart__stack flex w-full flex-col justify-end overflow-hidden rounded-t-sm"
:class="compact ? 'mx-auto max-w-[10px]' : ''"
:style="{ height: `${getBarHeight(row)}%` }"
>
<div
v-for="segment in getStackSegments(row)"
:key="`${getBarRowKey(row)}-${segment.key}`"
class="admin-analytics-trend-chart__stack-segment w-full transition-colors duration-150"
:style="{
flex: `${segment.value} 0 0`,
backgroundColor: getAnalyticsTrafficStackColor(segment.key, isBarEmphasized(row))
}"
/>
</div>
<div
v-else
class="admin-analytics-trend-chart__bar w-full rounded-t-sm transition-colors duration-150"
:class="compact ? 'mx-auto max-w-[10px]' : ''"
:style="{
height: `${getBarHeight(row)}%`,
backgroundColor: getSingleBarColor(row)
}"
/>
</component>
<div class="admin-analytics-trend-chart__axis overflow-visible border-t border-line px-0.5 pt-2 text-center">
<span
class="admin-analytics-trend-chart__axis-primary inline-flex max-w-full items-center justify-center whitespace-nowrap leading-none transition-colors duration-150"
:class="isAxisHighlighted(row) ? 'rounded-full bg-[#15171a] px-1.5 py-0.5 text-[9px] font-semibold !text-white' : 'text-[11px] font-medium text-muted'"
>
{{ getAnalyticsTrendAxisPrimaryLabel(period, row, index === visibleRows.length - 1, todayKey) }}
</span>
<span
v-if="getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index)"
class="admin-analytics-trend-chart__axis-secondary mt-0.5 block text-[10px] text-muted"
>
{{ getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index) }}
</span>
</div>
</div>
</div>
<p
v-if="!visibleRows.length"
class="admin-analytics-trend-chart__empty mt-4 text-center text-xs text-muted"
>
{{ emptyMessage }}
</p>
<div
v-if="showNavigation && visibleRows.length"
class="admin-analytics-trend-chart__nav pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between"
>
<button
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
type="button"
aria-label="이전 기간"
:disabled="!pagination.canGoPrev"
@click="goPrevPage"
>
‹
</button>
<button
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
type="button"
aria-label="다음 기간"
:disabled="!pagination.canGoNext"
@click="goNextPage"
>
›
</button>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,199 @@
<script setup>
import {
ANNOUNCEMENT_ALIGNMENT_OPTIONS,
ANNOUNCEMENT_BACKGROUND_PRESETS,
DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR,
normalizeAnnouncementAlignment,
normalizeAnnouncementBackgroundColor
} from '~/lib/announcement-bar.js'
/**
* 어나운스 바 설정 카드
* @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-announcement"
class="admin-announcement-settings-card admin-settings-screen__card admin-settings-screen__card--announcement 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]"
>
홈페이지 상단에 중요한 공지나 링크를 표시합니다.<br>방문자는 X로 이번 방문만 닫거나, 7일간 보지 않기를 선택할 수 있습니다.<br>공지를 수정·저장하면 다시 노출됩니다.
</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>
<label
v-if="!editing"
class="admin-settings-screen__announcement-toggle flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<span class="font-bold text-[#15171a]">사용</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 cursor-not-allowed items-center opacity-55 grayscale" aria-disabled="true">
<input
:checked="form.announcementEnabled"
class="peer sr-only"
type="checkbox"
disabled
aria-label="어나운스 바 사용"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#6d747d]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-[#f4f6f8] shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<div
v-else
class="admin-announcement-settings-card__edit admin-settings-screen__announcement-edit grid gap-6 border-t border-[#eceff2] pt-5"
>
<label class="admin-settings-screen__announcement-toggle flex items-center justify-between gap-4 text-sm">
<span class="font-bold text-[#15171a]">사용</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.announcementEnabled"
class="peer sr-only"
type="checkbox"
aria-label="어나운스 바 사용"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">어나운스</span>
<input
v-model="form.announcementText"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
maxlength="200"
placeholder="공지 문구"
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">링크 (선택)</span>
<input
v-model="form.announcementUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
maxlength="500"
placeholder="https://… 또는 /경로 (비우면 링크 없음)"
>
</label>
<div class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">배경색</span>
<div class="admin-settings-screen__announcement-color-editor flex flex-wrap items-center gap-3">
<div class="admin-settings-screen__announcement-colors flex flex-wrap items-center gap-2">
<button
v-for="preset in ANNOUNCEMENT_BACKGROUND_PRESETS"
:key="preset.id"
class="admin-settings-screen__announcement-color relative inline-flex size-9 items-center justify-center rounded-full border transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#15171a]"
:class="normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor) === preset.value ? 'border-[#22a06b] ring-2 ring-[#22a06b] ring-offset-2' : 'border-[#dce0e5]'"
type="button"
:style="{ backgroundColor: preset.value }"
:title="preset.label"
:aria-label="`${preset.label} 배경`"
:aria-pressed="normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor) === preset.value"
@click="form.announcementBackgroundColor = preset.value"
>
<span class="sr-only">{{ preset.label }}</span>
</button>
</div>
<label class="admin-settings-screen__announcement-color-input inline-flex h-10 items-center gap-2 rounded-md border border-[#dce0e5] bg-white px-2">
<input
class="size-7 rounded border-0 bg-transparent p-0"
type="color"
:value="normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR)"
aria-label="어나운스 바 배경색 선택"
@input="form.announcementBackgroundColor = $event.target.value"
>
<input
v-model="form.announcementBackgroundColor"
class="h-8 w-28 border-0 bg-transparent px-1 text-sm font-semibold text-[#15171a] outline-none"
maxlength="7"
placeholder="#15171a"
aria-label="어나운스 바 배경색 직접 입력"
>
</label>
</div>
</div>
<div class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">텍스트 정렬</span>
<div class="admin-settings-screen__announcement-alignment inline-flex w-full rounded-md border border-[#dce0e5] bg-white p-1 sm:w-auto">
<button
v-for="option in ANNOUNCEMENT_ALIGNMENT_OPTIONS"
:key="option.value"
class="admin-settings-screen__announcement-align-option inline-flex h-9 flex-1 items-center justify-center rounded px-4 text-sm font-semibold transition sm:min-w-24"
:class="normalizeAnnouncementAlignment(form.announcementAlignment) === option.value ? 'bg-[#15171a] text-white' : 'text-[#657080] hover:bg-[#f4f6f8] hover:text-[#15171a]'"
type="button"
:aria-pressed="normalizeAnnouncementAlignment(form.announcementAlignment) === option.value"
@click="form.announcementAlignment = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,272 @@
<script setup>
const props = defineProps({
summary: {
type: Object,
default: null
},
pending: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['refresh'])
/**
* 사이트 용량 숫자를 쉼표 포함 문자열로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 숫자 라벨
*/
const formatBackupNumber = (value) => Number(value || 0).toLocaleString()
/**
* 사이트 용량 바이트를 읽기 쉬운 문자열로 변환한다.
* @param {number} value - 바이트 값
* @returns {string} 용량 라벨
*/
const formatBackupSize = (value) => {
const bytes = Number(value || 0)
if (!bytes) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
/**
* 사이트 용량 기준 시각을 표시한다.
* @param {string} value - ISO 날짜 문자열
* @returns {string} 날짜 라벨
*/
const formatBackupGeneratedAt = (value) => {
if (!value) {
return '집계 대기'
}
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value))
}
/**
* 사이트 용량 콘텐츠 지표 목록
* @returns {Array<{ label: string, value: string, helper: string }>} 콘텐츠 지표
*/
const contentMetrics = computed(() => {
const content = props.summary?.content || {}
const posts = content.posts || {}
const pages = content.pages || {}
const tags = content.tags || {}
const comments = content.comments || {}
const members = content.members || {}
return [
{
label: '게시글',
value: `${formatBackupNumber(posts.total)}개`,
helper: `공개 ${formatBackupNumber(posts.published)} · 비공개 ${formatBackupNumber(posts.private)} · 초안 ${formatBackupNumber(posts.draft)} · 회원 ${formatBackupNumber(posts.members)}`
},
{
label: '페이지',
value: `${formatBackupNumber(pages.total)}개`,
helper: `공개 ${formatBackupNumber(pages.published)} · 목록 ${formatBackupNumber(pages.listed)} · 추천 ${formatBackupNumber(pages.featured)}`
},
{
label: '태그',
value: `${formatBackupNumber(tags.total)}개`,
helper: `사용 중 ${formatBackupNumber(tags.used)} · 관리 ${formatBackupNumber(tags.managed)} · 일반 ${formatBackupNumber(tags.general)}`
},
{
label: '댓글',
value: `${formatBackupNumber(comments.total)}개`,
helper: `공개 ${formatBackupNumber(comments.published)} · 대기 ${formatBackupNumber(comments.pending)} · 차단 ${formatBackupNumber(comments.blocked)}`
},
{
label: '회원',
value: `${formatBackupNumber(members.total)}명`,
helper: `소유자 ${formatBackupNumber(members.owner)} · 관리자 ${formatBackupNumber(members.admin)} · 회원 ${formatBackupNumber(members.member)}`
}
]
})
/**
* 사이트 용량 저장소 행 값을 포맷한다.
* @param {number} count - 파일 개수
* @param {number} sizeBytes - 파일 용량
* @returns {string} 개수·용량 라벨
*/
const formatBackupStatValue = (count, sizeBytes) => `${formatBackupNumber(count)}개 · ${formatBackupSize(sizeBytes)}`
/**
* 사이트 용량 저장소 섹션 목록
* @returns {{ imageRows: Array<{ label: string, value: string, child?: boolean }>, mainRows: Array<{ label: string, value: string }>, totalRow: { label: string, value: string } }} 저장소 섹션
*/
const storageSections = computed(() => {
const storage = props.summary?.storage || {}
const derivatives = storage.derivatives || {}
const profiles = storage.profiles || {}
const exports = storage.exports || {}
const byKind = storage.byKind || {}
const images = byKind.images || {}
const videos = byKind.videos || {}
const audio = byKind.audio || {}
const files = byKind.files || {}
const otherCount = Number(audio.count || 0) + Number(files.count || 0)
const otherSize = Number(audio.sizeBytes || 0) + Number(files.sizeBytes || 0)
const imageOriginalCount = Math.max(0, Number(images.count || 0) - Number(derivatives.count || 0) - Number(profiles.count || 0))
const imageOriginalSize = Math.max(0, Number(images.sizeBytes || 0) - Number(derivatives.sizeBytes || 0) - Number(profiles.sizeBytes || 0))
const totalCount = Number(images.count || 0) + Number(videos.count || 0) + otherCount + Number(exports.count || 0)
const totalSize = Number(images.sizeBytes || 0) + Number(videos.sizeBytes || 0) + otherSize + Number(exports.sizeBytes || 0)
const mainRows = [
{
label: '영상',
value: formatBackupStatValue(videos.count, videos.sizeBytes)
},
{
label: '보내기',
value: formatBackupStatValue(exports.count, exports.sizeBytes)
}
]
if (Number(audio.count || 0) > 0) {
mainRows.push({
label: '오디오',
value: formatBackupStatValue(audio.count, audio.sizeBytes)
})
}
if (Number(files.count || 0) > 0) {
mainRows.push({
label: '첨부 파일',
value: formatBackupStatValue(files.count, files.sizeBytes)
})
}
return {
imageRows: [
{
label: '이미지',
value: formatBackupStatValue(imageOriginalCount, imageOriginalSize)
},
{
label: '자동생성',
value: formatBackupStatValue(derivatives.count, derivatives.sizeBytes),
child: true
},
{
label: '프로필',
value: formatBackupStatValue(profiles.count, profiles.sizeBytes),
child: true
}
],
mainRows,
totalRow: {
label: '합계',
value: formatBackupStatValue(totalCount, totalSize)
}
}
})
</script>
<template>
<section
id="admin-settings-section-backup-summary"
class="admin-backup-summary-card admin-settings-screen__card admin-settings-screen__card--backup-summary rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-[#15171a]">
사이트 용량
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
콘텐츠 수량과 업로드 파일 용량을 한눈에 확인합니다.
</p>
</div>
<button
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8] disabled:cursor-wait disabled:opacity-60"
type="button"
:disabled="pending"
@click="emit('refresh')"
>
{{ pending ? '집계 중' : '새로고침' }}
</button>
</div>
<div class="mt-5 flex flex-wrap items-center gap-2 text-xs text-[#657080]">
<span class="rounded-full bg-[#f4f6f8] px-3 py-1">
기준 {{ formatBackupGeneratedAt(summary?.generatedAt) }}
</span>
</div>
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
<div class="flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">콘텐츠</span>
<span class="text-xs text-[#657080]">DB 기준</span>
</div>
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
<div
v-for="metric in contentMetrics"
:key="`backup-content-${metric.label}`"
class="rounded-md border border-[#edf0f2] px-3 py-2"
:class="metric.span ? 'sm:col-span-2' : ''"
>
<div class="flex items-center justify-between gap-3">
<span>{{ metric.label }}</span>
<strong class="font-mono text-[#15171a]">{{ metric.value }}</strong>
</div>
<p
v-if="metric.helper"
class="mt-1 text-[11px] leading-relaxed text-[#657080]"
>
{{ metric.helper }}
</p>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
<div class="flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">파일·용량</span>
<span class="text-xs text-[#657080]">public/uploads 기준</span>
</div>
<div class="grid gap-2 text-xs text-[#657080]">
<div class="admin-backup-summary-card__image-group admin-settings-screen__backup-image-group grid gap-2">
<div
v-for="row in storageSections.imageRows"
:key="`backup-storage-image-${row.label}`"
class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2"
:class="row.child ? 'border-dashed' : ''"
>
<span>{{ row.label }}</span>
<strong class="font-mono text-[#15171a]">{{ row.value }}</strong>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div
v-for="row in storageSections.mainRows"
:key="`backup-storage-main-${row.label}`"
class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2"
>
<span>{{ row.label }}</span>
<strong class="font-mono text-[#15171a]">{{ row.value }}</strong>
</div>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#dce0e5] bg-[#fbfcfd] px-3 py-2">
<span class="font-bold text-[#15171a]">{{ storageSections.totalRow.label }}</span>
<strong class="font-mono font-bold text-[#15171a]">{{ storageSections.totalRow.value }}</strong>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,156 @@
<script setup>
import { DEFAULT_BRAND_COLOR, normalizeBrandColor } from '~/lib/brand-color.js'
const props = defineProps({
/** 브랜드 컬러 값 */
brandColor: {
type: String,
default: DEFAULT_BRAND_COLOR
},
/** 편집 모드 여부 */
editing: {
type: Boolean,
default: false
},
/** 저장 중 여부 */
saving: {
type: Boolean,
default: false
},
/** 변경 여부 */
hasChanges: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['begin', 'cancel', 'save', 'update:brandColor', 'normalize'])
const normalizedBrandColor = computed(() => normalizeBrandColor(props.brandColor || DEFAULT_BRAND_COLOR))
</script>
<template>
<section
id="admin-settings-section-brand"
class="admin-brand-settings-card admin-settings-screen__card admin-settings-screen__card--brand 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]"
>
사용자 화면의 포인트 컬러입니다. 활성 네비게이션, TOC, 댓글 버튼 등에 공통으로 적용됩니다.
</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 class="grid gap-5 border-t border-[#eceff2] pt-5">
<div
v-if="!editing"
class="admin-brand-settings-card__readonly admin-settings-screen__brand-readonly flex flex-col gap-4 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-3">
<span
class="size-12 shrink-0 rounded-full border border-black/10"
:style="{ backgroundColor: normalizedBrandColor }"
aria-hidden="true"
/>
<div>
<p class="text-sm font-bold text-[#15171a]">
포인트 컬러
</p>
<p class="mt-1 font-mono text-sm text-[#657080]">
{{ normalizedBrandColor }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-bold text-white"
:style="{ backgroundColor: normalizedBrandColor }"
>
활성 배지
</span>
<span
class="inline-flex border-b-2 px-1 py-1 text-xs font-bold"
:style="{ borderColor: normalizedBrandColor, color: normalizedBrandColor }"
>
TOC
</span>
</div>
</div>
<div v-else class="admin-brand-settings-card__edit admin-settings-screen__brand-edit grid gap-4 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-4">
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">브랜드 컬러</span>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
class="h-12 w-full rounded-md border border-[#dce0e5] bg-white px-2 py-1 sm:w-20"
type="color"
:value="normalizedBrandColor"
@input="emit('update:brandColor', $event.target.value)"
>
<input
:value="brandColor"
class="h-12 min-w-0 flex-1 rounded-md border border-[#dce0e5] bg-white px-3 font-mono text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
placeholder="#ff4f2e"
@input="emit('update:brandColor', $event.target.value)"
@blur="emit('normalize')"
>
</div>
</label>
<p class="text-sm leading-relaxed text-[#657080]">
3자리 또는 6자리 hex 컬러를 사용할 수 있습니다. 비워두거나 잘못된 값은 기본 오렌지 컬러로 되돌립니다.
</p>
<div class="flex flex-wrap items-center gap-3">
<span
class="inline-flex h-10 items-center rounded-full px-4 text-sm font-bold text-white"
:style="{ backgroundColor: normalizedBrandColor }"
>
댓글 등록
</span>
<span
class="inline-flex h-10 items-center border-b-2 px-1 text-sm font-bold"
:style="{ borderColor: normalizedBrandColor, color: normalizedBrandColor }"
>
활성 TOC
</span>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
const { data, error, refresh } = await useFetch('/admin/api/comments', {
query: { pageSize: 5 },
default: () => ({ items: [], pagination: { total: 0 } })
})
let refreshTimer = null
onMounted(() => { refreshTimer = window.setInterval(() => refresh(), 30000) })
onUnmounted(() => { if (refreshTimer) window.clearInterval(refreshTimer) })
/**
* 최근 댓글의 작성 시각을 짧게 표시한다.
* @param {string} value - 작성 시각
* @returns {string} 한국어 날짜·시각
*/
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
}).format(new Date(value))
</script>
<template>
<section class="admin-comment-list border border-line bg-white px-4 py-3">
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ formatAnalyticsCount(data?.pagination.total) }}개</span></h2>
<p v-if="error" class="admin-comment-list__error text-xs text-red-700" role="alert">
댓글을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button>
</p>
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-1 text-xs text-muted">작성한 댓글이 없습니다.</p>
<ul v-else class="admin-comment-list__items divide-y divide-line">
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item grid min-w-0 grid-cols-2 items-center gap-x-3 gap-y-0.5 py-1.5 text-xs md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_7rem_8rem]">
<NuxtLink :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" v-if="['published', 'members'].includes(comment.postStatus)" target="_blank" rel="noopener noreferrer" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink v-else :to="`/admin/posts/${comment.postId}`" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<p class="admin-comment-list__body truncate text-muted" :title="comment.body">{{ comment.body }}</p>
<NuxtLink :to="`/admin/members/${comment.userId}`" class="admin-comment-list__author truncate text-muted hover:underline" :title="comment.username">{{ comment.username }}</NuxtLink>
<time :datetime="comment.createdAt" class="admin-comment-list__time whitespace-nowrap text-muted">{{ formatCreatedAt(comment.createdAt) }}</time>
</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,99 @@
<script setup>
defineProps({
open: {
type: Boolean,
default: false
},
title: {
type: String,
required: true
},
confirmLabel: {
type: String,
default: '확인'
},
cancelLabel: {
type: String,
default: '취소'
},
confirmTone: {
type: String,
default: 'primary'
},
loading: {
type: Boolean,
default: false
},
loadingLabel: {
type: String,
default: ''
}
})
defineEmits(['confirm', 'cancel'])
/**
* 확인 버튼 스타일 클래스를 반환한다.
* @param {string} tone - 버튼 톤
* @returns {string} 클래스 문자열
*/
const getConfirmButtonClass = (tone) => {
if (tone === 'danger') {
return 'bg-[#d21a26] text-white hover:bg-[#b81620]'
}
return 'bg-[#15171a] text-white hover:bg-[#2b2f35]'
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="admin-confirm-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/35 px-4 pt-10"
role="dialog"
aria-modal="true"
:aria-labelledby="'admin-confirm-modal-title'"
>
<section class="admin-confirm-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="admin-confirm-modal__header flex items-center justify-between border-b border-line px-6 py-5">
<h2 id="admin-confirm-modal-title" class="admin-confirm-modal__title text-xl font-semibold">
{{ title }}
</h2>
<button
class="admin-confirm-modal__close grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]"
type="button"
aria-label="닫기"
:disabled="loading"
@click="$emit('cancel')"
>
<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="admin-confirm-modal__body grid gap-4 px-6 py-5 text-sm leading-6 text-[#657080]">
<slot />
</div>
<footer class="admin-confirm-modal__footer flex justify-end gap-2 border-t border-line px-6 py-4">
<button
class="admin-confirm-modal__cancel h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]"
type="button"
:disabled="loading"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</button>
<button
class="admin-confirm-modal__confirm h-10 rounded-md px-4 text-sm font-semibold transition disabled:opacity-50"
:class="getConfirmButtonClass(confirmTone)"
type="button"
:disabled="loading"
@click="$emit('confirm')"
>
{{ loading && loadingLabel ? loadingLabel : confirmLabel }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,851 @@
<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',
'update-inline-color'
])
/** @type {number} 단일 이미지 직접 너비 최소값 */
const IMAGE_WIDTH_MIN = 120
/** @type {number} 단일 이미지 직접 너비 최대값 */
const IMAGE_WIDTH_MAX = 1600
/** @type {Array<{ label: string, value: string, pixelWidth: number|null }>} 단일 이미지 대표 너비 옵션 */
const IMAGE_WIDTH_PRESET_OPTIONS = [
{ label: '전체 너비(자동)', value: 'auto', pixelWidth: null },
{ label: '갤러리 2장 1칸(356px)', value: '356', pixelWidth: 356 },
{ label: '갤러리 3장 1칸(235px)', value: '235', pixelWidth: 235 },
{ label: '갤러리 3장 2칸(477px)', value: '477', pixelWidth: 477 },
{ label: '직접 입력', value: 'custom', pixelWidth: null }
]
/** @type {import('vue').Ref<Set<string>>} 직접 입력으로 열어둔 이미지 너비 상태 */
const customImageWidthKeys = ref(new Set())
/** @type {import('vue').Ref<Record<string, string>>} 직접 너비 입력 중인 원본 문자열 */
const imageWidthDraftValues = ref({})
const backgroundLabels = {
gray: '회색',
blue: '파랑',
green: '초록',
yellow: '노랑',
red: '빨강',
purple: '보라'
}
const backgroundSwatches = {
...CALLOUT_BACKGROUND_SWATCHES
}
const inlineColorSwatches = {
gray: '#6b7280',
blue: '#0055ff',
green: '#16ae68',
yellow: '#a16207',
red: '#e60000',
purple: '#8800ff'
}
/** @type {Array<{ label: string, value: string }>} 코드 블록 언어 셀렉트 옵션 */
const CODE_LANGUAGE_OPTIONS = [
{ label: '일반 텍스트', value: '' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'HTML', value: 'html' },
{ label: 'CSS', value: 'css' },
{ label: 'Vue', value: 'vue' },
{ label: 'JSON', value: 'json' },
{ label: 'Bash', value: 'bash' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'SQL', value: 'sql' }
]
/** @type {number|null} 패널 내부 포인터 조작 상태 해제 타이머 */
let panelPointerInsideTimer = null
/** @type {boolean} 패널 내부 포인터 조작 중 여부 */
let isPanelPointerInside = false
/**
* 배경 라벨을 반환한다.
* @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
/**
* 인라인 색상 스와치를 반환한다.
* @param {string} color - 색상 키
* @returns {string} CSS 색상
*/
const getInlineColorSwatch = (color) => inlineColorSwatches[color] || inlineColorSwatches.gray
/**
* 블록 종류 라벨
* @returns {string}
*/
const panelTitle = computed(() => {
if (!props.panel) {
return ''
}
if (props.panel.kind === 'gallery') {
return '갤러리'
}
if (props.panel.kind === 'inline-color') {
return '텍스트 색상'
}
if (props.panel.kind === 'html') {
return 'HTML'
}
if (props.panel.kind === 'link') {
return '링크'
}
if (props.panel.kind === 'iframe') {
return 'iframe'
}
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 === 'inline-color') {
return '선택한 텍스트'
}
if (props.panel.kind === 'html') {
return '사용자 HTML'
}
if (props.panel.kind === 'link') {
return 'URL 카드'
}
if (props.panel.kind === 'iframe') {
return 'sandbox iframe'
}
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 {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 입력 표시 값
*/
const getImagePixelWidthInputValue = (imageIndex, image) => {
const draftKey = getImageWidthStateKey(imageIndex, image)
if (Object.prototype.hasOwnProperty.call(imageWidthDraftValues.value, draftKey)) {
return imageWidthDraftValues.value[draftKey]
}
const width = Math.round(Number(image?.pixelWidth) || 0)
return width ? String(width) : ''
}
/**
* 이미지 너비 UI 상태 키를 반환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 상태 키
*/
const getImageWidthStateKey = (imageIndex, image) => `${imageIndex}:${String(image?.url || '')}`
/**
* 이미지 너비 직접 입력 UI 상태를 저장한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {boolean} enabled - 직접 입력 여부
* @returns {void}
*/
const setImageWidthCustomState = (imageIndex, image, enabled) => {
const nextKeys = new Set(customImageWidthKeys.value)
const key = getImageWidthStateKey(imageIndex, image)
if (enabled) {
nextKeys.add(key)
} else {
nextKeys.delete(key)
}
customImageWidthKeys.value = nextKeys
}
/**
* 이미지 너비 입력 임시 값을 저장한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {string} value - 입력 문자열
* @returns {void}
*/
const setImageWidthDraftValue = (imageIndex, image, value) => {
imageWidthDraftValues.value = {
...imageWidthDraftValues.value,
[getImageWidthStateKey(imageIndex, image)]: value
}
}
/**
* 이미지 너비 입력 임시 값을 지운다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {void}
*/
const clearImageWidthDraftValue = (imageIndex, image) => {
const key = getImageWidthStateKey(imageIndex, image)
const nextValues = { ...imageWidthDraftValues.value }
delete nextValues[key]
imageWidthDraftValues.value = nextValues
}
/**
* 직접 입력 너비를 허용 범위로 정리한다.
* @param {unknown} value - 입력 너비
* @returns {number|null} 정리된 너비
*/
const normalizeImageWidthInput = (value) => {
const width = Math.round(Number(value) || 0)
if (!width) {
return null
}
return Math.min(Math.max(width, IMAGE_WIDTH_MIN), IMAGE_WIDTH_MAX)
}
/**
* 이미지 너비 대표 옵션 값을 반환한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @returns {string} 셀렉트 값
*/
const getImageWidthPresetValue = (imageIndex, image) => {
const width = Math.round(Number(image?.pixelWidth) || 0)
if (customImageWidthKeys.value.has(getImageWidthStateKey(imageIndex, image))) {
return 'custom'
}
if (!width) {
return 'auto'
}
return IMAGE_WIDTH_PRESET_OPTIONS.some((option) => option.pixelWidth === width)
? String(width)
: 'custom'
}
/**
* 이미지 너비 대표 옵션 변경을 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 변경 이벤트
* @returns {void}
*/
const emitImageWidthPresetChange = (imageIndex, image, event) => {
const value = String(event.target?.value || 'auto')
const preset = IMAGE_WIDTH_PRESET_OPTIONS.find((option) => option.value === value)
if (value === 'custom') {
setImageWidthCustomState(imageIndex, image, true)
setImageWidthDraftValue(imageIndex, image, String(Math.round(Number(image?.pixelWidth) || 0) || 356))
emit('update-media-image', imageIndex, {
pixelWidth: Math.round(Number(image?.pixelWidth) || 0) || 356
})
return
}
setImageWidthCustomState(imageIndex, image, false)
clearImageWidthDraftValue(imageIndex, image)
emit('update-media-image', imageIndex, {
pixelWidth: preset?.pixelWidth || null
})
}
/**
* 이미지 너비 입력 변경을 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const emitImagePixelWidthChange = (imageIndex, image, event) => {
const raw = String(event.target?.value || '').trim()
const width = Math.round(Number(raw) || 0)
setImageWidthCustomState(imageIndex, image, true)
setImageWidthDraftValue(imageIndex, image, raw)
if (!raw) {
emit('update-media-image', imageIndex, {
pixelWidth: null
})
return
}
if (width < IMAGE_WIDTH_MIN || width > IMAGE_WIDTH_MAX) {
return
}
emit('update-media-image', imageIndex, {
pixelWidth: width
})
}
/**
* 이미지 너비 입력 종료 시 허용 범위로 정리해 전달한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Object} image - 이미지 정보
* @param {Event} event - 입력 이벤트
* @returns {void}
*/
const commitImagePixelWidthChange = (imageIndex, image, event) => {
const raw = String(event.target?.value || '').trim()
const pixelWidth = normalizeImageWidthInput(raw)
setImageWidthCustomState(imageIndex, image, true)
clearImageWidthDraftValue(imageIndex, image)
emit('update-media-image', imageIndex, {
pixelWidth
})
}
/**
* 패널 내부 포인터 조작 중에는 포커스 이탈로 패널이 닫히지 않게 유지한다.
* @returns {void}
*/
const markPanelPointerInside = () => {
window.clearTimeout(panelPointerInsideTimer)
isPanelPointerInside = true
emit('panel-focus-in')
panelPointerInsideTimer = window.setTimeout(() => {
isPanelPointerInside = false
}, 300)
}
/**
* 포커스가 패널 밖으로 나갔을 때만 이탈 이벤트를 보낸다.
* @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
}
if (isPanelPointerInside) {
return
}
emit('panel-focus-out')
}
onBeforeUnmount(() => {
window.clearTimeout(panelPointerInsideTimer)
})
</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"
@pointerdown.capture="markPanelPointerInside"
@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 === 'inline-color'">
<div class="admin-editor-block-panel__inline-color-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="color in QUOTE_BACKGROUND_OPTIONS"
:key="`inline-color-${color}`"
class="flex items-center gap-2 rounded border border-[#dce0e5] bg-[#fafafa] px-3 py-2 text-left text-xs font-semibold text-[#657080] transition hover:bg-white"
type="button"
@click="emit('update-inline-color', color)"
>
<span
class="size-5 shrink-0 rounded-full border border-black/5"
:style="{ background: getInlineColorSwatch(color) }"
aria-hidden="true"
/>
<span>{{ getQuoteBackgroundLabel(color) }}</span>
</button>
</div>
</div>
</template>
<template v-else-if="['html', 'link', 'iframe'].includes(panel.kind)">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">
{{ panel.kind === 'link' ? 'URL' : panel.kind === 'iframe' ? 'iframe 코드' : 'HTML 코드' }}
</span>
<textarea
class="min-h-28 resize-y rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 font-mono text-xs leading-5 text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
:value="panel.url"
:placeholder="panel.kind === 'link' ? 'https://example.com' : panel.kind === 'iframe' ? '<iframe src=&quot;https://...&quot;></iframe>' : '<table>...</table>'"
@input="emit('update-embed-url', $event.target.value)"
/>
</label>
<p v-if="panel.kind === 'html'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
본문 HTML은 그대로 표시하되 script, iframe, 이벤트 속성 같은 실행성 코드는 제거합니다. iframe은 /iframe 블록을 사용합니다.
</p>
<p v-else-if="panel.kind === 'link'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
YouTube·X(트위터)·Mastodon URL은 임베드로, 그 외 외부 URL은 링크 카드로 표시합니다.
</p>
<p v-else class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
YouTube·YouTube Shorts, X(트위터) 게시물 URL과 외부 서비스 iframe 코드를 지원합니다. iframe은 src 등 허용된 속성만 사용합니다.
</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="admin-editor-block-panel__callout-icon-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-editor-block-panel__callout-icon-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-editor-block-panel__callout-icon-toggle-label font-bold text-[#15171a]">
아이콘 표시
</span>
</span>
<span class="admin-editor-block-panel__callout-icon-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
class="peer sr-only"
type="checkbox"
aria-label="아이콘 표시"
:checked="panel.calloutEmojiEnabled"
@change="emit('update-callout-options', { calloutEmojiEnabled: $event.target.checked })"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<div
class="admin-editor-block-panel__callout-icon-options grid gap-2 transition-opacity"
:class="panel.calloutEmojiEnabled ? '' : 'opacity-45'"
>
<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:cursor-not-allowed"
: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.calloutEmojiEnabled && panel.calloutEmoji === emoji ? 'border-[#15171a] bg-white' : panel.calloutEmojiEnabled ? 'border-[#dce0e5] bg-[#fafafa] hover:bg-white' : 'cursor-not-allowed border-[#dce0e5] bg-[#f4f5f6] text-[#a4adb8]'"
type="button"
:disabled="!panel.calloutEmojiEnabled"
@mousedown.prevent
@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-5">
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
<span class="font-semibold text-[#394047]">언어</span>
<AdminSelectInput
:model-value="panel.language"
:options="CODE_LANGUAGE_OPTIONS"
label="코드 블록 언어"
root-class="admin-editor-block-panel__code-language-select"
width-class="w-full"
select-class="bg-[#eff1f2] border-[#d7dde2] text-[#15171a]"
@update:model-value="emit('update-code-options', { language: $event })"
/>
</label>
<label class="admin-editor-block-panel__code-line-number-toggle flex cursor-pointer items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-editor-block-panel__code-line-number-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-editor-block-panel__code-line-number-toggle-label font-bold text-[#15171a]">
줄번호 표시
</span>
</span>
<span class="admin-editor-block-panel__code-line-number-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
class="peer sr-only"
type="checkbox"
aria-label="줄번호 표시"
:checked="panel.showLineNumbers"
@change="emit('update-code-options', { showLineNumbers: $event.target.checked })"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</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>
<label
v-if="panel.kind === 'image'"
class="grid gap-1 text-xs font-semibold text-[#394047]"
>
표시 너비
<span class="relative block">
<select
class="w-full appearance-none rounded border border-[#d7dde2] bg-white px-3 py-2 pr-9 text-sm font-normal text-[#15171a] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
:value="getImageWidthPresetValue(imageIndex, image)"
@change="emitImageWidthPresetChange(imageIndex, image, $event)"
>
<option
v-for="option in IMAGE_WIDTH_PRESET_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
</label>
<label
v-if="panel.kind === 'image' && getImageWidthPresetValue(imageIndex, image) === 'custom'"
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="getImagePixelWidthInputValue(imageIndex, image)"
type="number"
inputmode="numeric"
:min="IMAGE_WIDTH_MIN"
:max="IMAGE_WIDTH_MAX"
step="10"
placeholder="자동"
@input="emitImagePixelWidthChange(imageIndex, image, $event)"
@blur="commitImagePixelWidthChange(imageIndex, image, $event)"
>
</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>

View File

@@ -0,0 +1,58 @@
<script setup>
const props = defineProps({
/** 현재 선택 값 */
modelValue: {
type: [String, Number, Boolean],
default: ''
},
/** 필터 옵션 목록 */
options: {
type: Array,
default: () => []
},
/** 필터 그룹 접근성 라벨 */
label: {
type: String,
default: '필터'
},
/** 그룹 루트에 추가할 화면별 클래스 */
rootClass: {
type: String,
default: ''
},
/** 버튼에 추가할 화면별 클래스 */
buttonClass: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
/**
* 필터 옵션이 선택 상태인지 확인한다.
* @param {Object} option - 필터 옵션
* @returns {boolean} 선택 상태
*/
const isSelected = (option) => option.value === props.modelValue
</script>
<template>
<div class="admin-filter-pill-group flex flex-wrap gap-1.5" :class="rootClass" role="group" :aria-label="label">
<button
v-for="option in options"
:key="String(option.value)"
class="admin-filter-pill-group__button rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="[
isSelected(option) ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink',
buttonClass
]"
type="button"
:aria-pressed="isSelected(option)"
@click="emit('update:modelValue', option.value)"
>
{{ option.label }}
<span v-if="option.count !== undefined" class="ml-1 opacity-70">{{ option.count }}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,331 @@
<script setup>
const homeCoverInputRef = ref(null)
const homeCoverDarkInputRef = ref(null)
/**
* 메인 화면 커버 설정 카드
* @property {Object} form - 사이트 설정 폼 객체
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} uploadingLight - 라이트 커버 업로드 중 여부
* @property {boolean} uploadingDark - 다크 커버 업로드 중 여부
* @property {boolean} hasChanges - 변경 여부
*/
defineProps({
form: {
type: Object,
required: true
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
uploadingLight: {
type: Boolean,
default: false
},
uploadingDark: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
}
})
defineEmits(['begin', 'cancel', 'save', 'upload-cover', 'drop-cover', 'clear-cover'])
/**
* 메인 화면 커버 파일 선택 창을 연다.
* @param {'light'|'dark'} variant - 커버 이미지 종류
* @returns {void}
*/
const openHomeCoverFilePicker = (variant = 'light') => {
if (variant === 'dark') {
homeCoverDarkInputRef.value?.click()
return
}
homeCoverInputRef.value?.click()
}
</script>
<template>
<section
id="admin-settings-section-home-cover"
class="admin-home-cover-settings-card admin-settings-screen__card admin-settings-screen__card--home-cover 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]"
>
홈 상단에 720px 너비 커버 이미지를 표시합니다. 라이트·다크 이미지를 각각 등록할 수 있고, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
</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-home-cover-settings-card__readonly admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div class="grid min-w-0 gap-6">
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-bold text-[#15171a]">
라이트모드
</h3>
</div>
<div
v-if="form.homeCoverImageUrl"
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<HomeHero
class="!max-w-full"
:image-url="form.homeCoverImageUrl"
:dark-image-url="''"
:title="form.homeCoverTitle"
:text="form.homeCoverText"
/>
</div>
<div
v-else
class="admin-settings-screen__home-cover-empty grid aspect-[720/215] w-full max-w-full place-items-center rounded-lg border border-dashed border-[#cfd6de] bg-[#f7f8fa] px-4 text-center text-sm text-[#657080]"
>
라이트모드 이미지가 없습니다.
</div>
</div>
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-bold text-[#15171a]">
다크모드
</h3>
</div>
<div
v-if="form.homeCoverDarkImageUrl"
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<HomeHero
class="!max-w-full"
:image-url="form.homeCoverDarkImageUrl"
:dark-image-url="''"
:title="form.homeCoverTitle"
:text="form.homeCoverText"
/>
</div>
<div
v-else
class="admin-settings-screen__home-cover-empty grid aspect-[720/215] w-full max-w-full place-items-center rounded-lg border border-dashed border-[#cfd6de] bg-[#f7f8fa] px-4 text-center text-sm text-[#657080]"
>
다크모드 전용 이미지가 없습니다. 공개 화면에서는 라이트모드 이미지를 대신 사용합니다.
</div>
</div>
</div>
</div>
<div v-else class="admin-home-cover-settings-card__edit admin-settings-screen__home-cover-edit grid min-w-0 gap-6 border-t border-[#eceff2] pt-5">
<div class="grid min-w-0 gap-6">
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h3 class="text-sm font-bold text-[#15171a]">
라이트모드
</h3>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
기본 헤더 이미지입니다.
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<button
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingLight"
@click="openHomeCoverFilePicker('light')"
>
{{ uploadingLight ? '업로드 중' : '이미지 변경' }}
</button>
<button
v-if="form.homeCoverImageUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingLight"
@click="$emit('clear-cover', 'light')"
>
삭제
</button>
</div>
</div>
<div
v-if="form.homeCoverImageUrl"
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<HomeHero
class="!max-w-full"
:image-url="form.homeCoverImageUrl"
:dark-image-url="''"
:title="form.homeCoverTitle"
:text="form.homeCoverText"
/>
</div>
<button
v-else
class="admin-settings-screen__home-cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
:disabled="uploadingLight"
@click="openHomeCoverFilePicker('light')"
@dragover.prevent
@drop.prevent="$emit('drop-cover', $event, 'light')"
>
<span class="grid place-items-center gap-2 text-sm font-semibold text-[#657080]">
<svg class="size-6 text-[#8a94a3]" xmlns="http://www.w3.org/2000/svg" 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 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
</svg>
<span>라이트모드 이미지를 드롭하거나 선택하세요.</span>
</span>
</button>
<input
ref="homeCoverInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingLight"
@change="$emit('upload-cover', $event, 'light')"
>
</div>
<div class="admin-settings-screen__home-cover-mode grid min-w-0 gap-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h3 class="text-sm font-bold text-[#15171a]">
다크모드
</h3>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
다크모드에서 교체되는 이미지입니다.
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<button
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingDark"
@click="openHomeCoverFilePicker('dark')"
>
{{ uploadingDark ? '업로드 중' : '이미지 변경' }}
</button>
<button
v-if="form.homeCoverDarkImageUrl"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingDark"
@click="$emit('clear-cover', 'dark')"
>
삭제
</button>
</div>
</div>
<div
v-if="form.homeCoverDarkImageUrl"
class="admin-settings-screen__home-cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<HomeHero
class="!max-w-full"
:image-url="form.homeCoverDarkImageUrl"
:dark-image-url="''"
:title="form.homeCoverTitle"
:text="form.homeCoverText"
/>
</div>
<button
v-else
class="admin-settings-screen__home-cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
:disabled="uploadingDark"
@click="openHomeCoverFilePicker('dark')"
@dragover.prevent
@drop.prevent="$emit('drop-cover', $event, 'dark')"
>
<span class="grid place-items-center gap-2 text-sm font-semibold text-[#657080]">
<svg class="size-6 text-[#8a94a3]" xmlns="http://www.w3.org/2000/svg" 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 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
</svg>
<span>다크모드 이미지를 드롭하거나 선택하세요.</span>
</span>
</button>
<input
ref="homeCoverDarkInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingDark"
@change="$emit('upload-cover', $event, 'dark')"
>
</div>
</div>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">오버레이 제목</span>
<input
v-model="form.homeCoverTitle"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
maxlength="120"
placeholder="선택 사항"
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">오버레이 본문</span>
<textarea
v-model="form.homeCoverText"
class="min-h-[4.5rem] resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
maxlength="280"
rows="3"
placeholder="짧은 소개 문구 (선택 사항)"
/>
</label>
</div>
</section>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,362 @@
<script setup>
import {
getMediaItemKind,
getMediaPickerPreviewUrl
} from '../../lib/markdown-editor-media.js'
const props = defineProps({
title: {
type: String,
required: true
},
activeTab: {
type: String,
required: true
},
searchInput: {
type: String,
default: ''
},
mediaItems: {
type: Array,
default: () => []
},
selectedUrls: {
type: Array,
default: () => []
},
isLoading: {
type: Boolean,
default: false
},
isUploading: {
type: Boolean,
default: false
},
accept: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: false
},
uploadHint: {
type: String,
default: ''
},
emptyMessage: {
type: String,
default: ''
},
rangeLabel: {
type: String,
default: ''
},
page: {
type: Number,
default: 1
},
pageCount: {
type: Number,
default: 1
},
pageItems: {
type: Array,
default: () => []
},
applyState: {
type: Object,
default: () => ({
disabled: true,
label: '삽입'
})
},
getSelectionOrder: {
type: Function,
required: true
}
})
const emit = defineEmits([
'apply',
'close',
'drop',
'image-error',
'page',
'search-composition-end',
'search-input',
'tab',
'toggle',
'upload'
])
/**
* 탭을 변경한다.
* @param {string} tab - 변경할 탭
* @returns {void}
*/
const selectTab = (tab) => {
emit('tab', tab)
}
/**
* 파일 입력 변경을 부모로 전달한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {void}
*/
const handleFileChange = (event) => {
emit('upload', event.target.files)
event.target.value = ''
}
</script>
<template>
<div class="admin-markdown-media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="emit('close')">
<div class="admin-markdown-media-picker__panel flex h-[min(86vh,760px)] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
<header class="admin-markdown-media-picker__header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
<div>
<h2 class="admin-markdown-media-picker__title text-lg font-bold text-black">
{{ title }}
</h2>
</div>
<button
class="admin-markdown-media-picker__close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="emit('close')"
>
닫기
</button>
</header>
<div class="admin-markdown-media-picker__tabs flex items-center gap-3 border-b border-[#e3e6e8] px-5">
<div class="flex min-w-0 flex-1 items-center">
<button
class="admin-markdown-media-picker__tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="selectTab('upload')"
>
업로드
</button>
<button
class="admin-markdown-media-picker__tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
type="button"
@click="selectTab('library')"
>
미디어 라이브러리
</button>
</div>
<label v-if="activeTab === 'library'" class="admin-markdown-media-picker__search mb-px flex w-full max-w-xs shrink-0 items-center py-2">
<span class="sr-only">파일명 검색</span>
<input
:value="searchInput"
class="admin-markdown-media-picker__search-input w-full rounded border border-[#d7dde2] bg-white px-3 py-1.5 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]/20"
type="search"
placeholder="파일명 검색"
autocomplete="off"
@input="emit('search-input', $event)"
@compositionupdate="emit('search-input', $event)"
@compositionend="emit('search-composition-end', $event)"
>
</label>
</div>
<div class="admin-markdown-media-picker__body min-h-0 flex-1 overflow-y-auto p-5">
<template v-if="activeTab === 'library'">
<div v-if="isLoading" class="admin-markdown-media-picker__loading py-12 text-center text-sm text-[#8e9cac]">
불러오는 중
</div>
<div v-else-if="mediaItems.length" class="admin-markdown-media-picker__grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-markdown-media-picker__item group relative overflow-hidden rounded border bg-white text-left transition"
:class="selectedUrls.includes(item.url) ? 'border-[3px] border-[#ff7a00] ring-2 ring-[#ff7a00]/40' : 'border border-[#e3e6e8] hover:border-[#8e9cac]'"
type="button"
@click="emit('toggle', item)"
>
<span
v-if="props.getSelectionOrder(item.url)"
class="admin-markdown-media-picker__selection-order absolute right-2 top-2 z-10 grid size-7 place-items-center rounded-full bg-[#ff7a00] text-xs font-bold text-white shadow-sm ring-2 ring-white"
>
{{ props.getSelectionOrder(item.url) }}
</span>
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-markdown-media-picker__thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
:src="getMediaPickerPreviewUrl(item)"
:alt="item.name || ''"
loading="lazy"
decoding="async"
@error="emit('image-error', { event: $event, item })"
>
<span
v-else
class="admin-markdown-media-picker__thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
>
{{ getMediaItemKind(item) }}
</span>
<span class="admin-markdown-media-picker__name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
{{ item.name || item.url }}
</span>
</button>
</div>
<div v-else class="admin-markdown-media-picker__empty py-12 text-center text-sm text-[#8e9cac]">
{{ emptyMessage }}
</div>
</template>
<div
v-else
class="admin-markdown-media-picker__upload-zone relative grid h-full min-h-[420px] place-items-center overflow-hidden rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
:class="isUploading ? 'admin-markdown-media-picker__upload-zone--uploading select-none border-[#8e9cac] bg-[#f3f5f7]' : ''"
@dragover.prevent
@drop.prevent="emit('drop', $event)"
>
<div class="admin-markdown-media-picker__upload-inner grid gap-3 px-6">
<p class="admin-markdown-media-picker__upload-title text-lg font-semibold text-[#15171a]">
{{ isUploading ? '업로드 중입니다' : '파일을 끌어 업로드' }}
</p>
<p class="admin-markdown-media-picker__upload-or text-sm text-[#6b7280]">
{{ isUploading ? '잠시만 기다려 주세요.' : '또는' }}
</p>
<label
class="admin-markdown-media-picker__upload-button mx-auto inline-flex h-10 items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50"
:class="isUploading ? 'cursor-not-allowed border-[#c8ced3] text-[#8e9cac] hover:bg-transparent' : 'cursor-pointer'"
>
{{ isUploading ? '업로드 중' : '파일 선택' }}
<input
class="sr-only"
type="file"
:accept="accept"
:multiple="multiple"
:disabled="isUploading"
@change="handleFileChange"
>
</label>
<p class="admin-markdown-media-picker__upload-hint text-xs text-[#8e9cac]">
{{ uploadHint }}
</p>
</div>
<div
v-if="isUploading"
class="admin-markdown-media-picker__upload-overlay pointer-events-none absolute inset-0 grid place-items-center bg-white/72 backdrop-blur-[1px]"
aria-live="polite"
>
<div class="admin-markdown-media-picker__upload-loading grid w-full max-w-sm gap-4 px-8 text-center">
<span class="admin-markdown-media-picker__upload-spinner mx-auto size-8 rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
<strong class="text-sm font-semibold text-[#15171a]">업로드 중입니다</strong>
<span class="admin-markdown-media-picker__upload-skeleton h-2 overflow-hidden rounded-full bg-[#e3e6e8]" aria-hidden="true" />
</div>
</div>
</div>
</div>
<footer v-if="activeTab === 'library'" class="admin-markdown-media-picker__footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
<div class="admin-markdown-media-picker__pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
<span>{{ rangeLabel }}</span>
<nav v-if="pageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
<button
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoading || page <= 1"
aria-label="이전 페이지"
@click="emit('page', page - 1)"
>
‹
</button>
<template v-for="item in pageItems" :key="item">
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
<button
v-else
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
:class="item === page ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
type="button"
:disabled="isLoading"
@click="emit('page', item)"
>
{{ item }}
</button>
</template>
<button
class="admin-markdown-media-picker__page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isLoading || page >= pageCount"
aria-label="다음 페이지"
@click="emit('page', page + 1)"
>
›
</button>
</nav>
</div>
<div class="admin-markdown-media-picker__actions flex items-center justify-end gap-2">
<button
class="admin-markdown-media-picker__cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="emit('close')"
>
취소
</button>
<button
class="admin-markdown-media-picker__apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="applyState.disabled"
@click="emit('apply')"
>
{{ applyState.label }}
</button>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
.admin-markdown-media-picker__upload-zone--uploading::before {
position: absolute;
inset: 0;
content: '';
background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.72) 45%, transparent 72%);
transform: translateX(-100%);
animation: admin-markdown-media-picker-upload-sheen 1.4s ease-in-out infinite;
}
.admin-markdown-media-picker__upload-spinner {
animation: admin-markdown-media-picker-upload-spin 0.8s linear infinite;
}
.admin-markdown-media-picker__upload-skeleton::before {
display: block;
width: 40%;
height: 100%;
content: '';
border-radius: inherit;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.86), transparent);
animation: admin-markdown-media-picker-upload-skeleton 1.15s ease-in-out infinite;
}
@keyframes admin-markdown-media-picker-upload-spin {
to {
transform: rotate(360deg);
}
}
@keyframes admin-markdown-media-picker-upload-sheen {
to {
transform: translateX(100%);
}
}
@keyframes admin-markdown-media-picker-upload-skeleton {
from {
transform: translateX(-120%);
}
to {
transform: translateX(260%);
}
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
defineProps({
activeMode: {
type: String,
required: true
},
teleportTo: {
type: String,
default: ''
}
})
const emit = defineEmits(['toggle'])
</script>
<template>
<Teleport v-if="teleportTo" :to="teleportTo">
<button
class="admin-markdown-editor__mode-toggle inline-flex size-8 shrink-0 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
type="button"
:aria-label="activeMode === 'write' ? '미리보기로 전환' : '작성 모드로 전환'"
@click="emit('toggle')"
>
<svg
v-if="activeMode === 'write'"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="admin-markdown-editor__mode-icon lucide-book-open"
aria-hidden="true"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="admin-markdown-editor__mode-icon lucide-edit-3"
aria-hidden="true"
>
<path d="M13 21h8" />
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
</svg>
</button>
</Teleport>
</template>

View File

@@ -0,0 +1,116 @@
<script setup>
const props = defineProps({
content: {
type: String,
default: ''
},
slashMenuActive: {
type: Boolean,
default: false
},
slashSuppressedLines: {
type: Array,
default: () => []
}
})
const emit = defineEmits([
'keydown',
'gallery-reorder',
'merge-images-to-gallery',
'insert-image-to-gallery',
'extract-gallery-image',
'remove-gallery-image',
'block-content-change',
'content-replace',
'append-paragraph',
'insert-after-line',
'delete-line',
'merge-with-previous-line',
'line-focus',
'line-blur',
'slash-update',
'slash-end',
'slash-apply'
])
const previewRef = ref(null)
const rendererRef = ref(null)
/**
* 미리보기 표면 DOM을 반환한다.
* @returns {HTMLElement|null} 미리보기 DOM
*/
const getPreviewElement = () => previewRef.value
/**
* 지정한 라이브 편집 줄에 포커스를 이동한다.
* @param {...unknown} args - ContentMarkdownRenderer 포커스 인자
* @returns {void}
*/
const focusEditableAtLine = (...args) => {
rendererRef.value?.focusEditableAtLine?.(...args)
}
/**
* 라이브 문서 전체를 선택한다.
* @returns {void}
*/
const selectAllLiveDocument = () => {
rendererRef.value?.selectAllLiveDocument?.()
}
/**
* 원본 줄 번호에 해당하는 라이브 편집 DOM을 반환한다.
* @param {number|string|null} sourceLine - 원본 줄 번호
* @returns {Element|null} 줄 DOM
*/
const getSourceLineElement = (sourceLine) => {
if (sourceLine === null || sourceLine === undefined) {
return null
}
return previewRef.value?.querySelector?.(`[data-source-line="${sourceLine}"]`) ?? null
}
defineExpose({
getPreviewElement,
focusEditableAtLine,
selectAllLiveDocument,
getSourceLineElement
})
</script>
<template>
<div
ref="previewRef"
class="admin-markdown-editor__preview min-h-[620px] px-0 py-5 text-[#15171a] outline-none"
style="--site-text: #15171a; --site-muted: #6b7280; --site-panel: #f6f7f8; --site-line: #e3e6e8; --site-accent: #2eb6ea;"
tabindex="0"
@keydown.capture="emit('keydown', $event)"
>
<ContentMarkdownRenderer
ref="rendererRef"
:content="props.content"
interactive
:slash-menu-active="props.slashMenuActive"
:slash-suppressed-lines="props.slashSuppressedLines"
@gallery-reorder="emit('gallery-reorder', $event)"
@merge-images-to-gallery="emit('merge-images-to-gallery', $event)"
@insert-image-to-gallery="emit('insert-image-to-gallery', $event)"
@extract-gallery-image="emit('extract-gallery-image', $event)"
@remove-gallery-image="emit('remove-gallery-image', $event)"
@block-content-change="emit('block-content-change', $event)"
@content-replace="emit('content-replace', $event)"
@append-paragraph="emit('append-paragraph', $event)"
@insert-after-line="emit('insert-after-line', $event)"
@delete-line="emit('delete-line', $event)"
@merge-with-previous-line="emit('merge-with-previous-line', $event)"
@line-focus="emit('line-focus', $event)"
@line-blur="emit('line-blur', $event)"
@slash-update="emit('slash-update', $event)"
@slash-end="emit('slash-end', $event)"
@slash-apply="emit('slash-apply', $event)"
/>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<script setup>
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
defineProps({
/** @type {import('vue').PropType<Array<{ id: string, label: string }>>} */
commands: {
type: Array,
default: () => []
},
highlightedIndex: {
type: Number,
default: 0
},
/** @type {import('vue').PropType<{ top: number, left: number }>} */
position: {
type: Object,
default: () => ({ top: 0, left: 0 })
}
})
const emit = defineEmits(['highlight', 'select'])
const rootRef = ref(null)
/**
* 부모 컴포넌트의 위치 보정·스크롤 계산에 사용할 메뉴 루트 엘리먼트를 반환한다.
* @returns {HTMLElement|null} 메뉴 루트 엘리먼트
*/
const getRootElement = () => rootRef.value
defineExpose({
getRootElement,
rootRef
})
</script>
<template>
<div
ref="rootRef"
class="admin-markdown-editor__slash-menu fixed z-[60] w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded-lg border border-[#e3e6e8] bg-white p-1 shadow-lg"
:style="{ top: `${position.top}px`, left: `${position.left}px` }"
role="listbox"
aria-label="슬래시 명령"
>
<button
v-for="(command, commandIndex) in commands"
:key="`live-slash-${command.id}`"
class="admin-markdown-editor__slash-item flex w-full items-center justify-between gap-3 rounded-md px-2.5 py-2 text-left transition-colors"
:class="commandIndex === highlightedIndex ? 'admin-markdown-editor__slash-item--active bg-[#f4f5f6]' : ''"
type="button"
role="option"
:aria-selected="commandIndex === highlightedIndex"
@mouseenter="emit('highlight', commandIndex)"
@mousedown.prevent="emit('select', command)"
>
<span class="admin-markdown-editor__slash-leading flex min-w-0 items-center gap-2.5">
<AdminSlashCommandIcon
:command-id="command.id"
class="admin-markdown-editor__slash-icon shrink-0 text-[#394047]"
/>
<span class="admin-markdown-editor__slash-label truncate text-sm text-[#15171a]">{{ command.label }}</span>
</span>
<span
class="admin-markdown-editor__slash-slug shrink-0 text-xs text-[#8b959e] transition-opacity"
:class="commandIndex === highlightedIndex ? 'opacity-100' : 'opacity-0'"
>/{{ command.id }}</span>
</button>
<p v-if="!commands.length" class="admin-markdown-editor__slash-empty px-4 py-3 text-sm text-[#6b7280]">
일치하는 명령이 없습니다.
</p>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
업로드 중
</div>
</template>

View File

@@ -0,0 +1,120 @@
<script setup>
import AdminMarkdownUploadingBadge from './AdminMarkdownUploadingBadge.vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
gutterLineCount: {
type: Number,
default: 1
},
getGutterLineHeight: {
type: Function,
required: true
},
isUploading: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'update:modelValue',
'keydown',
'paste',
'drop',
'input',
'compositionstart',
'compositionend',
'click',
'keyup',
'select',
'focus',
'blur'
])
const textareaRef = ref(null)
const gutterRef = ref(null)
/**
* textarea 입력 값을 부모 모델에 반영하고 입력 이벤트를 전달한다.
* @param {Event} event - textarea 입력 이벤트
* @returns {void}
*/
const onInput = (event) => {
emit('update:modelValue', event.target?.value || '')
emit('input', event)
}
/**
* 작성 textarea DOM을 반환한다.
* @returns {HTMLTextAreaElement|null} textarea DOM
*/
const getTextareaElement = () => textareaRef.value
/**
* 줄 번호 gutter DOM을 반환한다.
* @returns {HTMLElement|null} gutter DOM
*/
const getGutterElement = () => gutterRef.value
defineExpose({
getTextareaElement,
getGutterElement
})
</script>
<template>
<div class="admin-markdown-editor__write relative">
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
<div class="admin-markdown-editor__write-columns flex items-start">
<div
ref="gutterRef"
class="admin-markdown-editor__gutter w-10 shrink-0 select-none py-5 pr-2 font-mono text-[13px] leading-7 text-[#a0a8b0]"
aria-hidden="true"
>
<div
v-for="ln in props.gutterLineCount"
:key="`gutter-line-${ln}`"
class="admin-markdown-editor__gutter-line text-right tabular-nums"
:style="{ height: `${props.getGutterLineHeight(ln - 1)}px` }"
>
{{ ln }}
</div>
</div>
<textarea
ref="textareaRef"
:value="modelValue"
class="admin-markdown-editor__textarea min-h-[620px] min-w-0 flex-1 resize-none overflow-hidden break-words border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
placeholder="마크다운으로 글을 작성하세요."
spellcheck="false"
@keydown="emit('keydown', $event)"
@paste="emit('paste', $event)"
@drop="emit('drop', $event)"
@dragover.prevent
@input="onInput"
@compositionstart="emit('compositionstart', $event)"
@compositionend="emit('compositionend', $event)"
@click="emit('click', $event)"
@keyup="emit('keyup', $event)"
@select="emit('select', $event)"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
/>
</div>
</div>
<AdminMarkdownUploadingBadge v-if="isUploading" />
</div>
</template>
<style scoped>
.admin-markdown-editor__gutter {
scrollbar-width: none;
}
.admin-markdown-editor__gutter::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,424 @@
<script setup>
const props = defineProps({
canEditMediaCategory: {
type: Function,
required: true
},
canRegeneratePageCoverThumbnail: {
type: Function,
required: true
},
canRegeneratePostBodyThumbnail: {
type: Function,
required: true
},
canRegeneratePostThumbnail: {
type: Function,
required: true
},
canRenameMediaItem: {
type: Function,
required: true
},
deletingUrl: {
type: String,
default: ''
},
editingCategory: {
type: String,
default: ''
},
editingName: {
type: String,
default: ''
},
folders: {
type: Array,
default: () => []
},
getMediaBadgeLabel: {
type: Function,
required: true
},
getMediaItemKind: {
type: Function,
required: true
},
getMediaPreviewUrl: {
type: Function,
required: true
},
getPostBodyThumbnailResolutionLabel: {
type: Function,
required: true
},
isMediaItemLocked: {
type: Function,
required: true
},
isPageCoverThumbnailDiskItem: {
type: Function,
required: true
},
isPostBodyThumbnailDiskItem: {
type: Function,
required: true
},
isPostCardThumbnailDiskItem: {
type: Function,
required: true
},
item: {
type: Object,
required: true
},
mediaThumbnailRoot: {
type: String,
required: true
},
regeneratingBodyThumbnailUrl: {
type: String,
default: ''
},
regeneratingThumbnailUrl: {
type: String,
default: ''
}
})
const emit = defineEmits([
'close',
'delete',
'download',
'regenerate-body-thumbnail',
'regenerate-page-cover-thumbnail',
'regenerate-post-thumbnail',
'rename',
'save-category',
'update:editingCategory',
'update:editingName'
])
/**
* 파일 용량을 사람이 읽을 수 있는 문자열로 변환한다.
* @param {number} size - 바이트 단위 파일 크기
* @returns {string} 표시용 파일 크기
*/
const formatFileSize = (size) => {
if (!size) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB']
const exponent = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1)
const value = size / Math.pow(1024, exponent)
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`
}
/**
* ISO 시각을 짧은 로캘 문자열로 표시한다.
* @param {string | null} iso - ISO 시각
* @returns {string} 표시 문자열
*/
const formatDateTime = (iso) => {
if (!iso) {
return '—'
}
try {
return new Date(iso).toLocaleString('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
})
} catch {
return '—'
}
}
</script>
<template>
<div
class="admin-media-detail-modal admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
@click.self="emit('close')"
>
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
<img
v-if="props.getMediaItemKind(props.item) === 'image'"
class="admin-media__preview-image max-h-[72vh] max-w-full object-contain"
:src="props.getMediaPreviewUrl(props.item)"
:alt="props.item.title"
>
<video
v-else-if="props.getMediaItemKind(props.item) === 'video'"
class="admin-media__preview-image max-h-[72vh] max-w-full bg-black"
:src="props.item.url"
controls
preload="metadata"
/>
<audio
v-else-if="props.getMediaItemKind(props.item) === 'audio'"
class="w-full max-w-xl"
:src="props.item.url"
controls
preload="metadata"
/>
<span
v-else
class="rounded border border-line bg-white px-5 py-4 text-sm font-semibold text-muted"
>
{{ props.item.name }}
</span>
</div>
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
Attachment
</p>
<h2 class="admin-media__detail-title mt-1 break-all text-xl font-semibold">
{{ props.item.name }}
</h2>
</div>
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
<button
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
type="button"
@click="emit('download')"
>
다운로드
</button>
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="emit('close')">
닫기
</button>
</div>
</div>
<dl class="admin-media__info grid gap-3 text-sm">
<div class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">경로</dt>
<dd class="admin-media__info-value mt-1 break-all">{{ props.item.url }}</dd>
</div>
<div class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
<dd class="admin-media__info-value mt-1">{{ formatFileSize(props.item.size) }}</dd>
</div>
<div v-if="props.item.thumbnailStatus" class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">
{{ props.item.thumbnailStatus.variant === 'pageCover' || props.item.thumbnailStatus.role === 'pageCover' ? '페이지 커버 이미지' : '대표 이미지' }}
</dt>
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
<span v-if="props.item.thumbnailStatus.role === 'card'">
자동 생성된 목록 카드용 썸네일입니다.
</span>
<span v-else-if="props.item.thumbnailStatus.role === 'pageCover'">
자동 생성된 Pages 진열장용 커버 썸네일입니다.
</span>
<template v-else>
<span v-if="props.item.thumbnailStatus.hasThumbnail">
생성됨: {{ props.item.thumbnailStatus.thumbnailUrl }}
</span>
<span v-else-if="props.item.thumbnailStatus.isFallbackActive && props.item.thumbnailStatus.variant === 'pageCover'" class="font-semibold text-amber-700">
페이지 커버 썸네일 없음. 현재 Pages 진열장에서 원본 이미지를 불러옵니다.
</span>
<span v-else-if="props.item.thumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
썸네일 없음. 현재 목록에서 원본 이미지를 불러옵니다.
</span>
<span v-else-if="props.item.thumbnailStatus.variant === 'pageCover'">
페이지 커버 썸네일 없음. 홈 Pages 진열장에서 사용되면 원본으로 대체됩니다.
</span>
<span v-else>
썸네일 없음. 대표 이미지로 사용되면 원본으로 대체됩니다.
</span>
</template>
<span v-if="props.item.thumbnailStatus.originalUrl" class="text-muted">
원본: {{ props.item.thumbnailStatus.originalUrl }}
</span>
<button
v-if="props.canRegeneratePostThumbnail(props.item)"
class="admin-media__thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="props.regeneratingThumbnailUrl === props.item.url"
@click="emit('regenerate-post-thumbnail', props.item)"
>
{{ props.regeneratingThumbnailUrl === props.item.url ? '생성 중' : (props.item.thumbnailStatus.hasThumbnail ? '대표 이미지 다시 생성' : '대표 이미지 생성') }}
</button>
<button
v-if="props.canRegeneratePageCoverThumbnail(props.item)"
class="admin-media__page-cover-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="props.regeneratingThumbnailUrl === props.item.url"
@click="emit('regenerate-page-cover-thumbnail', props.item)"
>
{{ props.regeneratingThumbnailUrl === props.item.url ? '생성 중' : (props.item.thumbnailStatus.hasThumbnail ? '페이지 커버 썸네일 다시 생성' : '페이지 커버 썸네일 생성') }}
</button>
</dd>
</div>
<div v-if="props.item.bodyThumbnailStatus" class="admin-media__info-row">
<dt class="admin-media__info-label text-xs font-semibold text-muted">본문 이미지</dt>
<dd class="admin-media__info-value mt-1 grid gap-1 break-all">
<span v-if="props.item.bodyThumbnailStatus.role === 'body'">
자동 생성된 본문 표시용 WebP 이미지입니다. ({{ props.getPostBodyThumbnailResolutionLabel(props.item) }})
</span>
<template v-else>
<span v-if="props.item.bodyThumbnailStatus.hasThumbnail">
생성됨: {{ props.item.bodyThumbnailStatus.thumbnailUrl }}
</span>
<span v-else-if="props.item.bodyThumbnailStatus.isFallbackActive" class="font-semibold text-amber-700">
본문 이미지 없음. 현재 본문에서 원본 이미지를 불러옵니다.
</span>
<span v-else>
본문 이미지 없음. 게시물 본문에서 사용되면 원본으로 대체됩니다.
</span>
</template>
<span v-if="props.item.bodyThumbnailStatus.originalUrl" class="text-muted">
원본: {{ props.item.bodyThumbnailStatus.originalUrl }}
</span>
<button
v-if="props.canRegeneratePostBodyThumbnail(props.item)"
class="admin-media__body-thumbnail-regenerate mt-1 justify-self-start rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="props.regeneratingBodyThumbnailUrl === props.item.url"
@click="emit('regenerate-body-thumbnail', props.item)"
>
{{ props.regeneratingBodyThumbnailUrl === props.item.url ? '생성 중' : (props.item.bodyThumbnailStatus.hasThumbnail ? '본문 이미지 다시 생성' : '본문 이미지 생성') }}
</button>
</dd>
</div>
</dl>
<div class="admin-media__category grid gap-2">
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
폴더
</label>
<div class="admin-media__category-row flex gap-2">
<input
id="media-category"
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
type="text"
list="media-folder-options"
placeholder="미분류"
:disabled="!props.canEditMediaCategory(props.item)"
:value="props.editingCategory"
@input="emit('update:editingCategory', $event.target.value)"
@keydown.enter.prevent="emit('save-category')"
>
<button
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="!props.canEditMediaCategory(props.item)"
@click="emit('save-category')"
>
저장
</button>
</div>
<p v-if="props.item.avatarOwner" class="admin-media__category-hint text-xs text-muted">
프로필 썸네일의 논리 폴더는 「{{ props.mediaThumbnailRoot }}」로 고정됩니다.
</p>
<p v-else-if="props.isPostCardThumbnailDiskItem(props.item)" class="admin-media__category-hint text-xs text-muted">
대표 이미지 파일은 원본 이미지에 연결된 자동 생성 파일이라 폴더를 직접 바꾸지 않습니다.
</p>
<datalist id="media-folder-options">
<option v-for="folder in props.folders" :key="folder" :value="folder" />
</datalist>
</div>
<div
v-if="props.item.avatarOwner"
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
>
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
<dd class="mt-0.5 font-semibold text-ink">{{ props.item.avatarOwner.username }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
<dd class="mt-0.5 break-all text-ink">{{ props.item.avatarOwner.email }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">등급</dt>
<dd class="mt-0.5 text-ink">{{ props.getMediaBadgeLabel(props.item) }}</dd>
</div>
<div class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
<dd class="mt-0.5 text-ink">{{ formatDateTime(props.item.avatarOwner.lastSeenAt) }}</dd>
</div>
<div v-if="props.item.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ props.item.avatarOwner.lastSeenIp }}</dd>
</div>
</dl>
</div>
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
<strong class="admin-media__usage-title text-ink">
사용 현황 {{ props.item.usage.length }}곳
</strong>
<ul v-if="props.item.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
<li v-for="usage in props.item.usage" :key="`${props.item.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
<NuxtLink
v-if="usage.adminUrl"
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
:to="usage.adminUrl"
>
{{ usage.title }}
</NuxtLink>
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
</li>
</ul>
<p v-else class="admin-media__usage-empty mt-2 text-muted">
{{ props.item.avatarOwner ? '프로필 연결 외에 다른 사용처는 없습니다.' : '사용 중인 곳이 없습니다.' }}
</p>
</div>
<div class="admin-media__rename grid gap-2">
<label class="admin-media__rename-label text-xs font-semibold text-muted" for="media-name">
파일명
</label>
<input
id="media-name"
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
type="text"
:disabled="!props.canRenameMediaItem(props.item)"
:placeholder="props.item.title"
:value="props.editingName"
@input="emit('update:editingName', $event.target.value)"
@keydown.enter.prevent="emit('rename')"
>
<p v-if="props.isMediaItemLocked(props.item)" class="admin-media__locked text-xs text-muted">
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
</p>
<p v-else-if="props.isPostCardThumbnailDiskItem(props.item) || props.isPageCoverThumbnailDiskItem(props.item) || props.isPostBodyThumbnailDiskItem(props.item)" class="admin-media__locked text-xs text-muted">
자동 생성 이미지는 원본 이미지와의 연결을 유지하기 위해 파일명을 변경할 수 없습니다. 사용처가 없는 파생 파일만 정리용으로 삭제할 수 있습니다.
</p>
</div>
<div class="admin-media__actions flex flex-wrap gap-2">
<button
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!props.canRenameMediaItem(props.item) || !props.editingName"
@click="emit('rename')"
>
파일명 저장
</button>
<button
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="props.deletingUrl === props.item.url || props.isMediaItemLocked(props.item)"
@click="emit('delete', props.item)"
>
{{ props.deletingUrl === props.item.url ? '삭제 중' : '삭제' }}
</button>
</div>
</aside>
</section>
</div>
</template>

View File

@@ -0,0 +1,243 @@
<script setup>
const openMenuId = defineModel('openMenuId', {
type: String,
default: ''
})
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
const MEDIA_THUMBNAIL_ROOT = '썸네일'
const props = defineProps({
activeFolder: {
type: String,
default: ''
},
activeSystemMediaCount: {
type: Number,
default: 0
},
activeSystemMediaHint: {
type: String,
default: ''
},
activeSystemMediaTitle: {
type: String,
default: ''
},
folderMediaCounts: {
type: Object,
default: () => ({})
},
folders: {
type: Array,
default: () => []
},
libraryCount: {
type: Number,
default: 0
},
mode: {
type: String,
default: 'library'
},
deletingFolder: {
type: String,
default: ''
},
draggingCount: {
type: Number,
default: 0
},
busy: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'create-folder',
'drop-folder',
'remove-folder',
'select-folder'
])
const hoveredFolder = ref(null)
/**
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
* @param {string} folder - 폴더 경로
* @returns {boolean} 썸네일 전용이면 true
*/
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
/**
* 폴더 행 메뉴 id를 반환한다.
* @param {string} folder - 폴더 경로
* @returns {string} 행 메뉴 id
*/
const getFolderMenuId = (folder) => `folder:${folder}`
/**
* 폴더 경로의 표시 이름을 반환한다.
* @param {string} folder - 폴더 경로
* @returns {string} 표시 이름
*/
const getFolderName = (folder) => String(folder || '').split('/').filter(Boolean).pop() || '미분류'
/**
* 폴더 깊이를 반환한다.
* @param {string} folder - 폴더 경로
* @returns {number} 폴더 깊이
*/
const getFolderDepth = (folder) => Math.max(String(folder || '').split('/').filter(Boolean).length - 1, 0)
/**
* 내부 미디어 드래그만 폴더 이동 대상으로 허용한다.
* @param {DragEvent} event - 드래그 이벤트
* @param {string} folder - 대상 폴더
* @returns {void}
*/
const onFolderDragOver = (event, folder) => {
if (!props.draggingCount || props.busy || isThumbnailFolderPath(folder) || (props.deletingFolder && props.deletingFolder === folder)) {
return
}
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
hoveredFolder.value = folder
}
/**
* 폴더 내부 요소 간 이동을 제외하고 드롭 강조를 해제한다.
* @param {DragEvent} event - 드래그 이탈 이벤트
* @returns {void}
*/
const onFolderDragLeave = (event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
hoveredFolder.value = null
}
}
/**
* 유효한 내부 드래그에 한해 폴더 이동을 요청한다.
* @param {DragEvent} event - 드롭 이벤트
* @param {string} folder - 대상 폴더
* @returns {void}
*/
const onFolderDrop = (event, folder) => {
event.preventDefault()
hoveredFolder.value = null
if (props.draggingCount && !props.busy && !isThumbnailFolderPath(folder) && props.deletingFolder !== folder) {
emit('drop-folder', folder)
}
}
watch(() => props.draggingCount, (count) => {
if (!count) {
hoveredFolder.value = null
}
})
</script>
<template>
<aside
v-if="props.mode === 'library'"
class="admin-media-folder-panel admin-media__folders rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
:class="hoveredFolder === '' ? 'bg-sky-50 text-sky-900 ring-2 ring-inset ring-sky-500 hover:bg-sky-50' : !props.activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
type="button"
:disabled="props.busy"
@click="emit('select-folder', '')"
@dragenter="onFolderDragOver($event, '')"
@dragover="onFolderDragOver($event, '')"
@dragleave="onFolderDragLeave"
@drop="onFolderDrop($event, '미분류')"
>
<span>
전체 미디어
<span v-if="hoveredFolder === ''" class="admin-media__folder-drop-hint mt-1 block text-xs font-semibold">놓으면 {{ props.draggingCount }}개를 미분류로 이동</span>
</span>
<span>{{ props.libraryCount }}</span>
</button>
<div class="admin-media__folder-list mt-3 grid gap-1">
<div
v-for="folder in props.folders"
:key="folder"
class="admin-media__folder-row flex items-stretch rounded transition-colors"
:class="hoveredFolder === folder ? 'admin-media__folder-row--drop-target bg-sky-50 text-sky-900 ring-2 ring-inset ring-sky-500' : props.activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
@dragenter="onFolderDragOver($event, folder)"
@dragover="onFolderDragOver($event, folder)"
@dragleave="onFolderDragLeave"
@drop="onFolderDrop($event, folder)"
>
<button
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
type="button"
:disabled="props.busy"
:title="folder"
:aria-current="props.activeFolder === folder ? 'true' : undefined"
@click="emit('select-folder', folder)"
>
<span class="admin-media__folder-label min-w-0">
<span class="admin-media__folder-name block truncate">{{ getFolderName(folder) }}</span>
<span v-if="hoveredFolder === folder" class="admin-media__folder-drop-hint mt-1 block text-xs font-semibold">놓으면 {{ props.draggingCount }}개 이동</span>
</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ props.folderMediaCounts[folder] || 0 }}</span>
</button>
<AdminRowMoreMenu
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
v-model:open-menu-id="openMenuId"
:item-id="getFolderMenuId(folder)"
menu-label="폴더 메뉴"
size="sm"
class="mr-0.5 shrink-0"
:inverse="props.activeFolder === folder && hoveredFolder !== folder"
:busy="props.busy || props.deletingFolder === folder"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="props.busy || props.deletingFolder === folder"
@click="emit('remove-folder', folder)"
>
폴더 삭제
</button>
</AdminRowMoreMenu>
</div>
</div>
<div class="admin-media__folder-actions mt-4 border-t border-line pt-4">
<button
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
type="button"
:disabled="props.busy"
@click="emit('create-folder')"
>
폴더 추가
</button>
</div>
</aside>
<aside
v-else
class="admin-media-folder-panel admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
>
<button
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
type="button"
@click="emit('select-folder', '')"
>
<span>{{ props.activeSystemMediaTitle }}</span>
<span>{{ props.activeSystemMediaCount }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
{{ props.activeSystemMediaHint }}
</p>
</aside>
</template>

View File

@@ -0,0 +1,154 @@
<script setup>
const props = defineProps({
activeTab: {
type: String,
default: 'library'
},
getMediaBadgeClass: {
type: Function,
required: true
},
getMediaBadgeLabel: {
type: Function,
required: true
},
getMediaGridPreviewUrl: {
type: Function,
required: true
},
getMediaItemKind: {
type: Function,
required: true
},
getMediaUsageBadgeCount: {
type: Function,
required: true
},
items: {
type: Array,
default: () => []
},
selectedUrls: {
type: Array,
default: () => []
},
draggingUrls: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'drag-start',
'drag-end',
'image-error',
'open-detail',
'toggle-selection'
])
/**
* 미디어 선택 여부를 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 여부
*/
const isMediaSelected = (item) => props.selectedUrls.includes(item?.url)
</script>
<template>
<div class="admin-media-grid admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
<div
v-for="(item, index) in props.items"
:key="item.url"
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
:class="[
isMediaSelected(item) ? 'admin-media__item--selected' : '',
props.draggingUrls.includes(item.url) ? 'admin-media__item--dragging opacity-40' : '',
props.disabled ? 'opacity-60' : props.activeTab === 'library' ? 'cursor-grab active:cursor-grabbing' : ''
]"
:draggable="props.activeTab === 'library' && !props.disabled"
@dragstart="emit('drag-start', $event, item)"
@dragend="emit('drag-end')"
>
<span v-if="isMediaSelected(item)" class="admin-media__selection-outline pointer-events-none absolute inset-0 z-20 border-2 border-sky-600" aria-hidden="true" />
<button
type="button"
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
:class="isMediaSelected(item)
? 'border-white bg-[#15171a] text-white'
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
:aria-label="`${item.name} 선택`"
:aria-pressed="isMediaSelected(item)"
:disabled="props.disabled"
@click.stop="emit('toggle-selection', item, index, $event)"
>
<svg
v-if="isMediaSelected(item)"
class="size-4 shrink-0"
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="M20 6 9 17l-5-5" />
</svg>
</button>
<button
class="admin-media__thumb flex w-full flex-col text-left outline-none"
type="button"
:disabled="props.disabled"
@click="emit('open-detail', item)"
>
<span class="admin-media__thumb-frame relative block w-full overflow-hidden">
<img
v-if="props.getMediaItemKind(item) === 'image'"
class="admin-media__image aspect-square w-full bg-surface object-cover"
:src="props.getMediaGridPreviewUrl(item)"
:alt="item.title"
loading="lazy"
decoding="async"
:draggable="false"
@error="emit('image-error', $event, item)"
>
<AdminMediaVideoThumbnail
v-else-if="props.getMediaItemKind(item) === 'video'"
:src="item.url"
:alt="item.title"
/>
<span
v-else
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
>
{{ props.getMediaItemKind(item) }}
</span>
<span
v-if="props.getMediaUsageBadgeCount(item)"
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 z-[1] rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
{{ props.getMediaUsageBadgeCount(item) }}
</span>
<span
v-if="props.getMediaBadgeLabel(item)"
class="admin-media__usage-badge pointer-events-none absolute right-1.5 z-[1] rounded px-1.5 py-0.5 text-[10px] font-semibold"
:class="[
props.getMediaBadgeClass(item),
props.getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
]"
>
{{ props.getMediaBadgeLabel(item) }}
</span>
</span>
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
{{ item.name }}
</span>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,108 @@
<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"
:draggable="false"
>
<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,82 @@
<script setup>
import { formatAnalyticsCount } from '~/lib/analytics-display.js'
const props = defineProps({ memberId: { type: String, required: true } })
const searchInput = ref('')
const filters = ref({ q: '', type: 'all', page: 1 })
const query = computed(() => ({ ...filters.value, pageSize: 10 }))
const { data, status, error, refresh } = await useFetch(
() => `/admin/api/members/${props.memberId}/activity`,
{ query, default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } }) }
)
/**
* 검색어를 적용하고 첫 페이지를 조회한다.
* @returns {void}
*/
const applySearch = () => { filters.value = { ...filters.value, q: searchInput.value.trim(), page: 1 } }
/**
* 작성 종류를 바꾸고 첫 페이지에서 다시 조회한다.
* @param {Event} event - 종류 선택 이벤트
* @returns {void}
*/
const changeType = (event) => { filters.value = { q: searchInput.value.trim(), type: event.target.value, page: 1 } }
/**
* 통합 검색 결과에서 선택한 페이지를 조회한다.
* @param {number} page - 페이지 번호
* @returns {void}
*/
const changePage = (page) => { filters.value = { ...filters.value, page } }
/**
* 작성 시각을 짧은 한국어 날짜로 표시한다.
* @param {string} value - 작성 시각
* @returns {string} 날짜·시각
*/
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
}).format(new Date(value))
</script>
<template>
<section class="admin-member-activity border border-line bg-white p-4" :aria-busy="status === 'pending'">
<div class="admin-member-activity__toolbar flex flex-wrap items-center justify-between gap-3">
<h2 class="admin-member-activity__heading text-sm font-semibold">작성한 글 <span class="text-xs font-normal text-muted">{{ formatAnalyticsCount(data?.pagination.total) }}개 · 최신순</span></h2>
<form class="admin-member-activity__search flex w-full flex-wrap items-center gap-2 sm:w-auto" role="search" @submit.prevent="applySearch">
<select class="admin-member-activity__type h-10 rounded border border-line bg-white px-3 text-sm" aria-label="작성 종류" :value="filters.type" @change="changeType">
<option value="all">전체</option>
<option value="comment">댓글</option>
<option value="post">게시글</option>
</select>
<AdminSearchInput root-class="min-w-0 flex-1" width-class="w-full sm:w-64" :value="searchInput" label="작성한 글 검색" placeholder="게시글 제목·내용 검색" @input="searchInput = $event.target.value" />
<button type="submit" class="admin-member-activity__search-button h-10 shrink-0 rounded border border-line px-3 text-sm">검색</button>
</form>
</div>
<p v-if="error" class="admin-member-activity__error py-6 text-sm text-red-700" role="alert">목록을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button></p>
<p v-else-if="status === 'pending'" class="admin-member-activity__loading py-6 text-sm text-muted">목록을 불러오는 중입니다.</p>
<p v-else-if="!data?.items.length" class="admin-member-activity__empty py-6 text-sm text-muted">{{ filters.q ? '검색 결과가 없습니다.' : '작성한 글이 없습니다.' }}</p>
<div v-else class="admin-member-activity__table-wrap mt-3 overflow-x-auto">
<table class="admin-member-activity__table w-full min-w-[600px] table-fixed text-left text-xs">
<caption class="sr-only">회원이 작성한 게시글과 댓글 목록</caption>
<thead class="border-b border-line text-muted">
<tr>
<th scope="col" class="w-[32%] py-2 pr-3 font-medium">게시글</th>
<th scope="col" class="py-2 pr-3 font-medium">내용</th>
<th scope="col" class="w-44 py-2 font-medium">작성일</th>
</tr>
</thead>
<tbody class="divide-y divide-line">
<tr v-for="item in data.items" :key="`${item.kind}-${item.id}`" class="admin-member-activity__item">
<td class="py-2 pr-3">
<NuxtLink :to="item.publicUrl || `/admin/posts/${item.postId}`" :target="item.publicUrl ? '_blank' : undefined" :rel="item.publicUrl ? 'noopener noreferrer' : undefined" class="admin-member-activity__post block truncate font-medium hover:underline" :title="item.postTitle">{{ item.postTitle || '(제목 없음)' }}</NuxtLink>
</td>
<td class="py-2 pr-3"><p class="admin-member-activity__body truncate text-muted" :title="item.body">{{ item.body || '—' }}</p></td>
<td class="whitespace-nowrap py-2 text-muted"><time :datetime="item.createdAt">{{ formatCreatedAt(item.createdAt) }}</time></td>
</tr>
</tbody>
</table>
</div>
<AdminPagination v-if="status !== 'pending' && !error" :page="data?.pagination.page" :page-count="data?.pagination.pageCount" label="작성한 글 페이지" @update:page="changePage" />
</section>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<script setup>
const props = defineProps({
/** 모달 제목 요소 ID */
labelledBy: {
type: String,
required: true
},
/** 오버레이에 추가할 화면별 클래스 */
rootClass: {
type: String,
default: ''
},
/** 패널에 추가할 화면별 클래스 */
panelClass: {
type: String,
default: ''
},
/** 오버레이 z-index 클래스 */
zClass: {
type: String,
default: 'z-[60]'
}
})
const emit = defineEmits(['close'])
</script>
<template>
<div
class="admin-modal-frame fixed inset-0 grid place-items-center bg-black/40 px-5 py-8"
:class="[zClass, rootClass]"
role="dialog"
aria-modal="true"
:aria-labelledby="props.labelledBy"
@click.self="emit('close')"
>
<section
class="admin-modal-frame__panel max-h-[90vh] w-full overflow-y-auto rounded border border-line bg-white p-5 shadow-xl"
:class="panelClass"
>
<slot />
</section>
</div>
</template>

View File

@@ -0,0 +1,945 @@
<script setup>
const props = defineProps({
initialPage: {
type: Object,
default: () => ({})
},
saving: {
type: Boolean,
default: false
},
deleting: {
type: Boolean,
default: false
},
canViewPage: {
type: Boolean,
default: false
},
publicUrl: {
type: String,
default: ''
},
showDelete: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'delete'])
const slugTouched = ref(Boolean(props.initialPage.slug))
const blockEditor = ref(null)
const htmlEditor = ref(null)
const pageSectionInputRef = ref(null)
const editorMode = ref('write')
const isUploadingPageAsset = ref(false)
const isUploadingListingImage = ref(false)
const isSettingsOpen = ref(true)
const savedPageSnapshot = ref('')
const pageSectionInput = ref('')
const isPageSectionSuggestionsOpen = ref(false)
const activePageSectionSuggestionIndex = ref(0)
const isPageSectionInputComposing = ref(false)
const htmlCursorRange = reactive({
start: 0,
end: 0
})
/**
* HTML 태그 내부의 스마트 따옴표를 표준 따옴표로 정규화한다.
* @param {string} value - 원본 HTML
* @returns {string} 정규화된 HTML
*/
const normalizeHtmlTagQuotes = (value) => String(value || '').replace(/<[^>]*>/g, (tag) => tag
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'"))
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 { data: pageSections } = useFetch('/admin/api/page-sections', {
default: () => []
})
const form = reactive({
title: props.initialPage.title || '',
slug: props.initialPage.slug || '',
status: props.initialPage.status || 'published',
renderMode: props.initialPage.renderMode || 'html_document',
content: props.initialPage.content || '',
isListed: Boolean(props.initialPage.isListed),
isFeatured: Boolean(props.initialPage.isFeatured),
listingSection: props.initialPage.listingSection || '',
listingTitle: props.initialPage.listingTitle || '',
listingDescription: props.initialPage.listingDescription || '',
listingImage: props.initialPage.featuredImage || '',
listingSortOrder: Number(props.initialPage.listingSortOrder || 0)
})
/**
* 한글 음절 1자를 영문 표기로 변환
* @param {string} char - 변환할 문자
* @returns {string} 영문 표기
*/
const romanizeHangulSyllable = (char) => {
const syllableCode = char.charCodeAt(0)
const hangulBase = 0xac00
const hangulLast = 0xd7a3
if (syllableCode < hangulBase || syllableCode > hangulLast) {
return char
}
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
const offset = syllableCode - hangulBase
const choseongIndex = Math.floor(offset / 588)
const jungseongIndex = Math.floor((offset % 588) / 28)
const jongseongIndex = offset % 28
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
}
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 영문 슬러그
*/
const toSlug = (value) => value
.normalize('NFC')
.split('')
.map((char) => romanizeHangulSyllable(char))
.join('')
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/\s+/g, '-')
.replace(/[-_]+/g, (match) => match.includes('_') ? '_' : '-')
.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'}/`)
const pageSectionOptions = computed(() => {
const currentSection = form.listingSection.trim()
const options = pageSections.value.map((section) => section.name)
if (currentSection && !options.includes(currentSection)) {
options.push(currentSection)
}
return options
})
const pageSectionSuggestionOptions = computed(() => {
const keyword = pageSectionInput.value.trim().toLowerCase()
const options = pageSectionOptions.value.map((sectionName) => ({
name: sectionName,
isSelected: sectionName === form.listingSection.trim()
}))
if (!keyword) {
return options
}
return options.filter((option) => option.name.toLowerCase().includes(keyword))
})
const hasPageSectionSuggestions = computed(() => pageSectionSuggestionOptions.value.length > 0)
/**
* 페이지 섹션 추천 목록을 연다.
* @returns {void}
*/
const openPageSectionSuggestions = () => {
activePageSectionSuggestionIndex.value = 0
isPageSectionSuggestionsOpen.value = true
}
/**
* 페이지 섹션 추천 목록을 토글한다.
* @returns {Promise<void>}
*/
const togglePageSectionSuggestions = async () => {
isPageSectionSuggestionsOpen.value = !isPageSectionSuggestionsOpen.value
activePageSectionSuggestionIndex.value = 0
await nextTick()
pageSectionInputRef.value?.focus()
}
/**
* 페이지 섹션 추천 항목을 선택한다.
* @param {Object} option - 섹션 추천 항목
* @returns {Promise<void>}
*/
const selectPageSectionSuggestion = async (option) => {
form.listingSection = String(option?.name || '').trim()
pageSectionInput.value = ''
isPageSectionSuggestionsOpen.value = false
activePageSectionSuggestionIndex.value = 0
await nextTick()
pageSectionInputRef.value?.focus()
}
/**
* 페이지 섹션 선택을 제거한다.
* @returns {void}
*/
const clearPageSection = () => {
form.listingSection = ''
pageSectionInput.value = ''
activePageSectionSuggestionIndex.value = 0
}
/**
* 페이지 섹션 입력 blur를 처리한다.
* @returns {void}
*/
const handlePageSectionBlur = () => {
window.setTimeout(() => {
isPageSectionSuggestionsOpen.value = false
pageSectionInput.value = ''
}, 120)
}
/**
* 페이지 섹션 입력 키보드 조작을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handlePageSectionKeydown = (event) => {
if (isPageSectionInputComposing.value) {
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
openPageSectionSuggestions()
activePageSectionSuggestionIndex.value = Math.min(
activePageSectionSuggestionIndex.value + 1,
Math.max(0, pageSectionSuggestionOptions.value.length - 1)
)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
openPageSectionSuggestions()
activePageSectionSuggestionIndex.value = Math.max(0, activePageSectionSuggestionIndex.value - 1)
return
}
if (event.key === 'Enter') {
if (!isPageSectionSuggestionsOpen.value || !hasPageSectionSuggestions.value) {
return
}
event.preventDefault()
selectPageSectionSuggestion(pageSectionSuggestionOptions.value[activePageSectionSuggestionIndex.value])
return
}
if (event.key === 'Escape') {
isPageSectionSuggestionsOpen.value = false
pageSectionInput.value = ''
return
}
if (event.key === 'Backspace' && !pageSectionInput.value && form.listingSection) {
clearPageSection()
}
}
/**
* 페이지 폼의 저장 비교용 문자열을 생성한다.
* @param {Object|null} [payload] - 서버에 저장한 페이지 입력값
* @returns {string} 직렬화된 폼 상태
*/
const serializePageForm = (payload = null) => JSON.stringify(payload || {
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.renderMode === 'html_document' ? normalizeHtmlTagQuotes(form.content) : form.content,
featuredImage: form.listingImage.trim() || null,
isListed: Boolean(form.isListed),
isFeatured: Boolean(form.isListed && form.isFeatured),
listingSection: form.listingSection.trim(),
listingTitle: form.listingTitle.trim(),
listingDescription: form.listingDescription.trim(),
listingSortOrder: Number(form.listingSortOrder || 0)
})
const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value)
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage,
allowNextRouteLeave
} = useAdminUnsavedChangesGuard(hasUnsavedPageChanges)
const headerStatusText = computed(() => {
if (props.saving) {
return 'Saving...'
}
if (hasUnsavedPageChanges.value) {
return 'Unsaved changes'
}
if (props.initialPage.id) {
return 'Saved'
}
return 'New page'
})
watch(() => form.title, (title) => {
if (!slugTouched.value) {
form.slug = toSlug(title)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
watch(() => form.isListed, (isListed) => {
if (!isListed) {
form.isFeatured = false
}
})
/**
* 페이지 설정 패널을 토글한다.
* @returns {void}
*/
const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 제목 입력 후 본문 에디터로 이동
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const focusContentEditor = (event) => {
event?.preventDefault()
if (form.renderMode === 'html_document') {
htmlEditor.value?.focus()
return
}
blockEditor.value?.focusFirstBlock()
}
/**
* 페이지 작성 모드를 변경한다.
* @param {'markdown'|'html_document'} mode - 페이지 작성 모드
* @returns {void}
*/
const setRenderMode = (mode) => {
form.renderMode = mode
}
/**
* HTML textarea 커서 위치를 기억한다.
* @returns {void}
*/
const rememberHtmlCursor = () => {
if (!htmlEditor.value) {
return
}
htmlCursorRange.start = htmlEditor.value.selectionStart ?? form.content.length
htmlCursorRange.end = htmlEditor.value.selectionEnd ?? htmlCursorRange.start
}
/**
* HTML 본문 커서 위치에 텍스트를 삽입한다.
* @param {string} text - 삽입할 텍스트
* @returns {Promise<void>}
*/
const insertTextAtHtmlCursor = async (text) => {
const start = Math.max(0, htmlCursorRange.start)
const end = Math.max(start, htmlCursorRange.end)
form.content = `${form.content.slice(0, start)}${text}${form.content.slice(end)}`
await nextTick()
const nextCursor = start + text.length
htmlEditor.value?.focus()
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
htmlCursorRange.start = nextCursor
htmlCursorRange.end = nextCursor
}
/**
* HTML textarea 붙여넣기 값을 정규화해 삽입한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트
* @returns {Promise<void>}
*/
const handleHtmlEditorPaste = async (event) => {
if (form.renderMode !== 'html_document') {
return
}
const text = event.clipboardData?.getData('text/plain') ?? ''
const normalizedText = normalizeHtmlTagQuotes(text)
if (text === normalizedText) {
return
}
event.preventDefault()
rememberHtmlCursor()
await insertTextAtHtmlCursor(normalizedText)
}
/**
* HTML 기본 문서 골격을 현재 본문에 채운다.
* @returns {Promise<void>}
*/
const completeHtmlDocumentSkeleton = async () => {
form.content = defaultHtmlDocument
await nextTick()
const bodyIndex = form.content.indexOf('</body>')
const nextCursor = bodyIndex > -1 ? bodyIndex : form.content.length
htmlEditor.value?.focus()
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
htmlCursorRange.start = nextCursor
htmlCursorRange.end = nextCursor
}
/**
* HTML textarea에서 VS Code식 기본 골격 단축 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {Promise<void>}
*/
const handleHtmlEditorKeydown = async (event) => {
if (event.key !== 'Tab' || form.renderMode !== 'html_document') {
return
}
const content = form.content.trim()
if (content !== '' && content !== '!') {
return
}
event.preventDefault()
await completeHtmlDocumentSkeleton()
}
/**
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadPageAsset = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
rememberHtmlCursor()
const formData = new FormData()
formData.append('files', files[0])
isUploadingPageAsset.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
const uploadedUrl = result.files?.[0]?.url || ''
if (uploadedUrl && form.renderMode === 'html_document') {
await insertTextAtHtmlCursor(uploadedUrl)
}
} finally {
event.target.value = ''
isUploadingPageAsset.value = false
}
}
/**
* 페이지 목록 카드 이미지를 업로드한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadListingImage = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
const formData = new FormData()
formData.append('files', files[0])
isUploadingListingImage.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.listingImage = result.files?.[0]?.url || form.listingImage
} finally {
event.target.value = ''
isUploadingListingImage.value = false
}
}
/**
* 페이지 목록 카드 이미지를 제거한다.
* @returns {void}
*/
const clearListingImage = () => {
form.listingImage = ''
}
/**
* 페이지 입력값을 생성한다.
* @returns {Object} 페이지 입력값
*/
const createPayload = () => ({
title: form.title.trim(),
slug: pageSlug.value,
status: form.status,
renderMode: form.renderMode,
content: form.renderMode === 'html_document' ? normalizeHtmlTagQuotes(form.content) : form.content,
featuredImage: form.listingImage.trim() || null,
isListed: Boolean(form.isListed),
isFeatured: Boolean(form.isListed && form.isFeatured),
listingSection: form.listingSection.trim(),
listingTitle: form.listingTitle.trim(),
listingDescription: form.listingDescription.trim(),
listingSortOrder: Number(form.listingSortOrder || 0)
})
/**
* 페이지 입력값 제출
* @returns {void}
*/
const submitPage = () => {
if (form.renderMode === 'html_document') {
form.content = normalizeHtmlTagQuotes(form.content)
}
emit('submit', createPayload())
}
/**
* 현재 폼 상태를 저장 완료 기준점으로 표시한다.
* @param {Object|null} [savedPayload] - 실제 서버에 저장된 입력값
* @returns {void}
*/
const markSaved = (savedPayload = null) => {
savedPageSnapshot.value = serializePageForm(savedPayload)
}
onMounted(markSaved)
defineExpose({
allowNextRouteLeave,
markSaved
})
</script>
<template>
<form class="admin-page-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPage">
<div class="admin-page-form__workspace flex min-w-0 flex-1 flex-col bg-white">
<header class="admin-page-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
<div class="admin-page-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
<div class="admin-page-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
<NuxtLink class="admin-page-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black" to="/admin/pages">
<span class="admin-page-form__toolbar-back text-lg leading-none" aria-hidden="true">&lt;</span>
<span>Pages</span>
</NuxtLink>
<NuxtLink
v-if="canViewPage && viewPageUrl"
class="admin-page-form__toolbar-status-link inline-flex items-center gap-1 truncate rounded px-2 py-1.5 text-sm font-medium text-[#8E9CAC] transition-colors hover:bg-[#f1f3f4] hover:text-[#394047]"
:to="viewPageUrl"
target="_blank"
rel="noopener noreferrer"
>
<span>View page</span>
<span aria-hidden="true">↗</span>
</NuxtLink>
<span v-else class="admin-page-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8E9CAC]">
{{ headerStatusText }}
</span>
</div>
<div class="admin-page-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
<div
v-show="form.renderMode === 'markdown'"
id="admin-page-form-mode-toggle-host"
class="admin-page-form__mode-toggle-host flex shrink-0 items-center"
/>
<button
class="admin-page-form__toolbar-save rounded px-3 py-1.5 text-sm font-bold transition-colors disabled:cursor-default disabled:text-[#8E9CAC] disabled:hover:bg-transparent enabled:text-[#394047] enabled:hover:bg-[#f1f3f4]"
type="submit"
:disabled="saving || !form.title.trim() || !hasUnsavedPageChanges"
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button
class="admin-page-form__settings-toggle grid size-[34px] place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] hover:text-black"
type="button"
:aria-pressed="isSettingsOpen"
aria-label="페이지 설정 패널 전환"
@click="toggleSettingsPanel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2.16699C14.3242 2.16699 14.4998 2.39365 14.5 2.57129V13.4287C14.5 13.606 14.3242 13.834 14 13.834H11.5V2.16699H14ZM2 2.16699H10.5V13.834H2C1.6756 13.834 1.5 13.6064 1.5 13.4287V2.57129C1.50024 2.39409 1.67607 2.16699 2 2.16699Z" stroke="currentColor" />
</svg>
</button>
</div>
</div>
</header>
<main class="admin-page-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
<section class="admin-page-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-24">
<input
v-model="form.title"
class="admin-page-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="제목"
@keydown.enter="focusContentEditor"
>
<div v-if="form.renderMode === 'markdown'" class="admin-page-form__field admin-page-form__content-editor text-sm">
<AdminMarkdownEditor
ref="blockEditor"
v-model="form.content"
v-model:editor-mode="editorMode"
mode-toggle-teleport-to="#admin-page-form-mode-toggle-host"
/>
</div>
<label v-else class="admin-page-form__field admin-page-form__html-field grid gap-2 text-sm">
<span class="admin-page-form__label sr-only">HTML 문서</span>
<textarea
ref="htmlEditor"
v-model="form.content"
class="admin-page-form__html-editor min-h-[68vh] w-full resize-y rounded border border-[#e3e6e8] bg-white px-4 py-4 font-mono text-sm leading-6 text-[#15171a] outline-none placeholder:text-[#8e9cac] focus:border-[#8e9cac]"
spellcheck="false"
@blur="rememberHtmlCursor"
@click="rememberHtmlCursor"
@focus="rememberHtmlCursor"
@input="rememberHtmlCursor"
@keydown="handleHtmlEditorKeydown"
@keyup="rememberHtmlCursor"
@paste="handleHtmlEditorPaste"
@select="rememberHtmlCursor"
:placeholder="defaultHtmlDocument"
/>
</label>
</section>
</main>
</div>
<aside
class="admin-page-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out"
:class="isSettingsOpen ? 'w-[420px] border-l' : 'w-0 border-l-0'"
:aria-hidden="!isSettingsOpen"
>
<div class="admin-page-form__settings-inner relative flex h-full w-[420px] flex-col">
<div class="admin-page-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
<h2 class="admin-page-form__settings-title text-xl font-bold text-black">
페이지 설정
</h2>
<button class="admin-page-form__settings-close grid size-8 place-items-center rounded text-neutral-900 transition-colors hover:bg-[#eff1f2] hover:text-neutral-500" type="button" aria-label="페이지 설정 닫기" @click="toggleSettingsPanel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16" class="h-[1.1rem] w-[1.1rem]" aria-hidden="true">
<path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" />
</svg>
</button>
</div>
<div class="admin-page-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
<div class="admin-page-form__field grid gap-1 text-sm">
<div class="admin-page-form__page-url-header flex h-[22px] items-center justify-between">
<span class="admin-page-form__label font-bold text-[#15171a]">Page URL</span>
<NuxtLink
v-if="canViewPage && viewPageUrl"
class="admin-page-form__view-page inline-flex items-center gap-1 rounded px-3 py-1.5 text-xs text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
:to="viewPageUrl"
target="_blank"
>
<span>View Page</span>
<span aria-hidden="true">↗</span>
</NuxtLink>
</div>
<label class="admin-page-form__page-url-input flex h-[38px] items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<span class="admin-page-form__page-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
<input
v-model="form.slug"
class="admin-page-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
type="text"
pattern="[a-z0-9가-힣]+([-_][a-z0-9가-힣]+)*"
required
@input="touchSlug"
>
</label>
<p class="admin-page-form__page-url-hint text-xs text-[#7c8b9a]">
{{ pageUrlHint }}
</p>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">상태</span>
<span class="admin-page-form__select-wrap relative block">
<select v-model="form.status" class="admin-page-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">공개</option>
<option value="private">비공개</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<span v-if="form.status === 'draft'" class="admin-page-form__hint text-xs text-muted">
초안 페이지는 공개 URL에서 보이지 않습니다.
</span>
<span v-else-if="form.status === 'private'" class="admin-page-form__hint text-xs text-muted">
비공개 페이지는 공개 URL에서 보이지 않습니다.
</span>
</label>
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">페이지 형식</span>
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'markdown' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
type="button"
@click="setRenderMode('markdown')"
>
일반 텍스트
</button>
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'html_document' ? 'bg-[#15171a] text-white' : 'text-[#7c8b9a] hover:bg-white hover:text-[#15171a]'"
type="button"
@click="setRenderMode('html_document')"
>
HTML
</button>
</div>
</div>
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">HTML 자산</span>
<label
class="admin-page-form__asset-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"
:class="{ 'pointer-events-none opacity-50': isUploadingPageAsset }"
>
{{ isUploadingPageAsset ? '업로드 중' : '파일 업로드' }}
<input
class="sr-only"
type="file"
accept="image/*,video/*,audio/*,.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx"
@change="uploadPageAsset"
>
</label>
<p class="admin-page-form__asset-upload-hint text-xs leading-5 text-[#7c8b9a]">
HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. 예: &lt;img src=&quot;여기&quot;&gt;
</p>
</div>
<section class="admin-page-form__listing grid gap-4 border-t border-[#e3e6e8] pt-5">
<label class="admin-page-form__toggle-row flex items-center justify-between gap-4 text-sm">
<span class="admin-page-form__toggle-copy">
<span class="block font-medium text-[#15171a]">홈에 표시</span>
<span class="mt-1 block text-xs text-[#7c8b9a]">공개 상태일 때만 방문자에게 노출됩니다.</span>
</span>
<span class="admin-page-form__toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input v-model="form.isListed" class="peer sr-only" type="checkbox">
<span class="h-7 w-12 rounded-full bg-[#d7dde2] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="absolute left-1 size-5 rounded-full bg-white transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-page-form__toggle-row flex items-center justify-between gap-4 text-sm" :class="{ 'opacity-45': !form.isListed }">
<span class="admin-page-form__toggle-copy">
<span class="block font-medium text-[#15171a]">추천으로 강조</span>
<span class="mt-1 block text-xs text-[#7c8b9a]">Pages 영역에서 먼저 정렬합니다.</span>
</span>
<span class="admin-page-form__toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input v-model="form.isFeatured" class="peer sr-only" type="checkbox" :disabled="!form.isListed">
<span class="h-7 w-12 rounded-full bg-[#d7dde2] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="absolute left-1 size-5 rounded-full bg-white transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</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__section-combobox relative"
@focusin="openPageSectionSuggestions"
>
<div class="admin-page-form__section-editor flex min-h-[38px] items-center gap-1 rounded border border-[#e3e6e8] bg-[#eff1f2] px-2 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<button
v-if="form.listingSection"
class="admin-page-form__section-token inline-flex max-w-[15rem] items-center gap-1 rounded bg-white px-2 py-1 text-xs font-semibold text-[#394047] ring-1 ring-[#e3e6e8]"
type="button"
title="섹션 선택 해제"
@click="clearPageSection"
>
<span class="truncate">{{ form.listingSection }}</span>
<span aria-hidden="true">×</span>
</button>
<input
ref="pageSectionInputRef"
v-model="pageSectionInput"
class="admin-page-form__section-input min-w-0 flex-1 bg-transparent px-1 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
:placeholder="pageSectionOptions.length === 0 ? '등록된 섹션 없음' : form.listingSection ? '' : '섹션 검색'"
:disabled="pageSectionOptions.length === 0"
autocomplete="off"
@input="openPageSectionSuggestions"
@compositionupdate="pageSectionInput = $event.target.value; openPageSectionSuggestions()"
@focus="openPageSectionSuggestions"
@blur="handlePageSectionBlur"
@keydown="handlePageSectionKeydown"
@compositionstart="isPageSectionInputComposing = true"
@compositionend="isPageSectionInputComposing = false"
>
<button
class="admin-page-form__section-dropdown-trigger grid size-7 shrink-0 place-items-center rounded text-[#394047] transition-colors hover:bg-white disabled:opacity-35"
type="button"
:disabled="pageSectionOptions.length === 0"
aria-label="섹션 목록 열기"
@mousedown.prevent
@click="togglePageSectionSuggestions"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" 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>
</button>
</div>
<AdminPopoverSurface
v-if="isPageSectionSuggestionsOpen && hasPageSectionSuggestions"
root-class="admin-page-form__section-suggestions absolute left-0 right-0 top-[calc(100%+0.25rem)] z-20 max-h-48 overflow-y-auto rounded border-[#e3e6e8] py-1"
>
<button
v-for="(option, index) in pageSectionSuggestionOptions"
:key="option.name"
class="admin-page-form__section-suggestion flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition-colors"
:class="index === activePageSectionSuggestionIndex ? 'bg-[#f1f3f4] text-[#15171a]' : 'text-[#394047] hover:bg-[#f1f3f4]'"
type="button"
@mousedown.prevent
@click="selectPageSectionSuggestion(option)"
>
<span class="truncate">{{ option.name }}</span>
<span v-if="option.isSelected" class="text-xs text-[#8e9cac]">선택됨</span>
</button>
</AdminPopoverSurface>
<AdminPopoverSurface
v-else-if="isPageSectionSuggestionsOpen && pageSectionInput.trim()"
as="p"
root-class="admin-page-form__section-empty absolute left-0 right-0 top-[calc(100%+0.25rem)] z-20 rounded border-[#e3e6e8] px-3 py-2 text-xs text-[#7c8b9a]"
>
일치하는 섹션이 없습니다.
</AdminPopoverSurface>
</div>
<span class="admin-page-form__help text-xs text-[#7c8b9a]">
섹션은 네비게이션의 페이지 섹션에서 추가합니다.
</span>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">목록 제목</span>
<input
v-model="form.listingTitle"
class="admin-page-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 text-sm text-black outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
type="text"
maxlength="160"
placeholder="비워두면 페이지 제목을 사용"
>
</label>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">목록 설명</span>
<textarea
v-model="form.listingDescription"
class="admin-page-form__textarea min-h-24 resize-y rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 text-sm leading-5 text-black outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
maxlength="500"
placeholder="검색 결과에 사용할 짧은 설명"
/>
</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.listingImage" class="admin-page-form__listing-image group relative max-w-[180px] overflow-hidden rounded border border-[#e3e6e8] bg-[#eff1f2]">
<img class="aspect-[3/4] w-full object-cover" :src="form.listingImage" alt="">
<button class="absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-[#394047] shadow-sm transition-colors hover:bg-white" type="button" @click="clearListingImage">
제거
</button>
</figure>
<label
class="admin-page-form__listing-image-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': isUploadingListingImage }"
>
{{ isUploadingListingImage ? '업로드 중' : form.listingImage ? '이미지 변경' : '이미지 업로드' }}
<input
class="sr-only"
type="file"
accept="image/*"
@change="uploadListingImage"
>
</label>
</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>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
</form>
</template>

View File

@@ -0,0 +1,87 @@
<script setup>
import { buildAdminPaginationItems, clampAdminPage } from '~/lib/admin-pagination.js'
const props = defineProps({
/** 현재 페이지 */
page: {
type: Number,
default: 1
},
/** 전체 페이지 수 */
pageCount: {
type: Number,
default: 1
},
/** 페이지네이션 영역 접근성 라벨 */
label: {
type: String,
default: '페이지'
}
})
const emit = defineEmits(['update:page'])
/** @type {import('vue').ComputedRef<number>} */
const totalPageCount = computed(() => Math.max(1, Number(props.pageCount) || 1))
/** @type {import('vue').ComputedRef<number>} */
const currentPage = computed(() => clampAdminPage(props.page, totalPageCount.value))
/** @type {import('vue').ComputedRef<Array<number|string>>} */
const paginationItems = computed(() => buildAdminPaginationItems(currentPage.value, totalPageCount.value))
/**
* 페이지 변경을 요청한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const updatePage = (page) => {
emit('update:page', clampAdminPage(page, totalPageCount.value))
}
</script>
<template>
<nav
v-if="totalPageCount > 1"
class="admin-pagination mt-6 flex flex-wrap items-center justify-center gap-2"
:aria-label="label"
>
<button
class="admin-pagination__arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="currentPage <= 1"
aria-label="이전 페이지"
@click="updatePage(currentPage - 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<template v-for="item in paginationItems" :key="item">
<span v-if="typeof item === 'string'" class="admin-pagination__ellipsis px-2 text-sm font-semibold text-muted">
...
</span>
<button
v-else
class="admin-pagination__page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
:class="item === currentPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
type="button"
:aria-current="item === currentPage ? 'page' : undefined"
@click="updatePage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-pagination__arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="currentPage >= totalPageCount"
aria-label="다음 페이지"
@click="updatePage(currentPage + 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</nav>
</template>

View File

@@ -0,0 +1,41 @@
<script setup>
const props = defineProps({
/** 렌더링할 HTML 태그 */
as: {
type: String,
default: 'div'
},
/** 팝오버 접근성 role */
role: {
type: String,
default: ''
},
/** 팝오버에 추가할 화면별 클래스 */
rootClass: {
type: String,
default: ''
},
/** 팝오버에 적용할 인라인 스타일 */
surfaceStyle: {
type: Object,
default: () => ({})
},
/** 기본 overflow 숨김 사용 여부 */
clipped: {
type: Boolean,
default: false
}
})
</script>
<template>
<component
:is="props.as"
class="admin-popover-surface border bg-white shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
:class="[clipped ? 'overflow-hidden' : '', rootClass]"
:style="surfaceStyle"
:role="role || undefined"
>
<slot />
</component>
</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>

View File

@@ -0,0 +1,229 @@
<script setup>
/**
* 게시물 내보내기 작업 목록 카드
* @property {Array} jobs - Export 작업 목록
* @property {Function} formatDateTime - 날짜 표시 함수
* @property {Function} getStatusLabel - 상태 라벨 함수
* @property {Function} getStatusClass - 상태 배지 클래스 함수
* @property {Function} shouldShowProgress - 진행도 표시 여부 함수
* @property {Function} getProgressLabel - 진행 숫자 라벨 함수
* @property {Function} getProgressPercent - 진행률 함수
* @property {Function} getProgressDescription - 진행 보조 설명 함수
* @property {Function} isRetrying - 재시도 중 여부 함수
* @property {Function} isDeleting - 삭제 중 여부 함수
* @property {Function} isDownloading - 다운로드 중 여부 함수
* @property {Function} areAllReadyFilesSelected - 전체 선택 여부 함수
* @property {Function} getReadyFiles - 준비 완료 파일 목록 함수
* @property {Function} getSelectedReadyFiles - 선택된 준비 완료 파일 목록 함수
* @property {Function} isFileSelected - 파일 선택 여부 함수
* @property {Function} isFileSelectionDisabled - 파일 선택 비활성 여부 함수
*/
defineProps({
jobs: {
type: Array,
default: () => []
},
formatDateTime: {
type: Function,
required: true
},
getStatusLabel: {
type: Function,
required: true
},
getStatusClass: {
type: Function,
required: true
},
shouldShowProgress: {
type: Function,
required: true
},
getProgressLabel: {
type: Function,
required: true
},
getProgressPercent: {
type: Function,
required: true
},
getProgressDescription: {
type: Function,
required: true
},
isRetrying: {
type: Function,
required: true
},
isDeleting: {
type: Function,
required: true
},
isDownloading: {
type: Function,
required: true
},
areAllReadyFilesSelected: {
type: Function,
required: true
},
getReadyFiles: {
type: Function,
required: true
},
getSelectedReadyFiles: {
type: Function,
required: true
},
isFileSelected: {
type: Function,
required: true
},
isFileSelectionDisabled: {
type: Function,
required: true
}
})
defineEmits(['refresh', 'retry', 'delete', 'toggle-all-files', 'download-files', 'toggle-file'])
</script>
<template>
<section
v-if="jobs.length > 0"
id="admin-settings-section-export-results"
class="admin-post-export-job-list admin-settings-screen__card admin-settings-screen__card--export-results rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__export-list">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-[#15171a]">
최근 내보내기 작업
</h3>
<button
class="inline-flex h-8 cursor-pointer items-center justify-center rounded px-3 text-xs font-semibold text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
@click="$emit('refresh')"
>
새로고침
</button>
</div>
<div class="admin-settings-screen__export-items grid gap-3">
<article
v-for="job in jobs"
:key="job.id"
class="admin-settings-screen__export-item rounded-lg border border-[#e6e8eb] bg-white p-4"
>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
v-if="job.status !== 'ready'"
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold ring-1 ring-inset"
:class="getStatusClass(job.status)"
>
{{ getStatusLabel(job.status) }}
</span>
<span class="text-sm font-semibold text-[#15171a]">
게시물 {{ job.postCount }}개
</span>
<span class="text-sm text-[#9aa3ad]">
{{ job.files.length }}개 파일
</span>
<span class="text-sm text-[#657080]">
{{ job.rangeLabel || '전체' }}
</span>
</div>
<p class="mt-2 text-xs text-[#657080]">
만료: {{ formatDateTime(job.expiresAt) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
v-if="job.status === 'failed'"
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] px-3 text-xs font-semibold text-[#15171a] transition hover:bg-[#f4f6f8] disabled:cursor-not-allowed disabled:text-[#a6b0bb]"
type="button"
:disabled="isRetrying(job.id)"
@click="$emit('retry', job)"
>
{{ isRetrying(job.id) ? '재시도 중' : '재시도' }}
</button>
<button
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#ffd5d5] px-3 text-xs font-semibold text-[#d64545] transition hover:bg-[#fff3f3] disabled:cursor-not-allowed disabled:border-[#e1e5ea] disabled:text-[#a6b0bb]"
type="button"
:disabled="job.status === 'queued' || job.status === 'processing' || isDeleting(job.id)"
@click="$emit('delete', job)"
>
{{ isDeleting(job.id) ? '삭제 중' : '삭제' }}
</button>
</div>
</div>
<div
v-if="shouldShowProgress(job)"
class="admin-settings-screen__export-progress mt-4 rounded-lg border border-[#edf0f3] bg-[#fbfcfd] p-3"
>
<div class="mb-2 flex items-center justify-between gap-3 text-xs">
<span class="font-semibold text-[#15171a]">
진행도 {{ getProgressLabel(job) }}
</span>
<span class="text-[#657080]">
{{ getProgressPercent(job) }}%
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-[#e8edf2]">
<div
class="h-full rounded-full bg-[#15171a] transition-[width] duration-500"
:style="{ width: `${getProgressPercent(job)}%` }"
/>
</div>
<div class="mt-2 flex flex-col gap-1 text-xs text-[#657080] md:flex-row md:items-center md:justify-between">
<span>{{ getProgressDescription(job) }}</span>
<span>마지막 갱신: {{ formatDateTime(job.updatedAt) }}</span>
</div>
</div>
<details
v-if="job.status === 'failed' && job.errorDetail"
class="admin-settings-screen__export-error mt-4 rounded-lg border border-[#ffd5d5] bg-[#fff7f7] p-3 text-xs text-[#8f2d2d]"
>
<summary class="cursor-pointer font-semibold">
실패 원인 보기
</summary>
<pre class="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">{{ job.errorDetail }}</pre>
</details>
<div v-if="job.files.length > 0" class="admin-settings-screen__export-files mt-4 overflow-hidden rounded-md border border-[#edf0f3]">
<div class="flex flex-col gap-3 border-b border-[#edf0f3] bg-[#fbfcfd] px-3 py-3 md:flex-row md:items-center md:justify-between">
<label class="inline-flex cursor-pointer items-center gap-2 text-xs font-semibold text-[#394047] has-[:disabled]:cursor-not-allowed has-[:disabled]:text-[#a6b0bb]">
<input
class="size-4 rounded border-[#cfd6de] text-[#15171a] focus:ring-[#15171a] disabled:cursor-not-allowed"
type="checkbox"
:checked="areAllReadyFilesSelected(job)"
:disabled="getReadyFiles(job).length === 0 || isDownloading(job.id)"
@change="$emit('toggle-all-files', job)"
/>
전체 선택
</label>
<button
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#15171a] bg-[#15171a] px-3 text-xs font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:border-[#dce0e5] disabled:bg-white disabled:text-[#9aa3ad]"
type="button"
:disabled="getSelectedReadyFiles(job).length === 0 || isDownloading(job.id)"
@click="$emit('download-files', job)"
>
{{ isDownloading(job.id) ? '다운로드 중' : `선택 파일 다운로드 ${getSelectedReadyFiles(job).length || ''}` }}
</button>
</div>
<AdminPostExportFileRow
v-for="file in job.files"
:key="file.id"
:file="file"
:selected="isFileSelected(job, file)"
:disabled="isFileSelectionDisabled(job, file)"
@toggle="$emit('toggle-file', job, file)"
/>
</div>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,261 @@
<script setup>
/**
* 게시물 내보내기 요청 카드
* @property {boolean} expanded - 펼침 여부
* @property {string} dateRangeMode - 내보내기 범위 모드
* @property {number} year - 내보내기 연도
* @property {number} month - 내보내기 월
* @property {string} dateFrom - 직접 범위 시작일
* @property {string} dateTo - 직접 범위 종료일
* @property {number} maxFileSizeMb - 목표 ZIP 용량
* @property {number} chunkSize - ZIP당 최대 게시물 수
* @property {Array} dateRangeModeOptions - 범위 옵션
* @property {Array} yearOptions - 연도 옵션
* @property {Array} monthOptions - 월 옵션
* @property {boolean} canRequest - 요청 가능 여부
* @property {boolean} requesting - 요청 중 여부
* @property {boolean} hasActiveJobs - 진행 중 작업 여부
* @property {string} requestTitle - 요청 버튼 안내 문구
*/
defineProps({
expanded: {
type: Boolean,
default: false
},
dateRangeMode: {
type: String,
default: 'all'
},
year: {
type: [Number, String],
default: () => new Date().getFullYear()
},
month: {
type: [Number, String],
default: 1
},
dateFrom: {
type: String,
default: ''
},
dateTo: {
type: String,
default: ''
},
maxFileSizeMb: {
type: [Number, String],
default: 500
},
chunkSize: {
type: [Number, String],
default: 500
},
dateRangeModeOptions: {
type: Array,
default: () => []
},
yearOptions: {
type: Array,
default: () => []
},
monthOptions: {
type: Array,
default: () => []
},
canRequest: {
type: Boolean,
default: false
},
requesting: {
type: Boolean,
default: false
},
hasActiveJobs: {
type: Boolean,
default: false
},
requestTitle: {
type: String,
default: ''
}
})
const emit = defineEmits([
'toggle',
'request',
'update:dateRangeMode',
'update:year',
'update:month',
'update:dateFrom',
'update:dateTo',
'update:maxFileSizeMb',
'update:chunkSize'
])
/**
* 입력 이벤트 값을 문자열로 전달한다.
* @param {Event} event - 입력 이벤트
* @param {string} eventName - 전달할 이벤트 이름
* @returns {void}
*/
const emitTextInputValue = (event, eventName) => {
emit(eventName, event.target?.value || '')
}
/**
* 숫자 입력 이벤트 값을 전달한다.
* @param {Event} event - 입력 이벤트
* @param {string} eventName - 전달할 이벤트 이름
* @returns {void}
*/
const emitNumberInputValue = (event, eventName) => {
const value = event.target?.value ?? ''
emit(eventName, value === '' ? '' : Number(value))
}
</script>
<template>
<section
id="admin-settings-section-export"
class="admin-post-export-request-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-[#15171a]">
게시물 내보내기
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
게시물을 Obsidian 호환 ZIP 백업으로 만들고, 준비된 분할 파일을 내려받습니다.
</p>
</div>
<button
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black"
type="button"
:aria-expanded="expanded"
aria-controls="admin-settings-export-panel"
@click="emit('toggle')"
>
{{ expanded ? '접기' : '내보내기' }}
</button>
</div>
<div
v-if="expanded"
id="admin-settings-export-panel"
class="admin-post-export-request-card__actions admin-settings-screen__export-actions mt-4 grid gap-4 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4"
>
<div class="min-w-0">
<p class="text-sm font-semibold text-[#15171a]">
Obsidian 호환 백업 준비
</p>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
전체·연도·월·직접 범위로 게시물을 골라 목표 용량 기준 ZIP 백업을 만듭니다.
</p>
</div>
<div class="grid gap-3">
<div class="admin-post-export-request-card__range admin-settings-screen__export-range grid gap-2 md:grid-cols-4">
<div class="grid gap-1 text-xs font-semibold text-[#5d6673]">
<span>범위</span>
<AdminSelectInput
:model-value="dateRangeMode"
:options="dateRangeModeOptions"
label="내보내기 범위"
select-class="admin-settings-screen__export-select h-10 rounded-md border-[#dce0e5] text-sm font-semibold text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
@update:model-value="emit('update:dateRangeMode', $event)"
/>
</div>
<div
v-if="dateRangeMode === 'year' || dateRangeMode === 'month'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
<span>연도</span>
<AdminSelectInput
:model-value="year"
:options="yearOptions"
label="내보내기 연도"
select-class="admin-settings-screen__export-select h-10 rounded-md border-[#dce0e5] text-sm font-semibold text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
@update:model-value="emit('update:year', $event)"
/>
</div>
<div
v-if="dateRangeMode === 'month'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
<span>월</span>
<AdminSelectInput
:model-value="month"
:options="monthOptions"
label="내보내기 월"
select-class="admin-settings-screen__export-select h-10 rounded-md border-[#dce0e5] text-sm font-semibold text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
@update:model-value="emit('update:month', $event)"
/>
</div>
<label
v-if="dateRangeMode === 'custom'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
시작일
<input
:value="dateFrom"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="date"
@input="emitTextInputValue($event, 'update:dateFrom')"
>
</label>
<label
v-if="dateRangeMode === 'custom'"
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
>
종료일
<input
:value="dateTo"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="date"
@input="emitTextInputValue($event, 'update:dateTo')"
>
</label>
</div>
<div class="admin-post-export-request-card__split admin-settings-screen__export-split grid gap-2 md:grid-cols-2">
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
목표 ZIP 용량 (MB)
<input
:value="maxFileSizeMb"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="number"
min="10"
max="2048"
step="10"
@input="emitNumberInputValue($event, 'update:maxFileSizeMb')"
>
</label>
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
ZIP당 최대 게시물 수
<input
:value="chunkSize"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="number"
min="1"
max="500"
step="1"
@input="emitNumberInputValue($event, 'update:chunkSize')"
>
</label>
</div>
<div class="flex items-center justify-end">
<button
class="admin-post-export-request-card__request admin-settings-screen__export-request inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
type="button"
:disabled="!canRequest"
:title="requestTitle"
@click="emit('request')"
>
{{ requesting ? '요청 중...' : hasActiveJobs ? '내보내기 진행 중' : '내보내기 요청' }}
</button>
</div>
</div>
</div>
</section>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
<script setup>
defineProps({
expanded: {
type: Boolean,
default: false
},
importing: {
type: Boolean,
default: false
},
fileName: {
type: String,
default: ''
},
hasFile: {
type: Boolean,
default: false
},
hasJobs: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle', 'select-file', 'submit', 'open-jobs'])
const inputRef = ref(null)
/**
* 파일 선택창을 연다.
* @returns {void}
*/
const openFilePicker = () => {
inputRef.value?.click()
}
/**
* 파일 선택 이벤트를 부모로 전달한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {void}
*/
const handleFileChange = (event) => {
const target = event.target
const file = target instanceof HTMLInputElement ? target.files?.[0] : null
emit('select-file', file || null)
if (target instanceof HTMLInputElement) {
target.value = ''
}
}
/**
* 파일 드롭 이벤트를 부모로 전달한다.
* @param {DragEvent} event - 파일 드롭 이벤트
* @returns {void}
*/
const handleFileDrop = (event) => {
emit('select-file', event.dataTransfer?.files?.[0] || null)
}
</script>
<template>
<section
id="admin-settings-section-import"
class="admin-post-import-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-[#15171a]">
게시물 가져오기
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
내보내기로 만든 ZIP 백업을 게시물과 미디어 파일로 복원합니다. 이미 같은 slug가 있는 게시물은 건너뜁니다.
</p>
</div>
<button
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8]"
type="button"
:aria-expanded="expanded"
aria-controls="admin-settings-import-panel"
:disabled="importing"
@click="emit('toggle')"
>
{{ expanded ? '접기' : '가져오기' }}
</button>
</div>
<div
v-if="expanded"
id="admin-settings-import-panel"
class="admin-post-import-card__actions admin-settings-screen__import-actions mt-5 grid gap-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#fbfcfd] p-4"
>
<input
ref="inputRef"
class="sr-only"
type="file"
accept=".zip,application/zip"
:disabled="importing"
@change="handleFileChange"
>
<button
class="admin-post-import-card__dropzone admin-settings-screen__import-dropzone grid min-h-32 cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-white px-4 py-6 text-center transition hover:border-[#15171a] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:opacity-60"
type="button"
:disabled="importing"
@click="openFilePicker"
@dragover.prevent
@drop.prevent="handleFileDrop"
>
<span class="grid place-items-center gap-2 text-sm font-semibold text-[#657080]">
<svg class="size-6 text-[#8a94a3]" xmlns="http://www.w3.org/2000/svg" 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 16V4" />
<path d="m7 9 5-5 5 5" />
<path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />
</svg>
<span>{{ fileName || 'ZIP 파일을 드롭하거나 선택하세요.' }}</span>
<span class="text-xs font-medium text-[#8a94a3]">
같은 slug의 게시물은 덮어쓰지 않고 건너뛰며, 건너뛴 게시물의 자산도 다시 저장하지 않습니다.
</span>
</span>
</button>
<div
v-if="importing"
class="admin-post-import-card__progress admin-settings-screen__import-progress rounded-md bg-[#eef5ff] px-3 py-2 text-sm text-[#1f5f99] ring-1 ring-inset ring-[#bdd8f3]"
role="status"
aria-live="polite"
>
ZIP 파일을 서버 작업 대기열에 등록하는 중입니다.
</div>
<div class="flex items-center justify-end">
<button
v-if="hasJobs"
class="mr-auto inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f4f6f8]"
type="button"
@click="emit('open-jobs')"
>
작업 센터
</button>
<button
class="inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
type="button"
:disabled="importing || !hasFile"
@click="emit('submit')"
>
{{ importing ? '가져오는 중...' : '적용' }}
</button>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,267 @@
<script setup>
const props = defineProps({
open: {
type: Boolean,
default: false
},
job: {
type: Object,
default: null
},
deleting: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'refresh', 'delete'])
/**
* 날짜·시간을 관리자 화면용 문자열로 변환한다.
* @param {string|null} value - ISO 날짜 문자열
* @returns {string} 표시 문자열
*/
const formatDateTime = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
/**
* Import 작업 진행률을 계산한다.
* @param {Object|null} job - Import 작업
* @returns {number} 진행률
*/
const getProgressPercent = (job) => {
const totalCount = Number(job?.totalCount || 0)
if (job?.status === 'ready') {
return 100
}
if (totalCount <= 0) {
return job?.status === 'processing' ? 5 : 0
}
return Math.min(Math.round((Number(job.processedCount || 0) / totalCount) * 100), 100)
}
/**
* Import 작업 진행 라벨을 만든다.
* @param {Object|null} job - Import 작업
* @returns {string} 진행 라벨
*/
const getProgressLabel = (job) => {
const processedCount = Number(job?.processedCount || 0)
const totalCount = Number(job?.totalCount || 0)
if (totalCount <= 0) {
return '준비 중'
}
return `${processedCount.toLocaleString()} / ${totalCount.toLocaleString()}`
}
/**
* Import 상태 라벨을 반환한다.
* @param {string} status - 상태
* @returns {string} 상태 라벨
*/
const getStatusLabel = (status) => ({
queued: '대기',
processing: '진행중',
ready: '완료',
skipped: '스킵',
failed: '오류',
pending: '대기'
})[status] || '대기'
/**
* Import 상태 스타일을 반환한다.
* @param {string} status - 상태
* @returns {string} 클래스 문자열
*/
const getStatusClass = (status) => ({
queued: 'bg-[#eef3ff] text-[#2458b8]',
processing: 'bg-[#eef3ff] text-[#2458b8]',
ready: 'bg-[#eefbf3] text-[#147a45]',
skipped: 'bg-[#f3f5f7] text-[#657080]',
failed: 'bg-[#fff1f2] text-[#c01f32]',
pending: 'bg-[#f3f5f7] text-[#657080]'
})[status] || 'bg-[#f3f5f7] text-[#657080]'
/**
* Import 작업 삭제 가능 여부를 확인한다.
* @returns {boolean} 삭제 가능 여부
*/
const canDeleteJob = computed(() => Boolean(props.job)
&& props.job.status !== 'queued'
&& props.job.status !== 'processing'
&& !props.deleting)
</script>
<template>
<div
v-if="open"
class="admin-post-import-job-modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-post-import-job-modal-title"
>
<div class="flex max-h-[calc(100vh-4rem)] w-full max-w-5xl flex-col overflow-hidden rounded-xl bg-white shadow-xl">
<header class="flex shrink-0 items-center justify-between gap-4 border-b border-[#eceff2] px-5 py-4">
<div class="min-w-0">
<h2 id="admin-post-import-job-modal-title" class="text-base font-semibold text-[#15171a]">
게시물 가져오기 작업 센터
</h2>
<p class="mt-1 truncate text-xs text-[#657080]">
{{ job?.originalFileName || '최근 작업' }}
</p>
</div>
<button
class="grid size-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#657080] transition hover:bg-[#f3f5f7] hover:text-[#15171a]"
type="button"
aria-label="닫기"
@click="emit('close')"
>
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</header>
<div v-if="job" class="grid min-h-0 gap-4 overflow-auto px-5 py-4">
<div class="grid gap-3 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold"
:class="getStatusClass(job.status)"
>
{{ getStatusLabel(job.status) }}
</span>
<span class="text-sm font-semibold text-[#15171a]">
{{ getProgressLabel(job) }}
</span>
<span class="text-sm text-[#657080]">
완료 {{ job.importedCount }} · 스킵 {{ job.skippedCount }} · 오류 {{ job.failedCount }}
</span>
</div>
<span class="text-xs text-[#657080]">
마지막 갱신: {{ formatDateTime(job.updatedAt) }}
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-[#e8edf2]">
<div
class="h-full rounded-full bg-[#15171a] transition-[width] duration-500"
:style="{ width: `${getProgressPercent(job)}%` }"
/>
</div>
<p v-if="job.message" class="text-xs text-[#657080]">
{{ job.message }}
</p>
</div>
<div class="overflow-hidden rounded-lg border border-[#e6e8eb]">
<div class="grid min-w-[680px] grid-cols-[minmax(0,1fr)_220px_110px] gap-4 border-b border-[#eceff2] bg-[#fbfcfd] px-4 py-3 text-xs font-semibold text-[#394047]">
<span>게시물 제목</span>
<span>프로세스</span>
<span>상태</span>
</div>
<div v-if="job.items.length > 0" class="max-h-[52vh] overflow-auto">
<div
v-for="item in job.items"
:key="item.id"
class="grid min-w-[680px] grid-cols-[minmax(0,1fr)_220px_110px] gap-4 border-b border-[#edf0f3] px-4 py-3 last:border-b-0"
>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#15171a]">
{{ item.title || item.sourcePath }}
</p>
<p class="mt-1 truncate text-xs text-[#8a94a3]">
{{ item.slug || item.sourcePath }}
</p>
</div>
<div class="grid content-center gap-1">
<div class="h-1.5 overflow-hidden rounded-full bg-[#e8edf2]">
<div
class="h-full rounded-full transition-[width] duration-500"
:class="item.status === 'failed' ? 'bg-[#d64545]' : item.status === 'skipped' ? 'bg-[#9aa3ad]' : 'bg-[#20a04b]'"
:style="{ width: `${Number(item.progress || 0)}%` }"
/>
</div>
<p class="truncate text-xs text-[#657080]">
{{ Number(item.progress || 0) }}% {{ item.message || getStatusLabel(item.status) }}
</p>
</div>
<div class="flex items-center">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold"
:class="getStatusClass(item.status)"
>
{{ getStatusLabel(item.status) }}
</span>
</div>
</div>
</div>
<div v-else class="px-4 py-10 text-center text-sm text-[#657080]">
게시물 목록을 준비하는 중입니다.
</div>
</div>
<details
v-if="job.status === 'failed' && job.errorDetail"
class="rounded-lg border border-[#ffd5d5] bg-[#fff7f7] p-3 text-xs text-[#8f2d2d]"
>
<summary class="cursor-pointer font-semibold">
실패 원인 보기
</summary>
<pre class="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">{{ job.errorDetail }}</pre>
</details>
</div>
<footer class="flex shrink-0 items-center justify-between gap-3 border-t border-[#eceff2] px-5 py-4">
<button
class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md border border-[#dce0e5] bg-white px-3 text-xs font-semibold text-[#15171a] transition hover:bg-[#f4f6f8]"
type="button"
@click="emit('refresh')"
>
새로고침
</button>
<div class="flex items-center gap-2">
<button
v-if="job"
class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md border border-[#ffd5d5] bg-white px-3 text-xs font-semibold text-[#d64545] transition hover:bg-[#fff3f3] disabled:cursor-not-allowed disabled:border-[#e1e5ea] disabled:text-[#a6b0bb]"
type="button"
:disabled="!canDeleteJob"
@click="emit('delete', job)"
>
{{ deleting ? '삭제 중' : '완료 삭제' }}
</button>
<button
class="inline-flex h-9 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-xs font-semibold text-white transition hover:bg-black"
type="button"
@click="emit('close')"
>
닫기
</button>
</div>
</footer>
</div>
</div>
</template>

View File

@@ -0,0 +1,274 @@
<script setup>
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
DEFAULT_POST_LIST_PAGE_SIZE,
DEFAULT_TAG_POST_LIST_PAGE_SIZE,
normalizeDisplayCount
} from '~/lib/display-count-settings.js'
import {
DEFAULT_POST_TAG_LIMIT,
MAX_POST_TAG_LIMIT,
MIN_POST_TAG_LIMIT,
normalizePostTagLimit
} from '~/lib/post-tag-limit.js'
import {
formatUploadSizeLimit,
normalizeUploadMaxFileCount,
normalizeUploadMaxTotalSize
} from '~/lib/upload-size-limit.js'
/**
* POST 설정 카드
* @property {Object} form - 사이트 설정 폼 객체
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} hasChanges - 변경 여부
* @property {Array<{ value: number, label: string }>} postListCountOptions - 게시물 목록 표시개수 선택지
* @property {Array<{ value: number, label: string }>} displayCountOptions - 일반 표시개수 선택지
*/
defineProps({
form: {
type: Object,
required: true
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
},
postListCountOptions: {
type: Array,
default: () => []
},
displayCountOptions: {
type: Array,
default: () => []
}
})
defineEmits(['begin', 'cancel', 'save'])
</script>
<template>
<section
id="admin-settings-section-post"
class="admin-post-settings-card admin-settings-screen__card admin-settings-screen__card--post 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">
POST 설정
</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-post-settings-card__readonly admin-settings-screen__post-readonly border-t border-[#eceff2] pt-5 text-sm"
>
<div class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">수정일 표시</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 cursor-not-allowed items-center opacity-55 grayscale" aria-disabled="true">
<input
:checked="form.showPostUpdatedAt"
class="peer sr-only"
type="checkbox"
disabled
aria-label="수정일 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#6d747d]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-[#f4f6f8] shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</div>
<div class="mt-5 flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5">
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
<span class="font-mono text-sm font-semibold text-[#657080]">
{{ normalizePostTagLimit(form.postTagLimit) }}개
</span>
</div>
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
<div class="flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">표시개수 설정</span>
<span class="text-xs text-[#657080]">공개 목록 기준</span>
</div>
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>Latest</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>Posts</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>태그 게시물</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>Featured</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2 sm:col-span-2">
<span>Pages 섹션</span>
<strong class="font-mono text-[#15171a]">{{ normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT) }}개</strong>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 border-t border-[#eceff2] pt-5">
<div class="flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">업로드 제한</span>
<span class="text-xs text-[#657080]">1회 업로드 기준</span>
</div>
<div class="grid gap-2 text-xs text-[#657080] sm:grid-cols-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>파일 수</span>
<strong class="font-mono text-[#15171a]">{{ normalizeUploadMaxFileCount(form.uploadMaxFileCount) }}개</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-md border border-[#edf0f2] px-3 py-2">
<span>전체 용량</span>
<strong class="font-mono text-[#15171a]">{{ formatUploadSizeLimit(normalizeUploadMaxTotalSize(form.uploadMaxTotalSize)) }}</strong>
</div>
</div>
</div>
</div>
<div
v-else
class="admin-post-settings-card__edit grid gap-5 border-t border-[#eceff2] pt-5 text-sm"
>
<label class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">수정일 표시</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.showPostUpdatedAt"
class="peer sr-only"
type="checkbox"
aria-label="수정일 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-settings-screen__field grid gap-2">
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
<input
v-model.number="form.postTagLimit"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="number"
:min="MIN_POST_TAG_LIMIT"
:max="MAX_POST_TAG_LIMIT"
step="1"
@blur="form.postTagLimit = normalizePostTagLimit(form.postTagLimit)"
>
<span class="text-xs leading-relaxed text-[#657080]">
최소 {{ MIN_POST_TAG_LIMIT }}개, 최대 {{ MAX_POST_TAG_LIMIT }}개까지 설정할 수 있습니다. 기본값은 {{ DEFAULT_POST_TAG_LIMIT }}개입니다.
</span>
</label>
<div class="admin-settings-screen__field grid gap-3 border-t border-[#eceff2] pt-5">
<div>
<span class="font-bold text-[#15171a]">표시개수 설정</span>
<p class="mt-1 text-xs leading-relaxed text-[#657080]">
공개 목록에서 한 번에 불러오고 보여줄 개수를 선택합니다.
</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">Latest</span>
<AdminSelectInput
v-model="form.homeLatestPostLimit"
:options="postListCountOptions"
label="Latest 표시개수"
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
/>
</div>
<div class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">Posts</span>
<AdminSelectInput
v-model="form.postListPageSize"
:options="postListCountOptions"
label="Posts 표시개수"
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
/>
</div>
<div class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">태그 게시물</span>
<AdminSelectInput
v-model="form.tagPostListPageSize"
:options="postListCountOptions"
label="태그 게시물 표시개수"
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
/>
</div>
<div class="grid gap-1.5">
<span class="text-xs font-semibold text-[#657080]">Featured</span>
<AdminSelectInput
v-model="form.featuredPostLimit"
:options="displayCountOptions"
label="Featured 표시개수"
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
/>
</div>
<div class="grid gap-1.5 sm:col-span-2">
<span class="text-xs font-semibold text-[#657080]">Pages 섹션</span>
<AdminSelectInput
v-model="form.pageSectionItemLimit"
:options="displayCountOptions"
label="Pages 섹션 표시개수"
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
/>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,523 @@
<script setup>
const slugInputValue = defineModel('slugInputValue', {
type: String,
default: ''
})
const settingsPublishDate = defineModel('settingsPublishDate', {
type: String,
default: ''
})
const settingsPublishTime = defineModel('settingsPublishTime', {
type: String,
default: ''
})
const tagInput = defineModel('tagInput', {
type: String,
default: ''
})
const activeTagSuggestionIndex = defineModel('activeTagSuggestionIndex', {
type: Number,
default: 0
})
const searchEngineVisible = defineModel('searchEngineVisible', {
type: Boolean,
default: true
})
const props = defineProps({
active: {
type: Boolean,
default: true
},
canAddMoreTags: {
type: Boolean,
default: true
},
canViewPost: {
type: Boolean,
default: false
},
createTagBadgeStyle: {
type: Function,
required: true
},
deleting: {
type: Boolean,
default: false
},
editorBlockPanel: {
type: Object,
default: () => ({ open: false, panel: null })
},
form: {
type: Object,
required: true
},
formatStatNumber: {
type: Function,
required: true
},
getTagColor: {
type: Function,
required: true
},
getTagDisplayName: {
type: Function,
required: true
},
getTagSuggestionMeta: {
type: Function,
required: true
},
hasTagSuggestions: {
type: Boolean,
default: false
},
hasUnsavedSlugChange: {
type: Boolean,
default: false
},
isScheduledPost: {
type: Function,
required: true
},
isSlugAutoFromTitle: {
type: Boolean,
default: false
},
postTagLimit: {
type: Number,
default: 0
},
postUrlHint: {
type: String,
default: ''
},
postWritingStats: {
type: Object,
default: () => ({ words: 0, characters: 0, spaces: 0, readingMinutes: 0, blocks: 0, images: 0 })
},
selectedTags: {
type: Array,
default: () => []
},
showDelete: {
type: Boolean,
default: false
},
slugFieldError: {
type: String,
default: ''
},
tagSuggestionOptions: {
type: Array,
default: () => []
},
viewPostUrl: {
type: String,
default: ''
}
})
const emit = defineEmits([
'add-gallery-images',
'close',
'commit-slug-input',
'delete-post',
'move-gallery-image',
'open-date-time-picker',
'panel-focus-in',
'panel-focus-out',
'remove-media-image',
'remove-tag',
'select-tag-suggestion',
'set-media-use-alt',
'set-tag-input-ref',
'tag-composition-end',
'tag-composition-start',
'toggle-tag-suggestions',
'open-tag-suggestions',
'tag-blur',
'tag-keydown',
'update-callout-options',
'update-code-options',
'update-embed-url',
'update-inline-color',
'update-media-image',
'update-quote-background',
'update-toggle-options'
])
/**
* 태그 입력 요소 참조를 부모 태그 상태로 전달한다.
* @param {HTMLInputElement | null} element - 태그 입력 요소
* @returns {void}
*/
const setTagInputRef = (element) => {
emit('set-tag-input-ref', element)
}
</script>
<template>
<aside
class="admin-post-settings-sidebar admin-post-form__settings flex h-screen shrink-0 flex-col overflow-hidden border-[#e3e6e8] bg-white transition-[width,border-color] duration-300 ease-out max-lg:fixed max-lg:inset-y-0 max-lg:right-0 max-lg:z-50 max-lg:max-w-[calc(100vw-40px)] max-lg:shadow-[-18px_0_60px_rgba(15,23,42,0.14)]"
:class="props.active ? 'w-[420px] border-l' : 'w-0 border-l-0'"
:aria-hidden="!props.active"
>
<div class="admin-post-settings-sidebar__inner admin-post-form__settings-inner relative flex h-full w-[420px] max-lg:w-full flex-col">
<div class="admin-post-settings-sidebar__header admin-post-form__settings-header flex h-[56px] shrink-0 items-center justify-between px-6">
<h2 class="admin-post-settings-sidebar__title admin-post-form__settings-title text-xl font-bold text-black">
게시물 설정
</h2>
<button class="admin-post-settings-sidebar__close admin-post-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="emit('close')">
<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-post-settings-sidebar__body admin-post-form__settings-body grid flex-1 content-start gap-4 overflow-y-auto px-6 pb-8 pt-8">
<div class="admin-post-settings-sidebar__field admin-post-form__field grid gap-1 text-sm">
<div class="admin-post-form__post-url-header flex h-[22px] items-center justify-between">
<span class="admin-post-form__label font-bold text-[#15171a]">Post URL</span>
<NuxtLink
v-if="props.canViewPost && props.viewPostUrl"
class="admin-post-form__view-post 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="props.viewPostUrl"
target="_blank"
>
<span>View Post</span>
<span aria-hidden="true">↗</span>
</NuxtLink>
</div>
<label
class="admin-post-form__post-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]"
:class="{ 'admin-post-form__post-url-input--auto': props.isSlugAutoFromTitle }"
>
<span class="admin-post-form__post-url-icon text-sm text-[#394047]" aria-hidden="true">⌘</span>
<input
v-model="slugInputValue"
class="admin-post-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:class="props.isSlugAutoFromTitle ? 'text-[#8e9cac]' : 'text-black'"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
:required="!props.isSlugAutoFromTitle"
:aria-describedby="props.slugFieldError ? 'admin-post-form-slug-error' : (props.isSlugAutoFromTitle ? 'admin-post-form-slug-auto-hint' : undefined)"
@blur="emit('commit-slug-input')"
>
</label>
<p
v-if="props.isSlugAutoFromTitle"
id="admin-post-form-slug-auto-hint"
class="admin-post-form__post-url-auto-hint text-xs text-[#8e9cac]"
>
제목에서 자동 생성됩니다. 직접 수정하면 고정됩니다.
</p>
<p class="admin-post-form__post-url-hint text-xs text-[#7c8b9a]">
{{ props.postUrlHint }}
<span v-if="props.hasUnsavedSlugChange" class="admin-post-form__post-url-hint-unsaved text-[#8e9cac]"> (저장 후 반영)</span>
</p>
<p
v-if="props.slugFieldError"
id="admin-post-form-slug-error"
class="admin-post-form__post-url-error text-xs font-semibold text-red-600"
>
{{ props.slugFieldError }}
</p>
</div>
<label class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">상태</span>
<span class="admin-post-form__select-wrap relative block">
<select v-model="props.form.status" class="admin-post-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="members">멤버십</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="props.form.status === 'members'" class="admin-post-form__hint text-xs text-muted">
VIP 이상 등급 회원에게만 공개됩니다.
</span>
<span v-else-if="props.form.status === 'private'" class="admin-post-form__hint text-xs text-muted">
공개 사용자 화면에서는 보이지 않습니다.
</span>
</label>
<div v-if="props.form.status === 'published'" class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">발행 시각</span>
<div class="admin-post-form__publish-datetime-row grid grid-cols-2 gap-2">
<label class="admin-post-form__publish-date-field relative block">
<span class="sr-only">발행 날짜</span>
<input
v-model="settingsPublishDate"
class="admin-post-form__publish-date-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-9 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="date"
>
<button
class="admin-post-form__publish-date-icon absolute right-1 top-1/2 grid size-8 -translate-y-1/2 place-items-center rounded text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="발행 날짜 선택"
@click="emit('open-date-time-picker', $event)"
>
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 2v3M16 2v3M3 9h18M5 5h14a2 2 0 012 2v13a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z" stroke="currentColor" stroke-linecap="round" stroke-width="1.5" />
</svg>
</button>
</label>
<label class="admin-post-form__publish-time-field relative block">
<span class="sr-only">발행 시각</span>
<input
v-model="settingsPublishTime"
class="admin-post-form__publish-time-input h-[38px] w-full rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-12 text-sm text-[#15171a] transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="time"
>
<button
class="admin-post-form__publish-time-zone absolute right-1 top-1/2 grid h-8 min-w-[2.5rem] -translate-y-1/2 place-items-center rounded px-2 text-xs font-medium text-[#8e9cac] transition-colors hover:bg-[#e3e6e8] hover:text-[#394047]"
type="button"
tabindex="-1"
aria-label="발행 시각 선택"
@click="emit('open-date-time-picker', $event)"
>
KST
</button>
</label>
</div>
<span class="admin-post-form__hint text-xs text-muted">
{{ props.isScheduledPost() ? '미래 시각이면 예약 발행으로 저장됩니다.' : '비워두면 저장 시점으로 발행됩니다.' }}
</span>
</div>
<label class="admin-post-settings-sidebar__field admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">요약</span>
<textarea
v-model="props.form.excerpt"
class="admin-post-form__textarea min-h-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
/>
</label>
<div class="admin-post-settings-sidebar__field admin-post-form__field grid gap-1 text-sm">
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
<div class="admin-post-form__tag-combobox relative">
<div
class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
:class="!props.canAddMoreTags ? 'opacity-85' : ''"
>
<span
v-for="tag in props.selectedTags"
:key="tag"
class="admin-post-form__tag-badge inline-flex h-6 shrink-0 items-center gap-1.5 rounded-[3px] border px-2 text-sm font-semibold"
:style="props.createTagBadgeStyle(props.getTagColor(tag))"
>
<span>{{ props.getTagDisplayName(tag) }}</span>
<button
class="admin-post-form__tag-remove inline-flex size-4 shrink-0 items-center justify-center rounded text-current transition-colors hover:bg-white/45"
type="button"
:aria-label="`${props.getTagDisplayName(tag)} 태그 삭제`"
@click="emit('remove-tag', tag)"
>
<svg class="size-2.5" version="1" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" 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" />
</svg>
</button>
</span>
<input
:ref="setTagInputRef"
v-model="tagInput"
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
:placeholder="props.canAddMoreTags ? '태그 입력' : `최대 ${props.postTagLimit}개까지 선택됨`"
:disabled="!props.canAddMoreTags"
role="combobox"
:aria-expanded="props.hasTagSuggestions"
aria-autocomplete="list"
@focus="emit('open-tag-suggestions')"
@input="emit('open-tag-suggestions')"
@compositionupdate="tagInput = $event.target.value; emit('open-tag-suggestions')"
@blur="emit('tag-blur')"
@keydown="emit('tag-keydown', $event)"
@compositionstart="emit('tag-composition-start')"
@compositionend="emit('tag-composition-end')"
>
<button
class="admin-post-form__tag-dropdown-trigger ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#15171a] transition-colors hover:bg-[#e3e6e8] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac]"
type="button"
:aria-expanded="props.hasTagSuggestions"
:disabled="!props.canAddMoreTags"
aria-label="태그 추천 목록 열기"
@mousedown.prevent="emit('toggle-tag-suggestions')"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" 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>
</button>
</div>
<p class="mt-1 text-xs text-[#8e9cac]">
{{ props.selectedTags.length }} / {{ props.postTagLimit }}개 선택됨
</p>
<AdminPopoverSurface
v-if="props.hasTagSuggestions"
root-class="admin-post-form__tag-suggestions absolute left-0 right-0 top-full z-40 mt-1 max-h-64 overflow-y-auto rounded-lg border-[#d7dce0] py-1 text-sm"
role="listbox"
>
<button
v-for="(tag, index) in props.tagSuggestionOptions"
:key="tag.id || tag.slug"
class="admin-post-form__tag-suggestion flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors"
:class="index === activeTagSuggestionIndex ? 'bg-[#f3f5f7]' : 'hover:bg-[#f7f8f9]'"
type="button"
role="option"
:aria-selected="index === activeTagSuggestionIndex"
@mouseenter="activeTagSuggestionIndex = index"
@mousedown.prevent="emit('select-tag-suggestion', tag)"
>
<span class="admin-post-form__tag-suggestion-name min-w-0 truncate font-semibold text-[#15171a]">{{ tag.name }}</span>
<span class="admin-post-form__tag-suggestion-meta shrink-0 text-xs text-muted">{{ props.getTagSuggestionMeta(tag) }}</span>
</button>
</AdminPopoverSurface>
</div>
</div>
<label
class="admin-post-form__featured-image-display-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm"
>
<span class="admin-post-form__featured-image-display-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-image-display-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m7 15 3-3 2 2 3-4 2 5" />
<circle cx="8" cy="9" r="1" />
</svg>
</span>
<span class="admin-post-form__featured-image-display-label grid min-w-0 gap-0.5">
<span class="font-bold text-[#15171a]">대표 이미지 표시</span>
</span>
</span>
<span class="admin-post-form__featured-image-display-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="props.form.showFeaturedImage"
class="peer sr-only"
type="checkbox"
aria-label="대표 이미지 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 17.28l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 3.5z" />
</svg>
</span>
<span class="admin-post-form__featured-toggle-label font-bold text-[#15171a]">
추천 글
</span>
</span>
<span class="admin-post-form__featured-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="props.form.isFeatured"
class="peer sr-only"
type="checkbox"
aria-label="추천 글로 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-post-form__search-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
<span class="admin-post-form__search-toggle-copy flex min-w-0 items-center gap-3">
<span class="admin-post-form__search-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-100q-78.77 0-148.11-29.96-69.35-29.96-120.66-81.27-51.31-51.31-81.27-120.66Q100-401.23 100-480q0-78.77 29.96-148.11 29.96-69.35 81.27-120.66 51.31-51.31 120.66-81.27Q401.23-860 480-860q142.92 0 248.38 91.89 105.47 91.88 126.31 228.73h-61.23q-14.77-79.93-65.61-143.2Q677-745.85 600-776.77V-760q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h76.92v120H360L168-552q-3 18-5.5 36t-2.5 36q0 131 92 225t228 95v60Zm362.46-15.39-133-132.23q-19.46 12.39-41.92 20Q645.08-220 620-220q-66.92 0-113.46-46.54Q460-313.08 460-380q0-66.92 46.54-113.46Q553.08-540 620-540q66.92 0 113.46 46.54Q780-446.92 780-380q0 25.46-7.81 48.12-7.81 22.65-20.58 42.11l133 132.23-42.15 42.15ZM691-309q29-29 29-71t-29-71q-29-29-71-29t-71 29q-29 29-29 71t29 71q29 29 71 29t71-29Z"/></svg>
</span>
<span class="admin-post-form__search-toggle-label font-bold text-[#15171a]">
검색엔진 노출
</span>
</span>
<span class="admin-post-form__search-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="searchEngineVisible"
class="peer sr-only"
type="checkbox"
aria-label="검색엔진에 노출"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
</div>
<div class="admin-post-settings-sidebar__bottom admin-post-form__settings-bottom grid shrink-0 gap-4 border-t border-[#e3e6e8] px-8 py-6">
<section class="admin-post-form__writing-stats grid gap-2 text-xs text-[#657080]" aria-label="본문 통계">
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">단어</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ props.formatStatNumber(props.postWritingStats.words) }}개</span>
</div>
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">문자</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ props.formatStatNumber(props.postWritingStats.characters) }}개 <span class="text-[#8e9cac]">({{ props.formatStatNumber(props.postWritingStats.spaces) }} 공백)</span></span>
</div>
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">읽기 시간</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">{{ props.postWritingStats.readingMinutes ? `약 ${props.formatStatNumber(props.postWritingStats.readingMinutes)}분` : '0분' }}</span>
</div>
<div class="admin-post-form__writing-stats-row flex items-center justify-between gap-3">
<span class="admin-post-form__writing-stats-label">구성</span>
<span class="admin-post-form__writing-stats-value font-medium text-[#394047]">블록 {{ props.formatStatNumber(props.postWritingStats.blocks) }}개 · 이미지 {{ props.formatStatNumber(props.postWritingStats.images) }}개</span>
</div>
</section>
<button
v-if="props.showDelete"
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d7dde2] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-[#d21a26] hover:bg-red-50 hover:text-[#d21a26] disabled:opacity-50"
type="button"
:disabled="props.deleting"
@click="emit('delete-post')"
>
<svg
class="admin-post-form__delete-post-icon size-5 shrink-0"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.923 22.308H6.077a1.692 1.692 0 01-1.692-1.693V5.385h15.23v15.23a1.692 1.692 0 01-1.692 1.693zM9.462 17.23v-6.769M14.539 17.23v-6.769M1 5.385h22M14.539 2H9.462a1.692 1.692 0 00-1.693 1.692v1.693h8.462V3.692A1.692 1.692 0 0014.538 2z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
/>
</svg>
<span>{{ props.deleting ? '삭제 중' : 'Delete post' }}</span>
</button>
</div>
<AdminEditorBlockPanel
:open="props.editorBlockPanel.open"
:panel="props.editorBlockPanel.panel"
@panel-focus-in="emit('panel-focus-in')"
@panel-focus-out="emit('panel-focus-out')"
@update-media-image="(...args) => emit('update-media-image', ...args)"
@set-media-use-alt="(...args) => emit('set-media-use-alt', ...args)"
@move-gallery-image="(...args) => emit('move-gallery-image', ...args)"
@remove-media-image="emit('remove-media-image', $event)"
@add-gallery-images="emit('add-gallery-images', $event)"
@update-embed-url="emit('update-embed-url', $event)"
@update-quote-background="emit('update-quote-background', $event)"
@update-callout-options="emit('update-callout-options', $event)"
@update-code-options="emit('update-code-options', $event)"
@update-toggle-options="emit('update-toggle-options', $event)"
@update-inline-color="emit('update-inline-color', $event)"
/>
</div>
</aside>
</template>

View File

@@ -0,0 +1,152 @@
<script setup>
import { formatAnalyticsCount, getActivePageHref, getActivePageTitle, getRealtimePages } from '~/lib/analytics-display.js'
const props = defineProps({ session: { type: Object, required: true } })
const pages = computed(() => getRealtimePages(props.session))
const triggerRef = ref(null)
const panelRef = ref(null)
const isOpen = ref(false)
const isPinned = ref(false)
const popoverStyle = ref({})
const popoverId = useId()
let closeTimer = null
/**
* 열린 페이지 목록이 화면 안에 표시되도록 위치를 계산한다.
* @returns {void}
*/
const updatePosition = () => {
if (!triggerRef.value || !isOpen.value) return
const rect = triggerRef.value.getBoundingClientRect()
const width = Math.min(480, window.innerWidth - 24)
const maxHeight = Math.min(320, window.innerHeight - 24)
const height = Math.min(panelRef.value?.parentElement?.offsetHeight || maxHeight, maxHeight)
const top = rect.bottom + height + 8 <= window.innerHeight ? rect.bottom + 4 : Math.max(12, rect.top - height - 4)
popoverStyle.value = {
left: `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`,
top: `${top}px`, width: `${width}px`, maxHeight: `${maxHeight}px`
}
}
/**
* 목록 닫기 예약을 취소한다.
* @returns {void}
*/
const cancelClose = () => { clearTimeout(closeTimer) }
/**
* 열린 페이지 목록을 표시한다.
* @returns {Promise<void>} 위치 반영
*/
const openPages = async () => {
if (pages.value.length <= 1) return
cancelClose()
isOpen.value = true
await nextTick()
updatePosition()
}
/**
* 고정하지 않은 목록을 포인터 이동 후 닫는다.
* @returns {void}
*/
const scheduleClose = () => {
cancelClose()
closeTimer = setTimeout(() => {
if (!isPinned.value && !panelRef.value?.contains(document.activeElement) && document.activeElement !== triggerRef.value) isOpen.value = false
}, 150)
}
/**
* 클릭이나 Enter로 목록 고정을 전환한다.
* @returns {void}
*/
const togglePinned = () => {
isPinned.value = !isPinned.value
if (isPinned.value) openPages()
else isOpen.value = false
}
/**
* 목록을 닫고 필요하면 트리거로 포커스를 돌린다.
* @param {boolean} [restoreFocus] - 포커스 복원 여부
* @returns {void}
*/
const closePages = (restoreFocus = false) => {
isPinned.value = false
if (restoreFocus) triggerRef.value?.focus()
isOpen.value = false
}
/**
* 목록 바깥 클릭을 처리한다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const handleOutside = (event) => {
if (!triggerRef.value?.contains(event.target) && !panelRef.value?.contains(event.target)) closePages()
}
/**
* Escape로 페이지 목록을 닫는다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleKeydown = (event) => {
if (!isOpen.value) return
if (event.key === 'Escape') {
event.preventDefault()
closePages(true)
}
if (event.key === 'ArrowDown' && document.activeElement === triggerRef.value) {
event.preventDefault()
panelRef.value?.querySelector('a, button')?.focus()
}
}
watch(() => pages.value.length, (count) => {
if (count <= 1) closePages()
})
onMounted(() => {
document.addEventListener('pointerdown', handleOutside)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
})
onBeforeUnmount(() => {
cancelClose()
document.removeEventListener('pointerdown', handleOutside)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
})
</script>
<template>
<div class="admin-realtime-pages min-w-0">
<button v-if="pages.length > 1" ref="triggerRef" type="button" class="admin-realtime-pages__trigger flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted hover:text-ink" :aria-expanded="isOpen" :aria-controls="popoverId" aria-label="열린 페이지 목록" @mouseenter="openPages" @mouseleave="scheduleClose" @focus="openPages" @blur="scheduleClose" @click="togglePinned">
<span class="admin-realtime-pages__latest min-w-0 truncate">{{ getActivePageTitle(pages[0]) }}</span>
<span v-if="pages.length > 1" class="admin-realtime-pages__extra shrink-0 font-medium text-[#2563eb]">외 <strong class="admin-realtime-pages__extra-number font-bold">{{ formatAnalyticsCount(pages.length - 1) }}</strong>개</span>
</button>
<a v-else-if="getActivePageHref(pages[0])" :href="getActivePageHref(pages[0])" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__single block truncate text-xs text-muted hover:text-ink hover:underline">{{ getActivePageTitle(pages[0]) }}</a>
<span v-else class="admin-realtime-pages__single block truncate text-xs text-muted">{{ getActivePageTitle(pages[0]) }}</span>
<Teleport to="body">
<AdminPopoverSurface v-if="isOpen && pages.length > 1" :id="popoverId" root-class="admin-realtime-pages__popover admin-layout--light-controls fixed z-[80] overflow-y-auto rounded-lg border-line p-3 text-xs text-[#15171a]" :surface-style="popoverStyle" @mouseenter="cancelClose" @mouseleave="scheduleClose" @focusin="cancelClose" @focusout="scheduleClose">
<div ref="panelRef">
<div class="mb-2 flex items-center justify-between gap-3">
<p class="font-semibold">열린 페이지 {{ formatAnalyticsCount(pages.length) }}개 · 최근 확인순</p>
<button type="button" class="shrink-0 text-muted hover:text-ink" aria-label="열린 페이지 목록 닫기" @click="closePages(true)">닫기</button>
</div>
<p class="mb-2 text-muted">IP {{ session.ipAddress || '-' }}</p>
<ul class="admin-realtime-pages__list divide-y divide-line">
<li v-for="(page, index) in pages" :key="page.key || index" class="py-2">
<a v-if="getActivePageHref(page)" :href="getActivePageHref(page)" target="_blank" rel="noopener noreferrer" class="admin-realtime-pages__link block break-words hover:underline">{{ getActivePageTitle(page) }} <span aria-hidden="true">↗</span><span class="sr-only"> 새 창으로 열기</span></a>
<span v-else class="break-words">{{ getActivePageTitle(page) }}</span>
</li>
</ul>
</div>
</AdminPopoverSurface>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,194 @@
<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">
<AdminPopoverSurface
v-if="isOpen"
root-class="admin-row-more-menu__popover fixed z-[80] rounded-xl border-[#e2e5e9] py-2 text-sm text-[#3f4650]"
:surface-style="popoverStyle"
clipped
role="menu"
data-admin-row-menu
>
<slot />
</AdminPopoverSurface>
</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,80 @@
<script setup>
const props = defineProps({
/** 검색 입력 표시값 */
value: {
type: String,
default: ''
},
/** 검색 입력 placeholder */
placeholder: {
type: String,
default: '검색'
},
/** 스크린 리더용 입력 라벨 */
label: {
type: String,
default: '검색'
},
/** label 루트에 추가할 화면별 클래스 */
rootClass: {
type: String,
default: ''
},
/** input에 추가할 화면별 클래스 */
inputClass: {
type: String,
default: ''
},
/** input 너비 클래스 */
widthClass: {
type: String,
default: 'w-52'
}
})
const emit = defineEmits(['input', 'compositionupdate', 'compositionend'])
const inputRef = ref(null)
/**
* 검색 입력에 포커스를 이동한다.
* @returns {void}
*/
const focus = () => {
inputRef.value?.focus()
}
/**
* 검색 입력 텍스트를 선택한다.
* @returns {void}
*/
const select = () => {
inputRef.value?.select()
}
defineExpose({
focus,
select
})
</script>
<template>
<label class="admin-search-input relative block" :class="rootClass">
<span class="sr-only">{{ label }}</span>
<svg class="admin-search-input__icon pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
<input
ref="inputRef"
:value="props.value"
class="admin-search-input__field h-10 rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
:class="[widthClass, inputClass]"
type="search"
:placeholder="placeholder"
:aria-label="label"
@input="emit('input', $event)"
@compositionupdate="emit('compositionupdate', $event)"
@compositionend="emit('compositionend', $event)"
>
</label>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
const props = defineProps({
/** 선택 입력 값 */
modelValue: {
type: [String, Number],
default: ''
},
/** 선택 옵션 목록 */
options: {
type: Array,
default: () => []
},
/** 스크린 리더용 선택 라벨 */
label: {
type: String,
default: '선택'
},
/** label 루트에 추가할 화면별 클래스 */
rootClass: {
type: String,
default: ''
},
/** select에 추가할 화면별 클래스 */
selectClass: {
type: String,
default: ''
},
/** select 너비 클래스 */
widthClass: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
/**
* option에 전달할 실제 값을 반환한다.
* @param {Object} option - 선택 옵션
* @returns {string|number} 선택 값
*/
const getOptionValue = (option) => option.value
/**
* 선택 값을 상위 컴포넌트에 전달한다.
* @param {Event} event - 변경 이벤트
* @returns {void}
*/
const updateValue = (event) => {
const selectedValue = event.target?.value || ''
const selectedOption = props.options.find((option) => String(option.value) === selectedValue)
emit('update:modelValue', selectedOption ? selectedOption.value : selectedValue)
}
</script>
<template>
<label class="admin-select-input" :class="rootClass">
<span class="sr-only">{{ label }}</span>
<span class="admin-select-input__wrap relative block">
<select
:value="props.modelValue"
class="admin-select-input__field h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]"
:class="[widthClass, selectClass]"
:aria-label="label"
@change="updateValue"
>
<option v-for="option in options" :key="option.value" :value="getOptionValue(option)">
{{ option.label }}
</option>
</select>
<svg class="admin-select-input__chevron pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
</label>
</template>

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,119 @@
<script setup>
/**
* 관리자 설정 화면 내비게이션
* @property {string} searchValue - 검색 입력 표시값
* @property {Array} filteredGroups - 검색 결과가 반영된 내비 그룹
* @property {Array} groups - 전체 내비 그룹
* @property {string} activeSectionId - 현재 활성 섹션 id
*/
defineProps({
searchValue: {
type: String,
default: ''
},
filteredGroups: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
activeSectionId: {
type: String,
default: ''
}
})
const emit = defineEmits([
'search-input',
'search-composition-end',
'navigate'
])
/**
* 모바일 구역 선택 변경을 부모 내비게이션 요청으로 전달한다.
* @param {Event} event - change 이벤트
* @returns {void}
*/
const onMobileNavChange = (event) => {
const target = event.target
if (!(target instanceof HTMLSelectElement) || !target.value) {
return
}
emit('navigate', target.value)
}
</script>
<template>
<aside class="admin-settings-navigation admin-settings-screen__nav-column w-full shrink-0 border-b border-[#e6e8eb] bg-[#f7f8fa] lg:w-72 lg:max-w-[320px] lg:flex-none lg:border-b-0 lg:border-r lg:border-[#e6e8eb]">
<div class="admin-settings-navigation__inner admin-settings-screen__nav-inner max-h-[40vh] overflow-y-auto p-4 lg:max-h-none lg:h-full lg:overflow-y-auto lg:p-5">
<label class="admin-settings-navigation__search admin-settings-screen__search relative mb-4 block">
<span class="sr-only">설정 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa3ad]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="1.5" />
<path d="M16 16l4.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<input
:value="searchValue"
class="admin-settings-navigation__search-input admin-settings-screen__search-input w-full rounded-md border border-[#dce0e5] bg-white py-2 pr-3 pl-9 text-sm text-[#15171a] placeholder:text-[#9aa3ad] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="search"
autocomplete="off"
placeholder="설정 검색"
@input="$emit('search-input', $event)"
@compositionupdate="$emit('search-input', $event)"
@compositionend="$emit('search-composition-end', $event)"
>
</label>
<p
v-if="filteredGroups.length === 0"
class="admin-settings-navigation__empty admin-settings-screen__nav-empty rounded-md border border-dashed border-[#dce0e5] px-3 py-4 text-center text-xs text-[#657080]"
>
검색과 일치하는 설정 항목이 없습니다.
</p>
<nav v-else class="admin-settings-navigation__list admin-settings-screen__nav grid gap-5 text-sm" aria-label="설정 구역">
<template v-for="(group, gi) in filteredGroups" :key="group.heading">
<div v-if="gi > 0" class="admin-settings-navigation__separator admin-settings-screen__nav-separator h-px bg-[#dce0e5]" aria-hidden="true" />
<div class="admin-settings-navigation__group admin-settings-screen__nav-group">
<p class="admin-settings-navigation__heading admin-settings-screen__nav-heading mb-2 px-2 text-xs font-semibold tracking-wide text-[#9aa3ad] uppercase">
{{ group.heading }}
</p>
<ul class="admin-settings-navigation__items admin-settings-screen__nav-list grid gap-0.5">
<li v-for="item in group.items" :key="item.id">
<button
class="admin-settings-navigation__item admin-settings-screen__nav-item flex w-full items-center gap-2 rounded-md px-2 py-2 text-left font-medium text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
:class="activeSectionId === item.id ? 'bg-[#e9ecef] text-[#15171a]' : ''"
type="button"
@click="$emit('navigate', item.id)"
>
<AdminSettingsNavIcon :icon-id="item.iconId" />
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
</button>
</li>
</ul>
</div>
</template>
</nav>
<label class="admin-settings-navigation__jump admin-settings-screen__nav-jump mt-4 grid gap-1 lg:hidden">
<span class="mb-1 block text-xs font-medium text-[#657080]">구역 이동</span>
<span class="relative">
<select
class="w-full appearance-none rounded-md border border-[#dce0e5] bg-white px-2 py-2 pr-10 text-sm"
:value="activeSectionId"
@change="onMobileNavChange"
>
<optgroup v-for="group in groups" :key="group.heading" :label="group.heading">
<option v-for="item in group.items" :key="item.id" :value="item.id">
{{ item.label }}
</option>
</optgroup>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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>
</label>
</div>
</aside>
</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,196 @@
<script setup>
const logoInputRef = ref(null)
/**
* 사이트 정보 설정 카드
* @property {Object} form - 사이트 설정 폼 객체
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} uploadingLogo - 로고 업로드 중 여부
* @property {boolean} hasChanges - 변경 여부
*/
defineProps({
form: {
type: Object,
required: true
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
uploadingLogo: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
}
})
defineEmits(['begin', 'cancel', 'save', 'upload-logo'])
/**
* 로고 파일 선택창을 연다.
* @returns {void}
*/
const openLogoFilePicker = () => {
logoInputRef.value?.click()
}
</script>
<template>
<section
id="admin-settings-section-misc"
class="admin-site-info-settings-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<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]"
>
로고, 공개 URL, 푸터 저작권 문구를 관리합니다.
</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-info-settings-card__readonly admin-settings-screen__misc-readonly grid gap-5 pt-5"
>
<div class="flex flex-col gap-5 md:flex-row md:items-start">
<div
class="admin-settings-screen__logo-preview grid h-16 w-16 shrink-0 place-items-center overflow-hidden rounded-2xl bg-[#f7f8fa]"
:class="form.logoUrl ? 'border border-[#e6e8eb]' : 'border border-dashed border-[#cfd6df]'"
>
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt="사이트 로고"
>
<span v-else class="text-sm font-semibold text-[#8b95a1]">
미등록
</span>
</div>
<div class="admin-settings-screen__readonly-list grid min-w-0 flex-1 gap-4 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">
<h3 class="text-sm font-normal text-[#3f4650]">
사이트 URL
</h3>
<p class="min-w-0 break-all text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.siteUrl || '—' }}
</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">
<h3 class="text-sm font-normal text-[#3f4650]">
저작권 문구
</h3>
<p class="min-w-0 whitespace-pre-wrap text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.copyrightText || '—' }}
</p>
</div>
</div>
</div>
</div>
<div v-else class="admin-site-info-settings-card__edit admin-settings-screen__card-body grid gap-6">
<div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-4">
<div class="grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-[#e6e8eb] bg-[#f7f8fa]">
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt="사이트 로고"
>
<span v-else class="text-2xl font-semibold text-[#9aa3ad]">
{{ form.logoText || '井' }}
</span>
</div>
<div>
<h3 class="text-base font-semibold text-[#15171a]">
로고
</h3>
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
</p>
</div>
</div>
<button
class="h-10 shrink-0 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingLogo"
@click="openLogoFilePicker"
>
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
</button>
<input
ref="logoInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingLogo"
@change="$emit('upload-logo', $event)"
>
</div>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">사이트 URL</span>
<input
v-model="form.siteUrl"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="url"
required
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">저작권 문구</span>
<input
v-model="form.copyrightText"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
required
>
</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/html/link/iframe -->
<template v-else-if="['html', 'link', 'iframe'].includes(commandId)">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
</template>
<!-- fallback -->
<template v-else>
<path
fill="currentColor"
fill-rule="evenodd"
d="M12 4.5c.46 0 .833.373.833.833v5.834h5.834a.833.833 0 0 1 0 1.666h-5.834v5.834a.833.833 0 0 1-1.666 0v-5.834H5.333a.833.833 0 0 1 0-1.666h5.834V5.333c0-.46.373-.833.833-.833"
clip-rule="evenodd"
/>
</template>
</svg>
</template>

View File

@@ -0,0 +1,214 @@
<script setup>
import {
SOCIAL_ICON_PRESETS,
getSocialIconPreset,
normalizeSocialLinks
} from '~/lib/social-links.js'
const socialIconSelectOptions = SOCIAL_ICON_PRESETS.map((preset) => ({
value: preset.icon,
label: preset.label
}))
/**
* SNS 설정 카드
* @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'])
/**
* SNS 링크 항목을 추가한다.
* @param {Object} form - 사이트 설정 폼 객체
* @returns {void}
*/
const addSocialLink = (form) => {
form.socialLinks = [
...normalizeSocialLinks(form.socialLinks),
{
id: `social-${Date.now()}`,
icon: SOCIAL_ICON_PRESETS[0].icon,
label: SOCIAL_ICON_PRESETS[0].label,
url: '',
iconSvg: ''
}
]
}
/**
* SNS 링크 항목을 제거한다.
* @param {Object} form - 사이트 설정 폼 객체
* @param {number} index - 제거할 항목 순서
* @returns {void}
*/
const removeSocialLink = (form, index) => {
form.socialLinks = form.socialLinks.filter((_, itemIndex) => itemIndex !== index)
}
/**
* SNS 링크 아이콘을 변경한다.
* @param {Object} form - 사이트 설정 폼 객체
* @param {number} index - 항목 순서
* @param {string} icon - 아이콘 키
* @returns {void}
*/
const updateSocialLinkIcon = (form, index, icon) => {
const preset = getSocialIconPreset(icon)
form.socialLinks[index].icon = preset.icon
form.socialLinks[index].label = preset.label
if (preset.icon !== 'custom') {
form.socialLinks[index].iconSvg = ''
}
}
</script>
<template>
<section
id="admin-settings-section-social"
class="admin-social-settings-card admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<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">
SNS 정보
</h2>
<p
v-if="!editing"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
오른쪽 사이드바 FOLLOW 영역에 표시할 아이콘과 주소를 관리합니다.
</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-social-settings-card__readonly admin-settings-screen__social-readonly pt-5"
>
<div
v-if="normalizeSocialLinks(form.socialLinks).length"
class="flex flex-wrap gap-2"
>
<span
v-for="item in normalizeSocialLinks(form.socialLinks)"
:key="item.id"
class="inline-flex items-center gap-2 rounded-full border border-[#dce0e5] bg-[#f7f8fa] px-3 py-1.5 text-sm font-semibold text-[#15171a]"
>
<span>{{ item.label }}</span>
<span class="max-w-[220px] truncate text-xs font-medium text-[#657080]">{{ item.url }}</span>
</span>
</div>
<p v-else class="text-sm text-[#657080]">
등록된 SNS 링크가 없습니다. 공개 화면의 FOLLOW 아이콘도 표시되지 않습니다.
</p>
</div>
<div v-else class="admin-social-settings-card__edit admin-settings-screen__social-edit grid gap-4 border-t border-[#eceff2] pt-5">
<div
v-for="(item, index) in form.socialLinks"
:key="item.id || index"
class="grid gap-3 rounded-lg border border-[#edf0f2] bg-[#fafafa] p-3 md:grid-cols-[220px_minmax(0,1fr)_auto] md:items-start"
>
<div class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">아이콘</span>
<AdminSelectInput
:model-value="item.icon"
:options="socialIconSelectOptions"
label="SNS 아이콘"
select-class="admin-settings-screen__select h-10 rounded-md border-[#dce0e5] text-[#15171a] focus:ring-1 focus:ring-[#15171a]"
width-class="w-full"
@update:model-value="(icon) => updateSocialLinkIcon(form, index, icon)"
/>
</div>
<label class="grid gap-1.5 text-sm">
<span class="font-medium text-[#3f4650]">주소</span>
<input
v-model="item.url"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
:placeholder="getSocialIconPreset(item.icon).placeholder"
>
</label>
<button
class="mt-[26px] h-10 rounded-md border border-[#ffd1d1] bg-white px-4 text-sm font-semibold text-[#d73939] transition-colors hover:bg-[#fff5f5]"
type="button"
@click="removeSocialLink(form, index)"
>
삭제
</button>
<label
v-if="item.icon === 'custom'"
class="grid gap-1.5 text-sm md:col-span-3"
>
<span class="font-medium text-[#3f4650]">사용자 SVG 아이콘</span>
<textarea
v-model="item.iconSvg"
class="min-h-24 rounded-md border border-[#dce0e5] bg-white px-3 py-2 font-mono text-xs leading-relaxed text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
placeholder="<svg ...>...</svg>"
/>
<span class="text-xs leading-relaxed text-[#8b95a1]">
새 SNS 아이콘은 SVG 코드를 붙여 넣어 사용할 수 있습니다. 저장 시 기본적인 위험 태그는 제거됩니다.
</span>
</label>
</div>
<button
class="inline-flex h-10 w-fit items-center justify-center rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7]"
type="button"
@click="addSocialLink(form)"
>
SNS 링크 추가
</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,120 @@
<script setup>
/**
* 스팸 필터 설정 카드
* @property {Array<string>} blockedUsernames - 가입 금지 닉네임 목록
* @property {string} blockedUsernamesText - 가입 금지 닉네임 textarea 값
* @property {boolean} editing - 편집 모드 여부
* @property {boolean} saving - 저장 중 여부
* @property {boolean} hasChanges - 변경 여부
*/
defineProps({
blockedUsernames: {
type: Array,
default: () => []
},
blockedUsernamesText: {
type: String,
default: ''
},
editing: {
type: Boolean,
default: false
},
saving: {
type: Boolean,
default: false
},
hasChanges: {
type: Boolean,
default: false
}
})
defineEmits(['begin', 'cancel', 'save', 'update:blockedUsernamesText'])
</script>
<template>
<section
id="admin-settings-section-spam"
class="admin-spam-settings-card admin-settings-screen__card admin-settings-screen__card--spam 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]"
>
회원가입 시 사용할 수 없는 닉네임을 지정합니다. 닉네임에 해당 단어가 포함되면 가입이 거부됩니다.
</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-spam-settings-card__readonly admin-settings-screen__spam-readonly border-t border-[#eceff2] pt-5 text-sm"
>
<p class="font-medium text-[#3f4650]">
가입 금지 닉네임
</p>
<ul class="mt-3 grid gap-1.5 text-[#657080]">
<li
v-for="term in blockedUsernames"
:key="term"
class="font-mono text-[13px] text-[#15171a]"
>
{{ term }}
</li>
</ul>
</div>
<div
v-else
class="admin-spam-settings-card__edit admin-settings-screen__spam-edit grid gap-2 border-t border-[#eceff2] pt-5 text-sm"
>
<label class="admin-settings-screen__field grid gap-2">
<span class="font-medium text-[#3f4650]">가입 금지 닉네임</span>
<p class="text-xs leading-relaxed text-[#657080]">
한 줄에 하나씩 입력합니다. 대소문자는 구분하지 않으며, 닉네임에 해당 단어가 포함되면 사용할 수 없습니다.
</p>
<textarea
:value="blockedUsernamesText"
class="min-h-[10rem] 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="8"
placeholder="admin&#10;master&#10;zenn"
@input="$emit('update:blockedUsernamesText', $event.target.value)"
/>
</label>
</div>
</section>
</template>

View File

@@ -0,0 +1,342 @@
<script setup>
const props = defineProps({
initialTag: {
type: Object,
default: () => ({})
},
submitLabel: {
type: String,
default: '저장'
},
saving: {
type: Boolean,
default: false
},
requireChanges: {
type: Boolean,
default: false
},
defaultTagType: {
type: String,
default: 'general'
}
})
const emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialTag.slug))
const coverInputRef = ref(null)
const uploadingCover = ref(false)
const coverErrorMessage = ref('')
const form = reactive({
name: props.initialTag.name || '',
slug: props.initialTag.slug || '',
description: props.initialTag.description || '',
headerImageUrl: props.initialTag.headerImageUrl || '',
headerTitle: props.initialTag.headerTitle || '',
headerText: props.initialTag.headerText || '',
color: props.initialTag.color || '#15171a'
})
/**
* 태그 입력값을 저장 비교용 형태로 정규화한다.
* @param {Object} tag - 태그 입력값
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: 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(),
headerImageUrl: String(tag.headerImageUrl || '').trim(),
headerTitle: String(tag.headerTitle || '').trim(),
headerText: String(tag.headerText || '').trim(),
sortOrder: Number(tag.sortOrder ?? 0),
color: String(tag.color || '#15171a'),
tagType: String(tag.tagType || props.defaultTagType)
})
/**
* 현재 폼 입력값
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
*/
const currentPayload = computed(() => normalizeTagPayload({
name: form.name,
slug: form.slug || form.name,
description: form.description,
headerImageUrl: form.headerImageUrl,
headerTitle: form.headerTitle,
headerText: form.headerText,
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 최초 태그 입력값
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: 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 || '',
headerImageUrl: props.initialTag.headerImageUrl || '',
headerTitle: props.initialTag.headerTitle || '',
headerText: props.initialTag.headerText || '',
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 && !uploadingCover.value && (!props.requireChanges || hasChanges.value))
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 슬러그
*/
const toSlug = (value) => value
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
watch(() => form.name, (name) => {
if (!slugTouched.value) {
form.slug = toSlug(name)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
/**
* 태그 헤더 이미지 선택 창을 연다.
* @returns {void}
*/
const openCoverPicker = () => {
coverInputRef.value?.click()
}
/**
* 태그 헤더 이미지를 제거한다.
* @returns {void}
*/
const removeCoverImage = () => {
form.headerImageUrl = ''
}
/**
* 태그 헤더 이미지를 업로드한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>} 업로드 결과
*/
const uploadCoverImage = async (event) => {
const input = event.target
const file = input instanceof HTMLInputElement ? input.files?.[0] : null
if (!file) {
return
}
uploadingCover.value = true
coverErrorMessage.value = ''
try {
const body = new FormData()
body.append('file', file)
const result = await $fetch('/admin/api/tags/cover', {
method: 'POST',
body
})
form.headerImageUrl = result.headerImageUrl || ''
} catch (error) {
coverErrorMessage.value = error?.data?.message || '태그 헤더 이미지를 업로드하지 못했습니다.'
} finally {
uploadingCover.value = false
if (input instanceof HTMLInputElement) {
input.value = ''
}
}
}
/**
* 태그 입력값 제출
* @returns {void}
*/
const submitTag = () => {
if (!canSubmit.value) {
return
}
emit('submit', currentPayload.value)
}
</script>
<template>
<form class="admin-tag-form grid gap-6" @submit.prevent="submitTag">
<section class="admin-tag-form__panel grid gap-5 border border-line bg-white p-5">
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">이름</span>
<input
v-model="form.name"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">슬러그</span>
<input
v-model="form.slug"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
required
@input="touchSlug"
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">설명</span>
<textarea
v-model="form.description"
class="admin-tag-form__textarea min-h-28 rounded border border-line bg-white px-3 py-2"
/>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">색상 코드</span>
<span class="admin-tag-form__color-row flex items-center gap-3">
<input
v-model="form.color"
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
type="color"
>
<input
v-model="form.color"
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
type="text"
pattern="#[0-9a-fA-F]{6}"
required
>
</span>
</label>
</section>
<section class="admin-tag-form__panel admin-tag-form__panel--header grid gap-5 border border-line bg-white p-5">
<div class="admin-tag-form__header-cover-head flex flex-wrap items-start justify-between gap-3">
<div class="admin-tag-form__header-cover-copy grid gap-1">
<h2 class="admin-tag-form__section-title text-base font-semibold">
태그 헤더
</h2>
<p class="admin-tag-form__section-description text-sm text-muted">
이미지가 있으면 공개 태그 페이지의 기본 제목과 설명 대신 헤더 이미지를 표시합니다.
</p>
</div>
<div class="admin-tag-form__header-cover-actions flex shrink-0 gap-2">
<button
class="admin-tag-form__cover-button rounded border border-line bg-white px-3 py-2 text-sm font-semibold disabled:opacity-50"
type="button"
:disabled="uploadingCover"
@click="openCoverPicker"
>
{{ uploadingCover ? '업로드 중' : '이미지 변경' }}
</button>
<button
v-if="form.headerImageUrl"
class="admin-tag-form__cover-remove rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-muted disabled:opacity-50"
type="button"
:disabled="uploadingCover"
@click="removeCoverImage"
>
삭제
</button>
</div>
</div>
<div
v-if="form.headerImageUrl"
class="admin-tag-form__cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
>
<HomeHero
:image-url="form.headerImageUrl"
:title="form.headerTitle"
:text="form.headerText"
/>
</div>
<button
v-else
class="admin-tag-form__cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center text-sm text-muted transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
type="button"
:disabled="uploadingCover"
@click="openCoverPicker"
>
태그 헤더 이미지를 선택하세요.
</button>
<input
ref="coverInputRef"
class="sr-only"
type="file"
accept="image/jpeg,image/png,image/webp"
@change="uploadCoverImage"
>
<p v-if="coverErrorMessage" class="admin-tag-form__cover-error rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{{ coverErrorMessage }}
</p>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">오버레이 제목</span>
<input
v-model="form.headerTitle"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
maxlength="120"
placeholder="비워두면 이미지에 제목을 표시하지 않습니다."
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">오버레이 본문</span>
<textarea
v-model="form.headerText"
class="admin-tag-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
maxlength="280"
placeholder="비워두면 이미지에 본문을 표시하지 않습니다."
/>
</label>
</section>
<div class="admin-tag-form__actions flex justify-end gap-3">
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
취소
</NuxtLink>
<button
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="!canSubmit"
>
{{ saving ? '저장 중' : submitLabel }}
</button>
</div>
</form>
</template>

View File

@@ -0,0 +1,146 @@
<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-title"
data-testid="title-and-description"
class="admin-title-description-settings-card admin-settings-screen__card admin-settings-screen__card--title-desc 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">
<h5 class="text-base font-semibold text-[#15171a] md:text-lg">
블로그 제목·설명
</h5>
<p
v-if="!editing"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080] md:block"
>
공개 사이트에서 사이트 이름과 소개로 사용되는 값입니다.
</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-title-description-settings-card__readonly admin-settings-screen__readonly-list 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">
<h6 class="text-sm font-normal tracking-normal text-[#3f4650]">
사이트 이름
</h6>
<div class="min-w-0 text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.title?.trim() ? form.title : '—' }}
</div>
</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">
<h6 class="text-sm font-normal tracking-normal text-[#3f4650]">
사이트 설명
</h6>
<div class="min-w-0 whitespace-pre-wrap text-sm font-normal leading-relaxed text-[#15171a]">
{{ form.description?.trim() ? form.description : '—' }}
</div>
</div>
</div>
<div v-else class="admin-title-description-settings-card__edit admin-settings-screen__card-body grid gap-5">
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">사이트 이름</span>
<input
v-model="form.title"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
required
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">사이트 설명</span>
<textarea
v-model="form.description"
class="min-h-28 resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
required
/>
</label>
<div class="rounded-lg border border-[#eceff2] bg-[#f7f8fa] p-4">
<p class="text-xs font-semibold tracking-wide text-[#657080] uppercase">
공개 화면 미리보기
</p>
<div class="mt-3 flex items-center gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-[#15171a] text-lg font-bold text-white">
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt=""
>
<span v-else>{{ form.logoText || '井' }}</span>
</div>
<div class="min-w-0">
<p class="truncate font-semibold text-[#15171a]">
{{ form.title || 'sori.studio' }}
</p>
<p class="truncate text-sm text-[#657080]">
{{ form.description || '사이트 설명이 공개 화면에 표시됩니다.' }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
const props = defineProps({
/** 표시할 토스트 데이터 */
toast: {
type: Object,
default: null
},
/** 화면별 상단 위치 클래스 */
topClass: {
type: String,
default: 'top-5'
}
})
/**
* 토스트 타입별 스타일 클래스를 반환한다.
* @param {string} type - 토스트 타입
* @returns {string} 스타일 클래스
*/
const getToastToneClass = (type) => {
if (type === 'success') {
return 'border-green-200 bg-green-50 text-green-800'
}
if (type === 'error') {
return 'border-red-200 bg-red-50 text-red-800'
}
return 'border-line bg-white text-ink'
}
</script>
<template>
<div
v-if="props.toast"
class="admin-toast fixed right-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="[topClass, getToastToneClass(props.toast.type)]"
role="status"
>
{{ props.toast.message }}
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
<script>
import { h, mergeProps } from 'vue'
export default {
name: 'ContentMarkdownBlockGroup',
inheritAttrs: false,
props: {
/** 라이브 편집 모드 여부 */
interactive: {
type: Boolean,
default: false
},
/** 블록 앞 삽입 영역 표시 여부 */
showInsert: {
type: Boolean,
default: false
},
/** 블록 앞 삽입 영역 활성 상태 */
insertActive: {
type: Boolean,
default: false
}
},
setup(props, { slots, attrs }) {
/**
* 삽입 영역과 블록 본문을 조건부로 묶어 fragment 주석을 줄인다.
* @returns {import('vue').VNode|import('vue').VNode[]} 렌더 노드
*/
return () => {
const children = []
if (props.interactive && props.showInsert) {
children.push(h('div', mergeProps({
class: [
'content-markdown-renderer__block-insert',
props.insertActive ? 'content-markdown-renderer__block-insert--active' : ''
]
}, attrs)))
}
const defaultContent = slots.default?.()
if (defaultContent) {
const normalized = Array.isArray(defaultContent) ? defaultContent : [defaultContent]
children.push(...normalized)
}
if (children.length === 1) {
return children[0]
}
return children
}
}
}
</script>

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"
block-markdown
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

View File

@@ -0,0 +1,163 @@
<script setup>
import { parseInlineSegments } from '../../lib/markdown-inline.js'
const props = defineProps({
/** 렌더링할 인라인 마크다운 텍스트 */
text: {
type: String,
default: ''
},
/** 링크에 적용할 클래스 */
linkClass: {
type: String,
default: 'text-[var(--site-accent)] underline underline-offset-4'
},
/** 줄바꿈을 br로 렌더링할지 여부 */
multiline: {
type: Boolean,
default: false
}
})
const segmentLines = computed(() => {
const source = String(props.text || '')
const normalizedSource = props.multiline
? source.replace(/<br\s*\/?>/gi, '\n')
: source
const lines = props.multiline ? normalizedSource.split('\n') : [normalizedSource]
return lines.map(parseInlineSegments)
})
/**
* 인라인 색상 클래스명을 반환한다.
* @param {string} color - 색상 이름
* @returns {string} 클래스명
*/
const getInlineColorClass = (color) => `content-markdown-inline-segments__color--${String(color || '').trim().toLowerCase()}`
</script>
<template>
<span class="content-markdown-inline-segments">
<span
v-for="(segment, segmentIndex) in segmentLines[0] || []"
:key="`inline-segment-0-${segmentIndex}`"
class="content-markdown-inline-segments__segment"
>
<strong v-if="segment.type === 'strong'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</strong>
<em v-else-if="segment.type === 'em'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</em>
<sub v-else-if="segment.type === 'subscript'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</sub>
<sup v-else-if="segment.type === 'superscript'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</sup>
<code v-else-if="segment.type === 'code'" class="content-markdown-inline-segments__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" :class="linkClass" :href="segment.href" target="_blank" rel="noreferrer">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</a>
<span
v-else-if="segment.type === 'color'"
class="content-markdown-inline-segments__color"
:class="getInlineColorClass(segment.color)"
:data-inline-color="segment.color"
>
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</span>
<span v-else>{{ segment.text }}</span>
</span>
<span
v-for="(lineSegments, offsetIndex) in segmentLines.slice(1)"
:key="`inline-line-${offsetIndex + 1}`"
class="content-markdown-inline-segments__line"
>
<br>
<span
v-for="(segment, segmentIndex) in lineSegments"
:key="`inline-segment-${offsetIndex + 1}-${segmentIndex}`"
class="content-markdown-inline-segments__segment"
>
<strong v-if="segment.type === 'strong'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</strong>
<em v-else-if="segment.type === 'em'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</em>
<sub v-else-if="segment.type === 'subscript'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</sub>
<sup v-else-if="segment.type === 'superscript'">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</sup>
<code v-else-if="segment.type === 'code'" class="content-markdown-inline-segments__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" :class="linkClass" :href="segment.href" target="_blank" rel="noreferrer">
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</a>
<span
v-else-if="segment.type === 'color'"
class="content-markdown-inline-segments__color"
:class="getInlineColorClass(segment.color)"
:data-inline-color="segment.color"
>
<ContentMarkdownInlineSegments :text="segment.text" :link-class="linkClass" :multiline="multiline" />
</span>
<span v-else>{{ segment.text }}</span>
</span>
</span>
</span>
</template>
<style scoped>
.content-markdown-inline-segments,
.content-markdown-inline-segments__line,
.content-markdown-inline-segments__segment {
display: contents;
}
.content-markdown-inline-segments__color--gray {
color: var(--site-muted);
}
.content-markdown-inline-segments__color--blue {
color: #0055ff;
}
.content-markdown-inline-segments__color--green {
color: #16ae68;
}
.content-markdown-inline-segments__color--yellow {
color: #a16207;
}
.content-markdown-inline-segments__color--red {
color: #e60000;
}
.content-markdown-inline-segments__color--purple {
color: #8800ff;
}
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--blue {
color: #7aa2ff;
}
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--green {
color: #5ee0a0;
}
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--yellow {
color: #facc15;
}
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--red {
color: #ff6b6b;
}
:global(html[data-theme='dark']) .content-markdown-inline-segments__color--purple {
color: #c084fc;
}
</style>

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="defaultOpen"
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"
block-markdown
arrow-exit-creates-line
:source-line="bodySourceLine"
:source-line-count="String(modelValue ?? '').split('\n').length"
:model-value="modelValue"
@commit="onBodyCommit"
@insert-above="emit('insert-above', $event)"
@insert-below="onExitBelow"
@delete-line="emit('delete-line', $event)"
/>
</ProseToggle>
</template>
<style scoped>
.content-markdown-toggle-editor__title:empty::before {
content: '토글 제목';
color: var(--site-muted);
pointer-events: none;
}
.content-markdown-toggle-editor__body:empty::before {
content: '펼쳤을 때 보일 내용을 입력하세요';
color: var(--site-muted);
pointer-events: none;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,249 @@
<script setup>
const props = defineProps({
html: {
type: String,
default: ''
}
})
const allowedHtmlBlockTags = new Set([
'a',
'abbr',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'dd',
'details',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'mark',
'ol',
'p',
'pre',
's',
'small',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'time',
'tr',
'u',
'ul'
])
const globalAllowedAttributes = new Set(['class', 'title', 'aria-label', 'aria-hidden'])
const allowedAttributesByTag = {
a: new Set(['href', 'target', 'rel']),
col: new Set(['span', 'width']),
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
li: new Set(['value']),
ol: new Set(['start', 'type']),
td: new Set(['colspan', 'rowspan', 'headers']),
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
time: new Set(['datetime'])
}
const urlAttributeNames = new Set(['href', 'src'])
const allowedUrlSchemes = new Set(['http:', 'https:', 'mailto:', 'tel:'])
/**
* HTML 텍스트를 escape한다.
* @param {unknown} value - 원본 값
* @returns {string} HTML escape 문자열
*/
const escapeHtmlText = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
/**
* 브라우저에서 URL 속성 값을 허용할지 확인한다.
* @param {string} value - URL 값
* @returns {boolean} 허용 여부
*/
const isAllowedBrowserUrl = (value) => {
const trimmedValue = String(value || '').trim()
if (!trimmedValue || trimmedValue.startsWith('//')) {
return false
}
if (trimmedValue.startsWith('/') || trimmedValue.startsWith('#')) {
return true
}
try {
return allowedUrlSchemes.has(new URL(trimmedValue, window.location.origin).protocol)
} catch {
return false
}
}
/**
* 브라우저에서 HTML 노드를 허용 목록 기준으로 정리한다.
* @param {Node} node - 정리할 노드
* @returns {Node|null} 정리된 노드
*/
const sanitizeBrowserNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent || '')
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null
}
const element = /** @type {Element} */ (node)
const tagName = element.tagName.toLowerCase()
if (!allowedHtmlBlockTags.has(tagName)) {
const fragment = document.createDocumentFragment()
Array.from(element.childNodes).forEach((childNode) => {
const sanitizedChild = sanitizeBrowserNode(childNode)
if (sanitizedChild) {
fragment.appendChild(sanitizedChild)
}
})
return fragment
}
const sanitizedElement = document.createElement(tagName)
const tagAllowedAttributes = allowedAttributesByTag[tagName] || new Set()
Array.from(element.attributes).forEach((attribute) => {
const attributeName = attribute.name.toLowerCase()
if (!globalAllowedAttributes.has(attributeName) && !tagAllowedAttributes.has(attributeName)) {
return
}
if (urlAttributeNames.has(attributeName) && !isAllowedBrowserUrl(attribute.value)) {
return
}
sanitizedElement.setAttribute(attributeName, attribute.value)
})
if (tagName === 'a' && sanitizedElement.getAttribute('target') === '_blank') {
const relValues = new Set(String(sanitizedElement.getAttribute('rel') || '').split(/\s+/).filter(Boolean))
relValues.add('noopener')
relValues.add('noreferrer')
sanitizedElement.setAttribute('rel', [...relValues].join(' '))
}
Array.from(element.childNodes).forEach((childNode) => {
const sanitizedChild = sanitizeBrowserNode(childNode)
if (sanitizedChild) {
sanitizedElement.appendChild(sanitizedChild)
}
})
return sanitizedElement
}
/**
* 브라우저에서 HTML 블록을 정리한다.
* @param {unknown} value - 원본 HTML
* @returns {string} 정리된 HTML
*/
const sanitizeHtmlBlockInBrowser = (value) => {
const html = String(value || '').trim()
if (!html) {
return ''
}
if (!import.meta.client || typeof DOMParser === 'undefined') {
return escapeHtmlText(html)
}
const parser = new DOMParser()
const documentFragment = document.createDocumentFragment()
const parsedDocument = parser.parseFromString(`<template>${html}</template>`, 'text/html')
const template = parsedDocument.querySelector('template')
Array.from(template?.content.childNodes || []).forEach((node) => {
const sanitizedNode = sanitizeBrowserNode(node)
if (sanitizedNode) {
documentFragment.appendChild(sanitizedNode)
}
})
const container = document.createElement('div')
container.appendChild(documentFragment)
return container.innerHTML.trim()
}
const sanitizeHtmlBlock = import.meta.server
? (await import('../../lib/markdown-html-block.js')).sanitizeHtmlBlock
: sanitizeHtmlBlockInBrowser
const sanitizedHtml = computed(() => sanitizeHtmlBlock(props.html))
</script>
<template>
<div
v-if="sanitizedHtml"
class="prose-html prose-html-block mb-8 overflow-x-auto text-base leading-7 text-[var(--site-text)]"
v-html="sanitizedHtml"
/>
<p v-else class="prose-html prose-html-block mb-8 text-sm font-semibold text-[var(--site-muted)]">
표시할 수 있는 HTML이 없습니다.
</p>
</template>
<style scoped>
.prose-html :deep(table) {
width: 100%;
min-width: 520px;
border-collapse: collapse;
font-size: 0.875rem;
}
.prose-html :deep(th),
.prose-html :deep(td) {
border: 1px solid var(--site-line);
padding: 0.5rem 0.75rem;
vertical-align: top;
}
.prose-html :deep(th) {
background: color-mix(in srgb, var(--site-text) 6%, transparent);
font-weight: 700;
}
.prose-html :deep(p) {
margin-bottom: 0.625rem;
}
.prose-html :deep(a) {
color: var(--site-accent);
text-decoration: underline;
text-underline-offset: 0.18em;
}
</style>

View File

@@ -0,0 +1,190 @@
<script setup>
import { getImageDefaultAltLabel } from '../../lib/markdown-image.js'
const props = defineProps({
src: {
type: String,
default: ''
},
fallbackSrc: {
type: String,
default: ''
},
srcset: {
type: String,
default: ''
},
sizes: {
type: String,
default: '(max-width: 768px) 100vw, 760px'
},
alt: {
type: String,
default: ''
},
/** 이미지 아래 표시용 캡션 */
caption: {
type: String,
default: ''
},
variant: {
type: String,
default: 'regular'
},
pixelWidth: {
type: Number,
default: null
},
expandable: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['activate'])
const loadFailed = ref(false)
const currentSrc = ref(props.src)
const hasRenderableSrc = computed(() => String(currentSrc.value || '').trim().length > 0)
const currentSrcset = computed(() => currentSrc.value === props.src ? props.srcset : '')
const figureStyle = computed(() => {
const width = Math.round(Number(props.pixelWidth) || 0)
if (!width) {
return undefined
}
return {
maxWidth: `${width}px`,
width: '100%'
}
})
const errorLabel = computed(() => {
const trimmed = String(props.fallbackSrc || props.src || '').trim()
if (!trimmed) {
return '이미지 URL이 비어 있습니다'
}
const filename = getImageDefaultAltLabel(trimmed)
return filename ? `이미지를 불러올 수 없습니다 · ${filename}` : '이미지를 불러올 수 없습니다'
})
watch(() => props.src, () => {
currentSrc.value = props.src
loadFailed.value = false
})
/**
* 이미지 로드 실패 시 placeholder를 표시한다.
* @returns {void}
*/
const onImageError = () => {
if (props.fallbackSrc && currentSrc.value !== props.fallbackSrc) {
currentSrc.value = props.fallbackSrc
return
}
loadFailed.value = true
}
/**
* 이미지 로드 성공 시 오류 상태를 해제한다.
* @returns {void}
*/
const onImageLoad = () => {
loadFailed.value = false
}
/**
* 이미지 확대 요청을 전달한다.
* @returns {void}
*/
const activateImage = () => {
if (!props.expandable || !hasRenderableSrc.value || loadFailed.value) {
return
}
emit('activate')
}
</script>
<template>
<figure
class="prose-image mb-2.5"
:style="figureStyle"
: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'
}"
>
<component
:is="expandable && hasRenderableSrc && !loadFailed ? 'button' : 'div'"
class="prose-image__frame block w-full overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-0 text-left text-inherit"
:class="{
'prose-image__frame--empty': !hasRenderableSrc || loadFailed,
'prose-image__frame--broken': loadFailed,
'prose-image__frame--expandable cursor-zoom-in': expandable && hasRenderableSrc && !loadFailed
}"
:type="expandable && hasRenderableSrc && !loadFailed ? 'button' : undefined"
:aria-label="expandable && hasRenderableSrc && !loadFailed ? '이미지 크게 보기' : undefined"
@click="activateImage"
>
<img
v-if="hasRenderableSrc && !loadFailed"
class="prose-image__media w-full object-cover"
:src="currentSrc"
:srcset="currentSrcset || undefined"
:sizes="currentSrcset ? sizes : undefined"
:alt="alt"
loading="lazy"
decoding="async"
@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>
</component>
<figcaption
v-if="caption"
class="prose-image__caption mt-1.5 text-center text-sm text-[var(--site-muted)]"
>
{{ caption }}
</figcaption>
</figure>
</template>
<style scoped>
.prose-image__frame--empty,
.prose-image__frame--broken {
min-height: 180px;
}
.prose-image__frame:not(.prose-image__frame--empty):not(.prose-image__frame--broken) {
min-height: 120px;
}
.prose-image__placeholder-icon {
display: inline-flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: 1px dashed var(--site-line);
background: color-mix(in srgb, var(--site-panel) 88%, #fff 12%);
font-weight: 700;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,361 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
defineProps({
menuOpen: {
type: Boolean,
required: true
},
menuMounted: {
type: Boolean,
default: false
}
})
const { isDarkMode, toggleTheme } = useThemeMode()
const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
const { data: navigation } = await useFetch('/api/navigation', {
default: () => ({
primary: [],
footer: [],
recommended: []
})
})
/** 저자 영역 공개 여부 */
const showAuthorSection = false
const STORAGE_KEY = 'sori-primary-nav-expanded'
/**
* 공개 추천 사이트 목록을 반환한다.
* @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((item) => item?.isVisible !== false)
})
/**
* 새 탭으로 열 외부 URL인지 확인한다.
* @param {string} url - 링크
* @returns {boolean} 외부 URL 여부
*/
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()
}
/** 추천 사이트 이미지 로드 실패 단계 (`thumbnail` → `favicon` → `letter`) */
const recommendedImageStage = ref({})
/**
* 추천 사이트 파비콘 URL을 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 파비콘 URL
*/
const getRecommendedFaviconUrl = (item) => {
const cachedFaviconUrl = String(item?.cachedFaviconUrl || '').trim()
if (cachedFaviconUrl) {
return cachedFaviconUrl
}
return getExternalFaviconUrl(item?.url, 64)
}
/**
* 추천 사이트 이미지 URL을 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 이미지 URL
*/
const getRecommendedImageUrl = (item) => {
const id = String(item?.id || '')
const stage = recommendedImageStage.value[id] || 'thumbnail'
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
const faviconUrl = getRecommendedFaviconUrl(item)
if (stage === 'letter') {
return ''
}
if (stage === 'favicon') {
return faviconUrl
}
return thumbnailUrl || faviconUrl
}
/**
* 추천 사이트 이미지 로드 실패 시 다음 단계로 넘긴다.
* @param {Object} item - 추천 사이트 항목
* @returns {void}
*/
const onRecommendedImageError = (item) => {
const id = String(item?.id || '')
if (!id) {
return
}
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
const faviconUrl = getRecommendedFaviconUrl(item)
const stage = recommendedImageStage.value[id] || 'thumbnail'
if (stage === 'thumbnail' && thumbnailUrl && faviconUrl) {
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'favicon' }
return
}
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'letter' }
}
/**
* 트리에서 하위가 있는 노드 id를 모은다.
* @param {Array<Object>} list - 노드 목록
* @returns {string[]} id 목록
*/
const collectBranchIds = (list) => {
const out = []
for (const node of list || []) {
if (node.children?.length) {
out.push(String(node.id))
out.push(...collectBranchIds(node.children))
}
}
return out
}
/**
* localStorage에서 펼침 상태를 읽는다.
* @returns {string[]|null} id 배열
*/
const readStoredExpanded = () => {
if (typeof window === 'undefined') {
return null
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.map(String) : null
} catch {
return null
}
}
/**
* 펼침 상태를 localStorage에 저장한다.
* @param {Set<string>} set - id 집합
* @returns {void}
*/
const persistExpanded = (set) => {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
}
const primaryNavExpandedSet = ref(new Set())
/**
* 트리 구조에 맞게 펼침 집합을 맞춘다.
* @param {Array<Object>} nodes - primary 트리
* @param {boolean} useStorage - 최초·복원 시 저장값 반영
* @returns {void}
*/
const syncPrimaryNavExpanded = (nodes, useStorage = false) => {
const allBranch = new Set(collectBranchIds(nodes))
if (useStorage) {
const stored = readStoredExpanded()
if (stored && stored.length) {
const next = new Set()
for (const id of stored) {
if (allBranch.has(id)) {
next.add(id)
}
}
primaryNavExpandedSet.value = next.size ? next : allBranch
return
}
}
const next = new Set()
for (const id of primaryNavExpandedSet.value) {
if (allBranch.has(id)) {
next.add(id)
}
}
primaryNavExpandedSet.value = next.size ? next : allBranch
}
/**
* 상단 네비 폴더 펼침 토글
* @param {string} id - 노드 id
* @returns {void}
*/
const togglePrimaryNavBranch = (id) => {
const key = String(id)
const next = new Set(primaryNavExpandedSet.value)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
primaryNavExpandedSet.value = next
persistExpanded(next)
}
provide('sidebarPrimaryNavExpandedSet', primaryNavExpandedSet)
provide('sidebarPrimaryNavToggle', togglePrimaryNavBranch)
watch(
() => navigation.value?.primary,
(nodes) => {
syncPrimaryNavExpanded(nodes || [], false)
},
{ deep: true }
)
onMounted(() => {
syncPrimaryNavExpanded(navigation.value?.primary || [], true)
})
</script>
<template>
<aside
id="menu"
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:z-[60] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:z-10 lg:self-start"
style="top: var(--site-top-chrome-height, 57px); height: calc(100dvh - var(--site-top-chrome-height, 57px)); max-height: calc(100dvh - var(--site-top-chrome-height, 57px))"
:class="[
menuOpen
? 'lg:w-[287px] lg:opacity-100'
: 'lg:w-0 lg:opacity-0 lg:border-transparent',
menuOpen && menuMounted
? 'max-lg:translate-x-0 max-lg:pointer-events-auto'
: 'max-lg:-translate-x-full max-lg:pointer-events-none'
]"
>
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
<nav class="left-sidebar__nav" data-nav="menu">
<SidebarPrimaryNavList :nodes="navigation.primary" />
</nav>
</div>
<div class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0">
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
<span>Categories</span>
<span class="text-sm">⌃</span>
</div>
<div class="left-sidebar__category-grid mt-1.5 grid grid-cols-2 gap-x-2 gap-y-[2px] text-[0.8rem] font-medium">
<NuxtLink
v-for="tag in tags"
:key="tag.id"
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
:to="`/tag/${tag.slug}`"
>
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="left-sidebar__category-name flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ tag.name }}</span>
<span
v-if="tag.postCount"
class="left-sidebar__category-count invisible text-xs font-medium opacity-0 transition-opacity duration-200 group-hover:visible group-hover:opacity-100"
>
{{ tag.postCount }}
</span>
</NuxtLink>
</div>
</div>
<div
v-if="recommendedSites.length"
class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0"
>
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
<span>Recommended</span>
<span class="text-sm">⌃</span>
</div>
<ul class="left-sidebar__recommended-list mt-3 flex list-none flex-col gap-2 p-0">
<li v-for="item in recommendedSites" :key="item.id">
<a
class="left-sidebar__recommended-card site-interactive flex items-center gap-2.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] px-2.5 py-2 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="left-sidebar__recommended-icon grid h-8 w-8 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="32"
height="32"
:alt="item.thumbnailUrl ? item.label : ''"
loading="lazy"
referrerpolicy="no-referrer"
@error="onRecommendedImageError(item)"
>
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-[var(--site-text)]">{{ item.label }}</span>
<span class="mt-0.5 block truncate text-[11px] site-muted" :class="item.descriptionText ? '' : 'font-mono'">{{ getRecommendedDisplayText(item) }}</span>
</span>
<span class="shrink-0 text-xs site-muted" aria-hidden="true">↗</span>
</a>
</li>
</ul>
</div>
<div v-if="showAuthorSection" class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
<span>Authors</span>
<span>⌃</span>
</div>
<div class="left-sidebar__authors mt-4 grid gap-4 text-sm">
<div class="left-sidebar__author flex items-center gap-3">
<span class="h-8 w-8 rounded-full bg-[#e7c49d]" />
<span><strong class="block">sori</strong><span class="site-soft">Editor</span></span>
</div>
<div class="left-sidebar__author flex items-center gap-3">
<span class="h-8 w-8 rounded-full bg-[#98b7d5]" />
<span><strong class="block">zenn</strong><span class="site-soft">Writer</span></span>
</div>
</div>
</div>
</div>
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
<nav
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
aria-label="하단 링크"
>
<NuxtLink
v-for="item in navigation.footer"
:key="item.id"
class="left-sidebar__footer-link site-interactive shrink-0"
:to="item.url"
>
{{ item.label }}
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
@click="toggleTheme"
>
<span v-if="isDarkMode">☀</span>
<span v-else>☾</span>
</button>
</footer>
</aside>
</template>

View File

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

View File

@@ -0,0 +1,74 @@
<script setup>
defineProps({
post: {
type: Object,
required: true
}
})
</script>
<template>
<article class="post-card site-section group relative">
<div class="post-card__body site-section-body flex gap-4">
<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 post-title-link" :to="post.to">
{{ post.title }}
</NuxtLink>
</h2>
<p
v-if="post.excerpt"
class="post-card__excerpt post-summary-clamp post-summary-clamp--two mt-2 text-sm leading-6 site-muted"
>
{{ post.excerpt }}
</p>
<p class="post-card__meta mt-2 flex flex-wrap items-center gap-1.5 text-xs site-muted">
{{ post.publishedAt }}<template v-if="post.tag"> / </template>
<NuxtLink
v-if="post.tag && post.tagSlug"
class="post-card__tag-link site-interactive rounded-md px-1.5 py-px font-medium text-[var(--site-text)] hover:text-[var(--site-accent)]"
:style="post.tagColor ? { backgroundColor: `${post.tagColor}1a` } : undefined"
:to="`/tag/${post.tagSlug}`"
>
{{ post.tag }}
</NuxtLink>
<span
v-else-if="post.tag"
class="post-card__tag-label rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="post.tagColor ? { backgroundColor: `${post.tagColor}1a` } : undefined"
>
{{ post.tag }}
</span>
</p>
</div>
<NuxtLink
class="post-card__arrow absolute right-6 bottom-4 flex cursor-pointer items-center gap-1 text-[var(--site-text)] opacity-100 transition-[opacity,visibility] duration-200 hover:opacity-75 md:invisible md:opacity-0 md:group-hover:visible md:group-hover:opacity-100"
:to="post.to"
aria-label="게시물 보기"
>
<svg
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="h-4 w-4"
aria-hidden="true"
>
<path d="M17 7 7 17" />
<path d="M8 7h9v9" />
</svg>
</NuxtLink>
</div>
</article>
</template>

View File

@@ -0,0 +1,71 @@
<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]"
:class="aspectClass"
>
<img
v-if="displayImage"
class="post-card-media__image h-full w-full rounded-[inherit] object-cover"
:class="imageClass"
:src="displayImage"
:alt="title"
loading="lazy"
>
<span
v-else
class="post-card-media__placeholder flex h-full w-full items-center justify-center rounded-[inherit] bg-[#F7F4EF] p-4 text-center text-xs leading-snug text-[var(--site-muted)]"
: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

@@ -0,0 +1,446 @@
<script setup>
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 usePublicSiteSettings()
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)
})
}
/**
* 게시글 목차 링크를 부드럽게 이동한다.
* @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:z-10 lg:w-[287px] lg:self-start lg:border-l-0 lg:border-t-0 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 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">
{{ siteSettings.title }}
</p>
<p class="right-sidebar__description text-sm site-muted">
{{ siteSettings.description }}
</p>
</div>
</div>
</div>
<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
</p>
<nav class="right-sidebar__social relative z-10 flex flex-wrap items-center gap-1 text-sm text-[var(--site-text)]">
<a
v-for="item in followLinks"
:key="item.id"
class="right-sidebar__social-link site-interactive inline-flex h-5 w-5 items-center justify-center p-0.5 leading-none hover:opacity-75"
:href="item.href"
:aria-label="item.label"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noreferrer' : undefined"
>
<svg
v-if="item.icon === 'facebook'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
</svg>
<svg
v-else-if="item.icon === 'x'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M4 4l11.733 16H20L8.267 4z" />
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
</svg>
<svg
v-else-if="item.icon === 'github'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0c-2.4-1.6-3.5-1.3-3.5-1.3a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21" />
</svg>
<svg
v-else-if="item.icon === 'instagram'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<rect x="4" y="4" width="16" height="16" rx="4" />
<circle cx="12" cy="12" r="3" />
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501" />
</svg>
<svg
v-else-if="item.icon === 'linkedin'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</svg>
<svg
v-else-if="item.icon === 'youtube'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M2.5 17a24.1 24.1 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
<path d="m10 15 5-3-5-3z" />
</svg>
<svg
v-else-if="item.icon === 'rss'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<circle cx="5" cy="19" r="1" />
<path d="M4 4a16 16 0 0 1 16 16" />
<path d="M4 11a9 9 0 0 1 9 9" />
</svg>
<span
v-else-if="item.icon === 'custom' && item.iconSvg"
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center fill-[var(--site-text)]"
aria-hidden="true"
v-html="item.iconSvg"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.15-1.15" />
</svg>
<span class="sr-only">{{ item.label }}</span>
</a>
</nav>
</div>
</div>
<div
v-if="isPostDetailRoute"
class="right-sidebar__block right-sidebar__toc 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-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<p class="right-sidebar__about text-sm leading-6 site-muted">
{{ siteSettings.description }}
</p>
<NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about">
About {{ siteSettings.title }}
</NuxtLink>
</div>
<SiteAdSlot
class="right-sidebar__ad-slot 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">
{{ siteSettings.copyrightText }}
</footer>
</aside>
</template>
<style scoped>
@media (min-width: 1024px) {
.right-sidebar {
top: var(--site-top-chrome-height, 57px);
height: calc(100dvh - var(--site-top-chrome-height, 57px));
max-height: calc(100dvh - var(--site-top-chrome-height, 57px));
}
}
.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,196 @@
<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 usePublicSiteSettings()
/** DOM에 바를 둘지(애니메이션 종료 전까지 유지) */
const inDom = ref(false)
/** 펼침(아래로 슬라이드) 애니메이션 상태 */
const expanded = ref(false)
/** 숨김 처리 완료 여부 */
const dismissed = ref(false)
let closeTimer = null
/**
* 설정상 어나운스 바를 켤 수 있는지
* @returns {boolean} 가능 여부
*/
const isEligible = computed(() => {
if (!siteSettings.value?.announcementEnabled) {
return false
}
return Boolean((siteSettings.value?.announcementText || '').trim())
})
const announcementText = computed(() => (siteSettings.value?.announcementText || '').trim())
const announcementLink = computed(() => normalizeAnnouncementUrl(siteSettings.value?.announcementUrl || ''))
const snoozeLabel = computed(() => `${ANNOUNCEMENT_SNOOZE_DAYS}일간 보지 않기`)
const announcementAlignment = computed(() => siteSettings.value?.announcementAlignment === 'left' ? 'left' : 'center')
const barStyle = computed(() => {
const backgroundColor = siteSettings.value?.announcementBackgroundColor || '#15171a'
return {
backgroundColor,
color: getAnnouncementBarTextColor(backgroundColor)
}
})
/**
* 어나운스 바를 펼친다.
* @returns {Promise<void>}
*/
const openBar = async () => {
inDom.value = true
expanded.value = false
await nextTick()
requestAnimationFrame(() => {
requestAnimationFrame(() => {
expanded.value = true
})
})
}
/**
* 어나운스 바를 접고 DOM에서 제거한다.
* @param {() => void} persistDismiss - localStorage·sessionStorage 저장
* @returns {void}
*/
const closeBar = (persistDismiss) => {
if (!inDom.value) {
return
}
persistDismiss()
expanded.value = false
if (closeTimer) {
clearTimeout(closeTimer)
}
closeTimer = setTimeout(() => {
inDom.value = false
dismissed.value = true
closeTimer = null
}, SLIDE_MS)
}
/**
* 이번 방문(세션) 동안만 닫는다.
* @returns {void}
*/
const dismissForSession = () => {
closeBar(() => dismissAnnouncementForSession(siteSettings.value))
}
/**
* N일간 보지 않기로 닫는다.
* @returns {void}
*/
const dismissForSnooze = () => {
closeBar(() => dismissAnnouncementForDays(siteSettings.value))
}
watch(() => siteSettings.value?.updatedAt, async () => {
if (!import.meta.client) {
return
}
const hidden = isAnnouncementDismissed(siteSettings.value)
dismissed.value = hidden
if (!isEligible.value || hidden) {
expanded.value = false
inDom.value = false
return
}
await openBar()
})
onMounted(() => {
dismissed.value = isAnnouncementDismissed(siteSettings.value)
if (isEligible.value && !dismissed.value) {
openBar()
}
})
onBeforeUnmount(() => {
if (closeTimer) {
clearTimeout(closeTimer)
}
})
</script>
<template>
<div
v-if="inDom"
class="site-announcement-bar-shell grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
:style="{ transitionDuration: `${SLIDE_MS}ms` }"
>
<div class="min-h-0 overflow-hidden">
<div
class="site-announcement-bar relative z-30 w-full text-center text-sm font-medium"
:style="barStyle"
role="region"
aria-label="사이트 공지"
:aria-hidden="(!expanded).toString()"
>
<div
class="site-announcement-bar__inner relative mx-auto flex min-h-9 max-w-[1294px] items-center gap-3 px-4 py-2.5 sm:px-6 lg:px-8"
:class="announcementAlignment === 'left' ? 'justify-start' : 'justify-center'"
>
<component
:is="announcementLink ? 'a' : 'span'"
class="site-announcement-bar__text min-w-0 line-clamp-2 px-6 sm:px-8"
:class="[
announcementLink ? 'hover:underline' : '',
announcementAlignment === 'left' ? 'flex-1 text-left' : 'flex-1 text-center'
]"
:href="announcementLink || undefined"
:target="announcementLink ? '_blank' : undefined"
:rel="announcementLink ? 'noreferrer' : undefined"
>
{{ announcementText }}
</component>
<div class="site-announcement-bar__actions absolute top-1/2 right-3 flex shrink-0 -translate-y-1/2 items-center gap-2 sm:right-4 lg:right-5">
<button
class="site-announcement-bar__snooze whitespace-nowrap text-xs font-medium underline-offset-2 opacity-90 transition hover:underline hover:opacity-100 sm:text-[13px]"
type="button"
@click="dismissForSnooze"
>
{{ snoozeLabel }}
</button>
<button
class="site-announcement-bar__close inline-flex size-7 items-center justify-center rounded-full opacity-80 transition hover:opacity-100"
type="button"
aria-label="이번 방문 동안 닫기"
@click="dismissForSession"
>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

@@ -0,0 +1,145 @@
<script setup>
const props = defineProps({
page: {
type: Number,
default: 1
},
totalPages: {
type: Number,
default: 1
},
label: {
type: String,
default: '페이지'
},
basePath: {
type: String,
required: true
}
})
const normalizedTotalPages = computed(() => Math.max(1, Number(props.totalPages) || 1))
const currentPage = computed(() => Math.max(1, Math.min(Number(props.page) || 1, normalizedTotalPages.value)))
const previousPage = computed(() => Math.max(1, currentPage.value - 1))
const nextPage = computed(() => Math.min(normalizedTotalPages.value, currentPage.value + 1))
const canGoPrevious = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value < normalizedTotalPages.value)
/**
* 연속 페이지 번호를 만든다.
* @param {number} start - 시작 페이지
* @param {number} end - 끝 페이지
* @returns {Array<number>} 페이지 번호 목록
*/
const range = (start, end) => {
const pages = []
for (let page = start; page <= end; page += 1) {
pages.push(page)
}
return pages
}
const pageItems = computed(() => {
const total = normalizedTotalPages.value
const current = currentPage.value
if (total <= 8) {
return range(1, total).map((page) => ({ type: 'page', page }))
}
const pages = new Set([1, total])
if (current <= 5) {
range(1, Math.min(total - 1, Math.max(4, current + 3))).forEach((page) => pages.add(page))
} else if (current >= total - 4) {
range(Math.max(2, total - 7), total).forEach((page) => pages.add(page))
} else {
range(Math.max(2, current - 3), Math.min(total - 1, current + 3)).forEach((page) => pages.add(page))
}
const sortedPages = [...pages].sort((left, right) => left - right)
const items = []
sortedPages.forEach((page, index) => {
const previous = sortedPages[index - 1]
if (previous && page - previous > 1) {
items.push({
type: 'ellipsis',
key: `ellipsis-${previous}-${page}`
})
}
items.push({
type: 'page',
page
})
})
return items
})
/**
* 페이지 링크 대상을 만든다.
* @param {number} page - 페이지 번호
* @returns {Object} Nuxt 링크 대상
*/
const getPageLinkTo = (page) => ({
path: props.basePath,
query: page > 1 ? { page } : {}
})
</script>
<template>
<nav class="site-pagination site-section" :aria-label="label">
<div class="site-pagination__body site-section-body mx-auto flex max-w-[720px] items-center justify-start gap-3 overflow-x-auto text-sm sm:justify-center sm:gap-4">
<NuxtLink
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
:class="!canGoPrevious ? 'pointer-events-none opacity-35' : ''"
:to="getPageLinkTo(previousPage)"
:aria-disabled="!canGoPrevious"
:aria-label="`${label} 이전으로 이동`"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</NuxtLink>
<ol class="site-pagination__pages flex min-w-max items-center justify-center gap-1 sm:gap-2">
<li v-for="item in pageItems" :key="item.type === 'page' ? `page-${item.page}` : item.key">
<span
v-if="item.type === 'ellipsis'"
class="site-pagination__ellipsis inline-flex min-w-9 items-center justify-center px-1 text-base font-semibold text-[var(--site-muted)]"
aria-hidden="true"
>
...
</span>
<NuxtLink
v-else
class="site-pagination__page inline-flex min-w-9 items-center justify-center rounded-full px-2 py-2 text-base font-semibold text-[var(--site-muted)] transition hover:text-[var(--site-text)]"
:class="item.page === currentPage ? 'site-pagination__page--current pointer-events-none !bg-[var(--site-accent)] !text-white shadow-sm' : ''"
:to="getPageLinkTo(item.page)"
:aria-current="item.page === currentPage ? 'page' : undefined"
:aria-label="`${item.page}페이지로 이동`"
>
{{ item.page }}
</NuxtLink>
</li>
</ol>
<NuxtLink
class="site-pagination__arrow inline-flex size-10 items-center justify-center rounded-full border border-[var(--site-line)] text-[var(--site-muted)] transition hover:border-[var(--site-soft)] hover:text-[var(--site-text)]"
:class="!canGoNext ? 'pointer-events-none opacity-35' : ''"
:to="getPageLinkTo(nextPage)"
:aria-disabled="!canGoNext"
:aria-label="`${label} 다음으로 이동`"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</NuxtLink>
</div>
</nav>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
/** @type {RegExp} 요약에서 표시 텍스트만 남길 인라인 색상 문법 */
const INLINE_COLOR_SUMMARY_RE = /::(?:gray|blue|green|yellow|red|purple)\[((?:\\\]|[^\]\n])*)\]/g
/**
* 인라인 색상 문법을 요약용 일반 텍스트로 바꾼다.
* @param {string} value - 원본 문자열
* @returns {string} 색상 토큰을 제거한 문자열
*/
const stripInlineColorMarkdown = (value) => String(value || '').replace(
INLINE_COLOR_SUMMARY_RE,
(_match, text) => String(text || '').replaceAll('\\]', ']')
)
/**
* 게시물 요약 또는 본문에서 목록·메타용 짧은 텍스트를 만든다.
* @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 = stripInlineColorMarkdown(source)
.replace(/```[\s\S]*?```/g, ' ')
.replace(/:::[\s\S]*?:::/g, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/(^|\s)::(?:gray|blue|green|yellow|red|purple)(?=\s|$)/g, ' ')
.replace(/[#>*_`~|-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plainText) {
return ''
}
if (plainText.length <= maxLength) {
return plainText
}
if (!appendEllipsis) {
return plainText.slice(0, maxLength).trim()
}
return `${plainText.slice(0, maxLength - 3).trim()}...`
}

View File

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

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