Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6105208aef | |||
| f6dc64dfc8 | |||
| 40a8dac7b6 | |||
| faa2a01f6c | |||
| 2cdd627658 | |||
| 34ddd1083d | |||
| b5ec579e5d | |||
| 25b893407c |
@@ -252,6 +252,18 @@ async function ensureSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS favorite_games (
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
game_id VARCHAR(120) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, game_id),
|
||||
INDEX idx_favorite_games_game_id (game_id),
|
||||
CONSTRAINT fk_favorite_games_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_favorite_games_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS template_requests (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
@@ -431,7 +443,7 @@ async function adminDeleteUser(id) {
|
||||
await query('DELETE FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function listGames() {
|
||||
async function listGames(currentUserId = '') {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, name, thumbnail_src, display_rank, created_at
|
||||
@@ -445,7 +457,15 @@ async function listGames() {
|
||||
`,
|
||||
[FREEFORM_GAME_ID]
|
||||
)
|
||||
return rows.map(mapGameRow)
|
||||
const games = rows.map(mapGameRow)
|
||||
if (!currentUserId) return games.map((game) => ({ ...game, isFavorited: false }))
|
||||
|
||||
const favoriteRows = await query('SELECT game_id FROM favorite_games WHERE user_id = ?', [currentUserId])
|
||||
const favoriteSet = new Set(favoriteRows.map((row) => row.game_id))
|
||||
return games.map((game) => ({
|
||||
...game,
|
||||
isFavorited: favoriteSet.has(game.id),
|
||||
}))
|
||||
}
|
||||
|
||||
async function findGameById(id) {
|
||||
@@ -605,28 +625,51 @@ async function findCustomItemById(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomItemUsageMap() {
|
||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
||||
async function getCustomItemUsageMeta() {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT t.game_id, g.name AS game_name, t.groups_json, t.pool_json
|
||||
FROM tierlists t
|
||||
LEFT JOIN games g ON g.id = t.game_id
|
||||
`
|
||||
)
|
||||
const usageMap = new Map()
|
||||
const linkedGamesMap = new Map()
|
||||
|
||||
rows.forEach((row) => {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const pool = parseJson(row.pool_json, [])
|
||||
const seenItemIds = new Set()
|
||||
|
||||
groups.forEach((group) => {
|
||||
;(group?.itemIds || []).forEach((itemId) => {
|
||||
usageMap.set(itemId, (usageMap.get(itemId) || 0) + 1)
|
||||
if (itemId) seenItemIds.add(itemId)
|
||||
})
|
||||
})
|
||||
|
||||
pool.forEach((item) => {
|
||||
if (item?.id) {
|
||||
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
||||
seenItemIds.add(item.id)
|
||||
}
|
||||
})
|
||||
|
||||
if (!row.game_id) return
|
||||
|
||||
seenItemIds.forEach((itemId) => {
|
||||
if (!linkedGamesMap.has(itemId)) linkedGamesMap.set(itemId, new Map())
|
||||
linkedGamesMap.get(itemId).set(row.game_id, {
|
||||
id: row.game_id,
|
||||
name: row.game_name || row.game_id,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return usageMap
|
||||
return {
|
||||
usageMap,
|
||||
linkedGamesMap: new Map(Array.from(linkedGamesMap.entries()).map(([itemId, gameMap]) => [itemId, Array.from(gameMap.values())])),
|
||||
}
|
||||
}
|
||||
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||
@@ -655,7 +698,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
params
|
||||
)
|
||||
|
||||
const usageMap = await getCustomItemUsageMap()
|
||||
const { usageMap, linkedGamesMap } = await getCustomItemUsageMeta()
|
||||
const allItems = rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -666,6 +709,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
ownerName: row.nickname || row.email,
|
||||
ownerEmail: row.email,
|
||||
usageCount: usageMap.get(row.id) || 0,
|
||||
linkedGames: linkedGamesMap.get(row.id) || [],
|
||||
}))
|
||||
.filter((item) => (orphanOnly ? item.usageCount === 0 : true))
|
||||
|
||||
@@ -705,7 +749,7 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
|
||||
params
|
||||
)
|
||||
|
||||
const usageMap = await getCustomItemUsageMap()
|
||||
const { usageMap } = await getCustomItemUsageMeta()
|
||||
return rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -1271,6 +1315,14 @@ async function unfavoriteTierList({ userId, tierListId }) {
|
||||
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
|
||||
}
|
||||
|
||||
async function favoriteGame({ userId, gameId }) {
|
||||
await query('INSERT IGNORE INTO favorite_games (user_id, game_id, created_at) VALUES (?, ?, ?)', [userId, gameId, now()])
|
||||
}
|
||||
|
||||
async function unfavoriteGame({ userId, gameId }) {
|
||||
await query('DELETE FROM favorite_games WHERE user_id = ? AND game_id = ?', [userId, gameId])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
ensureData,
|
||||
@@ -1305,6 +1357,8 @@ module.exports = {
|
||||
findTierListById,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
favoriteGame,
|
||||
unfavoriteGame,
|
||||
deleteTierList,
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
const express = require('express')
|
||||
const { listGames, getGameDetail } = require('../db')
|
||||
const { listGames, getGameDetail, findGameById, favoriteGame, unfavoriteGame } = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const games = await listGames()
|
||||
const games = await listGames(req.session?.userId || '')
|
||||
res.json({ games })
|
||||
})
|
||||
|
||||
router.post('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await favoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: true }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.delete('/:gameId/favorite', requireAuth, async (req, res) => {
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game || game.id === 'freeform') return res.status(404).json({ error: 'not_found' })
|
||||
await unfavoriteGame({ userId: req.session.userId, gameId: game.id })
|
||||
const games = await listGames(req.session.userId)
|
||||
const updated = games.find((entry) => entry.id === game.id) || { ...game, isFavorited: false }
|
||||
res.json({ game: updated })
|
||||
})
|
||||
|
||||
router.get('/:gameId', async (req, res) => {
|
||||
const detail = await getGameDetail(req.params.gameId)
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
@@ -184,6 +184,8 @@ router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), asyn
|
||||
router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||
const schema = z.object({
|
||||
type: z.enum(['create', 'update']),
|
||||
requestTitle: z.string().trim().min(1).max(80),
|
||||
requestDescription: z.string().trim().min(1).max(240),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -197,9 +199,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||
|
||||
if (parsed.data.type === 'create') {
|
||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
|
||||
return res.status(400).json({ error: 'title_required' })
|
||||
}
|
||||
} else {
|
||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
||||
}
|
||||
@@ -212,8 +211,8 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
||||
sourceTierListId: tierList.id,
|
||||
sourceGameId: tierList.gameId,
|
||||
targetGameId: parsed.data.type === 'update' ? tierList.gameId : '',
|
||||
title: tierList.title,
|
||||
description: tierList.description || '',
|
||||
title: parsed.data.requestTitle,
|
||||
description: parsed.data.requestDescription,
|
||||
thumbnailSrc: tierList.thumbnailSrc || '',
|
||||
items: customItems,
|
||||
})
|
||||
|
||||
320
docs/update.md
320
docs/update.md
@@ -1,5 +1,96 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-31 v1.2.64
|
||||
- 메인 콘텐츠가 길어질 때 스크롤 끝이 화면 바닥에 붙지 않도록 중앙 워크스페이스 하단 여백을 추가하고, 긴 작업 화면에서도 마감선이 답답하지 않게 보정함.
|
||||
- 템플릿 요청 모달 입력창을 Settings 화면과 같은 어두운 언더라인 입력 문법으로 통일하고, 에디터의 공개/이름 표시 옵션은 체크박스 대신 스위치형 토글로 재구성함.
|
||||
|
||||
## 2026-03-31 v1.2.63
|
||||
- 앱 셸과 워크스페이스에 걸려 있던 고정 `100dvh` 높이를 풀어, 본문이 길어질 때 중앙 `main` 영역이 잘리거나 접히는 현상을 보정함.
|
||||
- 좌우 레일은 그대로 화면 기준 높이를 유지하되, 중앙 작업 영역은 내용만큼 자연스럽게 늘어나도록 높이 계산을 다시 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.62
|
||||
- 템플릿 요청 모달의 제목/설명 입력을 Settings 화면과 같은 어두운 입력 문법으로 맞춰 흰 배경/흰 글자처럼 보이던 문제를 정리함.
|
||||
- 앱 셸은 사이드 기본 바탕색을 중심으로 재정리하고, 중앙 바디에 배경과 좌우 보더를 줘 긴 스크롤에서도 사이드가 잘리는 듯한 인상을 줄이도록 조정함.
|
||||
|
||||
## 2026-03-31 v1.2.61
|
||||
- Game Library 왼쪽 검색을 전체 티어표 검색이 아니라 게임 템플릿 검색으로 바꾸고, 홈 화면에서 검색어에 맞는 게임만 필터링하도록 조정함.
|
||||
- 게임 템플릿에 사용자별 즐겨찾기 별 아이콘을 추가하고, 즐겨찾기한 게임이 관리자 고정 순서보다 우선 노출되도록 백엔드와 홈 화면을 함께 확장함.
|
||||
- 앱 셸의 100vh 높이 계산을 100dvh와 고정 행 구조로 정리해, 콘텐츠가 없어도 생기던 불필요한 세로 스크롤을 줄임.
|
||||
|
||||
## 2026-03-31 v1.2.60
|
||||
- 관리자 티어표 관리 카드에서 사용자가 입력한 설명을 제목 아래에 함께 노출해 요청 의도를 더 빨리 파악할 수 있게 함.
|
||||
- 템플릿 등록/업데이트 요청은 이제 에디터 모달에서 제목과 설명을 별도로 입력받고, 예시 문구와 함께 전송하도록 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.59
|
||||
- 관리자 아이템 상세 모달의 게임 선택을 전용 상태로 분리해 기본 선택값이 비어 있도록 바꾸고, 썸네일 아래에 배치해 정보/액션과 시각적으로 분리함.
|
||||
- 커스텀 아이템이 실제로 사용 중인 게임 목록을 백엔드에서 함께 내려주고, 템플릿 요청 생성 폼에는 게임 ID와 게임 이름 라벨을 추가해 구분을 명확히 함.
|
||||
|
||||
## 2026-03-31 v1.2.58
|
||||
- 관리자 아이템 관리 카드를 썸네일과 제목만 보이는 compact 카드로 줄여, 대량 업로드된 이미지도 훨씬 높은 밀도로 탐색할 수 있게 정리함.
|
||||
- 카드 클릭 시 상세 정보를 모달로 열고 이미지 다운로드, 기본 템플릿 추가, 삭제를 모달 안에서 결정하는 흐름으로 바꿈.
|
||||
|
||||
## 2026-03-31 v1.2.57
|
||||
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 패널을 완전히 제거하고, 티어표 요청 모드에는 모드 전환 탭만 남기도록 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.56
|
||||
- 관리자 아이템 관리 카드 그리드에 최대 폭을 줘서 결과가 1~2개일 때 카드가 과하게 늘어나지 않도록 조정함.
|
||||
- 관리자 오른쪽 사이드에서 Featured, Game Summary, Users 요약 패널과 티어표 요청 새로고침/대기 개수 영역을 제거해 중복 정보를 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.55
|
||||
- 관리자 게임 관리 썸네일 입력을 파일 버튼 대신 클릭/드래그형 드롭존으로 바꿔 에디터 쪽 업로드 경험과 맞춤.
|
||||
- 관리자 아이템 관리 카드를 세로 카드 구조로 재정리해 긴 파일명과 버튼 문구에도 레이아웃이 무너지지 않도록 보정함.
|
||||
|
||||
## 2026-03-31 v1.2.54
|
||||
- 관리자 게임 상세 로딩 전에 호출되던 preview reset helper를 복구해, 게임 선택 시 런타임 오류로 상세 패널이 비어 있던 문제를 보정함.
|
||||
- 선택 실패 시 원인을 더 쉽게 확인할 수 있도록 로딩 실패 안내와 콘솔 에러 로그를 추가함.
|
||||
|
||||
## 2026-03-31 v1.2.53
|
||||
- 관리자 게임 관리에서 새 게임 만들기 카드를 제거하고, 헤더 버튼으로 여는 모달 기반 생성 흐름으로 정리함.
|
||||
- 게임 선택은 명시적인 변경 핸들러로 다시 묶어 선택 즉시 상세 정보를 불러오도록 보강함.
|
||||
|
||||
## 2026-03-31 v1.2.52
|
||||
- 관리자 게임 관리에서 선택 이벤트를 놓치지 않도록 `selectedGameId`와 탭 진입 시점을 감시해 상세 정보를 자동으로 다시 불러오도록 보정함.
|
||||
- 선택 후 잠시 비어 보이던 구간을 줄이기 위해 로딩 상태와 선택된 게임 ID 안내를 추가함.
|
||||
|
||||
## 2026-03-31 v1.2.51
|
||||
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/`를 `.gitignore`에 추가해 푸시 대상에서 제외함.
|
||||
|
||||
## 2026-03-31 v1.2.50
|
||||
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
|
||||
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
|
||||
|
||||
## 2026-03-31 v1.2.49
|
||||
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
|
||||
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
|
||||
|
||||
## 2026-03-31 v1.2.48
|
||||
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
|
||||
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
|
||||
|
||||
## 2026-03-31 v1.2.47
|
||||
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
|
||||
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
|
||||
|
||||
## 2026-03-31 v1.2.46
|
||||
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
|
||||
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
|
||||
|
||||
## 2026-03-31 v1.2.45
|
||||
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
|
||||
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
|
||||
|
||||
## 2026-03-31 v1.2.44
|
||||
- **관리자 탭 구조 재정리**: `목록 관리`와 `게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
|
||||
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
|
||||
|
||||
## 2026-03-31 v1.2.43
|
||||
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
|
||||
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
|
||||
|
||||
## 2026-03-31 v1.2.42
|
||||
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
|
||||
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
|
||||
|
||||
## 2026-03-31 v1.2.41
|
||||
- **에디터 하단 아이템 풀 카드형 전환**: 브라우저 폭이 `980px` 이하로 줄어 아이템 풀이 티어표 아래로 내려오면, 세로 리스트 대신 `이미지 위 / 이름 아래` 카드형 그리드로 전환되도록 조정
|
||||
- **소형 폭 열 수 최적화**: 약 `800px` 전후에서는 6열 그리드가 유지되고, 더 작은 폭에서는 4열/3열로 자연스럽게 줄어들며 긴 이름은 가운데 정렬된 말줄임 형태로 보이도록 정리
|
||||
@@ -345,6 +436,101 @@
|
||||
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
|
||||
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
||||
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-19 v0.1.16
|
||||
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
||||
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
||||
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
||||
|
||||
## 2026-03-19 v0.1.15
|
||||
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
|
||||
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.14
|
||||
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
|
||||
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
|
||||
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
|
||||
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
||||
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
||||
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
||||
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
|
||||
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
||||
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
||||
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
|
||||
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
|
||||
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
|
||||
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
|
||||
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
|
||||
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
|
||||
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
||||
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
||||
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
||||
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
||||
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
||||
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
||||
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
||||
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
||||
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
||||
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
||||
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
||||
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
||||
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
||||
|
||||
## 2026-03-19 v0.1.1
|
||||
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
||||
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
||||
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
||||
|
||||
## 2026-03-19 v0.1.0
|
||||
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
|
||||
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가
|
||||
@@ -357,137 +543,3 @@
|
||||
- **네비/권한 UX**: 관리자 메뉴는 admin 로그인 시에만 노출, 로그인 대신 아바타 버튼/메뉴 노출
|
||||
- **프로필**: `/profile` 페이지 추가, 아바타 업로드 API(`/api/auth/avatar`) 및 표시 지원
|
||||
- **에디터 버그 수정**: 드래그 시 아이템들이 “묶음”으로 같이 움직이던 문제 해결(드롭 영역 DOM 구조/Sortable 옵션 수정), 드롭 영역 overflow/배치 레이아웃 개선
|
||||
|
||||
## 2026-03-19 v0.1.1
|
||||
- **티어표 메타데이터 개선**: 제목 미입력 시 저장 시점에 게임 이름 기반 자동 제목 적용, 설명(선택) 필드 추가
|
||||
- **시간 정보 표시**: 내 티어표/공개 목록에서 저장 시간(createdAt)과 업데이트 시간(updatedAt)을 시:분:초까지 표시
|
||||
- **에디터 UX 수정**: 빈 티어 칸 안내 문구가 첫 드래그 배치를 가리던 문제 수정(오버레이 처리), 제목 상단에 게임 이름 표시
|
||||
|
||||
## 2026-03-19 v0.1.2
|
||||
- **로그인 UI 개선**: 로그인 카드 중앙 배치, 중복 타이틀 제거, 입력 overflow 수정, 엔터로 로그인/회원가입 제출
|
||||
- **안내문 조건화**: “첫 회원가입 계정은 admin” 문구는 유저가 0명일 때만 표시(`/api/auth/meta`)
|
||||
- **게임 목록 UI 개선**: 게임 카드에 썸네일 표시, 중복 텍스트 제거, “새로운 게임 제안” 모달 추가
|
||||
- **관리자 기능 추가**: 게임 썸네일 업로드 API(`/api/admin/games/:gameId/thumbnail`) 및 UI 추가
|
||||
- **에디터 레이아웃 개선**: 등급(그룹) 라벨 칼럼 확장으로 텍스트 잘림 방지, 설명 입력 1줄, 정렬을 좌측 기준으로 조정
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- **배포 설정 개선**: 프런트엔드의 API/정적 파일 주소 하드코딩(`http://localhost:5179`)을 `VITE_API_ORIGIN` 기반으로 통합
|
||||
- **백엔드 운영 설정 추가**: `CORS_ORIGINS`, `TRUST_PROXY`, `SESSION_COOKIE_SECURE`, `SESSION_COOKIE_SAME_SITE`, `SESSION_SECRET` 환경변수 기반으로 NAS/리버스 프록시 배포 대응
|
||||
- **업로드 파일명 안정화**: 한글 원본 파일명 기반 저장을 제거하고 ASCII 안전 파일명으로 저장하도록 변경
|
||||
- **티어표 데이터 정규화**: 게임 이미지 경로가 절대 로컬 URL로 저장되지 않도록 저장/조회 시 `/uploads/...` 상대 경로로 정규화
|
||||
- **프로젝트 점검 결과 문서화**: DB 구조, 화면-파일 매핑, 코딩 규칙, 기술 명세, 남은 위험 요소를 `docs/`에 신규 정리
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- **DB 마이그레이션 준비**: 런타임 저장소를 `MariaDB(MySQL 호환)` 기준으로 재구성하고 `backend/scripts/migrate-lowdb-to-mariadb.js` 마이그레이션 스크립트 추가
|
||||
- **데이터 구조 분리**: 관리자 지정 아이템은 `game_items`, 유저 커스텀 이미지는 `custom_items`로 분리
|
||||
- **프로필 개선**: 작성자 닉네임 저장 지원, 아바타는 파일 선택 시 미리보기만 변경되고 저장 버튼 클릭 시 실제 반영되도록 수정
|
||||
- **공개 티어표 목록 개선**: 공개 티어표 목록에 작성자 닉네임(없으면 이메일) 표시
|
||||
- **관리자 UI 개편**: 게임 선택 전에는 우측 관리 패널을 숨기고, 선택 후에만 썸네일/아이템 관리가 보이도록 단계형 흐름으로 수정
|
||||
- **관리자 레이아웃 수정**: 새 게임 입력 필드와 카드 셀 overflow 문제를 줄이도록 `box-sizing`, 썸네일/아이템 카드 레이아웃 정리
|
||||
- **커스텀 아이템 저장 흐름 수정**: 에디터의 커스텀 이미지는 저장 시 서버 업로드 후 티어표에 반영되도록 변경
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- **로컬 개발 환경 정렬**: 기본 백엔드 실행 기준을 lowdb가 아닌 로컬 MariaDB로 전환
|
||||
- **개발용 인프라 추가**: 루트 `docker-compose.yml`에 `MariaDB + phpMyAdmin` 추가
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`에 로컬 MariaDB 실행 절차 반영
|
||||
- **Fallback 분리**: `backend/package.json`에 `dev:lowdb`, `start:lowdb` 예외 스크립트 추가
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- **저장소 메타데이터 정리**: Git 작성자 정보를 프로젝트 계정 기준으로 통일하고, 초기 릴리스 커밋 메시지를 한국어 기준으로 재작성
|
||||
- **버전 관리 규칙 보강**: 커밋 메시지 한국어 작성 및 문서 버전과 Git 태그를 함께 맞추는 규칙을 문서에 반영
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- **AI 작업 규칙 보강**: `ai-rules.md`에 Git 작성자 정보, 한국어 커밋 메시지, 버전/태그 동기화, 민감 정보 확인 규칙 추가
|
||||
- **관리자 화면 재구성**: `/admin`을 좌우 병렬 구조에서 `모드 선택 → 게임 선택/생성 → 선택된 게임 상세 관리` 흐름으로 재구성
|
||||
- **관리자 삭제 기능 추가**: 등록된 게임 자체 삭제 및 등록된 아이템 개별 삭제 기능 추가
|
||||
- **데이터 정합성 보강**: 관리자 아이템 삭제 시 관련 티어표의 `groups/pool` 참조를 함께 정리하도록 백엔드 로직 보강
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- **관리자 업로드 UX 개선**: 썸네일과 아이템 추가 시 파일 선택 직후 미리보기 표시
|
||||
- **썸네일 비율 정리**: 관리자 썸네일 미리보기와 대표 썸네일 표시를 16:9, 약 256px 폭 기준으로 조정
|
||||
- **아이템 카드 레이아웃 개선**: 아이템 목록과 추가 미리보기를 1:1 비율 기준으로 재구성하고 더 촘촘한 카드 그리드로 조정
|
||||
- **레거시 파일 역할 정리**: `db.json`과 lowdb 관련 코드는 현재 MariaDB 기본 런타임에는 필수가 아니며, 마이그레이션/예외 fallback 용도임을 문서에 명시
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
|
||||
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
|
||||
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
|
||||
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
|
||||
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
|
||||
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- **관리자 레이아웃 재구성**: 인라인 스타일을 제거하고, 썸네일 적용과 아이템 추가를 상단 2열 카드로 재배치한 뒤 아이템 목록은 하단 리스트로 분리
|
||||
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
||||
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
||||
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
||||
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
||||
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
||||
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- **관리자 탭 구조 정리**: 관리자 페이지를 `게임 관리 / 아이템 관리 / 회원 관리` 탭으로 분리하고 기능별 작업 영역을 명확히 분리
|
||||
- **커스텀 아이템 조회 강화**: 사용자 커스텀 아이템 목록에 파일명 검색, `50/200` 단위 페이지네이션, 다운로드 흐름 추가
|
||||
- **회원 비밀번호 초기화 추가**: 관리자 페이지와 API에서 회원 비밀번호를 직접 재설정할 수 있도록 기능 추가
|
||||
- **가변 티어 행 지원**: 티어표 에디터에서 `S~D` 고정 5단이 아니라 티어 행을 직접 추가/삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-19 v0.1.14
|
||||
- **커스텀 아이템 카드 반응형 수정**: 관리자 아이템 관리 탭의 커스텀 아이템 카드에서 이미지 폭을 유동값으로 조정하고, 텍스트 영역에 `min-width: 0`과 강제 줄바꿈 기준을 추가해 카드 바깥 overflow를 방지
|
||||
|
||||
## 2026-03-19 v0.1.15
|
||||
- **셀렉트 화살표 여백 정리**: 전역 `select` 스타일에 커스텀 화살표 위치와 오른쪽 여백을 추가해 텍스트와 화살표가 지나치게 붙지 않도록 조정
|
||||
- **티어표 다운로드 결과 개선**: `TierEditorView`의 이미지 저장을 Blob 다운로드 방식으로 바꾸고, 캡처 대상을 보드 영역만 포함하는 전용 export 뷰로 분리해 우측 아이템 영역과 편집용 버튼/입력 UI가 저장 이미지에 섞이지 않도록 수정
|
||||
|
||||
## 2026-03-19 v0.1.16
|
||||
- **티어표 헤더 마감 정리**: 제목/설명 입력을 각각 한 줄 폭으로 정리하고, 액션 영역과 분리해 헤더 가독성을 개선
|
||||
- **export 정보 보강**: 이미지 저장 시 제목 아래에 설명이 함께 표시되도록 보강
|
||||
- **보드 여백/정렬 정리**: 보드 내부 패딩을 늘리고, 티어 그룹 제목을 중앙 정렬로 조정해 완성본 느낌을 개선
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- **내 티어표 삭제 추가**: `내 티어표` 목록에서 작성자가 자신의 티어표를 직접 삭제할 수 있도록 삭제 버튼과 API를 추가
|
||||
- **미사용 커스텀 이미지 관리 추가**: 관리자 아이템 탭에서 커스텀 이미지의 사용 횟수를 표시하고, 미사용 항목만 따로 필터링해 개별/일괄 삭제할 수 있도록 보강
|
||||
|
||||
## 2026-03-31 v0.1.18
|
||||
- **에디터 보드 폭 기준 정리**: 티어표 보드 영역을 저장 이미지 기준에 맞춰 최대 약 `960px` 폭으로 묶고, 넓은 화면에서는 아이템 풀이 남는 공간을 더 가져가도록 조정
|
||||
- **아이템 풀 카드형 통일**: 넓은 화면에서도 우측 아이템 목록을 카드형 그리드로 바꿔 한 번에 더 많은 아이템을 보고 드래그할 수 있도록 개선
|
||||
|
||||
## 2026-03-31 v0.1.19
|
||||
- **이름 표시 옵션 추가**: 티어 에디터 우측 옵션에 `캐릭터 이름 표시` 토글을 추가하고, 보드 안에서는 이미지 하단 오버레이 라벨로 표시되도록 개선
|
||||
- **저장/불러오기 연동**: 이름 표시 옵션이 저장된 티어표와 다운로드 이미지에도 그대로 반영되도록 프런트/백엔드 저장 구조를 확장
|
||||
|
||||
## 2026-03-31 v0.1.20
|
||||
- **관리자 탭 구조 재정리**: `목록 관리`와 `게임 관리`를 분리하고, 게임 생성/선택 흐름을 우측 사이드가 아닌 본문 전용 작업 화면으로 이동
|
||||
- **회원/액션 레이아웃 정리**: 회원 카드의 작성 수/최근 활동을 텍스트형 정보로 단순화하고, 관리 버튼의 줄바꿈이 어색하지 않도록 액션 그리드를 보정
|
||||
|
||||
## 2026-03-31 v0.1.21
|
||||
- **회원 카드 액션 재구성**: 비밀번호 초기화와 회원 삭제를 아이콘 액션으로 축소하고, `회원정보 저장` 버튼은 실제 변경이 있을 때만 활성화되도록 조정
|
||||
- **관리자 아바타 편집 지원**: 관리자도 회원 아바타를 클릭해 변경하거나 삭제할 수 있도록 전용 업로드 API와 카드 UI를 추가
|
||||
|
||||
## 2026-03-31 v0.1.22
|
||||
- **회원 액션 플로우 수정**: 회원 카드의 불필요한 안내 문구와 상단 삭제 아이콘을 제거하고, 비밀번호 초기화/회원 삭제를 각각 전용 확인 모달로 재구성
|
||||
- **저장 버튼 활성 조건 정리**: 회원정보 저장은 필드가 실제로 바뀐 경우에만 활성화되고, 비밀번호 초기화와 삭제 아이콘은 즉시 사용할 수 있도록 조정
|
||||
|
||||
## v0.1.23
|
||||
- 관리자 회원 관리에서 비밀번호 초기화와 삭제를 실제 모달 플로우로 연결하고, 저장 버튼은 회원 정보 변경 시에만 활성화되도록 정리함.
|
||||
- 상단 휴지통 아이콘과 불필요 문구를 제거하고, 관리자도 회원 썸네일을 카드 안에서 바로 수정/삭제할 수 있게 보완함.
|
||||
|
||||
## v0.1.24
|
||||
- 관리자 회원 관리 배지를 Settings 화면의 Administrator 스타일로 통일하고, 카드 우측 상단에 걸치는 형태로 재배치함.
|
||||
- 관리자 권한 체크박스를 제거하고 작은 텍스트 액션과 확인 모달을 거쳐 draft 상태만 바꾸는 흐름으로 정리함.
|
||||
|
||||
## v0.1.25
|
||||
- 관리자 회원 저장 후 통계 정보가 흔들리던 문제를 줄이기 위해 저장/아바타 변경 뒤 회원 목록을 다시 동기화하도록 보정함.
|
||||
- 회원 아바타 액션을 hover 기반으로 재배치해 평소에는 숨기고, 마우스 오버 시에만 수정 오버레이와 삭제 버튼이 나타나도록 조정함.
|
||||
|
||||
## v0.1.26
|
||||
- 관리자 회원 아바타 삭제 버튼 조건을 명확히 하고 hover 표시를 visibility까지 포함해 보정해 다른 사용자 카드에서도 안정적으로 노출되도록 조정함.
|
||||
- 삭제 배지 아이콘을 흰색으로 보정하고 어두운 배경 위에서 더 잘 보이도록 스타일을 다듬음.
|
||||
|
||||
## v0.1.27
|
||||
- 운영 비밀값이 들어 있는 `.env.production`과 로컬 에디터 설정 `.vscode/`를 `.gitignore`에 추가해 푸시 대상에서 제외함.
|
||||
|
||||
@@ -21,6 +21,7 @@ const { toasts, dismissToast } = useToast()
|
||||
const leftRailCollapsed = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
@@ -261,6 +262,10 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
if (route.name === 'home') {
|
||||
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
|
||||
return
|
||||
}
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
|
||||
}
|
||||
|
||||
@@ -310,7 +315,7 @@ function submitGlobalSearch() {
|
||||
<img :src="iconSearch" alt="" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : '전체 티어표 검색'" />
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav class="leftNav">
|
||||
@@ -359,12 +364,12 @@ function submitGlobalSearch() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" aria-label="전체 티어표 검색" @click.self="closeCollapsedSearch">
|
||||
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
|
||||
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
|
||||
<span class="collapsedSearchBar__icon">
|
||||
<img :src="iconSearch" alt="" />
|
||||
</span>
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" placeholder="전체 티어표 검색" autofocus />
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -410,12 +415,10 @@ function submitGlobalSearch() {
|
||||
|
||||
<style scoped>
|
||||
.appShell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%),
|
||||
linear-gradient(180deg, #1a1a1a 0%, #121212 100%);
|
||||
background: rgba(14, 14, 14, 0.96);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
@@ -426,7 +429,7 @@ function submitGlobalSearch() {
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(14, 14, 14, 0.92);
|
||||
box-sizing: border-box;
|
||||
@@ -492,7 +495,7 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.leftRail__body {
|
||||
max-height: calc(100vh - 56px);
|
||||
max-height: calc(100dvh - 56px);
|
||||
}
|
||||
|
||||
.rightRail__body {
|
||||
@@ -785,7 +788,11 @@ function submitGlobalSearch() {
|
||||
|
||||
.appMain {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
background: rgba(18, 18, 18, 0.98);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.appMain--preview {
|
||||
@@ -794,8 +801,9 @@ function submitGlobalSearch() {
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: 56px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.workspace--localRail {
|
||||
@@ -836,23 +844,23 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
.workspaceBody {
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
padding: 18px 18px 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
background: rgba(24, 24, 24, 0.92);
|
||||
box-shadow: none;
|
||||
margin: 18px 18px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspaceBody--localRail {
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
padding: 18px 18px 32px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
background: rgba(24, 24, 24, 0.92);
|
||||
box-shadow: none;
|
||||
margin: 18px 18px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rightRail {
|
||||
@@ -978,7 +986,7 @@ function submitGlobalSearch() {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(360px, calc(100vw - 20px));
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
z-index: 30;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(14, 14, 14, 0.96);
|
||||
@@ -996,10 +1004,12 @@ function submitGlobalSearch() {
|
||||
@media (max-width: 860px) {
|
||||
.appShell {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@@ -1013,6 +1023,19 @@ function submitGlobalSearch() {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-height: auto;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.workspaceBody,
|
||||
.workspaceBody--localRail {
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.leftRail__content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export const api = {
|
||||
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
|
||||
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
|
||||
@@ -29,6 +29,7 @@ const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemOrphanOnly = ref(false)
|
||||
const customItemTargetGameId = ref('')
|
||||
const customItemModalTargetGameId = ref('')
|
||||
|
||||
const adminTierLists = ref([])
|
||||
const adminTierListQuery = ref('')
|
||||
@@ -48,9 +49,12 @@ const previewTierList = ref(null)
|
||||
const userPasswordModalOpen = ref(false)
|
||||
const userDeleteModalOpen = ref(false)
|
||||
const userRoleModalOpen = ref(false)
|
||||
const customItemModalOpen = ref(false)
|
||||
const customItemDeleteModalOpen = ref(false)
|
||||
const modalTargetUser = ref(null)
|
||||
const modalPasswordDraft = ref('')
|
||||
const modalRoleNextAdmin = ref(false)
|
||||
const modalTargetCustomItem = ref(null)
|
||||
|
||||
const users = ref([])
|
||||
|
||||
@@ -64,12 +68,15 @@ const uploadFiles = ref([])
|
||||
const thumbFile = ref(null)
|
||||
const itemPreviewUrls = ref([])
|
||||
const isItemDragOver = ref(false)
|
||||
const isThumbDragOver = ref(false)
|
||||
const thumbPreviewUrl = ref('')
|
||||
const itemFileInput = ref(null)
|
||||
const thumbFileInput = ref(null)
|
||||
const featuredListEl = ref(null)
|
||||
const featuredSortable = ref(null)
|
||||
const userAvatarInputs = ref({})
|
||||
const isGameLoading = ref(false)
|
||||
const gameCreateModalOpen = ref(false)
|
||||
|
||||
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
||||
@@ -168,6 +175,30 @@ onUnmounted(() => {
|
||||
destroyFeaturedSortable()
|
||||
})
|
||||
|
||||
function clearPreviewUrl(kind) {
|
||||
if (kind === 'item') {
|
||||
itemPreviewUrls.value.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
})
|
||||
itemPreviewUrls.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (thumbPreviewUrl.value) {
|
||||
URL.revokeObjectURL(thumbPreviewUrl.value)
|
||||
thumbPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function resetFileInput(kind) {
|
||||
if (kind === 'item') {
|
||||
if (itemFileInput.value) itemFileInput.value.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (thumbFileInput.value) thumbFileInput.value.value = ''
|
||||
}
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
@@ -180,6 +211,15 @@ watch(success, (message) => {
|
||||
success.value = ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => activeTab.value,
|
||||
async (tab) => {
|
||||
if (tab === 'game-admin' && selectedGameId.value && !selectedGame.value?.game?.id) {
|
||||
await loadGame()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function resetMessages() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
@@ -191,9 +231,6 @@ function setTab(tab) {
|
||||
if (tab === 'tierlists') {
|
||||
tierlistsMode.value = 'requests'
|
||||
}
|
||||
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
function setTierlistsMode(mode) {
|
||||
@@ -201,13 +238,26 @@ function setTierlistsMode(mode) {
|
||||
tierlistsMode.value = mode
|
||||
}
|
||||
|
||||
function openGameCreateModal() {
|
||||
resetMessages()
|
||||
newGameId.value = ''
|
||||
newGameName.value = ''
|
||||
gameCreateModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeGameCreateModal() {
|
||||
gameCreateModalOpen.value = false
|
||||
}
|
||||
|
||||
async function handleSelectedGameChange(event) {
|
||||
selectedGameId.value = event?.target?.value || ''
|
||||
await loadGame()
|
||||
}
|
||||
|
||||
async function refreshGames() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
games.value = data.games || []
|
||||
if (!customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
featuredGameIds.value = games.value
|
||||
.filter((game) => game.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
@@ -404,6 +454,7 @@ async function loadGame() {
|
||||
}
|
||||
|
||||
try {
|
||||
isGameLoading.value = true
|
||||
const data = await api.getGame(selectedGameId.value)
|
||||
selectedGame.value = {
|
||||
...data,
|
||||
@@ -413,7 +464,11 @@ async function loadGame() {
|
||||
})),
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
|
||||
selectedGame.value = null
|
||||
error.value = '게임 정보를 불러오지 못했어요.'
|
||||
} finally {
|
||||
isGameLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,26 +479,56 @@ async function createGame() {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: newGameId.value, name: newGameName.value }),
|
||||
body: JSON.stringify({ id: newGameId.value.trim(), name: newGameName.value.trim() }),
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
|
||||
const data = await res.json()
|
||||
await refreshGames()
|
||||
selectedGameId.value = data.game.id
|
||||
closeGameCreateModal()
|
||||
await loadGame()
|
||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 아이템을 관리할 수 있어요.'
|
||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
|
||||
} catch (e) {
|
||||
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
|
||||
}
|
||||
}
|
||||
|
||||
function onThumb(event) {
|
||||
thumbFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null
|
||||
function handleThumbFile(file) {
|
||||
const nextFile = file && (file.type || '').startsWith('image/') ? file : null
|
||||
thumbFile.value = nextFile
|
||||
clearPreviewUrl('thumb')
|
||||
if (thumbFile.value) thumbPreviewUrl.value = URL.createObjectURL(thumbFile.value)
|
||||
}
|
||||
|
||||
function onThumb(event) {
|
||||
handleThumbFile(event.target.files && event.target.files[0] ? event.target.files[0] : null)
|
||||
}
|
||||
|
||||
function openThumbFilePicker() {
|
||||
thumbFileInput.value?.click()
|
||||
}
|
||||
|
||||
function onThumbDragEnter(event) {
|
||||
event.preventDefault()
|
||||
isThumbDragOver.value = true
|
||||
}
|
||||
|
||||
function onThumbDragOver(event) {
|
||||
event.preventDefault()
|
||||
isThumbDragOver.value = true
|
||||
}
|
||||
|
||||
function onThumbDragLeave(event) {
|
||||
if (event.currentTarget === event.target) isThumbDragOver.value = false
|
||||
}
|
||||
|
||||
function onThumbDrop(event) {
|
||||
event.preventDefault()
|
||||
isThumbDragOver.value = false
|
||||
handleThumbFile(event.dataTransfer?.files?.[0] || null)
|
||||
}
|
||||
|
||||
function onFile(event) {
|
||||
handleItemFiles(event.target.files)
|
||||
}
|
||||
@@ -768,18 +853,44 @@ function moveCustomItemPage(direction) {
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
async function removeCustomItem(item) {
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalTargetGameId.value = ''
|
||||
customItemModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeCustomItemModal() {
|
||||
customItemModalOpen.value = false
|
||||
modalTargetCustomItem.value = null
|
||||
customItemModalTargetGameId.value = ''
|
||||
}
|
||||
|
||||
function openCustomItemDeleteModal(item) {
|
||||
if (!item) return
|
||||
if (item.usageCount > 0) {
|
||||
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
modalTargetCustomItem.value = item
|
||||
customItemDeleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeCustomItemDeleteModal() {
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
if (item.usageCount > 0) {
|
||||
error.value = '사용 중인 커스텀 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||
return
|
||||
}
|
||||
|
||||
const ok = window.confirm(`"${item.label}" 미사용 커스텀 이미지를 삭제할까요?`)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.deleteAdminCustomItem(item.id)
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value = '미사용 커스텀 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
@@ -803,16 +914,17 @@ async function removeUnusedCustomItems() {
|
||||
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemTargetGameId.value) {
|
||||
if (!customItemModalTargetGameId.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()
|
||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
|
||||
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
|
||||
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
|
||||
} catch (e) {
|
||||
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||
@@ -1122,36 +1234,34 @@ async function saveFeaturedOrder() {
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">게임 선택 및 생성</div>
|
||||
<div class="hint hint--tight">우측 사이드가 아니라 이 화면 안에서 게임을 만들고, 기존 게임을 선택해 바로 상세 관리로 이어집니다.</div>
|
||||
<div class="panel__title">게임 관리</div>
|
||||
<div class="hint hint--tight">등록된 게임을 선택하면 아래에서 썸네일과 기본 아이템을 바로 수정할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
|
||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
||||
</div>
|
||||
|
||||
<div class="gameManagerGrid">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">등록된 게임 선택</div>
|
||||
<div class="gameManagerCard__body">
|
||||
<select v-model="selectedGameId" class="select" @change="loadGame">
|
||||
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
|
||||
<option value="">게임을 선택해주세요</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
||||
</select>
|
||||
<div class="hint hint--tight">선택하면 아래 상세 영역에서 썸네일과 기본 아이템을 바로 수정할 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="adminCard">
|
||||
<div class="section__title">새 게임 만들기</div>
|
||||
<div class="gameManagerCard__body">
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<button class="btn btn--primary" @click="createGame">게임 생성</button>
|
||||
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSelectedGame" class="panel">
|
||||
<div v-if="isGameLoading" class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
|
||||
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 곧 표시합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasSelectedGame" class="panel">
|
||||
<div class="detailHead">
|
||||
<div>
|
||||
<div class="panel__title">선택된 게임 정보</div>
|
||||
@@ -1166,12 +1276,25 @@ async function saveFeaturedOrder() {
|
||||
<div class="section section--topGrid">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">썸네일 적용</div>
|
||||
<div class="uploadPreviewCard">
|
||||
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': isThumbDragOver }"
|
||||
type="button"
|
||||
@click="openThumbFilePicker"
|
||||
@dragenter="onThumbDragEnter"
|
||||
@dragover="onThumbDragOver"
|
||||
@dragleave="onThumbDragLeave"
|
||||
@drop="onThumbDrop"
|
||||
>
|
||||
<img v-if="displayThumbnailUrl" class="selectedThumb" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty">썸네일 미리보기</div>
|
||||
</div>
|
||||
<div v-else class="selectedThumb selectedThumb--empty">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__title">클릭하거나 드래그해서 썸네일 추가</div>
|
||||
<div class="thumbDropZone__desc">다른 업로드 화면처럼 이미지 한 장을 바로 지정할 수 있어요.</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="uploadControls">
|
||||
<input ref="thumbFileInput" type="file" accept="image/*" class="inputFile" @change="onThumb" />
|
||||
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1242,6 +1365,7 @@ async function saveFeaturedOrder() {
|
||||
<div class="emptyState__desc">
|
||||
위에서 기존 게임을 선택하거나 새 게임을 만든 뒤, 같은 화면에서 바로 썸네일과 기본 아이템을 정리할 수 있어요.
|
||||
</div>
|
||||
<div v-if="selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 중 오류가 발생했어요. 다시 선택해보세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1250,23 +1374,10 @@ async function saveFeaturedOrder() {
|
||||
<div class="panel">
|
||||
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<article v-for="item in customItems" :key="item.id" class="customItemCard">
|
||||
<button v-for="item in customItems" :key="item.id" type="button" class="customItemCard" @click="openCustomItemModal(item)">
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__body">
|
||||
<div class="customItemCard__title">{{ item.label }}</div>
|
||||
<div class="customItemCard__meta">파일: {{ item.src.split('/').pop() }}</div>
|
||||
<div class="customItemCard__meta">업로더: {{ item.ownerName }}</div>
|
||||
<div class="customItemCard__meta">사용 중: {{ item.usageCount }}개 티어표</div>
|
||||
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
||||
<div class="customItemCard__actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
@@ -1310,8 +1421,14 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
|
||||
<div v-if="request.type === 'create'" class="templateRequestCard__form">
|
||||
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 이름</span>
|
||||
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
|
||||
</label>
|
||||
<label class="templateRequestField">
|
||||
<span class="templateRequestField__label">게임 ID</span>
|
||||
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="templateRequestCard__actions">
|
||||
@@ -1344,6 +1461,7 @@ async function saveFeaturedOrder() {
|
||||
<div class="tierAdminCard__head">
|
||||
<div>
|
||||
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ tierList.gameName || tierList.gameId }} · {{ tierListAuthorDisplayName(tierList) }} · {{ tierListVisibilityLabel(tierList) }}
|
||||
</div>
|
||||
@@ -1462,6 +1580,21 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="gameCreateModalOpen" class="modalOverlay" @click.self="closeGameCreateModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">새 게임 만들기</div>
|
||||
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="!newGameId.trim() || !newGameName.trim()" @click="createGame">게임 생성</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">비밀번호 초기화</div>
|
||||
@@ -1543,6 +1676,61 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
|
||||
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__titleRow">
|
||||
<div class="modalCard__title">커스텀 아이템 상세</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closeCustomItemModal">닫기</button>
|
||||
</div>
|
||||
<div v-if="modalTargetCustomItem" class="customItemModal">
|
||||
<div class="customItemModal__side">
|
||||
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
|
||||
<div class="customItemModal__selector">
|
||||
<span class="customItemModal__label">기본 템플릿에 추가</span>
|
||||
<select v-model="customItemModalTargetGameId" class="select">
|
||||
<option value="">게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">이미 사용 중인 게임</span>
|
||||
<div v-if="modalTargetCustomItem.linkedGames?.length" class="customItemModal__chips">
|
||||
<span v-for="game in modalTargetCustomItem.linkedGames" :key="game.id" class="pill">{{ game.name }}</span>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 연결된 게임이 없어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customItemModal__body">
|
||||
<div class="customItemModal__title">{{ modalTargetCustomItem.label }}</div>
|
||||
<div class="customItemModal__metaList">
|
||||
<div class="customItemModal__metaRow"><span>파일</span><strong :title="modalTargetCustomItem.src.split('/').pop()">{{ modalTargetCustomItem.src.split('/').pop() }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>업로더</span><strong :title="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>사용 중</span><strong>{{ modalTargetCustomItem.usageCount }}개 티어표</strong></div>
|
||||
<div class="customItemModal__metaRow"><span>등록일</span><strong>{{ fmt(modalTargetCustomItem.createdAt) }}</strong></div>
|
||||
</div>
|
||||
<div class="customItemModal__actions">
|
||||
<a class="btn btn--ghost" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
|
||||
<button class="btn btn--ghost" :disabled="!customItemModalTargetGameId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
||||
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||
</button>
|
||||
<button class="btn btn--danger" :disabled="modalTargetCustomItem.usageCount > 0" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">커스텀 아이템 삭제</div>
|
||||
<div class="modalCard__desc">{{ modalTargetCustomItem ? '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 미사용 상태의 이미지에만 삭제를 허용합니다.' : '' }}</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
|
||||
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__titleRow">
|
||||
@@ -1576,47 +1764,7 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Featured</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshGames">목록 새로고침</button>
|
||||
<button class="btn btn--primary" @click="saveFeaturedOrder">순서 저장</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">상단 고정</span>
|
||||
<strong class="sidebarStat__value">{{ featuredGameIds.length }}/50</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">추가 가능</span>
|
||||
<strong class="sidebarStat__value">{{ Math.max(0, 50 - featuredGameIds.length) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'game-admin'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Game Summary</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
|
||||
<button v-if="hasSelectedGame" class="btn btn--ghost" @click="loadGame">선택 게임 다시 불러오기</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">전체 게임</span>
|
||||
<strong class="sidebarStat__value">{{ games.length }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">선택 상태</span>
|
||||
<strong class="sidebarStat__value">{{ hasSelectedGame ? '활성' : '대기' }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">기본 아이템</span>
|
||||
<strong class="sidebarStat__value">{{ selectedGame?.items?.length || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||
<section v-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Filters</div>
|
||||
<div class="adminSidebar__group">
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
@@ -1662,17 +1810,7 @@ async function saveFeaturedOrder() {
|
||||
전체 티어표 관리
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="tierlistsMode === 'requests'">
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshTemplateRequests">요청 새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">대기 요청</span>
|
||||
<strong class="sidebarStat__value">{{ templateRequests.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="tierlistsMode === 'requests'"></template>
|
||||
<template v-else>
|
||||
<div class="adminSidebar__group">
|
||||
<input
|
||||
@@ -1703,22 +1841,6 @@ async function saveFeaturedOrder() {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-else class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Users</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshUsers">회원 새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">가입 회원</span>
|
||||
<strong class="sidebarStat__value">{{ users.length }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">관리자 수</span>
|
||||
<strong class="sidebarStat__value">{{ users.filter((user) => user.isAdmin).length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -2229,6 +2351,35 @@ async function saveFeaturedOrder() {
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.thumbDropZone {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
justify-items: start;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
text-align: left;
|
||||
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
.thumbDropZone--active {
|
||||
border-color: rgba(96, 165, 250, 0.56);
|
||||
background: rgba(96, 165, 250, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.thumbDropZone__copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.thumbDropZone__title {
|
||||
font-weight: 900;
|
||||
}
|
||||
.thumbDropZone__desc {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.itemComposer {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
@@ -2344,57 +2495,120 @@ async function saveFeaturedOrder() {
|
||||
text-align: center;
|
||||
}
|
||||
.customItemGrid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.customItemCard {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.customItemCard:hover {
|
||||
border-color: rgba(126, 162, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.customItemCard__image {
|
||||
width: clamp(88px, 22vw, 150px);
|
||||
flex: 0 1 150px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.customItemCard__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.customItemCard__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.customItemCard__title {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-weight: 900;
|
||||
}
|
||||
.customItemCard__meta {
|
||||
opacity: 0.72;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #ffffff;
|
||||
}
|
||||
.customItemModal {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.customItemModal__side {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.customItemModal__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.customItemModal__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.customItemModal__selector,
|
||||
.customItemModal__linked {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.customItemModal__label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
.customItemModal__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.customItemModal__title {
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
.customItemModal__metaList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.customItemModal__metaRow {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.customItemModal__metaRow span {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
.customItemModal__metaRow strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
.customItemModal__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.modalCard--customItem {
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
@@ -2680,6 +2894,14 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.templateRequestField {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.templateRequestField__label {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
.templateRequestCard__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -2729,6 +2951,15 @@ async function saveFeaturedOrder() {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.tierAdminCard__desc {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tierAdminCard__meta {
|
||||
margin-top: 4px;
|
||||
opacity: 0.74;
|
||||
@@ -2880,6 +3111,9 @@ async function saveFeaturedOrder() {
|
||||
.adminHero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.customItemModal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminSidebar {
|
||||
display: none;
|
||||
}
|
||||
@@ -2923,19 +3157,22 @@ async function saveFeaturedOrder() {
|
||||
font-size: 24px;
|
||||
}
|
||||
.thumbGrid,
|
||||
.customItemGrid,
|
||||
.userList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.customItemGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
.tierAdminCard__head {
|
||||
display: grid;
|
||||
}
|
||||
.customItemCard {
|
||||
align-items: stretch;
|
||||
padding: 10px;
|
||||
}
|
||||
.customItemCard__image {
|
||||
width: clamp(72px, 28vw, 120px);
|
||||
flex-basis: 120px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const error = ref('')
|
||||
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const games = computed(() => {
|
||||
const filtered = items.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
.filter((item) => {
|
||||
if (!query.value) return true
|
||||
const haystack = `${item.name || ''} ${item.id || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
return filtered.slice().sort((a, b) => {
|
||||
if (!!a.isFavorited !== !!b.isFavorited) return a.isFavorited ? -1 : 1
|
||||
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
|
||||
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
|
||||
if (rankA !== rankB) return rankA - rankB
|
||||
return (a.name || '').localeCompare(b.name || '', 'ko')
|
||||
})
|
||||
})
|
||||
|
||||
async function loadGames() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
items.value = data.games || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadGames)
|
||||
watch(() => auth.user?.id, loadGames)
|
||||
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
}
|
||||
|
||||
async function toggleFavorite(game, event) {
|
||||
event?.stopPropagation()
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
|
||||
return
|
||||
}
|
||||
if (!game?.id || loadingFavoriteId.value === game.id) return
|
||||
|
||||
try {
|
||||
loadingFavoriteId.value = game.id
|
||||
const res = game.isFavorited ? await api.unfavoriteGame(game.id) : await api.favoriteGame(game.id)
|
||||
items.value = items.value.map((entry) => (entry.id === game.id ? { ...entry, ...res.game } : entry))
|
||||
} catch (e) {
|
||||
error.value = '즐겨찾기 변경에 실패했어요.'
|
||||
} finally {
|
||||
loadingFavoriteId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
return g.thumbnailSrc ? toApiUrl(g.thumbnailSrc) : ''
|
||||
}
|
||||
@@ -34,22 +77,35 @@ function thumbUrl(g) {
|
||||
<div class="pageHead__eyebrow">Workspace</div>
|
||||
<h1 class="pageHead__title">Game Library</h1>
|
||||
<p class="pageHead__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
<p v-if="query" class="pageHead__searchState">"{{ query }}"에 맞는 게임 템플릿만 보고 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="libraryGrid">
|
||||
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<section v-if="games.length" class="libraryGrid">
|
||||
<article v-for="g in games" :key="g.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
:class="{ 'libraryCard__favorite--active': g.isFavorited }"
|
||||
:disabled="loadingFavoriteId === g.id"
|
||||
@click.stop="toggleFavorite(g, $event)"
|
||||
>
|
||||
{{ g.isFavorited ? '★' : '☆' }}
|
||||
</button>
|
||||
<button class="libraryCard__main" type="button" @click="goGame(g.id)">
|
||||
<div class="libraryCard__thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 게임 템플릿이 없어요.' : '표시할 게임 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -66,7 +122,12 @@ function thumbUrl(g) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.pageHead__searchState {
|
||||
margin-top: 8px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.libraryCard {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
@@ -77,14 +138,40 @@ function thumbUrl(g) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
transition: transform 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.libraryCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.libraryCard__main {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.libraryCard__favorite {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(15, 15, 15, 0.72);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
.libraryCard__favorite--active {
|
||||
color: #ffd86b;
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -119,6 +206,10 @@ function thumbUrl(g) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.libraryEmpty {
|
||||
padding: 20px 0;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -43,6 +43,8 @@ const isExporting = ref(false)
|
||||
const isSaveModalOpen = ref(false)
|
||||
const isTemplateRequestModalOpen = ref(false)
|
||||
const isTemplateUpdateModalOpen = ref(false)
|
||||
const templateRequestDraftTitle = ref('')
|
||||
const templateRequestDraftDescription = ref('')
|
||||
const isDeleteModalOpen = ref(false)
|
||||
const ownerId = ref('')
|
||||
const authorName = ref('')
|
||||
@@ -112,7 +114,8 @@ const templateRequestChecks = computed(() => [
|
||||
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
||||
},
|
||||
])
|
||||
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
|
||||
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed) && !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
|
||||
|
||||
watch(error, (message) => {
|
||||
@@ -510,20 +513,29 @@ function closeSaveModal() {
|
||||
isSaveModalOpen.value = false
|
||||
}
|
||||
|
||||
function resetTemplateRequestDrafts() {
|
||||
templateRequestDraftTitle.value = ''
|
||||
templateRequestDraftDescription.value = ''
|
||||
}
|
||||
|
||||
function openTemplateRequestModal() {
|
||||
resetTemplateRequestDrafts()
|
||||
isTemplateRequestModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeTemplateRequestModal() {
|
||||
isTemplateRequestModalOpen.value = false
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
function openTemplateUpdateModal() {
|
||||
resetTemplateRequestDrafts()
|
||||
isTemplateUpdateModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeTemplateUpdateModal() {
|
||||
isTemplateUpdateModalOpen.value = false
|
||||
resetTemplateRequestDrafts()
|
||||
}
|
||||
|
||||
function openDeleteModal() {
|
||||
@@ -574,7 +586,11 @@ async function requestTemplate(type) {
|
||||
try {
|
||||
isRequestingTemplate.value = true
|
||||
const persisted = await persistTierList({ showModal: false })
|
||||
await api.requestTierListTemplate(persisted.savedTierListId, { type })
|
||||
await api.requestTierListTemplate(persisted.savedTierListId, {
|
||||
type,
|
||||
requestTitle: templateRequestDraftTitle.value.trim(),
|
||||
requestDescription: templateRequestDraftDescription.value.trim(),
|
||||
})
|
||||
if (type === 'create') closeTemplateRequestModal()
|
||||
if (type === 'update') closeTemplateUpdateModal()
|
||||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
||||
@@ -722,7 +738,18 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="requestChecklist__hint">
|
||||
제목만 명확하게 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 수 있어요. 여러 사용자가 비슷한 주제로 요청할 수 있으니 게임 이름을 구체적으로 적어주세요.
|
||||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 게임 템플릿이 필요합니다.`
|
||||
</div>
|
||||
<div class="templateRequestDraft">
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 제목</span>
|
||||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
|
||||
</label>
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 설명</span>
|
||||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
||||
@@ -741,10 +768,21 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="modalCard__note">
|
||||
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
||||
예시: 제목 `템플릿 업데이트 요청`, 설명 `여름 이벤트 한정 캐릭터 추가`
|
||||
</div>
|
||||
<div class="templateRequestDraft">
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 제목</span>
|
||||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
|
||||
</label>
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 설명</span>
|
||||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
||||
<button class="btn btn--save" :disabled="isRequestingTemplate" @click="requestTemplate('update')">
|
||||
<button class="btn btn--save" :disabled="!canSubmitTemplateUpdateRequest || isRequestingTemplate" @click="requestTemplate('update')">
|
||||
{{ isRequestingTemplate ? '요청중...' : '예, 요청할게요' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -952,13 +990,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="editorSidebar__section editorSidebar__section--footer">
|
||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
|
||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||
<span>공개</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
<span class="toggleSwitch__label">공개</span>
|
||||
</label>
|
||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
|
||||
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
|
||||
<span>캐릭터 이름 표시</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
<span class="toggleSwitch__label">캐릭터 이름 표시</span>
|
||||
</label>
|
||||
<div class="editorSidebar__actionGrid">
|
||||
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
||||
@@ -1094,23 +1134,55 @@ onUnmounted(() => {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
}
|
||||
.toggle {
|
||||
.toggleSwitch {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
font-weight: 800;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.toggleSwitch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toggle--disabled {
|
||||
.toggleSwitch__track {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: background 180ms ease, border-color 180ms ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.toggleSwitch__thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
.toggleSwitch__label {
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.toggleSwitch input:checked + .toggleSwitch__track {
|
||||
background: rgba(96, 165, 250, 0.34);
|
||||
border-color: rgba(96, 165, 250, 0.42);
|
||||
}
|
||||
.toggleSwitch input:checked + .toggleSwitch__track .toggleSwitch__thumb {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
.toggleSwitch--disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1253,6 +1325,42 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.78;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.templateRequestDraft {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.templateRequestDraft__field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.templateRequestDraft__label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
.templateRequestDraft__input {
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
outline: none;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.02em;
|
||||
resize: none;
|
||||
}
|
||||
.templateRequestDraft__input:focus {
|
||||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||||
}
|
||||
.templateRequestDraft__input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
.templateRequestDraft__textarea {
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
}
|
||||
.boardTools {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user