Compare commits

...

5 Commits

20 changed files with 245 additions and 191 deletions

View File

@@ -1,5 +1,22 @@
# 의사결정 이력
## 2026-04-02 v1.4.11
- 백엔드 `/api/games` 경로를 바로 바꾸기보다, 프런트 API 객체에서 먼저 `topic/template` 의미 이름을 제공하고 호출부를 옮기는 편이 위험이 훨씬 낮다고 판단했다.
## 2026-04-02 v1.4.10
- 사용자 주소는 이미 `/topics`로 옮기기 시작했으므로, 라우트 이름과 기본 파라미터도 `topicHub / topicId` 기준으로 맞추고 기존 `gameId`는 호환 fallback으로만 남기는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.4.9
- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다.
## 2026-04-02 v1.4.8
- 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다.
- 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다.
## 2026-04-02 v1.4.7
- 주제 상세 컬렉션 화면도 즐겨찾기·나의 티어표와 같은 `pageHead` 문법으로 맞춰야, 네비게이션으로 이동하는 주요 화면들의 리듬이 더 자연스럽다고 판단했다.
- 라우트 전환은 한 번에 `/games`를 없애기보다, 먼저 `/topics`를 기본 진입 경로로 세우고 기존 `/games`는 alias로 유지하는 점진 전환이 더 안전하다고 정리했다.
## 2026-04-02 v1.4.6
- 내부 리네이밍 2단계는 관리자 화면처럼 상태와 액션이 많은 영역부터 정리해 두는 편이, 이후 `/games` 라우트와 API 계층을 손볼 때 위험을 줄이는 데 더 유리하다고 판단했다.

View File

@@ -1,6 +1,16 @@
# 할 일 및 이슈
## 단기 확인
- 프런트 API 호출부는 `topic/template` 의미 이름으로 옮겼으므로, 다음 단계에서는 `api.js` 안의 레거시 alias를 얼마나 더 유지할지와 백엔드 API 경로를 실제로 바꿀지 범위를 정한다.
- 관리자 템플릿/주제 화면과 홈·에디터·즐겨찾기에서 새 API 이름층으로 바뀐 뒤에도 저장, 즐겨찾기, 템플릿 생성, 아이템 정렬 흐름이 자연스러운지 한 번 더 QA한다.
- `topicHub / topicId`를 기본 라우트 기준으로 세웠으므로, 기존 `/games/...` 북마크와 새 `/topics/...` 주소 양쪽에서 주제 상세와 에디터 진입이 모두 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `api.getGame`, `listGames`, `favoriteGame`처럼 남아 있는 프런트 API 이름을 어느 수준까지 `topic/template` 의미로 감쌀지 정리한다.
- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다.
- 다음 단계에서는 `router/index.js``gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다.
- 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다.
- 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다.
- 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다.
- `/topics/:gameId`를 기본 경로로 세우고 `/games/:gameId`는 alias로 남겼으므로, 다음 단계에서는 에디터/검색/공유 흐름에서 어떤 링크를 새 경로로 더 전환할지 범위를 정한다.
- 내부 리네이밍 2단계로 관리자 `selectedTemplate / templates / loadTemplate / refreshTemplates` 묶음까지 정리했으므로, 다음 단계에서는 `/games/:gameId` 라우트와 프런트 API 호출부를 어디까지 `topic/template` 의미로 감쌀지 범위를 먼저 정리한다.
- 화면/문서 층의 용어 정리는 거의 마무리됐으므로, 다음 단계에서는 내부 `gameId / games` 모델을 실제로 옮길지, 아니면 라우트 alias/리다이렉트부터 둘지 점진 전환 순서를 정한다.
- 관리자 화면까지 포함한 사용자 노출 `게임` 문구를 3차까지 정리했으므로, 실제 운영 흐름에서 뜨는 확인창/토스트/선택 모달까지 표현이 자연스러운지 한 번 더 QA한다.

View File

@@ -1,5 +1,25 @@
# 업데이트 로그
## 2026-04-02 v1.4.11
- 프런트 API 이름층을 한 단계 더 정리해 `listTopics / getTopic / favoriteTopic`, `updateAdminTemplate*`, `searchPublicTierListsByTopic` 같은 의미 기반 이름을 추가하고 실제 호출부도 이 기준으로 옮겼다.
- 백엔드 경로와 응답 구조는 그대로 유지한 채 프런트에서 읽는 이름만 먼저 바꿔, 다음 단계의 API/모델 리네이밍 부담을 더 줄였다.
## 2026-04-02 v1.4.10
- 주제 상세 라우트 이름을 `topicHub`로, 기본 경로 파라미터를 `topicId`로 바꾸고 기존 `gameId` 주소는 alias로 유지했다.
- 앱 셸, 주제 상세, 티어표 편집기는 이제 내부에서 `topicId`를 우선 읽고, 레거시 주소로 들어온 경우에만 `gameId` fallback을 쓰도록 정리했다.
## 2026-04-02 v1.4.9
- `frontend/src/lib/paths.js`를 추가해 주제 진입, 에디터 이동, 로그인 리다이렉트, 공유 프리뷰 주소 같은 사용자 표면 경로를 공용 함수로 모았다.
- 홈, 주제 상세, 나의 티어표, 즐겨찾기, 검색 결과, 로그인, 설정, 관리자 미리보기, 티어표 편집기까지 이 경로 헬퍼를 쓰도록 바꿔 이후 `topics` 전환을 더 안전하게 이어갈 수 있는 기반을 만들었다.
## 2026-04-02 v1.4.8
- 주제 상세 컬렉션 화면은 제목을 `topicId` fallback으로 먼저 노출하지 않도록 바꾸고, 주제 전환 시에는 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌게 정리했다.
- 검색 결과 화면도 공통 `pageHead` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다.
## 2026-04-02 v1.4.7
- 주제 선택 뒤에 들어가는 `Collection` 화면을 공통 `pageHead` 레이아웃으로 다시 맞추고, 검색 입력을 즐겨찾기 화면처럼 상단 우측 툴바로 정리했다.
- `공개 티어표` 보조 설명 줄은 제거해 헤더 밀도를 줄였고, 사용자 진입 경로는 `/topics/:gameId`를 기본으로 전환하면서 기존 `/games/:gameId`는 alias로 유지했다.
## 2026-04-02 v1.4.6
- 관리자 내부 리네이밍 2단계로 `AdminView`와 관련 composable/component의 핵심 상태명을 `selectedTemplate / templates / loadTemplate / refreshTemplates / createTemplate` 기준으로 정리했다.
- 요청 검토, 템플릿 생성 모달, 아이템 추가/정렬, 템플릿 선택 모달 흐름도 같은 기준으로 맞춰, 관리자 화면을 읽을 때 내부 이름과 사용자 노출 용어가 덜 어긋나게 정리했다.

View File

@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { editorNewPath, favoritesPath, homePath, loginPath, mePath } from './lib/paths'
import { toApiUrl } from './lib/runtime'
import { useToast } from './composables/useToast'
import iconDockToLeft from './assets/icons/dock_to_left.svg'
@@ -22,6 +23,7 @@ const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const currentTopicId = computed(() => route.params.topicId || route.params.gameId || '')
const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true)
@@ -134,16 +136,16 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
const isLightTheme = computed(() => themeMode.value === 'light')
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
const showTopicViewToggle = computed(() => route.name === 'gameHub')
const showTopicViewToggle = computed(() => route.name === 'topicHub')
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new', iconSrc: iconDashboardCustomize }
return { label: '커스텀 티어표 만들기', to: editorNewPath('freeform'), iconSrc: iconDashboardCustomize }
}
if (route.name === 'gameHub') {
const target = `/editor/${route.params.gameId}/new`
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
if (route.name === 'topicHub') {
const target = editorNewPath(currentTopicId.value)
return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes }
}
return null
})
@@ -157,11 +159,11 @@ const routeMeta = computed(() => {
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 주제를 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
action: () => {
router.push(auth.user ? '/editor/freeform/new' : '/login')
router.push(auth.user ? editorNewPath('freeform') : loginPath())
},
}
}
if (route.name === 'gameHub') {
if (route.name === 'topicHub') {
return {
title: '주제 티어표',
subtitle: '주제별 공개 티어표 탐색',
@@ -169,8 +171,8 @@ const routeMeta = computed(() => {
contextText: auth.user ? '이 주제의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
action: () => {
const target = `/editor/${route.params.gameId}/new`
router.push(auth.user ? target : `/login?redirect=${target}`)
const target = editorNewPath(currentTopicId.value)
router.push(auth.user ? target : loginPath(target))
},
}
}
@@ -181,7 +183,7 @@ const routeMeta = computed(() => {
contextTitle: '편집 패널',
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
actionLabel: '주제 목록으로',
action: () => router.push('/'),
action: () => router.push(homePath()),
}
}
if (isAdminRoute.value) {
@@ -191,7 +193,7 @@ const routeMeta = computed(() => {
contextTitle: '운영 노트',
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
actionLabel: '주제 목록으로',
action: () => router.push('/'),
action: () => router.push(homePath()),
}
}
if (route.name === 'me') {
@@ -201,7 +203,7 @@ const routeMeta = computed(() => {
contextTitle: '작성 이력',
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
actionLabel: '즐겨찾기 보기',
action: () => router.push('/favorites'),
action: () => router.push(favoritesPath()),
}
}
if (route.name === 'favorites') {
@@ -211,7 +213,7 @@ const routeMeta = computed(() => {
contextTitle: '정리 도구',
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
actionLabel: '나의 티어표 보기',
action: () => router.push('/me'),
action: () => router.push(mePath()),
}
}
if (route.name === 'profile') {
@@ -221,7 +223,7 @@ const routeMeta = computed(() => {
contextTitle: '계정 관리',
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
actionLabel: '나의 티어표 보기',
action: () => router.push('/me'),
action: () => router.push(mePath()),
}
}
if (route.name === 'search') {
@@ -231,7 +233,7 @@ const routeMeta = computed(() => {
contextTitle: '검색',
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
actionLabel: '홈으로',
action: () => router.push('/'),
action: () => router.push(homePath()),
}
}
return {
@@ -240,7 +242,7 @@ const routeMeta = computed(() => {
contextTitle: '작업 공간',
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
actionLabel: '홈으로',
action: () => router.push('/'),
action: () => router.push(homePath()),
}
})
@@ -345,7 +347,7 @@ function toggleRightRail() {
}
function setTopicViewMode(mode) {
if (route.name !== 'gameHub') return
if (route.name !== 'topicHub') return
const nextQuery = { ...route.query }
if (mode === 'list') nextQuery.view = 'list'
else delete nextQuery.view
@@ -395,7 +397,7 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
router.push(homePath(query))
}

View File

@@ -167,7 +167,7 @@ export function useAdminCustomItems({
try {
item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetTemplateId.value })
await api.promoteAdminTemplateItem(item.id, { gameId: customItemModalTargetTemplateId.value })
const targetTemplateName =
templates.value.find((template) => template.id === customItemModalTargetTemplateId.value)?.name || customItemModalTargetTemplateId.value
if (selectedTemplateId.value === customItemModalTargetTemplateId.value) await loadTemplate()

View File

@@ -70,7 +70,7 @@ export function useAdminFeaturedGames({
async function saveFeaturedOrder() {
resetMessages()
try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredTemplateIds.value })
const data = await api.updateAdminTemplateDisplayOrder({ gameIds: featuredTemplateIds.value })
templates.value = data.games || []
featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null)

View File

@@ -131,7 +131,7 @@ export function useAdminGameManager({
try {
isGameLoading.value = true
const data = await api.getGame(selectedTemplateId.value)
const data = await api.getTopic(selectedTemplateId.value)
selectedTemplate.value = {
...data,
items: (data.items || []).map((item) => ({
@@ -336,7 +336,7 @@ export function useAdminGameManager({
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) return
try {
const data = await api.updateAdminGameItemDisplayOrder(selectedTemplateId.value, {
const data = await api.updateAdminTemplateItemDisplayOrder(selectedTemplateId.value, {
itemIds: selectedTemplate.value.items.map((item) => item.id),
})
selectedTemplate.value = {

View File

@@ -1,3 +1,5 @@
import { editorPath } from '../lib/paths'
export function useAdminTemplateRequests({
api,
activeTemplateRequest,
@@ -37,7 +39,7 @@ export function useAdminTemplateRequests({
function templateRequestSourceUrl(request) {
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
return editorPath(request.sourceGameId, request.sourceTierListId, { preview: true })
}
function templateRequestReviewHint(request) {

View File

@@ -30,17 +30,17 @@ export const api = {
login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }),
logout: () => request('/api/auth/logout', { method: 'POST' }),
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 }),
updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGame: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listTopics: () => request('/api/games'),
getTopic: (topicId) => request(`/api/games/${encodeURIComponent(topicId)}`),
favoriteTopic: (topicId) => request(`/api/games/${encodeURIComponent(topicId)}/favorite`, { method: 'POST' }),
unfavoriteTopic: (topicId) => request(`/api/games/${encodeURIComponent(topicId)}/favorite`, { method: 'DELETE' }),
updateAdminTemplateDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminTemplateItemDisplayOrder: (templateId, payload) =>
request(`/api/admin/games/${encodeURIComponent(templateId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminTemplate: (templateId, payload) =>
request(`/api/admin/games/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
updateAdminTemplateItem: (templateId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
@@ -66,13 +66,13 @@ export const api = {
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
promoteAdminCustomItem: (itemId, payload) =>
promoteAdminTemplateItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
updateAdminCustomItemLabel: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
promoteAdminTierListItems: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
createAdminTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
@@ -111,10 +111,10 @@ export const api = {
},
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
listPublicTierListsByTopic: (topicId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(topicId || '')}`),
searchPublicTierListsByTopic: (topicId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(topicId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
@@ -146,4 +146,24 @@ export const api = {
deleteAdminCustomItem: (itemId) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}`, { method: 'DELETE' }),
deleteAdminUnusedCustomItems: ({ q = '' } = {}) =>
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}`, { method: 'DELETE' }),
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 }),
updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { method: 'PATCH', body: payload }),
updateAdminGame: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
updateAdminGameItem: (gameId, itemId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
promoteAdminCustomItem: (itemId, payload) =>
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
listPublicTierLists: (gameId) =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
}

38
frontend/src/lib/paths.js Normal file
View File

@@ -0,0 +1,38 @@
function encodeSegment(value) {
return encodeURIComponent(String(value || '').trim())
}
export function homePath(query = '') {
const normalized = String(query || '').trim()
return normalized ? `/?q=${encodeURIComponent(normalized)}` : '/'
}
export function loginPath(redirect = '') {
const normalized = String(redirect || '').trim()
return normalized ? `/login?redirect=${encodeURIComponent(normalized)}` : '/login'
}
export function topicPath(topicId) {
return `/topics/${encodeSegment(topicId)}`
}
export function editorNewPath(topicId) {
return `/editor/${encodeSegment(topicId)}/new`
}
export function editorPath(topicId, tierListId, { preview = false } = {}) {
const base = `/editor/${encodeSegment(topicId)}/${encodeSegment(tierListId)}`
return preview ? `${base}?preview=1` : base
}
export function mePath() {
return '/me'
}
export function favoritesPath() {
return '/favorites'
}
export function profilePath() {
return '/profile'
}

View File

@@ -16,9 +16,9 @@ export function createRouter() {
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/games/:gameId', name: 'gameHub', component: GameHubView },
{ path: '/editor/:gameId/new', name: 'newEditor', component: TierEditorView },
{ path: '/editor/:gameId/:tierListId', name: 'editEditor', component: TierEditorView },
{ path: '/topics/:topicId', alias: ['/games/:gameId'], name: 'topicHub', component: GameHubView },
{ path: '/editor/:topicId/new', alias: ['/editor/:gameId/new'], name: 'newEditor', component: TierEditorView },
{ path: '/editor/:topicId/:tierListId', alias: ['/editor/:gameId/:tierListId'], name: 'editEditor', component: TierEditorView },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },

View File

@@ -2,6 +2,7 @@
import { Teleport, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
@@ -786,7 +787,7 @@ async function selectAdminTemplate(templateId) {
async function refreshTemplates() {
try {
const data = await api.listGames()
const data = await api.listTopics()
templates.value = data.games || []
featuredTemplateIds.value = templates.value
.filter((template) => template.displayRank != null)
@@ -1169,7 +1170,7 @@ async function saveTemplateVisibility() {
if (!selectedTemplate.value?.game?.id) return
try {
gameVisibilitySaving.value = true
const data = await api.updateAdminGame(selectedTemplate.value.game.id, {
const data = await api.updateAdminTemplate(selectedTemplate.value.game.id, {
isPublic: !!selectedTemplate.value.game.isPublic,
})
selectedTemplate.value = {
@@ -1243,7 +1244,7 @@ async function saveTemplateItemLabel(item) {
try {
item.isSavingLabel = true
const data = await api.updateAdminGameItem(selectedTemplateId.value, item.id, { label: nextLabel })
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, { label: nextLabel })
item.label = data.item.label
item.draftLabel = data.item.label
success.value = '기본 아이템 이름을 수정했어요.'
@@ -1530,7 +1531,7 @@ function closePreviewModal() {
function previewTierListUrl(tierList) {
if (!tierList?.gameId || !tierList?.id) return ''
return `/editor/${tierList.gameId}/${tierList.id}?preview=1`
return editorPath(tierList.gameId, tierList.id, { preview: true })
}
function openTierListImportModal(tierList, items) {
@@ -1588,7 +1589,7 @@ async function confirmTierListImport() {
return
}
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
const data = await api.createAdminTemplateFromTierList(tierList.id, {
gameId: nextGameId,
name: nextGameName,
itemIds,

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths'
const router = useRouter()
const toast = useToast()
@@ -42,12 +43,12 @@ async function loadFavorites() {
favorites.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
router.push('/login?redirect=/favorites')
router.push(loginPath('/favorites'))
}
}
function openTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
router.push(editorPath(tierList.gameId, tierList.id))
}
onMounted(loadFavorites)

View File

@@ -1,21 +1,24 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { editorNewPath, editorPath, loginPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const topicId = computed(() => route.params.gameId)
const topicId = computed(() => route.params.topicId || route.params.gameId)
const topicName = ref('')
const tierLists = ref([])
const error = ref('')
const query = ref('')
const brokenThumbnailIds = ref({})
const isTopicLoading = ref(false)
const isListView = computed(() => route.query.view === 'list')
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
@@ -47,62 +50,66 @@ function handleThumbnailError(tierListId) {
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => {
await loadTierLists()
})
async function loadTierLists() {
isTopicLoading.value = true
try {
const [gameRes, listRes] = await Promise.all([
api.getGame(topicId.value),
api.searchPublicTierLists(topicId.value, query.value),
api.getTopic(topicId.value),
api.searchPublicTierListsByTopic(topicId.value, query.value),
])
topicName.value = gameRes.game?.name || topicId.value
topicName.value = gameRes.game?.name || ''
brokenThumbnailIds.value = {}
tierLists.value = listRes.tierLists || []
} catch (e) {
error.value = '주제 정보를 불러오지 못했어요.'
} finally {
isTopicLoading.value = false
}
}
function createNew() {
const target = editorNewPath(topicId.value)
if (!auth.user) {
router.push(`/login?redirect=/editor/${topicId.value}/new`)
router.push(loginPath(target))
return
}
router.push(`/editor/${topicId.value}/new`)
router.push(target)
}
function openTierList(id) {
router.push(`/editor/${topicId.value}/${id}`)
router.push(editorPath(topicId.value, id))
}
function submitSearch() {
loadTierLists()
}
watch(
topicId,
() => {
topicName.value = ''
error.value = ''
loadTierLists()
},
{ immediate: true }
)
</script>
<template>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Collection</div>
<h2 class="dashboardHero__title">{{ topicName || topicId }}</h2>
<p class="dashboardHero__desc"> 주제의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Collection</div>
<h2 class="pageHead__title">{{ topicTitle }}</h2>
<div class="pageHead__desc"> 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 티어표를 만들 있어요.</div>
</div>
<div class="pageHead__aside toolbar">
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="btn" @click="submitSearch">검색</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div class="panel__head">
<div>
<div class="panel__title">공개 티어표</div>
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 있어요.</div>
</div>
<div class="searchBar">
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="searchBar__button" @click="submitSearch">검색</button>
</div>
</div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list" :class="{ 'list--table': isListView }">
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
@@ -134,72 +141,17 @@ function submitSearch() {
</template>
<style scoped>
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 18px;
}
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: var(--theme-text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: var(--theme-text-strong);
}
.dashboardHero__desc {
margin: 0;
color: var(--theme-text-muted);
max-width: 720px;
}
.panel {
/* border: 1px solid var(--theme-border); */
background: transparent;
border-radius: 0;
padding: 0;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.panel__title {
font-weight: 800;
font-size: 18px;
}
.panel__sub {
margin-top: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.panel__head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
}
.searchBar {
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.searchBar__input {
.input {
min-width: 240px;
padding: 11px 13px;
border-radius: 14px;
@@ -207,8 +159,8 @@ function submitSearch() {
background: var(--theme-surface-soft);
color: var(--theme-text);
}
.searchBar__button {
padding: 11px 14px;
.btn {
padding: 11px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft-2);
@@ -216,6 +168,13 @@ function submitSearch() {
font-weight: 800;
cursor: pointer;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
}
.empty {
opacity: 0.75;
}
@@ -404,7 +363,11 @@ function submitSearch() {
grid-template-columns: 1fr;
}
.searchBar__input {
.toolbar {
width: 100%;
}
.input {
min-width: 0;
width: 100%;
}

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/api'
import SvgIcon from '../components/SvgIcon.vue'
import kidStarIcon from '../assets/icons/kid_star.svg'
import { toApiUrl } from '../lib/runtime'
import { loginPath, topicPath } from '../lib/paths'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
@@ -35,7 +36,7 @@ const templates = computed(() => {
async function loadTemplates() {
try {
const data = await api.listGames()
const data = await api.listTopics()
templateRecords.value = data.games || []
} catch (e) {
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
@@ -46,20 +47,20 @@ onMounted(loadTemplates)
watch(() => auth.user?.id, loadTemplates)
function openTopic(templateId) {
router.push(`/games/${templateId}`)
router.push(topicPath(templateId))
}
async function toggleFavorite(template, event) {
event?.stopPropagation()
if (!auth.user) {
router.push(`/login?redirect=${encodeURIComponent(route.fullPath || '/')}`)
router.push(loginPath(route.fullPath || '/'))
return
}
if (!template?.id || loadingFavoriteId.value === template.id) return
try {
loadingFavoriteId.value = template.id
const res = template.isFavorited ? await api.unfavoriteGame(template.id) : await api.favoriteGame(template.id)
const res = template.isFavorited ? await api.unfavoriteTopic(template.id) : await api.favoriteTopic(template.id)
templateRecords.value = templateRecords.value.map((entry) => (entry.id === template.id ? { ...entry, ...res.game } : entry))
} catch (e) {
error.value = '즐겨찾기 변경에 실패했어요.'

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths'
import { useToast } from '../composables/useToast'
const router = useRouter()
@@ -36,7 +37,7 @@ const checkingSession = computed(() => !authReady.value || auth.status === 'load
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
return
}
try {
@@ -51,7 +52,7 @@ watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
},
{ immediate: true }
)
@@ -65,7 +66,7 @@ async function submit() {
try {
if (mode.value === 'signup') await auth.signup(email.value, password.value)
else await auth.login(email.value, password.value)
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
} catch (e) {
error.value = '로그인/회원가입에 실패했어요.'
}
@@ -133,7 +134,7 @@ async function submit() {
<div v-if="!hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
</div>
</form>

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
import { editorPath, loginPath } from '../lib/paths'
const router = useRouter()
const toast = useToast()
@@ -54,14 +55,12 @@ onMounted(async () => {
myLists.value = data.tierLists || []
} catch (e) {
toast.error('로그인이 필요해요.')
router.push('/login?redirect=/me')
router.push(loginPath('/me'))
}
})
function openList(t) {
router.push(
"/editor/" + t.gameId + "/" + t.id,
)
router.push(editorPath(t.gameId, t.id))
}
</script>

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { homePath, loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
@@ -40,7 +41,7 @@ const displayInitial = computed(() => {
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
router.replace('/login')
router.replace(loginPath())
return
}
nickname.value = auth.user?.nickname || ''
@@ -112,7 +113,7 @@ async function saveProfile() {
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
router.push('/')
router.push(homePath())
}
</script>

View File

@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { editorPath } from '../lib/paths'
const route = useRoute()
const router = useRouter()
@@ -37,7 +38,7 @@ function tierListThumbnailUrl(tierList) {
}
function openTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
router.push(editorPath(tierList.gameId, tierList.id))
}
async function loadResults() {
@@ -65,13 +66,13 @@ watch(
<template>
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">검색</div>
<h2 class="title">전체 티어표 검색</h2>
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 있어요.</div>
<section class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Search</div>
<h2 class="pageHead__title">전체 티어표 검색</h2>
<div class="pageHead__desc">공개된 티어표를 제목과 작성자 기준으로 다시 찾아볼 있어요.</div>
</div>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="loading" class="empty">검색 중이에요.</div>
@@ -110,30 +111,6 @@ watch(
display: grid;
gap: 18px;
}
.head {
display: flex;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: var(--theme-text-strong);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: var(--theme-text-muted);
}
.error {
margin: 0 0 8px;
padding: 10px 12px;

View File

@@ -9,6 +9,7 @@ import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import shareIcon from '../assets/icons/share.svg'
import { api } from '../lib/api'
import { editorNewPath, editorPath, loginPath, mePath, topicPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
@@ -19,7 +20,7 @@ const auth = useAuthStore()
const toast = useToast()
const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const templateId = computed(() => route.params.gameId)
const templateId = computed(() => route.params.topicId || route.params.gameId)
const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const templateName = ref('')
@@ -134,8 +135,8 @@ const templateRequestTargetLabel = computed(() => (templateId.value === 'freefor
const shareTierListUrl = computed(() => {
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!savedTierListId) return ''
if (typeof window === 'undefined') return `/editor/${templateId.value}/${savedTierListId}?preview=1`
return new URL(`/editor/${templateId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
if (typeof window === 'undefined') return editorPath(templateId.value, savedTierListId, { preview: true })
return new URL(editorPath(templateId.value, savedTierListId, { preview: true }), window.location.origin).toString()
})
watch(error, (message) => {
@@ -697,7 +698,7 @@ async function persistTierList({ showModal = false } = {}) {
persistedTierListId.value = savedTierListId || ''
title.value = res.tierList?.title || payload.title
if (tierListId.value === 'new' && res.tierList?.id) {
await router.replace(`/editor/${templateId.value}/${res.tierList.id}`)
await router.replace(editorPath(templateId.value, res.tierList.id))
}
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
@@ -793,7 +794,7 @@ async function confirmDeleteTierList() {
await api.deleteTierList(currentTierListId)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(templateId.value === 'freeform' ? '/me' : `/games/${templateId.value}`)
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
} finally {
@@ -808,7 +809,7 @@ async function duplicateCurrentTierList() {
const duplicatedId = data.tierList?.id
if (!duplicatedId) throw new Error('duplicate_failed')
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
router.push(`/editor/${templateId.value}/${duplicatedId}`)
router.push(editorPath(templateId.value, duplicatedId))
} catch (e) {
error.value = '티어표 복사에 실패했어요.'
}
@@ -892,12 +893,12 @@ onMounted(() => {
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
if (isNewTierList.value && !auth.user) {
router.replace(`/login?redirect=/editor/${templateId.value}/new`)
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
try {
const gameRes = await api.getGame(templateId.value)
const gameRes = await api.getTopic(templateId.value)
templateName.value = gameRes.game?.name || templateId.value
const base = (gameRes.items || []).map((img) => ({
id: img.id,
@@ -1131,7 +1132,7 @@ onUnmounted(() => {
</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>복사본</span>
<button class="editorMain__sourceLink" type="button" @click="router.push(`/editor/${templateId}/${sourceTierListId}`)">{{ copiedFromLabel }}</button>
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
</div>
</div>
</section>