fix(media): 회원 썸네일을 관리자 미디어 폴더에서 다시 노출
회원 썸네일 경로 필터를 제거해 관리자 미디어의 회원/썸네일 카테고리에서 업로드 결과를 확인할 수 있게 하고, 설정 프로필 썸네일 UI 개편 및 문서 버전 업데이트를 함께 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.78
|
||||
|
||||
### 관리자 미디어에서 회원 썸네일 가시성 복구
|
||||
|
||||
회원 썸네일을 미디어 목록에서 완전히 제외하면 운영자가 업로드 결과를 확인하거나 정리할 수 없어 관리성이 떨어진다. 경로 분리는 유지하되 관리자 미디어에서는 `회원/썸네일` 카테고리로 조회되도록 바꿔, 일반 콘텐츠 미디어와 논리적으로 구분하면서도 관리 화면에서 추적 가능하게 했다.
|
||||
|
||||
## 2026-05-11 v0.0.77
|
||||
|
||||
### 회원 설정 썸네일 표시 방식 전환
|
||||
|
||||
@@ -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 | 회원 조회/생성 저장소 |
|
||||
|
||||
@@ -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
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.77",
|
||||
"version": "0.0.78",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user