Compare commits
4 Commits
v1.1.5_이미지
...
v1.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 2768975752 | |||
| 59a50a0c97 | |||
| b4e4e37f5a | |||
| 536ee7079e |
@@ -105,6 +105,7 @@ const form = reactive({
|
||||
excerpt: props.initialPost.excerpt || '',
|
||||
content: normalizeMarkdownContent(props.initialPost.content),
|
||||
featuredImage: props.initialPost.featuredImage || '',
|
||||
isFeatured: Boolean(props.initialPost.isFeatured),
|
||||
noindex: Boolean(props.initialPost.noindex),
|
||||
status: props.initialPost.status || 'draft',
|
||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
@@ -301,6 +302,7 @@ const createPostPayload = () => {
|
||||
excerpt: form.excerpt.trim(),
|
||||
content: normalizeMarkdownContent(form.content),
|
||||
featuredImage: form.featuredImage.trim() || null,
|
||||
isFeatured: form.isFeatured,
|
||||
seoTitle: form.title.trim(),
|
||||
seoDescription: form.excerpt.trim(),
|
||||
canonicalUrl: '',
|
||||
@@ -330,6 +332,7 @@ const createAutosavePayload = () => ({
|
||||
excerpt: form.excerpt,
|
||||
content: normalizeMarkdownContent(form.content),
|
||||
featuredImage: form.featuredImage,
|
||||
isFeatured: form.isFeatured,
|
||||
noindex: form.noindex,
|
||||
status: form.status,
|
||||
publishedAt: form.publishedAt,
|
||||
@@ -347,7 +350,8 @@ const isEmptyAutosavePayload = (payload) => ![
|
||||
payload.excerpt,
|
||||
payload.content,
|
||||
payload.featuredImage,
|
||||
payload.tagsText
|
||||
payload.tagsText,
|
||||
payload.isFeatured ? 'featured' : ''
|
||||
].some((value) => String(value || '').trim())
|
||||
|
||||
/**
|
||||
@@ -1026,6 +1030,29 @@ defineExpose({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
|
||||
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 17.28l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 3.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-toggle-label font-bold text-[#15171a]">
|
||||
추천 글
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="form.isFeatured"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="추천 글로 표시"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="admin-post-form__search-visibility grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<div>
|
||||
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink">
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultTagType: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,7 +68,7 @@ const submitTag = () => {
|
||||
description: form.description.trim(),
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || 'general'
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,6 +19,9 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
})
|
||||
})
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
|
||||
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||
|
||||
/**
|
||||
@@ -173,7 +176,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<div v-if="showAuthorSection" class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
|
||||
<span>Authors</span>
|
||||
<span>⌃</span>
|
||||
|
||||
@@ -17,6 +17,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
copyrightText: '©2026 sori.studio'
|
||||
})
|
||||
})
|
||||
|
||||
/** 소개 영역 공개 여부 */
|
||||
const showAboutSection = false
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,7 +179,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div v-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<p class="right-sidebar__about text-sm leading-6 site-muted">
|
||||
{{ siteSettings.description }}
|
||||
</p>
|
||||
|
||||
@@ -8,8 +8,7 @@ const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
logoUrl: ''
|
||||
title: 'sori.studio'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,12 +185,6 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
|
||||
:src="siteSettings.logoUrl"
|
||||
:alt="siteSettings.title"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
5
db/migrations/023_add_post_featured.sql
Normal file
5
db/migrations/023_add_post_featured.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS posts_is_featured_status_published_at_idx
|
||||
ON posts (is_featured, status, published_at DESC);
|
||||
@@ -1,5 +1,25 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.1.9
|
||||
|
||||
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
|
||||
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
|
||||
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
|
||||
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
|
||||
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||
|
||||
@@ -136,6 +136,9 @@ cp .env.example .env.production
|
||||
|
||||
# Docker 빌드 및 실행
|
||||
docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글 컬럼 마이그레이션 적용
|
||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql
|
||||
```
|
||||
|
||||
### Docker 네트워크 충돌 대응
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-15 v1.1.9
|
||||
|
||||
### 추천 글을 저장 필드로 분리
|
||||
|
||||
홈 Featured와 목록의 번개 표시는 최신 글 여부가 아니라 운영자가 명시한 추천 상태여야 한다. 기존처럼 첫 번째 글을 추천처럼 보이게 하면 추천 의도와 최신순 정렬이 섞이므로, 게시물에 `is_featured` 필드를 추가하고 글쓰기 사이드바 토글로 관리하도록 했다. 추천 글이 없으면 홈 Featured 영역도 숨겨 빈 운영 상태에서 불필요한 섹션이 보이지 않게 한다.
|
||||
|
||||
### 관리자 글 목록 필터를 클라이언트 우선으로 도입
|
||||
|
||||
현재 관리자 글 목록은 전체 글을 한 번에 조회하는 구조라 상태·태그·정렬 필터를 클라이언트에서 먼저 적용해 변경 범위를 줄였다. 목록 규모가 커지면 같은 필터 기준을 `/admin/api/posts` 쿼리 파라미터로 옮길 수 있도록 상태 키와 태그 필터 계산을 별도 함수로 분리했다.
|
||||
|
||||
## 2026-05-15 v1.1.8
|
||||
|
||||
### 태그 순서 저장을 드롭 즉시 자동화
|
||||
|
||||
메인 태그 정렬은 드래그 자체가 명확한 저장 의도를 가진 조작이므로 별도의 `정렬 저장` 버튼을 두면 화면의 책임이 나뉘어 보인다. 태그 추가 버튼도 화면 전체 제목 옆에 있으면 메인 태그 추가처럼 보일 수 있어, 새 태그가 기본적으로 일반 태그로 생성되는 현재 구조에 맞춰 일반 태그 섹션 헤더 오른쪽으로 옮겼다. 순서 저장 중에는 추가 드래그를 잠시 막아 서버 순서와 화면 순서가 어긋나지 않게 한다.
|
||||
|
||||
## 2026-05-15 v1.1.7
|
||||
|
||||
### 사이트 로고 파일명을 교체마다 고유하게 저장
|
||||
|
||||
사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.6
|
||||
|
||||
### 일반 태그도 검색 없이 보이는 관리 화면
|
||||
|
||||
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.5
|
||||
|
||||
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
||||
|
||||
19
docs/map.md
19
docs/map.md
@@ -50,11 +50,11 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, About 영역은 비공개, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 추천 글 토글, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
@@ -107,19 +107,19 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 상태·태그·최신순/오래된순 필터, 예약/초안/발행 텍스트 상태 표시, 제목 옆 댓글 수, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
@@ -128,7 +128,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
@@ -201,6 +201,7 @@
|
||||
| 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/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||
|
||||
23
docs/spec.md
23
docs/spec.md
@@ -51,6 +51,7 @@
|
||||
### 홈 Featured (인덱스)
|
||||
|
||||
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
||||
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
|
||||
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
||||
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
||||
|
||||
@@ -220,6 +221,7 @@ components/content/
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| excerpt | String | 요약 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
|
||||
| seo_title | String | SEO 제목 |
|
||||
| seo_description | String | SEO 설명 |
|
||||
| canonical_url | String | canonical URL |
|
||||
@@ -230,6 +232,8 @@ components/content/
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
|
||||
|
||||
### Users
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -443,11 +447,13 @@ components/content/
|
||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
||||
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
||||
> 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
||||
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
@@ -548,7 +554,7 @@ components/content/
|
||||
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
@@ -610,13 +616,14 @@ components/content/
|
||||
/uploads/posts/YYYY/MM/filename.webp
|
||||
/uploads/pages/YYYY/MM/filename.webp
|
||||
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||
/uploads/system/logo.png
|
||||
/uploads/system/favicon.png
|
||||
/uploads/system/logo-YYYYMM-random.webp
|
||||
/uploads/system/favicon-YYYYMM-random.png
|
||||
```
|
||||
|
||||
- 관리자 에디터 이미지 업로드 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`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
@@ -633,7 +640,7 @@ components/content/
|
||||
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.1.9
|
||||
|
||||
- 관리자 글 목록에 상태·태그·최신순/오래된순 필터 추가.
|
||||
- 관리자 글 목록 상태 표시를 배지에서 단순 텍스트 색상 기준으로 정리하고 제목 옆 댓글 수 표시 추가.
|
||||
- 게시물 추천 여부(`is_featured`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.
|
||||
- 홈 Featured 영역을 추천 글이 있을 때만 표시하고, 최신 글의 번개 표시는 실제 추천 글에만 나오도록 수정.
|
||||
- 공개 목록·상세의 댓글 수 표시를 API 댓글 집계값 기준으로 정리.
|
||||
- 공개 헤더의 이미지 로고 주석 코드를 제거하고 사이트 이름 텍스트만 표시하도록 정리.
|
||||
- 왼쪽 사이드바 Authors 영역과 오른쪽 사이드바 About 영역을 비공개 처리.
|
||||
- 패키지 버전 `1.1.9`로 갱신.
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 사이트 로고·파비콘 고유 파일명 접미사를 년월+랜덤 문자열 형식으로 간소화.
|
||||
- 관리자 태그 관리 화면의 `태그 추가` 버튼을 일반 태그 섹션 헤더 오른쪽으로 이동.
|
||||
- 메인 태그 `정렬 저장` 버튼을 제거하고 드래그 드롭 직후 자동 저장되도록 수정.
|
||||
- 메인 태그 순서 자동 저장 중 추가 드래그를 막고 저장 상태를 표시하도록 정리.
|
||||
- 패키지 버전 `1.1.8`로 갱신.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.
|
||||
- 사이트 파비콘도 로고와 같은 고유 접미사 파일명으로 생성해 브라우저 캐시로 이전 이미지가 남는 문제를 완화.
|
||||
- 미디어 라이브러리 사용 현황에 사이트 설정 로고·파비콘 참조를 포함하고, 현재 사용 중인 시스템 이미지는 파일명 변경·삭제가 잠기도록 수정.
|
||||
- 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 todo 정리.
|
||||
- 패키지 버전 `1.1.7`로 갱신.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
|
||||
- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬.
|
||||
- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가.
|
||||
- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리.
|
||||
- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리.
|
||||
- 패키지 버전 `1.1.6`으로 갱신.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.19",
|
||||
"version": "1.1.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.19",
|
||||
"version": "1.1.9",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -5920,7 +5920,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -5,11 +5,18 @@ definePageMeta({
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const tagFilter = ref('all')
|
||||
const sortOrder = ref('newest')
|
||||
|
||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: tags } = await useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
@@ -36,27 +43,106 @@ const formatDate = (value) => {
|
||||
const isPublicPost = (post) => post.status === 'published'
|
||||
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
|
||||
|
||||
/**
|
||||
* 게시물 상태 필터 키 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {'published' | 'scheduled' | 'draft' | 'private'} 상태 키
|
||||
*/
|
||||
const getPostStatusKey = (post) => {
|
||||
if (post.status === 'published' && !isPublicPost(post)) {
|
||||
return 'scheduled'
|
||||
}
|
||||
|
||||
if (post.status === 'published') {
|
||||
return 'published'
|
||||
}
|
||||
|
||||
if (post.status === 'private') {
|
||||
return 'private'
|
||||
}
|
||||
|
||||
return 'draft'
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 상태 표시 문자열 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 상태 표시 문자열
|
||||
*/
|
||||
const getPostStatusLabel = (post) => {
|
||||
if (post.status === 'published' && !isPublicPost(post)) {
|
||||
const statusKey = getPostStatusKey(post)
|
||||
|
||||
if (statusKey === 'scheduled') {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
if (post.status === 'published') {
|
||||
if (statusKey === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
if (post.status === 'private') {
|
||||
if (statusKey === 'private') {
|
||||
return '비공개'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 상태 텍스트 클래스 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 상태 텍스트 클래스
|
||||
*/
|
||||
const getPostStatusClass = (post) => {
|
||||
const statusKey = getPostStatusKey(post)
|
||||
|
||||
if (statusKey === 'scheduled') {
|
||||
return 'font-bold text-[#30cf43]'
|
||||
}
|
||||
|
||||
if (statusKey === 'draft') {
|
||||
return 'font-bold text-[#fb2d8d]'
|
||||
}
|
||||
|
||||
if (statusKey === 'published') {
|
||||
return 'text-[#99A3AD]'
|
||||
}
|
||||
|
||||
return 'font-bold text-[#8e9cac]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 슬러그의 표시 이름을 조회한다.
|
||||
* @param {string} slug - 태그 슬러그
|
||||
* @returns {string} 태그 표시 이름
|
||||
*/
|
||||
const getTagName = (slug) => tags.value.find((tag) => tag.slug === slug)?.name || slug
|
||||
|
||||
const usedTagSlugs = computed(() => {
|
||||
const slugs = new Set()
|
||||
for (const post of posts.value) {
|
||||
for (const tag of post.tags || []) {
|
||||
slugs.add(tag)
|
||||
}
|
||||
}
|
||||
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
|
||||
})
|
||||
|
||||
const filteredPosts = computed(() => {
|
||||
const filtered = posts.value.filter((post) => {
|
||||
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
|
||||
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
|
||||
|
||||
return matchesStatus && matchesTag
|
||||
})
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
const left = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const right = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
|
||||
return sortOrder.value === 'oldest' ? left - right : right - left
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 삭제
|
||||
* @param {Object} post - 삭제할 게시물
|
||||
@@ -99,6 +185,34 @@ const deletePost = async (post) => {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="admin-posts__filters mt-6 flex flex-wrap items-center gap-2">
|
||||
<label class="admin-posts__filter">
|
||||
<span class="sr-only">상태 필터</span>
|
||||
<select v-model="statusFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||
<option value="all">전체 상태</option>
|
||||
<option value="published">발행</option>
|
||||
<option value="draft">초안</option>
|
||||
<option value="scheduled">예약</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-posts__filter">
|
||||
<span class="sr-only">태그 필터</span>
|
||||
<select v-model="tagFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||
<option value="all">전체 태그</option>
|
||||
<option v-for="tag in usedTagSlugs" :key="tag" :value="tag">
|
||||
{{ getTagName(tag) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-posts__filter">
|
||||
<span class="sr-only">정렬</span>
|
||||
<select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||
<option value="newest">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
@@ -115,24 +229,24 @@ const deletePost = async (post) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="post in posts" :key="post.id" class="admin-posts__row">
|
||||
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||
{{ post.title || '(제목 없음)' }}
|
||||
</NuxtLink>
|
||||
<span class="admin-posts__comment-count text-xs font-medium text-muted">
|
||||
댓글 {{ post.commentCount || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
||||
/post/{{ post.slug }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<span
|
||||
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-green-50 text-green-700': getPostStatusLabel(post) === '발행',
|
||||
'bg-blue-50 text-blue-700': getPostStatusLabel(post) === '예약',
|
||||
'bg-[#f5f5f2] text-muted': getPostStatusLabel(post) === '초안',
|
||||
'bg-red-50 text-red-700': getPostStatusLabel(post) === '비공개'
|
||||
}"
|
||||
class="admin-posts__status-text text-xs"
|
||||
:class="getPostStatusClass(post)"
|
||||
>
|
||||
{{ getPostStatusLabel(post) }}
|
||||
</span>
|
||||
@@ -180,8 +294,8 @@ const deletePost = async (post) => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="posts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||
아직 작성된 글이 없습니다.
|
||||
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -12,16 +12,49 @@ const deletingGeneralTagId = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
const generalTagQuery = ref('')
|
||||
const generalTagSearchResults = ref([])
|
||||
const generalTagSearchLoading = ref(false)
|
||||
const generalTagSortMode = ref('recent')
|
||||
|
||||
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
||||
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
|
||||
const filteredGeneralTags = computed(() => {
|
||||
const keyword = generalTagQuery.value.trim().toLowerCase()
|
||||
const sortedTags = [...generalTags.value].sort((a, b) => {
|
||||
if (generalTagSortMode.value === 'count') {
|
||||
const countDiff = Number(b.postCount || 0) - Number(a.postCount || 0)
|
||||
if (countDiff !== 0) {
|
||||
return countDiff
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */
|
||||
if (generalTagSortMode.value === 'name') {
|
||||
return a.name.localeCompare(b.name, 'ko')
|
||||
}
|
||||
|
||||
const aTime = new Date(a.lastUsedAt || a.updatedAt || 0).getTime()
|
||||
const bTime = new Date(b.lastUsedAt || b.updatedAt || 0).getTime()
|
||||
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name, 'ko')
|
||||
})
|
||||
|
||||
if (!keyword) {
|
||||
return sortedTags
|
||||
}
|
||||
|
||||
return sortedTags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(keyword) ||
|
||||
tag.slug.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
/** 서버 기준 메인 태그 id 순서(자동 저장 필요 여부 비교용) */
|
||||
const baselineManagedTagIds = ref([])
|
||||
|
||||
/**
|
||||
@@ -44,7 +77,7 @@ const refreshTagsFromServer = async () => {
|
||||
resetManagedOrderBaseline()
|
||||
|
||||
/**
|
||||
* 메인 태그 드래그 순서가 기준선과 다른지 여부
|
||||
* 메인 태그 드래그 순서가 서버 기준선과 다른지 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const isManagedOrderDirty = computed(() => {
|
||||
@@ -83,6 +116,11 @@ const showToast = (type, message) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragStart = (event, tagId) => {
|
||||
if (savingOrder.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
@@ -97,6 +135,10 @@ const handleDragStart = (event, tagId) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragOver = (event, tagId) => {
|
||||
if (savingOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragOverTagId.value = tagId
|
||||
}
|
||||
@@ -139,15 +181,17 @@ const moveManagedTag = (sourceId, targetId) => {
|
||||
* 관리용 태그 드롭 처리
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} targetId - 대상 태그 ID
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleDrop = (event, targetId) => {
|
||||
const handleDrop = async (event, targetId) => {
|
||||
event.preventDefault()
|
||||
if (!draggingTagId.value) {
|
||||
if (!draggingTagId.value || savingOrder.value) {
|
||||
return
|
||||
}
|
||||
moveManagedTag(draggingTagId.value, targetId)
|
||||
handleDragEnd()
|
||||
await nextTick()
|
||||
await saveManagedOrder()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,9 +215,10 @@ const saveManagedOrder = async () => {
|
||||
|
||||
tags.value = [...reordered]
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', '메인 태그 순서가 저장되었습니다.')
|
||||
showToast('success', '메인 태그 순서가 자동 저장되었습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
||||
await refreshTagsFromServer()
|
||||
} finally {
|
||||
savingOrder.value = false
|
||||
}
|
||||
@@ -184,27 +229,16 @@ const saveManagedOrder = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const searchGeneralTags = async () => {
|
||||
const keyword = generalTagQuery.value.trim()
|
||||
if (!keyword) {
|
||||
generalTagSearchResults.value = []
|
||||
return
|
||||
}
|
||||
generalTagQuery.value = generalTagQuery.value.trim()
|
||||
}
|
||||
|
||||
generalTagSearchLoading.value = true
|
||||
|
||||
try {
|
||||
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
|
||||
query: {
|
||||
tagType: 'general',
|
||||
q: keyword,
|
||||
limit: 30
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
|
||||
} finally {
|
||||
generalTagSearchLoading.value = false
|
||||
}
|
||||
/**
|
||||
* 일반 태그 정렬 기준을 변경한다.
|
||||
* @param {'recent'|'count'|'name'} mode - 정렬 기준
|
||||
* @returns {void}
|
||||
*/
|
||||
const setGeneralTagSortMode = (mode) => {
|
||||
generalTagSortMode.value = mode
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,7 +266,6 @@ const promoteToMainTag = async (tag) => {
|
||||
}
|
||||
})
|
||||
await refreshTagsFromServer()
|
||||
await searchGeneralTags()
|
||||
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
|
||||
@@ -266,7 +299,6 @@ const demoteToGeneralTag = async (tag) => {
|
||||
}
|
||||
})
|
||||
await refreshTagsFromServer()
|
||||
await searchGeneralTags()
|
||||
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
|
||||
@@ -292,7 +324,6 @@ const deleteGeneralTag = async (tag) => {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refreshTagsFromServer()
|
||||
await searchGeneralTags()
|
||||
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
|
||||
@@ -309,7 +340,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<section class="admin-tags bg-paper p-6">
|
||||
<div class="admin-tags__header flex items-center justify-between gap-4">
|
||||
<div class="admin-tags__header">
|
||||
<div>
|
||||
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Tags
|
||||
@@ -318,25 +349,18 @@ onBeforeUnmount(() => {
|
||||
태그 관리
|
||||
</h1>
|
||||
</div>
|
||||
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/tags/new">
|
||||
태그 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 수 있습니다.
|
||||
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 때 메인 태그로 전환할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
|
||||
<div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">메인 태그</p>
|
||||
<button
|
||||
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingOrder || managedTags.length === 0 || !isManagedOrderDirty"
|
||||
@click="saveManagedOrder"
|
||||
>
|
||||
{{ savingOrder ? '저장 중' : '정렬 저장' }}
|
||||
</button>
|
||||
<span v-if="savingOrder" class="inline-flex items-center gap-2 text-xs font-semibold text-muted">
|
||||
<span class="size-3 animate-spin rounded-full border-2 border-line border-t-[#15171a]" />
|
||||
저장 중
|
||||
</span>
|
||||
</div>
|
||||
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
@@ -353,12 +377,13 @@ onBeforeUnmount(() => {
|
||||
<tr
|
||||
v-for="(tag, index) in managedTags"
|
||||
:key="tag.id"
|
||||
class="admin-tags__row cursor-move"
|
||||
class="admin-tags__row"
|
||||
:class="[
|
||||
dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '',
|
||||
draggingTagId === tag.id ? 'opacity-50' : ''
|
||||
draggingTagId === tag.id ? 'opacity-50' : '',
|
||||
savingOrder ? 'cursor-not-allowed opacity-60' : 'cursor-move'
|
||||
]"
|
||||
draggable="true"
|
||||
:draggable="!savingOrder"
|
||||
@dragstart="handleDragStart($event, tag.id)"
|
||||
@dragover="handleDragOver($event, tag.id)"
|
||||
@drop="handleDrop($event, tag.id)"
|
||||
@@ -403,45 +428,69 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
||||
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">일반 태그 검색</p>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
|
||||
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" to="/admin/tags/new">
|
||||
태그 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="space-y-3 bg-white p-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<input
|
||||
v-model="generalTagQuery"
|
||||
type="text"
|
||||
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
|
||||
placeholder="일반 태그 이름 또는 슬러그 검색"
|
||||
placeholder="일반 태그 이름 또는 슬러그 필터"
|
||||
@keydown.enter.prevent="searchGeneralTags"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded border border-line bg-white px-4 text-sm font-semibold disabled:opacity-50"
|
||||
:disabled="generalTagSearchLoading"
|
||||
@click="searchGeneralTags"
|
||||
>
|
||||
{{ generalTagSearchLoading ? '검색 중' : '검색' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="generalTagSearchResults.length" class="divide-y divide-line rounded border border-line">
|
||||
<div v-for="tag in generalTagSearchResults" :key="tag.id" class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-ink">{{ tag.name }}</p>
|
||||
<p class="truncate text-xs text-muted">{{ tag.slug }}</p>
|
||||
</div>
|
||||
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
:disabled="promotingTagId === tag.id"
|
||||
@click="promoteToMainTag(tag)"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('recent')"
|
||||
>
|
||||
{{ promotingTagId === tag.id ? '전환 중' : '메인 태그로 전환' }}
|
||||
최근 사용순
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'count' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('count')"
|
||||
>
|
||||
많이 사용순
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'name' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('name')"
|
||||
>
|
||||
이름순
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredGeneralTags.length" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="tag in filteredGeneralTags"
|
||||
:key="tag.id"
|
||||
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] px-3 py-2 text-sm"
|
||||
:title="tag.slug"
|
||||
>
|
||||
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="truncate font-semibold text-ink">{{ tag.name }}</span>
|
||||
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50"
|
||||
:disabled="promotingTagId === tag.id"
|
||||
@click="promoteToMainTag(tag)"
|
||||
>
|
||||
{{ promotingTagId === tag.id ? '전환 중' : '메인' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50"
|
||||
:disabled="deletingGeneralTagId === tag.id"
|
||||
@click="deleteGeneralTag(tag)"
|
||||
>
|
||||
@@ -449,8 +498,8 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
|
||||
검색 결과가 없습니다.
|
||||
<p v-else class="text-sm text-muted">
|
||||
{{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,10 +71,9 @@ const getTagMeta = (slug) => {
|
||||
/**
|
||||
* Latest 목록 데이터 변환
|
||||
* @param {Object} post - API 게시물
|
||||
* @param {number} index - 목록 인덱스
|
||||
* @returns {Object} 화면 표시 데이터
|
||||
*/
|
||||
const mapLatestPost = (post, index) => {
|
||||
const mapLatestPost = (post) => {
|
||||
const primaryTagSlug = post.tags?.[0]
|
||||
const tagMeta = getTagMeta(primaryTagSlug)
|
||||
|
||||
@@ -87,11 +86,12 @@ const mapLatestPost = (post, index) => {
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
publishedAtIso: post.publishedAt || '',
|
||||
to: `/post/${post.slug}`,
|
||||
isFeatured: index === 0
|
||||
isFeatured: Boolean(post.isFeatured),
|
||||
commentCount: Number(post.commentCount || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPosts = computed(() => posts.value.slice(0, 6))
|
||||
const featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
|
||||
const latestPosts = computed(() => posts.value.map(mapLatestPost))
|
||||
|
||||
const featuredTrackRef = ref(null)
|
||||
@@ -233,7 +233,7 @@ const scrollFeatured = (direction) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-4 px-6">
|
||||
<section v-if="featuredPosts.length" class="py-4 px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||
@@ -475,7 +475,7 @@ const scrollFeatured = (direction) => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span>0</span>
|
||||
<span>{{ post.commentCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,7 +254,7 @@ useHead(() => ({
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span class="pointer-events-none">0</span>
|
||||
<span class="pointer-events-none">{{ post.commentCount || 0 }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@ const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
|
||||
|
||||
const tagPosts = computed(() => posts.value
|
||||
.filter((post) => post.tags.includes(slug.value))
|
||||
.map((post, index) => ({
|
||||
.map((post) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
|
||||
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
|
||||
isFeatured: index === 0,
|
||||
isFeatured: Boolean(post.isFeatured),
|
||||
commentCount: Number(post.commentCount || 0),
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
publishedAtIso: post.publishedAt || '',
|
||||
to: `/post/${post.slug}`
|
||||
@@ -99,7 +100,7 @@ const tagPosts = computed(() => posts.value
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span>0</span>
|
||||
<span>{{ post.commentCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ const mapPostRow = (row) => ({
|
||||
content: row.content,
|
||||
excerpt: row.excerpt,
|
||||
featuredImage: row.featured_image,
|
||||
isFeatured: Boolean(row.is_featured),
|
||||
commentCount: Number(row.comment_count || 0),
|
||||
seoTitle: row.seo_title || '',
|
||||
seoDescription: row.seo_description || '',
|
||||
canonicalUrl: row.canonical_url || '',
|
||||
@@ -61,7 +63,10 @@ const mapTagRow = (row) => ({
|
||||
description: row.description,
|
||||
sortOrder: row.sort_order,
|
||||
color: row.color,
|
||||
tagType: row.tag_type || 'managed'
|
||||
tagType: row.tag_type || 'managed',
|
||||
postCount: Number(row.post_count || 0),
|
||||
lastUsedAt: row.last_used_at ? row.last_used_at.toISOString() : null,
|
||||
updatedAt: row.updated_at ? row.updated_at.toISOString() : null
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -190,6 +195,12 @@ export const listPosts = async () => {
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM comments
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
@@ -220,6 +231,12 @@ export const listAdminPosts = async () => {
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM comments
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
@@ -246,6 +263,12 @@ export const getAdminPostById = async (id) => {
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM comments
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
@@ -278,6 +301,7 @@ export const createAdminPost = async (input) => {
|
||||
content,
|
||||
excerpt,
|
||||
featured_image,
|
||||
is_featured,
|
||||
seo_title,
|
||||
seo_description,
|
||||
canonical_url,
|
||||
@@ -292,6 +316,7 @@ export const createAdminPost = async (input) => {
|
||||
${input.content},
|
||||
${input.excerpt},
|
||||
${input.featuredImage},
|
||||
${input.isFeatured},
|
||||
${input.seoTitle},
|
||||
${input.seoDescription},
|
||||
${input.canonicalUrl},
|
||||
@@ -333,6 +358,7 @@ export const updateAdminPost = async (id, input) => {
|
||||
content = ${input.content},
|
||||
excerpt = ${input.excerpt},
|
||||
featured_image = ${input.featuredImage},
|
||||
is_featured = ${input.isFeatured},
|
||||
seo_title = ${input.seoTitle},
|
||||
seo_description = ${input.seoDescription},
|
||||
canonical_url = ${input.canonicalUrl},
|
||||
@@ -393,6 +419,12 @@ export const getPostBySlug = async (slug) => {
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
posts.*,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM comments
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
@@ -572,7 +604,10 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
|
||||
if (!sql) {
|
||||
const sampleTags = getSampleTags().map((tag) => ({
|
||||
...tag,
|
||||
tagType: 'managed'
|
||||
tagType: 'managed',
|
||||
postCount: 0,
|
||||
lastUsedAt: null,
|
||||
updatedAt: null
|
||||
}))
|
||||
let filteredTags = sampleTags
|
||||
if (tagType) {
|
||||
@@ -588,17 +623,25 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
SELECT
|
||||
tags.*,
|
||||
COUNT(post_tags.post_id)::int AS post_count,
|
||||
MAX(posts.updated_at) AS last_used_at
|
||||
FROM tags
|
||||
LEFT JOIN post_tags ON post_tags.tag_id = tags.id
|
||||
LEFT JOIN posts ON posts.id = post_tags.post_id
|
||||
WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null})
|
||||
AND (
|
||||
${trimmedSearchQuery || null}::text IS NULL
|
||||
OR strpos(lower(name), ${trimmedSearchQuery || ''}) > 0
|
||||
OR strpos(lower(slug), ${trimmedSearchQuery || ''}) > 0
|
||||
OR strpos(lower(tags.name), ${trimmedSearchQuery || ''}) > 0
|
||||
OR strpos(lower(tags.slug), ${trimmedSearchQuery || ''}) > 0
|
||||
)
|
||||
GROUP BY tags.id
|
||||
ORDER BY
|
||||
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
|
||||
sort_order ASC,
|
||||
MAX(posts.updated_at) DESC NULLS LAST,
|
||||
tags.updated_at DESC,
|
||||
name ASC
|
||||
LIMIT ${resolvedLimit || 1000}
|
||||
`
|
||||
|
||||
@@ -31,6 +31,18 @@ const clampNumber = (value, minimum, maximum) => {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다.
|
||||
* @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 - 요청 이벤트
|
||||
@@ -77,10 +89,13 @@ export default defineEventHandler(async (event) => {
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const logoPath = join(directoryPath, 'logo.webp')
|
||||
const faviconPath = join(directoryPath, 'favicon.png')
|
||||
const logoUrl = `${uploadBaseUrl}/system/logo.webp`
|
||||
const faviconUrl = `${uploadBaseUrl}/system/favicon.png`
|
||||
const assetSuffix = createSystemAssetSuffix()
|
||||
const logoFileName = `logo-${assetSuffix}.webp`
|
||||
const faviconFileName = `favicon-${assetSuffix}.png`
|
||||
const logoPath = join(directoryPath, logoFileName)
|
||||
const faviconPath = join(directoryPath, faviconFileName)
|
||||
const logoUrl = `${uploadBaseUrl}/system/${logoFileName}`
|
||||
const faviconUrl = `${uploadBaseUrl}/system/${faviconFileName}`
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const adminPostInputSchema = z.object({
|
||||
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
|
||||
excerpt: z.string().default(''),
|
||||
featuredImage: z.string().trim().nullable().default(null),
|
||||
isFeatured: z.boolean().default(false),
|
||||
seoTitle: z.string().trim().default(''),
|
||||
seoDescription: z.string().trim().default(''),
|
||||
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),
|
||||
|
||||
@@ -9,6 +9,8 @@ export const postSchema = z.object({
|
||||
content: z.string(),
|
||||
excerpt: z.string().default(''),
|
||||
featuredImage: z.string().nullable().default(null),
|
||||
isFeatured: z.boolean().default(false),
|
||||
commentCount: z.number().int().default(0),
|
||||
seoTitle: z.string().default(''),
|
||||
seoDescription: z.string().default(''),
|
||||
canonicalUrl: z.string().default(''),
|
||||
|
||||
@@ -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 { listAdminPosts, listPages } from '../repositories/content-repository'
|
||||
import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
@@ -478,6 +478,46 @@ const getMediaUsage = (url, posts, pages) => {
|
||||
return [...postUsages, ...pageUsages]
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정에서 미디어 URL 사용처 조회
|
||||
* @param {string} url - 미디어 URL
|
||||
* @param {Object} siteSettings - 사이트 설정
|
||||
* @returns {Array<Object>} 사용처 목록
|
||||
*/
|
||||
const getSiteSettingsMediaUsage = (url, siteSettings) => {
|
||||
const usages = []
|
||||
|
||||
if (siteSettings.logoUrl === url) {
|
||||
usages.push({
|
||||
type: 'settings',
|
||||
typeLabel: '사이트 설정',
|
||||
id: 'site-logo',
|
||||
title: '사이트 로고',
|
||||
adminUrl: '/admin/settings',
|
||||
publicUrl: '/',
|
||||
status: 'system',
|
||||
location: 'logoUrl',
|
||||
label: '사이트 로고'
|
||||
})
|
||||
}
|
||||
|
||||
if (siteSettings.faviconUrl === url) {
|
||||
usages.push({
|
||||
type: 'settings',
|
||||
typeLabel: '사이트 설정',
|
||||
id: 'site-favicon',
|
||||
title: '파비콘',
|
||||
adminUrl: '/admin/settings',
|
||||
publicUrl: '/',
|
||||
status: 'system',
|
||||
location: 'faviconUrl',
|
||||
label: '파비콘'
|
||||
})
|
||||
}
|
||||
|
||||
return usages
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 목록 조회
|
||||
* @returns {Promise<Array<Object>>} 미디어 항목 목록
|
||||
@@ -485,9 +525,10 @@ const getMediaUsage = (url, posts, pages) => {
|
||||
export const listMediaItems = async () => {
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const [posts, pages] = await Promise.all([
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
])
|
||||
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
|
||||
const itemsWithUsage = items.map((item) => {
|
||||
@@ -498,7 +539,10 @@ export const listMediaItems = async () => {
|
||||
return {
|
||||
...item,
|
||||
category,
|
||||
usage: getMediaUsage(item.url, posts, pages),
|
||||
usage: [
|
||||
...getMediaUsage(item.url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(item.url, siteSettings)
|
||||
],
|
||||
avatarOwner
|
||||
}
|
||||
})
|
||||
@@ -615,11 +659,15 @@ export const updateMediaCategories = async (urls, category) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMediaItem = async (url) => {
|
||||
const [posts, pages] = await Promise.all([
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
])
|
||||
const usage = getMediaUsage(url, posts, pages)
|
||||
const usage = [
|
||||
...getMediaUsage(url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(url, siteSettings)
|
||||
]
|
||||
|
||||
if (usage.length) {
|
||||
throw createError({
|
||||
@@ -646,11 +694,15 @@ export const deleteMediaItem = async (url) => {
|
||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||
*/
|
||||
export const renameMediaItem = async (url, name) => {
|
||||
const [posts, pages] = await Promise.all([
|
||||
const [posts, pages, siteSettings] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
listPages(),
|
||||
getSiteSettings()
|
||||
])
|
||||
const usage = getMediaUsage(url, posts, pages)
|
||||
const usage = [
|
||||
...getMediaUsage(url, posts, pages),
|
||||
...getSiteSettingsMediaUsage(url, siteSettings)
|
||||
]
|
||||
|
||||
if (usage.length) {
|
||||
throw createError({
|
||||
|
||||
Reference in New Issue
Block a user