추천 티어표 분리와 관리자 추천 지정 기능 추가
This commit is contained in:
@@ -87,6 +87,8 @@ export const api = {
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
|
||||
updateAdminTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTierListFeatured: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/featured`, { method: 'PATCH', body: payload }),
|
||||
deleteAdminTierList: (tierListId) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
|
||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||
|
||||
@@ -54,7 +54,7 @@ const adminTierListTopicId = ref('')
|
||||
const adminTierListPage = ref(1)
|
||||
const adminTierListLimit = ref(50)
|
||||
const adminTierListTotal = ref(0)
|
||||
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
|
||||
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0, featuredCount: 0 })
|
||||
const selectedTemplateTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
|
||||
const templateRequests = ref([])
|
||||
const importModalOpen = ref(false)
|
||||
@@ -277,6 +277,7 @@ const adminOverviewStats = computed(() => {
|
||||
]
|
||||
: [
|
||||
{ label: '검색 결과', value: `${adminTierListStats.value.total || 0}` },
|
||||
{ label: '추천', value: `${adminTierListStats.value.featuredCount || 0}` },
|
||||
{ label: '공개', value: `${adminTierListStats.value.publicCount || 0}` },
|
||||
{ label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` },
|
||||
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
|
||||
@@ -844,9 +845,10 @@ async function refreshAdminTierListStats() {
|
||||
total: data.total || 0,
|
||||
publicCount: data.publicCount || 0,
|
||||
privateCount: data.privateCount || 0,
|
||||
featuredCount: data.featuredCount || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0, featuredCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1472,6 +1474,27 @@ async function deleteAdminTierListEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAdminTierListFeatured(tierList) {
|
||||
if (!tierList?.id) return
|
||||
const nextFeatured = !tierList.isFeatured
|
||||
resetMessages()
|
||||
|
||||
try {
|
||||
const data = await api.updateAdminTierListFeatured(tierList.id, { isFeatured: nextFeatured })
|
||||
const updated = data.tierList || {}
|
||||
adminTierLists.value = adminTierLists.value.map((entry) => (entry.id === tierList.id ? { ...entry, ...updated } : entry))
|
||||
if (previewTierList.value?.id === tierList.id) previewTierList.value = { ...previewTierList.value, ...updated }
|
||||
if (modalTargetAdminTierList.value?.id === tierList.id) {
|
||||
modalTargetAdminTierList.value = { ...modalTargetAdminTierList.value, ...updated }
|
||||
}
|
||||
await refreshAdminTierListStats()
|
||||
success.value = nextFeatured ? '추천 티어표로 지정했어요.' : '추천 지정을 해제했어요.'
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e?.data?.error === 'public_tierlist_required' ? '공개 티어표만 추천으로 지정할 수 있어요.' : '추천 상태 변경에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function openAdminTierList(tierList) {
|
||||
previewTierList.value = tierList
|
||||
previewModalOpen.value = true
|
||||
@@ -1782,6 +1805,7 @@ function userAvatarFallback(user) {
|
||||
:admin-tier-list-total="adminTierListTotal"
|
||||
:admin-tier-list-stats="adminTierListStats"
|
||||
:open-admin-tier-list-manage-modal="openAdminTierListManageModal"
|
||||
:toggle-admin-tier-list-featured="toggleAdminTierListFeatured"
|
||||
:move-admin-tier-list-page="moveAdminTierListPage"
|
||||
/>
|
||||
|
||||
|
||||
@@ -46,7 +46,14 @@ async function loadResults() {
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.searchAllPublicTierLists(query.value)
|
||||
tierLists.value = data.tierLists || []
|
||||
const featuredItems = Array.isArray(data.featuredTierLists) ? data.featuredTierLists : []
|
||||
const publicItems = Array.isArray(data.tierLists) ? data.tierLists : []
|
||||
const seen = new Set()
|
||||
tierLists.value = [...featuredItems, ...publicItems].filter((tierList) => {
|
||||
if (!tierList?.id || seen.has(tierList.id)) return false
|
||||
seen.add(tierList.id)
|
||||
return true
|
||||
})
|
||||
} catch (e) {
|
||||
error.value = '검색 결과를 불러오지 못했어요.'
|
||||
} finally {
|
||||
|
||||
@@ -12,6 +12,7 @@ const auth = useAuthStore()
|
||||
|
||||
const topicId = computed(() => route.params.topicId)
|
||||
const topicName = ref('')
|
||||
const featuredTierLists = ref([])
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
@@ -19,6 +20,7 @@ const brokenThumbnailIds = ref({})
|
||||
const isTopicLoading = ref(false)
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const topicTitle = computed(() => topicName.value || (isTopicLoading.value ? '주제 불러오는 중...' : ''))
|
||||
const publicTierLists = computed(() => tierLists.value.filter((tierList) => !tierList.isFeatured))
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
@@ -59,6 +61,7 @@ async function loadTierLists() {
|
||||
])
|
||||
topicName.value = topicRes.topic?.name || ''
|
||||
brokenThumbnailIds.value = {}
|
||||
featuredTierLists.value = listRes.featuredTierLists || []
|
||||
tierLists.value = listRes.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '주제 정보를 불러오지 못했어요.'
|
||||
@@ -110,10 +113,65 @@ watch(
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section v-if="featuredTierLists.length" class="featuredPanel">
|
||||
<div class="featuredHead">
|
||||
<div>
|
||||
<div class="featuredHead__eyebrow">Featured</div>
|
||||
<h3 class="featuredHead__title">추천 티어표</h3>
|
||||
</div>
|
||||
<div class="featuredHead__count">{{ featuredTierLists.length }}개</div>
|
||||
</div>
|
||||
<div class="list featuredList" :class="{ 'list--table': isListView }">
|
||||
<article
|
||||
v-for="t in featuredTierLists"
|
||||
:key="`featured-${t.id}`"
|
||||
class="boardCard boardCard--featured"
|
||||
:class="{ 'boardCard--list': isListView }"
|
||||
>
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(t)"
|
||||
class="boardCard__thumb"
|
||||
:src="tierListThumbnailUrl(t)"
|
||||
alt=""
|
||||
draggable="false"
|
||||
@error="handleThumbnailError(t.id)"
|
||||
/>
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img
|
||||
v-if="avatarSrcOf(t)"
|
||||
class="boardCard__avatar"
|
||||
:src="avatarSrcOf(t)"
|
||||
:alt="displayNameOf(t)"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div class="sectionLabel">전체 공개 티어표</div>
|
||||
<div v-if="publicTierLists.length === 0" class="empty">아직 일반 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in tierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<article v-for="t in publicTierLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(t.id)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" alt="" draggable="false" @error="handleThumbnailError(t.id)" />
|
||||
@@ -148,6 +206,44 @@ watch(
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.featuredPanel {
|
||||
margin-bottom: 28px;
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: linear-gradient(180deg, var(--theme-surface-soft) 0%, var(--theme-surface) 100%);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.featuredHead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.featuredHead__eyebrow,
|
||||
.sectionLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
.featuredHead__title {
|
||||
margin: 6px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.featuredHead__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.sectionLabel {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -206,6 +302,12 @@ watch(
|
||||
background: var(--theme-card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.boardCard--featured {
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 35%, var(--theme-card-border));
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 7%, transparent), transparent 55%),
|
||||
var(--theme-card-bg);
|
||||
}
|
||||
.boardCard__body {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
@@ -361,6 +463,17 @@ watch(
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.featuredPanel {
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.featuredHead {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user