Compare commits

...

4 Commits

11 changed files with 186 additions and 42 deletions

View File

@@ -201,6 +201,21 @@ function getUserAccountName(row) {
return email.split('@')[0] || email
}
function getAssetLibrarySourceLabel(src) {
const normalizedSrc = String(src || '').trim()
if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타'
if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일'
if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지'
return '보관 자산'
}
function getAssetLibraryKind(src) {
const normalizedSrc = String(src || '').trim()
if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar'
if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail'
return 'asset'
}
async function createPool() {
const rootConnection = await mysql.createConnection({
host: DB_HOST,
@@ -1814,6 +1829,32 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
getCustomItemUsageMeta(),
])
const [userAvatarRows, topicThumbnailRows, tierListThumbnailRows, templateRequestThumbnailRows] = await Promise.all([
query("SELECT avatar_src AS src FROM users WHERE avatar_src <> ''"),
query("SELECT thumbnail_src AS src FROM topics WHERE thumbnail_src <> ''"),
query("SELECT thumbnail_src AS src FROM tierlists WHERE thumbnail_src <> ''"),
query("SELECT thumbnail_src_snapshot AS src FROM template_requests WHERE thumbnail_src_snapshot <> ''"),
])
const avatarSrcSet = new Set(userAvatarRows.map((row) => row.src).filter(Boolean))
const thumbnailSrcSet = new Set([
...topicThumbnailRows.map((row) => row.src).filter(Boolean),
...tierListThumbnailRows.map((row) => row.src).filter(Boolean),
...templateRequestThumbnailRows.map((row) => row.src).filter(Boolean),
])
const resolveLibraryAssetKind = (src) => {
if (avatarSrcSet.has(src)) return 'avatar'
if (thumbnailSrcSet.has(src)) return 'thumbnail'
return getAssetLibraryKind(src)
}
const resolveLibraryAssetLabel = (src) => {
if (avatarSrcSet.has(src)) return '프로필 아바타'
if (thumbnailSrcSet.has(src)) return '썸네일 이미지'
return getAssetLibrarySourceLabel(src)
}
const templateLinkedBySrc = new Map()
topicItemRows.forEach((row) => {
if (!row?.src) return
@@ -1836,8 +1877,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates,
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'user',
sourceLabel: '사용자 업로드',
sourceLabel: '사용자 아이템',
canDelete: true,
}
})
@@ -1857,8 +1899,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerEmail: '',
usageCount: 0,
linkedTemplates: [],
sourceType: 'template',
sourceLabel: '관리자 템플릿',
sourceType: 'asset',
sourceLabel: resolveLibraryAssetLabel(row.src),
assetKind: resolveLibraryAssetKind(row.src),
canDelete: true,
sourceTopicId: '',
sourceTopicName: '',
@@ -1875,8 +1918,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerEmail: '',
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'template',
sourceLabel: '관리자 템플릿',
sourceLabel: '템플릿 아이템',
canDelete: true,
sourceTopicId: row.topic_id,
sourceTopicName: row.topic_name || row.topic_id,
@@ -1900,7 +1944,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
siblings.forEach((entry) => {
if (entry.sourceType === 'user') userReferenceCount += 1
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1
else templateReferenceCount += 1
;(entry.linkedTemplates || []).forEach((template) => {
if (template?.id) linkedTemplates.set(template.id, template)
@@ -1922,6 +1966,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
label: entry.label,
sourceLabel: entry.sourceLabel,
sourceType: entry.sourceType,
assetKind: entry.assetKind || '',
ownerName: entry.ownerName,
createdAt: entry.createdAt,
sourceTopicId: entry.sourceTopicId || '',
@@ -1939,11 +1984,17 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'template':
return item.sourceType === 'template' && !item.isAssetLibraryItem
case 'asset':
return !!item.isAssetLibraryItem
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
case 'thumbnail':
return item.assetKind === 'thumbnail'
case 'avatar':
return item.assetKind === 'avatar'
case 'library':
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
case 'unused-admin':
return !!item.isAssetLibraryItem
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
default:
return true
}

View File

@@ -75,10 +75,13 @@ async function optimizeAndPersist({ file, width, height, fit, quality }) {
}
}
const filename = nanoid() + '.webp'
const absoluteDir = path.join(UPLOAD_ROOT, OPTIMIZED_DIR)
const basename = nanoid()
const shardDirectory = basename.slice(0, 2)
const filename = basename + '.webp'
const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory)
const absoluteDir = path.join(UPLOAD_ROOT, relativeDir)
const absolutePath = path.join(absoluteDir, filename)
const src = '/uploads/' + OPTIMIZED_DIR + '/' + filename
const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename
await fs.mkdir(absoluteDir, { recursive: true })
await fs.writeFile(absolutePath, data)

View File

@@ -304,7 +304,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({
label: z.string().trim().min(1).max(60),
sourceType: z.enum(['template', 'user']).optional().default('user'),
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -332,7 +332,10 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
q: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
filter: z
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
.optional()
.default('library'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -672,6 +675,15 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' })
if (target.sourceType === 'asset' || String(target.id || '').startsWith('asset:')) {
const assetId = String(target.id).slice('asset:'.length)
const asset = await findImageAssetById(assetId)
if (!asset) return res.status(404).json({ error: 'not_found' })
await deleteImageAssets([assetId])
await removeUploadFiles([asset.src])
return res.json({ ok: true, sourceType: 'asset' })
}
if (target.sourceType === 'template') {
if (String(target.id || '').startsWith('asset:')) {
const assetId = String(target.id).slice('asset:'.length)

View File

@@ -1,5 +1,21 @@
# 의사결정 이력
## 2026-04-03 v1.4.59
- 최근 최적화 이미지가 `assets` 바로 아래 평면 파일로 저장되면서 경로만으로 프로필/썸네일 역할을 구분할 수 없게 되었으므로, 관리자 아이템 분류는 폴더명 규칙 하나에만 기대지 말고 실제 DB 참조 컬럼을 역추적해 판별하는 편이 더 안전하다고 판단했다.
- 이미지가 장기적으로 많이 쌓일 수 있는 서비스라면 한 폴더에 모든 파일을 계속 몰아넣기보다 적당한 수준의 하위 폴더 분산이 낫다고 보고, 신규 파일만 ID 앞 2글자로 1단계 샤딩 저장하되 기존 평면 경로는 그대로 유지하는 점진 방식으로 정리했다.
## 2026-04-03 v1.4.58
- 작성자 프로필 화면 상단에서 닉네임과 `@accountName`을 다시 보여주면 바로 아래 프로필 카드의 동일 정보와 역할이 겹치므로, 상단은 페이지 성격을 설명하는 공통 제목으로 두고 실제 사용자 식별 정보는 프로필 카드 한 곳에만 모으는 편이 낫다고 판단했다.
- `@accountName`은 사용자가 직접 만든 핸들이 아니라 이메일 앞부분 기반 표시라서 계정명이 따로 존재하는 것처럼 오해를 만들 수 있으므로, 별도 사용자명 정책을 도입하기 전까지는 공개 프로필 UI에서 숨기는 쪽으로 정리했다.
## 2026-04-03 v1.4.57
- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다.
- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다.
## 2026-04-03 v1.4.56
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
## 2026-04-03 v1.4.55
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동``마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.

View File

@@ -24,6 +24,7 @@
- 아바타: `backend/uploads/avatars/`
- 커스텀 아이템: `backend/uploads/custom/`
- 시드 이미지: `backend/uploads/seeds/`
- 최적화 이미지 자산: 신규 업로드는 `backend/uploads/assets/<앞2글자>/<파일명>.webp` 형태로 1단계 샤딩 저장하고, 기존 `backend/uploads/assets/<파일명>.webp` 평면 경로도 계속 읽는다.
## 화면 구조
- 좌측 패널
@@ -198,6 +199,8 @@
- `POST /api/admin/tierlists/:tierListId/promote-items`
- `POST /api/admin/tierlists/:tierListId/create-template`
- `GET /api/admin/custom-items`
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 현재 참조 역할이 썸네일/프로필인 이미지를 따로 조회한다.
- `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
- `POST /api/admin/custom-items/:itemId/promote`
- `DELETE /api/admin/custom-items/:itemId`
- `DELETE /api/admin/custom-items`
@@ -217,9 +220,11 @@
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
- `/uploads/assets/avatars/``프로필 아바타`, `/uploads/assets/tierlists/``/uploads/assets/topics/``썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
@@ -245,6 +250,7 @@
- 로그인한 사용자는 뷰어 모드 우측 레일에서 저장된 티어표를 복사할 수 있고, 타인 티어표면 `내 티어표로 복사`, 본인 티어표면 `복사본 만들기` 문구를 사용한다. 작성자 본인은 `수정 모드로 전환`도 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 편집/뷰어 우측 패널의 `작성자 프로필 보기`로 해당 작성자의 공개 프로필과 공개 티어표 목록을 열 수 있고, 로그인 상태에서는 작성자 프로필에서 팔로우/언팔로우를 전환할 수 있다.
- `/users/:userId` 공개 프로필 화면 상단 헤더는 고정 제목 `사용자 프로필`과 안내 문구를 보여주고, 실제 닉네임/아바타는 본문 프로필 카드에서만 표시한다. 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니므로 프로필 UI에 노출하지 않는다.
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
- 본인 티어표도 저장된 상태라면 편집/뷰어 우측 패널에서 복사본을 만들 수 있고, 편집 중 저장하지 않은 수정이 남아 있으면 복사 직전에 현재 수정본을 먼저 저장해 최신 상태 기준 복사본을 만든다.

View File

@@ -1,6 +1,16 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.59`에서 `thumbnail/avatar` 필터를 실제 DB 참조 역할 기준으로 다시 판별하도록 바꿨으므로, 최근 업로드처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로여도 썸네일 이미지/프로필 이미지 필터에서 빠지지 않는지 확인한다.
- 신규 업로드 이미지는 `/uploads/assets/<앞2글자>/<파일명>.webp`로 저장되므로, 템플릿 썸네일/티어표 썸네일/프로필 아바타/아이템 업로드를 각각 새로 올린 뒤 실제 파일이 샤딩 폴더에 생성되고, 브라우저 표시·삭제·중복 재사용이 모두 기존처럼 동작하는지 QA한다.
- 기존 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지되므로, 예전에 만든 티어표 썸네일과 아이템 이미지가 새 저장 구조 변경 후에도 깨지지 않는지 확인한다.
- `v1.4.58`에서 작성자 프로필 상단 헤더를 `사용자 프로필` 공통 제목으로 바꾸고 `@accountName` 노출을 뺐으므로, `/users/:userId`에서 상단 문구와 본문 프로필 카드가 중복되지 않고 닉네임/아바타/팔로우 버튼만 자연스럽게 읽히는지 확인한다.
- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다.
- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다.
- `미사용 아이템` 필터는 사용자 아이템 중 저장 티어표 사용 횟수와 템플릿 연결이 모두 0인 항목만 보여주고, 계정 탈퇴로 이미 `custom_items` 레코드가 삭제된 항목이 따로 남지 않는지 확인한다.
- `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다.
- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하는지 확인한다.
- 아이템 관리 상단 통계의 `미사용 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다.
- `v1.4.55`에서 회원 카드의 `최근 활동``최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.

View File

@@ -1,5 +1,27 @@
# 업데이트 로그
## 2026-04-03 v1.4.59
- 최근 업로드된 최적화 이미지가 `/uploads/assets/<파일명>.webp`처럼 하위 폴더 없이 저장되면서, `썸네일 이미지 / 프로필 이미지` 필터가 경로 문자열만으로 자산 종류를 판별하지 못해 비어 보일 수 있던 문제를 고쳤다.
- 관리자 아이템 목록 생성 시 `users.avatar_src`, `topics.thumbnail_src`, `tierlists.thumbnail_src`, `template_requests.thumbnail_src_snapshot`을 역으로 모아 해당 `src`가 프로필 이미지인지 썸네일 이미지인지 먼저 판별하고, `thumbnail/avatar` 필터는 `sourceType`이 아니라 이 실제 참조 역할(`assetKind`) 기준으로 걸리도록 보정했다.
- 신규 최적화 이미지 저장은 한 폴더에 무한정 쌓이지 않도록 파일 ID 앞 2글자 기준으로 `/uploads/assets/ab/<파일명>.webp`처럼 1단계 샤딩 디렉터리를 사용하게 바꿨다. 기존에 이미 저장된 `/uploads/assets/<파일명>.webp` 평면 경로는 그대로 유지해 과거 이미지 링크가 깨지지 않게 했다.
## 2026-04-03 v1.4.58
- 작성자 프로필 화면 상단 헤더가 `Author + 닉네임 + @accountName`을 다시 보여주면서, 바로 아래 프로필 카드의 아바타/닉네임 정보와 거의 같은 내용이 반복되던 구성을 정리했다.
- 상단 헤더는 공통 제목 `사용자 프로필`과 안내 문구로 바꾸고, 실제 닉네임은 아래 프로필 카드에서만 보여주도록 나눠 화면의 정보 역할이 겹치지 않게 했다.
- 이메일 앞부분에서 파생된 `@accountName`은 사용자가 직접 설정한 핸들이 아니라서 오히려 “내가 입력한 적 없는 계정명”처럼 느껴질 수 있으므로, 프로필 화면의 시각 노출에서는 제거했다.
## 2026-04-03 v1.4.57
- 관리자 아이템 관리 필터를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템` 순서로 다시 정리해, 전체 조회와 실제 아이템 검수 흐름이 더 직관적으로 이어지게 맞췄다.
- 기본 필터는 계속 `아이템(템플릿 + 사용자)`를 유지하되, 썸네일과 프로필 이미지는 각각 `filter=thumbnail`, `filter=avatar`로 분리 조회할 수 있게 백엔드 필터 enum과 자산 분류 값을 확장했다.
- 보관 자산 배지 문구도 `/uploads/assets/topics/` 경로는 `썸네일 이미지`, 사용자 업로드 항목은 `사용자 아이템`, 템플릿 기본 항목은 `템플릿 아이템`으로 맞춰 `관리자 템플릿`처럼 실제 의미와 어긋나는 표현이 남지 않도록 정리했다.
- `미사용 아이템`은 계정 탈퇴로 같이 삭제된 항목이 아니라, 사용자 아이템 레코드는 남아 있지만 저장 티어표/템플릿에서 더 이상 참조하지 않는 항목이라는 의미가 드러나도록 통계 라벨과 일괄 삭제 버튼 문구를 다시 정돈했다.
## 2026-04-03 v1.4.56
- 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다.
- 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다.
- 아이템 관리 필터 기본값을 `아이템만 (템플릿+사용자)`로 바꾸고, `썸네일·프로필 이미지`, `미사용 썸네일·프로필 이미지` 필터를 따로 제공해 기본 화면에서는 실제 아이템만 먼저 검수할 수 있게 했다.
- 아이템 관리 상단의 `미사용` 통계가 프로필/썸네일 같은 자산까지 `usageCount=0`으로 같이 세면 잘못된 숫자처럼 보일 수 있으므로, `미사용 사용자 아이템`이라는 라벨로 바꾸고 실제 사용자 업로드 아이템 중 템플릿 연결과 사용 횟수가 모두 없는 항목만 세도록 보정했다.
## 2026-04-03 v1.4.55
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
- 백엔드 `users``last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.

View File

@@ -16,7 +16,15 @@ const props = defineProps({
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
<span
class="customItemCard__badge"
:class="{
'customItemCard__badge--template': item.sourceType === 'template',
'customItemCard__badge--asset': item.sourceType === 'asset',
}"
>
{{ item.sourceLabel }}
</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>

View File

@@ -119,9 +119,19 @@ export function useAdminCustomItems({
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
success.value =
item.sourceType === 'template'
? '선택한 템플릿 아이템을 제거했어요.'
: item.sourceType === 'asset'
? '선택한 이미지 자산을 삭제했어요.'
: '사용자 업로드 이미지를 삭제했어요.'
} catch (e) {
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
error.value =
item.sourceType === 'template'
? '템플릿 아이템 제거에 실패했어요.'
: item.sourceType === 'asset'
? '이미지 자산 삭제에 실패했어요.'
: '사용자 업로드 이미지 삭제에 실패했어요.'
}
}

View File

@@ -44,7 +44,7 @@ const customItemQuery = ref('')
const customItemPage = ref(1)
const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemFilter = ref('all')
const customItemFilter = ref('library')
const customItemModalTargetTemplateId = ref('')
const adminTierLists = ref([])
@@ -231,7 +231,7 @@ const activeTabDescription = computed(() => {
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
}
if (activeTab.value === 'items') {
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
return '사용자 아이템과 템플릿 아이템을 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
}
if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests'
@@ -242,7 +242,12 @@ const activeTabDescription = computed(() => {
})
const adminOverviewStats = computed(() => {
const pendingRequests = templateRequests.value.length
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const orphanItems = customItems.value.filter(
(item) =>
item.sourceType === 'user' &&
Number(item.usageCount || 0) === 0 &&
!(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)
).length
const adminCount = users.value.filter((user) => user.isAdmin).length
if (activeTab.value === 'featured') {
@@ -264,8 +269,9 @@ const adminOverviewStats = computed(() => {
if (activeTab.value === 'items') {
return [
{ label: '검색 결과', value: `${customItemTotal.value}` },
{ label: '미사용', value: `${orphanItems}` },
{ label: '미사용 아이템', value: `${orphanItems}` },
{ label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` },
{ label: '이미지 자산', value: `${customItems.value.filter((item) => item.sourceType === 'asset').length}` },
]
}
if (activeTab.value === 'tierlists') {
@@ -490,7 +496,7 @@ watch(
if (tab === 'items') {
customItemQuery.value = ''
customItemFilter.value = 'all'
customItemFilter.value = 'library'
customItemPage.value = 1
await refreshCustomItems()
return
@@ -599,13 +605,14 @@ function formatImageJobStatus(status) {
function customItemDeleteImpactText(item) {
if (!item) return ''
if (item.sourceType === 'asset' || item.isAssetLibraryItem) {
return `"${item.label}" ${item.sourceLabel || '이미지 자산'} 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
}
if (item.sourceType === 'template') {
return item.isAssetLibraryItem
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
return `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
}
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
return `"${item.label}" 사용자 아이템을 삭제할까요? 현재 항목만 정리됩니다.`
}
const imageDiagnosticsCards = computed(() => {
@@ -747,7 +754,7 @@ function setTab(tab) {
}
if (tab === 'items') {
customItemQuery.value = ''
customItemFilter.value = 'all'
customItemFilter.value = 'library'
customItemPage.value = 1
refreshCustomItems()
}
@@ -1389,7 +1396,7 @@ function buildModalItemFromTierListItem(item, tierList) {
id,
label: item?.label || matchedItem?.label || '이름 없음',
src: item?.src || matchedItem?.src || '',
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
@@ -2290,16 +2297,17 @@ function openUserProfile(user) {
</select>
<select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
<option value="all">전체 이미지</option>
<option value="user">사용자 업로드</option>
<option value="template">템플릿 사용 이미지</option>
<option value="asset">관리 보관 자산</option>
<option value="unused-user">미사용 사용자 업로드</option>
<option value="unused-admin">미사용 관리자 자산</option>
<option value="library">아이템(템플릿 + 사용자)</option>
<option value="template">템플릿 아이템</option>
<option value="user">사용 아이템</option>
<option value="thumbnail">썸네일 이미지</option>
<option value="avatar">프로필 이미지</option>
<option value="unused-user">미사용 아이템</option>
</select>
</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 사용자 이미지 일괄 삭제</button>
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
@@ -3480,6 +3488,9 @@ function openUserProfile(user) {
.adminUiScope .customItemCard__badge--template {
background: rgba(96, 165, 250, 0.18);
}
.adminUiScope .customItemCard__badge--asset {
background: rgba(251, 191, 36, 0.18);
}
.adminUiScope .customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);

View File

@@ -113,10 +113,10 @@ watch(userId, loadProfile, { immediate: true })
<section class="pageWrap">
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Author</div>
<h2 class="pageHead__title">{{ profileDisplayName }}</h2>
<div class="pageHead__eyebrow">User Profile</div>
<h2 class="pageHead__title">사용자 프로필</h2>
<div class="pageHead__desc">
{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}
사용자가 공개한 티어표를 모아볼 있어요.
</div>
</div>
<div class="pageHead__aside profileActions">
@@ -133,7 +133,6 @@ watch(userId, loadProfile, { immediate: true })
<div v-else class="profileAvatar profileAvatar--fallback">{{ profileFallback }}</div>
<div class="profileMeta">
<div class="profileMeta__name">{{ profileDisplayName }}</div>
<div class="profileMeta__handle">{{ profile?.accountName ? `@${profile.accountName}` : '작성자 프로필' }}</div>
</div>
</div>
<div class="profileStats">
@@ -265,10 +264,6 @@ watch(userId, loadProfile, { immediate: true })
color: var(--theme-text);
word-break: break-word;
}
.profileMeta__handle {
font-size: 14px;
color: var(--theme-text-faint);
}
.profileStats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));