404 Commits
v1.0.0 ... 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
528 changed files with 85914 additions and 10487 deletions

View File

@@ -5,6 +5,12 @@ dist
coverage
.git
.env
.env.development
.env.production
.env.*
!.env.example
*.log
**/.DS_Store
public/uploads
tests
test-results
playwright-report
docs

View File

@@ -4,16 +4,29 @@ DATABASE_NAME=sori_studio
POSTGRES_DB=sori_studio
POSTGRES_USER=sori_studio
POSTGRES_PASSWORD=replace-with-random-password
DB_PORT=43119
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
@@ -24,10 +37,21 @@ AVATAR_WEBP_QUALITY=82
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
APP_PORT=43118
COMPOSE_PROJECT_NAME=sori-studio
APP_PORT=43080
DOCKER_SUBNET=10.250.50.0/24

3
.gitignore vendored
View File

@@ -11,6 +11,7 @@ public/uploads/
.env
.env.*
!.env.example
docker-compose.yaml.backup.*
# IDE
.idea/
@@ -28,6 +29,8 @@ npm-debug.log*
# Test
coverage/
playwright-report/
test-results/
# Reference theme sample (do not commit)
ZCF-v1.0.5/

View File

@@ -22,6 +22,11 @@ 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

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>

53
app.vue
View File

@@ -1,15 +1,18 @@
<script setup>
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
faviconUrl: ''
})
})
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
? `${titleChunk} · ${appSiteSettings.value.title}`
: appSiteSettings.value.title,
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
? [
{
@@ -23,7 +26,33 @@ useHead(() => ({
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<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
}

View File

@@ -56,6 +56,36 @@
background: var(--site-bg);
}
.site-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: var(--site-bg);
transition: opacity 0.22s ease, visibility 0.22s ease;
}
html.site-app-ready .site-splash {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.site-splash__logo {
width: 5rem;
height: 5rem;
border-radius: 1rem;
object-fit: cover;
}
.site-splash__text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--site-text);
}
html.site-mobile-nav-open {
overflow: hidden;
}
@@ -64,6 +94,34 @@
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;
@@ -76,6 +134,13 @@
overflow: hidden;
background: #ffffff;
}
html.admin-settings-document,
body.admin-settings-document {
height: 100%;
overflow: hidden;
background: #f7f8fa;
}
}
@layer components {
@@ -116,6 +181,27 @@
animation: site-search-modal-in 0.18s ease-out;
}
.post-summary-clamp {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
}
.post-summary-clamp--one {
-webkit-line-clamp: 1;
line-clamp: 1;
}
.post-summary-clamp--two {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.post-summary-clamp--three {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.post-prose {
@apply max-w-none text-[17px] leading-8;
color: var(--site-text);
@@ -139,7 +225,7 @@
.site-sidebar {
min-height: 0;
background: var(--site-panel);
background: var(--site-bg);
color: var(--site-text);
}
@@ -216,6 +302,108 @@
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를 상속하지 않는 경우 대비
*/
@@ -236,3 +424,14 @@
}
}
@layer utilities {
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}

View File

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

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>

File diff suppressed because it is too large Load Diff

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>

View File

@@ -12,25 +12,48 @@ const props = defineProps({
const emit = defineEmits(['saved', 'deleted'])
const { toast, showToast } = useAdminToast()
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
default: () => ({
userId: '',
roleCode: ''
})
})
const selectedMemberTab = ref('info')
const memberTabs = [
{ value: 'info', label: '정보' },
{ value: 'posts', label: '작성한 글' }
]
const isNewMember = computed(() => props.mode === 'new')
const saveMessage = ref('')
const saveError = ref('')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const avatarInputRef = ref(null)
const isUploadingAvatar = ref(false)
const isEditingMember = ref(props.mode === 'new')
const actionMenuOpen = ref(false)
const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false)
const googleUnlinkModalOpen = ref(false)
const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false)
const actionMessage = ref('')
const isUnlinkingGoogle = ref(false)
const actionError = ref('')
const roleOptions = [
{ value: 'owner', label: '소유자' },
{ value: 'admin', label: '관리자' },
{ value: 'vip', label: 'VIP' },
{ value: 'member', label: '멤버' }
]
const form = reactive({
username: '',
email: '',
avatarUrl: '',
roleCode: 'member',
labelsText: '',
note: ''
})
@@ -44,6 +67,10 @@ const deleteForm = reactive({
confirmText: ''
})
/** 소유자 권한 이전 확인 모달 */
const ownershipTransferModalOpen = ref(false)
const isTransferringOwnership = ref(false)
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @returns {void}
@@ -53,6 +80,7 @@ const syncMemberForm = () => {
form.username = member.username || ''
form.email = member.email || ''
form.avatarUrl = member.avatarUrl || ''
form.roleCode = member.roleCode || 'member'
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
form.note = member.note || ''
}
@@ -77,11 +105,133 @@ const normalizedLabels = computed(() => [...new Set(
.filter(Boolean)
)])
const isFormEditable = computed(() => {
if (isNewMember.value) {
return true
}
if (!canEditMemberProfile.value) {
return false
}
return isEditingMember.value
})
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
const isCurrentAdminOwner = computed(() => currentAdminRoleCode.value === 'owner')
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
&& String(props.member.id) === String(adminSession.value.userId))
/** 저장된 대상 회원 등급(권한 판단용) */
const targetSavedRoleCode = computed(() => String(props.member?.roleCode || 'member'))
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(targetSavedRoleCode.value))
/**
* 대상 회원 프로필을 수정할 수 있는지 여부
* @returns {boolean} 수정 가능 여부
*/
const canEditMemberProfile = computed(() => {
if (isNewMember.value) {
return isCurrentAdminPrivileged.value
}
if (currentAdminRoleCode.value === 'owner') {
return true
}
if (currentAdminRoleCode.value === 'admin') {
return !isEditingSelf.value && ['vip', 'member'].includes(targetSavedRoleCode.value)
}
return false
})
const canOpenEditMode = computed(() => !isNewMember.value && canEditMemberProfile.value && !isEditingMember.value)
const canManageMemberActions = computed(() => canEditMemberProfile.value)
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
const googleLinked = computed(() => Boolean(props.member?.googleLinkedAt))
const googleAccountText = computed(() => props.member?.googleEmail || props.member?.googleName || 'Google 계정')
const canUnlinkGoogle = computed(() => !isNewMember.value && googleLinked.value && isCurrentAdminOwner.value)
/**
* 회원 등급 변경 권한 여부(저장 중 상태는 제외)
* @returns {boolean} 변경 가능 여부
*/
const canChangeMemberRole = computed(() => {
if (!isFormEditable.value || shouldRenderRoleAsText.value) {
return false
}
if (currentAdminRoleCode.value === 'owner') {
return !isEditingSelf.value
}
if (currentAdminRoleCode.value === 'admin') {
return !isTargetPrivilegedRole.value
}
return false
})
const canEditRoleSelect = computed(() => canChangeMemberRole.value && !isSaving.value)
const availableRoleOptions = computed(() => {
if (!canEditRoleSelect.value) {
return roleOptions
}
if (currentAdminRoleCode.value === 'admin') {
return roleOptions.filter((option) => ['vip', 'member'].includes(option.value))
}
return roleOptions
})
/**
* 저장 시 소유자 권한 이전 확인이 필요한지 여부
* @returns {boolean} 확인 필요 여부
*/
const isOwnershipTransferPending = computed(() => {
return !isNewMember.value
&& currentAdminRoleCode.value === 'owner'
&& !isEditingSelf.value
&& form.roleCode === 'owner'
&& targetSavedRoleCode.value !== 'owner'
})
const roleHelpText = computed(() => {
if (!isFormEditable.value) {
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
}
if (shouldRenderRoleAsText.value) {
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
}
if (!canEditMemberProfile.value && currentAdminRoleCode.value === 'admin') {
return '관리자는 VIP·멤버 정보만 수정할 수 있습니다.'
}
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
return '관리자는 소유자·관리자 정보를 수정하거나 등급을 변경할 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin') {
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
}
if (isOwnershipTransferPending.value) {
return '소유자 등급으로 저장하면 권한 이전 확인 모달이 표시됩니다. 승인 시 본인은 관리자로 변경됩니다.'
}
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
})
/**
* 회원 저장 요청 본문을 문자열로 직렬화한다.
* @returns {string} 직렬화된 회원 입력값
*/
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
const serializeMemberPayload = () => JSON.stringify({
...getMemberPayload(),
roleCode: form.roleCode
})
/**
* 날짜 표시 형식 변환
@@ -157,18 +307,35 @@ const getMemberPayload = () => ({
})
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
const shouldBlockUnsavedMemberChanges = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value)
const canSubmitMemberForm = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value && !isSaving.value)
const {
isUnsavedModalOpen,
stayOnUnsavedPage,
leaveUnsavedPage
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges)
/**
* 회원 상세를 수정 모드로 전환한다.
* @returns {void}
*/
const enterEditMode = () => {
if (!canEditMemberProfile.value) {
return
}
isEditingMember.value = true
}
/**
* 썸네일 파일 선택창을 연다.
* @returns {void}
*/
const openAvatarFilePicker = () => {
if (!isFormEditable.value) {
return
}
avatarInputRef.value?.click()
}
@@ -185,20 +352,26 @@ const uploadAvatar = async (event) => {
return
}
if (!isFormEditable.value) {
return
}
isUploadingAvatar.value = true
saveError.value = ''
saveMessage.value = ''
try {
const formData = new FormData()
formData.append('files', file)
const result = await $fetch('/admin/api/uploads', {
formData.append('file', file)
const result = await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
form.avatarUrl = result.files?.[0]?.url || ''
form.avatarUrl = result.avatarUrl || ''
if (!isNewMember.value) {
showToast('info', '썸네일 미리보기가 반영되었습니다. 저장을 눌러 적용하세요.')
}
} catch (error) {
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
} finally {
isUploadingAvatar.value = false
if (target) {
@@ -212,6 +385,10 @@ const uploadAvatar = async (event) => {
* @returns {void}
*/
const removeAvatar = () => {
if (!isFormEditable.value) {
return
}
form.avatarUrl = ''
}
@@ -238,7 +415,6 @@ const closeActionMenu = () => {
const openPasswordModal = () => {
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = ''
actionError.value = ''
passwordModalOpen.value = true
closeActionMenu()
@@ -250,7 +426,6 @@ const openPasswordModal = () => {
*/
const openDeleteModal = () => {
deleteForm.confirmText = ''
actionMessage.value = ''
actionError.value = ''
deleteModalOpen.value = true
closeActionMenu()
@@ -280,12 +455,36 @@ const closeDeleteModal = () => {
deleteModalOpen.value = false
}
/**
* Google 연동 해제 확인 모달을 연다.
* @returns {void}
*/
const openGoogleUnlinkModal = () => {
if (!canUnlinkGoogle.value) {
return
}
actionError.value = ''
googleUnlinkModalOpen.value = true
}
/**
* Google 연동 해제 확인 모달을 닫는다.
* @returns {void}
*/
const closeGoogleUnlinkModal = () => {
if (isUnlinkingGoogle.value) {
return
}
googleUnlinkModalOpen.value = false
}
/**
* 관리자 권한으로 회원 비밀번호를 변경한다.
* @returns {Promise<void>}
*/
const updateMemberPassword = async () => {
actionMessage.value = ''
actionError.value = ''
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
@@ -309,9 +508,11 @@ const updateMemberPassword = async () => {
passwordModalOpen.value = false
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = '비밀번호가 변경되었습니다.'
showToast('success', '비밀번호가 변경되었습니다.')
} catch (error) {
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
const message = error?.data?.message || '비밀번호 변경에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isUpdatingPassword.value = false
}
@@ -322,7 +523,6 @@ const updateMemberPassword = async () => {
* @returns {Promise<void>}
*/
const deleteMember = async () => {
actionMessage.value = ''
actionError.value = ''
if (deleteForm.confirmText !== form.email) {
@@ -337,12 +537,113 @@ const deleteMember = async () => {
})
emit('deleted')
} catch (error) {
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
const message = error?.data?.message || '회원 삭제에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isDeletingMember.value = false
}
}
/**
* 소유자 권한으로 회원 Google 연동을 해제한다.
* @returns {Promise<void>}
*/
const unlinkGoogleIdentity = async () => {
if (!canUnlinkGoogle.value || isUnlinkingGoogle.value) {
return
}
actionError.value = ''
isUnlinkingGoogle.value = true
try {
const updated = await $fetch(`/admin/api/members/${props.member.id}/google`, {
method: 'DELETE'
})
googleUnlinkModalOpen.value = false
emit('saved', updated)
showToast('success', 'Google 연동을 해제했습니다.')
} catch (error) {
const message = error?.data?.message || 'Google 연동 해제에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isUnlinkingGoogle.value = false
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<Object>} 저장된 회원
*/
const performMemberSave = async () => {
const payload = getMemberPayload()
const roleChanged = !isNewMember.value && form.roleCode !== props.member?.roleCode
if (roleChanged && canChangeMemberRole.value && form.roleCode !== 'owner') {
await $fetch(`/admin/api/members/${props.member.id}/role`, {
method: 'PUT',
body: {
role: form.roleCode
}
})
}
const saved = isNewMember.value
? await $fetch('/admin/api/members', {
method: 'POST',
body: payload
})
: await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'PUT',
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
isEditingMember.value = isNewMember.value
return saved
}
/**
* 소유자 권한 이전 모달을 닫는다.
* @returns {void}
*/
const closeOwnershipTransferModal = () => {
if (isTransferringOwnership.value) {
return
}
ownershipTransferModalOpen.value = false
}
/**
* 소유자 권한 이전을 확정한다.
* @returns {Promise<void>}
*/
const confirmOwnershipTransfer = async () => {
if (isTransferringOwnership.value || !props.member?.id) {
return
}
isTransferringOwnership.value = true
try {
if (hasUnsavedMemberChanges.value) {
await performMemberSave()
}
await $fetch(`/admin/api/members/${props.member.id}/ownership-transfer`, {
method: 'POST'
})
ownershipTransferModalOpen.value = false
showToast('success', '소유자 권한을 이전했습니다. 본인 계정은 관리자로 변경되었습니다.')
await navigateTo('/admin/members')
} catch (error) {
showToast('error', error?.data?.message || '소유자 권한 이전에 실패했습니다.')
} finally {
isTransferringOwnership.value = false
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
@@ -352,27 +653,30 @@ const saveMember = async () => {
return
}
saveMessage.value = ''
saveError.value = ''
if (!isFormEditable.value) {
if (!canOpenEditMode.value) {
return
}
enterEditMode()
return
}
if (!hasUnsavedMemberChanges.value) {
return
}
if (isOwnershipTransferPending.value) {
ownershipTransferModalOpen.value = true
return
}
isSaving.value = true
try {
const payload = getMemberPayload()
const saved = isNewMember.value
? await $fetch('/admin/api/members', {
method: 'POST',
body: payload
})
: await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'PUT',
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
saveMessage.value = '저장되었습니다.'
await performMemberSave()
showToast('success', '저장되었습니다.')
} catch (error) {
saveError.value = error?.data?.message || '저장에 실패했습니다.'
showToast('error', error?.data?.message || '저장에 실패했습니다.')
} finally {
isSaving.value = false
}
@@ -401,22 +705,26 @@ watch(() => props.member, () => {
{{ pageTitle }}
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember && canManageMemberActions" class="admin-member-form__action-menu relative">
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
<AdminPopoverSurface
v-if="actionMenuOpen"
root-class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 rounded-xl border-line py-2 text-sm text-[#3f4650]"
clipped
>
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
비밀번호 변경
</button>
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
멤버 삭제
</button>
</div>
</AdminPopoverSurface>
</div>
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
@@ -424,14 +732,39 @@ watch(() => props.member, () => {
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
{{ isSaving ? '저장 중' : '저장' }}
<button
class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
type="button"
:disabled="(isFormEditable && !canSubmitMemberForm) || (!isFormEditable && !canOpenEditMode)"
@click="saveMember"
>
{{ !isFormEditable ? (canOpenEditMode ? '수정하기' : '수정 불가') : isSaving ? '저장 중' : '저장' }}
</button>
</div>
</div>
<p
v-if="!isNewMember && !canEditMemberProfile"
class="admin-member-form__readonly-note mt-4 rounded-md border border-line bg-[#f9f9f7] px-4 py-3 text-sm text-[#657080]"
>
관리자는 VIP·멤버 정보만 수정할 수 있습니다. 소유자·관리자 계정은 조회만 가능합니다.
</p>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<nav v-if="!isNewMember" class="admin-member-form__tabs mt-5 flex gap-6 border-b border-line" aria-label="멤버 상세 메뉴">
<button
v-for="tab in memberTabs" :key="tab.value" type="button"
class="admin-member-form__tab border-b-2 px-1 py-3 text-sm font-semibold"
:class="selectedMemberTab === tab.value ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
:aria-current="selectedMemberTab === tab.value ? 'page' : undefined"
@click="selectedMemberTab = tab.value"
>{{ tab.label }}</button>
</nav>
<AdminMemberActivityList
v-if="!isNewMember && selectedMemberTab !== 'info'"
:key="`${member.id}-${selectedMemberTab}`" :member-id="member.id"
class="admin-member-form__content-tab mt-6"
/>
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
@@ -439,6 +772,8 @@ watch(() => props.member, () => {
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
type="button"
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
:disabled="!isFormEditable"
:class="{ 'cursor-default': !isFormEditable }"
@click="openAvatarFilePicker"
>
<img
@@ -451,11 +786,11 @@ watch(() => props.member, () => {
{{ memberInitial }}
</span>
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
</span>
</button>
<button
v-if="form.avatarUrl"
v-if="form.avatarUrl && isFormEditable"
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
aria-label="썸네일 제거"
@@ -513,35 +848,102 @@ watch(() => props.member, () => {
<div class="grid gap-5 md:grid-cols-2">
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.username || '-' }}
</span>
<input v-else v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
</label>
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.email || '-' }}
</span>
<input v-else v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
</label>
</div>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
<span
v-if="shouldRenderRoleAsText"
class="admin-member-form__role-text flex min-h-12 w-full items-center text-sm font-semibold text-[#15171a]"
:class="{ 'rounded-md border border-[#d7dce0] bg-white px-4': isFormEditable }"
>
{{ currentRoleLabel }}
</span>
<span v-else class="admin-member-form__select-wrap relative block">
<select
v-model="form.roleCode"
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-[#d7dce0] bg-white px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
:disabled="!canEditRoleSelect"
>
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<svg class="pointer-events-none absolute right-4 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
<span class="admin-member-form__hint mt-2 block text-sm text-[#8a95a5]">
{{ roleHelpText }}
</span>
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
{{ form.labelsText || '-' }}
</span>
<input v-else v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
</span>
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
<p v-if="!isFormEditable" class="admin-member-form__readonly min-h-24 whitespace-pre-wrap text-sm leading-6 text-[#15171a]">
{{ form.note || '-' }}
</p>
<textarea v-else v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-[#d7dce0] bg-white px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
최대 500자. 현재 {{ noteLength }}자
</span>
</label>
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</form>
<section v-if="!isNewMember" class="admin-member-form__google">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">Google 로그인</h2>
<div class="admin-member-form__google-card rounded-xl border border-line bg-white px-5 py-5 md:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-[#15171a]">
{{ googleLinked ? `${googleAccountText} 연결됨` : '연결된 Google 계정 없음' }}
</p>
<p v-if="googleLinked" class="mt-1 text-sm text-[#8a95a5]">
연결일 {{ formatDate(member?.googleLinkedAt) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
class="admin-member-form__google-badge inline-flex h-9 items-center rounded-full px-4 text-sm font-semibold"
:class="googleLinked ? 'bg-[#eef1f4] text-[#8a95a5]' : 'bg-[#f7f8fa] text-[#b3bbc5]'"
>
{{ googleLinked ? '연결 됨' : '미연결' }}
</span>
<button
v-if="canUnlinkGoogle"
class="admin-member-form__google-unlink h-9 rounded-md border border-[#ffd2d6] px-4 text-sm font-semibold text-[#d21a26] transition hover:bg-[#fff1f2]"
type="button"
@click="openGoogleUnlinkModal"
>
연동 해제
</button>
</div>
</div>
</div>
</section>
<section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
@@ -576,6 +978,8 @@ watch(() => props.member, () => {
@leave="leaveUnsavedPage"
/>
<AdminToast :toast="toast" />
<Teleport to="body">
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
@@ -642,5 +1046,62 @@ watch(() => props.member, () => {
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="googleUnlinkModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">Google 연동 해제</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeGoogleUnlinkModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님의 Google 로그인 연동을 해제합니다. 해제 후에는 이 Google 계정으로 로그인할 수 없습니다.
</p>
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeGoogleUnlinkModal">
취소
</button>
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUnlinkingGoogle" @click="unlinkGoogleIdentity">
{{ isUnlinkingGoogle ? '해제 중' : '연동 해제' }}
</button>
</footer>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="ownershipTransferModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">소유자 권한 이전</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeOwnershipTransferModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님을 새로운 소유자로 지정하시겠습니까?<br> 소유자는 한 명만 설정할 수 있으며, 변경 후 현재 계정은 <strong class="text-[#15171a]">관리자 권한</strong>으로 전환됩니다.
</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeOwnershipTransferModal">
취소
</button>
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isTransferringOwnership" @click="confirmOwnershipTransfer">
{{ isTransferringOwnership ? '이전 중' : '권한 이전' }}
</button>
</footer>
</section>
</div>
</Teleport>
</section>
</template>

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

@@ -1,214 +0,0 @@
<script setup>
import AdminNavPrimaryBranch from './AdminNavPrimaryBranch.vue'
const props = defineProps({
/** buildNavigationEditorTree 결과 */
wraps: {
type: Array,
required: true
},
/** 들여쓰기 단계 */
depth: {
type: Number,
default: 0
},
/** 루트면 `'root'`, 아니면 부모 항목 id */
parentKey: {
type: String,
default: 'root'
},
/** 드래그 중인 항목 id */
draggingId: {
type: String,
default: ''
},
/** 드롭 대상 위에 올린 항목 id */
dragOverId: {
type: String,
default: ''
}
})
const emit = defineEmits([
'drag-start',
'drag-over',
'drag-end',
'drop',
'add-child',
'remove'
])
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean} true면 드래그를 막는다
*/
const shouldBlockRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onDragStart = (event, itemId) => {
if (shouldBlockRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
emit('drag-start', { parentKey: props.parentKey, itemId })
event.dataTransfer.effectAllowed = 'move'
}
/**
* 드래그 오버
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onDragOver = (event, itemId) => {
event.preventDefault()
emit('drag-over', itemId)
}
/**
* 드롭
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 대상 id
* @returns {void}
*/
const onDrop = (event, itemId) => {
event.preventDefault()
emit('drop', { parentKey: props.parentKey, targetId: itemId })
}
/**
* 드래그 종료
* @returns {void}
*/
const onDragEnd = () => {
emit('drag-end')
}
/**
* 행 하이라이트 클래스(태그 관리 메인 태그 테이블과 동일 톤)
* @param {string} itemId - 항목 id
* @returns {string}
*/
const rowStateClass = (itemId) => {
const id = String(itemId)
if (props.dragOverId === id) {
return 'bg-[#f9f9f7]'
}
if (props.draggingId === id) {
return 'opacity-50'
}
return ''
}
</script>
<template>
<div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
<div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
<table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
<thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-nav-primary-branch__cell px-4 py-3">
#
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
라벨
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
URL
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
<template v-for="(wrap, index) in wraps" :key="wrap.item.id">
<tr
class="admin-nav-primary-branch__row cursor-move"
:class="rowStateClass(wrap.item.id)"
draggable="true"
@dragstart="onDragStart($event, wrap.item.id)"
@dragover="onDragOver($event, wrap.item.id)"
@drop="onDrop($event, wrap.item.id)"
@dragend="onDragEnd"
>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
{{ index + 1 }}
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input
v-model="wrap.item.label"
class="admin-nav-primary-branch__label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input
v-model="wrap.item.url"
class="admin-nav-primary-branch__url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<div class="admin-nav-primary-branch__actions flex flex-wrap gap-2">
<button
class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
type="button"
@click="emit('add-child', wrap.item.id)"
>
하위
</button>
<button
class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="emit('remove', wrap.item.id)"
>
삭제
</button>
</div>
</td>
</tr>
<tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
<td class="p-0" colspan="4">
<div class="admin-nav-primary-branch__nest-inner border-t border-line bg-[#fafaf8] px-2 py-3">
<AdminNavPrimaryBranch
:wraps="wrap.children"
:depth="depth + 1"
:parent-key="String(wrap.item.id)"
:dragging-id="draggingId"
:drag-over-id="dragOverId"
@drag-start="emit('drag-start', $event)"
@drag-over="emit('drag-over', $event)"
@drag-end="emit('drag-end')"
@drop="emit('drop', $event)"
@add-child="emit('add-child', $event)"
@remove="emit('remove', $event)"
/>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

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

@@ -11,20 +11,95 @@ const props = defineProps({
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 - 원본 문자열
@@ -53,19 +128,66 @@ const touchSlug = () => {
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 = () => {
emit('submit', {
name: form.name.trim(),
slug: toSlug(form.slug || form.name),
description: form.description.trim(),
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || 'general'
})
if (!canSubmit.value) {
return
}
emit('submit', currentPayload.value)
}
</script>
@@ -121,6 +243,89 @@ const submitTag = () => {
</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">
취소
@@ -128,7 +333,7 @@ const submitTag = () => {
<button
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
:disabled="!canSubmit"
>
{{ saving ? '저장 중' : submitLabel }}
</button>

View File

@@ -0,0 +1,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

@@ -17,8 +17,11 @@ const replyErrorMessage = ref('')
const newCommentBody = ref('')
const replyBody = ref('')
const activeReplyTargetId = ref('')
const sortOption = ref('best')
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)
/**
* 댓글 시간을 상대 시간 형식으로 변환한다.
@@ -111,28 +114,59 @@ const markAvatarBroken = (commentId) => {
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
member.value = await $fetch('/api/auth/me?optional=1')
} catch {
member.value = null
}
}
/**
* 게시물 변경 시 댓글 입력·답글·오류 상태를 초기화한다.
* @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/${props.slug}/comments`)
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 {
loadingComments.value = false
if (requestId === commentsRequestId && requestedSlug === props.slug) {
loadingComments.value = false
}
}
}
@@ -146,22 +180,30 @@ const submitComment = async () => {
return
}
const requestedSlug = props.slug
submitting.value = true
errorMessage.value = ''
try {
await $fetch(`/api/posts/${props.slug}/comments`, {
await $fetch(`/api/posts/${requestedSlug}/comments`, {
method: 'POST',
body: {
body
}
})
if (requestedSlug !== props.slug) {
return
}
newCommentBody.value = ''
await fetchComments()
} catch (error) {
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
if (requestedSlug === props.slug) {
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
}
} finally {
submitting.value = false
if (requestedSlug === props.slug) {
submitting.value = false
}
}
}
@@ -197,23 +239,31 @@ const submitReply = async (parentId) => {
return
}
const requestedSlug = props.slug
submittingReplyId.value = parentId
replyErrorMessage.value = ''
try {
await $fetch(`/api/posts/${props.slug}/comments`, {
await $fetch(`/api/posts/${requestedSlug}/comments`, {
method: 'POST',
body: {
body,
parentId
}
})
if (requestedSlug !== props.slug) {
return
}
closeReplyForm()
await fetchComments()
} catch (error) {
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
if (requestedSlug === props.slug) {
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
}
} finally {
submittingReplyId.value = ''
if (requestedSlug === props.slug) {
submittingReplyId.value = ''
}
}
}
@@ -234,14 +284,18 @@ const toggleLike = async (commentId) => {
return
}
const requestedSlug = props.slug
likingCommentIds.value = [...likingCommentIds.value, commentId]
errorMessage.value = ''
try {
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
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
@@ -253,9 +307,13 @@ const toggleLike = async (commentId) => {
}
})
} catch (error) {
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
if (requestedSlug === props.slug) {
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
}
} finally {
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
if (requestedSlug === props.slug) {
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
}
}
}
@@ -303,6 +361,15 @@ const repliesByParent = computed(() => {
onMounted(async () => {
await Promise.all([fetchMember(), fetchComments()])
})
watch(() => props.slug, async (nextSlug, previousSlug) => {
if (!nextSlug || nextSlug === previousSlug) {
return
}
resetCommentState()
await fetchComments()
})
</script>
<template>
@@ -313,15 +380,15 @@ onMounted(async () => {
</div>
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
<label for="comment-sort">Sort by:</label>
<label for="comment-sort">정렬:</label>
<select
id="comment-sort"
v-model="sortOption"
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
>
<option value="best">Best</option>
<option value="latest">Latest</option>
<option value="oldest">Oldest</option>
<option value="latest">최신순</option>
<option value="best">인기순</option>
<option value="oldest">오래된순</option>
</select>
</div>
@@ -339,8 +406,8 @@ onMounted(async () => {
<div class="mt-2 flex justify-end">
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submitting"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!canSubmitComment"
@click="submitComment"
>
{{ submitting ? '등록 중...' : '댓글 등록' }}
@@ -448,7 +515,7 @@ onMounted(async () => {
</div>
</div>
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] p-2">
<textarea
v-model="replyBody"
rows="3"
@@ -464,8 +531,8 @@ onMounted(async () => {
</button>
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submittingReplyId === comment.id"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!canSubmitReply"
@click="submitReply(comment.id)"
>
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
@@ -543,4 +610,3 @@ onMounted(async () => {
</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

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

View File

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

View File

@@ -1,4 +1,6 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
const props = defineProps({
url: {
type: String,
@@ -34,13 +36,7 @@ const displayHost = computed(() => {
* 썸네일이 비었을 때 파비콘 보조 URL을 만든다.
* @returns {string} favicon 요청 URL
*/
const faviconUrl = computed(() => {
if (!displayHost.value) {
return ''
}
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(displayHost.value)}&sz=128`
})
const faviconUrl = computed(() => getExternalFaviconUrl(props.url, 128))
/**
* 실제로 표시할 이미지 주소

View File

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

View File

@@ -8,6 +8,10 @@ const props = defineProps({
type: String,
default: '💡'
},
title: {
type: String,
default: ''
},
background: {
type: String,
default: 'blue'
@@ -27,62 +31,63 @@ const backgroundClass = computed(() => {
return 'prose-callout--yellow'
}
if (props.background === 'red') {
return 'prose-callout--red'
if (props.background === 'blue') {
return 'prose-callout--blue'
}
if (props.background === 'purple') {
return 'prose-callout--purple'
}
if (props.background === 'pink') {
return 'prose-callout--pink'
}
return 'prose-callout--blue'
return 'prose-callout--red'
})
</script>
<template>
<aside
class="prose-callout prose-callout-card mt-8 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
class="prose-callout prose-callout-card mb-2.5 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
:class="backgroundClass"
>
<div class="flex items-center gap-2">
<span v-if="emojiEnabled" class="inline-flex shrink-0 text-[20px] leading-none">{{ emoji || '💡' }}</span>
<div class="min-w-0 flex-1 whitespace-pre-line">
<slot />
</div>
<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: rgba(100, 116, 139, 0.12);
background: color-mix(in srgb, #050505 10%, #ffffff);
border: 1px solid #050505;
}
.prose-callout--blue {
background: rgba(59, 130, 246, 0.14);
background: color-mix(in srgb, #0055ff 10%, #ffffff);
border: 1px solid #0055ff;
}
.prose-callout--green {
background: rgba(34, 197, 94, 0.14);
background: color-mix(in srgb, #16ae68 10%, #ffffff);
border: 1px solid #16ae68;
}
.prose-callout--yellow {
background: rgba(245, 158, 11, 0.16);
background: color-mix(in srgb, #ffff00 10%, #ffffff);
border: 1px solid #ffff00;
}
.prose-callout--red {
background: rgba(239, 68, 68, 0.14);
background: color-mix(in srgb, #ff0000 10%, #ffffff);
border: 1px solid #ff0000;
}
.prose-callout--purple {
background: rgba(168, 85, 247, 0.14);
}
.prose-callout--pink {
background: rgba(236, 72, 153, 0.14);
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

@@ -1,4 +1,6 @@
<script setup>
import { parseIframeEmbedMarkup } from '../../lib/markdown-iframe-embed.js'
const props = defineProps({
url: {
type: String,
@@ -59,9 +61,49 @@ const getTweetId = (value) => {
return ''
}
/**
* Mastodon 공개 게시물 URL인지 확인하고 embed URL을 반환한다.
* @param {string} value - Mastodon 게시물 URL
* @returns {string} Mastodon embed URL
*/
const getMastodonEmbedUrl = (value) => {
try {
const parsedUrl = new URL(value.trim())
const path = parsedUrl.pathname.replace(/\/$/, '')
const isKnownNonMastodonHost = [
'twitter.com',
'x.com',
'mobile.twitter.com',
'youtube.com',
'www.youtube.com',
'youtu.be'
].includes(parsedUrl.hostname.replace(/^www\./, ''))
if (
isKnownNonMastodonHost ||
!['http:', 'https:'].includes(parsedUrl.protocol)
) {
return ''
}
if (/^\/@[^/]+\/\d+$/.test(path) || /^\/users\/[^/]+\/statuses\/\d+$/.test(path)) {
return `${parsedUrl.origin}${path}/embed`
}
} catch {
return ''
}
return ''
}
const youtubeId = computed(() => getYouTubeId(props.url))
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
const tweetId = computed(() => getTweetId(props.url))
const mastodonEmbedUrl = computed(() => getMastodonEmbedUrl(props.url))
const customIframe = computed(() => parseIframeEmbedMarkup(props.url))
const mastodonIframeRef = ref(null)
const mastodonEmbedHeight = ref(640)
const mastodonEmbedId = ref(0)
/**
* 외부 링크로 열어도 되는 URL인지 확인한다.
@@ -92,10 +134,74 @@ const tweetEmbedUrl = computed(() => {
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
})
/**
* Mastodon embed iframe에 실제 콘텐츠 높이 계산을 요청한다.
* @returns {void}
*/
const requestMastodonEmbedHeight = () => {
if (!mastodonIframeRef.value?.contentWindow || !mastodonEmbedId.value) {
return
}
mastodonIframeRef.value.contentWindow.postMessage({
type: 'setHeight',
id: mastodonEmbedId.value
}, '*')
}
/**
* Mastodon embed 높이 응답을 반영한다.
* @param {MessageEvent} event - iframe 메시지 이벤트
* @returns {void}
*/
const handleMastodonEmbedMessage = (event) => {
const data = event.data || {}
if (
!mastodonIframeRef.value ||
event.source !== mastodonIframeRef.value.contentWindow ||
typeof data !== 'object' ||
data.type !== 'setHeight' ||
data.id !== mastodonEmbedId.value ||
typeof data.height !== 'number'
) {
return
}
try {
const expectedOrigin = new URL(mastodonEmbedUrl.value).origin
if (event.origin !== expectedOrigin) {
return
}
} catch {
return
}
mastodonEmbedHeight.value = Math.max(320, Math.ceil(data.height))
}
onMounted(() => {
mastodonEmbedId.value = Math.floor(Math.random() * 1000000000) + 1
window.addEventListener('message', handleMastodonEmbedMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMastodonEmbedMessage)
})
watch(mastodonEmbedUrl, () => {
mastodonEmbedHeight.value = 640
requestMastodonEmbedHeight()
})
</script>
<template>
<div class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<div
class="prose-embed prose-embed-card 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"
@@ -108,11 +214,36 @@ const tweetEmbedUrl = computed(() => {
<iframe
v-else-if="tweetEmbedUrl"
:key="tweetEmbedUrl"
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
class="prose-embed__tweet block min-h-[560px] w-full border-0 sm:min-h-[620px]"
:src="tweetEmbedUrl"
title="Embedded post"
loading="lazy"
/>
<iframe
v-else-if="mastodonEmbedUrl"
:key="mastodonEmbedUrl"
ref="mastodonIframeRef"
class="prose-embed__mastodon block w-full border-0"
:src="mastodonEmbedUrl"
:height="mastodonEmbedHeight"
title="Embedded Mastodon post"
allow="fullscreen"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups"
scrolling="no"
@load="requestMastodonEmbedHeight"
/>
<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"

View File

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

View File

@@ -3,6 +3,10 @@ const props = defineProps({
level: {
type: Number,
default: 2
},
id: {
type: String,
default: ''
}
})
@@ -12,7 +16,9 @@ const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
<template>
<component
:is="tagName"
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
:id="id || undefined"
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
style="scroll-margin-top: calc(var(--site-top-chrome-height, 57px) + 24px)"
:class="{
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
defineProps({
menuOpen: {
type: Boolean,
required: true
},
menuMounted: {
type: Boolean,
default: false
}
})
@@ -15,12 +21,101 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: navigation } = await useFetch('/api/navigation', {
default: () => ({
primary: [],
footer: []
footer: [],
recommended: []
})
})
/** 저자 영역 공개 여부 */
const showAuthorSection = false
const STORAGE_KEY = 'sori-primary-nav-expanded'
/**
* 공개 추천 사이트 목록을 반환한다.
* @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 - 노드 목록
@@ -137,10 +232,16 @@ onMounted(() => {
<template>
<aside
id="menu"
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:top-[57px] max-lg:z-[60] max-lg:h-[calc(100dvh-57px)] max-lg:max-h-[calc(100dvh-57px)] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start"
:class="menuOpen
? 'max-lg:translate-x-0 max-lg:pointer-events-auto lg:w-[287px] lg:opacity-100'
: 'max-lg:-translate-x-full max-lg:pointer-events-none lg:w-0 lg:opacity-0 lg:border-transparent'"
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">
@@ -158,7 +259,7 @@ onMounted(() => {
<NuxtLink
v-for="tag in tags"
:key="tag.id"
class="left-sidebar__category site-panel-hover group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
:to="`/tag/${tag.slug}`"
>
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
@@ -173,7 +274,47 @@ onMounted(() => {
</div>
</div>
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
<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>
@@ -206,7 +347,7 @@ onMounted(() => {
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
class="left-sidebar__theme-dot site-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"

View File

@@ -8,29 +8,67 @@ defineProps({
</script>
<template>
<article class="post-card site-section site-panel-hover">
<article class="post-card site-section group relative">
<div class="post-card__body site-section-body flex gap-4">
<img
v-if="post.featuredImage"
class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-surface object-cover"
:src="post.featuredImage"
:alt="post.title"
loading="lazy"
>
<div v-else class="post-card__thumb h-20 w-36 shrink-0 rounded-lg bg-[linear-gradient(135deg,#06333a,#f4a261)]" />
<PostCardMedia
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
:thumbnail-image="post.featuredImageThumbnail"
link-class="h-20 w-36 shrink-0"
aspect-class="h-full w-full"
/>
<div class="post-card__content min-w-0">
<h2 class="post-card__title text-base font-semibold leading-tight">
<NuxtLink class="post-card__title-link site-interactive hover:opacity-70" :to="post.to">
<NuxtLink class="post-card__title-link post-title-link" :to="post.to">
{{ post.title }}
</NuxtLink>
</h2>
<p class="post-card__excerpt mt-2 text-sm leading-6 site-muted">
<p
v-if="post.excerpt"
class="post-card__excerpt post-summary-clamp post-summary-clamp--two mt-2 text-sm leading-6 site-muted"
>
{{ post.excerpt }}
</p>
<p class="post-card__meta mt-2 text-xs site-muted">
{{ post.publishedAt }} / {{ post.tag }}
<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

@@ -1,30 +1,196 @@
<script setup>
const followLinks = [
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
]
import { getVisibleSocialLinks } from '~/lib/social-links.js'
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
logoText: '井',
logoUrl: '',
copyrightText: '©2026 sori.studio'
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:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
<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 place-items-center overflow-hidden rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
<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"
@@ -42,15 +208,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</p>
</div>
</div>
<form class="right-sidebar__subscribe mt-4 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input class="right-sidebar__input min-w-0 w-full flex-1 rounded-lg px-3 py-2 text-sm site-input sm:min-w-0" placeholder="Your email">
<button class="right-sidebar__button shrink-0 rounded-lg px-4 py-2 text-sm font-semibold site-button sm:self-auto" type="button">
Subscribe
</button>
</form>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div v-if="!isPostDetailRoute && followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Follow
@@ -59,11 +219,11 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
<a
v-for="item in followLinks"
:key="item.id"
class="site-interactive p-0.5 hover:opacity-75"
class="right-sidebar__social-link site-interactive inline-flex h-5 w-5 items-center justify-center p-0.5 leading-none hover:opacity-75"
:href="item.href"
:aria-label="item.label"
target="_blank"
rel="noreferrer"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noreferrer' : undefined"
>
<svg
v-if="item.icon === 'facebook'"
@@ -74,7 +234,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
class="block h-4 w-4 shrink-0"
>
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
</svg>
@@ -87,7 +247,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
class="block h-4 w-4 shrink-0"
>
<path d="M4 4l11.733 16H20L8.267 4z" />
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
@@ -101,7 +261,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
class="block h-4 w-4 shrink-0"
>
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0c-2.4-1.6-3.5-1.3-3.5-1.3a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21" />
</svg>
@@ -114,7 +274,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
class="block h-4 w-4 shrink-0"
>
<rect x="4" y="4" width="16" height="16" rx="4" />
<circle cx="12" cy="12" r="3" />
@@ -129,12 +289,47 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
class="block h-4 w-4 shrink-0"
>
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</svg>
<svg
v-else-if="item.icon === 'youtube'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<path d="M2.5 17a24.1 24.1 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.6 49.6 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.1 24.1 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.6 49.6 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
<path d="m10 15 5-3-5-3z" />
</svg>
<svg
v-else-if="item.icon === 'rss'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="block h-4 w-4 shrink-0"
>
<circle cx="5" cy="19" r="1" />
<path d="M4 4a16 16 0 0 1 16 16" />
<path d="M4 11a9 9 0 0 1 9 9" />
</svg>
<span
v-else-if="item.icon === 'custom' && item.iconSvg"
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center fill-[var(--site-text)]"
aria-hidden="true"
v-html="item.iconSvg"
/>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
@@ -144,11 +339,10 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
class="block h-4 w-4 shrink-0"
>
<circle cx="5" cy="19" r="1" />
<path d="M4 4a16 16 0 0 1 16 16" />
<path d="M4 11a9 9 0 0 1 9 9" />
<path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.15-1.15" />
</svg>
<span class="sr-only">{{ item.label }}</span>
</a>
@@ -156,27 +350,49 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<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">
Recommended
목차
</p>
<span>↗</span>
</div>
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/post/hello-sori-studio">
sori.studio 첫 글과 방향
</NuxtLink>
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/projects">
Projects and services
</NuxtLink>
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/links">
Links and portal
</NuxtLink>
</div>
<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 class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div v-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<p class="right-sidebar__about text-sm leading-6 site-muted">
{{ siteSettings.description }}
</p>
@@ -184,6 +400,12 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
About {{ siteSettings.title }}
</NuxtLink>
</div>
<SiteAdSlot
class="right-sidebar__ad-slot py-5 pl-5 pr-0 max-lg:px-0"
:code="sidebarAdCode"
:location="isPostDetailRoute ? 'post-sidebar-right' : 'sidebar'"
/>
</div>
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
@@ -191,3 +413,34 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</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

@@ -15,17 +15,17 @@ const toggleBranch = inject('sidebarPrimaryNavToggle')
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-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
`${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-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
/** 행 공통: site-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
const navRowShell =
'site-panel-hover 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'
'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'
/**
* 노드가 펼쳐져 있는지
@@ -122,7 +122,7 @@ const navLinkClass = (url) => {
: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 border-l border-[var(--site-line)] pl-2 pt-0">
<ul class="sidebar-primary-nav-list__sub ml-0 mt-1 pl-3 pt-0">
<SidebarPrimaryNavList :nodes="node.children" />
</ul>
</div>

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

@@ -6,11 +6,15 @@ const userMenuToggleRef = ref(null)
const searchOpen = ref(false)
const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
logoUrl: ''
})
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'
})
/**
@@ -66,7 +70,7 @@ const toggleUserMenu = () => {
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
member.value = await $fetch('/api/auth/me?optional=1')
} catch {
member.value = null
}
@@ -152,7 +156,7 @@ onBeforeUnmount(() => {
</script>
<template>
<header class="site-header sticky top-0 z-20 backdrop-blur">
<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="/">
@@ -167,14 +171,6 @@ onBeforeUnmount(() => {
@click.prevent="toggleMenu"
>
<span v-if="menuOpen" class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
</svg>
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
</svg>
</span>
<span v-else class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
<path d="M9 4v16" />
@@ -185,13 +181,15 @@ onBeforeUnmount(() => {
<path d="m14 10 2 2-2 2" />
</svg>
</span>
<span v-else class="site-header__menu-icon pointer-events-none">
<svg class="block h-6 w-6 group-hover:hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 21a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6Zm12-16h-8v14h8a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z" />
</svg>
<svg class="hidden h-6 w-6 group-hover:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 3a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12Zm0 2h-9v14h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Zm-2.3 4.3a1 1 0 0 1 0 1.4L14.4 12l1.3 1.3a1 1 0 0 1-1.4 1.4l-2-2a1 1 0 0 1 0-1.4l2-2a1 1 0 0 1 1.4 0Z" />
</svg>
</span>
</button>
<img
v-if="siteSettings.logoUrl"
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
:src="siteSettings.logoUrl"
:alt="siteSettings.title"
>
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink>
</div>
@@ -202,7 +200,13 @@ onBeforeUnmount(() => {
aria-label="검색 열기"
@click="openSearchModal"
>
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
<span class="site-header__search-icon mr-2 text-lg leading-none">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="10" cy="10" r="7"></circle>
<line x1="21" y1="21" x2="15" y2="15"></line>
</svg>
</span>
<span class="site-header__search-text site-soft">Search</span>
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
</button>
@@ -224,7 +228,15 @@ onBeforeUnmount(() => {
class="h-full w-full rounded-full object-cover"
>
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-[var(--site-text)]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M367-527q-47-47-47-113t47-113q47-47 113-47t113 47q47 47 47 113t-47 113q-47 47-113 47t-113-47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm296.5-343.5Q560-607 560-640t-23.5-56.5Q513-720 480-720t-56.5 23.5Q400-673 400-640t23.5 56.5Q447-560 480-560t56.5-23.5ZM480-640Zm0 400Z" />
</svg>
</span>
</button>
@@ -255,7 +267,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex flex-col gap-0.5">
<div class="max-w-xs truncate leading-[1.15]">
{{ member?.username || 'Anonymous' }}
{{ member?.username || '방문자' }}
</div>
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
{{ member.email }}
@@ -264,6 +276,12 @@ onBeforeUnmount(() => {
</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" />
@@ -287,7 +305,7 @@ onBeforeUnmount(() => {
<path d="M15 9l-6 6" />
<path d="M15 15v-6h-6" />
</svg>
<span>Sign up</span>
<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">
@@ -296,7 +314,7 @@ onBeforeUnmount(() => {
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>Sign in</span>
<span>로그인</span>
</NuxtLink>
</template>
</div>

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

@@ -2,6 +2,7 @@
/**
* @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 })
@@ -14,6 +15,16 @@ 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 - 현재 입력값
@@ -22,7 +33,7 @@ let debounceTimer = null
const scheduleDebouncedQuery = (value) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debouncedQuery.value = value
debouncedQuery.value = normalizeSearchQuery(value)
}, 200)
}
@@ -36,7 +47,11 @@ const { data, pending } = await useFetch('/api/search', {
key: fetchKey,
query: { q: debouncedQuery },
watch: [debouncedQuery],
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
default: () => ({
tags: /** @type {SearchTagHit[]} */ ([]),
posts: /** @type {SearchPostHit[]} */ ([]),
pages: /** @type {SearchPageHit[]} */ ([])
})
})
/**
@@ -54,7 +69,7 @@ const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
*/
const highlightParts = (text, needle) => {
const base = String(text ?? '')
const q = String(needle ?? '').trim()
const q = normalizeSearchQuery(needle)
if (!q) {
return [{ text: base, hit: false }]
}
@@ -110,32 +125,36 @@ const clearQuery = () => {
*/
const onCompositionStart = () => {
isComposing.value = true
clearTimeout(debounceTimer)
}
/**
* IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다.
* 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다.
* @param {CompositionEvent} event - 조합 업데이트 이벤트
* 입력 이벤트에서 화면에 보이는 검색어를 갱신한다.
* @param {InputEvent | CompositionEvent} event - 입력 이벤트
* @returns {void}
*/
const onCompositionUpdate = (event) => {
const onInput = (event) => {
const target = event.target
if (!(target instanceof HTMLInputElement)) {
return
}
const value = target.value
query.value = value
scheduleDebouncedQuery(value)
query.value = target.value
scheduleDebouncedQuery(target.value)
}
/**
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
* @param {CompositionEvent} event - 조합 종료 이벤트
* @returns {void}
*/
const onCompositionEnd = () => {
const onCompositionEnd = (event) => {
isComposing.value = false
const target = event.target
if (target instanceof HTMLInputElement) {
query.value = target.value
}
clearTimeout(debounceTimer)
debouncedQuery.value = query.value
debouncedQuery.value = normalizeSearchQuery(query.value)
}
/**
@@ -208,8 +227,9 @@ onBeforeUnmount(() => {
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
placeholder="글 제목, 본문, 태그 검색"
@compositionstart="onCompositionStart"
@compositionupdate="onCompositionUpdate"
@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">
취소
@@ -218,7 +238,7 @@ onBeforeUnmount(() => {
<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>
@@ -244,7 +264,7 @@ onBeforeUnmount(() => {
</ul>
</section>
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__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>
@@ -272,7 +292,35 @@ onBeforeUnmount(() => {
</ul>
</section>
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
<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>

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

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

View File

@@ -0,0 +1,340 @@
import { computed, ref, watch } from 'vue'
import {
getPostBodyPreviewThumbnailUrl,
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
isPostCardThumbnailUrl,
withGeneratedImageCacheVersion
} from '../lib/post-card-thumbnail-url.js'
/**
* 자동 생성 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} 자동 생성 이미지이면 true
*/
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|| isPostBodyThumbnailUrl(imageUrl)
|| isPageCoverThumbnailUrl(imageUrl)
/**
* 관리자 게시물 폼의 대표 이미지 선택 모달 상태를 만든다.
* @param {Object} options - 대표 이미지 선택 옵션
* @param {Object} options.form - 게시물 폼 reactive 객체
* @param {number} [options.pageSize=50] - 미디어 페이지 크기
* @returns {Object} 대표 이미지 선택 상태와 이벤트 처리기
*/
export const useAdminFeaturedImagePicker = ({
form,
pageSize = 50
}) => {
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const mediaPickerTarget = ref('featuredImage')
const isLoadingMedia = ref(false)
const isUploadingFeaturedImage = ref(false)
const activeMediaPickerTab = ref('upload')
const selectedMediaPickerUrl = ref('')
const mediaSearchQuery = ref('')
const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerPage = ref(1)
const mediaPickerPageSize = pageSize
const mediaPickerPagination = ref({
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
})
/**
* 미디어 선택 페이지네이션을 초기값으로 되돌린다.
* @returns {void}
*/
const resetMediaPickerPagination = () => {
mediaPickerPage.value = 1
mediaPickerPagination.value = {
page: 1,
pageSize: mediaPickerPageSize,
total: 0,
pageCount: 1
}
}
/**
* 미디어 라이브러리 목록을 조회한다.
* @returns {Promise<void>}
*/
const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
const response = await $fetch('/admin/api/media', {
query: {
paged: '1',
tab: 'library',
page: mediaPickerPage.value,
pageSize: mediaPickerPageSize,
kind: 'image',
search: mediaSearchQuery.value.trim() || undefined
}
})
mediaItems.value = response?.items || []
mediaPickerPagination.value = response?.pagination || {
page: 1,
pageSize: mediaPickerPageSize,
total: mediaItems.value.length,
pageCount: 1
}
mediaPickerPage.value = mediaPickerPagination.value.page || 1
} finally {
isLoadingMedia.value = false
}
}
/**
* 대표 이미지 선택 창을 연다.
* @param {string} target - 선택 값을 적용할 폼 필드
* @returns {Promise<void>}
*/
const openMediaPicker = async (target = 'featuredImage') => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
resetMediaPickerPagination()
selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || ''
isMediaPickerOpen.value = true
}
/**
* 대표 이미지 선택 창을 닫는다.
* @returns {void}
*/
const closeMediaPicker = () => {
if (isUploadingFeaturedImage.value) {
return
}
isMediaPickerOpen.value = false
mediaItems.value = []
mediaSearchQuery.value = ''
resetMediaPickerPagination()
}
const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => (
item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url)
)))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const mediaPickerRangeLabel = computed(() => {
const total = Number(mediaPickerPagination.value.total) || 0
if (total <= 0) {
return '0개'
}
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
const start = ((page - 1) * mediaPickerPageSize) + 1
const end = Math.min(start + mediaPickerPageSize - 1, total)
return `${total}개 중 ${start}-${end}개`
})
const mediaPickerPageItems = computed(() => {
const total = mediaPickerPageCount.value
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
const pages = new Set([1, total, current])
for (let page = current - 2; page <= current + 2; page += 1) {
pages.add(page)
}
if (current <= 3) {
pages.add(2)
pages.add(3)
pages.add(4)
}
if (current >= total - 2) {
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
items.push(page)
return items
}, [])
})
/**
* 대표 이미지 선택 모달 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {Promise<void>}
*/
const setMediaPickerPage = async (page) => {
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
await fetchMediaItems()
}
/**
* 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다.
* @param {Object} item - 미디어 항목
* @returns {string} 미리보기 URL
*/
const getFeaturedMediaPreviewUrl = (item) => {
const url = String(item?.url || '')
if (item?.bodyThumbnailStatus?.hasThumbnail) {
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`)
}
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
}
return url
}
watch(mediaSearchQuery, async () => {
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
watch(activeMediaPickerTab, async (tab) => {
if (!isMediaPickerOpen.value || tab !== 'library') {
return
}
mediaPickerPage.value = 1
await fetchMediaItems()
})
/**
* 대표 이미지 선택 상태를 변경한다.
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const selectPickedImage = (item) => {
selectedMediaPickerUrl.value = item.url
}
/**
* 선택한 대표 이미지를 적용한다.
* @returns {void}
*/
const applyPickedImage = () => {
if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) {
return
}
form[mediaPickerTarget.value] = selectedMediaPickerUrl.value
closeMediaPicker()
}
/**
* 대표 이미지를 삭제한다.
* @returns {void}
*/
const removeFeaturedImage = () => {
form.featuredImage = ''
}
/**
* 대표 이미지 파일을 업로드한다.
* @param {File} file - 업로드 파일
* @returns {Promise<void>}
*/
const uploadFeaturedImageFile = async (file) => {
const formData = new FormData()
formData.append('files', file)
isUploadingFeaturedImage.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
selectedMediaPickerUrl.value = result.files?.[0]?.url || ''
await fetchMediaItems()
activeMediaPickerTab.value = 'library'
} finally {
isUploadingFeaturedImage.value = false
}
}
/**
* 대표 이미지 파일 입력을 처리한다.
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
const uploadFeaturedImage = async (event) => {
const files = event.target.files
if (!files?.length) {
return
}
try {
await uploadFeaturedImageFile(files[0])
} finally {
event.target.value = ''
}
}
/**
* 대표 이미지 드롭 업로드를 처리한다.
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const dropFeaturedImage = async (event) => {
const files = event.dataTransfer?.files
if (!files?.length) {
return
}
await uploadFeaturedImageFile(files[0])
}
return {
activeMediaPickerTab,
applyPickedImage,
closeMediaPicker,
dropFeaturedImage,
featuredImageMediaItems,
getFeaturedMediaPreviewUrl,
isLoadingMedia,
isMediaPickerOpen,
isUploadingFeaturedImage,
mediaPickerPage,
mediaPickerPageCount,
mediaPickerPageItems,
mediaPickerRangeLabel,
mediaSearchInput,
mediaSearchQuery,
onMediaSearchCompositionEnd,
onMediaSearchInput,
openMediaPicker,
removeFeaturedImage,
selectPickedImage,
selectedMediaPickerUrl,
setMediaPickerPage,
uploadFeaturedImage
}
}

View File

@@ -0,0 +1,264 @@
import { nextTick } from 'vue'
import {
createCalloutOptionsReplacementPlan,
createCodeOptionsReplacementPlan,
createEmbedUrlReplacementPlan,
createGalleryImageMovePlan,
createGalleryImagesAppendPlan,
createLiveInlineColorValue,
createMediaImageRemovePlan,
createMediaImagesReplacementPlan,
createMediaImageUpdatePlan,
createMediaUseAltPatch,
createQuoteBackgroundValue,
createSourceInlineColorValue,
createToggleOptionsReplacementPlan
} from '../lib/markdown-editor-block-options.js'
/**
* 관리자 Markdown 블록 패널의 옵션 적용 함수를 만든다.
* @param {Object} options - 블록 액션 옵션
* @param {import('vue').Ref<string>} options.value - Markdown 본문 ref
* @param {import('vue').Ref<Object|null>} options.activeMediaBlock - 현재 미디어 블록 ref
* @param {import('vue').Ref<Object|null>} options.activeBlockContext - 현재 활성 블록 ref
* @param {import('vue').Ref<Object|null>} options.inlineColorSelectionContext - 인라인 색상 선택 컨텍스트 ref
* @param {(plan: Object|null, focusEditor?: boolean) => boolean} options.applyLineReplacementPlan - 줄 교체 계획 적용 함수
* @param {() => void} options.ensureBlockPanelEngaged - 블록 패널 유지 함수
* @param {(images: Array<Object>) => void} options.insertGallery - 갤러리 삽입 함수
* @param {(start: number, end?: number, options?: Object) => void} options.setTextareaSelection - textarea 선택 범위 설정 함수
* @param {(focus: Object) => void} options.requestPreviewFocus - 미리보기 포커스 예약 함수
* @param {(line: number, column?: number, align?: string, offset?: number, block?: string) => void} options.focusPreviewEditableAtLine - 미리보기 줄 포커스 함수
* @returns {Object} 블록 패널 옵션 적용 함수
*/
export const useAdminMarkdownBlockActions = (options) => {
/**
* 줄 교체 계획을 블록 패널 유지와 함께 적용한다.
* @param {Object|null} plan - 줄 교체 계획
* @param {boolean} [engage=true] - 패널 유지 여부
* @returns {boolean} 적용 여부
*/
const applyPanelLineReplacementPlan = (plan, engage = true) => {
if (!plan) {
return false
}
if (engage) {
options.ensureBlockPanelEngaged()
}
return options.applyLineReplacementPlan(plan, false)
}
/**
* 현재 미디어 블록을 이미지 목록 기준으로 다시 작성한다.
* @param {Array<{ alt: string, url: string, width?: string }>} images - 이미지 목록
* @returns {void}
*/
const replaceActiveMediaImages = (images) => {
options.applyLineReplacementPlan(
createMediaImagesReplacementPlan(options.activeMediaBlock.value, images),
false
)
}
/**
* 현재 미디어 블록의 특정 이미지를 수정한다.
* @param {number} imageIndex - 이미지 인덱스
* @param {Partial<{ alt: string, url: string, width: string, pixelWidth: number|null }>} patch - 변경 값
* @returns {void}
*/
const updateActiveMediaImage = (imageIndex, patch) => {
applyPanelLineReplacementPlan(
createMediaImageUpdatePlan(options.activeMediaBlock.value, imageIndex, patch)
)
}
/**
* 현재 미디어 이미지의 파일명 캡션 사용 여부를 바꾼다.
* @param {number} imageIndex - 이미지 인덱스
* @param {boolean} enabled - 파일명 캡션 사용 여부
* @returns {void}
*/
const setActiveMediaUseAlt = (imageIndex, enabled) => {
const image = options.activeMediaBlock.value?.images[imageIndex]
const patch = createMediaUseAltPatch(image, enabled)
if (patch) {
updateActiveMediaImage(imageIndex, patch)
}
}
/**
* 현재 갤러리 이미지 순서를 바꾼다.
* @param {number} imageIndex - 이동할 이미지 인덱스
* @param {-1|1} direction - 이동 방향
* @returns {void}
*/
const moveActiveGalleryImage = (imageIndex, direction) => {
applyPanelLineReplacementPlan(
createGalleryImageMovePlan(options.activeMediaBlock.value, imageIndex, direction),
false
)
}
/**
* 현재 미디어 블록에서 이미지를 삭제한다.
* @param {number} imageIndex - 삭제할 이미지 인덱스
* @returns {void}
*/
const removeActiveMediaImage = (imageIndex) => {
applyPanelLineReplacementPlan(
createMediaImageRemovePlan(options.activeMediaBlock.value, imageIndex),
false
)
}
/**
* 현재 갤러리에 이미지를 추가한다.
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 추가 이미지 목록
* @returns {void}
*/
const appendImagesToActiveGallery = (images) => {
const plan = createGalleryImagesAppendPlan(options.activeMediaBlock.value, images)
if (!plan) {
options.insertGallery(images)
return
}
options.applyLineReplacementPlan(plan, false)
}
/**
* 현재 링크·iframe·HTML 블록 값을 수정한다.
* @param {string} url - 블록 값
* @returns {void}
*/
const updateActiveEmbedUrl = (url) => {
applyPanelLineReplacementPlan(
createEmbedUrlReplacementPlan(options.activeBlockContext.value, url)
)
}
/**
* 현재 인용 블록 배경색을 수정한다.
* @param {string} background - 배경색 옵션
* @returns {void}
*/
const updateActiveQuoteBackground = (background) => {
const nextValue = createQuoteBackgroundValue({
value: options.value.value,
block: options.activeBlockContext.value,
background
})
if (nextValue === null) {
return
}
options.ensureBlockPanelEngaged()
options.value.value = nextValue
}
/**
* 현재 콜아웃 블록 옵션을 수정한다.
* @param {Partial<{ calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string, title: string }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveCalloutOptions = (patch = {}) => {
applyPanelLineReplacementPlan(
createCalloutOptionsReplacementPlan(options.activeBlockContext.value, patch)
)
}
/**
* 현재 코드 블록 옵션을 수정한다.
* @param {Partial<{ language: string, showLineNumbers: boolean }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveCodeOptions = (patch = {}) => {
applyPanelLineReplacementPlan(
createCodeOptionsReplacementPlan(options.activeBlockContext.value, patch)
)
}
/**
* 현재 토글 블록 옵션을 수정한다.
* @param {Partial<{ defaultOpen: boolean }>} patch - 변경 옵션
* @returns {void}
*/
const updateActiveToggleOptions = (patch = {}) => {
applyPanelLineReplacementPlan(
createToggleOptionsReplacementPlan(options.activeBlockContext.value, patch)
)
}
/**
* 현재 선택된 텍스트에 인라인 색상을 적용한다.
* @param {string} color - 색상 이름
* @returns {void}
*/
const updateActiveInlineColor = (color) => {
const context = options.inlineColorSelectionContext.value
if (!context || context.kind !== 'inline-color') {
return
}
options.ensureBlockPanelEngaged()
const sourceResult = createSourceInlineColorValue({
value: options.value.value,
context,
color
})
if (sourceResult) {
options.value.value = sourceResult.value
options.inlineColorSelectionContext.value = null
options.setTextareaSelection(sourceResult.selectionStart, sourceResult.selectionEnd, { preserveScroll: true })
return
}
const liveResult = createLiveInlineColorValue({
value: options.value.value,
context,
color
})
if (!liveResult) {
return
}
options.value.value = liveResult.value
options.inlineColorSelectionContext.value = null
options.requestPreviewFocus({
line: liveResult.focusLine,
offset: liveResult.focusOffset
})
nextTick(() => {
options.focusPreviewEditableAtLine(
liveResult.focusLine,
0,
'auto',
liveResult.focusOffset,
'nearest'
)
})
}
return {
appendImagesToActiveGallery,
moveActiveGalleryImage,
removeActiveMediaImage,
replaceActiveMediaImages,
setActiveMediaUseAlt,
updateActiveCalloutOptions,
updateActiveCodeOptions,
updateActiveEmbedUrl,
updateActiveInlineColor,
updateActiveMediaImage,
updateActiveQuoteBackground,
updateActiveToggleOptions
}
}

View File

@@ -0,0 +1,332 @@
import { computed, nextTick, ref, watch } from 'vue'
/**
* 지정 줄이 블록 컨텍스트 범위 안에 있는지 확인한다.
* @param {Object|null} context - 블록 컨텍스트
* @param {number} lineIndex - 현재 논리 줄
* @returns {boolean}
*/
const isLineInsideBlockContext = (context, lineIndex) => {
if (!context) {
return false
}
const startLine = Number(context.startLine)
const endLine = Number(context.endLine)
return Number.isInteger(startLine)
&& Number.isInteger(endLine)
&& lineIndex >= startLine
&& lineIndex <= endLine
}
/**
* 관리자 Markdown 에디터의 블록 설정 패널 표시 상태를 만든다.
* @param {Object} options - 블록 패널 옵션
* @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드
* @param {import('vue').Ref<string>} options.value - Markdown 본문
* @param {import('vue').Ref<string|null>} options.writeModeContextValue - 작성 모드 최신 본문
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
* @param {import('vue').Ref<boolean>} options.isMediaPickerOpen - 미디어 선택 모달 표시 여부
* @param {(value: string, lineIndex: number) => Object|null} options.resolveBlockContext - 블록 컨텍스트 계산 함수
* @param {() => Object|null} options.getInlineColorSelectionContext - 인라인 색상 선택 컨텍스트 계산 함수
* @param {(payload: { open: boolean, panel: Object|null }) => void} options.emitBlockPanel - 패널 상태 전달 함수
* @returns {Object} 블록 패널 상태와 이벤트
*/
export const useAdminMarkdownBlockPanel = (options) => {
/** textarea 포커스 여부 */
const isTextareaFocused = ref(false)
/** 블록 패널에 포커스가 있거나 패널 편집을 유지해야 하는지 여부 */
const isBlockPanelEngaged = ref(false)
/** 한글 등 IME 조합 입력 중 여부 */
const isTextComposing = ref(false)
/** 조합 입력 중 패널 닫힘을 방지하기 위한 마지막 블록 컨텍스트 */
const lastStableBlockContext = ref(null)
/** @type {import('vue').Ref<{ kind: 'inline-color', mode: 'source'|'live', start?: number, end?: number, sourceLine?: number, startOffset?: number, endOffset?: number, bodyText?: string, text: string }|null>} 선택 텍스트 색상 패널 컨텍스트 */
const inlineColorSelectionContext = ref(null)
let blockPanelFocusTimer = null
/**
* 현재 포커스가 블록 설정 패널 안에 있는지 확인한다.
* @returns {boolean}
*/
const isFocusInBlockPanel = () => Boolean(
typeof document !== 'undefined'
&& document.activeElement?.closest?.('.admin-editor-block-panel')
)
/**
* 현재 포커스가 미디어 선택 모달 안에 있는지 확인한다.
* @returns {boolean}
*/
const isFocusInMediaPicker = () => Boolean(
typeof document !== 'undefined'
&& document.activeElement?.closest?.('.admin-markdown-editor__media-modal')
)
/**
* 커서 줄 기준 활성 블록(이미지·갤러리·임베드·인용)
* @returns {Object|null}
*/
const activeBlockContext = computed(() => options.resolveBlockContext(
options.writeModeContextValue.value ?? options.value.value,
options.activeLogicalLineIndex.value
))
/** @deprecated 내부 호환 alias */
const activeMediaBlock = activeBlockContext
/**
* IME 조합 중에는 일시적으로 블록 판별이 비어도 직전 패널을 유지한다.
* @returns {Object|null} 패널 표시용 블록 컨텍스트
*/
const visibleBlockContext = computed(() => {
if (inlineColorSelectionContext.value) {
return null
}
if (activeBlockContext.value) {
return activeBlockContext.value
}
if (isTextComposing.value
&& isLineInsideBlockContext(lastStableBlockContext.value, options.activeLogicalLineIndex.value)) {
return lastStableBlockContext.value
}
return null
})
/**
* 오른쪽 설정 패널에 표시할 현재 컨텍스트를 반환한다.
* @returns {Object|null} 패널 컨텍스트
*/
const activePanelContext = computed(() => inlineColorSelectionContext.value || visibleBlockContext.value)
/**
* 블록 설정 패널 표시 여부
* @returns {boolean}
*/
const isBlockPanelVisible = computed(() => (options.activeMode.value === 'write' || isBlockPanelEngaged.value)
&& Boolean(activePanelContext.value)
&& (isTextareaFocused.value || isBlockPanelEngaged.value))
/**
* 부모(글 설정 사이드바 오버레이)에 패널 상태를 전달한다.
* @returns {void}
*/
const syncBlockPanelState = () => {
options.emitBlockPanel({
open: isBlockPanelVisible.value,
panel: activePanelContext.value
})
}
/**
* 블록 패널 편집 중 상태를 유지한다.
* @returns {void}
*/
const ensureBlockPanelEngaged = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = true
syncBlockPanelState()
}
/**
* 블록 설정 패널을 닫는다.
* @returns {void}
*/
const closeBlockPanel = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = false
isTextareaFocused.value = false
inlineColorSelectionContext.value = null
syncBlockPanelState()
}
/**
* 현재 선택 범위를 기준으로 텍스트 색상 패널 컨텍스트를 갱신한다.
* @returns {void}
*/
const syncInlineColorSelectionContext = () => {
if (isFocusInBlockPanel() || options.isMediaPickerOpen.value) {
return
}
const nextContext = options.getInlineColorSelectionContext()
inlineColorSelectionContext.value = nextContext
if (nextContext) {
isBlockPanelEngaged.value = true
}
}
/**
* textarea 포커스 진입 상태를 기록한다.
* @returns {void}
*/
const handleTextareaFocusIn = () => {
window.clearTimeout(blockPanelFocusTimer)
isTextareaFocused.value = true
}
/**
* textarea 포커스 이탈 상태를 지연 반영한다.
* @returns {void}
*/
const handleTextareaFocusOut = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (options.isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isTextareaFocused.value = false
syncBlockPanelState()
}, 0)
}
/**
* 블록 패널 포커스 진입(AdminPostForm 오버레이에서 호출)
* @returns {void}
*/
const handleBlockPanelFocusIn = () => {
window.clearTimeout(blockPanelFocusTimer)
isBlockPanelEngaged.value = true
syncBlockPanelState()
}
/**
* 블록 패널 포커스 이탈
* @returns {void}
*/
const handleBlockPanelFocusOut = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (options.isMediaPickerOpen.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isBlockPanelEngaged.value = false
if (!isTextareaFocused.value) {
syncBlockPanelState()
}
}, 50)
}
/**
* IME 조합 입력 시작 시 패널 컨텍스트를 유지한다.
* @returns {void}
*/
const handleTextareaCompositionStart = () => {
isTextComposing.value = true
if (activeBlockContext.value) {
lastStableBlockContext.value = activeBlockContext.value
}
}
/**
* IME 조합 입력 종료 상태를 기록한다.
* @returns {void}
*/
const handleTextareaCompositionEnd = () => {
isTextComposing.value = false
}
/**
* 라이브 모드 포커스 줄을 기준으로 블록 패널을 동기화한다.
* @param {number} sourceLine - 원본 마크다운 줄(0-based)
* @returns {Promise<void>}
*/
const handleLiveLineFocus = async (sourceLine) => {
const nextLine = Number(sourceLine)
if (!Number.isInteger(nextLine) || nextLine < 0) {
return
}
options.activeLogicalLineIndex.value = nextLine
isTextareaFocused.value = false
isTextComposing.value = false
await nextTick()
if (activeBlockContext.value) {
lastStableBlockContext.value = activeBlockContext.value
isBlockPanelEngaged.value = true
} else {
isBlockPanelEngaged.value = false
}
syncBlockPanelState()
}
/**
* 라이브 모드 포커스가 블록·패널 밖으로 나가면 패널을 닫는다.
* @returns {void}
*/
const handleLiveLineBlur = () => {
blockPanelFocusTimer = window.setTimeout(() => {
if (options.isMediaPickerOpen.value || isBlockPanelEngaged.value || isFocusInBlockPanel() || isFocusInMediaPicker()) {
return
}
isBlockPanelEngaged.value = false
syncBlockPanelState()
}, 0)
}
/**
* 블록 패널 컨텍스트를 초기화한다.
* @returns {void}
*/
const resetBlockPanelContext = () => {
isBlockPanelEngaged.value = false
lastStableBlockContext.value = null
syncBlockPanelState()
}
/**
* 블록 패널 타이머를 정리한다.
* @returns {void}
*/
const clearBlockPanelFocusTimer = () => {
window.clearTimeout(blockPanelFocusTimer)
}
watch(activeBlockContext, (context) => {
if (context) {
lastStableBlockContext.value = context
}
}, { deep: true, immediate: true })
watch([isBlockPanelVisible, activePanelContext], syncBlockPanelState, { deep: true })
return {
isTextareaFocused,
isBlockPanelEngaged,
isTextComposing,
lastStableBlockContext,
inlineColorSelectionContext,
activeBlockContext,
activeMediaBlock,
activePanelContext,
isBlockPanelVisible,
isFocusInBlockPanel,
isFocusInMediaPicker,
ensureBlockPanelEngaged,
closeBlockPanel,
syncBlockPanelState,
syncInlineColorSelectionContext,
handleTextareaFocusIn,
handleTextareaFocusOut,
handleBlockPanelFocusIn,
handleBlockPanelFocusOut,
handleTextareaCompositionStart,
handleTextareaCompositionEnd,
handleLiveLineFocus,
handleLiveLineBlur,
resetBlockPanelContext,
clearBlockPanelFocusTimer
}
}

View File

@@ -0,0 +1,131 @@
import { nextTick, unref } from 'vue'
/**
* 관리자 Markdown 에디터의 공통 포커스 실행을 관리한다.
* @param {Object} options - 포커스 실행 옵션
* @param {import('vue').Ref<'write'|'preview'|string>} options.activeMode - 현재 에디터 모드
* @param {import('vue').Ref<string>} options.value - Markdown 본문
* @param {import('vue').Ref<number>} options.activeLogicalLineIndex - 현재 논리 줄
* @param {() => HTMLTextAreaElement|null} options.getWriteTextareaElement - 작성 textarea 반환 함수
* @param {(offset: number) => number} options.getLineIndexAtOffset - 오프셋 기준 줄 계산 함수
* @param {(lineIndex: number) => number} options.getLineStartOffset - 줄 시작 오프셋 계산 함수
* @param {(start: number, end?: number, options?: { preserveScroll?: boolean }) => void} options.setTextareaSelection - textarea 선택 복원 함수
* @param {(lineIndex: number) => void} options.focusTextareaAtLine - 지정 줄 textarea 포커스 함수
* @param {() => void} options.refreshCaretLogicalLine - 현재 커서 줄 갱신 함수
* @param {() => void} options.scrollPreviewToTop - 라이브 미리보기 상단 이동 함수
* @param {(line: number, childIndex: number, position: string, offset?: number, scrollAlign?: string) => void} options.focusPreviewEditableAtLine - 라이브 편집 줄 포커스 함수
* @returns {{
* focusFirstBlock: () => void,
* getInitialContentFocusLineIndex: () => number,
* ensureContentFocusLineExists: (lineIndex: number) => number,
* restoreEditorFocusAfterHistory: (selection?: { start: number, end: number }|null) => void
* }} 공통 포커스 액션
*/
export const useAdminMarkdownEditorFocusActions = (options) => {
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
*/
const getInitialContentFocusLineIndex = () => {
const lines = String(unref(options.value) ?? '').split('\n')
if (!lines.length) {
return 0
}
const firstLine = lines[0]?.trim() ?? ''
if (/^#{1,6}\s+/.test(firstLine)) {
return 1
}
return 0
}
/**
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
* @param {number} lineIndex - 포커스할 줄
* @returns {number} 보정된 줄 번호
*/
const ensureContentFocusLineExists = (lineIndex) => {
const value = String(unref(options.value) ?? '')
const lines = value.split('\n')
if (lineIndex < lines.length) {
return lineIndex
}
options.value.value = lines.length ? `${value}\n` : ''
return lines.length
}
/**
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
* @returns {void}
*/
const focusFirstBlock = () => {
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
if (options.activeMode.value === 'preview') {
options.scrollPreviewToTop()
nextTick(() => {
nextTick(() => {
options.focusPreviewEditableAtLine(focusLine, 0, 'start')
})
})
return
}
nextTick(() => {
const textarea = options.getWriteTextareaElement()
const lineStart = options.getLineStartOffset(focusLine)
if (textarea) {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart)
textarea.scrollTop = 0
}
options.activeLogicalLineIndex.value = focusLine
options.refreshCaretLogicalLine()
})
}
/**
* 히스토리 복원 후 현재 모드에 맞게 포커스를 복원한다.
* @param {{ start: number, end: number }|null} [selection=null] - 복원할 선택 영역
* @returns {void}
*/
const restoreEditorFocusAfterHistory = (selection = null) => {
const focusLine = selection
? options.getLineIndexAtOffset(selection.start)
: options.activeLogicalLineIndex.value
if (options.activeMode.value === 'write') {
if (selection) {
options.setTextareaSelection(selection.start, selection.end, { preserveScroll: true })
return
}
options.focusTextareaAtLine(focusLine)
return
}
nextTick(() => {
const offset = selection
? Math.max(0, selection.start - options.getLineStartOffset(focusLine))
: 0
options.focusPreviewEditableAtLine(focusLine, 0, 'auto', offset, 'center')
})
}
return {
focusFirstBlock,
getInitialContentFocusLineIndex,
ensureContentFocusLineExists,
restoreEditorFocusAfterHistory
}
}

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