Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b134431d91 | |||
| c7cafb87c3 |
@@ -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,
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
findUnusedCustomItems,
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
markCustomItemReplaced,
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
@@ -834,11 +835,14 @@ 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 || '',
|
||||
})
|
||||
|
||||
const sourceCustomItems = await findCustomItemsByIds([sourceItem.id])
|
||||
await deleteCustomItems([sourceItem.id])
|
||||
await removeCustomItemFiles(sourceCustomItems)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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
|
||||
- 관리자 이미지 대체 모달은 처음 열었을 때 기존 목록을 자동으로 보여주지 않고, 검색을 실행한 뒤에만 대체 후보를 표시하도록 바꿨다. 같은 티어표/같은 문맥의 항목이 처음부터 섞여 보여 혼란스럽던 점을 줄이기 위한 조정이다.
|
||||
- 사용자 업로드 아이템을 다른 이미지로 대체할 때는 이제 원본 항목을 그대로 `대상 이미지와 라벨`로 덮어써 중복 항목을 남기지 않고, 참조를 옮긴 뒤 원본 사용자 아이템 레코드와 파일을 함께 정리해 관리자 라이브러리에 동일 이미지가 두 번 보이지 않도록 맞췄다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2179,6 +2179,19 @@ 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)">
|
||||
@@ -3620,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);
|
||||
|
||||
Reference in New Issue
Block a user