diff --git a/docs/history.md b/docs/history.md index 2bf8ea5..9ff955a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-03-27 v0.1.45 +- 카드형 목록의 별표는 개수 표시로 읽히기 쉬우므로, 목록에서는 상태/개수만 보여주고 실제 즐겨찾기 토글은 상세 화면에서 처리하는 편이 오해가 적다고 정리했다. +- 토스트는 같은 메시지가 짧게 반복될 때 누적 표시가 더 낫고, 종료도 급격히 끊기지 않도록 페이드아웃을 넣는 편이 사용자 경험상 자연스럽다고 판단했다. + ## 2026-03-27 v0.1.44 - 전역 토스트는 공통 composable을 템플릿에 넘길 때 top-level ref로 풀어줘야 하므로, 렌더링 구조를 단순하게 유지하는 편이 안전하다고 정리했다. - 공개 티어표가 많아질수록 게임별 목록 안에서 바로 제목/작성자 검색이 가능해야 하므로, 검색은 별도 페이지보다 각 게임 허브 안에서 먼저 제공하기로 결정했다. diff --git a/docs/spec.md b/docs/spec.md index 348d647..1fbb59d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -131,6 +131,7 @@ - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다. - 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다. +- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다. - 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다. - `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. @@ -146,6 +147,7 @@ - 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다. - 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `960px` 보드 폭과 `pixelRatio 1.5`, 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다. - 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다. +- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다. - 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다. - `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다. diff --git a/docs/todo.md b/docs/todo.md index 3e8e1e0..b648eac 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,6 +12,7 @@ - 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리 기능을 추가 검토한다. - 전역 토스트는 기본 시간 기반 자동 종료만 지원하므로, 필요하면 중복 합치기나 액션 링크 포함 형태로 확장할 수 있다. - 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다. +- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index df11722..c960baa 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-03-27 v0.1.45 +- **즐겨찾기 카드 액션 보정**: 카드형 목록에서는 별표를 클릭 액션이 아닌 상태/개수 표시로만 보여주고, 실제 즐겨찾기 토글은 상세 화면에서 처리하도록 조정 +- **토스트 중복/페이드아웃 개선**: 같은 메시지 토스트는 하나로 합치고 카운트를 올리도록 변경했으며, 사라질 때는 짧은 페이드아웃 애니메이션을 적용 + ## 2026-03-27 v0.1.44 - **토스트 렌더링 버그 수정**: 전역 토스트가 빈 카드 여러 개로 보이던 ref 참조 문제를 수정해 실제 메시지만 표시되도록 정리 - **공개 티어표 검색 추가**: 게임별 공개 티어표 목록에서 제목/작성자 기준 검색이 가능하도록 검색창과 API 쿼리 지원 추가 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 582055f..883232b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -85,8 +85,11 @@ async function logout() {
-
-
{{ item.message }}
+
+
+
{{ item.message }}
+
x{{ item.count }}
+
@@ -227,6 +230,13 @@ async function logout() { background: rgba(11, 18, 32, 0.94); backdrop-filter: blur(12px); box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28); + opacity: 1; + transform: translateY(0); + transition: opacity 220ms ease, transform 220ms ease; +} +.toast--closing { + opacity: 0; + transform: translateY(-6px); } .toast--success { border-color: rgba(52, 211, 153, 0.38); @@ -241,6 +251,19 @@ async function logout() { line-height: 1.5; font-size: 14px; } +.toast__body { + min-width: 0; +} +.toast__count { + margin-top: 6px; + width: fit-content; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + font-size: 12px; + font-weight: 800; + opacity: 0.84; +} .toast__close { flex: 0 0 auto; border: 0; diff --git a/frontend/src/composables/useToast.js b/frontend/src/composables/useToast.js index ab4fef7..268f81c 100644 --- a/frontend/src/composables/useToast.js +++ b/frontend/src/composables/useToast.js @@ -2,18 +2,48 @@ import { readonly, ref } from 'vue' const toasts = ref([]) let toastSeq = 0 +const TOAST_EXIT_MS = 220 + +function clearToastTimer(toast) { + if (toast?.timerId) { + window.clearTimeout(toast.timerId) + toast.timerId = 0 + } +} + +function removeToast(id) { + toasts.value = toasts.value.filter((toast) => toast.id !== id) +} function dismissToast(id) { - toasts.value = toasts.value.filter((toast) => toast.id !== id) + const target = toasts.value.find((toast) => toast.id === id) + if (!target || target.isClosing) return + + clearToastTimer(target) + target.isClosing = true + target.timerId = window.setTimeout(() => removeToast(id), TOAST_EXIT_MS) } function showToast(message, { type = 'info', duration = 2600 } = {}) { if (!message) return '' + const duplicated = toasts.value.find((toast) => toast.message === message && toast.type === type && !toast.isClosing) + + if (duplicated) { + duplicated.count = (duplicated.count || 1) + 1 + clearToastTimer(duplicated) + if (duration > 0) { + duplicated.timerId = window.setTimeout(() => dismissToast(duplicated.id), duration) + } + toasts.value = [...toasts.value] + return duplicated.id + } + const id = `toast-${++toastSeq}` - toasts.value = [...toasts.value, { id, message, type }] + const nextToast = { id, message, type, count: 1, isClosing: false, timerId: 0 } + toasts.value = [...toasts.value, nextToast] if (duration > 0) { - window.setTimeout(() => dismissToast(id), duration) + nextToast.timerId = window.setTimeout(() => dismissToast(id), duration) } return id diff --git a/frontend/src/views/FavoriteTierListsView.vue b/frontend/src/views/FavoriteTierListsView.vue index 5691357..3744c58 100644 --- a/frontend/src/views/FavoriteTierListsView.vue +++ b/frontend/src/views/FavoriteTierListsView.vue @@ -1,5 +1,5 @@ @@ -103,9 +96,9 @@ onMounted(loadFavorites)
{{ tierList.gameName || tierList.gameId }}
-
{{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}
+
{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}
- +
★ {{ tierList.favoriteCount || 0 }}
@@ -239,13 +232,12 @@ onMounted(loadFavorites) opacity: 0.78; font-size: 13px; } -.favoriteBtn { +.favoriteStat { 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; } @media (max-width: 1100px) { diff --git a/frontend/src/views/GameHubView.vue b/frontend/src/views/GameHubView.vue index 6d16d82..2716f57 100644 --- a/frontend/src/views/GameHubView.vue +++ b/frontend/src/views/GameHubView.vue @@ -4,12 +4,10 @@ import { useRoute, useRouter } from 'vue-router' import { api } from '../lib/api' import { toApiUrl } from '../lib/runtime' import { useAuthStore } from '../stores/auth' -import { useToast } from '../composables/useToast' const route = useRoute() const router = useRouter() const auth = useAuthStore() -const toast = useToast() const gameId = computed(() => route.params.gameId) @@ -73,21 +71,6 @@ function openTierList(id) { router.push(`/editor/${gameId.value}/${id}`) } -async function toggleFavorite(tierList) { - if (!auth.user) { - router.push(`/login?redirect=/games/${gameId.value}`) - return - } - - try { - const data = tierList.isFavorited ? await api.unfavoriteTierList(tierList.id) : await api.favoriteTierList(tierList.id) - tierLists.value = tierLists.value.map((entry) => (entry.id === tierList.id ? { ...entry, ...data.tierList } : entry)) - toast.success(tierList.isFavorited ? '즐겨찾기를 해제했어요.' : '즐겨찾기에 추가했어요.') - } catch (e) { - toast.error('즐겨찾기 처리에 실패했어요.') - } -} - function submitSearch() { loadTierLists() } @@ -133,9 +116,9 @@ function submitSearch() {
{{ fmt(t.updatedAt) }}
- +
@@ -319,18 +302,14 @@ function submitSearch() { justify-content: space-between; margin-top: auto; } -.favoriteBtn { +.favoriteStat { 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));