Compare commits

...

3 Commits

7 changed files with 167 additions and 81 deletions

View File

@@ -115,6 +115,21 @@ function mapTopicItemRow(row) {
}
}
function mapCustomItemRow(row) {
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
replacedByItemId: row.replaced_by_item_id || '',
replacedBySrc: row.replaced_by_src || '',
replacedByLabel: row.replaced_by_label || '',
replacedAt: Number(row.replaced_at || 0),
}
}
function mapImageAssetRow(row) {
if (!row) return null
return {
@@ -360,12 +375,21 @@ async function ensureSchema() {
owner_id VARCHAR(64) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '',
replaced_by_src VARCHAR(255) NOT NULL DEFAULT '',
replaced_by_label VARCHAR(120) NOT NULL DEFAULT '',
replaced_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_custom_items_owner_id (owner_id),
CONSTRAINT fk_custom_items_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_item_id VARCHAR(64) NOT NULL DEFAULT '' AFTER label`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_src VARCHAR(255) NOT NULL DEFAULT '' AFTER replaced_by_item_id`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_by_label VARCHAR(120) NOT NULL DEFAULT '' AFTER replaced_by_src`)
await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS replaced_at BIGINT NOT NULL DEFAULT 0 AFTER replaced_by_label`)
await query(`
CREATE TABLE IF NOT EXISTS tierlists (
id VARCHAR(64) PRIMARY KEY,
@@ -1128,7 +1152,7 @@ function replaceItemSrc(items, fromSrc, toSrc, toLabel = '') {
return { changed, items: nextItems }
}
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '', updateCustomItemsBySrc = true }) {
if (!fromSrc || !toSrc || fromSrc === toSrc) return { updatedRows: 0 }
const normalizedLabel = typeof toLabel === 'string' ? toLabel.trim().slice(0, 60) : ''
@@ -1138,9 +1162,11 @@ async function replaceUploadSourceReferences({ fromSrc, toSrc, toLabel = '' }) {
normalizedLabel
? 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]),
updateCustomItemsBySrc
? normalizedLabel
? query('UPDATE custom_items SET src = ?, label = ? WHERE src = ?', [toSrc, normalizedLabel, fromSrc])
: query('UPDATE custom_items SET src = ? WHERE src = ?', [toSrc, fromSrc])
: Promise.resolve({ affectedRows: 0 }),
])
let updatedRows = Number(userResult.affectedRows || 0) + Number(topicResult.affectedRows || 0) + Number(topicItemResult.affectedRows || 0) + Number(customItemResult.affectedRows || 0)
@@ -1518,7 +1544,7 @@ async function updateTopicItemDisplayOrder(topicId, itemIds) {
async function updateCustomItemLabel(itemId, label) {
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query(`
SELECT c.id, c.owner_id, c.src, c.label, c.created_at, u.nickname, u.email
SELECT c.id, c.owner_id, c.src, c.label, c.replaced_by_item_id, c.replaced_by_src, c.replaced_by_label, c.replaced_at, c.created_at, u.nickname, u.email
FROM custom_items c
INNER JOIN users u ON u.id = c.owner_id
WHERE c.id = ?
@@ -1527,16 +1553,20 @@ async function updateCustomItemLabel(itemId, label) {
const row = rows[0]
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
}
}
async function markCustomItemReplaced({ itemId, replacedByItemId = '', replacedBySrc = '', replacedByLabel = '' }) {
await query(
'UPDATE custom_items SET replaced_by_item_id = ?, replaced_by_src = ?, replaced_by_label = ?, replaced_at = ? WHERE id = ?',
[replacedByItemId || '', replacedBySrc || '', replacedByLabel || '', now(), itemId]
)
return findCustomItemById(itemId)
}
async function updateImageAssetLabel(assetId, label) {
await query('UPDATE image_assets SET label_override = ? WHERE id = ?', [label, assetId])
const rows = await query('SELECT id, content_hash, src, label_override, mime_type, byte_size, original_byte_size, width, height, created_at FROM image_assets WHERE id = ? LIMIT 1', [assetId])
@@ -1626,7 +1656,7 @@ async function syncOwnedCustomItemLabels({ ownerId, items }) {
async function findCustomItemById(id) {
const rows = await query(
`
SELECT id, owner_id, src, label, created_at
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id = ?
LIMIT 1
@@ -1635,14 +1665,7 @@ async function findCustomItemById(id) {
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}
return mapCustomItemRow(row)
}
async function getCustomItemUsageMeta() {
@@ -1707,6 +1730,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
c.owner_id,
c.src,
c.label,
c.replaced_by_item_id,
c.replaced_by_src,
c.replaced_by_label,
c.replaced_at,
c.created_at,
u.nickname,
u.email
@@ -1786,17 +1813,14 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
const linkedTemplates = Array.from((templateLinkedBySrc.get(row.src) || new Map()).values())
return {
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates,
assetKind: resolveLibraryAssetKind(row.src),
sourceType: 'user',
sourceLabel: '사용자 아이템',
sourceLabel: Number(row.replaced_at || 0) > 0 ? '대체된 사용자 아이템' : '사용자 아이템',
canDelete: true,
}
})
@@ -1895,6 +1919,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: entry.usageCount || 0,
linkedTemplates: entry.linkedTemplates || [],
isAssetLibraryItem: !!entry.isAssetLibraryItem,
replacedByItemId: entry.replacedByItemId || '',
replacedBySrc: entry.replacedBySrc || '',
replacedByLabel: entry.replacedByLabel || '',
replacedAt: entry.replacedAt || 0,
})),
}
})
@@ -1947,6 +1975,10 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
c.owner_id,
c.src,
c.label,
c.replaced_by_item_id,
c.replaced_by_src,
c.replaced_by_label,
c.replaced_at,
c.created_at,
u.nickname,
u.email
@@ -1961,11 +1993,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
const { usageMap } = await getCustomItemUsageMeta()
return rows
.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
...mapCustomItemRow(row),
ownerName: row.nickname || row.email,
ownerEmail: row.email,
usageCount: usageMap.get(row.id) || 0,
@@ -2895,20 +2923,14 @@ async function findCustomItemsByIds(ids) {
const placeholders = ids.map(() => '?').join(', ')
const rows = await query(
`
SELECT id, owner_id, src, label, created_at
SELECT id, owner_id, src, label, replaced_by_item_id, replaced_by_src, replaced_by_label, replaced_at, created_at
FROM custom_items
WHERE id IN (${placeholders})
`,
ids
)
return rows.map((row) => ({
id: row.id,
ownerId: row.owner_id,
src: row.src,
label: row.label,
createdAt: Number(row.created_at),
}))
return rows.map(mapCustomItemRow)
}
async function deleteCustomItems(ids) {
@@ -3064,6 +3086,7 @@ module.exports = {
deleteTopic,
updateTopicDisplayOrder,
updateCustomItemLabel,
markCustomItemReplaced,
updateImageAssetLabel,
createCustomItem,
findCustomItemById,

View File

@@ -33,6 +33,7 @@ const {
findUnusedCustomItems,
findCustomItemsByIds,
deleteCustomItems,
markCustomItemReplaced,
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
@@ -822,6 +823,7 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
if (!sourceItem?.src) return res.status(404).json({ error: 'source_not_found' })
if (sourceItem.sourceType !== 'user') return res.status(400).json({ error: 'user_item_required' })
const targetItem = await findLibraryItemForReplacement(parsed.data.targetItemId, parsed.data.targetSourceType)
if (!targetItem?.src) return res.status(404).json({ error: 'target_not_found' })
@@ -833,6 +835,13 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
fromSrc: sourceItem.src,
toSrc: targetItem.src,
toLabel: targetItem.label || '',
updateCustomItemsBySrc: false,
})
await markCustomItemReplaced({
itemId: sourceItem.id,
replacedByItemId: targetItem.id || '',
replacedBySrc: targetItem.src || '',
replacedByLabel: targetItem.label || '',
})
res.json({

View File

@@ -1,5 +1,17 @@
# 의사결정 이력
## 2026-04-06 v1.4.81
- 원본 보존을 하더라도 실제 `custom_items.src``label`까지 대체 대상 값으로 덮어쓰면 관리자 입장에서는 “원본을 남겼다”기보다 “같은 새 이미지가 두 번 보이는 상태”로 읽히므로, 원본 아이템 레코드는 실제 이미지/라벨을 그대로 유지하고 참조 이동 대상에서만 제외하는 편이 맞다고 정리했다.
- 즉 대체 이력 메타(`어떤 아이템으로 대체됐는지`)와 원본 본문 데이터(`원래 A 이미지/이름`)는 분리해서 보존해야, 이후 복구나 검수 기준으로 의미가 살아난다고 판단했다.
## 2026-04-06 v1.4.80
- 이미지 대체 직후 원본 사용자 아이템을 완전히 지워버리면 관리자 입장에서는 “왜 바꿨는지”, “나중에 정리해도 되는지”를 다시 확인할 근거가 사라지므로, 참조 이동과 원본 보존을 분리하는 편이 운영 흐름에 더 맞다고 판단했다.
- 다만 대체 완료된 원본까지 별도 보호 대상으로 빼면 라이브러리 정리가 끝없이 쌓일 수 있으므로, 원본은 `대체됨` 상태로 계속 보이게 하되 이미 미사용인 이상 `미사용 아이템 일괄 삭제`와 개별 삭제로 언제든 정리할 수 있게 두는 쪽이 균형이 맞는다고 정리했다.
## 2026-04-06 v1.4.79
- 관리자 대체 모달은 열자마자 현재 라이브러리 결과를 그대로 쏟아내면 “같은 보드 안의 비슷한 항목을 고르는 화면”처럼 읽히기 쉬우므로, 검색 전에는 후보를 비워 두고 운영자가 의도적으로 찾은 뒤 고르는 방식이 더 분명하다고 판단했다.
- 또 사용자 업로드 A를 F로 대체했을 때 관리자 목록에 F가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다.
## 2026-04-06 v1.4.78
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.

View File

@@ -1,5 +1,23 @@
# 업데이트 로그
## 2026-04-06 v1.4.81
- 관리자 이미지 대체 시 원본 사용자 아이템 레코드의 `src / label`까지 대체 대상 이미지로 덮어써져, 카드와 상세 모달에서 원래 A 이미지 정보를 다시 볼 수 없던 문제를 바로잡았다.
- 이제 대체 처리에서는 티어표/요청/템플릿 참조만 새 이미지로 옮기고, 원본 사용자 아이템 레코드는 원래 이미지와 이름을 그대로 유지한 채 `대체됨` 메타만 남긴다.
- 그래서 관리자 화면에서는 원래 A 썸네일과 A 라벨을 계속 확인하면서도, 이 아이템이 어떤 대상(Y)으로 대체되었는지 함께 보고 이후 수동 정리나 복구 판단을 할 수 있다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.80
- 관리자 이미지 대체는 더 이상 원본 사용자 아이템을 즉시 삭제하지 않고, 원본 레코드와 파일을 남겨 둔 채 `어떤 아이템으로 대체됐는지` 메타만 기록하도록 바꿨다.
- 따라서 대체 후에도 원본 이미지는 아이템 라이브러리에서 계속 확인할 수 있고, 카드와 상세 모달에서 `대체됨` 상태와 대체 대상 라벨을 함께 볼 수 있다.
- 다만 이 원본은 이미 참조가 옮겨진 미사용 사용자 아이템이므로, 기존 `미사용 아이템 일괄 삭제`와 개별 삭제 대상에는 그대로 포함되게 유지해 운영자가 원할 때 정리할 수 있게 했다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.79
- 관리자 이미지 대체 모달은 처음 열었을 때 기존 목록을 자동으로 보여주지 않고, 검색을 실행한 뒤에만 대체 후보를 표시하도록 바꿨다. 같은 티어표/같은 문맥의 항목이 처음부터 섞여 보여 혼란스럽던 점을 줄이기 위한 조정이다.
- 사용자 업로드 아이템을 다른 이미지로 대체할 때는 이제 원본 항목을 그대로 `대상 이미지와 라벨`로 덮어써 중복 항목을 남기지 않고, 참조를 옮긴 뒤 원본 사용자 아이템 레코드와 파일을 함께 정리해 관리자 라이브러리에 동일 이미지가 두 번 보이지 않도록 맞췄다.
- 이미지 대체 기능은 현재 사용자 업로드 아이템에만 노출되도록 제한해, 템플릿 기본 아이템이나 보관 자산까지 같은 방식으로 다뤄 생길 수 있는 오해를 줄였다.
- 확인: `node --check backend/src/routes/admin.js`, `npm run build`
## 2026-04-06 v1.4.78
- 관리자 아이템 관리 모달에 `선택한 이미지로 대체` 기능을 추가했다. 운영자는 대체할 원본 아이템을 연 뒤, 모달 안에서 다른 라이브러리 이미지를 검색·선택해 수동으로 치환할 수 있다.
- 이 치환은 단순히 `src`만 바꾸는 것이 아니라, 선택한 대상 이미지의 `라벨`도 함께 따라가도록 처리해 사용자 업로드 아이템과 티어표 저장 JSON 안의 표기가 같은 이름으로 정리되게 맞췄다.

View File

@@ -25,6 +25,7 @@ const props = defineProps({
>
{{ item.sourceLabel }}
</span>
<span v-if="item.replacedAt" class="customItemCard__badge customItemCard__badge--replaced">대체됨</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

@@ -89,13 +89,12 @@ export function useAdminCustomItems({
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetTemplateId.value = ''
customItemReplacementQuery.value = item?.label || ''
customItemReplacementQuery.value = ''
customItemReplacementItems.value = []
customItemReplacementTargetId.value = ''
customItemReplacementBusy.value = false
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
void refreshReplacementCandidates()
}
function closeCustomItemModal({ fromPopState = false } = {}) {

View File

@@ -655,6 +655,7 @@ const visibleLinkedTemplates = computed(() =>
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
)
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
const canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
@@ -2095,48 +2096,53 @@ function openUserProfile(user) {
<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>
</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">
대체 후보가 없어요. 검색어를 바꾸거나 먼저 관리자 이미지를 등록해주세요.
<template v-if="canReplaceModalTarget">
<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>
</template>
<div v-else class="hint hint--tight">
이미지 대체는 현재 사용자 업로드 아이템에서만 지원합니다.
</div>
</aside>
<div class="customItemModal__body">
@@ -2173,12 +2179,25 @@ function openUserProfile(user) {
</div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
</div>
<div v-if="modalTargetCustomItem.replacedAt" class="customItemModal__linked">
<span class="customItemModal__label">대체 상태</span>
<div class="customItemModal__titleRow">
<div>
<div class="customItemModal__title">{{ modalTargetCustomItem.replacedByLabel || '대체 대상 이름 없음' }}</div>
<div class="customItemModal__source"> 아이템은 대상 이미지로 대체된 상태예요.</div>
</div>
</div>
<div class="customItemModal__metaRow">
<span>대체 시각</span>
<strong>{{ fmt(modalTargetCustomItem.replacedAt) }}</strong>
</div>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetTemplateId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
<button v-if="canReplaceModalTarget" 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>
@@ -3614,6 +3633,11 @@ function openUserProfile(user) {
.adminUiScope .customItemCard__badge--asset {
background: rgba(251, 191, 36, 0.18);
}
.adminUiScope .customItemCard__badge--replaced {
top: 40px;
background: rgba(245, 158, 11, 0.2);
color: #fde68a;
}
.adminUiScope .customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);