fix(media): 회원 썸네일을 관리자 미디어 폴더에서 다시 노출

회원 썸네일 경로 필터를 제거해 관리자 미디어의 회원/썸네일 카테고리에서 업로드 결과를 확인할 수 있게 하고, 설정 프로필 썸네일 UI 개편 및 문서 버전 업데이트를 함께 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:40:32 +09:00
parent 080f76799a
commit b18aca4dcc
7 changed files with 63 additions and 53 deletions

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-11 v0.0.78
### 관리자 미디어에서 회원 썸네일 가시성 복구
회원 썸네일을 미디어 목록에서 완전히 제외하면 운영자가 업로드 결과를 확인하거나 정리할 수 없어 관리성이 떨어진다. 경로 분리는 유지하되 관리자 미디어에서는 `회원/썸네일` 카테고리로 조회되도록 바꿔, 일반 콘텐츠 미디어와 논리적으로 구분하면서도 관리 화면에서 추적 가능하게 했다.
## 2026-05-11 v0.0.77
### 회원 설정 썸네일 표시 방식 전환

View File

@@ -171,7 +171,7 @@
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 |
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티(회원 썸네일 경로 제외) |
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티(회원 썸네일 포함, `회원/썸네일` 카테고리 노출) |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |

View File

@@ -349,6 +349,7 @@ components/content/
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
> 회원 썸네일을 새로 업로드하거나 제거/탈퇴할 때 기존 회원 썸네일 파일과 메타데이터는 자동 정리한다.
> 관리자 미디어 화면에서는 회원 썸네일도 `회원/썸네일` 폴더로 조회 가능하다.
### 관리자 API (`/admin/api/`)
@@ -589,6 +590,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.77
- 현재 버전: v0.0.78
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v0.0.78
- 관리자 미디어 목록에서 회원 썸네일 경로(`/uploads/members/avatars/`)를 다시 포함해 `회원/썸네일` 폴더에서 확인 가능하도록 수정.
- 회원 썸네일 파일도 일반 미디어와 동일하게 폴더 트리/검색/카운트 집계에 반영되도록 정리.
## v0.0.77
- 회원 설정 프로필 영역에서 썸네일 URL 입력을 제거하고, 썸네일 미리보기 + 이미지 변경/제거 버튼 중심 UI로 개편.
@@ -33,7 +38,7 @@
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.
- 회원 설정 페이지에서 썸네일 파일 업로드를 직접 처리하고, 업로드 후 프로필 저장 흐름으로 연결.
- 관리자 미디어 목록에서 회원 썸네일 경로(`/uploads/members/avatars/`)를 제외해 작가용 미디어와 분리.
- 관리자 미디어 목록에서 회원 썸네일은 전용 카테고리(`회원/썸네일`)로 구분해 관리.
## v0.0.71

View File

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

View File

@@ -247,42 +247,54 @@ onMounted(loadProfile)
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
<h2 class="text-sm font-semibold">프로필</h2>
<div class="mt-3 flex flex-col gap-4">
<div class="settings-profile-avatar-card flex items-center gap-4 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel)] p-3">
<div class="settings-profile-avatar-frame relative h-20 w-20 shrink-0 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]">
<img
<div class="settings-profile-account flex flex-col gap-3 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel)] p-3 md:flex-row md:items-center md:gap-4">
<div class="relative w-fit shrink-0">
<button
type="button"
class="group relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]"
:disabled="uploadingAvatar || removingAvatar"
@click="openAvatarFilePicker"
>
<img
v-if="profileForm.avatarUrl"
:src="profileForm.avatarUrl"
alt="프로필 썸네일"
class="h-full w-full object-cover"
>
<span
v-else
class="grid h-full w-full place-items-center text-2xl font-semibold site-muted"
>
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
</span>
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
</span>
</button>
<button
v-if="profileForm.avatarUrl"
:src="profileForm.avatarUrl"
alt="프로필 썸네일"
class="h-full w-full object-cover"
type="button"
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-[var(--site-line)] bg-[var(--site-panel-strong)] text-xs site-muted transition-opacity hover:opacity-80 disabled:opacity-50"
:disabled="removingAvatar"
@click.stop="removeAvatar"
>
<span
v-else
class="grid h-full w-full place-items-center text-sm font-semibold site-muted"
>
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
</span>
{{ removingAvatar ? '...' : 'X' }}
</button>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs site-muted">
프로필 이미지
</p>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs transition-opacity hover:opacity-80 disabled:opacity-60"
:disabled="uploadingAvatar"
@click="openAvatarFilePicker"
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
<p class="text-[11px] site-muted">닉네임</p>
<input
v-model="profileForm.username"
type="text"
class="mt-1 h-7 w-full border-none bg-transparent p-0 text-sm font-semibold outline-none focus-visible:ring-0"
>
{{ uploadingAvatar ? '업로드 중...' : '이미지 변경' }}
</button>
<button
type="button"
class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs site-muted transition-opacity hover:opacity-80 disabled:opacity-60"
:disabled="removingAvatar || !profileForm.avatarUrl"
@click="removeAvatar"
>
{{ removingAvatar ? '제거 중...' : '이미지 제거' }}
</button>
</div>
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
<p class="text-[11px] site-muted">ID (이메일)</p>
<p class="mt-1 truncate text-sm font-semibold">
{{ profileForm.email }}
</p>
</div>
</div>
</div>
@@ -294,22 +306,9 @@ onMounted(loadProfile)
:disabled="uploadingAvatar"
@change="uploadAvatar"
>
<label class="text-xs site-muted">이메일</label>
<input
v-model="profileForm.email"
type="text"
disabled
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-3 text-sm"
>
<label class="text-xs site-muted">닉네임</label>
<input
v-model="profileForm.username"
type="text"
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
>
<button
type="button"
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
class="site-accent-button w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="savingProfile"
@click="saveProfile"
>
@@ -320,7 +319,7 @@ onMounted(loadProfile)
</p>
</div>
</section>
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
<h2 class="text-sm font-semibold">비밀번호 변경</h2>
<div class="mt-3 flex flex-col gap-3">

View File

@@ -252,13 +252,12 @@ const getMediaUsage = (url, posts, pages) => {
*/
export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const publicAdminItems = items.filter((item) => !item.url.startsWith('/uploads/members/avatars/'))
const metadataMap = await getMediaMetadataMap()
const [posts, pages] = await Promise.all([
listAdminPosts(),
listPages()
])
const itemsWithUsage = publicAdminItems.map((item) => ({
const itemsWithUsage = items.map((item) => ({
...item,
category: metadataMap[item.url]?.category || item.category,
usage: getMediaUsage(item.url, posts, pages)