릴리스: v0.1.45 토스트와 즐겨찾기 상호작용 보정
This commit is contained in:
@@ -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