4 Commits

Author SHA1 Message Date
2768975752 게시물 추천과 관리자 목록 필터 정리 2026-05-15 11:49:12 +09:00
59a50a0c97 태그 관리 자동 저장 정리 2026-05-15 11:21:57 +09:00
b4e4e37f5a 사이트 로고 캐시 갱신 보강 2026-05-15 11:00:48 +09:00
536ee7079e 일반 태그 배지 목록 정리 2026-05-15 10:50:25 +09:00
25 changed files with 559 additions and 153 deletions

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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);

View File

@@ -1,5 +1,25 @@
# 업데이트 요약
## v1.1.9
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
## v1.1.8
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
## v1.1.7
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
## v1.1.6
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
## v1.1.5
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.

View File

@@ -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 네트워크 충돌 대응

View File

@@ -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
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공

View File

@@ -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 |

View File

@@ -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을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.

View File

@@ -6,7 +6,6 @@
## 2차 관리자 개발
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
## 프론트엔드 개발

View File

@@ -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
View File

@@ -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"

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
`

View File

@@ -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 })

View File

@@ -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(''),

View File

@@ -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(''),

View File

@@ -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({