추천 티어표 분리와 관리자 추천 지정 기능 추가

This commit is contained in:
2026-04-03 12:18:04 +09:00
parent 3b9f5f18e0
commit 8a43a2dd2c
12 changed files with 281 additions and 15 deletions

View File

@@ -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;
}