Compare commits

...

4 Commits

4 changed files with 124 additions and 16 deletions

View File

@@ -1,5 +1,20 @@
# 업데이트 로그
## 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
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
## 2026-04-01 v1.3.17
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.

View File

@@ -270,6 +270,49 @@ const visibleLinkedGames = computed(() =>
)
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
const currentYear = new Date().getFullYear()
return Array.from({ length: 6 }, (_, index) => String(currentYear - index))
})
const imageStatsMonthOptions = [
{ value: '01', label: '1월' },
{ value: '02', label: '2월' },
{ value: '03', label: '3월' },
{ value: '04', label: '4월' },
{ value: '05', label: '5월' },
{ value: '06', label: '6월' },
{ value: '07', label: '7월' },
{ value: '08', label: '8월' },
{ value: '09', label: '9월' },
{ value: '10', label: '10월' },
{ value: '11', label: '11월' },
{ value: '12', label: '12월' },
]
const selectedImageStatsYear = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : ''),
set: (year) => {
if (!year) {
imageStatsMonth.value = ''
return
}
const month = imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : '01'
imageStatsMonth.value = `${year}-${month}`
},
})
const selectedImageStatsMonthNumber = computed({
get: () => (imageStatsMonth.value ? imageStatsMonth.value.slice(5, 7) : ''),
set: (month) => {
if (!month) {
imageStatsMonth.value = ''
return
}
const year = imageStatsMonth.value ? imageStatsMonth.value.slice(0, 4) : String(new Date().getFullYear())
imageStatsMonth.value = `${year}-${month}`
},
})
function clearImageStatsMonth() {
imageStatsMonth.value = ''
}
async function refreshImageDiagnostics() {
try {
@@ -2100,8 +2143,18 @@ async function saveFeaturedOrder() {
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Image Optimization</div>
<div class="adminSidebar__group">
<input v-model="imageStatsMonth" class="input" type="month" />
<div class="adminSidebar__group adminSidebar__group--monthPicker">
<div class="monthPicker">
<select v-model="selectedImageStatsYear" class="select monthPicker__select monthPicker__select--year">
<option value="">전체 기간</option>
<option v-for="year in imageStatsYearOptions" :key="year" :value="year">{{ year }}</option>
</select>
<select v-if="selectedImageStatsYear" v-model="selectedImageStatsMonthNumber" class="select monthPicker__select monthPicker__select--month">
<option value=""> 선택</option>
<option v-for="month in imageStatsMonthOptions" :key="month.value" :value="month.value">{{ month.label }}</option>
</select>
<button v-if="imageStatsMonth" class="btn btn--ghost btn--tiny" type="button" @click="clearImageStatsMonth">전체</button>
</div>
<select v-model.number="imageStatsLimit" class="select">
<option :value="6">최근 6</option>
<option :value="12">최근 12</option>
@@ -2288,6 +2341,24 @@ async function saveFeaturedOrder() {
display: grid;
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 {
width: 100%;
}

View File

@@ -112,7 +112,6 @@ function openList(t) {
gap: 18px;
}
.boardCard {
display: grid;
min-width: 0;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
@@ -129,7 +128,6 @@ function openList(t) {
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
cursor: pointer;
@@ -137,9 +135,11 @@ function openList(t) {
background: transparent;
color: inherit;
padding: 0;
width: 100%;
display: grid;
}
.boardCard__thumbWrap {
min-width: 0;
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
@@ -164,29 +164,29 @@ function openList(t) {
border-radius: 18px;
}
.boardCard__title {
flex: 1 1 auto;
font-weight: 800;
min-width: 0;
font-weight: 900;
font-size: 18px;
line-height: 1.3;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
.boardCard__head {
min-width: 0;
padding: 16px 18px 18px;
display: grid;
gap: 8px;
min-width: 0;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 10px;
min-width: 0;
align-items: center;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.boardCard__titleRow {
@@ -197,13 +197,14 @@ function openList(t) {
align-items: flex-end;
}
.boardCard__author {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
display: inline-flex;
gap: 7px;
align-items: center;
font-size: 13px;
opacity: 0.84;
opacity: 0.86;
overflow: hidden;
}
.boardCard__authorName {
min-width: 0;
@@ -229,9 +230,13 @@ function openList(t) {
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
min-width: 0;
max-width: 100%;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__date {
font-size: 10px;

View File

@@ -342,6 +342,15 @@ function createColumnName(index = columns.value.length) {
return `${index + 1}`
}
function createCustomItemLabel(fileName = '') {
const normalized = String(fileName || '')
.replace(/\.[^.]+$/, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return (normalized || 'custom').slice(0, 60)
}
async function addGroup() {
groups.value = [
...groups.value,
@@ -440,7 +449,7 @@ function addCustomImage(file) {
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
itemsById.value = {
...itemsById.value,
[id]: { id, src: url, label: file.name || 'custom', origin: 'custom', pendingFile: file },
[id]: { id, src: url, label: createCustomItemLabel(file.name), origin: 'custom', pendingFile: file },
}
pool.value = [id, ...pool.value]
}
@@ -574,7 +583,7 @@ async function uploadPendingCustomItems() {
for (const item of entries) {
const fd = new FormData()
fd.append('label', item.label || 'custom')
fd.append('label', createCustomItemLabel(item.label || 'custom'))
fd.append('image', item.pendingFile)
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
@@ -797,6 +806,10 @@ async function requestTemplate(type) {
: '템플릿 업데이트 요청을 보냈어요.'
)
} catch (e) {
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.status === 409) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
return
@@ -805,6 +818,10 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
} finally {
isRequestingTemplate.value = false