설정 화면 메인 커버 UI 정리 v1.5.29
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.5.29
|
||||||
|
|
||||||
|
- 관리자 설정의 메인 화면 커버를 라이트모드와 다크모드로 나누어 각각 확인하고 변경할 수 있게 했다.
|
||||||
|
- 커버 이미지가 없는 경우 점선 드롭존에서 파일 선택 또는 드래그 앤 드롭으로 업로드할 수 있게 했다.
|
||||||
|
- 사용하지 않는 타임존 설정을 제거하고 `기타 설정`을 `사이트 정보`로 정리했다.
|
||||||
|
|
||||||
## v1.5.28
|
## v1.5.28
|
||||||
|
|
||||||
- 게시물 Import가 Obsidian식 YAML 블록 배열 태그를 읽을 수 있게 했다.
|
- 게시물 Import가 Obsidian식 YAML 블록 배열 태그를 읽을 수 있게 했다.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 로컬 기준 v1.5.28에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
> 로컬 기준 v1.5.29에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-06-02 v1.5.29 — 라이트·다크 커버는 각각 확인 가능해야 한다
|
||||||
|
|
||||||
|
홈 커버 이미지는 라이트·다크 테마에서 서로 다른 시각 결과를 만들 수 있다. 하나의 `HomeHero` 미리보기만 두면 현재 OS나 테마 상태에 따라 한쪽 이미지를 확인하기 어렵기 때문에, 관리자 설정에서는 라이트모드와 다크모드를 상하 개별 프리뷰로 항상 보여 준다. 아직 실제 동작이 없는 타임존 설정은 노출하지 않고, 로고·URL·저작권은 포괄적인 `사이트 정보`로 이름을 정리한다.
|
||||||
|
|
||||||
## 2026-06-02 v1.5.28 — Import는 부분 누락을 경고로 남긴다
|
## 2026-06-02 v1.5.28 — Import는 부분 누락을 경고로 남긴다
|
||||||
|
|
||||||
Export ZIP을 Obsidian에서 열고 편집한 뒤 다시 Import할 수 있어야 하므로, 태그 frontmatter는 inline 배열뿐 아니라 Obsidian이 흔히 쓰는 블록 배열도 읽는다. 또한 일부 이미지나 파일이 ZIP에서 누락된 경우 전체 Import를 막으면 나머지 게시물 복구까지 실패하므로, 게시물 생성은 계속 진행하고 누락 자산은 관리자 화면 경고로 확인하게 한다.
|
Export ZIP을 Obsidian에서 열고 편집한 뒤 다시 Import할 수 있어야 하므로, 태그 frontmatter는 inline 배열뿐 아니라 Obsidian이 흔히 쓰는 블록 배열도 읽는다. 또한 일부 이미지나 파일이 ZIP에서 누락된 경우 전체 Import를 막으면 나머지 게시물 복구까지 실패하므로, 게시물 생성은 계속 진행하고 누락 자산은 관리자 화면 경고로 확인하게 한다.
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), 타임존, 게시물 Import/Export 기본 액션 버튼, 펼침형 Export 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 펼침형 Import ZIP 업로드·완료 요약·누락 자산 경고 표시, 최근 작업·진행도·준비 완료 분할 파일 다운로드·브라우저 순차 일괄 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제, 진행 중 요청 버튼 잠금 |
|
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명, **사이트 정보**(로고·URL·저작권), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), 게시물 Import/Export 기본 액션 버튼, 펼침형 Export 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 펼침형 Import ZIP 업로드·완료 요약·누락 자산 경고 표시, 최근 작업·진행도·준비 완료 분할 파일 다운로드·브라우저 순차 일괄 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제, 진행 중 요청 버튼 잠금 |
|
||||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||||
|
|||||||
@@ -545,7 +545,7 @@ components/content/
|
|||||||
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바 필드 포함)
|
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바 필드 포함)
|
||||||
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
|
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
|
||||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
||||||
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 기타 설정 저장 시 `PUT`으로 처리한다.
|
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
|
||||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바 필드 포함)
|
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바 필드 포함)
|
||||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||||
@@ -697,16 +697,16 @@ components/content/
|
|||||||
|
|
||||||
### 사이트 설정
|
### 사이트 설정
|
||||||
|
|
||||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export 기본 화면은 `Export 요청`·`Import 하기` 액션 버튼과 최근 작업 목록을 중심으로 표시한다. Export 상세 설정은 `Export 요청` 버튼을 눌렀을 때만 펼쳐지며, 전체·특정년·특정월·직접 지정 범위 Export 요청, 목표 ZIP 용량과 ZIP당 최대 게시물 수 지정을 제공한다. Import 상세는 `Import 하기` 버튼을 눌렀을 때 안내 패널로 펼쳐진다. 최근 작업 목록은 진행도, 준비 완료 분할 파일 다운로드, 브라우저 순차 일괄 다운로드, 실패 작업 재시도, 실패 상세 오류, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 게시물 Import/Export 기본 화면은 `Export 요청`·`Import 하기` 액션 버튼과 최근 작업 목록을 중심으로 표시한다. Export 상세 설정은 `Export 요청` 버튼을 눌렀을 때만 펼쳐지며, 전체·특정년·특정월·직접 지정 범위 Export 요청, 목표 ZIP 용량과 ZIP당 최대 게시물 수 지정을 제공한다. Import 상세는 `Import 하기` 버튼을 눌렀을 때 안내 패널로 펼쳐진다. 최근 작업 목록은 진행도, 준비 완료 분할 파일 다운로드, 브라우저 순차 일괄 다운로드, 실패 작업 재시도, 실패 상세 오류, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
||||||
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 ZIP을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 `/uploads/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 ZIP을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 `/uploads/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
|
||||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
|
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
|
||||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||||
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 읽기·편집 미리보기는 실제 `HomeHero` 컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(`max-w-[32rem]`)과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
||||||
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
|
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
|
||||||
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
|
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
|
||||||
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
|
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
|
||||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 업로드 API는 파일 URL만 반환하고, 실제 `logo_url`·`favicon_url` DB 반영은 기타 설정 카드의 **저장** 버튼에서 처리한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 업로드 API는 파일 URL만 반환하고, 실제 `logo_url`·`favicon_url` DB 반영은 사이트 정보 카드의 **저장** 버튼에서 처리한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.29
|
||||||
|
|
||||||
|
- 관리자 사이트 설정: 메인 화면 커버를 라이트모드·다크모드 상하 개별 프리뷰로 표시하도록 수정.
|
||||||
|
- 관리자 사이트 설정: 커버 이미지가 비어 있는 모드는 점선 드롭존으로 표시하고 파일 선택·드래그 앤 드롭 업로드를 지원하도록 추가.
|
||||||
|
- 관리자 사이트 설정: 타임존 준비 중 섹션을 제거하고 `기타 설정` 명칭을 `사이트 정보`로 변경.
|
||||||
|
|
||||||
## v1.5.28
|
## v1.5.28
|
||||||
|
|
||||||
- 게시물 Import: Obsidian에서 흔히 쓰는 YAML 블록 배열(`tags:\n- tag`) 형태를 읽을 수 있도록 frontmatter 파서 보강.
|
- 게시물 Import: Obsidian에서 흔히 쓰는 YAML 블록 배열(`tags:\n- tag`) 형태를 읽을 수 있도록 frontmatter 파서 보강.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.28",
|
"version": "1.5.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.28",
|
"version": "1.5.29",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.28",
|
"version": "1.5.29",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const activeSectionId = ref('admin-settings-section-title')
|
|||||||
const scrollSpySuspended = ref(false)
|
const scrollSpySuspended = ref(false)
|
||||||
/** 블로그 제목·설명 카드 편집 모드 여부 */
|
/** 블로그 제목·설명 카드 편집 모드 여부 */
|
||||||
const editTitleDesc = ref(false)
|
const editTitleDesc = ref(false)
|
||||||
/** 기타 설정 카드 편집 모드 여부 */
|
/** 사이트 정보 카드 편집 모드 여부 */
|
||||||
const editMisc = ref(false)
|
const editMisc = ref(false)
|
||||||
/** POST 설정 카드 편집 모드 여부 */
|
/** POST 설정 카드 편집 모드 여부 */
|
||||||
const editPost = ref(false)
|
const editPost = ref(false)
|
||||||
@@ -62,7 +62,7 @@ const titleDescSnapshot = reactive({
|
|||||||
title: '',
|
title: '',
|
||||||
description: ''
|
description: ''
|
||||||
})
|
})
|
||||||
/** 편집 시작 시점의 기타 설정(취소 시 복원용) */
|
/** 편집 시작 시점의 사이트 정보(취소 시 복원용) */
|
||||||
const miscSnapshot = reactive({
|
const miscSnapshot = reactive({
|
||||||
siteUrl: '',
|
siteUrl: '',
|
||||||
logoText: '',
|
logoText: '',
|
||||||
@@ -134,7 +134,7 @@ const hasTitleDescChanges = computed(() => editTitleDesc.value && (
|
|||||||
))
|
))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기타 설정 변경 여부
|
* 사이트 정보 변경 여부
|
||||||
* @returns {boolean} 변경 여부
|
* @returns {boolean} 변경 여부
|
||||||
*/
|
*/
|
||||||
const hasMiscChanges = computed(() => editMisc.value && (
|
const hasMiscChanges = computed(() => editMisc.value && (
|
||||||
@@ -434,8 +434,7 @@ const settingsNavGroups = [
|
|||||||
heading: '일반',
|
heading: '일반',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name', iconId: 'title-desc' },
|
{ id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name', iconId: 'title-desc' },
|
||||||
{ id: 'admin-settings-section-timezone', label: '타임존', keywords: 'timezone seoul gmt', iconId: 'timezone' },
|
{ id: 'admin-settings-section-misc', label: '사이트 정보', keywords: 'logo url copyright favicon site info' }
|
||||||
{ id: 'admin-settings-section-misc', label: '기타 설정', keywords: 'logo url copyright favicon' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -927,7 +926,7 @@ const saveTitleDescSection = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기타 설정 편집 모드 진입
|
* 사이트 정보 편집 모드 진입
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const beginEditMisc = () => {
|
const beginEditMisc = () => {
|
||||||
@@ -940,7 +939,7 @@ const beginEditMisc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기타 설정 편집 취소
|
* 사이트 정보 편집 취소
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const cancelEditMisc = () => {
|
const cancelEditMisc = () => {
|
||||||
@@ -953,7 +952,7 @@ const cancelEditMisc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기타 설정 저장
|
* 사이트 정보 저장
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const saveMiscSection = async () => {
|
const saveMiscSection = async () => {
|
||||||
@@ -962,7 +961,7 @@ const saveMiscSection = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ok = await persistSiteSettings({
|
const ok = await persistSiteSettings({
|
||||||
successToast: '기타 설정이 저장되었습니다.',
|
successToast: '사이트 정보가 저장되었습니다.',
|
||||||
savingFlag: savingMisc
|
savingFlag: savingMisc
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1039,14 +1038,12 @@ const openHomeCoverDarkFilePicker = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메인 화면 커버 이미지를 업로드한다.
|
* 메인 화면 커버 이미지 파일을 업로드한다.
|
||||||
* @param {Event} event - 파일 선택 이벤트
|
* @param {File} file - 업로드 파일
|
||||||
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const uploadHomeCover = async (event, variant = 'light') => {
|
const uploadHomeCoverFile = async (file, variant = 'light') => {
|
||||||
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
|
||||||
const file = target?.files?.[0]
|
|
||||||
const uploadingFlag = variant === 'dark' ? uploadingHomeCoverDark : uploadingHomeCover
|
const uploadingFlag = variant === 'dark' ? uploadingHomeCoverDark : uploadingHomeCover
|
||||||
|
|
||||||
if (!file || uploadingFlag.value) {
|
if (!file || uploadingFlag.value) {
|
||||||
@@ -1075,12 +1072,48 @@ const uploadHomeCover = async (event, variant = 'light') => {
|
|||||||
showToast('error', errorMessage.value)
|
showToast('error', errorMessage.value)
|
||||||
} finally {
|
} finally {
|
||||||
uploadingFlag.value = false
|
uploadingFlag.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 화면 커버 이미지를 업로드한다.
|
||||||
|
* @param {Event} event - 파일 선택 이벤트
|
||||||
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadHomeCover = async (event, variant = 'light') => {
|
||||||
|
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
||||||
|
const file = target?.files?.[0]
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadHomeCoverFile(file, variant)
|
||||||
|
} finally {
|
||||||
if (target) {
|
if (target) {
|
||||||
target.value = ''
|
target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드롭된 메인 화면 커버 이미지를 업로드한다.
|
||||||
|
* @param {DragEvent} event - 드롭 이벤트
|
||||||
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const dropHomeCover = async (event, variant = 'light') => {
|
||||||
|
const file = event.dataTransfer?.files?.[0]
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadHomeCoverFile(file, variant)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메인 화면 커버 이미지를 제거한다.
|
* 메인 화면 커버 이미지를 제거한다.
|
||||||
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
||||||
@@ -1514,23 +1547,6 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
id="admin-settings-section-timezone"
|
|
||||||
class="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 mb-2">
|
|
||||||
<h2 class="text-lg font-semibold text-[#15171a]">
|
|
||||||
타임존
|
|
||||||
</h2>
|
|
||||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
|
||||||
게시 시각·예약 발행 등에 사용할 표준 시간대입니다. (준비 중)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
|
|
||||||
이후 버전에서 타임존 선택과 현지 시각 미리보기를 제공합니다.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="admin-settings-section-misc"
|
id="admin-settings-section-misc"
|
||||||
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
|
||||||
@@ -1538,7 +1554,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
|
<h2 class="text-base font-semibold text-[#15171a] md:text-lg">
|
||||||
기타 설정
|
사이트 정보
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
v-if="!editMisc"
|
v-if="!editMisc"
|
||||||
@@ -1829,29 +1845,69 @@ onBeforeUnmount(() => {
|
|||||||
v-if="!editHomeCover"
|
v-if="!editHomeCover"
|
||||||
class="admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
class="admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||||
>
|
>
|
||||||
<div v-if="form.homeCoverImageUrl || form.homeCoverDarkImageUrl" class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]">
|
<div class="grid gap-6">
|
||||||
<HomeHero
|
<div class="admin-settings-screen__home-cover-mode grid gap-2">
|
||||||
:image-url="form.homeCoverImageUrl"
|
<div class="flex items-center justify-between gap-3">
|
||||||
:dark-image-url="form.homeCoverDarkImageUrl"
|
<h3 class="text-sm font-bold text-[#15171a]">
|
||||||
:title="form.homeCoverTitle"
|
라이트모드
|
||||||
:text="form.homeCoverText"
|
</h3>
|
||||||
/>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.homeCoverImageUrl"
|
||||||
|
class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||||
|
>
|
||||||
|
<HomeHero
|
||||||
|
: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-[720px] 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 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-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||||
|
>
|
||||||
|
<HomeHero
|
||||||
|
: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-[720px] place-items-center rounded-lg border border-dashed border-[#cfd6de] bg-[#f7f8fa] px-4 text-center text-sm text-[#657080]"
|
||||||
|
>
|
||||||
|
다크모드 전용 이미지가 없습니다. 공개 화면에서는 라이트모드 이미지를 대신 사용합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-[#657080]">
|
|
||||||
등록된 커버 이미지가 없습니다. 홈 상단 배너는 표시되지 않습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="admin-settings-screen__home-cover-edit grid gap-6 border-t border-[#eceff2] pt-5">
|
<div v-else class="admin-settings-screen__home-cover-edit grid gap-6 border-t border-[#eceff2] pt-5">
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-6">
|
||||||
<div class="admin-settings-screen__home-cover-upload rounded-lg border border-[#e6e8eb] bg-[#fbfbfc] p-4">
|
<div class="admin-settings-screen__home-cover-mode grid gap-2">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0">
|
||||||
<h3 class="text-sm font-medium text-[#3f4650]">
|
<h3 class="text-sm font-bold text-[#15171a]">
|
||||||
라이트모드 이미지
|
라이트모드
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||||
기본 헤더 이미지입니다. 다크 이미지가 없으면 다크모드에서도 이 이미지를 사용합니다.
|
기본 헤더 이미지입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 flex-wrap gap-2">
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
@@ -1861,7 +1917,7 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="uploadingHomeCover"
|
:disabled="uploadingHomeCover"
|
||||||
@click="openHomeCoverFilePicker"
|
@click="openHomeCoverFilePicker"
|
||||||
>
|
>
|
||||||
{{ uploadingHomeCover ? '업로드 중' : form.homeCoverImageUrl ? '이미지 변경' : '이미지 등록' }}
|
{{ uploadingHomeCover ? '업로드 중' : '이미지 변경' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="form.homeCoverImageUrl"
|
v-if="form.homeCoverImageUrl"
|
||||||
@@ -1870,10 +1926,39 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="uploadingHomeCover"
|
:disabled="uploadingHomeCover"
|
||||||
@click="clearHomeCoverImage('light')"
|
@click="clearHomeCoverImage('light')"
|
||||||
>
|
>
|
||||||
제거
|
삭제
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.homeCoverImageUrl"
|
||||||
|
class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||||
|
>
|
||||||
|
<HomeHero
|
||||||
|
: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-[720px] 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="uploadingHomeCover"
|
||||||
|
@click="openHomeCoverFilePicker"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropHomeCover($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
|
<input
|
||||||
ref="homeCoverInputRef"
|
ref="homeCoverInputRef"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@@ -1884,14 +1969,14 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-settings-screen__home-cover-upload rounded-lg border border-[#e6e8eb] bg-[#fbfbfc] p-4">
|
<div class="admin-settings-screen__home-cover-mode grid gap-2">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0">
|
||||||
<h3 class="text-sm font-medium text-[#3f4650]">
|
<h3 class="text-sm font-bold text-[#15171a]">
|
||||||
다크모드 이미지
|
다크모드
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||||
다크모드에서만 교체되는 이미지입니다. 선택하지 않으면 라이트 이미지를 그대로 씁니다.
|
다크모드에서 교체되는 이미지입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 flex-wrap gap-2">
|
<div class="flex shrink-0 flex-wrap gap-2">
|
||||||
@@ -1901,7 +1986,7 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="uploadingHomeCoverDark"
|
:disabled="uploadingHomeCoverDark"
|
||||||
@click="openHomeCoverDarkFilePicker"
|
@click="openHomeCoverDarkFilePicker"
|
||||||
>
|
>
|
||||||
{{ uploadingHomeCoverDark ? '업로드 중' : form.homeCoverDarkImageUrl ? '이미지 변경' : '이미지 등록' }}
|
{{ uploadingHomeCoverDark ? '업로드 중' : '이미지 변경' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="form.homeCoverDarkImageUrl"
|
v-if="form.homeCoverDarkImageUrl"
|
||||||
@@ -1910,10 +1995,39 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="uploadingHomeCoverDark"
|
:disabled="uploadingHomeCoverDark"
|
||||||
@click="clearHomeCoverImage('dark')"
|
@click="clearHomeCoverImage('dark')"
|
||||||
>
|
>
|
||||||
제거
|
삭제
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="form.homeCoverDarkImageUrl"
|
||||||
|
class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||||
|
>
|
||||||
|
<HomeHero
|
||||||
|
: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-[720px] 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="uploadingHomeCoverDark"
|
||||||
|
@click="openHomeCoverDarkFilePicker"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropHomeCover($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
|
<input
|
||||||
ref="homeCoverDarkInputRef"
|
ref="homeCoverDarkInputRef"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@@ -1925,18 +2039,6 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="form.homeCoverImageUrl || form.homeCoverDarkImageUrl"
|
|
||||||
class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]"
|
|
||||||
>
|
|
||||||
<HomeHero
|
|
||||||
:image-url="form.homeCoverImageUrl"
|
|
||||||
:dark-image-url="form.homeCoverDarkImageUrl"
|
|
||||||
:title="form.homeCoverTitle"
|
|
||||||
:text="form.homeCoverText"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
<label class="admin-settings-screen__field grid gap-2 text-sm">
|
||||||
<span class="font-medium text-[#3f4650]">오버레이 제목</span>
|
<span class="font-medium text-[#3f4650]">오버레이 제목</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user