Compare commits
8 Commits
v1.1.0_글쓰기
...
v1.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 59a50a0c97 | |||
| b4e4e37f5a | |||
| 536ee7079e | |||
| 9e544d97fa | |||
| 20b901d4a1 | |||
| 0ed848a2eb | |||
| 08f0aa0efa | |||
| 17dcd04339 |
@@ -216,6 +216,27 @@
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 등 행 호버 — 라이트 테마에서 밝은 크림 톤, 다크는 패널 대비 유지
|
||||
*/
|
||||
.site-sidebar-nav-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-sidebar-nav-row:hover {
|
||||
background-color: #f7f4ef;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .site-sidebar-nav-row:hover {
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) .site-sidebar-nav-row:hover {
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
|
||||
*/
|
||||
|
||||
@@ -191,12 +191,23 @@ const uploadAvatar = async (event) => {
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('files', file)
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
formData.append('file', file)
|
||||
const result = isNewMember.value
|
||||
? await $fetch('/admin/api/member-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.files?.[0]?.url || ''
|
||||
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.avatarUrl || ''
|
||||
|
||||
if (!isNewMember.value) {
|
||||
emit('saved', result)
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
saveMessage.value = '썸네일이 변경되었습니다.'
|
||||
}
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||
} finally {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -647,7 +647,7 @@ const showNextImage = () => {
|
||||
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||
><code>{{ block.text }}</code></pre>
|
||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base leading-7 text-[var(--site-text)] last:mb-0">
|
||||
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
||||
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||
<br v-if="lineIndex > 0">
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||
|
||||
@@ -158,7 +158,7 @@ onMounted(() => {
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="left-sidebar__category site-panel-hover group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
||||
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
@@ -206,7 +206,7 @@ onMounted(() => {
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button
|
||||
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
||||
class="left-sidebar__theme-dot site-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
||||
type="button"
|
||||
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||
|
||||
@@ -28,7 +28,7 @@ defineProps({
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<p class="post-card__meta mt-2 text-xs site-muted">
|
||||
{{ post.publishedAt }} / {{ post.tag }}
|
||||
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,9 @@ const navBarBeforeInactive =
|
||||
const navBarBeforeActive =
|
||||
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
|
||||
|
||||
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
/** 행 공통: site-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
const navRowShell =
|
||||
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
|
||||
'site-sidebar-nav-row flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
|
||||
|
||||
/**
|
||||
* 노드가 펼쳐져 있는지
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
|
||||
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
|
||||
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
|
||||
- 태그 목록 카드 그리드 여백 수정 반영.
|
||||
|
||||
## v1.0.19
|
||||
|
||||
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
||||
|
||||
@@ -323,7 +323,8 @@ docker compose --env-file .env.production restart sori-studio-db
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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
|
||||
|
||||
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
||||
|
||||
Nuxt 운영 빌드는 `public/`을 빌드 시점에 `.output/public`으로 복사해 정적 파일로 제공한다. 반면 Docker 운영 업로드는 컨테이너 실행 중 `/app/public/uploads` 볼륨에 기록되므로, 새 파일이 `.output/public` 스냅샷에 없으면 업로드 직후 이미지가 깨져 보일 수 있다. 업로드 파일은 사용자 콘텐츠이자 런타임 데이터이므로 빌드 산출물에 의존하지 않고 `/uploads/**` 요청을 `public/uploads`에서 직접 스트리밍하도록 결정했다.
|
||||
|
||||
## 2026-05-15 v1.1.4
|
||||
|
||||
### 관리자 멤버 썸네일 업로드 경로 분리
|
||||
|
||||
회원 프로필 썸네일은 관리자 계정인지 일반 회원인지와 무관하게 회원 자산이므로 `/uploads/members/avatars`에 저장해야 한다. 관리자 멤버 편집 화면이 공용 게시물 이미지 업로드 API를 사용하면 `/uploads/posts`에 저장되어 미디어 분류와 썸네일 생명주기 규칙이 어긋난다. 회원 설정 업로드와 관리자 멤버 업로드가 같은 검증·WebP 변환·1:1 크롭 로직을 쓰도록 공통 유틸로 분리하고, 관리자 멤버 화면은 회원 전용 업로드 API를 사용하도록 정리했다.
|
||||
|
||||
## 2026-05-13 v1.1.3
|
||||
|
||||
### 사이드바 행 호버 배경 분리
|
||||
|
||||
전역 `site-panel-hover`는 패널과 텍스트 색을 `color-mix`해 라이트에서도 호버가 진하게 느껴진다. 카드·태그 목록 등 다른 패널은 기존 대비를 유지하고, 왼쪽 사이드바 네비·카테고리·테마 점만 `site-sidebar-nav-row`로 분리해 라이트에서 `#F7F4EF`로 완화했다. 다크에서는 가독성을 위해 기존과 동일한 `color-mix` 호버를 유지한다.
|
||||
|
||||
## 2026-05-14 v1.1.2
|
||||
|
||||
### 태그 없을 때 “POST” 더미 표시 제거
|
||||
|
||||
태그 배열이 비어 있을 때 UI 폴백으로 `POST` 문자열을 넣어 두어, 사용자는 실제 태그가 붙은 것으로 오해했다. 저장 데이터와 무관한 표시이므로 슬러그가 있을 때만 첫 태그를 노출하고 없으면 태그 영역을 렌더하지 않는다.
|
||||
|
||||
## 2026-05-14 v1.1.1
|
||||
|
||||
### 공개 문단 행간 기본값으로 복귀
|
||||
|
||||
문단 글자 크기만 16px(`text-base`)로 고정하고 행간은 `leading-7` 대신 Tailwind·브라우저 기본(`leading-normal` 계열)에 맡긴다.
|
||||
|
||||
## 2026-05-14 v1.1.0
|
||||
|
||||
### 관리자 제목·공개 본문 타이포 마이너 조정
|
||||
|
||||
29
docs/map.md
29
docs/map.md
@@ -43,6 +43,7 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
|
||||
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
@@ -51,11 +52,11 @@
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `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` |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `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/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
@@ -83,7 +84,7 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base·leading-7, 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
@@ -113,12 +114,12 @@
|
||||
| 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 | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
@@ -127,10 +128,10 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| 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/링크복사), 회원 댓글 섹션 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
@@ -190,7 +191,8 @@
|
||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
@@ -199,18 +201,21 @@
|
||||
| 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 |
|
||||
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
||||
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
||||
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
|
||||
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
|
||||
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
|
||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
|
||||
34
docs/spec.md
34
docs/spec.md
@@ -185,7 +185,7 @@ components/content/
|
||||
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
||||
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
|
||||
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
|
||||
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기·행간은 `ContentMarkdownRenderer` 문단에 `text-base`·`leading-7`을 적용한다.
|
||||
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기는 `ContentMarkdownRenderer` 문단에 `text-base`(16px·`1rem`)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다.
|
||||
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
|
||||
- 카드류
|
||||
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||
@@ -395,6 +395,10 @@ components/content/
|
||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||
|
||||
### 업로드 파일 제공
|
||||
|
||||
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
@@ -416,7 +420,8 @@ components/content/
|
||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링)
|
||||
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
@@ -430,18 +435,21 @@ components/content/
|
||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||
> 태그 삭제 시 `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` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
@@ -506,6 +514,7 @@ components/content/
|
||||
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
@@ -541,7 +550,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 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
@@ -556,7 +565,7 @@ components/content/
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
### 관리자 인증
|
||||
@@ -603,13 +612,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`만 허용한다.
|
||||
@@ -626,7 +636,7 @@ components/content/
|
||||
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
@@ -1,9 +1,64 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## 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/**` 서버 라우트 추가.
|
||||
- Docker 운영 이미지의 `.output/public` 빌드 시점 스냅샷에 의존하지 않고 새로 업로드한 로고·게시물 이미지·회원 썸네일이 즉시 표시되도록 수정.
|
||||
- 패키지 버전 `1.1.5`로 갱신.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 게시물용 `/uploads/posts`가 아니라 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 회원 썸네일 업로드 검증·WebP 변환·1:1 크롭 로직을 공통 유틸로 분리.
|
||||
- 관리자 멤버 편집 전용 썸네일 업로드 API와 새 멤버 생성 전 썸네일 사전 업로드 API 추가.
|
||||
- 관리자 회원 기본 정보 저장에서 기존 회원 전용 썸네일 URL이 교체·제거되면 `media_metadata` 연결을 분리하도록 정리.
|
||||
- 태그 목록 카드 그리드에 사용자 수정 `px-6` 반영.
|
||||
- 패키지 버전 `1.1.4`로 갱신.
|
||||
|
||||
## v1.1.3
|
||||
|
||||
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.
|
||||
- 패키지 버전 `1.1.3`으로 갱신.
|
||||
|
||||
## v1.1.2
|
||||
|
||||
- 태그가 없는 게시물에 기본값으로 보이던 `POST` 표기 제거: 공개 상세·홈 Latest·게시물 목록 카드에서 태그가 있을 때만 배지·메타에 표시.
|
||||
- 패키지 버전 `1.1.2`로 갱신.
|
||||
|
||||
## v1.1.1
|
||||
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단에서 `leading-7`을 제거하고 `text-base`(16px)만 적용.
|
||||
- 패키지 버전 `1.1.1`로 갱신.
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- 관리자 글 작성 폼 제목 입력 타이포를 `text-5xl`에서 `text-3xl`로 조정.
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단을 `text-base leading-7` 기준으로 조정(기존 `text-[15px] leading-4` 대비 크기·행간 정리).
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단을 `text-base`·`leading-7` 기준으로 조정(기존 `text-[15px] leading-4` 대비 크기·행간 정리).
|
||||
- `ProseHeading`에서 제목 블록 상단 `mt-12` 제거로 제목·본문 간 세로 리듬 정리.
|
||||
- 패키지 버전 `1.1.0`으로 갱신.
|
||||
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.19",
|
||||
"version": "1.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.19",
|
||||
"version": "1.1.8",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -9929,7 +9929,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob": {
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
|
||||
<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"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('recent')"
|
||||
>
|
||||
{{ generalTagSearchLoading ? '검색 중' : '검색' }}
|
||||
최근 사용순
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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 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 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 border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
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 ? '전환 중' : '메인 태그로 전환' }}
|
||||
{{ 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-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>
|
||||
|
||||
@@ -53,10 +53,17 @@ const onDocumentPointerDown = (event) => {
|
||||
* @returns {{name: string, color: string}} 태그 정보
|
||||
*/
|
||||
const getTagMeta = (slug) => {
|
||||
if (!slug) {
|
||||
return {
|
||||
name: '',
|
||||
color: '#4d4d4d'
|
||||
}
|
||||
}
|
||||
|
||||
const matchedTag = tags.value.find((item) => item.slug === slug)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (slug ? slug.toUpperCase() : 'POST'),
|
||||
name: matchedTag?.name || String(slug).toUpperCase(),
|
||||
color: matchedTag?.color || '#4d4d4d'
|
||||
}
|
||||
}
|
||||
@@ -454,14 +461,16 @@ const scrollFeatured = (direction) => {
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<template v-if="post.tagName">
|
||||
<span v-if="post.publishedAt" class="text-[var(--site-line)]">/</span>
|
||||
<span
|
||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
>
|
||||
{{ post.tagName }}
|
||||
</span>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
</template>
|
||||
<span v-if="post.publishedAt || post.tagName" class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<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" />
|
||||
|
||||
@@ -23,12 +23,22 @@ if (!post.value) {
|
||||
|
||||
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
||||
const primaryTagMeta = computed(() => {
|
||||
const matchedTag = tags.value.find((item) => item.slug === primaryTagSlug.value)
|
||||
const slug = primaryTagSlug.value
|
||||
|
||||
if (!slug) {
|
||||
return {
|
||||
name: '',
|
||||
color: '',
|
||||
to: '/tags'
|
||||
}
|
||||
}
|
||||
|
||||
const matchedTag = tags.value.find((item) => item.slug === slug)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (primaryTagSlug.value ? primaryTagSlug.value.toUpperCase() : 'POST'),
|
||||
name: matchedTag?.name || slug.toUpperCase(),
|
||||
color: matchedTag?.color || '#4d4d4d',
|
||||
to: primaryTagSlug.value ? `/tag/${primaryTagSlug.value}` : '/tags'
|
||||
to: `/tag/${slug}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -228,8 +238,8 @@ useHead(() => ({
|
||||
{{ authorLabel }}
|
||||
</a>
|
||||
|
||||
<ul class="flex flex-wrap items-center font-medium">
|
||||
<li v-if="primaryTagMeta.name" :style="{ '--color-accent': primaryTagMeta.color }">
|
||||
<ul v-if="primaryTagSlug" class="flex flex-wrap items-center font-medium">
|
||||
<li :style="{ '--color-accent': primaryTagMeta.color }">
|
||||
<NuxtLink
|
||||
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
|
||||
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"
|
||||
|
||||
@@ -7,7 +7,7 @@ const postCards = computed(() => posts.value.map((post) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
||||
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})))
|
||||
|
||||
@@ -31,7 +31,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
|
||||
</section>
|
||||
|
||||
<section class="tags-page-list mb-8">
|
||||
<ul class="mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
||||
<ul class="px-6 mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
||||
<li
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
|
||||
@@ -1,82 +1,10 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { createError } from 'h3'
|
||||
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
|
||||
|
||||
const allowedImageTypes = new Map([
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/png', '.png'],
|
||||
['image/webp', '.webp'],
|
||||
['image/gif', '.gif']
|
||||
])
|
||||
|
||||
/**
|
||||
* 업로드 경로 조각을 URL 안전 문자열로 정리
|
||||
* @param {string} value - 원본 경로 조각
|
||||
* @returns {string} 정리된 경로 조각
|
||||
*/
|
||||
const sanitizePathPart = (value) => value
|
||||
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
/**
|
||||
* 숫자 설정값을 최소/최대 범위로 보정한다.
|
||||
* @param {number} value - 원본 값
|
||||
* @param {number} minimum - 최소값
|
||||
* @param {number} maximum - 최대값
|
||||
* @returns {number} 보정된 값
|
||||
*/
|
||||
const clampNumber = (value, minimum, maximum) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
if (value < minimum) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
if (value > maximum) {
|
||||
return maximum
|
||||
}
|
||||
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다. 동일 stem이 있으면 `-2`, `-3` 넘버링한다.
|
||||
* @param {string} directoryPath - 저장 디렉터리 절대 경로
|
||||
* @param {string} stem - 확장자 제외 파일명
|
||||
* @returns {Promise<{ fileName: string, filePath: string }>} 파일명과 절대 경로
|
||||
*/
|
||||
const pickUniqueWebpFileName = async (directoryPath, stem) => {
|
||||
let suffix = 1
|
||||
|
||||
while (suffix < 10000) {
|
||||
const fileName = suffix === 1 ? `${stem}.webp` : `${stem}-${suffix}.webp`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
|
||||
try {
|
||||
await stat(filePath)
|
||||
suffix += 1
|
||||
} catch {
|
||||
return {
|
||||
fileName,
|
||||
filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '저장할 고유 파일명을 만들 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 썸네일 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -92,80 +20,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||
const avatarMinWidth = clampNumber(Number(config.avatarMinWidth || 96), 1, 4096)
|
||||
const avatarMinHeight = clampNumber(Number(config.avatarMinHeight || 96), 1, 4096)
|
||||
const avatarMaxWidth = clampNumber(Number(config.avatarMaxWidth || 512), avatarMinWidth, 4096)
|
||||
const avatarMaxHeight = clampNumber(Number(config.avatarMaxHeight || 512), avatarMinHeight, 4096)
|
||||
const avatarSquareSize = Math.min(avatarMaxWidth, avatarMaxHeight)
|
||||
const avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 이미지가 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!allowedImageTypes.has(file.type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 파일만 업로드할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > maxFileSize) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
|
||||
const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem)
|
||||
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
|
||||
const metadata = await sharp(file.data).metadata()
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
|
||||
})
|
||||
}
|
||||
|
||||
const resizedBuffer = await sharp(file.data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: avatarSquareSize,
|
||||
height: avatarSquareSize,
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.webp({
|
||||
quality: avatarWebpQuality
|
||||
})
|
||||
.toBuffer()
|
||||
|
||||
await writeFile(filePath, resizedBuffer)
|
||||
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||
|
||||
await updateMemberProfile({
|
||||
userId: session.userId,
|
||||
@@ -183,4 +38,3 @@ export default defineEventHandler(async (event) => {
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -61,7 +61,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
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -572,7 +575,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 +594,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}
|
||||
`
|
||||
|
||||
@@ -678,6 +678,25 @@ export const updateMemberByAdmin = async (input) => {
|
||||
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 썸네일만 수정한다.
|
||||
* @param {{ memberId: string, avatarUrl: string }} input - 수정 값
|
||||
* @returns {Promise<Object | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberAvatarByAdmin = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
avatar_url = ${input.avatarUrl},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.memberId}
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 기준 관리자 회원 조회
|
||||
* @param {string} email - 이메일
|
||||
|
||||
19
server/routes/admin/api/member-avatar.post.js
Normal file
19
server/routes/admin/api/member-avatar.post.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { uploadMemberAvatarImage } from '../../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 새 회원용 썸네일 사전 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||
|
||||
return {
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
||||
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||
|
||||
const memberInputSchema = z.object({
|
||||
username: z.string().trim().min(1).max(60),
|
||||
@@ -76,5 +77,9 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (existing.avatarUrl && existing.avatarUrl !== updated.avatarUrl) {
|
||||
await removeManagedAvatarAsset(existing.avatarUrl)
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
52
server/routes/admin/api/members/[id]/avatar.post.js
Normal file
52
server/routes/admin/api/members/[id]/avatar.post.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, updateMemberAvatarByAdmin } from '../../../../../repositories/member-repository'
|
||||
import { removeManagedAvatarAsset } from '../../../../../utils/member-avatar'
|
||||
import { uploadMemberAvatarImage } from '../../../../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 회원 썸네일 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 수정된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const member = await getMemberForAdmin(memberId)
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||
const updated = await updateMemberAvatarByAdmin({
|
||||
memberId,
|
||||
avatarUrl
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 썸네일 수정에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||
|
||||
if (member.avatarUrl && member.avatarUrl !== avatarUrl) {
|
||||
await removeManagedAvatarAsset(member.avatarUrl)
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
|
||||
84
server/routes/uploads/[...path].get.js
Normal file
84
server/routes/uploads/[...path].get.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import { createError, getRequestURL, sendStream, setResponseHeader } from 'h3'
|
||||
|
||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||
const contentTypes = new Map([
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.png', 'image/png'],
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
['.svg', 'image/svg+xml'],
|
||||
['.ico', 'image/x-icon']
|
||||
])
|
||||
|
||||
/**
|
||||
* 업로드 요청 URL을 디스크 파일 경로로 변환한다.
|
||||
* @param {string} pathname - 요청 경로
|
||||
* @returns {string} 디스크 파일 경로
|
||||
*/
|
||||
const resolveUploadFilePath = (pathname) => {
|
||||
let decodedPath = ''
|
||||
|
||||
try {
|
||||
decodedPath = decodeURIComponent(pathname)
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드 파일 경로가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const relativeUrlPath = decodedPath.replace(/^\/uploads\/?/g, '')
|
||||
|
||||
if (!relativeUrlPath || relativeUrlPath.includes('\0')) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '업로드 파일을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const filePath = join(uploadRoot, relativeUrlPath)
|
||||
const relativeDiskPath = relative(uploadRoot, filePath)
|
||||
|
||||
if (relativeDiskPath.startsWith('..') || relativeDiskPath === '') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드 파일 경로가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임 업로드 파일 제공 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<void>} 업로드 파일 스트림
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const filePath = resolveUploadFilePath(getRequestURL(event).pathname)
|
||||
const fileStat = await stat(filePath).catch(() => null)
|
||||
|
||||
if (!fileStat?.isFile()) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '업로드 파일을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const extension = extname(filePath).toLowerCase()
|
||||
const contentType = contentTypes.get(extension)
|
||||
|
||||
if (contentType) {
|
||||
setResponseHeader(event, 'content-type', contentType)
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'content-length', String(fileStat.size))
|
||||
setResponseHeader(event, 'cache-control', 'no-cache')
|
||||
setResponseHeader(event, 'last-modified', fileStat.mtime.toUTCString())
|
||||
|
||||
return sendStream(event, createReadStream(filePath))
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
160
server/utils/member-avatar-upload.js
Normal file
160
server/utils/member-avatar-upload.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const allowedImageTypes = new Map([
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/png', '.png'],
|
||||
['image/webp', '.webp'],
|
||||
['image/gif', '.gif']
|
||||
])
|
||||
|
||||
/**
|
||||
* 업로드 경로 조각을 URL 안전 문자열로 정리한다.
|
||||
* @param {string} value - 원본 경로 조각
|
||||
* @returns {string} 정리된 경로 조각
|
||||
*/
|
||||
const sanitizePathPart = (value) => value
|
||||
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
/**
|
||||
* 숫자 설정값을 최소/최대 범위로 보정한다.
|
||||
* @param {number} value - 원본 값
|
||||
* @param {number} minimum - 최소값
|
||||
* @param {number} maximum - 최대값
|
||||
* @returns {number} 보정된 값
|
||||
*/
|
||||
const clampNumber = (value, minimum, maximum) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
if (value < minimum) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
if (value > maximum) {
|
||||
return maximum
|
||||
}
|
||||
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다.
|
||||
* @param {string} directoryPath - 저장 디렉터리 절대 경로
|
||||
* @param {string} stem - 확장자 제외 파일명
|
||||
* @returns {Promise<{ fileName: string, filePath: string }>} 파일명과 절대 경로
|
||||
*/
|
||||
const pickUniqueWebpFileName = async (directoryPath, stem) => {
|
||||
let suffix = 1
|
||||
|
||||
while (suffix < 10000) {
|
||||
const fileName = suffix === 1 ? `${stem}.webp` : `${stem}-${suffix}.webp`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
|
||||
try {
|
||||
await stat(filePath)
|
||||
suffix += 1
|
||||
} catch {
|
||||
return {
|
||||
fileName,
|
||||
filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '저장할 고유 파일명을 만들 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 썸네일 파일을 검증하고 회원 전용 경로에 저장한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ avatarUrl: string }>} 저장된 썸네일 URL
|
||||
*/
|
||||
export const uploadMemberAvatarImage = async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||
const avatarMinWidth = clampNumber(Number(config.avatarMinWidth || 96), 1, 4096)
|
||||
const avatarMinHeight = clampNumber(Number(config.avatarMinHeight || 96), 1, 4096)
|
||||
const avatarMaxWidth = clampNumber(Number(config.avatarMaxWidth || 512), avatarMinWidth, 4096)
|
||||
const avatarMaxHeight = clampNumber(Number(config.avatarMaxHeight || 512), avatarMinHeight, 4096)
|
||||
const avatarSquareSize = Math.min(avatarMaxWidth, avatarMaxHeight)
|
||||
const avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const file = (formData || []).find((part) => ['file', 'files'].includes(String(part.name || '')) && part.filename)
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 이미지가 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!allowedImageTypes.has(file.type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 파일만 업로드할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > maxFileSize) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const metadata = await sharp(file.data).metadata()
|
||||
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
|
||||
const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem)
|
||||
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
|
||||
const resizedBuffer = await sharp(file.data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: avatarSquareSize,
|
||||
height: avatarSquareSize,
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.webp({
|
||||
quality: avatarWebpQuality
|
||||
})
|
||||
.toBuffer()
|
||||
|
||||
await writeFile(filePath, resizedBuffer)
|
||||
|
||||
return {
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user