v1.4.6: 사이트 설정 이미지 저장 흐름·홈 커버 라이트/다크 분리
- 로고 업로드는 파일 URL만 폼에 반영하고 기타 설정 저장 시 DB에 반영 - 메인 화면 커버 라이트·다크 이미지 필드 추가 및 테마별 HomeHero 교체 - home_cover_dark_image_url 마이그레이션 및 미디어 사용 현황 보정
This commit is contained in:
@@ -5,6 +5,11 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 다크모드 커버 이미지 URL */
|
||||
darkImageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 오버레이 제목 */
|
||||
title: {
|
||||
type: String,
|
||||
@@ -19,18 +24,32 @@ const props = defineProps({
|
||||
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.trim()))
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const lightImageUrl = computed(() => props.imageUrl?.trim() || props.darkImageUrl?.trim() || '')
|
||||
/** @type {import('vue').ComputedRef<boolean>} */
|
||||
const hasDarkImage = computed(() => Boolean(props.imageUrl?.trim() && props.darkImageUrl?.trim()))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="imageUrl"
|
||||
v-if="lightImageUrl"
|
||||
class="home-hero relative mx-auto w-full max-w-[720px] overflow-hidden"
|
||||
data-home-hero
|
||||
>
|
||||
<div class="home-hero__frame relative aspect-[720/215] w-full bg-[var(--site-panel)]">
|
||||
<img
|
||||
class="home-hero__cover absolute inset-0 h-full w-full object-cover"
|
||||
:src="imageUrl"
|
||||
:class="[
|
||||
'home-hero__cover home-hero__cover--light absolute inset-0 h-full w-full object-cover',
|
||||
hasDarkImage ? '' : 'home-hero__cover--single'
|
||||
]"
|
||||
:src="lightImageUrl"
|
||||
alt=""
|
||||
loading="eager"
|
||||
>
|
||||
<img
|
||||
v-if="hasDarkImage"
|
||||
class="home-hero__cover home-hero__cover--dark absolute inset-0 h-full w-full object-cover"
|
||||
:src="darkImageUrl"
|
||||
alt=""
|
||||
loading="eager"
|
||||
>
|
||||
@@ -56,3 +75,35 @@ const hasOverlay = computed(() => Boolean(props.title?.trim() || props.text?.tri
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-hero__cover--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.home-hero__cover--light:not(.home-hero__cover--single) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-hero__cover--dark {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='light'] .home-hero__cover--light {
|
||||
display: block;
|
||||
}
|
||||
|
||||
html[data-theme='light'] .home-hero__cover--dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .home-hero__cover--light:not(.home-hero__cover--single) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .home-hero__cover--dark {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,7 +56,7 @@ const showAboutSection = false
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__profile flex items-center gap-3">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS home_cover_dark_image_url TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.4.6
|
||||
|
||||
- 관리자 사이트 설정에서 로고와 메인 커버 이미지가 저장 버튼을 통해 반영되도록 정리했다.
|
||||
- 홈 커버 이미지를 라이트모드·다크모드용으로 따로 등록할 수 있다.
|
||||
|
||||
## v1.4.3
|
||||
|
||||
- 관리자 화면이 공개 사이트 다크모드 영향을 받지 않도록 라이트 UI를 분리했다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-22 v1.4.6 — 사이트 설정 이미지 저장 흐름 통일
|
||||
|
||||
관리자 사이트 설정은 섹션별 `편집` 후 `저장`으로 반영되는 컨셉이므로, 로고 업로드도 DB를 즉시 갱신하지 않고 업로드된 파일 URL만 폼에 반영한 뒤 기타 설정 저장 시 함께 저장하도록 정리한다. 홈 커버는 공개 라이트·다크 테마에 따라 이미지 톤이 크게 달라질 수 있어 기존 라이트 이미지를 기본값으로 유지하면서 다크 전용 URL을 별도 컬럼으로 추가한다. 다크 이미지가 없으면 기존 이미지로 fallback해 기존 설정과 공개 화면 동작을 유지한다.
|
||||
|
||||
## 2026-05-22 v1.4.5 — 게시물 작성자 기준 편집 링크
|
||||
|
||||
공개 게시글 상세의 편집 버튼은 단순히 “관리자 로그인 여부”가 아니라 실제 글쓴이인지로 판단해야 한다. 현재 운영은 관리자 1인 작성 전제지만, 멤버·권한 구조가 이미 분리되어 있으므로 게시물에 `author_id`를 명시해 현재 로그인 회원 ID와 비교하는 방식으로 정리한다. 기존 게시물은 owner/admin 계정이 정확히 1개일 때만 backfill해 잘못된 작성자 배정을 피하고, 새 게시물은 관리자 세션의 사용자 ID를 작성자로 저장한다.
|
||||
|
||||
10
docs/map.md
10
docs/map.md
@@ -66,7 +66,7 @@
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation`의 `recommended` 카드 목록(외부 URL은 Google 파비콘 프록시 썸네일), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너·왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(대표 이미지 없으면 제목 텍스트 플레이스홀더) |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
@@ -138,7 +138,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 플레이스홀더 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 토글), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색), **스팸 필터**(가입 금지 닉네임), 타임존·Import/Export 플레이스홀더 |
|
||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
@@ -148,7 +148,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 로그인 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
@@ -221,7 +221,8 @@
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| 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` 생성, `시스템` 미디어 메타 저장) |
|
||||
| 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/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||
@@ -285,6 +286,7 @@
|
||||
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
|
||||
| db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 |
|
||||
| db/migrations/032_add_post_author.sql | 게시물 작성자(`posts.author_id`) 컬럼 추가 및 기존 글 owner/admin backfill |
|
||||
| db/migrations/033_site_settings_home_cover_dark_image.sql | 사이트 설정 다크모드 홈 커버 이미지 URL 컬럼 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
19
docs/spec.md
19
docs/spec.md
@@ -339,6 +339,7 @@ components/content/
|
||||
| logo_text | String | 레거시 텍스트 로고 fallback |
|
||||
| logo_url | String | 공개 로고 이미지 URL |
|
||||
| favicon_url | String | 파비콘 이미지 URL |
|
||||
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
|
||||
| copyright_text | String | 저작권 문구 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -490,10 +491,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`만 허용.
|
||||
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
|
||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 홈 커버·어나운스 바 필드 포함)
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바 필드 포함)
|
||||
- `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/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 기타 설정 저장 시 `PUT`으로 처리한다.
|
||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바 필드 포함)
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
@@ -638,11 +640,11 @@ components/content/
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·게시물 Import/Export는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 준다.
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- **메인 화면**(`home_cover_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 이미지가 있을 때만 `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`로 반영한다. 읽기·편집 미리보기는 실제 `HomeHero` 컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(`max-w-[32rem]`)과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
||||
- **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`이 달라지면 다시 노출된다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 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 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
@@ -713,12 +715,13 @@ components/content/
|
||||
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||
/uploads/system/logo-YYYYMM-random.webp
|
||||
/uploads/system/favicon-YYYYMM-random.png
|
||||
/uploads/system/home-cover-YYYYMM-random.webp
|
||||
```
|
||||
|
||||
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다.
|
||||
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||
- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 사이트 로고·파비콘·홈 커버는 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url`, `favicon_url`, `home_cover_image_url`, `home_cover_dark_image_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||
|
||||
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
|
||||
@@ -735,7 +738,7 @@ components/content/
|
||||
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘·홈 커버 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.4.6
|
||||
|
||||
- 관리자 설정: 로고 업로드가 저장 버튼 없이 즉시 DB에 반영되던 흐름을 파일 업로드 후 저장 버튼으로 반영하도록 수정.
|
||||
- 관리자 설정: 메인 화면 커버에 다크모드 전용 이미지 필드 추가.
|
||||
- 공개 홈: `HomeHero`가 라이트·다크 커버 이미지를 테마에 맞춰 교체 표시하도록 수정.
|
||||
- DB: `site_settings.home_cover_dark_image_url` 컬럼 추가.
|
||||
|
||||
## v1.4.5
|
||||
|
||||
- 공개 게시글 상세: 게시물 메타 영역의 수정 시각 표시 제거, 글쓴이용 편집 링크는 요청 SVG 아이콘으로 교체.
|
||||
|
||||
@@ -19,10 +19,12 @@ const savingAnnouncement = ref(false)
|
||||
const savingSpam = ref(false)
|
||||
const uploadingLogo = ref(false)
|
||||
const uploadingHomeCover = ref(false)
|
||||
const uploadingHomeCoverDark = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
const logoInputRef = ref(null)
|
||||
const homeCoverInputRef = ref(null)
|
||||
const homeCoverDarkInputRef = ref(null)
|
||||
const mainScrollRef = ref(null)
|
||||
const navSearchQuery = ref('')
|
||||
const activeSectionId = ref('admin-settings-section-title')
|
||||
@@ -59,6 +61,7 @@ const postSnapshot = reactive({
|
||||
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
|
||||
const homeCoverSnapshot = reactive({
|
||||
homeCoverImageUrl: '',
|
||||
homeCoverDarkImageUrl: '',
|
||||
homeCoverTitle: '',
|
||||
homeCoverText: ''
|
||||
})
|
||||
@@ -88,6 +91,7 @@ const form = reactive({
|
||||
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
|
||||
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
|
||||
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
|
||||
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
|
||||
homeCoverTitle: settings.value?.homeCoverTitle || '',
|
||||
homeCoverText: settings.value?.homeCoverText || '',
|
||||
announcementEnabled: Boolean(settings.value?.announcementEnabled),
|
||||
@@ -131,6 +135,7 @@ const hasPostChanges = computed(() => editPost.value
|
||||
*/
|
||||
const hasHomeCoverChanges = computed(() => editHomeCover.value && (
|
||||
form.homeCoverImageUrl !== homeCoverSnapshot.homeCoverImageUrl
|
||||
|| form.homeCoverDarkImageUrl !== homeCoverSnapshot.homeCoverDarkImageUrl
|
||||
|| form.homeCoverTitle !== homeCoverSnapshot.homeCoverTitle
|
||||
|| form.homeCoverText !== homeCoverSnapshot.homeCoverText
|
||||
))
|
||||
@@ -352,14 +357,13 @@ const uploadLogo = async (event) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const updatedSettings = await $fetch('/admin/api/settings/logo', {
|
||||
const uploadedLogo = await $fetch('/admin/api/settings/logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
Object.assign(form, updatedSettings)
|
||||
miscSnapshot.logoUrl = form.logoUrl
|
||||
miscSnapshot.faviconUrl = form.faviconUrl
|
||||
showToast('success', '로고가 등록되었습니다.')
|
||||
form.logoUrl = uploadedLogo.logoUrl || ''
|
||||
form.faviconUrl = uploadedLogo.faviconUrl || ''
|
||||
showToast('success', '로고를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '로고 업로드에 실패했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
@@ -385,6 +389,7 @@ const buildSiteSettingsPayload = () => ({
|
||||
copyrightText: form.copyrightText,
|
||||
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
|
||||
homeCoverImageUrl: form.homeCoverImageUrl || '',
|
||||
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
|
||||
homeCoverTitle: form.homeCoverTitle || '',
|
||||
homeCoverText: form.homeCoverText || '',
|
||||
announcementEnabled: Boolean(form.announcementEnabled),
|
||||
@@ -562,19 +567,33 @@ const openHomeCoverFilePicker = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 화면 커버 이미지를 업로드한다.
|
||||
* @param {Event} event - 파일 선택 이벤트
|
||||
* @returns {Promise<void>}
|
||||
* 메인 화면 다크 커버 파일 선택 창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const uploadHomeCover = async (event) => {
|
||||
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
||||
const file = target?.files?.[0]
|
||||
|
||||
if (!file || uploadingHomeCover.value) {
|
||||
const openHomeCoverDarkFilePicker = () => {
|
||||
if (uploadingHomeCoverDark.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingHomeCover.value = true
|
||||
homeCoverDarkInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 화면 커버 이미지를 업로드한다.
|
||||
* @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]
|
||||
const uploadingFlag = variant === 'dark' ? uploadingHomeCoverDark : uploadingHomeCover
|
||||
|
||||
if (!file || uploadingFlag.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingFlag.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '커버 이미지를 업로드하는 중입니다.')
|
||||
|
||||
@@ -585,13 +604,17 @@ const uploadHomeCover = async (event) => {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.homeCoverImageUrl = homeCoverImageUrl || ''
|
||||
if (variant === 'dark') {
|
||||
form.homeCoverDarkImageUrl = homeCoverImageUrl || ''
|
||||
} else {
|
||||
form.homeCoverImageUrl = homeCoverImageUrl || ''
|
||||
}
|
||||
showToast('success', '커버 이미지를 불러왔습니다. 저장 버튼을 눌러 적용하세요.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '커버 이미지 업로드에 실패했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
uploadingHomeCover.value = false
|
||||
uploadingFlag.value = false
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
@@ -600,9 +623,15 @@ const uploadHomeCover = async (event) => {
|
||||
|
||||
/**
|
||||
* 메인 화면 커버 이미지를 제거한다.
|
||||
* @param {'light'|'dark'} variant - 커버 이미지 종류
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearHomeCoverImage = () => {
|
||||
const clearHomeCoverImage = (variant = 'light') => {
|
||||
if (variant === 'dark') {
|
||||
form.homeCoverDarkImageUrl = ''
|
||||
return
|
||||
}
|
||||
|
||||
form.homeCoverImageUrl = ''
|
||||
}
|
||||
|
||||
@@ -612,6 +641,7 @@ const clearHomeCoverImage = () => {
|
||||
*/
|
||||
const beginEditHomeCover = () => {
|
||||
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
|
||||
homeCoverSnapshot.homeCoverDarkImageUrl = form.homeCoverDarkImageUrl
|
||||
homeCoverSnapshot.homeCoverTitle = form.homeCoverTitle
|
||||
homeCoverSnapshot.homeCoverText = form.homeCoverText
|
||||
editHomeCover.value = true
|
||||
@@ -623,6 +653,7 @@ const beginEditHomeCover = () => {
|
||||
*/
|
||||
const cancelEditHomeCover = () => {
|
||||
form.homeCoverImageUrl = homeCoverSnapshot.homeCoverImageUrl
|
||||
form.homeCoverDarkImageUrl = homeCoverSnapshot.homeCoverDarkImageUrl
|
||||
form.homeCoverTitle = homeCoverSnapshot.homeCoverTitle
|
||||
form.homeCoverText = homeCoverSnapshot.homeCoverText
|
||||
editHomeCover.value = false
|
||||
@@ -644,6 +675,7 @@ const saveHomeCoverSection = async () => {
|
||||
|
||||
if (ok) {
|
||||
homeCoverSnapshot.homeCoverImageUrl = form.homeCoverImageUrl
|
||||
homeCoverSnapshot.homeCoverDarkImageUrl = form.homeCoverDarkImageUrl
|
||||
homeCoverSnapshot.homeCoverTitle = form.homeCoverTitle
|
||||
homeCoverSnapshot.homeCoverText = form.homeCoverText
|
||||
editHomeCover.value = false
|
||||
@@ -1290,7 +1322,7 @@ onBeforeUnmount(() => {
|
||||
v-if="!editHomeCover"
|
||||
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
|
||||
>
|
||||
홈 상단에 720px 너비 커버 이미지를 표시합니다. 제목·짧은 문구는 이미지 왼쪽 하단에 겹쳐 보이며, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
|
||||
홈 상단에 720px 너비 커버 이미지를 표시합니다. 라이트·다크 이미지를 각각 등록할 수 있고, 이미지·텍스트는 저장 버튼으로 함께 반영합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
|
||||
@@ -1328,9 +1360,10 @@ onBeforeUnmount(() => {
|
||||
v-if="!editHomeCover"
|
||||
class="admin-settings-screen__home-cover-readonly grid gap-4 border-t border-[#eceff2] pt-5 text-sm"
|
||||
>
|
||||
<div v-if="form.homeCoverImageUrl" class="admin-settings-screen__home-cover-preview w-full max-w-[720px] overflow-hidden rounded-lg border border-[#e6e8eb]">
|
||||
<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"
|
||||
/>
|
||||
@@ -1341,50 +1374,95 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-settings-screen__home-cover-edit grid gap-6 border-t border-[#eceff2] pt-5">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-medium text-[#3f4650]">
|
||||
커버 이미지
|
||||
</h3>
|
||||
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
|
||||
가로 720px WebP로 변환해 미리 불러옵니다. 제목·본문과 함께 저장 버튼을 눌러야 사이트에 반영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="admin-settings-screen__home-cover-upload rounded-lg border border-[#e6e8eb] bg-[#fbfbfc] p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-medium text-[#3f4650]">
|
||||
라이트모드 이미지
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
기본 헤더 이미지입니다. 다크 이미지가 없으면 다크모드에서도 이 이미지를 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingHomeCover"
|
||||
@click="openHomeCoverFilePicker"
|
||||
>
|
||||
{{ uploadingHomeCover ? '업로드 중' : form.homeCoverImageUrl ? '이미지 변경' : '이미지 등록' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.homeCoverImageUrl"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingHomeCover"
|
||||
@click="clearHomeCoverImage('light')"
|
||||
>
|
||||
제거
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="homeCoverInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingHomeCover"
|
||||
@click="openHomeCoverFilePicker"
|
||||
@change="uploadHomeCover($event, 'light')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings-screen__home-cover-upload rounded-lg border border-[#e6e8eb] bg-[#fbfbfc] p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-medium text-[#3f4650]">
|
||||
다크모드 이미지
|
||||
</h3>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
다크모드에서만 교체되는 이미지입니다. 선택하지 않으면 라이트 이미지를 그대로 씁니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingHomeCoverDark"
|
||||
@click="openHomeCoverDarkFilePicker"
|
||||
>
|
||||
{{ uploadingHomeCoverDark ? '업로드 중' : form.homeCoverDarkImageUrl ? '이미지 변경' : '이미지 등록' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.homeCoverDarkImageUrl"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingHomeCoverDark"
|
||||
@click="clearHomeCoverImage('dark')"
|
||||
>
|
||||
제거
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="homeCoverDarkInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingHomeCoverDark"
|
||||
@change="uploadHomeCover($event, 'dark')"
|
||||
>
|
||||
{{ uploadingHomeCover ? '업로드 중' : form.homeCoverImageUrl ? '이미지 변경' : '이미지 등록' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.homeCoverImageUrl"
|
||||
class="h-10 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#657080] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingHomeCover"
|
||||
@click="clearHomeCoverImage"
|
||||
>
|
||||
이미지 제거
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="homeCoverInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingHomeCover"
|
||||
@change="uploadHomeCover"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.homeCoverImageUrl"
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -281,9 +281,10 @@ const scrollFeatured = (direction) => {
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section v-if="siteSettings?.homeCoverImageUrl" class="home-page__hero">
|
||||
<section v-if="siteSettings?.homeCoverImageUrl || siteSettings?.homeCoverDarkImageUrl" class="home-page__hero">
|
||||
<HomeHero
|
||||
:image-url="siteSettings.homeCoverImageUrl"
|
||||
:dark-image-url="siteSettings.homeCoverDarkImageUrl"
|
||||
:title="siteSettings.homeCoverTitle"
|
||||
:text="siteSettings.homeCoverText"
|
||||
/>
|
||||
|
||||
@@ -100,6 +100,7 @@ const mapSiteSettingsRow = (row) => ({
|
||||
copyrightText: row.copyright_text,
|
||||
showPostUpdatedAt: Boolean(row.show_post_updated_at),
|
||||
homeCoverImageUrl: row.home_cover_image_url || '',
|
||||
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
|
||||
homeCoverTitle: row.home_cover_title || '',
|
||||
homeCoverText: row.home_cover_text || '',
|
||||
announcementEnabled: Boolean(row.announcement_enabled),
|
||||
@@ -829,6 +830,7 @@ export const updateSiteSettings = async (input) => {
|
||||
copyright_text,
|
||||
show_post_updated_at,
|
||||
home_cover_image_url,
|
||||
home_cover_dark_image_url,
|
||||
home_cover_title,
|
||||
home_cover_text,
|
||||
announcement_enabled,
|
||||
@@ -849,6 +851,7 @@ export const updateSiteSettings = async (input) => {
|
||||
${input.copyrightText},
|
||||
${input.showPostUpdatedAt ? true : false},
|
||||
${input.homeCoverImageUrl || ''},
|
||||
${input.homeCoverDarkImageUrl || ''},
|
||||
${input.homeCoverTitle || ''},
|
||||
${input.homeCoverText || ''},
|
||||
${input.announcementEnabled ? true : false},
|
||||
@@ -869,6 +872,7 @@ export const updateSiteSettings = async (input) => {
|
||||
copyright_text = EXCLUDED.copyright_text,
|
||||
show_post_updated_at = EXCLUDED.show_post_updated_at,
|
||||
home_cover_image_url = EXCLUDED.home_cover_image_url,
|
||||
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
|
||||
home_cover_title = EXCLUDED.home_cover_title,
|
||||
home_cover_text = EXCLUDED.home_cover_text,
|
||||
announcement_enabled = EXCLUDED.announcement_enabled,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { updateSiteLogo } from '../../../../repositories/content-repository'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
@@ -46,7 +45,7 @@ const createSystemAssetSuffix = () => {
|
||||
/**
|
||||
* 사이트 로고 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||
* @returns {Promise<{ logoUrl: string, faviconUrl: string }>} 업로드된 로고 URL
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
@@ -126,8 +125,8 @@ export default defineEventHandler(async (event) => {
|
||||
await upsertMediaMetadataCategory(logoUrl, '시스템')
|
||||
await upsertMediaMetadataCategory(faviconUrl, '시스템')
|
||||
|
||||
return updateSiteLogo({
|
||||
return {
|
||||
logoUrl,
|
||||
faviconUrl
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ export const adminSiteSettingsInputSchema = z.object({
|
||||
copyrightText: z.string().trim().min(1),
|
||||
showPostUpdatedAt: z.boolean().optional().default(false),
|
||||
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
|
||||
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
|
||||
homeCoverTitle: z.string().trim().max(120).optional().default(''),
|
||||
homeCoverText: z.string().trim().max(280).optional().default(''),
|
||||
announcementEnabled: z.boolean().optional().default(false),
|
||||
|
||||
@@ -526,6 +526,34 @@ const getSiteSettingsMediaUsage = (url, siteSettings) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (siteSettings.homeCoverImageUrl === url) {
|
||||
usages.push({
|
||||
type: 'settings',
|
||||
typeLabel: '사이트 설정',
|
||||
id: 'home-cover-light',
|
||||
title: '메인 화면 라이트 이미지',
|
||||
adminUrl: '/admin/settings',
|
||||
publicUrl: '/',
|
||||
status: 'system',
|
||||
location: 'homeCoverImageUrl',
|
||||
label: '메인 화면 라이트 이미지'
|
||||
})
|
||||
}
|
||||
|
||||
if (siteSettings.homeCoverDarkImageUrl === url) {
|
||||
usages.push({
|
||||
type: 'settings',
|
||||
typeLabel: '사이트 설정',
|
||||
id: 'home-cover-dark',
|
||||
title: '메인 화면 다크 이미지',
|
||||
adminUrl: '/admin/settings',
|
||||
publicUrl: '/',
|
||||
status: 'system',
|
||||
location: 'homeCoverDarkImageUrl',
|
||||
label: '메인 화면 다크 이미지'
|
||||
})
|
||||
}
|
||||
|
||||
return usages
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const getDefaultSiteSettings = () => {
|
||||
copyrightText: `©${new Date().getFullYear()} ${title}`,
|
||||
showPostUpdatedAt: false,
|
||||
homeCoverImageUrl: '',
|
||||
homeCoverDarkImageUrl: '',
|
||||
homeCoverTitle: '',
|
||||
homeCoverText: '',
|
||||
announcementEnabled: false,
|
||||
|
||||
Reference in New Issue
Block a user