Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7164d32ae8 | |||
| dddc29fd4b |
@@ -1114,24 +1114,33 @@ async function listReferencedUploadUsage() {
|
|||||||
.sort((a, b) => a.src.localeCompare(b.src))
|
.sort((a, b) => a.src.localeCompare(b.src))
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceItemSrc(items, fromSrc, toSrc) {
|
function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextItems = (items || []).map((item) => {
|
const nextItems = (items || []).map((item) => {
|
||||||
if (item?.src !== fromSrc) return item
|
if (item?.src !== fromSrc) return item
|
||||||
changed = true
|
changed = true
|
||||||
return { ...item, src: toSrc }
|
return {
|
||||||
|
...item,
|
||||||
|
src: toSrc,
|
||||||
|
...(typeof toLabel === 'string' && toLabel.trim() ? { label: toLabel.trim().slice(0, 60) } : {}),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return { changed, items: nextItems }
|
return { changed, items: nextItems }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
|
||||||
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
|
||||||
|
const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''
|
||||||
|
|
||||||
const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
|
const [userResult, topicResult, topicItemResult, customItemResult] = await Promise.all([
|
||||||
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
query('UPDATE users SET avatar_src = ? WHERE avatar_src = ?', [toSrc, fromSrc]),
|
||||||
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
query('UPDATE topics SET thumbnail_src = ? WHERE thumbnail_src = ?', [toSrc, fromSrc]),
|
||||||
query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
normalizedLabel
|
||||||
query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
? query('UPDATE topic_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
|
||||||
|
: query('UPDATE topic_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||||
|
normalizedLabel
|
||||||
|
? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
|
||||||
|
: query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc]),
|
||||||
])
|
])
|
||||||
|
|
||||||
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
|
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
|
||||||
@@ -1145,7 +1154,7 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc)
|
const replacedPool = replaceItemSrc(parseJson(row.pool_json, []), fromSrc, toSrc, normalizedLabel)
|
||||||
if (replacedPool.changed) changed = true
|
if (replacedPool.changed) changed = true
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
@@ -1168,8 +1177,8 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc }) {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc)
|
const replacedItems = replaceItemSrc(parseJson(row.items_json, []), fromSrc, toSrc, normalizedLabel)
|
||||||
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc)
|
const replacedBoardItems = replaceItemSrc(parseJson(row.board_items_json, []), fromSrc, toSrc, normalizedLabel)
|
||||||
if (replacedItems.changed || replacedBoardItems.changed) changed = true
|
if (replacedItems.changed || replacedBoardItems.changed) changed = true
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const {
|
|||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
clearImageOptimizationJobs,
|
clearImageOptimizationJobs,
|
||||||
cleanupMissingUploadReferences,
|
cleanupMissingUploadReferences,
|
||||||
|
replaceUploadSourceReferences,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||||
@@ -551,6 +552,57 @@ async function promoteLibraryItemToTemplateItem({ item, templateId }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findLibraryItemForReplacement(itemId, sourceType = '') {
|
||||||
|
const normalizedId = String(itemId || '').trim()
|
||||||
|
const normalizedSourceType = String(sourceType || '').trim()
|
||||||
|
if (!normalizedId) return null
|
||||||
|
|
||||||
|
if (normalizedId.startsWith('asset:') || normalizedSourceType === 'asset') {
|
||||||
|
const assetId = normalizedId.startsWith('asset:') ? normalizedId.slice(6) : normalizedId
|
||||||
|
const asset = await findImageAssetById(assetId)
|
||||||
|
if (!asset) return null
|
||||||
|
return {
|
||||||
|
id: `asset:${asset.id}`,
|
||||||
|
sourceType: 'asset',
|
||||||
|
src: asset.src || '',
|
||||||
|
label: asset.labelOverride || buildItemLabelFromSrc(asset.src),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedSourceType === 'template') {
|
||||||
|
const item = await findTopicItemById(normalizedId)
|
||||||
|
if (!item) return null
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
sourceType: 'template',
|
||||||
|
src: item.src || '',
|
||||||
|
label: item.label || buildItemLabelFromSrc(item.src),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customItem = await findCustomItemById(normalizedId)
|
||||||
|
if (customItem) {
|
||||||
|
return {
|
||||||
|
id: customItem.id,
|
||||||
|
sourceType: 'user',
|
||||||
|
src: customItem.src || '',
|
||||||
|
label: customItem.label || buildItemLabelFromSrc(customItem.src),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateItem = await findTopicItemById(normalizedId)
|
||||||
|
if (templateItem) {
|
||||||
|
return {
|
||||||
|
id: templateItem.id,
|
||||||
|
sourceType: 'template',
|
||||||
|
src: templateItem.src || '',
|
||||||
|
label: templateItem.label || buildItemLabelFromSrc(templateItem.src),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function copyUploadIntoTopicAsset(src) {
|
async function copyUploadIntoTopicAsset(src) {
|
||||||
if (typeof src !== 'string') return ''
|
if (typeof src !== 'string') return ''
|
||||||
const raw = src.trim()
|
const raw = src.trim()
|
||||||
@@ -760,6 +812,37 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
|||||||
res.json({ item })
|
res.json({ item })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
targetItemId: z.string().trim().min(1),
|
||||||
|
targetSourceType: 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' })
|
||||||
|
|
||||||
|
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
|
||||||
|
if (!sourceItem?.src) return res.status(404).json({ error: 'source_not_found' })
|
||||||
|
|
||||||
|
const targetItem = await findLibraryItemForReplacement(parsed.data.targetItemId, parsed.data.targetSourceType)
|
||||||
|
if (!targetItem?.src) return res.status(404).json({ error: 'target_not_found' })
|
||||||
|
if (sourceItem.src === targetItem.src && (sourceItem.label || '') === (targetItem.label || '')) {
|
||||||
|
return res.status(409).json({ error: 'same_target' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await replaceUploadSourceReferences({
|
||||||
|
fromSrc: sourceItem.src,
|
||||||
|
toSrc: targetItem.src,
|
||||||
|
toLabel: targetItem.label || '',
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
updatedRows: result.updatedRows || 0,
|
||||||
|
sourceItem,
|
||||||
|
targetItem,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
topicId: z.string().min(1),
|
topicId: z.string().min(1),
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.78
|
||||||
|
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
|
||||||
|
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.77
|
||||||
|
- 작성자 프로필 화면의 공개 티어표 카드는 같은 계열의 다른 목록 뷰와 거의 동일한 마크업을 쓰고 있었지만, `overflow: hidden`과 일부 최소 너비 제약이 빠져 있어 긴 제목/메타/썸네일이 카드 라운드 경계 안에서 안정적으로 잘리지 못한다고 판단했다.
|
||||||
|
- 또 공유용 프리뷰는 “완성된 티어표 보드”를 보여주는 화면이므로, 편집 중 보조 영역인 미사용 풀까지 노출하면 실제 배치 결과보다 산만해질 수 있어 프리뷰에서는 보드에 배치된 아이템만 노출하는 쪽이 더 일관된다고 정리했다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.76
|
## 2026-04-03 v1.4.76
|
||||||
- 프리뷰용 `viewerSidebar__section`은 데스크톱 오른쪽 레일에서 하단 액션 카드처럼 보이게 하려고 `margin-top: auto`를 갖고 있었지만, 모바일 전체 화면 overlay에서는 이 규칙이 카드를 바닥으로 밀어 과도하게 붙은 인상을 만들 수 있다고 판단했다.
|
- 프리뷰용 `viewerSidebar__section`은 데스크톱 오른쪽 레일에서 하단 액션 카드처럼 보이게 하려고 `margin-top: auto`를 갖고 있었지만, 모바일 전체 화면 overlay에서는 이 규칙이 카드를 바닥으로 밀어 과도하게 붙은 인상을 만들 수 있다고 판단했다.
|
||||||
- 게다가 `localRightRailRoot`가 최소 높이 100%를 유지한 채 상위 콘텐츠 컨테이너도 flex 남은 높이를 채우면, 하단 footer 영역과 Teleport 콘텐츠의 시각적 쌓임이 어색해질 수 있으므로 모바일 overlay에서는 콘텐츠 컨테이너를 내용 높이 기준으로 풀어 footer가 자연스럽게 아래로 따라오게 정리했다.
|
- 게다가 `localRightRailRoot`가 최소 높이 100%를 유지한 채 상위 콘텐츠 컨테이너도 flex 남은 높이를 채우면, 하단 footer 영역과 Teleport 콘텐츠의 시각적 쌓임이 어색해질 수 있으므로 모바일 overlay에서는 콘텐츠 컨테이너를 내용 높이 기준으로 풀어 footer가 자연스럽게 아래로 따라오게 정리했다.
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.78
|
||||||
|
- 관리자 아이템 관리 모달에 `선택한 이미지로 대체` 기능을 추가했다. 운영자는 대체할 원본 아이템을 연 뒤, 모달 안에서 다른 라이브러리 이미지를 검색·선택해 수동으로 치환할 수 있다.
|
||||||
|
- 이 치환은 단순히 `src`만 바꾸는 것이 아니라, 선택한 대상 이미지의 `라벨`도 함께 따라가도록 처리해 사용자 업로드 아이템과 티어표 저장 JSON 안의 표기가 같은 이름으로 정리되게 맞췄다.
|
||||||
|
- 백엔드의 업로드 참조 치환 로직은 이제 `custom_items`, `topic_items`, `tierlists.pool_json`, `template_requests.items_json / board_items_json`까지 `src + label`을 함께 갱신하므로, 같은 캐릭터를 서로 다른 저화질 이미지로 올린 경우도 관리자가 고화질 기준 이미지 하나로 수동 통합할 수 있다.
|
||||||
|
- 치환 후 기존 이미지 참조가 0이 되면, 기존처럼 미사용 이미지 정리 대상으로 후속 삭제할 수 있다.
|
||||||
|
- 확인: `node --check backend/src/routes/admin.js`, `node --check backend/src/db.js`, `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.77
|
||||||
|
- 작성자 프로필 보기 화면에서 공개 티어표 카드의 내부 콘텐츠가 카드 라운드 테두리 밖으로 밀려 보이거나 일부가 잘려 보일 수 있던 문제를 정리했다.
|
||||||
|
- `UserProfileView`의 카드 본문/헤더에 `overflow: hidden`을 맞추고 썸네일 래퍼에도 `min-width: 0`을 추가해, 다른 목록 화면과 같은 카드 경계 안에서 안정적으로 렌더링되도록 조정했다.
|
||||||
|
- 티어표 프리뷰 화면에서는 더 이상 `남은 아이템` 풀을 노출하지 않도록 바꿔, 실제 완성본과 공유 미리보기가 같은 기준으로 보이게 맞췄다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.76
|
## 2026-04-03 v1.4.76
|
||||||
- 모바일 티어표 프리뷰에서 오른쪽 레일의 `VIEWER MODE` 카드가 패널 바닥에 딱 붙고, 카피라이트 문구가 카드 뒤쪽 중간 높이에 겹쳐 보일 수 있던 배치를 보정했다.
|
- 모바일 티어표 프리뷰에서 오른쪽 레일의 `VIEWER MODE` 카드가 패널 바닥에 딱 붙고, 카피라이트 문구가 카드 뒤쪽 중간 높이에 겹쳐 보일 수 있던 배치를 보정했다.
|
||||||
- 모바일 오른쪽 overlay 레일에서는 `rightRail__content`가 남는 높이를 억지로 채우지 않도록 `flex: 0 0 auto`로 풀고, `localRightRailRoot`의 최소 높이도 `auto`로 낮춰 footer와 콘텐츠가 자연스럽게 순서대로 쌓이게 했다.
|
- 모바일 오른쪽 overlay 레일에서는 `rightRail__content`가 남는 높이를 억지로 채우지 않도록 `flex: 0 0 auto`로 풀고, `localRightRailRoot`의 최소 높이도 `auto`로 낮춰 footer와 콘텐츠가 자연스럽게 순서대로 쌓이게 했다.
|
||||||
- 프리뷰 전용 `viewerSidebar__section`의 `margin-top: auto`는 모바일에서만 끄고, 광고 아래에 바로 카드가 이어지도록 조정했다.
|
- 프리뷰 전용 `viewerSidebar__section`의 `margin-top: auto`는 모바일에서만 끄고, 광고 아래에 바로 카드가 이어지도록 조정했다.
|
||||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
|
||||||
|
|
||||||
## 2026-04-03 v1.4.75
|
## 2026-04-03 v1.4.75
|
||||||
- 모바일에서 오른쪽 레일을 열었을 때 패널이 `calc(100vw - 20px)` 폭의 좁은 서랍처럼 떠서 화면 전체를 채우지 못하고, 아래쪽도 어색하게 비어 보이던 부분을 조정했다.
|
- 모바일에서 오른쪽 레일을 열었을 때 패널이 `calc(100vw - 20px)` 폭의 좁은 서랍처럼 떠서 화면 전체를 채우지 못하고, 아래쪽도 어색하게 비어 보이던 부분을 조정했다.
|
||||||
- 모바일 오른쪽 레일 overlay는 `inset: 0`, `width: 100vw`, `height: 100dvh`로 화면 전체를 덮는 패널처럼 열리게 바꾸고, 하단 액션/공유 버튼이 바닥에 붙거나 잘려 보이지 않도록 내부 패딩을 `32px + safe-area`까지 늘렸다.
|
- 모바일 오른쪽 레일 overlay는 `inset: 0`, `width: 100vw`, `height: 100dvh`로 화면 전체를 덮는 패널처럼 열리게 바꾸고, 하단 액션/공유 버튼이 바닥에 붙거나 잘려 보이지 않도록 내부 패딩을 `32px + safe-area`까지 늘렸다.
|
||||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
|
||||||
|
|
||||||
## 2026-04-03 v1.4.74
|
## 2026-04-03 v1.4.74
|
||||||
- 모바일 본문 영역에서 `workspaceBody` 배경색이 좌우 마진 안쪽에만 칠해져 중앙에 어설픈 배경 박스가 떠 있는 것처럼 보이던 부분을 정리했다.
|
- 모바일 본문 영역에서 `workspaceBody` 배경색이 좌우 마진 안쪽에만 칠해져 중앙에 어설픈 배경 박스가 떠 있는 것처럼 보이던 부분을 정리했다.
|
||||||
- 모바일에서는 공통 워크스페이스 배경을 투명하게 두고, 실제 화면별 카드/섹션 배경만 남겨 덜 미완성처럼 보이도록 조정했다.
|
- 모바일에서는 공통 워크스페이스 배경을 투명하게 두고, 실제 화면별 카드/섹션 배경만 남겨 덜 미완성처럼 보이도록 조정했다.
|
||||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
|
||||||
|
|
||||||
## 2026-04-03 v1.4.73
|
## 2026-04-03 v1.4.73
|
||||||
- 모바일에서 왼쪽 레일 아래 메인 컨텐츠가 화면 중간부터 시작하는 것처럼 보이던 회귀를 수정했다.
|
- 모바일에서 왼쪽 레일 아래 메인 컨텐츠가 화면 중간부터 시작하는 것처럼 보이던 회귀를 수정했다.
|
||||||
- 원인은 모바일 `.appShell`이 1열 그리드로 바뀐 상태에서 세로 행 정의가 없어 `leftRail` 행과 `appMain` 행이 남는 높이를 나눠 가지며 위쪽이 불필요하게 늘어날 수 있던 점이었다. 모바일 그리드를 `auto + minmax(0, 1fr)` 행으로 고정하고 `align-content: start`를 적용해 상단 레일 바로 아래에 본문이 이어지도록 보정했다.
|
- 원인은 모바일 `.appShell`이 1열 그리드로 바뀐 상태에서 세로 행 정의가 없어 `leftRail` 행과 `appMain` 행이 남는 높이를 나눠 가지며 위쪽이 불필요하게 늘어날 수 있던 점이었다. 모바일 그리드를 `auto + minmax(0, 1fr)` 행으로 고정하고 `align-content: start`를 적용해 상단 레일 바로 아래에 본문이 이어지도록 보정했다.
|
||||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
|
||||||
|
|
||||||
## 2026-04-03 v1.4.72
|
## 2026-04-03 v1.4.72
|
||||||
- 모바일 공통 상단 헤더(`railHeader`) 좌우 패딩을 `20px`로 넓혀, 오른쪽 레일 토글 버튼과 화면 가장자리 간격이 왼쪽 유저 카드 쪽과 더 자연스럽게 맞도록 조정했다.
|
- 모바일 공통 상단 헤더(`railHeader`) 좌우 패딩을 `20px`로 넓혀, 오른쪽 레일 토글 버튼과 화면 가장자리 간격이 왼쪽 유저 카드 쪽과 더 자연스럽게 맞도록 조정했다.
|
||||||
- 모바일에서 오른쪽 레일 열기/닫기 아이콘도 왼쪽 네비게이션 토글과 같은 버튼형 카드 스타일로 보이도록 `42px` 크기, 테두리, 배경, 둥근 모서리를 맞췄다.
|
- 모바일에서 오른쪽 레일 열기/닫기 아이콘도 왼쪽 네비게이션 토글과 같은 버튼형 카드 스타일로 보이도록 `42px` 크기, 테두리, 배경, 둥근 모서리를 맞췄다.
|
||||||
- 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
|
||||||
|
|
||||||
## 2026-04-03 v1.4.71
|
## 2026-04-03 v1.4.71
|
||||||
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.
|
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export function useAdminCustomItems({
|
|||||||
customItemModalDraftLabel,
|
customItemModalDraftLabel,
|
||||||
customItemModalLabelSaving,
|
customItemModalLabelSaving,
|
||||||
customItemModalTargetTemplateId,
|
customItemModalTargetTemplateId,
|
||||||
|
customItemReplacementQuery,
|
||||||
|
customItemReplacementItems,
|
||||||
|
customItemReplacementLoading,
|
||||||
|
customItemReplacementTargetId,
|
||||||
|
customItemReplacementBusy,
|
||||||
templates,
|
templates,
|
||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
refreshCustomItems,
|
refreshCustomItems,
|
||||||
@@ -56,12 +61,41 @@ export function useAdminCustomItems({
|
|||||||
customItemModalHistoryActive.value = true
|
customItemModalHistoryActive.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshReplacementCandidates() {
|
||||||
|
const currentItemId = modalTargetCustomItem.value?.id || ''
|
||||||
|
if (!currentItemId) {
|
||||||
|
customItemReplacementItems.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
customItemReplacementLoading.value = true
|
||||||
|
const data = await api.listAdminCustomItems({
|
||||||
|
q: customItemReplacementQuery.value,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
filter: 'all',
|
||||||
|
})
|
||||||
|
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '대체할 이미지 목록을 불러오지 못했어요.'
|
||||||
|
customItemReplacementItems.value = []
|
||||||
|
} finally {
|
||||||
|
customItemReplacementLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCustomItemModal(item) {
|
function openCustomItemModal(item) {
|
||||||
modalTargetCustomItem.value = item || null
|
modalTargetCustomItem.value = item || null
|
||||||
customItemModalDraftLabel.value = item?.label || ''
|
customItemModalDraftLabel.value = item?.label || ''
|
||||||
customItemModalTargetTemplateId.value = ''
|
customItemModalTargetTemplateId.value = ''
|
||||||
|
customItemReplacementQuery.value = item?.label || ''
|
||||||
|
customItemReplacementItems.value = []
|
||||||
|
customItemReplacementTargetId.value = ''
|
||||||
|
customItemReplacementBusy.value = false
|
||||||
customItemModalOpen.value = true
|
customItemModalOpen.value = true
|
||||||
pushCustomItemModalHistoryState()
|
pushCustomItemModalHistoryState()
|
||||||
|
void refreshReplacementCandidates()
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCustomItemModal({ fromPopState = false } = {}) {
|
function closeCustomItemModal({ fromPopState = false } = {}) {
|
||||||
@@ -71,6 +105,11 @@ export function useAdminCustomItems({
|
|||||||
customItemModalDraftLabel.value = ''
|
customItemModalDraftLabel.value = ''
|
||||||
customItemModalLabelSaving.value = false
|
customItemModalLabelSaving.value = false
|
||||||
customItemModalTargetTemplateId.value = ''
|
customItemModalTargetTemplateId.value = ''
|
||||||
|
customItemReplacementQuery.value = ''
|
||||||
|
customItemReplacementItems.value = []
|
||||||
|
customItemReplacementTargetId.value = ''
|
||||||
|
customItemReplacementLoading.value = false
|
||||||
|
customItemReplacementBusy.value = false
|
||||||
|
|
||||||
if (fromPopState) {
|
if (fromPopState) {
|
||||||
customItemModalHistoryActive.value = false
|
customItemModalHistoryActive.value = false
|
||||||
@@ -190,6 +229,35 @@ export function useAdminCustomItems({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function replaceCustomItem(item = modalTargetCustomItem.value) {
|
||||||
|
resetMessages()
|
||||||
|
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
|
||||||
|
if (!item?.id) {
|
||||||
|
error.value = '대체할 원본 아이템을 찾지 못했어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!targetItem?.id) {
|
||||||
|
error.value = '대체할 대상 이미지를 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
customItemReplacementBusy.value = true
|
||||||
|
const data = await api.replaceAdminCustomItem(item.id, {
|
||||||
|
targetItemId: targetItem.id,
|
||||||
|
targetSourceType: targetItem.sourceType || 'user',
|
||||||
|
})
|
||||||
|
if (selectedTemplateId.value) await loadTemplate()
|
||||||
|
await refreshCustomItems()
|
||||||
|
closeCustomItemModal()
|
||||||
|
success.value = `"${item.label}" 이미지를 "${data.targetItem?.label || targetItem.label}" 기준으로 대체했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.status === 409 ? '같은 이미지/이름으로는 대체할 수 없어요.' : '이미지 대체에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
customItemReplacementBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submitCustomItemSearch,
|
submitCustomItemSearch,
|
||||||
changeCustomItemFilter,
|
changeCustomItemFilter,
|
||||||
@@ -205,5 +273,7 @@ export function useAdminCustomItems({
|
|||||||
removeUnusedCustomItems,
|
removeUnusedCustomItems,
|
||||||
saveCustomItemModalLabel,
|
saveCustomItemModalLabel,
|
||||||
promoteCustomItem,
|
promoteCustomItem,
|
||||||
|
refreshReplacementCandidates,
|
||||||
|
replaceCustomItem,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export const api = {
|
|||||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||||
promoteAdminTemplateItem: (itemId, payload) =>
|
promoteAdminTemplateItem: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
|
replaceAdminCustomItem: (itemId, payload) =>
|
||||||
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }),
|
||||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||||
promoteAdminTierListItems: (tierListId, payload) =>
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ const customItemLimit = ref(50)
|
|||||||
const customItemTotal = ref(0)
|
const customItemTotal = ref(0)
|
||||||
const customItemFilter = ref('library')
|
const customItemFilter = ref('library')
|
||||||
const customItemModalTargetTemplateId = ref('')
|
const customItemModalTargetTemplateId = ref('')
|
||||||
|
const customItemReplacementQuery = ref('')
|
||||||
|
const customItemReplacementItems = ref([])
|
||||||
|
const customItemReplacementLoading = ref(false)
|
||||||
|
const customItemReplacementTargetId = ref('')
|
||||||
|
const customItemReplacementBusy = ref(false)
|
||||||
|
|
||||||
const adminTierLists = ref([])
|
const adminTierLists = ref([])
|
||||||
const adminTierListQuery = ref('')
|
const adminTierListQuery = ref('')
|
||||||
@@ -227,6 +232,9 @@ const filteredTemplatePickerTemplates = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const customItemTargetTemplate = computed(() => templates.value.find((template) => template.id === customItemModalTargetTemplateId.value) || null)
|
const customItemTargetTemplate = computed(() => templates.value.find((template) => template.id === customItemModalTargetTemplateId.value) || null)
|
||||||
|
const customItemReplacementTarget = computed(
|
||||||
|
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
|
||||||
|
)
|
||||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||||
const activeTabTitle = computed(() => {
|
const activeTabTitle = computed(() => {
|
||||||
if (activeTab.value === 'featured') return '목록 관리'
|
if (activeTab.value === 'featured') return '목록 관리'
|
||||||
@@ -647,6 +655,7 @@ const visibleLinkedTemplates = computed(() =>
|
|||||||
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
|
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
|
||||||
)
|
)
|
||||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||||
|
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
||||||
|
|
||||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||||
const imageStatsYearOptions = computed(() => {
|
const imageStatsYearOptions = computed(() => {
|
||||||
@@ -1041,6 +1050,8 @@ const {
|
|||||||
removeUnusedCustomItems,
|
removeUnusedCustomItems,
|
||||||
saveCustomItemModalLabel,
|
saveCustomItemModalLabel,
|
||||||
promoteCustomItem,
|
promoteCustomItem,
|
||||||
|
refreshReplacementCandidates,
|
||||||
|
replaceCustomItem,
|
||||||
} = useAdminCustomItems({
|
} = useAdminCustomItems({
|
||||||
api,
|
api,
|
||||||
toast,
|
toast,
|
||||||
@@ -1057,6 +1068,11 @@ const {
|
|||||||
customItemModalDraftLabel,
|
customItemModalDraftLabel,
|
||||||
customItemModalLabelSaving,
|
customItemModalLabelSaving,
|
||||||
customItemModalTargetTemplateId,
|
customItemModalTargetTemplateId,
|
||||||
|
customItemReplacementQuery,
|
||||||
|
customItemReplacementItems,
|
||||||
|
customItemReplacementLoading,
|
||||||
|
customItemReplacementTargetId,
|
||||||
|
customItemReplacementBusy,
|
||||||
templates,
|
templates,
|
||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
refreshCustomItems,
|
refreshCustomItems,
|
||||||
@@ -2079,6 +2095,49 @@ function openUserProfile(user) {
|
|||||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||||
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="customItemModal__pickerHead">
|
||||||
|
<div class="customItemModal__pickerEyebrow">IMAGE REPLACEMENT</div>
|
||||||
|
<div class="customItemModal__pickerTitle">대체할 이미지 선택</div>
|
||||||
|
</div>
|
||||||
|
<div class="adminSelectionCard">
|
||||||
|
<div class="adminSelectionCard__label">선택한 대체 이미지</div>
|
||||||
|
<div class="adminSelectionCard__title">{{ customItemReplacementTarget?.label || '아직 선택하지 않음' }}</div>
|
||||||
|
<div class="adminSelectionCard__meta">{{ customItemReplacementTarget?.sourceLabel || '검색 후 대체할 이미지를 골라 주세요.' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="customItemModal__pickerActions">
|
||||||
|
<input
|
||||||
|
v-model="customItemReplacementQuery"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="대체할 이미지 이름 또는 파일명 검색"
|
||||||
|
@keydown.enter.prevent="refreshReplacementCandidates"
|
||||||
|
/>
|
||||||
|
<button class="btn btn--ghost btn--small" type="button" @click="refreshReplacementCandidates">
|
||||||
|
{{ customItemReplacementLoading ? '검색중...' : '대체 이미지 검색' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="customItemModal__replacementList">
|
||||||
|
<button
|
||||||
|
v-for="item in customItemReplacementItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="adminTemplatePicker__item"
|
||||||
|
:class="{ 'adminTemplatePicker__item--active': customItemReplacementTargetId === item.id }"
|
||||||
|
type="button"
|
||||||
|
@click="customItemReplacementTargetId = item.id"
|
||||||
|
>
|
||||||
|
<span class="customItemModal__replacementRow">
|
||||||
|
<img class="customItemModal__replacementThumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||||
|
<span class="customItemModal__replacementCopy">
|
||||||
|
<span class="adminTemplatePicker__name">{{ item.label }}</span>
|
||||||
|
<span class="adminTemplatePicker__meta">{{ item.sourceLabel }} · {{ item.ownerName || '알 수 없음' }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!customItemReplacementLoading && !replacementCandidateCount" class="hint hint--tight">
|
||||||
|
대체 후보가 없어요. 검색어를 바꾸거나 먼저 관리자 이미지를 등록해주세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="customItemModal__body">
|
<div class="customItemModal__body">
|
||||||
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
|
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
|
||||||
@@ -2119,6 +2178,9 @@ function openUserProfile(user) {
|
|||||||
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetTemplateId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetTemplateId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
||||||
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
|
||||||
|
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
|
||||||
|
</button>
|
||||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3611,6 +3673,29 @@ function openUserProfile(user) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.adminUiScope .customItemModal__replacementList {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.adminUiScope .customItemModal__replacementRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.adminUiScope .customItemModal__replacementThumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
background: var(--theme-surface-soft);
|
||||||
|
}
|
||||||
|
.adminUiScope .customItemModal__replacementCopy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
.adminUiScope .customItemModal__createTemplateButton {
|
.adminUiScope .customItemModal__createTemplateButton {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
@@ -3736,7 +3821,7 @@ function openUserProfile(user) {
|
|||||||
}
|
}
|
||||||
.adminUiScope .customItemModal__actions {
|
.adminUiScope .customItemModal__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-self: end;
|
align-self: end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1396,14 +1396,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="pool.length" class="previewOnly__pool">
|
|
||||||
<div class="previewOnly__poolTitle">남은 아이템</div>
|
|
||||||
<div class="previewOnly__poolGrid">
|
|
||||||
<div v-for="id in pool" :key="id" class="previewOnly__poolItem previewOnly__poolItem--inactive">
|
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="previewOnly__footer">
|
<div class="previewOnly__footer">
|
||||||
<span>{{ effectiveAuthorName }}</span>
|
<span>{{ effectiveAuthorName }}</span>
|
||||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||||
|
|||||||
@@ -333,8 +333,10 @@ watch(userId, loadProfile, { immediate: true })
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__thumbWrap {
|
.boardCard__thumbWrap {
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
padding: 14px 14px 0;
|
padding: 14px 14px 0;
|
||||||
@@ -363,6 +365,7 @@ watch(userId, loadProfile, { immediate: true })
|
|||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
|
|||||||
Reference in New Issue
Block a user