릴리스: v0.1.45 토스트와 즐겨찾기 상호작용 보정
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-27 v0.1.45
|
||||
- 카드형 목록의 별표는 개수 표시로 읽히기 쉬우므로, 목록에서는 상태/개수만 보여주고 실제 즐겨찾기 토글은 상세 화면에서 처리하는 편이 오해가 적다고 정리했다.
|
||||
- 토스트는 같은 메시지가 짧게 반복될 때 누적 표시가 더 낫고, 종료도 급격히 끊기지 않도록 페이드아웃을 넣는 편이 사용자 경험상 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-03-27 v0.1.44
|
||||
- 전역 토스트는 공통 composable을 템플릿에 넘길 때 top-level ref로 풀어줘야 하므로, 렌더링 구조를 단순하게 유지하는 편이 안전하다고 정리했다.
|
||||
- 공개 티어표가 많아질수록 게임별 목록 안에서 바로 제목/작성자 검색이 가능해야 하므로, 검색은 별도 페이지보다 각 게임 허브 안에서 먼저 제공하기로 결정했다.
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
|
||||
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
|
||||
- 공개 티어표 목록은 현재 게임 기준으로 제목/작성자 검색을 지원한다.
|
||||
- `내 즐겨찾기` 화면에서는 즐겨찾기한 순, 최신 업데이트순, 인기순 정렬을 제공한다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
@@ -146,6 +147,7 @@
|
||||
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
||||
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `960px` 보드 폭과 `pixelRatio 1.5`, 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다.
|
||||
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
|
||||
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
|
||||
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- 즐겨찾기는 현재 `내 즐겨찾기` 목록과 정렬까지 지원하므로, 필요하면 폴더 분류나 메모 같은 개인 정리 기능을 추가 검토한다.
|
||||
- 전역 토스트는 기본 시간 기반 자동 종료만 지원하므로, 필요하면 중복 합치기나 액션 링크 포함 형태로 확장할 수 있다.
|
||||
- 공개 티어표 검색은 현재 게임별 허브 안에서만 제공하므로, 필요하면 홈 전역 통합 검색도 검토한다.
|
||||
- 즐겨찾기 토글은 현재 상세 화면 중심이므로, 필요하면 카드 목록에서도 안전한 보조 인터랙션(예: 길게 누르기, 별도 메뉴)을 검토한다.
|
||||
|
||||
## 배포 전 작업
|
||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-27 v0.1.45
|
||||
- **즐겨찾기 카드 액션 보정**: 카드형 목록에서는 별표를 클릭 액션이 아닌 상태/개수 표시로만 보여주고, 실제 즐겨찾기 토글은 상세 화면에서 처리하도록 조정
|
||||
- **토스트 중복/페이드아웃 개선**: 같은 메시지 토스트는 하나로 합치고 카운트를 올리도록 변경했으며, 사라질 때는 짧은 페이드아웃 애니메이션을 적용
|
||||
|
||||
## 2026-03-27 v0.1.44
|
||||
- **토스트 렌더링 버그 수정**: 전역 토스트가 빈 카드 여러 개로 보이던 ref 참조 문제를 수정해 실제 메시지만 표시되도록 정리
|
||||
- **공개 티어표 검색 추가**: 게임별 공개 티어표 목록에서 제목/작성자 기준 검색이 가능하도록 검색창과 API 쿼리 지원 추가
|
||||
|
||||
@@ -85,8 +85,11 @@ async function logout() {
|
||||
<RouterView />
|
||||
</main>
|
||||
<div class="toastStack" aria-live="polite" aria-atomic="true">
|
||||
<div v-for="item in toasts" :key="item.id" class="toast" :class="`toast--${item.type}`">
|
||||
<div class="toast__message">{{ item.message }}</div>
|
||||
<div v-for="item in toasts" :key="item.id" class="toast" :class="[`toast--${item.type}`, { 'toast--closing': item.isClosing }]">
|
||||
<div class="toast__body">
|
||||
<div class="toast__message">{{ item.message }}</div>
|
||||
<div v-if="item.count > 1" class="toast__count">x{{ item.count }}</div>
|
||||
</div>
|
||||
<button class="toast__close" @click="dismissToast(item.id)">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -11,6 +11,9 @@ const toast = useToast()
|
||||
const favorites = ref([])
|
||||
const query = ref('')
|
||||
const sort = ref('favorited')
|
||||
const sortLabel = computed(() =>
|
||||
sort.value === 'favorited' ? '즐겨찾기한 날짜' : sort.value === 'updated' ? '최종 업데이트' : '즐겨찾기 수'
|
||||
)
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
@@ -52,16 +55,6 @@ function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function unfavorite(tierList) {
|
||||
try {
|
||||
await api.unfavoriteTierList(tierList.id)
|
||||
favorites.value = favorites.value.filter((entry) => entry.id !== tierList.id)
|
||||
toast.success('즐겨찾기를 해제했어요.')
|
||||
} catch (e) {
|
||||
toast.error('즐겨찾기 해제에 실패했어요.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
</script>
|
||||
|
||||
@@ -103,9 +96,9 @@ onMounted(loadFavorites)
|
||||
<div class="row__foot">
|
||||
<div class="row__meta">
|
||||
<div>{{ tierList.gameName || tierList.gameId }}</div>
|
||||
<div>{{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
|
||||
<div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
<button class="favoriteBtn" @click="unfavorite(tierList)">★ {{ tierList.favoriteCount || 0 }}</button>
|
||||
<div class="favoriteStat">★ {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
</button>
|
||||
<div class="row__foot">
|
||||
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
|
||||
<button class="favoriteBtn" @click="toggleFavorite(t)">
|
||||
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user