프로필 썸네일 해제 시 메타 분리 통일·미디어 모달 다운로드(v0.0.92)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-12 v0.0.92
|
||||||
|
|
||||||
|
### 프로필 썸네일 해제와 다운로드
|
||||||
|
|
||||||
|
서버는 이미 디스크를 지우지 않지만, 설정 화면이 `PUT /api/auth/profile`로만 `avatarUrl`을 비울 때는 메타 분리가 빠져 관리자 목록과 체감이 어긋날 수 있어 `DELETE /api/auth/avatar`와 같은 `removeManagedAvatarAsset` 호출을 맞췄다. 관리자 미디어 모달에 다운로드를 넣어 원본 확인을 쉽게 했다.
|
||||||
|
|
||||||
## 2026-05-12 v0.0.91
|
## 2026-05-12 v0.0.91
|
||||||
|
|
||||||
### 썸네일 미사용 자산과 업로드 파일명
|
### 썸네일 미사용 자산과 업로드 파일명
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집) |
|
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) |
|
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) |
|
||||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
||||||
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
||||||
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
||||||
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
|
| server/api/auth/profile.put.js | 회원 프로필 수정 API(닉네임·`avatarUrl`; 관리 썸네일 URL 교체 시 메타만 분리) |
|
||||||
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정, `media_metadata` 논리 폴더 `썸네일`) |
|
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정, `media_metadata` 논리 폴더 `썸네일`) |
|
||||||
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
|
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
|
||||||
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ components/content/
|
|||||||
- `GET /api/auth/me` - 현재 회원 세션 조회
|
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||||
- `POST /api/auth/logout` - 회원 로그아웃
|
- `POST /api/auth/logout` - 회원 로그아웃
|
||||||
- `GET /api/auth/profile` - 회원 설정 조회
|
- `GET /api/auth/profile` - 회원 설정 조회
|
||||||
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
|
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
|
||||||
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
|
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
|
||||||
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
||||||
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||||
@@ -574,6 +574,7 @@ components/content/
|
|||||||
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||||
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
||||||
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||||
|
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.92
|
||||||
|
|
||||||
|
- 회원 `PUT /api/auth/profile`에서 관리 썸네일 URL이 바뀌거나 비워질 때도 `removeManagedAvatarAsset`으로 메타만 분리해, 해제 후에도 디스크·썸네일 탭 목록과 일치하도록 정리.
|
||||||
|
- 관리자 미디어 상세 모달에 **다운로드** 버튼 추가.
|
||||||
|
- 썸네일 탭 안내: 프로필 해제 시에도 파일이 삭제되지 않음·목록 갱신은 새로고침을 명시.
|
||||||
|
|
||||||
## v0.0.91
|
## v0.0.91
|
||||||
|
|
||||||
- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함.
|
- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.91",
|
"version": "0.0.92",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -90,6 +90,27 @@ const formatDateTime = (iso) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상세 모달에서 선택된 미디어를 브라우저로 내려받는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const downloadSelectedMedia = () => {
|
||||||
|
const item = selectedMedia.value
|
||||||
|
|
||||||
|
if (!item?.url || !import.meta.client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = item.url
|
||||||
|
anchor.download = item.name || 'image'
|
||||||
|
anchor.rel = 'noopener noreferrer'
|
||||||
|
anchor.target = '_blank'
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
document.body.removeChild(anchor)
|
||||||
|
}
|
||||||
|
|
||||||
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
||||||
default: () => ['미분류']
|
default: () => ['미분류']
|
||||||
})
|
})
|
||||||
@@ -635,7 +656,7 @@ const deleteMedia = async (item) => {
|
|||||||
<span>{{ thumbnailMediaItems.length }}</span>
|
<span>{{ thumbnailMediaItems.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
||||||
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제된 파일은 이 목록에 남으며, 관리자가 직접 정리할 수 있습니다.
|
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -775,8 +796,8 @@ const deleteMedia = async (item) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
||||||
<div class="admin-media__detail-header flex items-start justify-between gap-3">
|
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
Attachment
|
Attachment
|
||||||
</p>
|
</p>
|
||||||
@@ -784,10 +805,19 @@ const deleteMedia = async (item) => {
|
|||||||
{{ selectedMedia.name }}
|
{{ selectedMedia.name }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
|
||||||
|
type="button"
|
||||||
|
@click="downloadSelectedMedia"
|
||||||
|
>
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
||||||
닫기
|
닫기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dl class="admin-media__info grid gap-3 text-sm">
|
<dl class="admin-media__info grid gap-3 text-sm">
|
||||||
<div class="admin-media__info-row">
|
<div class="admin-media__info-row">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createError, readBody } from 'h3'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getUserById, isUsernameTaken, updateMemberProfile } from '../../repositories/member-repository'
|
import { getUserById, isUsernameTaken, updateMemberProfile } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
|
import { isManagedAvatarUrl, removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
username: z.string().trim().min(1).max(30),
|
username: z.string().trim().min(1).max(30),
|
||||||
@@ -36,10 +37,26 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existing = await getUserById(session.userId)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원 정보를 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousAvatarUrl = existing.avatarUrl || ''
|
||||||
|
const nextAvatarUrl = parsedBody.data.avatarUrl || ''
|
||||||
|
|
||||||
|
if (previousAvatarUrl && previousAvatarUrl !== nextAvatarUrl && isManagedAvatarUrl(previousAvatarUrl)) {
|
||||||
|
await removeManagedAvatarAsset(previousAvatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await updateMemberProfile({
|
const updated = await updateMemberProfile({
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
username: parsedBody.data.username,
|
username: parsedBody.data.username,
|
||||||
avatarUrl: parsedBody.data.avatarUrl
|
avatarUrl: nextAvatarUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
Reference in New Issue
Block a user