diff --git a/docs/history.md b/docs/history.md index 9fd32e7..9c64999 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,8 @@ # 의사결정 이력 +## 2026-04-02 v1.4.9 +- 경로 전환은 화면마다 문자열을 직접 고치는 방식보다, 공용 경로 헬퍼를 먼저 세워 주제·에디터·로그인 리다이렉트 흐름을 한 기준으로 묶는 편이 이후 리네이밍 비용을 훨씬 줄인다고 판단했다. + ## 2026-04-02 v1.4.8 - 주제 상세 화면 제목은 내부 ID를 잠깐 보여주는 것보다, 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌는 편이 사용자 체감상 더 안정적이라고 판단했다. - 주요 목록 화면은 `pageHead` 문법을 계속 통일해 두는 편이, 이후 검색/필터 툴바를 더 붙이더라도 구조를 예측하기 쉽다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 517e89b..f853e28 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,8 @@ # 할 일 및 이슈 ## 단기 확인 +- 경로 헬퍼를 사용자 주요 화면에 연결했으므로, 로그인 리다이렉트·공유 프리뷰·복사본 이동·주제 복귀 흐름이 실제 브라우저에서 모두 같은 주소 체계로 자연스럽게 이어지는지 한 번 더 QA한다. +- 다음 단계에서는 `router/index.js`의 `gameHub`, `route.params.gameId`, `api.getGame`처럼 남아 있는 프런트 내부 이름도 어디까지 `topic/template` 의미로 감쌀지 범위를 더 좁힌다. - 주제 상세 화면 제목은 ID fallback 대신 로딩 문구를 쓰도록 바꿨으므로, 직접 진입·뒤로가기·다른 주제로 연속 이동할 때 헤더가 깜빡이거나 이전 제목을 잠깐 유지하지 않는지 한 번 더 QA한다. - 검색 결과 화면도 `pageHead` 구조로 맞췄으므로, 주요 목록 화면들 간 상단 여백과 타이포 리듬이 자연스러운지 한 번 더 비교 QA한다. - 주제 상세 컬렉션 화면은 `pageHead` 공통 레이아웃과 `/topics` 기본 경로로 옮겼으므로, 직접 진입·뒤로가기·검색 후 재진입 시 주소와 헤더 흐름이 자연스러운지 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index 07195e5..3f92402 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-04-02 v1.4.9 +- `frontend/src/lib/paths.js`를 추가해 주제 진입, 에디터 이동, 로그인 리다이렉트, 공유 프리뷰 주소 같은 사용자 표면 경로를 공용 함수로 모았다. +- 홈, 주제 상세, 나의 티어표, 즐겨찾기, 검색 결과, 로그인, 설정, 관리자 미리보기, 티어표 편집기까지 이 경로 헬퍼를 쓰도록 바꿔 이후 `topics` 전환을 더 안전하게 이어갈 수 있는 기반을 만들었다. + ## 2026-04-02 v1.4.8 - 주제 상세 컬렉션 화면은 제목을 `topicId` fallback으로 먼저 노출하지 않도록 바꾸고, 주제 전환 시에는 로딩 문구를 거쳐 실제 이름으로 자연스럽게 바뀌게 정리했다. - 검색 결과 화면도 공통 `pageHead` 문법으로 맞춰 주요 목록 화면들의 상단 리듬을 한 번 더 통일했다. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 07c8b78..a2887fa 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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' @@ -139,11 +140,11 @@ const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'gr 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 } + const target = editorNewPath(route.params.gameId) + return { label: '새 티어표 만들기', to: auth.user ? target : loginPath(target), iconSrc: iconAddNotes } } return null }) @@ -157,7 +158,7 @@ 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()) }, } } @@ -169,8 +170,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(route.params.gameId) + router.push(auth.user ? target : loginPath(target)) }, } } @@ -181,7 +182,7 @@ const routeMeta = computed(() => { contextTitle: '편집 패널', contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.', actionLabel: '주제 목록으로', - action: () => router.push('/'), + action: () => router.push(homePath()), } } if (isAdminRoute.value) { @@ -191,7 +192,7 @@ const routeMeta = computed(() => { contextTitle: '운영 노트', contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.', actionLabel: '주제 목록으로', - action: () => router.push('/'), + action: () => router.push(homePath()), } } if (route.name === 'me') { @@ -201,7 +202,7 @@ const routeMeta = computed(() => { contextTitle: '작성 이력', contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.', actionLabel: '즐겨찾기 보기', - action: () => router.push('/favorites'), + action: () => router.push(favoritesPath()), } } if (route.name === 'favorites') { @@ -211,7 +212,7 @@ const routeMeta = computed(() => { contextTitle: '정리 도구', contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.', actionLabel: '나의 티어표 보기', - action: () => router.push('/me'), + action: () => router.push(mePath()), } } if (route.name === 'profile') { @@ -221,7 +222,7 @@ const routeMeta = computed(() => { contextTitle: '계정 관리', contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.', actionLabel: '나의 티어표 보기', - action: () => router.push('/me'), + action: () => router.push(mePath()), } } if (route.name === 'search') { @@ -231,7 +232,7 @@ const routeMeta = computed(() => { contextTitle: '검색', contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.', actionLabel: '홈으로', - action: () => router.push('/'), + action: () => router.push(homePath()), } } return { @@ -240,7 +241,7 @@ const routeMeta = computed(() => { contextTitle: '작업 공간', contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.', actionLabel: '홈으로', - action: () => router.push('/'), + action: () => router.push(homePath()), } }) @@ -395,7 +396,7 @@ function handleLeftRailSearch() { function submitGlobalSearch() { const query = (searchQuery.value || '').trim() isCollapsedSearchOpen.value = false - router.push(query ? `/?q=${encodeURIComponent(query)}` : '/') + router.push(homePath(query)) } diff --git a/frontend/src/composables/useAdminTemplateRequests.js b/frontend/src/composables/useAdminTemplateRequests.js index 0d3c1f0..78e982a 100644 --- a/frontend/src/composables/useAdminTemplateRequests.js +++ b/frontend/src/composables/useAdminTemplateRequests.js @@ -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) { diff --git a/frontend/src/lib/paths.js b/frontend/src/lib/paths.js new file mode 100644 index 0000000..96fb741 --- /dev/null +++ b/frontend/src/lib/paths.js @@ -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' +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 47772da..c157daf 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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' @@ -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) { diff --git a/frontend/src/views/FavoriteTierListsView.vue b/frontend/src/views/FavoriteTierListsView.vue index b92cdfb..327efb9 100644 --- a/frontend/src/views/FavoriteTierListsView.vue +++ b/frontend/src/views/FavoriteTierListsView.vue @@ -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) diff --git a/frontend/src/views/GameHubView.vue b/frontend/src/views/GameHubView.vue index 988dea0..4793e74 100644 --- a/frontend/src/views/GameHubView.vue +++ b/frontend/src/views/GameHubView.vue @@ -3,6 +3,7 @@ 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() @@ -67,15 +68,16 @@ async function loadTierLists() { } 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() { diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6f446e9..d555a3d 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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() @@ -46,13 +47,13 @@ onMounted(loadTemplates) watch(() => auth.user?.id, loadTemplates) function openTopic(templateId) { - router.push(`/topics/${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 diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 6daed7a..d84edde 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -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() {
첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.
- +
diff --git a/frontend/src/views/MyTierListsView.vue b/frontend/src/views/MyTierListsView.vue index 8d1a95b..2aef889 100644 --- a/frontend/src/views/MyTierListsView.vue +++ b/frontend/src/views/MyTierListsView.vue @@ -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)) } diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 67b5eba..5a6d315 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -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()) } diff --git a/frontend/src/views/SearchResultsView.vue b/frontend/src/views/SearchResultsView.vue index 651eafe..6da65f9 100644 --- a/frontend/src/views/SearchResultsView.vue +++ b/frontend/src/views/SearchResultsView.vue @@ -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() { diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 5de6e99..d79b0e8 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -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' @@ -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' : `/topics/${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,7 +893,7 @@ 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 } @@ -1131,7 +1132,7 @@ onUnmounted(() => {
복사본 - +