설정 내보내기 가져오기 분리 v1.5.30

This commit is contained in:
2026-06-02 11:03:41 +09:00
parent c2e69d9048
commit e2df9d55ac
9 changed files with 213 additions and 162 deletions

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.30
- 관리자 설정의 메인 화면 커버 읽기 모드에서 라이트·다크 프리뷰가 카드 밖으로 넘치지 않게 정리했다.
- 게시물 백업 도구를 `게시물 내보내기``게시물 가져오기` 카드로 분리했다.
- 가져오기는 ZIP 파일 선택 후 `적용`을 눌러야 실행되도록 바꿔 실수 실행을 줄였다.
## v1.5.29
- 관리자 설정의 메인 화면 커버를 라이트모드와 다크모드로 나누어 각각 확인하고 변경할 수 있게 했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.29에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.30에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-02 v1.5.30 — 내보내기와 가져오기는 서로 다른 작업이다
게시물 백업 생성과 백업 ZIP 복원은 사용 빈도와 위험도가 다르다. 특히 가져오기는 거의 쓰지 않지만 한 번 실행하면 게시물이 새로 생성되므로, 파일 선택 즉시 실행하지 않고 `적용` 버튼을 명시적으로 누르게 했다. 설정 화면의 기본 철학도 현재 상태를 먼저 보여 주고 조작은 버튼 뒤에 숨기는 쪽이므로, 내보내기와 가져오기를 독립 카드로 나누고 각 상세 입력은 아코디언처럼 펼쳐지게 정리한다.
## 2026-06-02 v1.5.29 — 라이트·다크 커버는 각각 확인 가능해야 한다
홈 커버 이미지는 라이트·다크 테마에서 서로 다른 시각 결과를 만들 수 있다. 하나의 `HomeHero` 미리보기만 두면 현재 OS나 테마 상태에 따라 한쪽 이미지를 확인하기 어렵기 때문에, 관리자 설정에서는 라이트모드와 다크모드를 상하 개별 프리뷰로 항상 보여 준다. 아직 실제 동작이 없는 타임존 설정은 노출하지 않고, 로고·URL·저작권은 포괄적인 `사이트 정보`로 이름을 정리한다.

View File

@@ -140,7 +140,7 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.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·저작권), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 최근 작업·진행도·준비 완료 분할 파일 다운로드·브라우저 순차 일괄 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제, 진행 중 요청 버튼 잠금 |
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |

View File

@@ -697,7 +697,7 @@ 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`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 게시물 백업 도구는 `게시물 내보내기``게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼과 최근 작업 목록을 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 최근 작업 목록은 진행도, 준비 완료 분할 파일 다운로드, 브라우저 순차 일괄 다운로드, 실패 작업 재시도, 실패 상세 오류, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `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 게시물까지 처리한다.
- 대용량 게시물 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` 테이블의 단일 레코드로 관리한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.30
- 관리자 사이트 설정: 메인 화면 커버 읽기 모드 프리뷰가 카드 너비를 벗어나던 레이아웃 수정.
- 관리자 사이트 설정: 게시물 Import/Export 통합 영역을 `게시물 내보내기``게시물 가져오기` 독립 카드로 분리.
- 관리자 사이트 설정: 게시물 가져오기는 ZIP 선택 후 `적용` 버튼을 눌러 실행되도록 수정.
- 관리자 사이트 설정: 내보내기·가져오기 상세 조작은 각 카드 버튼을 눌렀을 때만 펼쳐지도록 정리.
## v1.5.29
- 관리자 사이트 설정: 메인 화면 커버를 라이트모드·다크모드 상하 개별 프리뷰로 표시하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.29",
"version": "1.5.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.29",
"version": "1.5.30",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.29",
"version": "1.5.30",
"private": true,
"type": "module",
"imports": {

View File

@@ -25,6 +25,7 @@ const deletingPostExportJobIds = ref([])
const downloadingPostExportJobIds = ref([])
const retryingPostExportJobIds = ref([])
const importingPosts = ref(false)
const postImportFile = ref(null)
const postImportFileName = ref('')
const postImportResult = ref(null)
const postExportDateRangeMode = ref('all')
@@ -245,7 +246,7 @@ const canRequestPostExport = computed(() => {
*/
const postExportRequestTitle = computed(() => {
if (hasActivePostExportJobs.value) {
return '진행 중인 Export 작업이 끝난 뒤 새 요청을 만들 수 있습니다.'
return '진행 중인 내보내기 작업이 끝난 뒤 새 요청을 만들 수 있습니다.'
}
if (postExportDateRangeMode.value === 'custom' && !canRequestPostExport.value) {
@@ -256,7 +257,7 @@ const postExportRequestTitle = computed(() => {
return 'ZIP당 최대 게시물 수와 목표 용량을 확인해 주세요.'
}
return '게시물 Export 작업을 요청합니다.'
return '게시물 내보내기 작업을 요청합니다.'
})
/**
@@ -453,7 +454,8 @@ const settingsNavGroups = [
{
heading: '콘텐츠·안전',
items: [
{ id: 'admin-settings-section-import-export', label: '게시물 Import/Export', keywords: 'import export backup', iconId: 'import-export' },
{ id: 'admin-settings-section-export', label: '게시물 내보내기', keywords: 'export backup 내보내기', iconId: 'import-export' },
{ id: 'admin-settings-section-import', label: '게시물 가져오기', keywords: 'import restore 가져오기', iconId: 'import-export' },
{ id: 'admin-settings-section-spam', label: '스팸 필터', keywords: 'spam moderation comments', iconId: 'spam' }
]
}
@@ -592,7 +594,7 @@ const requestPostExport = async () => {
requestingPostExport.value = true
errorMessage.value = ''
showToast('info', '게시물 Export 작업을 등록하는 중입니다.')
showToast('info', '게시물 내보내기 작업을 등록하는 중입니다.')
try {
await $fetch('/admin/api/posts/export-jobs', {
@@ -600,9 +602,9 @@ const requestPostExport = async () => {
body: createPostExportRequestBody()
})
await refreshPostExportJobs()
showToast('success', '게시물 Export 작업이 등록되었습니다.')
showToast('success', '게시물 내보내기 작업이 등록되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '게시물 Export 작업을 등록하지 못했습니다.'
errorMessage.value = error?.data?.message || '게시물 내보내기 작업을 등록하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
requestingPostExport.value = false
@@ -618,32 +620,67 @@ const openPostImportFilePicker = () => {
}
/**
* 게시물 Import 파일을 업로드한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {Promise<void>}
* 게시물 Import 대상 파일을 선택한다.
* @param {File|null|undefined} file - 선택 파일
* @returns {boolean} 선택 성공 여부
*/
const importPostsFromFile = async (event) => {
const target = event.target
const file = target instanceof HTMLInputElement ? target.files?.[0] : null
const selectPostImportFile = (file) => {
if (!file) {
return
return false
}
if (!file.name.toLowerCase().endsWith('.zip')) {
showToast('error', 'ZIP 파일만 Import할 수 있습니다.')
showToast('error', 'ZIP 파일만 가져올 수 있습니다.')
return false
}
postImportFile.value = file
postImportFileName.value = file.name
postImportResult.value = null
return true
}
/**
* 게시물 Import 파일 선택 이벤트를 처리한다.
* @param {Event} event - 파일 선택 이벤트
* @returns {void}
*/
const handlePostImportFileChange = (event) => {
const target = event.target
const file = target instanceof HTMLInputElement ? target.files?.[0] : null
selectPostImportFile(file)
if (target instanceof HTMLInputElement) {
target.value = ''
}
}
/**
* 게시물 Import 파일 드롭 이벤트를 처리한다.
* @param {DragEvent} event - 파일 드롭 이벤트
* @returns {void}
*/
const dropPostImportFile = (event) => {
selectPostImportFile(event.dataTransfer?.files?.[0])
}
/**
* 선택된 게시물 Import 파일을 업로드한다.
* @returns {Promise<void>}
*/
const importSelectedPostFile = async () => {
if (!postImportFile.value) {
showToast('error', '가져올 ZIP 파일을 먼저 선택해 주세요.')
return
}
importingPosts.value = true
postImportFileName.value = file.name
postImportResult.value = null
showToast('info', '게시물 Import를 시작합니다.')
showToast('info', '게시물 가져오기를 시작합니다.')
try {
const formData = new FormData()
formData.append('file', file)
formData.append('file', postImportFile.value)
const result = await $fetch('/admin/api/posts/import', {
method: 'POST',
@@ -651,13 +688,14 @@ const importPostsFromFile = async (event) => {
})
postImportResult.value = result
showToast('success', `게시물 ${result.importedCount}개를 Import했습니다.`)
postImportFile.value = null
postImportFileName.value = ''
showToast('success', `게시물 ${result.importedCount}개를 가져왔습니다.`)
} catch (error) {
const message = error?.data?.message || '게시물 Import를 완료하지 못했습니다.'
const message = error?.data?.message || '게시물 가져오기를 완료하지 못했습니다.'
showToast('error', message)
} finally {
importingPosts.value = false
target.value = ''
}
}
@@ -734,16 +772,16 @@ const retryPostExportJob = async (job) => {
}
retryingPostExportJobIds.value = [...retryingPostExportJobIds.value, job.id]
showToast('info', '실패한 Export 작업을 다시 대기열에 등록하는 중입니다.')
showToast('info', '실패한 내보내기 작업을 다시 대기열에 등록하는 중입니다.')
try {
await $fetch(`/admin/api/posts/export-jobs/${job.id}/retry`, {
method: 'POST'
})
await refreshPostExportJobs()
showToast('success', 'Export 작업을 다시 시작했습니다.')
showToast('success', '내보내기 작업을 다시 시작했습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || 'Export 작업을 다시 시작하지 못했습니다.'
errorMessage.value = error?.data?.message || '내보내기 작업을 다시 시작하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
retryingPostExportJobIds.value = retryingPostExportJobIds.value.filter((id) => id !== job.id)
@@ -768,16 +806,16 @@ const deletePostExportJob = async (job) => {
}
deletingPostExportJobIds.value = [...deletingPostExportJobIds.value, job.id]
showToast('info', 'Export 백업 파일을 삭제하는 중입니다.')
showToast('info', '내보내기 백업 파일을 삭제하는 중입니다.')
try {
await $fetch(`/admin/api/posts/export-jobs/${job.id}`, {
method: 'DELETE'
})
await refreshPostExportJobs()
showToast('success', 'Export 백업 파일을 삭제했습니다.')
showToast('success', '내보내기 백업 파일을 삭제했습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || 'Export 백업 파일을 삭제하지 못했습니다.'
errorMessage.value = error?.data?.message || '내보내기 백업 파일을 삭제하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
deletingPostExportJobIds.value = deletingPostExportJobIds.value.filter((id) => id !== job.id)
@@ -1845,8 +1883,8 @@ onBeforeUnmount(() => {
v-if="!editHomeCover"
class="admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
>
<div class="grid gap-6">
<div class="admin-settings-screen__home-cover-mode grid gap-2">
<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]">
라이트모드
@@ -1854,9 +1892,10 @@ onBeforeUnmount(() => {
</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]"
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"
@@ -1865,13 +1904,13 @@ onBeforeUnmount(() => {
</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]"
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 gap-2">
<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]">
다크모드
@@ -1879,9 +1918,10 @@ onBeforeUnmount(() => {
</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]"
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"
@@ -1890,7 +1930,7 @@ onBeforeUnmount(() => {
</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]"
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>
@@ -1898,9 +1938,9 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-else class="admin-settings-screen__home-cover-edit grid gap-6 border-t border-[#eceff2] pt-5">
<div class="grid gap-6">
<div class="admin-settings-screen__home-cover-mode grid gap-2">
<div v-else class="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]">
@@ -1932,9 +1972,10 @@ onBeforeUnmount(() => {
</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]"
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"
@@ -1943,7 +1984,7 @@ onBeforeUnmount(() => {
</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"
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="uploadingHomeCover"
@click="openHomeCoverFilePicker"
@@ -1969,7 +2010,7 @@ onBeforeUnmount(() => {
>
</div>
<div class="admin-settings-screen__home-cover-mode grid gap-2">
<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]">
@@ -2001,9 +2042,10 @@ onBeforeUnmount(() => {
</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]"
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"
@@ -2012,7 +2054,7 @@ onBeforeUnmount(() => {
</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"
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="uploadingHomeCoverDark"
@click="openHomeCoverDarkFilePicker"
@@ -2192,61 +2234,26 @@ onBeforeUnmount(() => {
콘텐츠·안전
</h2>
<section
id="admin-settings-section-import-export"
id="admin-settings-section-export"
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]">
게시물 Import/Export
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
게시물 백업을 서버 작업으로 등록하고, 준비된 분할 파일을 내려받습니다.
</p>
</div>
<div class="admin-settings-screen__import-export-actions mt-5 grid gap-3 md:grid-cols-2">
<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="admin-settings-screen__import-export-action flex min-h-20 cursor-pointer items-center justify-between gap-4 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4 text-left transition hover:border-[#c5cbd3] hover:bg-white"
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="postImportExportPanel === 'export'"
aria-controls="admin-settings-export-panel"
@click="togglePostImportExportPanel('export')"
>
<span class="min-w-0">
<span class="block text-sm font-semibold text-[#15171a]">Export 요청</span>
<span class="mt-1 block text-sm leading-relaxed text-[#657080]">
기간과 분할 기준을 정해 백업을 만듭니다.
</span>
</span>
<span
class="grid size-8 shrink-0 place-items-center rounded-full border border-[#dce0e5] text-[#394047] transition"
:class="postImportExportPanel === 'export' ? 'rotate-180 bg-white' : 'bg-[#f4f6f8]'"
aria-hidden="true"
>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</span>
</button>
<button
class="admin-settings-screen__import-export-action flex min-h-20 cursor-pointer items-center justify-between gap-4 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4 text-left transition hover:border-[#c5cbd3] hover:bg-white"
type="button"
:aria-expanded="postImportExportPanel === 'import'"
aria-controls="admin-settings-import-panel"
@click="togglePostImportExportPanel('import')"
>
<span class="min-w-0">
<span class="block text-sm font-semibold text-[#15171a]">Import 하기</span>
<span class="mt-1 block text-sm leading-relaxed text-[#657080]">
백업 ZIP 가져오기 영역을 엽니다.
</span>
</span>
<span
class="grid size-8 shrink-0 place-items-center rounded-full border border-[#dce0e5] text-[#394047] transition"
:class="postImportExportPanel === 'import' ? 'rotate-180 bg-white' : 'bg-[#f4f6f8]'"
aria-hidden="true"
>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</span>
{{ postImportExportPanel === 'export' ? '접기' : '내보내기' }}
</button>
</div>
@@ -2377,80 +2384,16 @@ onBeforeUnmount(() => {
:title="postExportRequestTitle"
@click="requestPostExport"
>
{{ requestingPostExport ? '요청 중...' : hasActivePostExportJobs ? 'Export 진행 중' : 'Export 요청' }}
{{ requestingPostExport ? '요청 중...' : hasActivePostExportJobs ? '내보내기 진행 중' : '내보내기 요청' }}
</button>
</div>
</div>
</div>
<div
v-if="postImportExportPanel === 'import'"
id="admin-settings-import-panel"
class="admin-settings-screen__import-actions mt-4 grid gap-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#fbfcfd] p-4"
>
<div class="min-w-0">
<p class="text-sm font-semibold text-[#15171a]">
백업 ZIP 가져오기
</p>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
Export로 만든 Obsidian 호환 ZIP을 게시물과 미디어 파일로 다시 가져옵니다.
</p>
</div>
<div class="grid gap-3 rounded-md border border-[#e6e8eb] bg-white p-4">
<input
ref="postImportInputRef"
class="sr-only"
type="file"
accept=".zip,application/zip"
@change="importPostsFromFile"
>
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-[#15171a]">
{{ postImportFileName || 'ZIP 파일을 선택해 주세요.' }}
</p>
<p class="mt-1 text-xs leading-relaxed text-[#657080]">
같은 슬러그가 있으면 덮어쓰지 않고 슬러그로 Import합니다.
</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 disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
type="button"
:disabled="importingPosts"
@click="openPostImportFilePicker"
>
{{ importingPosts ? 'Import 중...' : 'ZIP 선택' }}
</button>
</div>
<div
v-if="postImportResult"
class="rounded-md bg-[#f4fbf7] px-3 py-2 text-sm text-[#147a45] ring-1 ring-inset ring-[#b9e7cd]"
>
게시물 {{ postImportResult.importedCount }}, 자산 {{ postImportResult.assetCount }}개를 가져왔습니다.
</div>
<div
v-if="postImportResult?.warningCount"
class="rounded-md bg-[#fff8e8] px-3 py-2 text-sm text-[#8a5a00] ring-1 ring-inset ring-[#f0d28a]"
>
<p class="font-semibold">
경고 {{ postImportResult.warningCount }}개가 있습니다.
</p>
<ul class="mt-2 grid gap-1 text-xs leading-relaxed">
<li
v-for="warning in postImportResult.warnings"
:key="warning"
>
{{ warning }}
</li>
</ul>
</div>
</div>
</div>
<div class="admin-settings-screen__export-list mt-5">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-[#15171a]">
최근 Export 작업
최근 내보내기 작업
</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]"
@@ -2464,7 +2407,7 @@ onBeforeUnmount(() => {
v-if="normalizedPostExportJobs.length === 0"
class="admin-settings-screen__export-empty rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]"
>
아직 등록된 Export 작업이 없습니다.
아직 등록된 내보내기 작업이 없습니다.
</div>
<div v-else class="admin-settings-screen__export-items grid gap-3">
<article
@@ -2594,6 +2537,97 @@ onBeforeUnmount(() => {
</div>
</section>
<section
id="admin-settings-section-import"
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 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 백업을 게시물과 미디어 파일로 복원합니다.
</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="postImportExportPanel === 'import'"
aria-controls="admin-settings-import-panel"
@click="togglePostImportExportPanel('import')"
>
{{ postImportExportPanel === 'import' ? '접기' : '가져오기' }}
</button>
</div>
<div
v-if="postImportExportPanel === 'import'"
id="admin-settings-import-panel"
class="admin-settings-screen__import-actions mt-5 grid gap-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#fbfcfd] p-4"
>
<input
ref="postImportInputRef"
class="sr-only"
type="file"
accept=".zip,application/zip"
@change="handlePostImportFileChange"
>
<button
class="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="importingPosts"
@click="openPostImportFilePicker"
@dragover.prevent
@drop.prevent="dropPostImportFile"
>
<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>{{ postImportFileName || 'ZIP 파일을 드롭하거나 선택하세요.' }}</span>
<span class="text-xs font-medium text-[#8a94a3]">
같은 슬러그가 있으면 덮어쓰지 않고 슬러그로 가져옵니다.
</span>
</span>
</button>
<div class="flex items-center justify-end">
<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="importingPosts || !postImportFile"
@click="importSelectedPostFile"
>
{{ importingPosts ? '가져오는 중...' : '적용' }}
</button>
</div>
<div
v-if="postImportResult"
class="rounded-md bg-[#f4fbf7] px-3 py-2 text-sm text-[#147a45] ring-1 ring-inset ring-[#b9e7cd]"
>
게시물 {{ postImportResult.importedCount }}, 자산 {{ postImportResult.assetCount }}개를 가져왔습니다.
</div>
<div
v-if="postImportResult?.warningCount"
class="rounded-md bg-[#fff8e8] px-3 py-2 text-sm text-[#8a5a00] ring-1 ring-inset ring-[#f0d28a]"
>
<p class="font-semibold">
경고 {{ postImportResult.warningCount }}개가 있습니다.
</p>
<ul class="mt-2 grid gap-1 text-xs leading-relaxed">
<li
v-for="warning in postImportResult.warnings"
:key="warning"
>
{{ warning }}
</li>
</ul>
</div>
</div>
</section>
<section
id="admin-settings-section-spam"
class="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"