2 Commits

Author SHA1 Message Date
2768975752 게시물 추천과 관리자 목록 필터 정리 2026-05-15 11:49:12 +09:00
59a50a0c97 태그 관리 자동 저장 정리 2026-05-15 11:21:57 +09:00
22 changed files with 322 additions and 78 deletions

View File

@@ -105,6 +105,7 @@ const form = reactive({
excerpt: props.initialPost.excerpt || '', excerpt: props.initialPost.excerpt || '',
content: normalizeMarkdownContent(props.initialPost.content), content: normalizeMarkdownContent(props.initialPost.content),
featuredImage: props.initialPost.featuredImage || '', featuredImage: props.initialPost.featuredImage || '',
isFeatured: Boolean(props.initialPost.isFeatured),
noindex: Boolean(props.initialPost.noindex), noindex: Boolean(props.initialPost.noindex),
status: props.initialPost.status || 'draft', status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt), publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
@@ -301,6 +302,7 @@ const createPostPayload = () => {
excerpt: form.excerpt.trim(), excerpt: form.excerpt.trim(),
content: normalizeMarkdownContent(form.content), content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage.trim() || null, featuredImage: form.featuredImage.trim() || null,
isFeatured: form.isFeatured,
seoTitle: form.title.trim(), seoTitle: form.title.trim(),
seoDescription: form.excerpt.trim(), seoDescription: form.excerpt.trim(),
canonicalUrl: '', canonicalUrl: '',
@@ -330,6 +332,7 @@ const createAutosavePayload = () => ({
excerpt: form.excerpt, excerpt: form.excerpt,
content: normalizeMarkdownContent(form.content), content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage, featuredImage: form.featuredImage,
isFeatured: form.isFeatured,
noindex: form.noindex, noindex: form.noindex,
status: form.status, status: form.status,
publishedAt: form.publishedAt, publishedAt: form.publishedAt,
@@ -347,7 +350,8 @@ const isEmptyAutosavePayload = (payload) => ![
payload.excerpt, payload.excerpt,
payload.content, payload.content,
payload.featuredImage, payload.featuredImage,
payload.tagsText payload.tagsText,
payload.isFeatured ? 'featured' : ''
].some((value) => String(value || '').trim()) ].some((value) => String(value || '').trim())
/** /**
@@ -1026,6 +1030,29 @@ defineExpose({
</label> </label>
</div> </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 class="admin-post-form__search-visibility grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
<div> <div>
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink"> <h2 class="admin-post-form__section-title text-sm font-semibold text-ink">

View File

@@ -19,6 +19,9 @@ const { data: navigation } = await useFetch('/api/navigation', {
}) })
}) })
/** 저자 영역 공개 여부 */
const showAuthorSection = false
const STORAGE_KEY = 'sori-primary-nav-expanded' const STORAGE_KEY = 'sori-primary-nav-expanded'
/** /**
@@ -173,7 +176,7 @@ onMounted(() => {
</div> </div>
</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"> <div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
<span>Authors</span> <span>Authors</span>
<span></span> <span></span>

View File

@@ -17,6 +17,9 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
copyrightText: '©2026 sori.studio' copyrightText: '©2026 sori.studio'
}) })
}) })
/** 소개 영역 공개 여부 */
const showAboutSection = false
</script> </script>
<template> <template>
@@ -176,7 +179,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</div> </div>
</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"> <p class="right-sidebar__about text-sm leading-6 site-muted">
{{ siteSettings.description }} {{ siteSettings.description }}
</p> </p>

View File

@@ -8,8 +8,7 @@ const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', { const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({ default: () => ({
title: 'sori.studio', title: 'sori.studio'
logoUrl: ''
}) })
}) })
@@ -186,12 +185,6 @@ onBeforeUnmount(() => {
</svg> </svg>
</span> </span>
</button> </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> <span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink> </NuxtLink>
</div> </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,16 @@
# 업데이트 요약 # 업데이트 요약
## v1.1.9
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
## v1.1.8
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
## v1.1.7 ## v1.1.7
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임. - 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.

View File

@@ -136,6 +136,9 @@ cp .env.example .env.production
# Docker 빌드 및 실행 # Docker 빌드 및 실행
docker compose --env-file .env.production up -d --build 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 네트워크 충돌 대응 ### Docker 네트워크 충돌 대응

View File

@@ -1,5 +1,21 @@
# 의사결정 이력 # 의사결정 이력
## 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 ## 2026-05-15 v1.1.7
### 사이트 로고 파일명을 교체마다 고유하게 저장 ### 사이트 로고 파일명을 교체마다 고유하게 저장

View File

@@ -50,11 +50,11 @@
| 파일 | 화면 위치 | | 파일 | 화면 위치 |
|------|-----------| |------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) | | 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/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/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/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 | | components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/TagHeader.vue | 태그 페이지 헤더 | | 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/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 | | 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 표시 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
@@ -107,7 +107,7 @@
|------|------| |------|------|
| pages/admin/index.vue | 대시보드 | | pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) | | pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 | | pages/admin/posts/index.vue | 글 목록, 상태·태그·최신순/오래된순 필터, 예약/초안/발행 텍스트 상태 표시, 제목 옆 댓글 수, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 | | pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 | | pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) | | pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
@@ -116,7 +116,7 @@
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 | | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 | | pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) | | pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
@@ -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/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 | | pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 | | pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
@@ -201,7 +201,7 @@
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API | | server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API | | server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API | | server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-*.webp`, `/uploads/system/favicon-*.png` 생성, `시스템` 미디어 메타 저장) | | 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.get.js | 관리자 네비게이션 목록 API |
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API | | server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API | | server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |

View File

@@ -51,6 +51,7 @@
### 홈 Featured (인덱스) ### 홈 Featured (인덱스)
- 가로 카드 트랙은 `overflow-x-auto``snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다. - 가로 카드 트랙은 `overflow-x-auto``snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화. - 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다. - 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
@@ -220,6 +221,7 @@ components/content/
| content | Text | 마크다운 콘텐츠 | | content | Text | 마크다운 콘텐츠 |
| excerpt | String | 요약 | | excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 | | featured_image | String nullable | 대표 이미지 |
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
| seo_title | String | SEO 제목 | | seo_title | String | SEO 제목 |
| seo_description | String | SEO 설명 | | seo_description | String | SEO 설명 |
| canonical_url | String | canonical URL | | canonical_url | String | canonical URL |
@@ -230,6 +232,8 @@ components/content/
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 | | updated_at | DateTime | 수정일 |
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
### Users ### Users
| 필드 | 타입 | 설명 | | 필드 | 타입 | 설명 |
@@ -445,8 +449,8 @@ components/content/
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다. > 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다. > 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다. > 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다. > 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다. > 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다. > 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다. > 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다. > 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
@@ -550,7 +554,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다. - 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다. - 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-*.webp``/uploads/system/favicon-*.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다. - 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다. - 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다. - 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다. - DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
@@ -612,8 +616,8 @@ components/content/
/uploads/posts/YYYY/MM/filename.webp /uploads/posts/YYYY/MM/filename.webp
/uploads/pages/YYYY/MM/filename.webp /uploads/pages/YYYY/MM/filename.webp
/uploads/members/avatars/YYYY/MM/filename.webp /uploads/members/avatars/YYYY/MM/filename.webp
/uploads/system/logo-YYYYMMDDTHHMMSS-random.webp /uploads/system/logo-YYYYMM-random.webp
/uploads/system/favicon-YYYYMMDDTHHMMSS-random.png /uploads/system/favicon-YYYYMM-random.png
``` ```
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다. - 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.

View File

@@ -1,5 +1,24 @@
# 업데이트 이력 # 업데이트 이력
## v1.1.9
- 관리자 글 목록에 상태·태그·최신순/오래된순 필터 추가.
- 관리자 글 목록 상태 표시를 배지에서 단순 텍스트 색상 기준으로 정리하고 제목 옆 댓글 수 표시 추가.
- 게시물 추천 여부(`is_featured`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.
- 홈 Featured 영역을 추천 글이 있을 때만 표시하고, 최신 글의 번개 표시는 실제 추천 글에만 나오도록 수정.
- 공개 목록·상세의 댓글 수 표시를 API 댓글 집계값 기준으로 정리.
- 공개 헤더의 이미지 로고 주석 코드를 제거하고 사이트 이름 텍스트만 표시하도록 정리.
- 왼쪽 사이드바 Authors 영역과 오른쪽 사이드바 About 영역을 비공개 처리.
- 패키지 버전 `1.1.9`로 갱신.
## v1.1.8
- 사이트 로고·파비콘 고유 파일명 접미사를 년월+랜덤 문자열 형식으로 간소화.
- 관리자 태그 관리 화면의 `태그 추가` 버튼을 일반 태그 섹션 헤더 오른쪽으로 이동.
- 메인 태그 `정렬 저장` 버튼을 제거하고 드래그 드롭 직후 자동 저장되도록 수정.
- 메인 태그 순서 자동 저장 중 추가 드래그를 막고 저장 상태를 표시하도록 정리.
- 패키지 버전 `1.1.8`로 갱신.
## v1.1.7 ## v1.1.7
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정. - 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -5,11 +5,18 @@ definePageMeta({
const deletingId = ref('') const deletingId = ref('')
const errorMessage = 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', { const { data: posts, refresh } = await useFetch('/admin/api/posts', {
default: () => [] default: () => []
}) })
const { data: tags } = await useFetch('/admin/api/tags', {
default: () => []
})
/** /**
* 날짜 표시 형식 변환 * 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열 * @param {string | null} value - ISO 날짜 문자열
@@ -36,27 +43,106 @@ const formatDate = (value) => {
const isPublicPost = (post) => post.status === 'published' const isPublicPost = (post) => post.status === 'published'
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date()) && (!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 - 게시물 * @param {Object} post - 게시물
* @returns {string} 상태 표시 문자열 * @returns {string} 상태 표시 문자열
*/ */
const getPostStatusLabel = (post) => { const getPostStatusLabel = (post) => {
if (post.status === 'published' && !isPublicPost(post)) { const statusKey = getPostStatusKey(post)
if (statusKey === 'scheduled') {
return '예약' return '예약'
} }
if (post.status === 'published') { if (statusKey === 'published') {
return '발행' return '발행'
} }
if (post.status === 'private') { if (statusKey === 'private') {
return '비공개' return '비공개'
} }
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 - 삭제할 게시물 * @param {Object} post - 삭제할 게시물
@@ -99,6 +185,34 @@ const deletePost = async (post) => {
</NuxtLink> </NuxtLink>
</div> </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"> <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 }} {{ errorMessage }}
</p> </p>
@@ -115,24 +229,24 @@ const deletePost = async (post) => {
</tr> </tr>
</thead> </thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white"> <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"> <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}`"> <div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
{{ post.title }} <NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
</NuxtLink> {{ 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"> <p class="admin-posts__slug mt-1 text-xs text-muted">
/post/{{ post.slug }} /post/{{ post.slug }}
</p> </p>
</td> </td>
<td class="admin-posts__cell px-4 py-4"> <td class="admin-posts__cell px-4 py-4">
<span <span
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold" class="admin-posts__status-text text-xs"
:class="{ :class="getPostStatusClass(post)"
'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) === '비공개'
}"
> >
{{ getPostStatusLabel(post) }} {{ getPostStatusLabel(post) }}
</span> </span>
@@ -180,8 +294,8 @@ const deletePost = async (post) => {
</tbody> </tbody>
</table> </table>
</div> </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> </p>
</section> </section>
</template> </template>

View File

@@ -54,7 +54,7 @@ const filteredGeneralTags = computed(() => {
) )
}) })
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */ /** 서버 기준 메인 태그 id 순서(자동 저장 필요 여부 비교용) */
const baselineManagedTagIds = ref([]) const baselineManagedTagIds = ref([])
/** /**
@@ -77,7 +77,7 @@ const refreshTagsFromServer = async () => {
resetManagedOrderBaseline() resetManagedOrderBaseline()
/** /**
* 메인 태그 드래그 순서가 기준선과 다른지 여부 * 메인 태그 드래그 순서가 서버 기준선과 다른지 여부
* @returns {boolean} 변경 여부 * @returns {boolean} 변경 여부
*/ */
const isManagedOrderDirty = computed(() => { const isManagedOrderDirty = computed(() => {
@@ -116,6 +116,11 @@ const showToast = (type, message) => {
* @returns {void} * @returns {void}
*/ */
const handleDragStart = (event, tagId) => { const handleDragStart = (event, tagId) => {
if (savingOrder.value) {
event.preventDefault()
return
}
if (!event.dataTransfer) { if (!event.dataTransfer) {
return return
} }
@@ -130,6 +135,10 @@ const handleDragStart = (event, tagId) => {
* @returns {void} * @returns {void}
*/ */
const handleDragOver = (event, tagId) => { const handleDragOver = (event, tagId) => {
if (savingOrder.value) {
return
}
event.preventDefault() event.preventDefault()
dragOverTagId.value = tagId dragOverTagId.value = tagId
} }
@@ -172,15 +181,17 @@ const moveManagedTag = (sourceId, targetId) => {
* 관리용 태그 드롭 처리 * 관리용 태그 드롭 처리
* @param {DragEvent} event - 드래그 이벤트 * @param {DragEvent} event - 드래그 이벤트
* @param {string} targetId - 대상 태그 ID * @param {string} targetId - 대상 태그 ID
* @returns {void} * @returns {Promise<void>}
*/ */
const handleDrop = (event, targetId) => { const handleDrop = async (event, targetId) => {
event.preventDefault() event.preventDefault()
if (!draggingTagId.value) { if (!draggingTagId.value || savingOrder.value) {
return return
} }
moveManagedTag(draggingTagId.value, targetId) moveManagedTag(draggingTagId.value, targetId)
handleDragEnd() handleDragEnd()
await nextTick()
await saveManagedOrder()
} }
/** /**
@@ -204,9 +215,10 @@ const saveManagedOrder = async () => {
tags.value = [...reordered] tags.value = [...reordered]
await refreshTagsFromServer() await refreshTagsFromServer()
showToast('success', '메인 태그 순서가 저장되었습니다.') showToast('success', '메인 태그 순서가 자동 저장되었습니다.')
} catch (error) { } catch (error) {
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.') showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
await refreshTagsFromServer()
} finally { } finally {
savingOrder.value = false savingOrder.value = false
} }
@@ -328,7 +340,7 @@ onBeforeUnmount(() => {
<template> <template>
<section class="admin-tags bg-paper p-6"> <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> <div>
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted"> <p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
Tags Tags
@@ -337,9 +349,6 @@ onBeforeUnmount(() => {
태그 관리 태그 관리
</h1> </h1>
</div> </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> </div>
<p class="mt-3 text-xs text-muted"> <p class="mt-3 text-xs text-muted">
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다. 메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
@@ -348,14 +357,10 @@ onBeforeUnmount(() => {
<div class="admin-tags__table mt-6 overflow-hidden border border-line"> <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"> <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> <p class="text-xs font-semibold uppercase text-muted">메인 태그</p>
<button <span v-if="savingOrder" class="inline-flex items-center gap-2 text-xs font-semibold text-muted">
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50" <span class="size-3 animate-spin rounded-full border-2 border-line border-t-[#15171a]" />
type="button" 저장
:disabled="savingOrder || managedTags.length === 0 || !isManagedOrderDirty" </span>
@click="saveManagedOrder"
>
{{ savingOrder ? '저장 중' : '정렬 저장' }}
</button>
</div> </div>
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm"> <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"> <thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
@@ -372,12 +377,13 @@ onBeforeUnmount(() => {
<tr <tr
v-for="(tag, index) in managedTags" v-for="(tag, index) in managedTags"
:key="tag.id" :key="tag.id"
class="admin-tags__row cursor-move" class="admin-tags__row"
:class="[ :class="[
dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '', 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)" @dragstart="handleDragStart($event, tag.id)"
@dragover="handleDragOver($event, tag.id)" @dragover="handleDragOver($event, tag.id)"
@drop="handleDrop($event, tag.id)" @drop="handleDrop($event, tag.id)"
@@ -422,8 +428,11 @@ onBeforeUnmount(() => {
</div> </div>
<div class="admin-tags__table mt-8 overflow-hidden border border-line"> <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"> <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> <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>
<div class="space-y-3 bg-white p-4"> <div class="space-y-3 bg-white p-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between"> <div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">

View File

@@ -71,10 +71,9 @@ const getTagMeta = (slug) => {
/** /**
* Latest 목록 데이터 변환 * Latest 목록 데이터 변환
* @param {Object} post - API 게시물 * @param {Object} post - API 게시물
* @param {number} index - 목록 인덱스
* @returns {Object} 화면 표시 데이터 * @returns {Object} 화면 표시 데이터
*/ */
const mapLatestPost = (post, index) => { const mapLatestPost = (post) => {
const primaryTagSlug = post.tags?.[0] const primaryTagSlug = post.tags?.[0]
const tagMeta = getTagMeta(primaryTagSlug) const tagMeta = getTagMeta(primaryTagSlug)
@@ -87,11 +86,12 @@ const mapLatestPost = (post, index) => {
publishedAt: formatPostDate(post.publishedAt), publishedAt: formatPostDate(post.publishedAt),
publishedAtIso: post.publishedAt || '', publishedAtIso: post.publishedAt || '',
to: `/post/${post.slug}`, 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 latestPosts = computed(() => posts.value.map(mapLatestPost))
const featuredTrackRef = ref(null) const featuredTrackRef = ref(null)
@@ -233,7 +233,7 @@ const scrollFeatured = (direction) => {
</div> </div>
</section> </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="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2"> <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> <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"> <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" /> <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> </svg>
<span>0</span> <span>{{ post.commentCount }}</span>
</span> </span>
</div> </div>
</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"> <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" /> <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> </svg>
<span class="pointer-events-none">0</span> <span class="pointer-events-none">{{ post.commentCount || 0 }}</span>
</a> </a>
</div> </div>

View File

@@ -14,13 +14,14 @@ const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
const tagPosts = computed(() => posts.value const tagPosts = computed(() => posts.value
.filter((post) => post.tags.includes(slug.value)) .filter((post) => post.tags.includes(slug.value))
.map((post, index) => ({ .map((post) => ({
title: post.title, title: post.title,
excerpt: post.excerpt, excerpt: post.excerpt,
featuredImage: post.featuredImage, featuredImage: post.featuredImage,
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(), 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', 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), publishedAt: formatPostDate(post.publishedAt),
publishedAtIso: post.publishedAt || '', publishedAtIso: post.publishedAt || '',
to: `/post/${post.slug}` 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"> <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" /> <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> </svg>
<span>0</span> <span>{{ post.commentCount }}</span>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -22,6 +22,8 @@ const mapPostRow = (row) => ({
content: row.content, content: row.content,
excerpt: row.excerpt, excerpt: row.excerpt,
featuredImage: row.featured_image, featuredImage: row.featured_image,
isFeatured: Boolean(row.is_featured),
commentCount: Number(row.comment_count || 0),
seoTitle: row.seo_title || '', seoTitle: row.seo_title || '',
seoDescription: row.seo_description || '', seoDescription: row.seo_description || '',
canonicalUrl: row.canonical_url || '', canonicalUrl: row.canonical_url || '',
@@ -193,6 +195,12 @@ export const listPosts = async () => {
const rows = await sql` const rows = await sql`
SELECT SELECT
posts.*, 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 COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN post_tags ON post_tags.post_id = posts.id
@@ -223,6 +231,12 @@ export const listAdminPosts = async () => {
const rows = await sql` const rows = await sql`
SELECT SELECT
posts.*, 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 COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN post_tags ON post_tags.post_id = posts.id
@@ -249,6 +263,12 @@ export const getAdminPostById = async (id) => {
const rows = await sql` const rows = await sql`
SELECT SELECT
posts.*, 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 COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN post_tags ON post_tags.post_id = posts.id
@@ -281,6 +301,7 @@ export const createAdminPost = async (input) => {
content, content,
excerpt, excerpt,
featured_image, featured_image,
is_featured,
seo_title, seo_title,
seo_description, seo_description,
canonical_url, canonical_url,
@@ -295,6 +316,7 @@ export const createAdminPost = async (input) => {
${input.content}, ${input.content},
${input.excerpt}, ${input.excerpt},
${input.featuredImage}, ${input.featuredImage},
${input.isFeatured},
${input.seoTitle}, ${input.seoTitle},
${input.seoDescription}, ${input.seoDescription},
${input.canonicalUrl}, ${input.canonicalUrl},
@@ -336,6 +358,7 @@ export const updateAdminPost = async (id, input) => {
content = ${input.content}, content = ${input.content},
excerpt = ${input.excerpt}, excerpt = ${input.excerpt},
featured_image = ${input.featuredImage}, featured_image = ${input.featuredImage},
is_featured = ${input.isFeatured},
seo_title = ${input.seoTitle}, seo_title = ${input.seoTitle},
seo_description = ${input.seoDescription}, seo_description = ${input.seoDescription},
canonical_url = ${input.canonicalUrl}, canonical_url = ${input.canonicalUrl},
@@ -396,6 +419,12 @@ export const getPostBySlug = async (slug) => {
const rows = await sql` const rows = await sql`
SELECT SELECT
posts.*, 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 COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id LEFT JOIN post_tags ON post_tags.post_id = posts.id

View File

@@ -35,9 +35,13 @@ const clampNumber = (value, minimum, maximum) => {
* 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다. * 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다.
* @returns {string} 파일명 접미사 * @returns {string} 파일명 접미사
*/ */
const createSystemAssetSuffix = () => `${new Date().toISOString() const createSystemAssetSuffix = () => {
.replace(/[-:]/g, '') const now = new Date()
.replace(/\.\d{3}Z$/g, '')}-${Math.random().toString(36).slice(2, 8)}` const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
}
/** /**
* 사이트 로고 업로드 API * 사이트 로고 업로드 API

View File

@@ -8,6 +8,7 @@ export const adminPostInputSchema = z.object({
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''), content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
excerpt: z.string().default(''), excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null), featuredImage: z.string().trim().nullable().default(null),
isFeatured: z.boolean().default(false),
seoTitle: z.string().trim().default(''), seoTitle: z.string().trim().default(''),
seoDescription: z.string().trim().default(''), seoDescription: z.string().trim().default(''),
canonicalUrl: z.string().trim().url().or(z.literal('')).default(''), canonicalUrl: z.string().trim().url().or(z.literal('')).default(''),

View File

@@ -9,6 +9,8 @@ export const postSchema = z.object({
content: z.string(), content: z.string(),
excerpt: z.string().default(''), excerpt: z.string().default(''),
featuredImage: z.string().nullable().default(null), featuredImage: z.string().nullable().default(null),
isFeatured: z.boolean().default(false),
commentCount: z.number().int().default(0),
seoTitle: z.string().default(''), seoTitle: z.string().default(''),
seoDescription: z.string().default(''), seoDescription: z.string().default(''),
canonicalUrl: z.string().default(''), canonicalUrl: z.string().default(''),