태그 헤더 이미지 설정 추가
This commit is contained in:
@@ -25,23 +25,32 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const slugTouched = ref(Boolean(props.initialTag.slug))
|
||||
const coverInputRef = ref(null)
|
||||
const uploadingCover = ref(false)
|
||||
const coverErrorMessage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || '',
|
||||
description: props.initialTag.description || '',
|
||||
headerImageUrl: props.initialTag.headerImageUrl || '',
|
||||
headerTitle: props.initialTag.headerTitle || '',
|
||||
headerText: props.initialTag.headerText || '',
|
||||
color: props.initialTag.color || '#15171a'
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 입력값을 저장 비교용 형태로 정규화한다.
|
||||
* @param {Object} tag - 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
|
||||
*/
|
||||
const normalizeTagPayload = (tag) => ({
|
||||
name: String(tag.name || '').trim(),
|
||||
slug: toSlug(tag.slug || tag.name || ''),
|
||||
description: String(tag.description || '').trim(),
|
||||
headerImageUrl: String(tag.headerImageUrl || '').trim(),
|
||||
headerTitle: String(tag.headerTitle || '').trim(),
|
||||
headerText: String(tag.headerText || '').trim(),
|
||||
sortOrder: Number(tag.sortOrder ?? 0),
|
||||
color: String(tag.color || '#15171a'),
|
||||
tagType: String(tag.tagType || props.defaultTagType)
|
||||
@@ -49,12 +58,15 @@ const normalizeTagPayload = (tag) => ({
|
||||
|
||||
/**
|
||||
* 현재 폼 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
|
||||
*/
|
||||
const currentPayload = computed(() => normalizeTagPayload({
|
||||
name: form.name,
|
||||
slug: form.slug || form.name,
|
||||
description: form.description,
|
||||
headerImageUrl: form.headerImageUrl,
|
||||
headerTitle: form.headerTitle,
|
||||
headerText: form.headerText,
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
@@ -62,12 +74,15 @@ const currentPayload = computed(() => normalizeTagPayload({
|
||||
|
||||
/**
|
||||
* 최초 태그 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
|
||||
* @returns {{ name: string, slug: string, description: string, headerImageUrl: string, headerTitle: string, headerText: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
|
||||
*/
|
||||
const initialPayload = computed(() => normalizeTagPayload({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || props.initialTag.name || '',
|
||||
description: props.initialTag.description || '',
|
||||
headerImageUrl: props.initialTag.headerImageUrl || '',
|
||||
headerTitle: props.initialTag.headerTitle || '',
|
||||
headerText: props.initialTag.headerText || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a',
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
@@ -83,7 +98,7 @@ const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.
|
||||
* 태그 저장 가능 여부
|
||||
* @returns {boolean} 저장 가능 여부
|
||||
*/
|
||||
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value))
|
||||
const canSubmit = computed(() => !props.saving && !uploadingCover.value && (!props.requireChanges || hasChanges.value))
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
@@ -113,6 +128,56 @@ const touchSlug = () => {
|
||||
form.slug = toSlug(form.slug)
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지 선택 창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openCoverPicker = () => {
|
||||
coverInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지를 제거한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeCoverImage = () => {
|
||||
form.headerImageUrl = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지를 업로드한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>} 업로드 결과
|
||||
*/
|
||||
const uploadCoverImage = async (event) => {
|
||||
const input = event.target
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingCover.value = true
|
||||
coverErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const result = await $fetch('/admin/api/tags/cover', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
|
||||
form.headerImageUrl = result.headerImageUrl || ''
|
||||
} catch (error) {
|
||||
coverErrorMessage.value = error?.data?.message || '태그 헤더 이미지를 업로드하지 못했습니다.'
|
||||
} finally {
|
||||
uploadingCover.value = false
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 입력값 제출
|
||||
* @returns {void}
|
||||
@@ -178,6 +243,89 @@ const submitTag = () => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="admin-tag-form__panel admin-tag-form__panel--header grid gap-5 border border-line bg-white p-5">
|
||||
<div class="admin-tag-form__header-cover-head flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="admin-tag-form__header-cover-copy grid gap-1">
|
||||
<h2 class="admin-tag-form__section-title text-base font-semibold">
|
||||
태그 헤더
|
||||
</h2>
|
||||
<p class="admin-tag-form__section-description text-sm text-muted">
|
||||
이미지가 있으면 공개 태그 페이지의 기본 제목과 설명 대신 헤더 이미지를 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-tag-form__header-cover-actions flex shrink-0 gap-2">
|
||||
<button
|
||||
class="admin-tag-form__cover-button rounded border border-line bg-white px-3 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="openCoverPicker"
|
||||
>
|
||||
{{ uploadingCover ? '업로드 중' : '이미지 변경' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="form.headerImageUrl"
|
||||
class="admin-tag-form__cover-remove rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-muted disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="removeCoverImage"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.headerImageUrl"
|
||||
class="admin-tag-form__cover-preview w-full max-w-full overflow-hidden rounded-lg border border-[#e6e8eb]"
|
||||
>
|
||||
<HomeHero
|
||||
:image-url="form.headerImageUrl"
|
||||
:title="form.headerTitle"
|
||||
:text="form.headerText"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="admin-tag-form__cover-dropzone grid aspect-[720/215] w-full max-w-full cursor-pointer place-items-center rounded-lg border border-dashed border-[#b8c1cc] bg-[#f7f8fa] px-4 text-center text-sm text-muted transition hover:border-[#15171a] hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="uploadingCover"
|
||||
@click="openCoverPicker"
|
||||
>
|
||||
태그 헤더 이미지를 선택하세요.
|
||||
</button>
|
||||
<input
|
||||
ref="coverInputRef"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
@change="uploadCoverImage"
|
||||
>
|
||||
<p v-if="coverErrorMessage" class="admin-tag-form__cover-error rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{{ coverErrorMessage }}
|
||||
</p>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">오버레이 제목</span>
|
||||
<input
|
||||
v-model="form.headerTitle"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="비워두면 이미지에 제목을 표시하지 않습니다."
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">오버레이 본문</span>
|
||||
<textarea
|
||||
v-model="form.headerText"
|
||||
class="admin-tag-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
|
||||
maxlength="280"
|
||||
placeholder="비워두면 이미지에 본문을 표시하지 않습니다."
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="admin-tag-form__actions flex justify-end gap-3">
|
||||
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
|
||||
취소
|
||||
|
||||
@@ -32,6 +32,9 @@ CREATE TABLE IF NOT EXISTS tags (
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
header_image_url TEXT NOT NULL DEFAULT '',
|
||||
header_title TEXT NOT NULL DEFAULT '',
|
||||
header_text TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT '#15171a',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
4
db/migrations/058_tag_header_cover.sql
Normal file
4
db/migrations/058_tag_header_cover.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS header_image_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS header_title TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS header_text TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.8.7
|
||||
|
||||
- 태그마다 헤더 이미지를 설정할 수 있게 했다.
|
||||
- 태그 헤더 이미지를 쓰면 태그 페이지 상단의 기본 제목/설명 대신 이미지를 표시하고, 필요하면 이미지 위 오버레이 제목과 본문을 따로 넣을 수 있다.
|
||||
|
||||
## v1.8.6
|
||||
|
||||
- 일반 태그 페이지에서도 슬러그 대신 등록된 태그 이름과 설명이 보이도록 수정했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.8.6에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.8.7에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.8.7 참고
|
||||
|
||||
- DB 마이그레이션 `058_tag_header_cover.sql`이 추가되었다.
|
||||
- 배포 전 운영 DB에 `npm run db:migrate:prod`를 실행한다.
|
||||
- 관리자 태그 생성·수정 화면에서 태그 헤더 이미지를 업로드하고 저장한 뒤 `/tag/:slug`에서 기본 제목/설명 대신 이미지가 표시되는지 확인한다.
|
||||
- 오버레이 제목·본문을 비워두면 태그 헤더 이미지 단독으로 표시되는지 확인한다.
|
||||
|
||||
### v1.8.6 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-26 v1.8.7 — 태그 헤더는 이미지 우선 옵션으로 둔다
|
||||
|
||||
태그 페이지는 기본적으로 태그 이름과 설명을 보여 주지만, 특정 태그는 시리즈나 작품 단위로 별도 분위기를 줄 필요가 있다. 태그 헤더 이미지가 설정된 경우에는 기본 제목·설명을 자동으로 숨기고 이미지를 우선 표시한다. 오버레이 제목·본문은 선택 입력으로 두어, 이미지만 쓰는 태그와 이미지 위 텍스트를 쓰는 태그를 같은 구조에서 처리한다.
|
||||
|
||||
## 2026-06-26 v1.8.6 — 공개 태그 메타 조회 범위를 분리한다
|
||||
|
||||
왼쪽 사이드바와 `/tags` 목록은 기존처럼 메인 태그만 노출해야 하지만, `/tag/:slug` 상세와 게시물 배지는 일반 태그의 이름·설명·색상도 필요하다. 따라서 공개 태그 API의 기본값은 메인 태그로 유지하고, `tagType=all` 옵션을 추가해 필요한 화면에서만 전체 태그 메타를 조회하도록 정리했다.
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
|------|------|
|
||||
| db/migrations/056_site_settings_post_tag_limit.sql | 사이트 설정의 게시물 태그 최대 개수 컬럼 추가 |
|
||||
| db/migrations/057_navigation_recommended_favicon_cache.sql | 추천 사이트 파비콘 캐시 URL·갱신 시각 컬럼 추가 |
|
||||
| db/migrations/058_tag_header_cover.sql | 태그 헤더 이미지·오버레이 제목·본문 컬럼 추가 |
|
||||
|
||||
## 서버 미들웨어
|
||||
|
||||
@@ -118,7 +119,7 @@
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
@@ -195,7 +196,7 @@
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 해당 URL 재진입 |
|
||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
|
||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||
@@ -211,6 +212,7 @@
|
||||
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) |
|
||||
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
|
||||
| server/api/tags.get.js | 공개 태그 목록 API, 기본 메인 태그 반환·`tagType=all` 전체 태그 메타 반환 |
|
||||
| server/routes/admin/api/tags/cover.post.js | 관리자 태그 헤더 이미지 업로드 API(`/uploads/system/tag-cover-*.webp`) |
|
||||
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/homepage-widget.get.js | gethomepage customapi용 당일·7일·30일·180일·365일·누적·콘텐츠 통계 위젯 API |
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
|
||||
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||
- 태그에 `headerImageUrl`이 있으면 `/tag/:slug` 상단에는 기본 태그명·설명 대신 태그 헤더 이미지를 표시한다. `headerTitle`, `headerText`는 이미지 위 오버레이로 표시하며, 둘 다 비어 있으면 이미지 단독으로 표시한다.
|
||||
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||
- `/signin` - 로그인
|
||||
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||
@@ -642,6 +643,7 @@ components/content/
|
||||
- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제, 태그 생성·수정 저장 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. 태그 수정 화면의 `변경 저장` 버튼은 실제 변경사항이 있을 때만 활성화한다.
|
||||
- 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
- 태그 헤더는 `headerImageUrl`, `headerTitle`, `headerText`를 사용한다. 관리자 태그 폼에서 이미지를 업로드하면 `/uploads/system/tag-cover-*.webp`로 저장하고, 해당 이미지가 미디어 사용처로 보호된다.
|
||||
|
||||
### 관리자 글 편집
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.8.7
|
||||
|
||||
- 태그별 헤더 이미지·오버레이 제목·오버레이 본문 필드 추가.
|
||||
- 관리자 태그 생성·수정 폼에 태그 헤더 이미지 업로드·삭제와 오버레이 입력 추가.
|
||||
- 공개 태그 상세에서 헤더 이미지가 있으면 기본 태그명·설명 대신 헤더 이미지를 표시하도록 수정.
|
||||
- 태그 헤더 이미지가 미디어 사용처로 보호되도록 정리.
|
||||
|
||||
## v1.8.6
|
||||
|
||||
- 공개 태그 API에 전체 태그 메타 조회 옵션을 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.6",
|
||||
"version": "1.8.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.6",
|
||||
"version": "1.8.7",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.6",
|
||||
"version": "1.8.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -11,6 +11,7 @@ const { data: posts } = await useFetch('/api/posts', {
|
||||
})
|
||||
|
||||
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
|
||||
const hasTagHeaderImage = computed(() => Boolean(tag.value?.headerImageUrl))
|
||||
|
||||
/**
|
||||
* 태그 슬러그로 공개 태그 메타를 찾는다.
|
||||
@@ -48,7 +49,13 @@ const tagPosts = computed(() => posts.value
|
||||
<MainColumn>
|
||||
<section class="tag-posts-header site-section">
|
||||
<div class="tag-posts-header__inner site-section-header mx-auto flex max-w-[720px] flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<HomeHero
|
||||
v-if="hasTagHeaderImage"
|
||||
:image-url="tag.headerImageUrl"
|
||||
:title="tag.headerTitle"
|
||||
:text="tag.headerText"
|
||||
/>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<h1 class="text-lg font-medium leading-tight sm:text-xl">
|
||||
{{ tag?.name || slug.toUpperCase() }}
|
||||
</h1>
|
||||
|
||||
@@ -94,6 +94,9 @@ const mapTagRow = (row) => ({
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
headerImageUrl: row.header_image_url || '',
|
||||
headerTitle: row.header_title || '',
|
||||
headerText: row.header_text || '',
|
||||
sortOrder: row.sort_order,
|
||||
color: row.color,
|
||||
tagType: row.tag_type || 'managed',
|
||||
@@ -743,6 +746,9 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
|
||||
if (!sql) {
|
||||
const sampleTags = getSampleTags().map((tag) => ({
|
||||
...tag,
|
||||
headerImageUrl: tag.headerImageUrl || '',
|
||||
headerTitle: tag.headerTitle || '',
|
||||
headerText: tag.headerText || '',
|
||||
tagType: 'managed',
|
||||
postCount: 0,
|
||||
lastUsedAt: null,
|
||||
@@ -1327,8 +1333,8 @@ export const createAdminTag = async (input) => {
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO tags (name, slug, description, sort_order, color, tag_type)
|
||||
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color}, ${input.tagType})
|
||||
INSERT INTO tags (name, slug, description, header_image_url, header_title, header_text, sort_order, color, tag_type)
|
||||
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.headerImageUrl}, ${input.headerTitle}, ${input.headerText}, ${input.sortOrder}, ${input.color}, ${input.tagType})
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
@@ -1354,6 +1360,9 @@ export const updateAdminTag = async (id, input) => {
|
||||
name = ${input.name},
|
||||
slug = ${input.slug},
|
||||
description = ${input.description},
|
||||
header_image_url = ${input.headerImageUrl},
|
||||
header_title = ${input.headerTitle},
|
||||
header_text = ${input.headerText},
|
||||
sort_order = ${input.sortOrder},
|
||||
color = ${input.color},
|
||||
tag_type = ${input.tagType},
|
||||
|
||||
88
server/routes/admin/api/tags/cover.post.js
Normal file
88
server/routes/admin/api/tags/cover.post.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
const tagCoverWidth = 720
|
||||
|
||||
/**
|
||||
* 시스템 자산 파일명 접미사
|
||||
* @returns {string} 파일명 접미사
|
||||
*/
|
||||
const createSystemAssetSuffix = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
|
||||
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 커버 이미지 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ headerImageUrl: string }>} 업로드된 이미지 URL
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 태그 헤더 이미지가 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!allowedImageTypes.has(file.type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'JPG, PNG, WebP 이미지만 태그 헤더로 사용할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > maxFileSize) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const metadata = await sharp(file.data).metadata()
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const assetSuffix = createSystemAssetSuffix()
|
||||
const coverFileName = `tag-cover-${assetSuffix}.webp`
|
||||
const coverPath = join(directoryPath, coverFileName)
|
||||
const headerImageUrl = `${uploadBaseUrl}/system/${coverFileName}`
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
const coverBuffer = await sharp(file.data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: tagCoverWidth,
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.webp({ quality: 88 })
|
||||
.toBuffer()
|
||||
|
||||
await writeFile(coverPath, coverBuffer)
|
||||
await upsertMediaMetadataCategory(headerImageUrl, '시스템')
|
||||
|
||||
return { headerImageUrl }
|
||||
})
|
||||
@@ -4,6 +4,9 @@ export const adminTagInputSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
description: z.string().default(''),
|
||||
headerImageUrl: z.string().trim().max(500).optional().default(''),
|
||||
headerTitle: z.string().trim().max(120).optional().default(''),
|
||||
headerText: z.string().trim().max(280).optional().default(''),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a'),
|
||||
tagType: z.enum(['managed', 'general']).default('managed')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||
import { createError } from 'h3'
|
||||
import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository'
|
||||
import { getSiteSettings, listAdminPosts, listAdminTags, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
import {
|
||||
getPostThumbnailUrl,
|
||||
@@ -506,17 +506,40 @@ const getMediaUsage = (url, posts, pages) => {
|
||||
return [...postUsages, ...pageUsages]
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 헤더 이미지 사용처 조회
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {Array<Object>} tags - 태그 목록
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getTagMediaUsage = (url, tags) => tags
|
||||
.filter((tag) => tag.headerImageUrl === url)
|
||||
.map((tag) => ({
|
||||
type: 'tag',
|
||||
typeLabel: '태그',
|
||||
id: tag.id,
|
||||
title: tag.name,
|
||||
slug: tag.slug,
|
||||
adminUrl: `/admin/tags/${tag.id}`,
|
||||
publicUrl: `/tag/${tag.slug}`,
|
||||
status: tag.tagType,
|
||||
location: 'headerImageUrl',
|
||||
label: '태그 헤더 이미지'
|
||||
}))
|
||||
|
||||
/**
|
||||
* 미디어 URL이 실제 사용처에서 쓰이는지 조회한다.
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {Array<Object>} posts - 게시물 목록
|
||||
* @param {Array<Object>} pages - 페이지 목록
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @param {Array<Object>} tags - 태그 목록
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getDirectMediaUsage = (url, posts, pages, siteSettings) => [
|
||||
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
|
||||
...getMediaUsage(url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(url, siteSettings)
|
||||
...getSiteSettingsMediaUsage(url, siteSettings),
|
||||
...getTagMediaUsage(url, tags)
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -673,10 +696,11 @@ const getSiteSettingsMediaUsage = (url, siteSettings) => {
|
||||
export const listMediaItems = async () => {
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
const [posts, pages, siteSettings, tags] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
])
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemUrlSet = new Set(items.map((item) => item.url))
|
||||
@@ -685,7 +709,7 @@ export const listMediaItems = async () => {
|
||||
const rawCategory = metadataMap[item.url]?.category ?? item.category
|
||||
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
|
||||
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
|
||||
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings)
|
||||
const directUsage = getDirectMediaUsage(item.url, posts, pages, siteSettings, tags)
|
||||
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
|
||||
const originalUrl = thumbnailOwnerByUrl.get(item.url) || ''
|
||||
|
||||
@@ -818,13 +842,14 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMediaItem = async (url) => {
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
const [posts, pages, siteSettings, tags] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
])
|
||||
const usage = [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings),
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
|
||||
]
|
||||
|
||||
@@ -860,13 +885,14 @@ export const renameMediaItem = async (url, name) => {
|
||||
})
|
||||
}
|
||||
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
const [posts, pages, siteSettings, tags] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
getSiteSettings(),
|
||||
listAdminTags()
|
||||
])
|
||||
const usage = [
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings),
|
||||
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
|
||||
...getPostThumbnailMediaUsage(url, buildPostThumbnailOwnerMap(await readMediaDirectory(uploadRoot)), posts, pages, siteSettings)
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user