Compare commits

...

5 Commits

4 changed files with 147 additions and 60 deletions

View File

@@ -1,5 +1,22 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-01 v1.3.23
- 내 티어표 목록 그리드는 auto-fit 최대폭 방식 대신 게임 목록과 같은 4/3/2/1열 고정 반응형 규칙으로 맞춰, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 떨어지며 여백이 크게 남던 문제를 줄임.
## 2026-04-01 v1.3.22
- 내 티어표 카드는 게임 목록과 같은 상단 히어로/패널 문법으로 다시 맞추고, 깨진 썸네일은 alt 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
## 2026-04-01 v1.3.21
- 내 티어표 카드는 게임 목록 화면과 같은 카드 폭/헤더/메타 배치 문법으로 맞춰, 화면 간 카드 크기와 정보 정렬이 더 통일된 인상으로 보이도록 정리함.
## 2026-04-01 v1.3.20
- 내 티어표 카드 그리드는 카드 최대폭 우선 규칙 대신 더 촘촘한 auto-fill 기준으로 조정해, 넓은 화면에서도 한 줄에 더 많은 카드가 자연스럽게 배치되도록 보정함.
## 2026-04-01 v1.3.19
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
## 2026-04-01 v1.3.18 ## 2026-04-01 v1.3.18
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임. - 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함. - 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.

View File

@@ -78,14 +78,10 @@ onMounted(async () => {
.rightRailAd__frame { .rightRailAd__frame {
min-height: 520px; min-height: 520px;
padding: 14px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
} }
.rightRailAd__slot { .rightRailAd__slot {
width: 100%; width: 100%;
min-height: 490px; min-height: 520px;
} }
</style> </style>

View File

@@ -2145,15 +2145,15 @@ async function saveFeaturedOrder() {
<div class="adminSidebar__label">Image Optimization</div> <div class="adminSidebar__label">Image Optimization</div>
<div class="adminSidebar__group adminSidebar__group--monthPicker"> <div class="adminSidebar__group adminSidebar__group--monthPicker">
<div class="monthPicker"> <div class="monthPicker">
<select v-model="selectedImageStatsYear" class="select monthPicker__select"> <select v-model="selectedImageStatsYear" class="select monthPicker__select monthPicker__select--year">
<option value="">전체 기간</option> <option value="">전체 기간</option>
<option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}</option> <option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}</option>
</select> </select>
<select v-model="selectedImageStatsMonthNumber" class="select monthPicker__select" :disabled="!selectedImageStatsYear"> <select v-if="selectedImageStatsYear" v-model="selectedImageStatsMonthNumber" class="select monthPicker__select monthPicker__select--month">
<option value=""> 선택</option> <option value=""> 선택</option>
<option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option> <option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option>
</select> </select>
<button class="btn btn--ghost btn--tiny" type="button" :disabled="!imageStatsMonth" @click="clearImageStatsMonth">전체</button> <button v-if="imageStatsMonth" class="btn btn--ghost btn--tiny" type="button" @click="clearImageStatsMonth">전체</button>
</div> </div>
<select v-model.number="imageStatsLimit" class="select"> <select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option> <option :value="6">최근 6</option>
@@ -2341,6 +2341,24 @@ async function saveFeaturedOrder() {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.adminSidebar__group--monthPicker {
align-items: start;
}
.monthPicker {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.monthPicker__select {
min-width: 0;
}
.monthPicker__select--year {
flex: 1 1 132px;
}
.monthPicker__select--month {
flex: 1 1 108px;
}
.adminSidebar__actions--stack .btn { .adminSidebar__actions--stack .btn {
width: 100%; width: 100%;
} }

View File

@@ -9,6 +9,7 @@ const router = useRouter()
const toast = useToast() const toast = useToast()
const myLists = ref([]) const myLists = ref([])
const error = ref('') const error = ref('')
const brokenThumbnailIds = ref({})
watch(error, (message) => { watch(error, (message) => {
if (!message) return if (!message) return
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
} }
function tierListThumbnailUrl(tierList) { function tierListThumbnailUrl(tierList) {
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : '' return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
} }
function handleThumbnailError(tierListId) {
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
}
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.listMyTierLists() const data = await api.listMyTierLists()
brokenThumbnailIds.value = {}
myLists.value = data.tierLists || [] myLists.value = data.tierLists || []
} catch (e) { } catch (e) {
toast.error('로그인이 필요해요.') toast.error('로그인이 필요해요.')
@@ -51,53 +59,87 @@ onMounted(async () => {
}) })
function openList(t) { function openList(t) {
router.push(`/editor/${t.gameId}/${t.id}`) router.push(
"/editor/" + t.gameId + "/" + t.id,
)
} }
</script> </script>
<template> <template>
<section class="pageWrap"> <section class="dashboardHero">
<header class="pageHead"> <div class="dashboardHero__left">
<div class="pageHead__main"> <div class="dashboardHero__eyebrow">Library</div>
<div class="pageHead__eyebrow">Library</div> <h2 class="dashboardHero__title"> 티어표</h2>
<h2 class="pageHead__title"> 티어표</h2> <p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</p>
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div> </div>
</div> </section>
</header>
<div class="card"> <section class="panel">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div> <div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list"> <div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="boardCard"> <article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)"> <button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap"> <div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" /> <img
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div> v-if="tierListThumbnailUrl(t)"
class="boardCard__thumb"
:src="tierListThumbnailUrl(t)"
alt=""
@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"> {{ t.favoriteCount || 0 }}</div>
</div> </div>
<div class="boardCard__head"> <div class="boardCard__metaRow">
<div class="boardCard__titleRow"> <div class="boardCard__author">
<div class="boardCard__title">{{ t.title }}</div> <img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div> <div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
</div> <span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<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>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div> </div>
</button> </div>
</article> </button>
</div> </article>
</div> </div>
</section> </section>
</template> </template>
<style scoped> <style scoped>
.card { .dashboardHero {
border: 0; display: flex;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 18px;
}
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.panel {
background: transparent; background: transparent;
border-radius: 0; border-radius: 0;
padding: 0; padding: 0;
@@ -107,12 +149,10 @@ function openList(t) {
} }
.list { .list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 320px)); grid-template-columns: repeat(4, minmax(0, 1fr));
justify-content: start;
gap: 18px; gap: 18px;
} }
.boardCard { .boardCard {
display: grid;
min-width: 0; min-width: 0;
border-radius: 22px; border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.16);
@@ -129,7 +169,6 @@ function openList(t) {
background: rgba(70, 70, 70, 0.96); background: rgba(70, 70, 70, 0.96);
} }
.boardCard__body { .boardCard__body {
flex: 1 1 auto;
min-width: 0; min-width: 0;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
@@ -137,9 +176,12 @@ function openList(t) {
background: transparent; background: transparent;
color: inherit; color: inherit;
padding: 0; padding: 0;
width: 100%;
display: grid; display: grid;
overflow: hidden;
} }
.boardCard__thumbWrap { .boardCard__thumbWrap {
min-width: 0;
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
padding: 14px 14px 0; padding: 14px 14px 0;
@@ -164,46 +206,46 @@ function openList(t) {
border-radius: 18px; border-radius: 18px;
} }
.boardCard__title { .boardCard__title {
flex: 1 1 auto; font-weight: 800;
min-width: 0; min-width: 0;
font-weight: 900;
font-size: 18px; font-size: 18px;
line-height: 1.3; line-height: 1.35;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
} }
.boardCard__head { .boardCard__head {
min-width: 0;
padding: 16px 18px 18px; padding: 16px 18px 18px;
display: grid; display: grid;
gap: 8px; gap: 8px;
min-width: 0; overflow: hidden;
} }
.boardCard__titleRow, .boardCard__titleRow,
.boardCard__metaRow { .boardCard__metaRow {
display: flex;
gap: 10px;
min-width: 0; min-width: 0;
align-items: center; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
} }
.boardCard__titleRow { .boardCard__titleRow {
align-items: flex-start; align-items: flex-start;
} }
.boardCard__metaRow { .boardCard__metaRow {
align-items: flex-end; align-items: flex-end;
} }
.boardCard__author { .boardCard__author {
flex: 1 1 auto;
min-width: 0; min-width: 0;
max-width: 100%;
display: inline-flex; display: inline-flex;
gap: 7px; gap: 7px;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
opacity: 0.84; opacity: 0.86;
overflow: hidden;
} }
.boardCard__authorName { .boardCard__authorName {
min-width: 0; min-width: 0;
@@ -229,14 +271,28 @@ function openList(t) {
.boardCard__date, .boardCard__date,
.favoriteStat { .favoriteStat {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px; font-size: 13px;
color: rgba(255, 255, 255, 0.64); color: rgba(255, 255, 255, 0.64);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.boardCard__date { .boardCard__date {
font-size: 10px; font-size: 10px;
} }
@media (max-width: 720px) { @media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1200px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.list { .list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }