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() {