사이트 코드와 홈페이지 위젯 추가 v1.5.34

This commit is contained in:
2026-06-02 14:21:47 +09:00
parent 600b0fd1d9
commit 5b78a8c92f
21 changed files with 618 additions and 39 deletions

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

@@ -77,6 +77,20 @@ defineProps({
<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-code'"
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="M8 9 5 12l3 3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m16 9 3 3 -3 3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="m13 7 -2 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M4.5 3.5h15s2 0 2 2v13s0 2 -2 2h-15s-2 0 -2 -2v-13s0 -2 2 -2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<!-- Import/Export -->
<svg
v-else-if="iconId === 'import-export'"

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-5 border-t border-[#eceff2] pt-5 text-sm md:grid-cols-3"
>
<div>
<p class="font-medium text-[#3f4650]">
ads.txt
</p>
<p class="mt-1 text-[#15171a]">
{{ form.adsTxt.trim() ? '등록됨' : '미등록' }}
</p>
</div>
<div>
<p class="font-medium text-[#3f4650]">
헤더 코드
</p>
<p class="mt-1 text-[#15171a]">
{{ form.customHeadCode.trim() ? '등록됨' : '미등록' }}
</p>
</div>
<div>
<p class="font-medium text-[#3f4650]">
푸터 코드
</p>
<p class="mt-1 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,4 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS ads_txt TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS custom_head_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS custom_footer_code TEXT NOT NULL DEFAULT '';

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.34
- 공개 404/오류 페이지를 추가했다.
- 관리자 사이트 설정에서 ads.txt, 공통 헤더 코드, 공통 푸터 코드를 저장하고 공개 페이지에 반영할 수 있게 했다.
- gethomepage 커스텀 위젯용 사이트 통계 API를 추가했다.
## v1.5.33
- 준비 완료된 내보내기 작업에서는 상태 배지를 표시하지 않도록 정리했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.33에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.34에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -68,6 +68,11 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
### v1.5.34 마이그레이션
- `044_site_settings_custom_code.sql`: `site_settings``ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼을 추가한다.
- 배포 후 `/ads.txt`와 공개 페이지 HTML head/body 하단 코드 삽입이 정상 동작하는지 확인한다.
### 확인 주소
- 개발 서버: http://127.0.0.1:43117

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-02 v1.5.34 — 사이트 검증 코드는 설정으로 관리한다
AdSense의 `ads.txt`나 헤더 검증 스크립트는 테마 파일을 직접 고치기보다 운영 설정에서 바꾸는 편이 안전하다. 그래서 사이트 설정에 ads.txt, 헤더 코드, 푸터 코드를 별도 카드로 두고, 공개 HTML 응답과 루트 `/ads.txt`에서만 반영한다. 관리자 화면과 API 응답에는 삽입하지 않아 관리 도구가 외부 광고·검증 스크립트에 영향을 받지 않게 했다. gethomepage 연동은 아직 표시 항목이 확정되지 않았으므로 기존 통계 집계에서 바로 만들 수 있는 오늘 방문자·페이지뷰·현재 접속자·평균 체류시간을 반환하는 커스텀 API 틀로 시작한다.
## 2026-06-02 v1.5.33 — 완료 상태는 조용히 둔다
최근 내보내기 작업에서 준비 완료는 이미 다운로드 가능한 파일 목록으로 충분히 드러난다. 완료 배지를 계속 표시하면 정리된 다운로드 카드 안에서 불필요한 시각 요소가 되므로, 대기·생성·실패처럼 주의가 필요한 상태에서만 배지를 보여 준다.

View File

@@ -11,6 +11,7 @@
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
| layouts/page.vue | 고정 페이지 전체 화면 |
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
| error.vue | 공개 404/서버 오류 화면, 홈 이동 버튼 |
## Composables
@@ -54,6 +55,7 @@
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
| server/middleware/html-page-renderer.js | HTML 문서 모드 고정 페이지(`/pages/:slug`)를 Nuxt 렌더링 대신 `text/html` 원문으로 응답 |
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
## 사이트 컴포넌트
@@ -79,6 +81,8 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
@@ -174,6 +178,7 @@
| server/api/tags.get.js | 태그 목록 샘플 API |
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/api/homepage-widget.get.js | gethomepage customapi용 사이트 요약 위젯 API |
| server/api/navigation.get.js | 공개 네비게이션 API |
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
@@ -231,6 +236,7 @@
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장, DB 반영 없이 URL 반환) |
| server/routes/admin/api/settings/home-cover.post.js | 관리자 메인 화면 커버 업로드 API(`/uploads/system/home-cover-YYYYMM-*.webp` 생성, `시스템` 미디어 메타 저장, 라이트·다크 슬롯용 URL 반환) |
| server/routes/ads.txt.get.js | 사이트 설정 ads.txt 루트 text/plain 응답 |
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
@@ -311,6 +317,7 @@
| db/migrations/041_post_export_progress.sql | 게시물 Export 작업 진행도 컬럼 추가 |
| db/migrations/042_post_export_date_range.sql | 게시물 Export 날짜 범위 컬럼 추가 |
| db/migrations/043_post_export_size_and_error_detail.sql | 게시물 Export 목표 용량·실패 상세 로그 컬럼 추가 |
| db/migrations/044_site_settings_custom_code.sql | 사이트 설정 ads.txt·공통 헤더 코드·공통 푸터 코드 컬럼 추가 |
## 설정/배포

View File

@@ -130,6 +130,8 @@ layouts/
└── admin.vue # 관리자 화면
```
- 존재하지 않는 공개 경로 또는 공개되지 않는 콘텐츠는 Nuxt 전역 오류 화면(`error.vue`)으로 처리한다. 404 화면은 상태 코드, 안내 문구, 홈 이동 버튼을 표시한다.
### 관리자 레이아웃
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
@@ -382,7 +384,19 @@ components/content/
| logo_text | String | 레거시 텍스트 로고 fallback |
| logo_url | String | 공개 로고 이미지 URL |
| favicon_url | String | 파비콘 이미지 URL |
| show_post_updated_at | Boolean | 관리자 글 목록 수정일 보조 표시 여부 |
| home_cover_image_url | String | 라이트모드 홈 커버 이미지 URL |
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
| home_cover_title | String | 홈 커버 오버레이 제목 |
| home_cover_text | Text | 홈 커버 오버레이 본문 |
| announcement_enabled | Boolean | 공개 어나운스 바 사용 여부 |
| announcement_text | Text | 어나운스 바 문구 |
| announcement_url | String | 어나운스 바 링크 URL |
| announcement_background_color | String | 어나운스 바 배경색 |
| signup_blocked_usernames | JSON Text | 가입 금지 닉네임 목록 |
| ads_txt | Text | 루트 `/ads.txt` 응답 본문 |
| custom_head_code | Text | 공개 HTML head 끝에 삽입할 코드 |
| custom_footer_code | Text | 공개 HTML body 끝에 삽입할 코드 |
| copyright_text | String | 저작권 문구 |
| updated_at | DateTime | 수정일 |
@@ -475,9 +489,11 @@ components/content/
- `GET /api/tags` - 태그 목록
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `GET /api/homepage-widget` - gethomepage customapi용 사이트 요약. `title`, `updatedAt`, `todayVisitors`, `todayPageViews`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `items[]`를 반환한다.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록한다.
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결.
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
- `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다.
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status``emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
@@ -542,11 +558,11 @@ components/content/
- `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /admin/api/tags/:id` - 태그 삭제
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바 필드 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `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` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
@@ -697,7 +713,7 @@ components/content/
### 사이트 설정
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 게시물 백업 도구는 `게시물 내보내기``게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 게시물 백업 도구는 `게시물 내보내기``게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `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` 테이블의 단일 레코드로 관리한다.
@@ -706,6 +722,7 @@ components/content/
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **가입 금지 닉네임**(`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`이 달라지면 다시 노출된다.
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code``body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.
- 로고 이미지는 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 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v1.5.34
- 공개 화면: Nuxt 전역 404/오류 페이지 추가.
- 사이트 설정: `ads.txt`, 공통 헤더 코드, 공통 푸터 코드 입력·저장 추가.
- 공개 응답: `/ads.txt` text/plain 응답과 공개 HTML 헤더·푸터 코드 삽입 플러그인 추가.
- 외부 위젯: gethomepage customapi 연동용 `/api/homepage-widget` 요약 API 추가.
- DB: `site_settings.ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼 추가.
## v1.5.33
- 관리자 사이트 설정: 최근 내보내기 작업이 준비 완료 상태일 때 상단 상태 배지를 숨기도록 수정.

45
error.vue Normal file
View File

@@ -0,0 +1,45 @@
<script setup>
const error = useError()
const statusCode = computed(() => Number(error.value?.statusCode || 500))
const isNotFound = computed(() => statusCode.value === 404)
const pageTitle = computed(() => (isNotFound.value ? '페이지를 찾을 수 없습니다' : '오류가 발생했습니다'))
const pageDescription = computed(() => (
isNotFound.value
? '요청한 주소가 없거나 공개되지 않은 페이지입니다.'
: '잠시 후 다시 시도해 주세요.'
))
useHead(() => ({
title: pageTitle.value
}))
/**
* 오류 상태를 정리하고 홈으로 이동한다.
* @returns {Promise<void>} 이동 처리
*/
const goHome = () => clearError({ redirect: '/' })
</script>
<template>
<main class="error-page min-h-screen bg-[var(--site-bg)] px-5 py-12 text-[var(--site-text)]">
<section class="error-page__panel mx-auto flex min-h-[calc(100vh-6rem)] max-w-3xl flex-col items-start justify-center">
<p class="error-page__code text-sm font-semibold uppercase tracking-[0.12em] text-[var(--site-muted)]">
{{ statusCode }}
</p>
<h1 class="error-page__title mt-4 text-4xl font-bold tracking-tight md:text-6xl">
{{ pageTitle }}
</h1>
<p class="error-page__description mt-5 max-w-xl text-base leading-7 text-[var(--site-muted)] md:text-lg">
{{ pageDescription }}
</p>
<button
class="error-page__home-button mt-8 inline-flex h-11 items-center justify-center rounded-md bg-[var(--site-text)] px-5 text-sm font-semibold text-[var(--site-bg)] transition hover:opacity-85"
type="button"
@click="goHome"
>
홈으로 이동
</button>
</section>
</main>
</template>

4
package-lock.json generated
View File

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

View File

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

View File

@@ -17,6 +17,7 @@ const savingPost = ref(false)
const savingHomeCover = ref(false)
const savingAnnouncement = ref(false)
const savingSpam = ref(false)
const savingSiteCode = ref(false)
const uploadingLogo = ref(false)
const uploadingHomeCover = ref(false)
const uploadingHomeCoverDark = ref(false)
@@ -59,6 +60,8 @@ const editHomeCover = ref(false)
const customizeAnnouncement = ref(false)
/** 스팸 필터 카드 편집 모드 여부 */
const editSpam = ref(false)
/** 사이트 코드 카드 편집 모드 여부 */
const editSiteCode = ref(false)
/** 편집 시작 시점의 제목·설명(취소 시 복원용) */
const titleDescSnapshot = reactive({
title: '',
@@ -94,6 +97,12 @@ const announcementSnapshot = reactive({
const spamSnapshot = reactive({
signupBlockedUsernames: []
})
/** 편집 시작 시점의 사이트 코드(취소 시 복원용) */
const siteCodeSnapshot = reactive({
adsTxt: '',
customHeadCode: '',
customFooterCode: ''
})
let toastTimer = null
let scrollSpyFrame = null
let postExportRefreshTimer = null
@@ -123,7 +132,10 @@ const form = reactive({
announcementText: settings.value?.announcementText || '',
announcementUrl: settings.value?.announcementUrl || '',
announcementBackgroundColor: settings.value?.announcementBackgroundColor || '#15171a',
signupBlockedUsernames: normalizeSignupBlockedUsernames(settings.value?.signupBlockedUsernames)
signupBlockedUsernames: normalizeSignupBlockedUsernames(settings.value?.signupBlockedUsernames),
adsTxt: settings.value?.adsTxt || '',
customHeadCode: settings.value?.customHeadCode || '',
customFooterCode: settings.value?.customFooterCode || ''
})
/**
@@ -183,6 +195,16 @@ const hasAnnouncementChanges = computed(() => customizeAnnouncement.value && (
const hasSpamChanges = computed(() => editSpam.value
&& JSON.stringify(form.signupBlockedUsernames) !== JSON.stringify(spamSnapshot.signupBlockedUsernames))
/**
* 사이트 코드 변경 여부
* @returns {boolean} 변경 여부
*/
const hasSiteCodeChanges = computed(() => editSiteCode.value && (
form.adsTxt !== siteCodeSnapshot.adsTxt
|| form.customHeadCode !== siteCodeSnapshot.customHeadCode
|| form.customFooterCode !== siteCodeSnapshot.customFooterCode
))
/**
* 최신 게시물 export 작업 목록
* @returns {Array} export 작업 목록
@@ -449,7 +471,8 @@ const settingsNavGroups = [
heading: '사이트',
items: [
{ id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image', iconId: 'home-cover' },
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' }
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' },
{ id: 'admin-settings-section-site-code', label: '사이트 코드', keywords: 'ads ads.txt head footer script code adsense', iconId: 'site-code' }
]
},
{
@@ -733,6 +756,16 @@ const getSelectedPostExportFileIds = (jobId) => Array.isArray(selectedPostExport
*/
const isPostExportFileSelected = (job, file) => getSelectedPostExportFileIds(job?.id).includes(file?.id)
/**
* Export 파일 선택 비활성 여부를 확인한다.
* @param {Object} job - Export 작업
* @param {Object} file - Export 파일
* @returns {boolean} 비활성 여부
*/
const isPostExportFileSelectionDisabled = (job, file) => file.status !== 'ready'
|| !file.filePath
|| isDownloadingPostExportJob(job.id)
/**
* Export 파일 선택 상태를 변경한다.
* @param {Object} job - Export 작업
@@ -756,6 +789,16 @@ const setPostExportFileSelected = (job, file, selected) => {
}
}
/**
* Export 파일 선택 상태를 반대로 전환한다.
* @param {Object} job - Export 작업
* @param {Object} file - Export 파일
* @returns {void}
*/
const togglePostExportFileSelection = (job, file) => {
setPostExportFileSelected(job, file, !isPostExportFileSelected(job, file))
}
/**
* Export 작업에서 선택된 다운로드 가능 파일을 가져온다.
* @param {Object} job - Export 작업
@@ -1003,7 +1046,10 @@ const buildSiteSettingsPayload = () => ({
announcementText: form.announcementText || '',
announcementUrl: form.announcementUrl || '',
announcementBackgroundColor: form.announcementBackgroundColor || '#15171a',
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames)
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames),
adsTxt: form.adsTxt || '',
customHeadCode: form.customHeadCode || '',
customFooterCode: form.customFooterCode || ''
})
/**
@@ -1408,6 +1454,50 @@ const saveSpamSection = async () => {
}
}
/**
* 사이트 코드 편집 모드 진입
* @returns {void}
*/
const beginEditSiteCode = () => {
siteCodeSnapshot.adsTxt = form.adsTxt
siteCodeSnapshot.customHeadCode = form.customHeadCode
siteCodeSnapshot.customFooterCode = form.customFooterCode
editSiteCode.value = true
}
/**
* 사이트 코드 편집 취소
* @returns {void}
*/
const cancelEditSiteCode = () => {
form.adsTxt = siteCodeSnapshot.adsTxt
form.customHeadCode = siteCodeSnapshot.customHeadCode
form.customFooterCode = siteCodeSnapshot.customFooterCode
editSiteCode.value = false
}
/**
* 사이트 코드 저장
* @returns {Promise<void>}
*/
const saveSiteCodeSection = async () => {
if (!hasSiteCodeChanges.value) {
return
}
const ok = await persistSiteSettings({
successToast: '사이트 코드 설정이 저장되었습니다.',
savingFlag: savingSiteCode
})
if (ok) {
siteCodeSnapshot.adsTxt = form.adsTxt
siteCodeSnapshot.customHeadCode = form.customHeadCode
siteCodeSnapshot.customFooterCode = form.customFooterCode
editSiteCode.value = false
}
}
/**
* Escape 키: 제목·설명 편집 중이면 취소, 아니면 설정 화면 닫기
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -1447,6 +1537,11 @@ const onGlobalKeydown = (event) => {
cancelEditSpam()
return
}
if (editSiteCode.value) {
event.preventDefault()
cancelEditSiteCode()
return
}
closeSettings()
}
@@ -2340,6 +2435,16 @@ onBeforeUnmount(() => {
</div>
</section>
<AdminSiteCodeSettingsCard
:form="form"
:editing="editSiteCode"
:saving="savingSiteCode"
:has-changes="hasSiteCodeChanges"
@begin="beginEditSiteCode"
@cancel="cancelEditSiteCode"
@save="saveSiteCodeSection"
/>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
콘텐츠·안전
</h2>
@@ -2614,7 +2719,7 @@ onBeforeUnmount(() => {
:checked="areAllReadyPostExportFilesSelected(job)"
:disabled="getReadyPostExportFiles(job).length === 0 || isDownloadingPostExportJob(job.id)"
@change="toggleAllPostExportFiles(job)"
>
/>
전체 선택
</label>
<button
@@ -2626,34 +2731,14 @@ onBeforeUnmount(() => {
{{ isDownloadingPostExportJob(job.id) ? '다운로드 중' : `선택 파일 다운로드 ${getSelectedReadyPostExportFiles(job).length || ''}` }}
</button>
</div>
<div
<AdminPostExportFileRow
v-for="file in job.files"
:key="file.id"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 border-b border-[#edf0f3] px-3 py-2 last:border-b-0"
>
<input
class="size-4 rounded border-[#cfd6de] text-[#15171a] focus:ring-[#15171a] disabled:cursor-not-allowed"
type="checkbox"
:checked="isPostExportFileSelected(job, file)"
:disabled="file.status !== 'ready' || !file.filePath || isDownloadingPostExportJob(job.id)"
:aria-label="`${file.fileName} 선택`"
@change="setPostExportFileSelected(job, file, $event.target.checked)"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-[#15171a]">
{{ file.fileName }}
</p>
<p class="mt-0.5 text-xs text-[#9aa3ad]">
{{ file.postStart }}-{{ file.postEnd }}
</p>
</div>
<span
v-if="file.status !== 'ready' || !file.filePath"
class="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>
:file="file"
:selected="isPostExportFileSelected(job, file)"
:disabled="isPostExportFileSelectionDisabled(job, file)"
@toggle="togglePostExportFileSelection(job, file)"
/>
</div>
</article>
</div>

View File

@@ -0,0 +1,72 @@
import { getSiteSettings } from '../repositories/content-repository'
import { getAnalyticsSummary } from '../repositories/analytics-repository'
/**
* 초 단위 체류 시간을 짧은 한국어 문자열로 변환한다.
* @param {number} seconds - 초 단위 시간
* @returns {string} 표시 문자열
*/
const formatDuration = (seconds) => {
const safeSeconds = Math.max(0, Math.round(Number(seconds) || 0))
if (safeSeconds < 60) {
return `${safeSeconds}`
}
const minutes = Math.floor(safeSeconds / 60)
const restSeconds = safeSeconds % 60
return restSeconds > 0 ? `${minutes}${restSeconds}` : `${minutes}`
}
/**
* 숫자를 한국어 단위 문자열로 변환한다.
* @param {number} value - 숫자 값
* @param {string} unit - 단위
* @returns {string} 표시 문자열
*/
const formatNumberLabel = (value, unit) => `${Number(value || 0).toLocaleString('ko-KR')}${unit}`
/**
* gethomepage 커스텀 API용 사이트 요약 위젯 데이터를 반환한다.
* @returns {Promise<Object>} 위젯 데이터
*/
export default defineEventHandler(async () => {
const [settings, summary] = await Promise.all([
getSiteSettings(),
getAnalyticsSummary({ days: 1 })
])
const avgEngagedSeconds = summary.todayAvgEngagedSeconds || summary.avgEngagedSeconds || 0
return {
title: settings.title || 'sori.studio',
updatedAt: new Date().toISOString(),
todayVisitors: summary.todayVisitors,
todayPageViews: summary.todayPageViews,
onlineNow: summary.onlineNow,
loggedInNow: summary.loggedInNow,
avgEngagedSeconds,
items: [
{
name: '오늘 방문자',
label: formatNumberLabel(summary.todayVisitors, '명'),
value: summary.todayVisitors
},
{
name: '오늘 페이지뷰',
label: formatNumberLabel(summary.todayPageViews, '회'),
value: summary.todayPageViews
},
{
name: '현재 접속자',
label: formatNumberLabel(summary.onlineNow, '명'),
value: summary.onlineNow
},
{
name: '평균 체류',
label: formatDuration(avgEngagedSeconds),
value: avgEngagedSeconds
}
]
}
})

View File

@@ -0,0 +1,47 @@
import { getSiteSettings } from '../repositories/content-repository'
/**
* 공통 코드 삽입을 건너뛸 서버 경로인지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {boolean} 삽입 제외 여부
*/
const shouldSkipCustomCode = (event) => {
const path = String(event?.path || event?.node?.req?.url || '/')
return path.startsWith('/admin')
|| path.startsWith('/api')
|| path.startsWith('/uploads')
|| path.startsWith('/_nuxt')
|| path.startsWith('/ads.txt')
}
/**
* HTML 조각을 응답 배열에 안전하게 추가한다.
* @param {Array<string>} target - Nitro HTML 조각 배열
* @param {string} code - 삽입할 HTML 코드
* @returns {void}
*/
const pushHtmlCode = (target, code) => {
const trimmed = String(code || '').trim()
if (!trimmed || !Array.isArray(target)) {
return
}
target.push(trimmed)
}
/**
* 공개 HTML 응답에 관리자 설정의 헤더·푸터 코드를 삽입한다.
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('render:html', async (html, { event }) => {
if (shouldSkipCustomCode(event)) {
return
}
const settings = await getSiteSettings()
pushHtmlCode(html.head, settings.customHeadCode)
pushHtmlCode(html.bodyAppend, settings.customFooterCode)
})
})

View File

@@ -542,6 +542,8 @@ export const getAnalyticsSummary = async (options = {}) => {
if (!sql) {
return {
todayVisitors: 0,
todayPageViews: 0,
todayAvgEngagedSeconds: 0,
visitorsLast7Days: 0,
pageViewsLast30Days: 0,
onlineNow: 0,
@@ -630,9 +632,15 @@ export const getAnalyticsSummary = async (options = {}) => {
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
const todayEngagedViews = Number(todayRows[0]?.engaged_views || 0)
const todayTotalEngagedSeconds = Number(todayRows[0]?.total_engaged_seconds || 0)
return {
todayVisitors: Number(todayRows[0]?.visitors || 0),
todayPageViews: Number(todayRows[0]?.page_views || 0),
todayAvgEngagedSeconds: todayEngagedViews > 0
? Math.round(todayTotalEngagedSeconds / todayEngagedViews)
: 0,
visitorsLast7Days: Number(last7Rows[0]?.visitors || 0),
pageViewsLast30Days: Number(pageViewRows[0]?.page_views || 0),
onlineNow: Number(onlineRows[0]?.online_now || 0),

View File

@@ -110,6 +110,9 @@ const mapSiteSettingsRow = (row) => ({
announcementUrl: row.announcement_url || '',
announcementBackgroundColor: row.announcement_background_color || '#15171a',
signupBlockedUsernames: parseSignupBlockedUsernamesFromDb(row.signup_blocked_usernames),
adsTxt: row.ads_txt || '',
customHeadCode: row.custom_head_code || '',
customFooterCode: row.custom_footer_code || '',
updatedAt: row.updated_at.toISOString()
})
@@ -874,6 +877,9 @@ export const updateSiteSettings = async (input) => {
announcement_url,
announcement_background_color,
signup_blocked_usernames,
ads_txt,
custom_head_code,
custom_footer_code,
updated_at
)
VALUES (
@@ -895,6 +901,9 @@ export const updateSiteSettings = async (input) => {
${input.announcementUrl || ''},
${input.announcementBackgroundColor || '#15171a'},
${JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))},
${input.adsTxt || ''},
${input.customHeadCode || ''},
${input.customFooterCode || ''},
now()
)
ON CONFLICT (id) DO UPDATE
@@ -916,6 +925,9 @@ export const updateSiteSettings = async (input) => {
announcement_url = EXCLUDED.announcement_url,
announcement_background_color = EXCLUDED.announcement_background_color,
signup_blocked_usernames = EXCLUDED.signup_blocked_usernames,
ads_txt = EXCLUDED.ads_txt,
custom_head_code = EXCLUDED.custom_head_code,
custom_footer_code = EXCLUDED.custom_footer_code,
updated_at = now()
RETURNING *
`

View File

@@ -0,0 +1,26 @@
import { setHeader } from 'h3'
import { getSiteSettings } from '../repositories/content-repository'
/**
* ads.txt 본문을 정규화한다.
* @param {string} value - 관리자 설정에 저장된 ads.txt 본문
* @returns {string} text/plain 응답 본문
*/
const normalizeAdsTxt = (value) => {
const text = String(value || '').trim()
return text ? `${text}\n` : ''
}
/**
* 루트 ads.txt 응답
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<string>} ads.txt 본문
*/
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
const settings = await getSiteSettings()
return normalizeAdsTxt(settings.adsTxt)
})

View File

@@ -30,7 +30,10 @@ export const adminSiteSettingsInputSchema = z.object({
announcementBackgroundColor: z.string().trim().optional().default(DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
signupBlockedUsernames: z.array(
z.string().trim().min(1).max(MAX_SIGNUP_BLOCKED_USERNAME_LENGTH)
).max(MAX_SIGNUP_BLOCKED_USERNAME_COUNT).optional().default([...DEFAULT_SIGNUP_BLOCKED_USERNAMES])
).max(MAX_SIGNUP_BLOCKED_USERNAME_COUNT).optional().default([...DEFAULT_SIGNUP_BLOCKED_USERNAMES]),
adsTxt: z.string().max(20000).optional().default(''),
customHeadCode: z.string().max(50000).optional().default(''),
customFooterCode: z.string().max(50000).optional().default('')
}).superRefine((data, ctx) => {
if (!isValidAnnouncementBackgroundColor(data.announcementBackgroundColor)) {
ctx.addIssue({

View File

@@ -26,6 +26,9 @@ export const getDefaultSiteSettings = () => {
announcementUrl: '',
announcementBackgroundColor: '#15171a',
signupBlockedUsernames: [...DEFAULT_SIGNUP_BLOCKED_USERNAMES],
adsTxt: '',
customHeadCode: '',
customFooterCode: '',
updatedAt: null
}
}