Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7164d32ae8 | |||
| dddc29fd4b | |||
| 947837fe40 | |||
| 5ef833fde5 | |||
| 67e192b0e1 | |||
| 8ef011bfc8 | |||
| 9403e3698d | |||
| 5b15ec12fa | |||
| 28c6dafa02 | |||
| f98524390b | |||
| da37fe9fc9 | |||
| d09cd7e508 | |||
| 907ea75182 | |||
| 4285883e28 |
@@ -14,6 +14,7 @@ const topicsRoutes = require('./src/routes/topics')
|
|||||||
const tierListsRoutes = require('./src/routes/tierlists')
|
const tierListsRoutes = require('./src/routes/tierlists')
|
||||||
const usersRoutes = require('./src/routes/users')
|
const usersRoutes = require('./src/routes/users')
|
||||||
const adminRoutes = require('./src/routes/admin')
|
const adminRoutes = require('./src/routes/admin')
|
||||||
|
const shareRoutes = require('./src/routes/share')
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 5179
|
||||||
@@ -88,6 +89,7 @@ app.use('/api/topics', topicsRoutes)
|
|||||||
app.use('/api/tierlists', tierListsRoutes)
|
app.use('/api/tierlists', tierListsRoutes)
|
||||||
app.use('/api/users', usersRoutes)
|
app.use('/api/users', usersRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
|
app.use('/share', shareRoutes)
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[backend] listening on http://localhost:${PORT}`)
|
console.log(`[backend] listening on http://localhost:${PORT}`)
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -1795,7 +1804,11 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
|||||||
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
const templateSrcSet = new Set(topicItemRows.map((row) => row.src).filter(Boolean))
|
||||||
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
const customSrcSet = new Set(customRows.map((row) => row.src).filter(Boolean))
|
||||||
const assetLibraryItems = assetRows
|
const assetLibraryItems = assetRows
|
||||||
.filter((row) => row?.src && !templateSrcSet.has(row.src) && !customSrcSet.has(row.src))
|
.filter((row) => {
|
||||||
|
if (!row?.src) return false
|
||||||
|
if (avatarSrcSet.has(row.src) || thumbnailSrcSet.has(row.src)) return true
|
||||||
|
return !templateSrcSet.has(row.src) && !customSrcSet.has(row.src)
|
||||||
|
})
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
id: `asset:${row.id}`,
|
id: `asset:${row.id}`,
|
||||||
assetId: row.id,
|
assetId: row.id,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
75
backend/src/routes/share.js
Normal file
75
backend/src/routes/share.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const { findTierListById } = require('../db')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
const APP_ORIGIN = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '')
|
||||||
|
const DEFAULT_TITLE = 'Tier Maker | 템플릿으로 쉽게 만드는 티어표'
|
||||||
|
const DEFAULT_DESCRIPTION = '템플릿과 커스텀 이미지로 티어표를 만들고 저장하고 공유하세요.'
|
||||||
|
const DEFAULT_IMAGE_URL = `${APP_ORIGIN}/og-card.png`
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAbsoluteUrl(pathname) {
|
||||||
|
const src = String(pathname || '').trim()
|
||||||
|
if (!src) return DEFAULT_IMAGE_URL
|
||||||
|
if (/^https?:\/\//i.test(src)) return src
|
||||||
|
return `${APP_ORIGIN}${src.startsWith('/') ? src : `/${src}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }) {
|
||||||
|
const safeTitle = escapeHtml(title || DEFAULT_TITLE)
|
||||||
|
const safeDescription = escapeHtml(description || DEFAULT_DESCRIPTION)
|
||||||
|
const safeImageUrl = escapeHtml(imageUrl || DEFAULT_IMAGE_URL)
|
||||||
|
const safeShareUrl = escapeHtml(shareUrl || APP_ORIGIN)
|
||||||
|
const safeAppUrl = escapeHtml(appUrl || APP_ORIGIN)
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${safeTitle}</title>
|
||||||
|
<meta name="description" content="${safeDescription}" />
|
||||||
|
<link rel="canonical" href="${safeAppUrl}" />
|
||||||
|
<meta property="og:site_name" content="Tier Maker" />
|
||||||
|
<meta property="og:locale" content="ko_KR" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="${safeShareUrl}" />
|
||||||
|
<meta property="og:title" content="${safeTitle}" />
|
||||||
|
<meta property="og:description" content="${safeDescription}" />
|
||||||
|
<meta property="og:image" content="${safeImageUrl}" />
|
||||||
|
<meta property="og:image:alt" content="${safeTitle}" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="${safeTitle}" />
|
||||||
|
<meta name="twitter:description" content="${safeDescription}" />
|
||||||
|
<meta name="twitter:image" content="${safeImageUrl}" />
|
||||||
|
<meta http-equiv="refresh" content="0; url=${safeAppUrl}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>window.location.replace(${JSON.stringify(appUrl || APP_ORIGIN)})</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/editor/:topicId/:tierListId', async (req, res) => {
|
||||||
|
const { topicId, tierListId } = req.params
|
||||||
|
const appUrl = `${APP_ORIGIN}/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}?preview=1`
|
||||||
|
const shareUrl = `${APP_ORIGIN}${req.originalUrl || `/share/editor/${encodeURIComponent(topicId)}/${encodeURIComponent(tierListId)}`}`
|
||||||
|
|
||||||
|
const tierList = await findTierListById(tierListId)
|
||||||
|
const isPublicMatch = tierList?.isPublic && (tierList.topicSlug === topicId || tierList.topicId === topicId)
|
||||||
|
const title = isPublicMatch ? tierList.title : DEFAULT_TITLE
|
||||||
|
const description = isPublicMatch && tierList.description ? tierList.description : DEFAULT_DESCRIPTION
|
||||||
|
const imageUrl = isPublicMatch ? toAbsoluteUrl(tierList.thumbnailSrc) : DEFAULT_IMAGE_URL
|
||||||
|
|
||||||
|
res.type('html').send(buildShareHtml({ title, description, imageUrl, shareUrl, appUrl }))
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -1,5 +1,59 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.78
|
||||||
|
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
|
||||||
|
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-06 v1.4.77
|
||||||
|
- 작성자 프로필 화면의 공개 티어표 카드는 같은 계열의 다른 목록 뷰와 거의 동일한 마크업을 쓰고 있었지만, `overflow: hidden`과 일부 최소 너비 제약이 빠져 있어 긴 제목/메타/썸네일이 카드 라운드 경계 안에서 안정적으로 잘리지 못한다고 판단했다.
|
||||||
|
- 또 공유용 프리뷰는 “완성된 티어표 보드”를 보여주는 화면이므로, 편집 중 보조 영역인 미사용 풀까지 노출하면 실제 배치 결과보다 산만해질 수 있어 프리뷰에서는 보드에 배치된 아이템만 노출하는 쪽이 더 일관된다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.76
|
||||||
|
- 프리뷰용 `viewerSidebar__section`은 데스크톱 오른쪽 레일에서 하단 액션 카드처럼 보이게 하려고 `margin-top: auto`를 갖고 있었지만, 모바일 전체 화면 overlay에서는 이 규칙이 카드를 바닥으로 밀어 과도하게 붙은 인상을 만들 수 있다고 판단했다.
|
||||||
|
- 게다가 `localRightRailRoot`가 최소 높이 100%를 유지한 채 상위 콘텐츠 컨테이너도 flex 남은 높이를 채우면, 하단 footer 영역과 Teleport 콘텐츠의 시각적 쌓임이 어색해질 수 있으므로 모바일 overlay에서는 콘텐츠 컨테이너를 내용 높이 기준으로 풀어 footer가 자연스럽게 아래로 따라오게 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.75
|
||||||
|
- 데스크톱/태블릿에서는 오른쪽 레일을 폭이 정해진 서랍 패널처럼 여는 게 맞지만, 모바일에서는 같은 폭 규칙을 유지하면 오히려 “오른쪽에서 덜 열린 반쪽 패널”처럼 보여 하단 공간까지 어색해질 수 있다고 판단했다.
|
||||||
|
- 그래서 모바일 한정으로 오른쪽 레일 overlay를 전체 화면 패널로 바꾸고, 공유/복사 같은 하단 액션이 기기 하단 UI나 safe-area에 붙어 잘리지 않도록 내부 바디 패딩을 더 넉넉하게 두는 쪽으로 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.74
|
||||||
|
- 모바일에서는 `workspaceBody` 자체가 카드처럼 배경색을 가지는 것보다, 바깥 앱 셸 배경 위에 각 화면의 실제 카드/섹션만 떠 있는 편이 시각 구조가 더 명확하다고 판단했다.
|
||||||
|
- 특히 `workspaceBody`가 좌우 마진을 가진 상태로 별도 배경색을 칠하면 “내용과 무관한 중간 레이어 박스”처럼 보일 수 있으므로, 모바일 한정으로 공통 워크스페이스 배경을 투명 처리해 불필요한 레이어감을 줄이는 쪽으로 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.73
|
||||||
|
- 모바일 `appShell`은 PC처럼 좌우 3열이 아니라 위쪽 레일과 아래쪽 본문이 세로로 쌓이는 2행 구조이므로, 열 정의만 1fr로 바꾸고 행 정의를 비워두면 암묵 그리드 행이 남는 높이를 늘려 본문이 아래로 밀려 보일 수 있다고 판단했다.
|
||||||
|
- 이 문제는 각 화면 본문을 개별 조정하기보다 모바일 셸 컨테이너에서 첫 행은 `auto`, 본문 행은 `minmax(0, 1fr)`로 고정하고 전체 콘텐츠 정렬을 위쪽으로 붙이는 편이 공통 회귀를 가장 작게 되돌리는 방법이라고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.72
|
||||||
|
- 모바일 상단 레일은 왼쪽 유저 카드 영역과 오른쪽 패널 토글 버튼이 같은 시각적 간격 체계를 가져야 전체 셸이 덜 비뚤어져 보이므로, 모바일에서만 `railHeader` 좌우 패딩을 본문 카드 여백보다 조금 넓은 `20px`로 맞추는 편이 낫다고 판단했다.
|
||||||
|
- 오른쪽 레일 토글 아이콘이 모바일에서 테두리 없는 아이콘만 보이면 왼쪽 네비 토글 버튼과 컴포넌트 문법이 달라 보이므로, 모바일 한정으로 같은 버튼형 배경/테두리/라운드를 적용해 조작 가능한 컨트롤처럼 통일하는 쪽으로 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.71
|
||||||
|
- 모바일에서 공통 본문 하단이 딱 붙어 보이는 문제는 로그인 화면 하나만 고치는 것보다 `workspaceBody` 공통 하단 여백을 safe-area까지 포함해 보강하는 편이 이후 모든 본문 화면에 일괄 적용되어 유지보수상 낫다고 판단했다.
|
||||||
|
- 모바일 왼쪽 네비게이션은 데스크톱의 폭 축소형 접기와 목적이 다르므로, 기존 `leftRailCollapsed`를 억지로 재사용하기보다 `mobileLeftNavOpen` 상태를 분리하고 유저 카드 우측 버튼으로 검색/메뉴 묶음만 접는 방식이 더 자연스럽다고 정리했다.
|
||||||
|
- 오른쪽 레일은 모바일에서 기본 자동 열림이 실제 조작 공간을 빼앗는 경우가 많으므로, 모바일 진입과 라우트 이동 시 기본 닫힘으로 두되 PC 레이아웃으로 돌아오면 다시 기본 열림을 복원하는 쪽으로 맞췄다.
|
||||||
|
- 모바일 터치에서는 짧은 탭 선택과 드래그 시작이 같은 포인터 입력에서 충돌하기 쉬우므로, Sortable에 터치 전용 지연과 threshold를 둬 탭은 선택, 길게 누르고 움직이면 드래그가 되도록 의도를 분리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.70
|
||||||
|
- 카카오톡/디스코드/X 공유 미리보기는 대개 프런트 SPA 자바스크립트를 실행하기 전에 HTML 메타를 먼저 읽으므로, 기존 `index.html` 고정 메타를 프런트 런타임에서 바꾸는 방식만으로는 티어표별 썸네일/제목/설명을 안정적으로 보여주기 어렵다고 판단했다.
|
||||||
|
- 현재 운영 구조가 프런트 Nginx 정적 서빙 + 백엔드 API 분리 형태이므로, 모든 SPA 경로를 SSR로 바꾸기보다 공유 버튼만 `/share/editor/...` 서버 렌더링 경로를 사용하게 하고, 이 경로에서 OG 메타를 만든 뒤 기존 `preview=1` 화면으로 넘기는 방식이 가장 작은 변경이라고 정리했다.
|
||||||
|
- 다만 비공개 티어표의 제목/설명/썸네일이 외부 크롤러에게 노출되면 안 되므로, 공유 메타 생성은 공개 티어표이면서 URL의 주제 식별자와 실제 티어표 소속이 일치하는 경우에만 개별 메타를 사용하고, 그 외에는 서비스 기본 메타로 떨어지게 제한했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.69
|
||||||
|
- 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다.
|
||||||
|
- 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.68
|
||||||
|
- 우클릭 복제 UX는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
|
||||||
|
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.67
|
||||||
|
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.66
|
||||||
|
- 특정 템플릿은 같은 아이템을 조건별로 여러 칸에 동시에 배치해야 하므로, 기존 아이템을 직접 공유해서 재사용하기보다 우클릭으로 새 복제본을 만들어 미사용 풀에 넣는 방식이 더 자연스럽다고 판단했다.
|
||||||
|
- 현재 티어표 저장 구조는 아이템 ID 기준으로 위치를 추적하므로, 복제본이 원본과 같은 ID를 쓰면 중복 배치가 불가능해진다. 따라서 복제 시 `dup-...` 형태의 새 ID를 발급해 원본과 복제본을 별도 인스턴스로 관리하기로 했다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.62
|
## 2026-04-03 v1.4.62
|
||||||
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
|
- 운영 서버를 새 DB로 다시 시작하는 절차는 “일반 업데이트 재빌드”와 “볼륨까지 삭제하는 완전 초기화”가 같은 문서 안에 섞이면 실수로 데이터를 날릴 위험이 크므로, 배포 문서에서 두 흐름을 별도 섹션으로 나누는 편이 맞다고 판단했다.
|
||||||
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
|
- DB만 비우고 업로드 볼륨을 남기는 방식도 가능하지만, 현재 서비스는 DB 레코드와 업로드 파일 참조가 강하게 연결되어 있으므로 이 방법 역시 “운영 데이터를 전부 버리는 전제”라는 경고를 같이 적어두는 쪽으로 정리했다.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰 렌더링
|
||||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
|
||||||
|
|
||||||
## `/login`
|
## `/login`
|
||||||
|
|||||||
@@ -254,6 +254,7 @@
|
|||||||
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
|
- 사용자 아이템은 사용 횟수(`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`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`와 `/uploads/assets/topics/`는 `썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 최근처럼 `/uploads/assets/<파일명>.webp` 또는 `/uploads/assets/<앞2글자>/<파일명>.webp` 경로만 보고 종류를 알 수 없는 자산은 DB 참조(`avatar_src`, `thumbnail_src`, `thumbnail_src_snapshot`)를 역추적해 프로필/썸네일 여부를 분류한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
|
||||||
|
- 같은 이미지 `src`가 해시 중복 재사용으로 템플릿 아이템/사용자 아이템과 프로필 아바타 또는 썸네일 자산에서 동시에 공유되더라도, 아바타/썸네일로 참조 중인 `src`는 자산 카드도 함께 유지해 `프로필 이미지`, `썸네일 이미지`, `전체 이미지` 필터에서 누락되지 않게 한다.
|
||||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||||
@@ -295,6 +296,7 @@
|
|||||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||||
|
- 보드 칸이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 열리고, 실행 시 같은 이미지/이름/출처를 가진 새 아이템 인스턴스를 미사용 풀 맨 앞에 추가한다. 복제본은 `dup-...` 형태의 새 ID를 쓰므로 원본과 복제본을 서로 다른 칸에 동시에 배치할 수 있다.
|
||||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||||
|
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||||
|
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
||||||
|
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
||||||
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
- `v1.4.62`에서 NAS 배포 문서에 운영 DB 완전 초기화 절차를 추가했으므로, 실제 NAS에서 `git pull → docker compose ... down -v → up -d --build` 순서로 재배포했을 때 빈 DB가 현재 스키마로 다시 올라오고 `freeform`만 생성되는지 확인한다.
|
||||||
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
|
- `docker volume rm tier-maker_tmaker_mariadb_data` 방식은 프로젝트 디렉터리명에 따라 실제 볼륨 이름이 달라질 수 있으므로, 운영 NAS에서는 먼저 `docker volume ls | grep tmaker`로 이름을 확인한 뒤 문서 명령이 그대로 맞는지 점검한다.
|
||||||
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
|
- `v1.4.61`에서 템플릿 공개 주소를 `slug`로 분리했으므로, 홈 카드/주제 상세/나의 티어표/즐겨찾기/검색 결과/팔로우 피드/사용자 프로필에서 열리는 URL이 `/topics/:slug`, `/editor/:slug/...` 형태로 바뀌고, 실제 화면 내용도 같은 주제 템플릿으로 정확히 열리는지 QA한다.
|
||||||
|
|||||||
@@ -1,5 +1,72 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- 모바일 티어표 프리뷰에서 오른쪽 레일의 `VIEWER MODE` 카드가 패널 바닥에 딱 붙고, 카피라이트 문구가 카드 뒤쪽 중간 높이에 겹쳐 보일 수 있던 배치를 보정했다.
|
||||||
|
- 모바일 오른쪽 overlay 레일에서는 `rightRail__content`가 남는 높이를 억지로 채우지 않도록 `flex: 0 0 auto`로 풀고, `localRightRailRoot`의 최소 높이도 `auto`로 낮춰 footer와 콘텐츠가 자연스럽게 순서대로 쌓이게 했다.
|
||||||
|
- 프리뷰 전용 `viewerSidebar__section`의 `margin-top: auto`는 모바일에서만 끄고, 광고 아래에 바로 카드가 이어지도록 조정했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.75
|
||||||
|
- 모바일에서 오른쪽 레일을 열었을 때 패널이 `calc(100vw - 20px)` 폭의 좁은 서랍처럼 떠서 화면 전체를 채우지 못하고, 아래쪽도 어색하게 비어 보이던 부분을 조정했다.
|
||||||
|
- 모바일 오른쪽 레일 overlay는 `inset: 0`, `width: 100vw`, `height: 100dvh`로 화면 전체를 덮는 패널처럼 열리게 바꾸고, 하단 액션/공유 버튼이 바닥에 붙거나 잘려 보이지 않도록 내부 패딩을 `32px + safe-area`까지 늘렸다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.74
|
||||||
|
- 모바일 본문 영역에서 `workspaceBody` 배경색이 좌우 마진 안쪽에만 칠해져 중앙에 어설픈 배경 박스가 떠 있는 것처럼 보이던 부분을 정리했다.
|
||||||
|
- 모바일에서는 공통 워크스페이스 배경을 투명하게 두고, 실제 화면별 카드/섹션 배경만 남겨 덜 미완성처럼 보이도록 조정했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.73
|
||||||
|
- 모바일에서 왼쪽 레일 아래 메인 컨텐츠가 화면 중간부터 시작하는 것처럼 보이던 회귀를 수정했다.
|
||||||
|
- 원인은 모바일 `.appShell`이 1열 그리드로 바뀐 상태에서 세로 행 정의가 없어 `leftRail` 행과 `appMain` 행이 남는 높이를 나눠 가지며 위쪽이 불필요하게 늘어날 수 있던 점이었다. 모바일 그리드를 `auto + minmax(0, 1fr)` 행으로 고정하고 `align-content: start`를 적용해 상단 레일 바로 아래에 본문이 이어지도록 보정했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.72
|
||||||
|
- 모바일 공통 상단 헤더(`railHeader`) 좌우 패딩을 `20px`로 넓혀, 오른쪽 레일 토글 버튼과 화면 가장자리 간격이 왼쪽 유저 카드 쪽과 더 자연스럽게 맞도록 조정했다.
|
||||||
|
- 모바일에서 오른쪽 레일 열기/닫기 아이콘도 왼쪽 네비게이션 토글과 같은 버튼형 카드 스타일로 보이도록 `42px` 크기, 테두리, 배경, 둥근 모서리를 맞췄다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.71
|
||||||
|
- 모바일에서 본문 페이지나 로그인 화면 하단이 카드/버튼 바로 아래에서 끊겨 보여 답답했던 부분을 줄이기 위해, 공통 워크스페이스 본문 하단에 모바일 safe-area 기반 여백을 추가했다.
|
||||||
|
- 모바일 왼쪽 네비게이션은 유저 프로필 카드 오른쪽 토글 버튼으로 접고 펼칠 수 있게 바꾸고, 닫힘/열림 전환 시 검색창과 메뉴가 위아래로 부드럽게 스르륵 접히는 애니메이션을 추가했다.
|
||||||
|
- 모바일 진입 시 오른쪽 레일은 기본 닫힘으로 시작하고, 모바일에서 직접 오른쪽 레일을 열었을 때도 레일 하단 컨텐츠가 화면 바닥에 붙지 않도록 safe-area 여백을 더했다.
|
||||||
|
- 티어표 편집기 모바일 터치 조작에서 아이템을 짧게 탭하면 선택만 하고, 길게 누른 뒤 움직일 때 드래그가 시작되도록 Sortable 터치 시작 지연과 이동 임계값을 추가했다.
|
||||||
|
- 서버 점검 안내 문구는 `서비스 내부 점검이 필요합니다.` 대신 `서비스 내부 점검중입니다.`로 다듬었고, 프런트 프로덕션 빌드(`npm run build`) 통과를 확인했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.70
|
||||||
|
- 저장된 티어표의 `공유하기` 버튼이 기존 `preview=1` 편집기 주소 대신 `/share/editor/:topicId/:tierListId` 공유 전용 주소를 복사하도록 바꿨다.
|
||||||
|
- 이 공유 전용 주소는 공개 티어표인 경우 해당 티어표의 제목, 설명, 썸네일을 기반으로 Open Graph/Twitter 메타 태그를 서버에서 동적으로 생성한 뒤, 실제 뷰어 화면 `/editor/:topicId/:tierListId?preview=1`로 즉시 이동시킨다.
|
||||||
|
- 비공개 티어표이거나 주제 경로와 티어표 소속이 맞지 않는 경우에는 개별 제목/설명/썸네일을 노출하지 않고 서비스 기본 공유 메타를 사용하도록 제한했다.
|
||||||
|
- 운영 프런트 Nginx에서 `/share/` 경로를 백엔드로 프록시하도록 추가해, 카카오톡/디스코드/X 같은 크롤러가 JS 실행 전에 공유 메타를 먼저 읽을 수 있게 했다.
|
||||||
|
- `backend/index.js`, `backend/src/routes/share.js` 문법 검사와 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.69
|
||||||
|
- 티어표 편집 화면의 아이템 검색에서 한글 아이템명이 검색어와 눈으로는 같아 보여도 내부 유니코드 정규형 차이 때문에 일부 항목이 매칭되지 않을 수 있던 문제를 보강했다.
|
||||||
|
- 검색어와 아이템 라벨을 비교하기 전에 `NFC`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다.
|
||||||
|
- 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.68
|
||||||
|
- 티어표 편집 화면에서 아이템을 우클릭해도 브라우저 기본 컨텍스트 메뉴가 먼저 떠서 `아이템 복제` 메뉴를 누르기 어려울 수 있던 부분을 보강했다.
|
||||||
|
- 기존에는 각 아이템 카드의 `@contextmenu.prevent`에 주로 의존했지만, 이제는 `window` 캡처 단계에서 `[data-item-id]` 대상 우클릭을 먼저 잡아 기본 메뉴를 막고 커스텀 복제 메뉴를 열도록 바꿨다.
|
||||||
|
- 아이템 썸네일 이미지에도 `draggable="false"`를 명시해, 이미지 자체의 기본 드래그/컨텍스트 동작이 편집 조작보다 앞서는 상황을 줄였다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.67
|
||||||
|
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
||||||
|
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
||||||
|
- 로컬 MariaDB를 새로 만든 뒤 `프로필 아바타`와 사용자 아이템이 같은 `src`를 공유하는 테스트 데이터를 직접 넣고, `listCustomItems({ filterMode: 'avatar' })`와 `filterMode: 'all'` 결과에 프로필 자산 카드가 포함되는 것까지 확인했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.66
|
||||||
|
- 티어표 편집 화면에서 보드 위 아이템이나 미사용 아이템 풀의 아이템을 우클릭하면 `아이템 복제` 메뉴가 뜨고, 선택한 아이템의 이미지/이름/출처를 유지한 새 복제본을 미사용 풀 맨 앞에 추가하도록 구현했다.
|
||||||
|
- 기존 아이템 ID를 그대로 다시 쓰면 같은 항목을 서로 다른 칸에 동시에 둘 수 없으므로, 복제 시 `dup-...` 새 ID를 발급해 원본과 복제본을 별도 아이템 인스턴스로 저장하도록 정리했다.
|
||||||
|
- 우클릭 메뉴는 메뉴 밖 클릭, 다른 곳 우클릭, 스크롤, 창 포커스 이탈 시 닫히도록 했고, 화면 가장자리에서는 메뉴가 뷰포트 밖으로 나가지 않게 좌표를 보정했다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.62
|
## 2026-04-03 v1.4.62
|
||||||
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
|
- UGREEN NAS 운영 배포 문서에 `git pull origin main` 후 일반 재빌드하는 절차와, 운영 데이터를 전부 버리고 `docker compose ... down -v`로 MariaDB/업로드/세션 볼륨까지 초기화한 뒤 새로 `up -d --build` 하는 절차를 분리해서 추가했다.
|
||||||
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
|
- DB만 비우고 싶을 때 `tmaker_mariadb_data` 볼륨만 삭제하는 방법과, 실제 볼륨 이름이 다를 수 있으니 `docker volume ls | grep tmaker`로 먼저 확인하는 안내도 함께 적었다.
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /share/ {
|
||||||
|
proxy_pass http://backend:5179/share/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
proxy_pass http://backend:5179/uploads/;
|
proxy_pass http://backend:5179/uploads/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const RIGHT_RAIL_COPYRIGHT_URL = 'https://x.com/zennbox'
|
|||||||
const currentTopicId = computed(() => route.params.topicId || '')
|
const currentTopicId = computed(() => route.params.topicId || '')
|
||||||
|
|
||||||
const leftRailCollapsed = ref(false)
|
const leftRailCollapsed = ref(false)
|
||||||
|
const mobileLeftNavOpen = ref(false)
|
||||||
const rightRailOpen = ref(true)
|
const rightRailOpen = ref(true)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
const leftRailSearchPlaceholder = '주제 템플릿 검색'
|
||||||
@@ -145,6 +146,7 @@ const showSettingsThemePanel = computed(() => route.name === 'profile')
|
|||||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||||
|
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||||
const leftBottomPrimaryAction = computed(() => {
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
if (!authReady.value) return null
|
if (!authReady.value) return null
|
||||||
if (route.name === 'home' && auth.user) {
|
if (route.name === 'home' && auth.user) {
|
||||||
@@ -295,6 +297,11 @@ function toggleTheme() {
|
|||||||
applyTheme(isLightTheme.value ? 'dark' : 'light')
|
applyTheme(isLightTheme.value ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncRightRailBodyScrollLock(shouldLock) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.body.style.overflow = shouldLock ? 'hidden' : ''
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const savedTheme = window.localStorage.getItem('tier-maker:theme')
|
const savedTheme = window.localStorage.getItem('tier-maker:theme')
|
||||||
@@ -312,6 +319,12 @@ onMounted(async () => {
|
|||||||
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
|
||||||
if (saved === '0') rightRailOpen.value = false
|
if (saved === '0') rightRailOpen.value = false
|
||||||
}
|
}
|
||||||
|
if (isMobileLayout.value) {
|
||||||
|
mobileLeftNavOpen.value = false
|
||||||
|
rightRailOpen.value = false
|
||||||
|
} else {
|
||||||
|
rightRailOpen.value = true
|
||||||
|
}
|
||||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -331,6 +344,7 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('resize', syncViewportWidth)
|
window.removeEventListener('resize', syncViewportWidth)
|
||||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
}
|
}
|
||||||
|
syncRightRailBodyScrollLock(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -339,13 +353,27 @@ watch(
|
|||||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||||
isCollapsedSearchOpen.value = false
|
isCollapsedSearchOpen.value = false
|
||||||
isGuideModalOpen.value = false
|
isGuideModalOpen.value = false
|
||||||
|
if (isMobileLayout.value) {
|
||||||
|
mobileLeftNavOpen.value = false
|
||||||
|
rightRailOpen.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
isMobileLayout,
|
isMobileLayout,
|
||||||
(mobile) => {
|
(mobile) => {
|
||||||
if (mobile) leftRailCollapsed.value = false
|
if (mobile) {
|
||||||
|
leftRailCollapsed.value = false
|
||||||
|
mobileLeftNavOpen.value = false
|
||||||
|
rightRailOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mobileLeftNavOpen.value = false
|
||||||
|
rightRailOpen.value = true
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -353,7 +381,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
usesLocalRightRail,
|
usesLocalRightRail,
|
||||||
(needed) => {
|
(needed) => {
|
||||||
if (!needed || rightRailOpen.value) return
|
if (!needed || rightRailOpen.value || isMobileLayout.value) return
|
||||||
rightRailOpen.value = true
|
rightRailOpen.value = true
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
window.localStorage.setItem('tier-maker:right-rail-open', '1')
|
||||||
@@ -362,13 +390,24 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
shouldLockRightRailBodyScroll,
|
||||||
|
(shouldLock) => {
|
||||||
|
syncRightRailBodyScrollLock(shouldLock)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
function isRouteActive(path) {
|
function isRouteActive(path) {
|
||||||
if (path === '/') return route.path === '/'
|
if (path === '/') return route.path === '/'
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLeftRail() {
|
function toggleLeftRail() {
|
||||||
if (isMobileLayout.value) return
|
if (isMobileLayout.value) {
|
||||||
|
mobileLeftNavOpen.value = !mobileLeftNavOpen.value
|
||||||
|
return
|
||||||
|
}
|
||||||
leftRailCollapsed.value = !leftRailCollapsed.value
|
leftRailCollapsed.value = !leftRailCollapsed.value
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
|
||||||
@@ -449,6 +488,7 @@ function reloadApp() {
|
|||||||
class="appShell"
|
class="appShell"
|
||||||
:class="{
|
:class="{
|
||||||
'appShell--leftCollapsed': leftRailCollapsed,
|
'appShell--leftCollapsed': leftRailCollapsed,
|
||||||
|
'appShell--mobileNavClosed': isMobileLayout && !mobileLeftNavOpen,
|
||||||
'appShell--rightClosed': !rightRailOpen,
|
'appShell--rightClosed': !rightRailOpen,
|
||||||
'appShell--rightOverlay': isRightRailOverlay,
|
'appShell--rightOverlay': isRightRailOverlay,
|
||||||
}"
|
}"
|
||||||
@@ -483,7 +523,7 @@ function reloadApp() {
|
|||||||
|
|
||||||
<div class="leftRail__body">
|
<div class="leftRail__body">
|
||||||
<div class="leftRail__content">
|
<div class="leftRail__content">
|
||||||
<div v-if="authReady && auth.user" class="appUserCard">
|
<div v-if="authReady" class="appUserCard">
|
||||||
<div class="appUserCard__button">
|
<div class="appUserCard__button">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" draggable="false" />
|
||||||
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||||
@@ -491,40 +531,52 @@ function reloadApp() {
|
|||||||
<div class="appUserCard__name">{{ accountName }}</div>
|
<div class="appUserCard__name">{{ accountName }}</div>
|
||||||
<div class="appUserCard__email">{{ accountEmail }}</div>
|
<div class="appUserCard__email">{{ accountEmail }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="isMobileLayout"
|
||||||
|
class="appUserCard__navToggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="mobileLeftNavOpen ? '네비게이션 메뉴 닫기' : '네비게이션 메뉴 열기'"
|
||||||
|
:aria-expanded="mobileLeftNavOpen"
|
||||||
|
@click="toggleLeftRail"
|
||||||
|
>
|
||||||
|
<SvgIcon :src="mobileLeftNavOpen ? iconDockToLeft : iconDockToRight" :size="24" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
<div class="leftRail__mobileMenu">
|
||||||
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
<form class="searchStub" @submit.prevent="submitGlobalSearch">
|
||||||
<span class="searchStub__icon">
|
<button class="searchStub__iconButton" type="button" :aria-label="leftRailCollapsed ? '검색 열기' : '검색'" @click="handleLeftRailSearch">
|
||||||
<SvgIcon :src="iconSearch" :size="24" />
|
<span class="searchStub__icon">
|
||||||
</span>
|
<SvgIcon :src="iconSearch" :size="24" />
|
||||||
</button>
|
</span>
|
||||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
</button>
|
||||||
</form>
|
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||||
|
</form>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="leftNav"
|
class="leftNav"
|
||||||
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
:class="{ 'leftNav--hasActive': activeLeftNavIndex >= 0 }"
|
||||||
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
:style="{ '--left-nav-active-index': String(Math.max(activeLeftNavIndex, 0)) }"
|
||||||
>
|
|
||||||
<span class="leftNav__indicator" aria-hidden="true"></span>
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in leftNavItems"
|
|
||||||
:key="item.key"
|
|
||||||
:to="item.path"
|
|
||||||
class="leftNav__item"
|
|
||||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
|
||||||
:title="leftRailCollapsed ? item.label : ''"
|
|
||||||
:aria-label="leftRailCollapsed ? item.label : undefined"
|
|
||||||
>
|
>
|
||||||
<span class="leftNav__glyph">
|
<span class="leftNav__indicator" aria-hidden="true"></span>
|
||||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
<RouterLink
|
||||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
v-for="item in leftNavItems"
|
||||||
</span>
|
:key="item.key"
|
||||||
<span class="leftNav__label">{{ item.label }}</span>
|
:to="item.path"
|
||||||
</RouterLink>
|
class="leftNav__item"
|
||||||
</nav>
|
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||||
|
:title="leftRailCollapsed ? item.label : ''"
|
||||||
|
:aria-label="leftRailCollapsed ? item.label : undefined"
|
||||||
|
>
|
||||||
|
<span class="leftNav__glyph">
|
||||||
|
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||||
|
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="leftNav__label">{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="leftRail__bottom">
|
<div class="leftRail__bottom">
|
||||||
@@ -968,6 +1020,25 @@ function reloadApp() {
|
|||||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leftRail__mobileMenu {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appUserCard__navToggle {
|
||||||
|
display: none;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--theme-surface-soft);
|
||||||
|
color: var(--theme-text-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.appUserCard__name {
|
.appUserCard__name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -1975,9 +2046,15 @@ function reloadApp() {
|
|||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.appShell {
|
.appShell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
align-content: start;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.railHeader {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.leftRail {
|
.leftRail {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -1994,6 +2071,43 @@ function reloadApp() {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appUserCard {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appUserCard__button {
|
||||||
|
padding: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appUserCard__meta {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appUserCard__navToggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceHead .ghostIcon--iconOnly,
|
||||||
|
.rightRail__top .ghostIcon--iconOnly {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--theme-surface-soft);
|
||||||
|
color: var(--theme-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail--overlay {
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
border-left: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.appMain {
|
.appMain {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
@@ -2011,6 +2125,18 @@ function reloadApp() {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leftRail__mobileMenu {
|
||||||
|
max-height: 540px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
max-height 260ms ease,
|
||||||
|
opacity 220ms ease,
|
||||||
|
transform 220ms ease,
|
||||||
|
margin-top 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
.appShell--leftCollapsed .leftRail__top {
|
.appShell--leftCollapsed .leftRail__top {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2046,17 +2172,44 @@ function reloadApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspaceBody {
|
.workspaceBody {
|
||||||
padding: 0;
|
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
margin: 14px 14px 0;
|
margin: 14px 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspaceBody--localRail {
|
.workspaceBody--localRail {
|
||||||
padding: 0;
|
padding: 0 0 calc(28px + env(safe-area-inset-bottom));
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
margin: 14px 14px 0;
|
margin: 14px 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.appShell--mobileNavClosed .leftRail__mobileMenu {
|
||||||
|
max-height: 0;
|
||||||
|
margin-top: -8px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appShell--mobileNavClosed .leftRail__bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail--overlay .rightRail__body {
|
||||||
|
padding: 14px 20px calc(32px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail--overlay .rightRail__content {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightRail--overlay .localRightRailRoot {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.collapsedSearchModal {
|
.collapsedSearchModal {
|
||||||
padding: 72px 16px 16px;
|
padding: 72px 16px 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function request(path, { method = 'GET', body, headers } = {}) {
|
|||||||
} else if (res.status >= 500) {
|
} else if (res.status >= 500) {
|
||||||
emitBackendStatus({
|
emitBackendStatus({
|
||||||
state: 'maintenance',
|
state: 'maintenance',
|
||||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
message: '서비스 내부 점검중입니다. 잠시 후 다시 이용해주세요.',
|
||||||
path,
|
path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export function editorPath(topicId, tierListId, { preview = false } = {}) {
|
|||||||
return preview ? `${base}?preview=1` : base
|
return preview ? `${base}?preview=1` : base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shareEditorPath(topicId, tierListId) {
|
||||||
|
return `/share/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
|
||||||
|
}
|
||||||
|
|
||||||
export function mePath() {
|
export function mePath() {
|
||||||
return '/me'
|
return '/me'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import Sortable from 'sortablejs'
|
import Sortable from 'sortablejs'
|
||||||
import * as htmlToImage from 'html-to-image'
|
import * as htmlToImage from 'html-to-image'
|
||||||
@@ -10,7 +10,7 @@ import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
|||||||
import shareIcon from '../assets/icons/share.svg'
|
import shareIcon from '../assets/icons/share.svg'
|
||||||
import RightRailAd from '../components/RightRailAd.vue'
|
import RightRailAd from '../components/RightRailAd.vue'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { editorNewPath, editorPath, loginPath, mePath, topicPath, userProfilePath } from '../lib/paths'
|
import { editorNewPath, editorPath, loginPath, mePath, shareEditorPath, topicPath, userProfilePath } from '../lib/paths'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
@@ -79,6 +79,12 @@ const poolSearchQuery = ref('')
|
|||||||
const selectedItemId = ref('')
|
const selectedItemId = ref('')
|
||||||
const recentDragFinishedAt = ref(0)
|
const recentDragFinishedAt = ref(0)
|
||||||
const savedEditorSnapshot = ref('')
|
const savedEditorSnapshot = ref('')
|
||||||
|
const itemContextMenu = ref({
|
||||||
|
open: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
itemId: '',
|
||||||
|
})
|
||||||
let editorLoadToken = 0
|
let editorLoadToken = 0
|
||||||
|
|
||||||
const boardEl = ref(null)
|
const boardEl = ref(null)
|
||||||
@@ -96,6 +102,12 @@ const isNewTierList = computed(() => tierListId.value === 'new')
|
|||||||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||||||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||||||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||||||
|
const touchSortableOptions = {
|
||||||
|
delayOnTouchOnly: true,
|
||||||
|
delay: 180,
|
||||||
|
touchStartThreshold: 8,
|
||||||
|
fallbackTolerance: 8,
|
||||||
|
}
|
||||||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||||
const effectiveAuthorName = computed(() => {
|
const effectiveAuthorName = computed(() => {
|
||||||
@@ -135,7 +147,7 @@ const copiedFromLabel = computed(() => {
|
|||||||
return parts.join(' · ') || '복사해 온 티어표'
|
return parts.join(' · ') || '복사해 온 티어표'
|
||||||
})
|
})
|
||||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
const normalizedPoolSearchQuery = computed(() => normalizeSearchText(poolSearchQuery.value))
|
||||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||||
const canRequestTemplateCreate = computed(
|
const canRequestTemplateCreate = computed(
|
||||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||||
@@ -149,8 +161,9 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
|
|||||||
const shareTierListUrl = computed(() => {
|
const shareTierListUrl = computed(() => {
|
||||||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||||||
if (!savedTierListId) return ''
|
if (!savedTierListId) return ''
|
||||||
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
|
const sharePath = shareEditorPath(templateId.value, savedTierListId)
|
||||||
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
|
if (typeof window === 'undefined') return sharePath
|
||||||
|
return new URL(sharePath, window.location.origin).toString()
|
||||||
})
|
})
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -158,6 +171,13 @@ watch(error, (message) => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function normalizeSearchText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.normalize('NFC')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function createAutoTierListTitle() {
|
function createAutoTierListTitle() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||||
@@ -223,7 +243,7 @@ function isPoolItemVisible(itemId) {
|
|||||||
const query = normalizedPoolSearchQuery.value
|
const query = normalizedPoolSearchQuery.value
|
||||||
if (!query) return true
|
if (!query) return true
|
||||||
const item = itemsById.value[itemId]
|
const item = itemsById.value[itemId]
|
||||||
const label = String(item?.label || itemId || '').toLowerCase()
|
const label = normalizeSearchText(item?.label || itemId || '')
|
||||||
return label.includes(query)
|
return label.includes(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +349,31 @@ function shouldIgnoreItemClick() {
|
|||||||
return Date.now() - recentDragFinishedAt.value < 180
|
return Date.now() - recentDragFinishedAt.value < 180
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeItemContextMenu() {
|
||||||
|
if (!itemContextMenu.value.open) return
|
||||||
|
itemContextMenu.value = {
|
||||||
|
open: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
itemId: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openItemContextMenu(itemId, event) {
|
||||||
|
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
|
||||||
|
selectedItemId.value = itemId
|
||||||
|
const viewportWidth = typeof window === 'undefined' ? 0 : window.innerWidth
|
||||||
|
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight
|
||||||
|
const menuX = Number(event?.clientX || 0)
|
||||||
|
const menuY = Number(event?.clientY || 0)
|
||||||
|
itemContextMenu.value = {
|
||||||
|
open: true,
|
||||||
|
x: viewportWidth ? Math.max(12, Math.min(menuX, viewportWidth - 180)) : menuX,
|
||||||
|
y: viewportHeight ? Math.max(12, Math.min(menuY, viewportHeight - 72)) : menuY,
|
||||||
|
itemId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getItemLocation(itemId) {
|
function getItemLocation(itemId) {
|
||||||
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
||||||
|
|
||||||
@@ -405,6 +450,56 @@ function moveSelectedItemToPool() {
|
|||||||
selectedItemId.value = ''
|
selectedItemId.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateItemToPool() {
|
||||||
|
if (!canEdit.value || !itemContextMenu.value.itemId) return
|
||||||
|
|
||||||
|
const sourceItem = itemsById.value[itemContextMenu.value.itemId]
|
||||||
|
if (!sourceItem) {
|
||||||
|
closeItemContextMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const clonedId = createClonedItemId()
|
||||||
|
itemsById.value = {
|
||||||
|
...itemsById.value,
|
||||||
|
[clonedId]: {
|
||||||
|
...sourceItem,
|
||||||
|
id: clonedId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pool.value = [clonedId, ...pool.value]
|
||||||
|
selectedItemId.value = clonedId
|
||||||
|
closeItemContextMenu()
|
||||||
|
toast.success('아이템 추가 완료')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalContextMenu(event) {
|
||||||
|
const target = event?.target
|
||||||
|
if (target?.closest?.('[data-item-context-menu]')) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
event?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemEl = target?.closest?.('[data-item-id]')
|
||||||
|
if (canEdit.value && itemEl?.dataset?.itemId) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
event?.stopPropagation?.()
|
||||||
|
openItemContextMenu(itemEl.dataset.itemId, event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemContextMenu.value.open) return
|
||||||
|
closeItemContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalPointerDown(event) {
|
||||||
|
if (!itemContextMenu.value.open) return
|
||||||
|
const target = event?.target
|
||||||
|
if (target?.closest?.('[data-item-context-menu]')) return
|
||||||
|
closeItemContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
function setGroupDropEl(groupId, columnIndex, el) {
|
function setGroupDropEl(groupId, columnIndex, el) {
|
||||||
const key = `${groupId}::${columnIndex}`
|
const key = `${groupId}::${columnIndex}`
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -458,6 +553,7 @@ async function initSortables() {
|
|||||||
destroySortables()
|
destroySortables()
|
||||||
|
|
||||||
groupSortable.value = Sortable.create(groupListEl.value, {
|
groupSortable.value = Sortable.create(groupListEl.value, {
|
||||||
|
...touchSortableOptions,
|
||||||
animation: 160,
|
animation: 160,
|
||||||
handle: '[data-group-handle]',
|
handle: '[data-group-handle]',
|
||||||
ghostClass: 'ghost',
|
ghostClass: 'ghost',
|
||||||
@@ -471,6 +567,7 @@ async function initSortables() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
poolSortable.value = Sortable.create(poolEl.value, {
|
poolSortable.value = Sortable.create(poolEl.value, {
|
||||||
|
...touchSortableOptions,
|
||||||
group: 'tier-items',
|
group: 'tier-items',
|
||||||
animation: 160,
|
animation: 160,
|
||||||
draggable: '[data-item-id]',
|
draggable: '[data-item-id]',
|
||||||
@@ -488,6 +585,7 @@ async function initSortables() {
|
|||||||
|
|
||||||
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||||||
Sortable.create(el, {
|
Sortable.create(el, {
|
||||||
|
...touchSortableOptions,
|
||||||
group: 'tier-items',
|
group: 'tier-items',
|
||||||
animation: 160,
|
animation: 160,
|
||||||
draggable: '[data-item-id]',
|
draggable: '[data-item-id]',
|
||||||
@@ -537,6 +635,7 @@ function createColumnName(index = columns.value.length) {
|
|||||||
|
|
||||||
function createCustomItemLabel(fileName = '') {
|
function createCustomItemLabel(fileName = '') {
|
||||||
const normalized = String(fileName || '')
|
const normalized = String(fileName || '')
|
||||||
|
.normalize('NFC')
|
||||||
.replace(/\.[^.]+$/, '')
|
.replace(/\.[^.]+$/, '')
|
||||||
.replace(/[_-]+/g, ' ')
|
.replace(/[_-]+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
@@ -544,6 +643,10 @@ function createCustomItemLabel(fileName = '') {
|
|||||||
return (normalized || 'custom').slice(0, 60)
|
return (normalized || 'custom').slice(0, 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createClonedItemId() {
|
||||||
|
return `dup-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
async function addGroup() {
|
async function addGroup() {
|
||||||
groups.value = [
|
groups.value = [
|
||||||
...groups.value,
|
...groups.value,
|
||||||
@@ -1135,6 +1238,7 @@ function resetEditorStateForRoute() {
|
|||||||
selectedItemId.value = ''
|
selectedItemId.value = ''
|
||||||
recentDragFinishedAt.value = 0
|
recentDragFinishedAt.value = 0
|
||||||
savedEditorSnapshot.value = ''
|
savedEditorSnapshot.value = ''
|
||||||
|
closeItemContextMenu()
|
||||||
resetTemplateRequestDrafts()
|
resetTemplateRequestDrafts()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,7 +1344,21 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||||||
|
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
|
window.addEventListener('blur', closeItemContextMenu)
|
||||||
|
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||||||
|
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
|
window.removeEventListener('blur', closeItemContextMenu)
|
||||||
|
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||||
|
}
|
||||||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||||||
destroySortables()
|
destroySortables()
|
||||||
})
|
})
|
||||||
@@ -1278,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>
|
||||||
@@ -1557,7 +1667,12 @@ onUnmounted(() => {
|
|||||||
:data-item-id="id"
|
:data-item-id="id"
|
||||||
@click.stop="selectItemByClick(id)"
|
@click.stop="selectItemByClick(id)"
|
||||||
>
|
>
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img
|
||||||
|
:src="resolveItemSrc(itemsById[id])"
|
||||||
|
class="thumb"
|
||||||
|
:alt="itemsById[id]?.label || id"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||||
<button
|
<button
|
||||||
v-if="canEdit && !isExporting"
|
v-if="canEdit && !isExporting"
|
||||||
@@ -1638,7 +1753,12 @@ onUnmounted(() => {
|
|||||||
:data-item-id="id"
|
:data-item-id="id"
|
||||||
@click.stop="selectItemByClick(id)"
|
@click.stop="selectItemByClick(id)"
|
||||||
>
|
>
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img
|
||||||
|
:src="resolveItemSrc(itemsById[id])"
|
||||||
|
class="thumb"
|
||||||
|
:alt="itemsById[id]?.label || id"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1646,6 +1766,19 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="itemContextMenu.open && canEdit"
|
||||||
|
class="itemContextMenu"
|
||||||
|
:style="{ left: `${itemContextMenu.x}px`, top: `${itemContextMenu.y}px` }"
|
||||||
|
data-item-context-menu
|
||||||
|
@click.stop
|
||||||
|
@contextmenu.prevent.stop
|
||||||
|
>
|
||||||
|
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
|
||||||
|
아이템 복제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
@@ -2940,6 +3073,35 @@ onUnmounted(() => {
|
|||||||
.poolItem--hidden {
|
.poolItem--hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.itemContextMenu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemContextMenu__action {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemContextMenu__action:hover {
|
||||||
|
background: var(--theme-pill-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -3028,6 +3190,9 @@ onUnmounted(() => {
|
|||||||
.previewOnly {
|
.previewOnly {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
.viewerSidebar__section {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.pool {
|
.pool {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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