Compare commits
5 Commits
v1.1.0_글쓰기
...
v1.1.5_이미지
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e544d97fa | |||
| 20b901d4a1 | |||
| 0ed848a2eb | |||
| 08f0aa0efa | |||
| 17dcd04339 |
@@ -216,6 +216,27 @@
|
|||||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
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를 상속하지 않는 경우 대비
|
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -191,12 +191,23 @@ const uploadAvatar = async (event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('files', file)
|
formData.append('file', file)
|
||||||
const result = await $fetch('/admin/api/uploads', {
|
const result = isNewMember.value
|
||||||
|
? await $fetch('/admin/api/member-avatar', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
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) {
|
} catch (error) {
|
||||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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"
|
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>
|
><code>{{ block.text }}</code></pre>
|
||||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
<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}`">
|
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||||
<br v-if="lineIndex > 0">
|
<br v-if="lineIndex > 0">
|
||||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ onMounted(() => {
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag.id"
|
: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}`"
|
: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 }" />
|
<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>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
<button
|
<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"
|
type="button"
|
||||||
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||||
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ defineProps({
|
|||||||
{{ post.excerpt }}
|
{{ post.excerpt }}
|
||||||
</p>
|
</p>
|
||||||
<p class="post-card__meta mt-2 text-xs site-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ const navBarBeforeInactive =
|
|||||||
const navBarBeforeActive =
|
const navBarBeforeActive =
|
||||||
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
|
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
|
||||||
|
|
||||||
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
|
/** 행 공통: site-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||||
const navRowShell =
|
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,15 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.1.5
|
||||||
|
|
||||||
|
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||||
|
|
||||||
|
## v1.1.4
|
||||||
|
|
||||||
|
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||||
|
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
|
||||||
|
- 태그 목록 카드 그리드 여백 수정 반영.
|
||||||
|
|
||||||
## v1.0.19
|
## v1.0.19
|
||||||
|
|
||||||
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
||||||
|
|||||||
@@ -323,7 +323,8 @@ docker compose --env-file .env.production restart sori-studio-db
|
|||||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||||
|
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 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
|
## 2026-05-14 v1.1.0
|
||||||
|
|
||||||
### 관리자 제목·공개 본문 타이포 마이너 조정
|
### 관리자 제목·공개 본문 타이포 마이너 조정
|
||||||
|
|||||||
22
docs/map.md
22
docs/map.md
@@ -43,6 +43,7 @@
|
|||||||
| 파일 | 용도 |
|
| 파일 | 용도 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
|
| 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/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `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` |
|
| 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`로 패널 호버 배경 폭, `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, `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 | 태그 페이지 헤더 |
|
||||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
| 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/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||||
| components/content/ProseList.vue | 목록 |
|
| components/content/ProseList.vue | 목록 |
|
||||||
@@ -127,10 +128,10 @@
|
|||||||
|
|
||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pages/index.vue | 홈, 중앙 Hero/Featured/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/링크복사), 회원 댓글 섹션 |
|
||||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||||
@@ -190,7 +191,8 @@
|
|||||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||||
| server/routes/admin/api/media-folders.post.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.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||||
@@ -204,13 +206,15 @@
|
|||||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||||
| server/routes/admin/api/members.post.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].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/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
| 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-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||||
|
|||||||
15
docs/spec.md
15
docs/spec.md
@@ -185,7 +185,7 @@ components/content/
|
|||||||
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
||||||
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
|
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
|
||||||
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
|
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--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:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
|
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
|
||||||
- 카드류
|
- 카드류
|
||||||
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||||
@@ -395,6 +395,10 @@ components/content/
|
|||||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||||
|
|
||||||
|
### 업로드 파일 제공
|
||||||
|
|
||||||
|
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
|
||||||
|
|
||||||
### 관리자 API (`/admin/api/`)
|
### 관리자 API (`/admin/api/`)
|
||||||
|
|
||||||
- `POST /admin/api/auth/login` - 로그인
|
- `POST /admin/api/auth/login` - 로그인
|
||||||
@@ -416,7 +420,8 @@ components/content/
|
|||||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
- `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`)
|
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||||
- `POST /admin/api/tags` - 태그 생성
|
- `POST /admin/api/tags` - 태그 생성
|
||||||
- `GET /admin/api/tags/:id` - 태그 상세
|
- `GET /admin/api/tags/:id` - 태그 상세
|
||||||
@@ -430,7 +435,8 @@ components/content/
|
|||||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
- `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/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||||
|
|
||||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
@@ -506,6 +512,7 @@ components/content/
|
|||||||
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
||||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||||
|
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||||
@@ -556,7 +563,7 @@ components/content/
|
|||||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
- `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로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
|
|||||||
@@ -1,9 +1,39 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## 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
|
## v1.1.0
|
||||||
|
|
||||||
- 관리자 글 작성 폼 제목 입력 타이포를 `text-5xl`에서 `text-3xl`로 조정.
|
- 관리자 글 작성 폼 제목 입력 타이포를 `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` 제거로 제목·본문 간 세로 리듬 정리.
|
- `ProseHeading`에서 제목 블록 상단 `mt-12` 제거로 제목·본문 간 세로 리듬 정리.
|
||||||
- 패키지 버전 `1.1.0`으로 갱신.
|
- 패키지 버전 `1.1.0`으로 갱신.
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -5920,7 +5920,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -9929,7 +9929,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdir-glob": {
|
"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",
|
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.1.0",
|
"version": "1.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -53,10 +53,17 @@ const onDocumentPointerDown = (event) => {
|
|||||||
* @returns {{name: string, color: string}} 태그 정보
|
* @returns {{name: string, color: string}} 태그 정보
|
||||||
*/
|
*/
|
||||||
const getTagMeta = (slug) => {
|
const getTagMeta = (slug) => {
|
||||||
|
if (!slug) {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
color: '#4d4d4d'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const matchedTag = tags.value.find((item) => item.slug === slug)
|
const matchedTag = tags.value.find((item) => item.slug === slug)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: matchedTag?.name || (slug ? slug.toUpperCase() : 'POST'),
|
name: matchedTag?.name || String(slug).toUpperCase(),
|
||||||
color: matchedTag?.color || '#4d4d4d'
|
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">
|
<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>
|
<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
|
<span
|
||||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||||
>
|
>
|
||||||
{{ post.tagName }}
|
{{ post.tagName }}
|
||||||
</span>
|
</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">
|
<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">
|
<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" />
|
||||||
|
|||||||
@@ -23,12 +23,22 @@ if (!post.value) {
|
|||||||
|
|
||||||
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
||||||
const primaryTagMeta = computed(() => {
|
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 {
|
return {
|
||||||
name: matchedTag?.name || (primaryTagSlug.value ? primaryTagSlug.value.toUpperCase() : 'POST'),
|
name: matchedTag?.name || slug.toUpperCase(),
|
||||||
color: matchedTag?.color || '#4d4d4d',
|
color: matchedTag?.color || '#4d4d4d',
|
||||||
to: primaryTagSlug.value ? `/tag/${primaryTagSlug.value}` : '/tags'
|
to: `/tag/${slug}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -228,8 +238,8 @@ useHead(() => ({
|
|||||||
{{ authorLabel }}
|
{{ authorLabel }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="flex flex-wrap items-center font-medium">
|
<ul v-if="primaryTagSlug" class="flex flex-wrap items-center font-medium">
|
||||||
<li v-if="primaryTagMeta.name" :style="{ '--color-accent': primaryTagMeta.color }">
|
<li :style="{ '--color-accent': primaryTagMeta.color }">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
|
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
|
||||||
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"
|
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const postCards = computed(() => posts.value.map((post) => ({
|
|||||||
title: post.title,
|
title: post.title,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
featuredImage: post.featuredImage,
|
featuredImage: post.featuredImage,
|
||||||
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
|
||||||
publishedAt: formatPostDate(post.publishedAt),
|
publishedAt: formatPostDate(post.publishedAt),
|
||||||
to: `/post/${post.slug}`
|
to: `/post/${post.slug}`
|
||||||
})))
|
})))
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tags-page-list mb-8">
|
<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
|
<li
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
|
|||||||
@@ -1,82 +1,10 @@
|
|||||||
import { mkdir, stat, writeFile } from 'node:fs/promises'
|
import { createError } from 'h3'
|
||||||
import { join } from 'node:path'
|
|
||||||
import { createError, readMultipartFormData } from 'h3'
|
|
||||||
import sharp from 'sharp'
|
|
||||||
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||||
|
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
|
||||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
|
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
|
* 회원 썸네일 업로드 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
@@ -92,80 +20,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||||
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)
|
|
||||||
|
|
||||||
await updateMemberProfile({
|
await updateMemberProfile({
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
@@ -183,4 +38,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
avatarUrl
|
avatarUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -678,6 +678,25 @@ export const updateMemberByAdmin = async (input) => {
|
|||||||
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
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 - 이메일
|
* @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 { z } from 'zod'
|
||||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
||||||
|
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||||
|
|
||||||
const memberInputSchema = z.object({
|
const memberInputSchema = z.object({
|
||||||
username: z.string().trim().min(1).max(60),
|
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
|
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
|
||||||
|
})
|
||||||
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))
|
||||||
|
})
|
||||||
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