릴리스: v0.1.43 토스트와 즐겨찾기 추가

This commit is contained in:
2026-03-27 10:23:29 +09:00
parent 3bd9751621
commit 61fe758b7c
17 changed files with 559 additions and 209 deletions

View File

@@ -3,10 +3,12 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { toApiUrl } from './lib/runtime'
import { useToast } from './composables/useToast'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => !!auth.user?.isAdmin)
const avatarUrl = computed(() => {
if (!auth.user?.avatarSrc) return ''
@@ -81,6 +83,12 @@ async function logout() {
<main class="app-main">
<RouterView />
</main>
<div class="toastStack" aria-live="polite" aria-atomic="true">
<div v-for="item in toast.toasts" :key="item.id" class="toast" :class="`toast--${item.type}`">
<div class="toast__message">{{ item.message }}</div>
<button class="toast__close" @click="toast.dismissToast(item.id)">닫기</button>
</div>
</div>
</div>
</template>
@@ -198,4 +206,54 @@ async function logout() {
.menuItem:hover {
background: rgba(255, 255, 255, 0.09);
}
.toastStack {
position: fixed;
top: 78px;
right: 18px;
z-index: 30;
display: grid;
gap: 10px;
width: min(360px, calc(100vw - 24px));
}
.toast {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(11, 18, 32, 0.94);
backdrop-filter: blur(12px);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28);
}
.toast--success {
border-color: rgba(52, 211, 153, 0.38);
}
.toast--error {
border-color: rgba(239, 68, 68, 0.34);
}
.toast--info {
border-color: rgba(96, 165, 250, 0.34);
}
.toast__message {
line-height: 1.5;
font-size: 14px;
}
.toast__close {
flex: 0 0 auto;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.76);
cursor: pointer;
font-weight: 800;
}
@media (max-width: 640px) {
.toastStack {
top: 70px;
right: 12px;
left: 12px;
width: auto;
}
}
</style>

View File

@@ -0,0 +1,31 @@
import { readonly, ref } from 'vue'
const toasts = ref([])
let toastSeq = 0
function dismissToast(id) {
toasts.value = toasts.value.filter((toast) => toast.id !== id)
}
function showToast(message, { type = 'info', duration = 2600 } = {}) {
if (!message) return ''
const id = `toast-${++toastSeq}`
toasts.value = [...toasts.value, { id, message, type }]
if (duration > 0) {
window.setTimeout(() => dismissToast(id), duration)
}
return id
}
export function useToast() {
return {
toasts: readonly(toasts),
dismissToast,
showToast,
success: (message, options = {}) => showToast(message, { type: 'success', ...options }),
error: (message, options = {}) => showToast(message, { type: 'error', ...options }),
info: (message, options = {}) => showToast(message, { type: 'info', ...options }),
}
}

View File

@@ -58,6 +58,8 @@ export const api = {
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
listMyTierLists: () => request('/api/tierlists/me'),
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
uploadTierListThumbnail: async (file) => {

View File

@@ -1,13 +1,15 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/useToast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => !!auth.user?.isAdmin)
const activeTab = ref('games')
@@ -31,7 +33,13 @@ const adminTierListQuery = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
const adminTierListTargetGameId = ref('')
const importModalOpen = ref(false)
const importModalMode = ref('existing')
const importModalTierList = ref(null)
const importModalItems = ref([])
const importModalTargetGameId = ref('')
const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const users = ref([])
@@ -62,6 +70,7 @@ const featuredGames = computed(() =>
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const importModalItemCount = computed(() => importModalItems.value.length)
onMounted(async () => {
await auth.refresh()
@@ -75,6 +84,18 @@ onUnmounted(() => {
destroyFeaturedSortable()
})
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
watch(success, (message) => {
if (!message) return
toast.success(message)
success.value = ''
})
function resetMessages() {
error.value = ''
success.value = ''
@@ -86,9 +107,6 @@ function setTab(tab) {
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
customItemTargetGameId.value = games.value[0].id
}
if (tab === 'tierlists' && !adminTierListTargetGameId.value && games.value.length) {
adminTierListTargetGameId.value = games.value[0].id
}
}
async function refreshGames() {
@@ -98,9 +116,6 @@ async function refreshGames() {
if (!customItemTargetGameId.value && games.value.length) {
customItemTargetGameId.value = games.value[0].id
}
if (!adminTierListTargetGameId.value && games.value.length) {
adminTierListTargetGameId.value = games.value[0].id
}
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
@@ -165,14 +180,7 @@ async function refreshAdminTierLists() {
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
adminTierLists.value = (data.tierLists || []).map((tierList) => ({
...tierList,
templateGameId: tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`,
templateGameName:
tierList.gameId === 'freeform'
? `${tierList.title} 템플릿`
: `${tierList.gameName || tierList.gameId} 확장 템플릿`,
}))
adminTierLists.value = data.tierLists || []
adminTierListTotal.value = data.total || 0
adminTierListPage.value = data.page || 1
adminTierListLimit.value = data.limit || adminTierListLimit.value
@@ -612,76 +620,73 @@ function openAdminTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
}
async function promoteTierListExtraItem(tierList, item) {
function openTierListImportModal(tierList, items) {
resetMessages()
if (!adminTierListTargetGameId.value) {
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
const nextItems = (items || []).filter(Boolean)
if (!nextItems.length) {
error.value = '가져올 아이템이 없어요.'
return
}
try {
item.isPromoting = true
const data = await api.promoteAdminTierListItems(tierList.id, {
gameId: adminTierListTargetGameId.value,
itemIds: [item.id],
})
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
success.value = `"${item.label}" 아이템을 기본 템플릿으로 추가했어요. (${data.items?.length || 0}개 반영)`
} catch (e) {
error.value = '티어표 추가 아이템을 기본 템플릿으로 가져오지 못했어요.'
} finally {
item.isPromoting = false
}
importModalTierList.value = tierList
importModalItems.value = nextItems
importModalMode.value = 'existing'
importModalTargetGameId.value = ''
importModalNewGameId.value = tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`
importModalNewGameName.value =
tierList.gameId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.gameName || tierList.gameId} 파생 템플릿`
importModalOpen.value = true
}
async function promoteAllTierListExtraItems(tierList) {
resetMessages()
if (!adminTierListTargetGameId.value) {
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
return
}
if (!tierList.extraItems?.length) {
error.value = '가져올 추가 아이템이 없어요.'
return
}
try {
tierList.isPromotingAll = true
const data = await api.promoteAdminTierListItems(tierList.id, {
gameId: adminTierListTargetGameId.value,
itemIds: tierList.extraItems.map((item) => item.id),
})
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
success.value = `${data.items?.length || 0}개의 추가 아이템을 기본 템플릿으로 가져왔어요.`
} catch (e) {
error.value = '추가 아이템 일괄 가져오기에 실패했어요.'
} finally {
tierList.isPromotingAll = false
}
function closeTierListImportModal() {
importModalOpen.value = false
importModalTierList.value = null
importModalItems.value = []
}
async function createTemplateFromTierList(tierList) {
async function confirmTierListImport() {
resetMessages()
const nextGameId = (tierList.templateGameId || '').trim()
const nextName = (tierList.templateGameName || '').trim()
if (!nextGameId || !nextName) {
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
if (!importModalTierList.value || !importModalItems.value.length) {
error.value = '가져올 티어표 정보를 확인하지 못했어요.'
return
}
const tierList = importModalTierList.value
const itemIds = importModalItems.value.map((item) => item.id)
try {
tierList.isCreatingTemplate = true
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
gameId: nextGameId,
name: nextName,
})
await refreshGames()
success.value = `"${data.game?.name || nextName}" 게임 템플릿을 생성했어요.`
if (importModalMode.value === 'existing') {
if (!importModalTargetGameId.value) {
error.value = '아이템을 추가할 기존 게임을 선택해주세요.'
return
}
const data = await api.promoteAdminTierListItems(tierList.id, {
gameId: importModalTargetGameId.value,
itemIds,
})
if (selectedGameId.value === importModalTargetGameId.value) await loadGame()
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
} else {
const nextGameId = (importModalNewGameId.value || '').trim()
const nextGameName = (importModalNewGameName.value || '').trim()
if (!nextGameId || !nextGameName) {
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
return
}
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
gameId: nextGameId,
name: nextGameName,
itemIds,
})
await refreshGames()
success.value = `"${data.game?.name || nextGameName}" 템플릿을 생성했어요.`
}
closeTierListImportModal()
} catch (e) {
error.value = '커스텀 티어표를 새 게임 템플릿으로 만들지 못했어요.'
} finally {
tierList.isCreatingTemplate = false
error.value = '티어표 아이템 가져오기에 실패했어요.'
}
}
@@ -761,9 +766,6 @@ async function saveFeaturedOrder() {
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
<template v-if="activeTab === 'games'">
<div class="panel">
<div class="sectionHeader">
@@ -1015,13 +1017,6 @@ async function saveFeaturedOrder() {
</select>
</div>
<div class="toolbar toolbar--secondary">
<select v-model="adminTierListTargetGameId" class="select toolbar__select">
<option value="">추가 아이템을 넣을 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList">
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
@@ -1050,37 +1045,15 @@ async function saveFeaturedOrder() {
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList">
<article v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem">
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="openTierListImportModal(tierList, [item])">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__body">
<div class="tierAdminItem__title">{{ item.label }}</div>
<div class="tierAdminItem__meta">{{ item.origin === 'custom' ? '사용자 추가 아이템' : '기본 아이템' }}</div>
</div>
<button
class="btn btn--ghost btn--small"
:disabled="!adminTierListTargetGameId || item.isPromoting"
@click="promoteTierListExtraItem(tierList, item)"
>
{{ item.isPromoting ? '추가중...' : '이 아이템 추가' }}
</button>
</article>
<div class="tierAdminItem__title">{{ item.label }}</div>
</button>
</div>
<button
class="btn btn--ghost btn--small"
:disabled="!adminTierListTargetGameId || tierList.isPromotingAll"
@click="promoteAllTierListExtraItems(tierList)"
>
{{ tierList.isPromotingAll ? '가져오는 중...' : '추가 아이템 전체 가져오기' }}
</button>
</div>
<div v-if="tierList.gameId === 'freeform'" class="tierAdminSection">
<div class="tierAdminSection__title">커스텀 티어표를 게임 템플릿으로 만들기</div>
<div class="tierAdminTemplateForm">
<input v-model="tierList.templateGameId" class="input" placeholder="새 게임 ID" />
<input v-model="tierList.templateGameName" class="input" placeholder="새 게임 이름" />
<button class="btn btn--primary" :disabled="tierList.isCreatingTemplate" @click="createTemplateFromTierList(tierList)">
{{ tierList.isCreatingTemplate ? '생성중...' : ' 게임 템플릿 만들기' }}
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="openTierListImportModal(tierList, tierList.extraItems)">
템플릿으로 가져오기
</button>
</div>
</div>
@@ -1094,6 +1067,43 @@ async function saveFeaturedOrder() {
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
</div>
</div>
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 아이템 가져오기</div>
<div class="modalCard__desc">
"{{ importModalTierList?.title }}" 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
</div>
<div class="importModeTabs">
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
기존 템플릿에 추가
</button>
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
템플릿 만들기
</button>
</div>
<div v-if="importModalMode === 'existing'" class="modalCard__form">
<select v-model="importModalTargetGameId" class="select">
<option value="">기존 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div v-else class="modalCard__form">
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
<button class="btn btn--primary" @click="confirmTierListImport">
{{ importModalMode === 'existing' ? '여기로 가져오기' : ' 템플릿 생성' }}
</button>
</div>
</div>
</div>
</template>
<template v-else>
@@ -1778,43 +1788,85 @@ async function saveFeaturedOrder() {
.tierAdminSection__title {
font-weight: 800;
}
.tierAdminSection__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tierAdminItemList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
}
.tierAdminItem {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
gap: 8px;
justify-items: center;
padding: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
text-align: center;
min-width: 0;
}
.tierAdminItem__thumb {
width: 56px;
height: 56px;
width: min(100%, 72px);
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
}
.tierAdminItem__body {
min-width: 0;
}
.tierAdminItem__title {
width: 100%;
font-weight: 800;
word-break: break-word;
}
.tierAdminItem__meta {
margin-top: 4px;
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.tierAdminTemplateForm {
.modalOverlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 20px;
background: rgba(3, 7, 18, 0.66);
backdrop-filter: blur(6px);
}
.modalCard {
width: min(560px, 100%);
display: grid;
gap: 14px;
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(11, 18, 32, 0.96);
}
.modalCard__title {
font-size: 18px;
font-weight: 900;
}
.modalCard__desc {
opacity: 0.78;
line-height: 1.5;
}
.modalCard__form {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 10px;
}
.modalCard__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
.importModeTabs {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.checkRow {
margin-top: 12px;
display: inline-flex;
@@ -1830,8 +1882,7 @@ async function saveFeaturedOrder() {
.section--topGrid,
.toolbar,
.itemComposer,
.tierAdminCard,
.tierAdminTemplateForm {
.tierAdminCard {
grid-template-columns: 1fr;
}
.toolbar--secondary {
@@ -1847,9 +1898,8 @@ async function saveFeaturedOrder() {
.userList {
grid-template-columns: 1fr;
}
.tierAdminCard__head,
.tierAdminItem {
grid-template-columns: 1fr;
.tierAdminCard__head {
display: grid;
}
.customItemCard {
align-items: stretch;

View File

@@ -4,10 +4,12 @@ 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)
@@ -62,6 +64,21 @@ function createNew() {
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('즐겨찾기 처리에 실패했어요.')
}
}
</script>
<template>
@@ -81,21 +98,28 @@ function openTierList(id) {
<div class="panel__title">공개 티어표</div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list">
<button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<article v-for="t in tierLists" :key="t.id" class="row">
<button class="row__body" @click="openTierList(t.id)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
</div>
</div>
</button>
<div class="row__foot">
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
<button class="favoriteBtn" @click="toggleFavorite(t)">
{{ t.isFavorited ? '' : '' }} {{ t.favoriteCount || 0 }}
</button>
</div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
</button>
</article>
</div>
</section>
</template>
@@ -160,14 +184,10 @@ function openTierList(id) {
gap: 14px;
}
.row {
text-align: left;
padding: 0;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
width: 100%;
display: grid;
gap: 10px;
align-content: start;
@@ -177,6 +197,17 @@ function openTierList(id) {
.row:hover {
background: rgba(255, 255, 255, 0.05);
}
.row__body {
text-align: left;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
width: 100%;
display: grid;
gap: 10px;
}
.row__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
@@ -229,11 +260,29 @@ function openTierList(id) {
font-weight: 900;
}
.row__meta {
padding: 0 14px 14px;
opacity: 0.78;
font-size: 13px;
}
.row__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.favoriteBtn {
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));

View File

@@ -1,12 +1,14 @@
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { useToast } from '../composables/useToast'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
const email = ref('')
const password = ref('')
@@ -14,6 +16,12 @@ const mode = ref('login')
const error = ref('')
const hasUsers = ref(true)
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
onMounted(async () => {
try {
const meta = await api.authMeta()
@@ -46,9 +54,6 @@ async function submit() {
회원가입
</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<label class="label">이메일</label>
<input v-model="email" class="input" placeholder="you@example.com" autocomplete="email" />
@@ -103,13 +108,6 @@ async function submit() {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(255, 255, 255, 0.16);
}
.error {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
}
.label {
display: block;
font-size: 13px;

View File

@@ -1,13 +1,21 @@
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
const router = useRouter()
const toast = useToast()
const myLists = ref([])
const error = ref('')
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
year: 'numeric',
@@ -39,7 +47,8 @@ onMounted(async () => {
const data = await api.listMyTierLists()
myLists.value = data.tierLists || []
} catch (e) {
error.value = '로그인이 필요해요.'
toast.error('로그인이 필요해요.')
router.push('/login?redirect=/me')
}
})
@@ -54,6 +63,7 @@ async function removeList(t) {
if (!ok) return
await api.deleteTierList(t.id)
myLists.value = myLists.value.filter((entry) => entry.id !== t.id)
toast.success('티어표를 삭제했어요.')
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
}
@@ -64,10 +74,6 @@ async function removeList(t) {
<section class="wrap">
<h2 class="title"> 티어표</h2>
<div class="card">
<div v-if="error" class="error">
{{ error }}
<button class="link" @click="$router.push('/login')">로그인 하러가기</button>
</div>
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="row">
@@ -108,17 +114,6 @@ async function removeList(t) {
border-radius: 16px;
padding: 14px;
}
.error {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.link {
padding: 8px 10px;
border-radius: 10px;

View File

@@ -1,11 +1,13 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { toApiUrl } from '../lib/runtime'
import { useToast } from '../composables/useToast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToast()
const error = ref('')
const saving = ref(false)
@@ -13,6 +15,12 @@ const nickname = ref('')
const previewUrl = ref('')
const avatarFile = ref(null)
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
const avatarUrl = computed(() => {
if (previewUrl.value) return previewUrl.value
if (!auth.user?.avatarSrc) return ''
@@ -55,6 +63,7 @@ async function saveProfile() {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
toast.success('프로필을 저장했어요.')
} catch (e2) {
error.value = '프로필 저장에 실패했어요.'
} finally {
@@ -66,7 +75,6 @@ async function saveProfile() {
<template>
<section class="wrap">
<h2 class="title">프로필</h2>
<div v-if="error" class="error">{{ error }}</div>
<div class="card" v-if="auth.user">
<div class="row">
@@ -102,13 +110,6 @@ async function saveProfile() {
font-size: 26px;
letter-spacing: -0.02em;
}
.error {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
}
.card {
max-width: 520px;
border: 1px solid rgba(255, 255, 255, 0.12);

View File

@@ -1,15 +1,17 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
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)
const tierListId = computed(() => route.params.tierListId)
const gameName = ref('')
@@ -41,6 +43,9 @@ const authorAccountName = ref('')
const updatedAt = ref(0)
const isDragActive = ref(false)
const iconSize = ref(80)
const isFavoriteBusy = ref(false)
const favoriteCount = ref(0)
const isFavorited = ref(false)
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -78,6 +83,13 @@ const untitledWarning = computed(
!hasCustomTitle.value &&
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
)
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
watch(error, (message) => {
if (!message) return
toast.error(message)
error.value = ''
})
function formatTitleDate(ts) {
const date = new Date(ts)
@@ -390,6 +402,8 @@ async function save() {
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!res.tierList?.isFavorited
isSaveModalOpen.value = true
} catch (e) {
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
@@ -409,12 +423,28 @@ async function removeTierList() {
const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`)
if (!ok) return
await api.deleteTierList(tierListId.value)
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
}
}
async function toggleFavorite() {
if (!canFavorite.value || isFavoriteBusy.value) return
try {
isFavoriteBusy.value = true
const data = isFavorited.value ? await api.unfavoriteTierList(tierListId.value) : await api.favoriteTierList(tierListId.value)
favoriteCount.value = Number(data.tierList?.favoriteCount || 0)
isFavorited.value = !!data.tierList?.isFavorited
toast.success(isFavorited.value ? '즐겨찾기에 추가했어요.' : '즐겨찾기를 해제했어요.')
} catch (e) {
error.value = '즐겨찾기 처리에 실패했어요.'
} finally {
isFavoriteBusy.value = false
}
}
onMounted(() => {
;(async () => {
await auth.refresh()
@@ -455,6 +485,8 @@ onMounted(() => {
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
groups.value = t.groups
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
@@ -525,6 +557,9 @@ onUnmounted(() => {
<button v-if="canEdit && !isNewTierList" class="btn btn--danger" @click="removeTierList">삭제</button>
</div>
<div class="actions__right">
<button v-if="canFavorite" class="btn btn--ghost" :disabled="isFavoriteBusy" @click="toggleFavorite">
{{ isFavorited ? ' 즐겨찾기' : ' 즐겨찾기' }} {{ favoriteCount }}
</button>
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>{{ isPublic ? '공개 ON' : '공개 OFF' }}</span>
@@ -534,8 +569,6 @@ onUnmounted(() => {
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>