Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9aa714501 | |||
| 6ddc82b1c7 | |||
| 94152f22b2 |
@@ -472,6 +472,28 @@ async function createCustomItem({ id, ownerId, src, label }) {
|
|||||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findCustomItemById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT id, owner_id, src, label, created_at
|
||||||
|
FROM custom_items
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getCustomItemUsageMap() {
|
async function getCustomItemUsageMap() {
|
||||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
||||||
const usageMap = new Map()
|
const usageMap = new Map()
|
||||||
@@ -778,6 +800,7 @@ module.exports = {
|
|||||||
deleteGame,
|
deleteGame,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
createCustomItem,
|
createCustomItem,
|
||||||
|
findCustomItemById,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
findUnusedCustomItems,
|
findUnusedCustomItems,
|
||||||
listPublicTierLists,
|
listPublicTierLists,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const {
|
|||||||
deleteGame,
|
deleteGame,
|
||||||
updateGameDisplayOrder,
|
updateGameDisplayOrder,
|
||||||
listCustomItems,
|
listCustomItems,
|
||||||
|
findCustomItemById,
|
||||||
findUnusedCustomItems,
|
findUnusedCustomItems,
|
||||||
findCustomItemsByIds,
|
findCustomItemsByIds,
|
||||||
deleteCustomItems,
|
deleteCustomItems,
|
||||||
@@ -174,6 +175,23 @@ async function removeCustomItemFiles(items) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
||||||
|
const originalName = path.basename(customItem.src || '')
|
||||||
|
const nextFilename = buildUploadFilename({ originalname: originalName })
|
||||||
|
const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, ''))
|
||||||
|
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
||||||
|
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
||||||
|
|
||||||
|
await fs.copyFile(sourcePath, targetPath)
|
||||||
|
|
||||||
|
return createGameItem({
|
||||||
|
id: nanoid(),
|
||||||
|
gameId,
|
||||||
|
src: `/${targetRelativePath.replace(/\\/g, '/')}`,
|
||||||
|
label: customItem.label,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||||
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||||
@@ -186,6 +204,23 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
|||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||||
|
const schema = z.object({
|
||||||
|
gameId: z.string().min(1),
|
||||||
|
})
|
||||||
|
const parsed = schema.safeParse(req.body)
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
|
|
||||||
|
const game = await findGameById(parsed.data.gameId)
|
||||||
|
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||||
|
|
||||||
|
const customItem = await findCustomItemById(req.params.itemId)
|
||||||
|
if (!customItem) return res.status(404).json({ error: 'not_found' })
|
||||||
|
|
||||||
|
const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id })
|
||||||
|
res.json({ item })
|
||||||
|
})
|
||||||
|
|
||||||
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
q: z.string().trim().max(120).optional().default(''),
|
q: z.string().trim().max(120).optional().default(''),
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.41
|
||||||
|
- 관리자 커스텀 아이템 승격은 버튼만 보이는 상태로 끝나면 안 되므로, 프런트 API와 백엔드 라우트가 실제로 함께 연결되어야 기능이 완결된다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.40
|
||||||
|
- 관리자 기본 아이템 이름 저장은 눌러도 변화가 없으면 혼란스러우므로, 실제 변경이 있을 때만 버튼이 활성화되는 편이 더 명확하다고 판단했다.
|
||||||
|
- 사용자 커스텀 이미지는 관리자 검토 후 특정 게임의 기본 템플릿으로 복제해 가져올 수 있어야 운영 효율이 높아지므로, 게임 선택 기반 승격 흐름을 추가하기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.39
|
||||||
|
- 티어표 편집 헤더는 게임명 kicker보다 제목과 설명이 더 중요하므로, 좌측 입력 중심 구조로 재배치하고 썸네일은 우측 보조 카드로 분리하는 편이 더 자연스럽다고 판단했다.
|
||||||
|
- 썸네일 조작 버튼은 모바일에서도 카드와 함께 유지되는 편이 흐름이 덜 끊기므로, 미리보기 아래 별도 줄로 떨어뜨리기보다 카드 내부의 짧은 액션 행으로 묶기로 결정했다.
|
||||||
|
|
||||||
## 2026-03-26 v0.1.38
|
## 2026-03-26 v0.1.38
|
||||||
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
|
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
|
||||||
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
|
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
|
|
||||||
## `/admin`
|
## `/admin`
|
||||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||||
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||||
|
|
||||||
## `/profile`
|
## `/profile`
|
||||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||||
- `GET /api/admin/custom-items`
|
- `GET /api/admin/custom-items`
|
||||||
|
- `POST /api/admin/custom-items/:itemId/promote`
|
||||||
- `DELETE /api/admin/custom-items/:itemId`
|
- `DELETE /api/admin/custom-items/:itemId`
|
||||||
- `DELETE /api/admin/custom-items`
|
- `DELETE /api/admin/custom-items`
|
||||||
- `GET /api/admin/users`
|
- `GET /api/admin/users`
|
||||||
@@ -102,10 +103,12 @@
|
|||||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||||
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
||||||
- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다.
|
- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다.
|
||||||
|
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
|
||||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||||
|
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@
|
|||||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||||
|
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||||
|
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||||
|
|
||||||
## 배포 전 작업
|
## 배포 전 작업
|
||||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.41
|
||||||
|
- **커스텀 아이템 승격 연결 수정**: 관리자 아이템 관리의 `기본 템플릿에 추가` 버튼이 실제 API와 백엔드 승격 라우트로 연결되도록 누락된 프런트/백엔드 구현을 보완
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.40
|
||||||
|
- **기본 아이템 저장 UX 보강**: 관리자 게임 관리에서 아이템 이름이 실제로 바뀐 경우에만 `이름 저장` 버튼이 활성화되도록 조정하고, 저장 중 상태를 버튼에 표시
|
||||||
|
- **커스텀 아이템 승격 추가**: 관리자 아이템 관리에서 사용자 커스텀 이미지를 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있도록 API와 UI를 추가
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.39
|
||||||
|
- **에디터 헤더 재구성**: 티어표 편집 상단에서 게임명 kicker를 제거하고, 좌측 제목/설명 입력과 우측 썸네일 카드가 나란히 보이는 구조로 재정리
|
||||||
|
- **썸네일 영역 UX 개선**: 썸네일 미리보기와 선택/제거 버튼을 하나의 카드 안에 묶고, 모바일에서도 버튼이 카드 아래로 무너지지 않도록 밀도 있게 조정
|
||||||
|
|
||||||
## 2026-03-26 v0.1.38
|
## 2026-03-26 v0.1.38
|
||||||
- **관리자 기본 아이템 이름 수정 추가**: 게임 관리 화면의 현재 기본 아이템 목록에서 이름을 직접 수정하고 저장할 수 있도록 API와 UI를 보강
|
- **관리자 기본 아이템 이름 수정 추가**: 게임 관리 화면의 현재 기본 아이템 목록에서 이름을 직접 수정하고 저장할 수 있도록 API와 UI를 보강
|
||||||
- **티어표 썸네일 추가**: 티어표 편집 화면에서 별도 썸네일 이미지를 선택해 저장할 수 있도록 업로드 흐름을 추가하고, 게임별 공개 티어표/내 티어표 목록은 게임 카드처럼 상단 썸네일 + 하단 제목/작성자 정보 카드 구조로 변경
|
- **티어표 썸네일 추가**: 티어표 편집 화면에서 별도 썸네일 이미지를 선택해 저장할 수 있도록 업로드 흐름을 추가하고, 게임별 공개 티어표/내 티어표 목록은 게임 카드처럼 상단 썸네일 + 하단 제목/작성자 정보 카드 구조로 변경
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const api = {
|
|||||||
request(
|
request(
|
||||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||||
),
|
),
|
||||||
|
promoteAdminCustomItem: (itemId, payload) =>
|
||||||
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
listAdminUsers: () => request('/api/admin/users'),
|
listAdminUsers: () => request('/api/admin/users'),
|
||||||
updateAdminUser: (userId, payload) =>
|
updateAdminUser: (userId, payload) =>
|
||||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const customItemPage = ref(1)
|
|||||||
const customItemLimit = ref(50)
|
const customItemLimit = ref(50)
|
||||||
const customItemTotal = ref(0)
|
const customItemTotal = ref(0)
|
||||||
const customItemOrphanOnly = ref(false)
|
const customItemOrphanOnly = ref(false)
|
||||||
|
const customItemTargetGameId = ref('')
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
|
||||||
@@ -72,12 +73,18 @@ function resetMessages() {
|
|||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
activeTab.value = tab
|
activeTab.value = tab
|
||||||
|
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||||
|
customItemTargetGameId.value = games.value[0].id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGames() {
|
async function refreshGames() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
games.value = data.games || []
|
games.value = data.games || []
|
||||||
|
if (!customItemTargetGameId.value && games.value.length) {
|
||||||
|
customItemTargetGameId.value = games.value[0].id
|
||||||
|
}
|
||||||
featuredGameIds.value = games.value
|
featuredGameIds.value = games.value
|
||||||
.filter((game) => game.displayRank != null)
|
.filter((game) => game.displayRank != null)
|
||||||
.sort((a, b) => a.displayRank - b.displayRank)
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
@@ -360,13 +367,18 @@ async function saveGameItemLabel(item) {
|
|||||||
error.value = '아이템 이름을 입력해주세요.'
|
error.value = '아이템 이름을 입력해주세요.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (nextLabel === item.label) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
|
item.isSavingLabel = true
|
||||||
await loadGame()
|
const data = await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
|
||||||
|
item.label = data.item.label
|
||||||
|
item.draftLabel = data.item.label
|
||||||
success.value = '기본 아이템 이름을 수정했어요.'
|
success.value = '기본 아이템 이름을 수정했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
item.isSavingLabel = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +518,26 @@ async function removeUnusedCustomItems() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteCustomItem(item) {
|
||||||
|
resetMessages()
|
||||||
|
if (!customItemTargetGameId.value) {
|
||||||
|
error.value = '가져올 게임을 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.isPromoting = true
|
||||||
|
await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value })
|
||||||
|
const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value
|
||||||
|
if (selectedGameId.value === customItemTargetGameId.value) await loadGame()
|
||||||
|
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||||
|
} finally {
|
||||||
|
item.isPromoting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const displayThumbnailUrl = computed(() => {
|
const displayThumbnailUrl = computed(() => {
|
||||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||||
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
||||||
@@ -734,7 +766,13 @@ async function saveFeaturedOrder() {
|
|||||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
|
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
|
||||||
<div class="thumbCard__actions">
|
<div class="thumbCard__actions">
|
||||||
<button class="btn btn--ghost btn--small" @click="saveGameItemLabel(item)">이름 저장</button>
|
<button
|
||||||
|
class="btn btn--ghost btn--small"
|
||||||
|
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||||
|
@click="saveGameItemLabel(item)"
|
||||||
|
>
|
||||||
|
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||||
|
</button>
|
||||||
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
|
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,6 +801,10 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar toolbar--secondary">
|
<div class="toolbar toolbar--secondary">
|
||||||
|
<select v-model="customItemTargetGameId" class="select toolbar__select">
|
||||||
|
<option value="">가져올 게임 선택</option>
|
||||||
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||||
|
</select>
|
||||||
<label class="checkRow checkRow--toolbar">
|
<label class="checkRow checkRow--toolbar">
|
||||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||||
<span>미사용 커스텀 이미지만 보기</span>
|
<span>미사용 커스텀 이미지만 보기</span>
|
||||||
@@ -784,6 +826,9 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
||||||
<div class="customItemCard__actions">
|
<div class="customItemCard__actions">
|
||||||
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
||||||
|
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
|
||||||
|
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||||
|
</button>
|
||||||
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
|
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1038,7 +1083,7 @@ async function saveFeaturedOrder() {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.toolbar--secondary {
|
.toolbar--secondary {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.toolbar__search,
|
.toolbar__search,
|
||||||
@@ -1324,7 +1369,7 @@ async function saveFeaturedOrder() {
|
|||||||
}
|
}
|
||||||
.customItemCard__actions {
|
.customItemCard__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -1417,6 +1462,9 @@ async function saveFeaturedOrder() {
|
|||||||
.itemComposer {
|
.itemComposer {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.toolbar--secondary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.itemPreviewCard {
|
.itemPreviewCard {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,34 +482,41 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="head">
|
<section class="head">
|
||||||
<div class="head__meta">
|
<div class="heroCard">
|
||||||
<div class="kicker">{{ gameName || gameId }}</div>
|
<div class="heroCard__main">
|
||||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
||||||
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
||||||
<div v-if="canEdit" class="thumbComposer">
|
<input
|
||||||
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
v-model="description"
|
||||||
<div class="thumbComposer__preview">
|
class="descInput"
|
||||||
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
placeholder="설명(선택): 이 티어표의 기준/룰"
|
||||||
<div v-else class="thumbComposer__empty">썸네일 없음</div>
|
:readonly="!canEdit"
|
||||||
</div>
|
/>
|
||||||
<div class="thumbComposer__actions">
|
<div class="hint">
|
||||||
<button class="btn btn--ghost" @click="openThumbnailFile">썸네일 선택</button>
|
<template v-if="canEdit">
|
||||||
<button class="btn btn--danger" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">썸네일 제거</button>
|
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="heroCard__side">
|
||||||
v-model="description"
|
<div class="thumbComposer">
|
||||||
class="descInput"
|
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
||||||
placeholder="설명(선택): 이 티어표의 기준/룰"
|
<div class="thumbComposer__header">
|
||||||
:readonly="!canEdit"
|
<div class="thumbComposer__eyebrow">대표 썸네일</div>
|
||||||
/>
|
<div class="thumbComposer__caption">목록 카드 상단에 표시됩니다.</div>
|
||||||
<div class="hint">
|
</div>
|
||||||
<template v-if="canEdit">
|
<div class="thumbComposer__preview">
|
||||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
||||||
</template>
|
<div v-else class="thumbComposer__empty">썸네일 없음</div>
|
||||||
<template v-else>
|
</div>
|
||||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
<div v-if="canEdit" class="thumbComposer__actions">
|
||||||
</template>
|
<button class="btn btn--ghost thumbComposer__button" @click="openThumbnailFile">썸네일 선택</button>
|
||||||
|
<button class="btn btn--danger thumbComposer__button" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">제거</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -630,30 +637,41 @@ onUnmounted(() => {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 6px 2px 14px;
|
padding: 6px 2px 14px;
|
||||||
}
|
}
|
||||||
.head__meta {
|
.heroCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.kicker {
|
.heroCard__main,
|
||||||
font-size: 12px;
|
.heroCard__side {
|
||||||
opacity: 0.7;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heroCard__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.heroCard__side {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
.titleInput {
|
.titleInput {
|
||||||
width: min(100%, 920px);
|
width: 100%;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
padding: 10px 12px;
|
padding: 14px 16px;
|
||||||
border-radius: 14px;
|
border-radius: 18px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.descInput {
|
.descInput {
|
||||||
width: min(100%, 920px);
|
width: 100%;
|
||||||
padding: 10px 12px;
|
min-height: 92px;
|
||||||
border-radius: 14px;
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
@@ -663,24 +681,47 @@ onUnmounted(() => {
|
|||||||
.hint {
|
.hint {
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
.titleNotice {
|
.titleNotice {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgba(251, 191, 36, 0.94);
|
color: rgba(251, 191, 36, 0.94);
|
||||||
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
.thumbComposer {
|
.thumbComposer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: min(100%, 920px);
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 46%),
|
||||||
|
rgba(255, 255, 255, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.thumbComposer__header {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.thumbComposer__eyebrow {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.thumbComposer__caption {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.68;
|
||||||
}
|
}
|
||||||
.thumbComposer__preview {
|
.thumbComposer__preview {
|
||||||
width: min(100%, 360px);
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(11, 18, 32, 0.78);
|
||||||
}
|
}
|
||||||
.thumbComposer__image {
|
.thumbComposer__image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -693,11 +734,16 @@ onUnmounted(() => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: rgba(255, 255, 255, 0.62);
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.thumbComposer__actions {
|
.thumbComposer__actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
}
|
||||||
|
.thumbComposer__button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -774,7 +820,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.btn--ghost {
|
.btn--ghost {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1073,6 +1119,9 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.heroCard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1089,5 +1138,13 @@ onUnmounted(() => {
|
|||||||
.row {
|
.row {
|
||||||
grid-template-columns: 150px 1fr;
|
grid-template-columns: 150px 1fr;
|
||||||
}
|
}
|
||||||
|
.thumbComposer {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
.titleInput,
|
||||||
|
.descInput {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user