diff --git a/backend/src/db.js b/backend/src/db.js index 3a7de84..150c675 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -212,6 +212,18 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + await query(` + CREATE TABLE IF NOT EXISTS favorite_tierlists ( + user_id VARCHAR(64) NOT NULL, + tierlist_id VARCHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + PRIMARY KEY (user_id, tierlist_id), + INDEX idx_favorite_tierlists_tierlist_id (tierlist_id), + CONSTRAINT fk_favorite_tierlists_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_favorite_tierlists_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'") if (!tierListThumbnailColumns.length) { await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title") @@ -610,7 +622,51 @@ async function findUnusedCustomItems({ queryText = '' } = {}) { .filter((item) => item.usageCount === 0) } -async function listPublicTierLists(gameId) { +async function getFavoriteStatsForTierListIds(tierListIds, userId = '') { + const ids = Array.from(new Set((tierListIds || []).filter(Boolean))) + const countMap = new Map() + const favoritedSet = new Set() + if (!ids.length) return { countMap, favoritedSet } + + const placeholders = ids.map(() => '?').join(', ') + const countRows = await query( + ` + SELECT tierlist_id, COUNT(*) AS favorite_count + FROM favorite_tierlists + WHERE tierlist_id IN (${placeholders}) + GROUP BY tierlist_id + `, + ids + ) + + countRows.forEach((row) => { + countMap.set(row.tierlist_id, Number(row.favorite_count || 0)) + }) + + if (userId) { + const favoriteRows = await query( + ` + SELECT tierlist_id + FROM favorite_tierlists + WHERE user_id = ? AND tierlist_id IN (${placeholders}) + `, + [userId, ...ids] + ) + favoriteRows.forEach((row) => favoritedSet.add(row.tierlist_id)) + } + + return { countMap, favoritedSet } +} + +function applyFavoriteMetaToTierLists(tierLists, favoriteStats) { + return tierLists.map((tierList) => ({ + ...tierList, + favoriteCount: favoriteStats.countMap.get(tierList.id) || 0, + isFavorited: favoriteStats.favoritedSet.has(tierList.id), + })) +} + +async function listPublicTierLists(gameId, currentUserId = '') { const params = [] let whereClause = 'WHERE t.is_public = 1' if (gameId) { @@ -640,7 +696,7 @@ async function listPublicTierLists(gameId) { params ) - return rows.map((row) => ({ + const tierLists = rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, @@ -652,6 +708,12 @@ async function listPublicTierLists(gameId) { authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', })) + + const favoriteStats = await getFavoriteStatsForTierListIds( + tierLists.map((tierList) => tierList.id), + currentUserId + ) + return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } async function listUserTierLists(userId) { @@ -676,7 +738,7 @@ async function listUserTierLists(userId) { [userId] ) - return rows.map((row) => ({ + const tierLists = rows.map((row) => ({ id: row.id, gameId: row.game_id, title: row.title, @@ -688,6 +750,12 @@ async function listUserTierLists(userId) { authorAccountName: getUserAccountName(row), authorAvatarSrc: row.avatar_src || '', })) + + const favoriteStats = await getFavoriteStatsForTierListIds( + tierLists.map((tierList) => tierList.id), + userId + ) + return applyFavoriteMetaToTierLists(tierLists, favoriteStats) } function uniqueTierListItems(poolItems) { @@ -704,7 +772,7 @@ function uniqueTierListItems(poolItems) { return Array.from(map.values()) } -async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {}) { +async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) { const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200) const normalizedPage = Math.max(Number(page) || 1, 1) const hasQuery = !!(queryText || '').trim() @@ -762,15 +830,20 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {}) const total = allItems.length const offset = (normalizedPage - 1) * normalizedLimit + const pagedTierLists = allItems.slice(offset, offset + normalizedLimit) + const favoriteStats = await getFavoriteStatsForTierListIds( + pagedTierLists.map((tierList) => tierList.id), + currentUserId + ) return { - tierLists: allItems.slice(offset, offset + normalizedLimit), + tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats), total, page: normalizedPage, limit: normalizedLimit, } } -async function findTierListById(id) { +async function findTierListById(id, currentUserId = '') { const rows = await query( ` SELECT @@ -797,7 +870,10 @@ async function findTierListById(id) { `, [id] ) - return mapTierListRow(rows[0]) + const tierList = mapTierListRow(rows[0]) + if (!tierList) return null + const favoriteStats = await getFavoriteStatsForTierListIds([tierList.id], currentUserId) + return applyFavoriteMetaToTierLists([tierList], favoriteStats)[0] } async function deleteTierList(id) { @@ -832,7 +908,7 @@ async function deleteCustomItems(ids) { } async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) { - const existing = id ? await findTierListById(id) : null + const existing = id ? await findTierListById(id, authorId) : null if (existing) { await query( @@ -843,7 +919,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de `, [title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id] ) - return findTierListById(existing.id) + return findTierListById(existing.id, authorId) } const createdAt = now() @@ -856,7 +932,15 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de `, [id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt] ) - return findTierListById(id) + return findTierListById(id, authorId) +} + +async function favoriteTierList({ userId, tierListId }) { + await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()]) +} + +async function unfavoriteTierList({ userId, tierListId }) { + await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId]) } module.exports = { @@ -890,6 +974,8 @@ module.exports = { listUserTierLists, listAdminTierLists, findTierListById, + favoriteTierList, + unfavoriteTierList, deleteTierList, findCustomItemsByIds, deleteCustomItems, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index f72a594..0575fc8 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -176,6 +176,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => { queryText: parsed.data.q, page: parsed.data.page, limit: parsed.data.limit, + currentUserId: req.session?.userId || '', }) res.json(result) }) @@ -332,6 +333,7 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async ( const schema = z.object({ gameId: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120), + itemIds: z.array(z.string().min(1)).optional().default([]), }) const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) @@ -343,7 +345,10 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async ( if (!tierList) return res.status(404).json({ error: 'not_found' }) const result = await createGameTemplateFromTierList({ - tierList, + tierList: { + ...tierList, + pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool, + }, gameId: parsed.data.gameId, gameName: parsed.data.name, }) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index d6ade02..0c95a50 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -11,6 +11,8 @@ const { saveTierList, createCustomItem, findUserById, + favoriteTierList, + unfavoriteTierList, } = require('../db') const { requireAuth } = require('../middleware/auth') @@ -87,7 +89,7 @@ const tierListUpsertSchema = z.object({ router.get('/public', async (req, res) => { const gameId = req.query.gameId - const lists = await listPublicTierLists(gameId) + const lists = await listPublicTierLists(gameId, req.session?.userId || '') res.json({ tierLists: lists }) }) @@ -97,7 +99,7 @@ router.get('/me', requireAuth, async (req, res) => { }) router.get('/:id', async (req, res) => { - const t = await findTierListById(req.params.id) + const t = await findTierListById(req.params.id, req.session?.userId || '') if (!t) return res.status(404).json({ error: 'not_found' }) if (!t.isPublic) { if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' }) @@ -108,7 +110,7 @@ router.get('/:id', async (req, res) => { }) router.delete('/:id', requireAuth, async (req, res) => { - const tierList = await findTierListById(req.params.id) + const tierList = await findTierListById(req.params.id, req.session.userId) if (!tierList) return res.status(404).json({ error: 'not_found' }) if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) @@ -116,6 +118,25 @@ router.delete('/:id', requireAuth, async (req, res) => { res.json({ ok: true }) }) +router.post('/:id/favorite', requireAuth, async (req, res) => { + const tierList = await findTierListById(req.params.id, req.session.userId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + if (!tierList.isPublic && tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' }) + + await favoriteTierList({ userId: req.session.userId, tierListId: tierList.id }) + const updated = await findTierListById(tierList.id, req.session.userId) + res.json({ tierList: normalizeTierList(updated) }) +}) + +router.delete('/:id/favorite', requireAuth, async (req, res) => { + const tierList = await findTierListById(req.params.id, req.session.userId) + if (!tierList) return res.status(404).json({ error: 'not_found' }) + + await unfavoriteTierList({ userId: req.session.userId, tierListId: tierList.id }) + const updated = await findTierListById(tierList.id, req.session.userId) + res.json({ tierList: normalizeTierList(updated) }) +}) + router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) diff --git a/docs/history.md b/docs/history.md index 3dba9fd..898b306 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-03-27 v0.1.43 +- 화면 상단 인라인 경고는 스크롤이 생기면 놓치기 쉬우므로, 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 통일하는 편이 더 적합하다고 정리했다. +- 관리자 티어표 관리의 목적지 선택은 상단 고정 셀렉트보다 액션 직전 모달이 더 명확하므로, `기존 템플릿에 추가 / 새 템플릿 만들기`를 그 순간에 고르게 하기로 결정했다. +- 공개 티어표는 수량이 많아질수록 개인 보관 수단이 필요하므로, 사용자별 즐겨찾기를 별도 테이블로 저장하고 목록/상세에서 즉시 토글할 수 있게 하기로 했다. + ## 2026-03-26 v0.1.42 - 관리자 운영 관점에서는 공개 목록만으로는 부족하므로, 전체 티어표를 검색하고 추가 아이템까지 확인하는 별도 `티어표 관리` 탭을 두는 편이 더 적합하다고 정리했다. - 게임 기반 티어표의 “사용자 추가 아이템”과 `freeform` 티어표의 “전체 아이템”은 활용 목적이 다르므로, 전자는 기존 게임 템플릿 승격 중심으로, 후자는 새 게임 템플릿 생성 중심으로 다루기로 결정했다. diff --git a/docs/map.md b/docs/map.md index 49a53f0..67aa027 100644 --- a/docs/map.md +++ b/docs/map.md @@ -7,13 +7,13 @@ ## `/games/:gameId` - 화면 파일: `frontend/src/views/GameHubView.vue` -- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 티어표별 상단 썸네일/작성자 표시, 새 티어표 작성 진입 -- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public` +- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입 +- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite` ## `/editor/:gameId/new`, `/editor/:gameId/:tierListId` - 화면 파일: `frontend/src/views/TierEditorView.vue` -- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드 -- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists` +- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, 즐겨찾기 토글, PNG 다운로드 +- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists` ## `/login` - 화면 파일: `frontend/src/views/LoginView.vue` @@ -27,7 +27,7 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템 승격, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 +- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 - 연동 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/tierlists`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `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` @@ -37,7 +37,7 @@ ## 공통 레이아웃 - 앱 셸 파일: `frontend/src/App.vue` -- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어 +- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링 ## 백엔드 진입점 - 서버 엔트리: `backend/index.js` diff --git a/docs/spec.md b/docs/spec.md index 07176e5..e826a82 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -58,6 +58,10 @@ - `pool`: `{ id, src, label, origin }[]` - `createdAt`: number - `updatedAt`: number +- `favoriteTierLists` + - `userId`: string + - `tierListId`: string + - `createdAt`: number - `gameSuggestions` - `id`: string - `name`: string @@ -78,6 +82,8 @@ - `GET /api/tierlists/public` - `GET /api/tierlists/me` - `GET /api/tierlists/:id` + - `POST /api/tierlists/:id/favorite` + - `DELETE /api/tierlists/:id/favorite` - `DELETE /api/tierlists/:id` - `POST /api/tierlists/thumbnail` - `POST /api/tierlists/custom-items` @@ -114,6 +120,7 @@ - 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다. - 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. - 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 최근 티어표 전체를 제목/게임/작성자 기준으로 검색하고 공개 여부를 함께 확인할 수 있다. +- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다. - `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다. - `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다. - 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. @@ -122,6 +129,7 @@ - `new` 작성 경로는 로그인한 사용자만 진입할 수 있다. - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다. +- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. @@ -134,6 +142,7 @@ - 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다. - 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다. - 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `960px` 보드 폭과 `pixelRatio 1.5`, 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다. +- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다. - 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다. - `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다. diff --git a/docs/todo.md b/docs/todo.md index 259a426..b93ded5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -9,6 +9,8 @@ - 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. - 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다. - 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다. +- 즐겨찾기는 현재 저장/토글만 지원하므로, 필요하면 `내 즐겨찾기` 목록 화면이나 즐겨찾기순 정렬을 추가 검토한다. +- 전역 토스트는 기본 시간 기반 자동 종료만 지원하므로, 필요하면 중복 합치기나 액션 링크 포함 형태로 확장할 수 있다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index a272311..7def1e8 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-27 v0.1.43 +- **전역 토스트 알림 추가**: 저장/삭제/가져오기 같은 사용자 행동 피드백을 상단 인라인 경고 대신 우측 상단 토스트로 통일해 잠시 표시 후 자동으로 사라지도록 변경 +- **관리자 티어표 아이템 가져오기 모달화**: 티어표 관리의 추가 아이템 영역을 소형 그리드로 다듬고, 가져오기 시점에 `기존 템플릿에 추가 / 새 템플릿 만들기`를 선택하는 모달 흐름으로 재정리 +- **티어표 즐겨찾기 추가**: 공개 티어표 목록과 상세 화면에서 즐겨찾기 토글과 개수를 표시하고, MariaDB에 사용자별 즐겨찾기 이력을 저장하도록 확장 + ## 2026-03-26 v0.1.42 - **관리자 티어표 관리 탭 추가**: 공개/비공개를 포함한 최근 티어표 전체를 관리자 화면에서 검색/페이지네이션으로 확인하고, 제목·작성자·게임·공개 여부를 함께 볼 수 있도록 보강 - **추가 아이템 승격 흐름 확장**: 티어표 안에서 사용자가 추가한 커스텀 아이템을 관리자 화면에서 바로 특정 게임의 기본 템플릿으로 개별 또는 일괄 복제할 수 있도록 추가 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 62ab56f..d14b76c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,10 +3,12 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from './stores/auth' import { toApiUrl } from './lib/runtime' +import { useToast } from './composables/useToast' const route = useRoute() const router = useRouter() const auth = useAuthStore() +const toast = useToast() const isAdmin = computed(() => !!auth.user?.isAdmin) const avatarUrl = computed(() => { if (!auth.user?.avatarSrc) return '' @@ -81,6 +83,12 @@ async function logout() {
+
+
+
{{ item.message }}
+ +
+
@@ -198,4 +206,54 @@ async function logout() { .menuItem:hover { background: rgba(255, 255, 255, 0.09); } +.toastStack { + position: fixed; + top: 78px; + right: 18px; + z-index: 30; + display: grid; + gap: 10px; + width: min(360px, calc(100vw - 24px)); +} +.toast { + display: flex; + gap: 12px; + align-items: flex-start; + justify-content: space-between; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(11, 18, 32, 0.94); + backdrop-filter: blur(12px); + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28); +} +.toast--success { + border-color: rgba(52, 211, 153, 0.38); +} +.toast--error { + border-color: rgba(239, 68, 68, 0.34); +} +.toast--info { + border-color: rgba(96, 165, 250, 0.34); +} +.toast__message { + line-height: 1.5; + font-size: 14px; +} +.toast__close { + flex: 0 0 auto; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.76); + cursor: pointer; + font-weight: 800; +} +@media (max-width: 640px) { + .toastStack { + top: 70px; + right: 12px; + left: 12px; + width: auto; + } +} diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js new file mode 100644 index 0000000..ab4fef7 --- /dev/null +++ b/frontend/src/composables/useToast.js @@ -0,0 +1,31 @@ +import { readonly, ref } from 'vue' + +const toasts = ref([]) +let toastSeq = 0 + +function dismissToast(id) { + toasts.value = toasts.value.filter((toast) => toast.id !== id) +} + +function showToast(message, { type = 'info', duration = 2600 } = {}) { + if (!message) return '' + const id = `toast-${++toastSeq}` + toasts.value = [...toasts.value, { id, message, type }] + + if (duration > 0) { + window.setTimeout(() => dismissToast(id), duration) + } + + return id +} + +export function useToast() { + return { + toasts: readonly(toasts), + dismissToast, + showToast, + success: (message, options = {}) => showToast(message, { type: 'success', ...options }), + error: (message, options = {}) => showToast(message, { type: 'error', ...options }), + info: (message, options = {}) => showToast(message, { type: 'info', ...options }), + } +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 3f35157..52d5609 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -58,6 +58,8 @@ export const api = { request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), listMyTierLists: () => request('/api/tierlists/me'), getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`), + favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }), + unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }), deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }), saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }), uploadTierListThumbnail: async (file) => { diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 61b736e..0e98e28 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,13 +1,15 @@ @@ -160,14 +184,10 @@ function openTierList(id) { gap: 14px; } .row { - text-align: left; - padding: 0; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.92); - cursor: pointer; - width: 100%; display: grid; gap: 10px; align-content: start; @@ -177,6 +197,17 @@ function openTierList(id) { .row:hover { background: rgba(255, 255, 255, 0.05); } +.row__body { + text-align: left; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + width: 100%; + display: grid; + gap: 10px; +} .row__thumbWrap { width: 100%; aspect-ratio: 16 / 9; @@ -229,11 +260,29 @@ function openTierList(id) { font-weight: 900; } .row__meta { - padding: 0 14px 14px; opacity: 0.78; font-size: 13px; +} +.row__foot { + padding: 0 14px 14px; + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; margin-top: auto; } +.favoriteBtn { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.92); + border-radius: 999px; + padding: 7px 10px; + cursor: pointer; + font-weight: 800; +} +.favoriteBtn:hover { + background: rgba(255, 255, 255, 0.09); +} @media (max-width: 1100px) { .list { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 492bca5..89e07dd 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,12 +1,14 @@