Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b851100c89 | |||
| e70e685a06 | |||
| 09acebc2d5 | |||
| e3391b5f07 | |||
| 22220494d6 | |||
| 909ed72502 | |||
| c352bf459f | |||
| 730a87b923 | |||
| e9a049241d | |||
| 0fec84de13 |
@@ -72,8 +72,8 @@ const templateRequestSchema = z.object({
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()),
|
||||
})
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
),
|
||||
boardItems: z.array(
|
||||
z.object({
|
||||
@@ -100,8 +100,8 @@ const tierListUpsertSchema = z.object({
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(16),
|
||||
itemIds: z.array(z.string()),
|
||||
})
|
||||
itemIds: z.array(z.string()).optional().default([]),
|
||||
}).passthrough()
|
||||
),
|
||||
pool: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 최근 게임들은 S, A, B,C 같은 랭크 뿐만 아니라 가로 열도 나누어진형태의 티어표를 원함 (공격, 방어, 지원 등 각 파트별 랭크를 보고싶어함)
|
||||
- 티어표에서 티어 추가(세로라인)를 행 추가 등의 단어로 변경. 열 추가 버튼도 표시. 열을 추가할경우 열에도 이름을 입력 가능. (1열만 있을 경우에는 삭제 불가, 열 제목 사용 안함)
|
||||
- 드래그 아이콘의 위치를 이동시키거나, 제외하는 등(기능은 가능해야함) 적절한 판단을 통해 행 라벨의 너비가 큰편인데 조금 줄일 필요 있음. (실제로는 5~6자 이상 쓰는경우가 거의 없기 때문)
|
||||
- 이미지 저장시 라벨 텍스트가 중앙 정렬이 아닌 살짝 상단으로 치우쳐진 상태라 이쁘지 않은데... 확인 필요함.
|
||||
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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
|
||||
- 커스텀 아이템 기본 이름은 파일명 전체를 그대로 쓰지 않고 확장자 제거·공백 정리·60자 제한을 먼저 적용하도록 바꿔, 템플릿 요청 전에 커스텀 업로드가 길이 제한으로 실패하던 흐름을 줄임.
|
||||
- 템플릿 요청 실패 안내는 커스텀 이미지 업로드 실패와 일반 bad request를 구분해, 사용자가 제목/설명/아이템 이름 길이 제한 문제를 더 쉽게 파악할 수 있게 보강함.
|
||||
- 관리자 Image Optimization 월 필터는 기본 month input 대신 연도/월 셀렉트와 전체 초기화 버튼으로 바꿔, 기간 선택을 더 직관적으로 조작할 수 있게 정리함.
|
||||
|
||||
## 2026-04-01 v1.3.17
|
||||
- 티어 에디터 열 헤더 입력창과 행 라벨은 좌우 패딩을 대칭으로 다시 잡아, 드래그 핸들과 삭제 아이콘이 있어도 제목이 한쪽으로 쏠려 보이지 않도록 보정함.
|
||||
- 열 삭제도 이제 행 삭제와 같은 확인 모달을 거쳐 진행되도록 바꿔, 실수로 즉시 제거되던 문제를 막음.
|
||||
- 내보내기 보드는 여전히 960px 고정 폭이라 열 수가 늘수록 각 칸 폭이 줄어드는 구조라는 점을 기준으로 정리했고, 현재 보정은 헤더 정렬 문제를 우선 해결하는 쪽에 맞춤.
|
||||
|
||||
## 2026-04-01 v1.3.16
|
||||
- 티어 에디터의 행 삭제와 열 삭제는 다시 작은 X 아이콘 액션으로 정리해, 행/열 이름 주변의 반복 텍스트 때문에 보드가 답답해 보이던 문제를 줄임.
|
||||
- 열 헤더 편집 영역은 입력창 오른쪽에 아이콘 삭제만 남기고, 행 라벨도 상단 우측의 작은 제거 버튼으로 맞춰 더 압축된 편집 밀도를 유지하도록 조정함.
|
||||
- 저장 이미지에서 열 제목이 살짝 위로 떠 보이던 문제는 내보내기 헤더의 비대칭 패딩을 제거하고 flex 중앙 정렬로 바꿔, 시각적으로 정확한 중앙에 오도록 보정함.
|
||||
|
||||
## 2026-04-01 v1.3.15
|
||||
- 티어 에디터의 열 이름은 각 행 안에서 반복 렌더링되지 않도록 공통 상단 헤더로 분리해, 행 제목과 같은 구조로 더 또렷하게 구분되도록 수정함.
|
||||
- 행 추가/열 추가 액션은 새 SVG 아이콘 버튼으로 압축해, 텍스트 때문에 보드 상단 툴바 높이가 과하게 커지던 문제를 정리함.
|
||||
- 미리보기와 삭제 모달 문구도 행/열 기준으로 함께 정리해, 전체 티어 에디터 흐름을 더 일관된 용어와 레이아웃으로 다듬음.
|
||||
|
||||
## 2026-04-01 v1.3.14
|
||||
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
|
||||
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.
|
||||
- 이름 오버레이 정렬과 저장용 미리보기 보드도 함께 손봐서, 이미지 다운로드 시 라벨 텍스트가 하단 중앙에 더 안정적으로 배치되도록 수정함.
|
||||
|
||||
## 2026-04-01 v1.3.13
|
||||
- 템플릿 등록/업데이트 요청 모달은 이제 현재 티어표 제목·설명을 기본값으로 가져오고, 비어 있더라도 모달 안에서 바로 작성해 요청할 수 있도록 흐름을 단순화함.
|
||||
- 템플릿 요청 시 `내 티어 리스트에도 저장` 토글을 추가해, 요청 스냅샷만 관리자에게 전달할지 아니면 현재 양식도 내 티어표로 함께 저장할지 분리함.
|
||||
|
||||
1
frontend/src/assets/icons/add_column_right.svg
Normal file
1
frontend/src/assets/icons/add_column_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M160-760v560h240v-560H160ZM80-120v-720h720v160h-80v-80H480v560h240v-80h80v160H80Zm400-360Zm-80 0h80-80Zm0 0Zm320 120v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 283 B |
1
frontend/src/assets/icons/add_row_below.svg
Normal file
1
frontend/src/assets/icons/add_row_below.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-560h560v-240H200v240Zm-80 400v-720h720v720H680v-80h80v-240H200v240h80v80H120Zm360-320Zm0-80v80-80Zm0 0ZM440-80v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -78,14 +78,10 @@ onMounted(async () => {
|
||||
|
||||
.rightRailAd__frame {
|
||||
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 {
|
||||
width: 100%;
|
||||
min-height: 490px;
|
||||
min-height: 520px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const myLists = ref([])
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -37,12 +38,19 @@ function avatarFallbackOf(tierList) {
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
if (!tierList?.id || brokenThumbnailIds.value[tierList.id]) return ''
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function handleThumbnailError(tierListId) {
|
||||
if (!tierListId || brokenThumbnailIds.value[tierListId]) return
|
||||
brokenThumbnailIds.value = { ...brokenThumbnailIds.value, [tierListId]: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.listMyTierLists()
|
||||
brokenThumbnailIds.value = {}
|
||||
myLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
@@ -51,53 +59,87 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function openList(t) {
|
||||
router.push(`/editor/${t.gameId}/${t.id}`)
|
||||
router.push(
|
||||
"/editor/" + t.gameId + "/" + t.id,
|
||||
)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<header class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Library</div>
|
||||
<h2 class="pageHead__title">내 티어표</h2>
|
||||
<div class="pageHead__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Library</div>
|
||||
<h2 class="dashboardHero__title">내 티어표</h2>
|
||||
<p class="dashboardHero__desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
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 class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ 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)" />
|
||||
<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 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>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border: 0;
|
||||
.dashboardHero {
|
||||
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;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
@@ -107,12 +149,10 @@ function openList(t) {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 320px));
|
||||
justify-content: start;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
@@ -129,7 +169,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 +176,12 @@ function openList(t) {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
@@ -164,46 +206,46 @@ 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;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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 {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
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,14 +271,28 @@ 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;
|
||||
}
|
||||
@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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -19,11 +22,12 @@ const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
const gameName = ref('')
|
||||
|
||||
const columns = ref([{ id: 'col-1', name: '' }])
|
||||
const groups = ref([
|
||||
{ id: 'gS', name: 'S', itemIds: [] },
|
||||
{ id: 'gA', name: 'A', itemIds: [] },
|
||||
{ id: 'gB', name: 'B', itemIds: [] },
|
||||
{ id: 'gC', name: 'C', itemIds: [] },
|
||||
{ id: 'gS', name: 'S', itemIds: [], cells: [[]] },
|
||||
{ id: 'gA', name: 'A', itemIds: [], cells: [[]] },
|
||||
{ id: 'gB', name: 'B', itemIds: [], cells: [[]] },
|
||||
{ id: 'gC', name: 'C', itemIds: [], cells: [[]] },
|
||||
])
|
||||
|
||||
const pool = ref([])
|
||||
@@ -47,7 +51,9 @@ const templateRequestDraftDescription = ref('')
|
||||
const templateRequestSaveToMyTierList = ref(true)
|
||||
const isDeleteModalOpen = ref(false)
|
||||
const isGroupDeleteModalOpen = ref(false)
|
||||
const isColumnDeleteModalOpen = ref(false)
|
||||
const pendingRemoveGroupId = ref('')
|
||||
const pendingRemoveColumnIndex = ref(-1)
|
||||
const ownerId = ref('')
|
||||
const authorName = ref('')
|
||||
const authorAccountName = ref('')
|
||||
@@ -154,38 +160,107 @@ function setIconSize(nextSize) {
|
||||
iconSize.value = nextSize
|
||||
}
|
||||
|
||||
function removeItemFromGroup(groupId, itemId) {
|
||||
if (!canEdit.value || !groupId || !itemId) return
|
||||
function getGroupCellIds(group, columnIndex) {
|
||||
return Array.isArray(group?.cells?.[columnIndex]) ? group.cells[columnIndex] : []
|
||||
}
|
||||
|
||||
function syncGroupItemIds(group) {
|
||||
group.itemIds = (group.cells || []).flat()
|
||||
}
|
||||
|
||||
function normalizeLoadedColumns(rawGroups) {
|
||||
const fromGroup = Array.isArray(rawGroups) ? rawGroups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) : null
|
||||
const rawColumns = Array.isArray(fromGroup?.columnNames) ? fromGroup.columnNames : []
|
||||
const cellCount = Math.max(1, ...(Array.isArray(rawGroups) ? rawGroups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)) : [0]))
|
||||
const size = Math.max(rawColumns.length || 0, cellCount)
|
||||
return Array.from({ length: size || 1 }, (_, index) => ({
|
||||
id: rawColumns[index]?.id || `col-${index + 1}` ,
|
||||
name: typeof rawColumns[index]?.name === 'string' ? rawColumns[index].name.slice(0, 16) : '',
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeLoadedGroups(rawGroups, nextColumns = columns.value) {
|
||||
if (!Array.isArray(rawGroups) || !rawGroups.length) {
|
||||
return [
|
||||
{ id: 'gS', name: 'S', itemIds: [], cells: nextColumns.map(() => []) },
|
||||
{ id: 'gA', name: 'A', itemIds: [], cells: nextColumns.map(() => []) },
|
||||
{ id: 'gB', name: 'B', itemIds: [], cells: nextColumns.map(() => []) },
|
||||
{ id: 'gC', name: 'C', itemIds: [], cells: nextColumns.map(() => []) },
|
||||
]
|
||||
}
|
||||
return rawGroups.map((group, index) => {
|
||||
const cells = Array.from({ length: nextColumns.length }, (_, cellIndex) => {
|
||||
if (Array.isArray(group?.cells?.[cellIndex])) return [...group.cells[cellIndex]]
|
||||
if (cellIndex === 0 && Array.isArray(group?.itemIds)) return [...group.itemIds]
|
||||
return []
|
||||
})
|
||||
return {
|
||||
id: typeof group?.id === 'string' && group.id ? group.id : `g-${index + 1}` ,
|
||||
name: typeof group?.name === 'string' && group.name ? group.name.slice(0, 16) : 'Tier',
|
||||
itemIds: cells.flat(),
|
||||
cells,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildGroupPayload() {
|
||||
return groups.value.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
itemIds: (group.cells || []).flat(),
|
||||
cells: (group.cells || []).map((cell) => [...cell]),
|
||||
columnNames: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeItemFromGroup(groupId, columnIndex, itemId) {
|
||||
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
|
||||
const targetGroup = groups.value.find((group) => group.id === groupId)
|
||||
if (!targetGroup) return
|
||||
if (!targetGroup.itemIds.includes(itemId)) return
|
||||
targetGroup.itemIds = targetGroup.itemIds.filter((id) => id !== itemId)
|
||||
const nextCells = [...targetGroup.cells]
|
||||
nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== itemId)
|
||||
targetGroup.cells = nextCells
|
||||
syncGroupItemIds(targetGroup)
|
||||
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
|
||||
}
|
||||
|
||||
function setGroupDropEl(groupId, el) {
|
||||
function setGroupDropEl(groupId, columnIndex, el) {
|
||||
const key = `${groupId}::${columnIndex}`
|
||||
if (!el) {
|
||||
delete groupDropEls.value[groupId]
|
||||
delete groupDropEls.value[key]
|
||||
return
|
||||
}
|
||||
groupDropEls.value[groupId] = el
|
||||
groupDropEls.value[key] = el
|
||||
}
|
||||
|
||||
function getListByContainer(containerEl) {
|
||||
if (!containerEl) return { type: null, groupId: null }
|
||||
if (!containerEl) return { type: null, groupId: null, columnIndex: null }
|
||||
const t = containerEl.getAttribute('data-list-type')
|
||||
if (t === 'pool') return { type: 'pool', groupId: null }
|
||||
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
|
||||
return { type: null, groupId: null }
|
||||
if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null }
|
||||
if (t === 'group') {
|
||||
return {
|
||||
type: 'group',
|
||||
groupId: containerEl.getAttribute('data-group-id'),
|
||||
columnIndex: Number(containerEl.getAttribute('data-column-index')),
|
||||
}
|
||||
}
|
||||
return { type: null, groupId: null, columnIndex: null }
|
||||
}
|
||||
|
||||
function normalizeSort(containerEl) {
|
||||
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
|
||||
const meta = getListByContainer(containerEl)
|
||||
if (meta.type === 'pool') pool.value = ids
|
||||
if (meta.type === 'pool') {
|
||||
pool.value = ids
|
||||
return
|
||||
}
|
||||
if (meta.type === 'group') {
|
||||
const g = groups.value.find((x) => x.id === meta.groupId)
|
||||
if (g) g.itemIds = ids
|
||||
if (!g || !Number.isInteger(meta.columnIndex)) return
|
||||
const nextCells = [...g.cells]
|
||||
nextCells[meta.columnIndex] = ids
|
||||
g.cells = nextCells
|
||||
syncGroupItemIds(g)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +299,7 @@ async function initSortables() {
|
||||
onAdd: () => normalizeSort(poolEl.value),
|
||||
})
|
||||
|
||||
dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) =>
|
||||
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||||
Sortable.create(el, {
|
||||
group: 'tier-items',
|
||||
animation: 160,
|
||||
@@ -257,32 +332,96 @@ async function syncSortables() {
|
||||
}
|
||||
}
|
||||
|
||||
function createGroupName() {
|
||||
function createGroupName(index = groups.value.length) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const index = groups.value.length
|
||||
if (index < alphabet.length) return alphabet[index]
|
||||
return `Tier ${index + 1}`
|
||||
}
|
||||
|
||||
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,
|
||||
{
|
||||
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` ,
|
||||
name: createGroupName(),
|
||||
itemIds: [],
|
||||
cells: columns.value.map(() => []),
|
||||
},
|
||||
]
|
||||
await syncSortables()
|
||||
}
|
||||
|
||||
async function addColumn() {
|
||||
columns.value = [
|
||||
...columns.value,
|
||||
{ id: `col-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: createColumnName() },
|
||||
]
|
||||
groups.value = groups.value.map((group) => ({
|
||||
...group,
|
||||
cells: [...group.cells, []],
|
||||
itemIds: [...group.itemIds],
|
||||
}))
|
||||
await syncSortables()
|
||||
}
|
||||
|
||||
async function removeColumn(columnIndex) {
|
||||
if (!canEdit.value || columns.value.length <= 1) return
|
||||
const nextColumns = columns.value.filter((_, index) => index !== columnIndex)
|
||||
groups.value = groups.value.map((group) => {
|
||||
const nextCells = group.cells.filter((_, index) => index !== columnIndex)
|
||||
const removed = Array.isArray(group.cells[columnIndex]) ? group.cells[columnIndex] : []
|
||||
if (nextCells[0] && removed.length) nextCells[0] = [...removed, ...nextCells[0]]
|
||||
const nextGroup = { ...group, cells: nextCells }
|
||||
syncGroupItemIds(nextGroup)
|
||||
return nextGroup
|
||||
})
|
||||
Object.keys(groupDropEls.value).forEach((key) => {
|
||||
if (key.endsWith(`::${columnIndex}`)) delete groupDropEls.value[key]
|
||||
})
|
||||
columns.value = nextColumns
|
||||
await syncSortables()
|
||||
}
|
||||
|
||||
function openColumnDeleteModal(columnIndex) {
|
||||
if (!canEdit.value || columns.value.length <= 1) return
|
||||
pendingRemoveColumnIndex.value = columnIndex
|
||||
isColumnDeleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeColumnDeleteModal() {
|
||||
isColumnDeleteModalOpen.value = false
|
||||
pendingRemoveColumnIndex.value = -1
|
||||
}
|
||||
|
||||
async function confirmRemoveColumn() {
|
||||
const columnIndex = pendingRemoveColumnIndex.value
|
||||
closeColumnDeleteModal()
|
||||
if (columnIndex < 0) return
|
||||
await removeColumn(columnIndex)
|
||||
}
|
||||
|
||||
async function performRemoveGroup(groupId) {
|
||||
if (groups.value.length <= 1) return
|
||||
const target = groups.value.find((group) => group.id === groupId)
|
||||
if (!target) return
|
||||
pool.value = [...target.itemIds, ...pool.value]
|
||||
groups.value = groups.value.filter((group) => group.id !== groupId)
|
||||
delete groupDropEls.value[groupId]
|
||||
Object.keys(groupDropEls.value).forEach((key) => {
|
||||
if (key.startsWith(`${groupId}::`)) delete groupDropEls.value[key]
|
||||
})
|
||||
await syncSortables()
|
||||
}
|
||||
|
||||
@@ -310,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]
|
||||
}
|
||||
@@ -444,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'), {
|
||||
@@ -469,10 +608,14 @@ async function uploadPendingCustomItems() {
|
||||
}
|
||||
itemsById.value = nextItemsById
|
||||
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
|
||||
groups.value = groups.value.map((group) => ({
|
||||
...group,
|
||||
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
|
||||
}))
|
||||
groups.value = groups.value.map((group) => {
|
||||
const nextGroup = {
|
||||
...group,
|
||||
cells: group.cells.map((cell) => cell.map((currentId) => (currentId === item.id ? uploaded.id : currentId))),
|
||||
}
|
||||
syncGroupItemIds(nextGroup)
|
||||
return nextGroup
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,7 +644,7 @@ function buildPayload(existingId) {
|
||||
sourceTierListId: sourceTierListId.value || '',
|
||||
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||||
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
||||
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
||||
groups: buildGroupPayload(),
|
||||
pool: Object.values(itemsById.value),
|
||||
}
|
||||
}
|
||||
@@ -633,11 +776,7 @@ async function requestTemplate(type) {
|
||||
isPublic: !!isPublic.value,
|
||||
showCharacterNames: !!showCharacterNames.value,
|
||||
saveToMyTierList: !!templateRequestSaveToMyTierList.value,
|
||||
groups: groups.value.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
itemIds: [...group.itemIds],
|
||||
})),
|
||||
groups: buildGroupPayload(),
|
||||
boardItems: Object.values(itemsById.value),
|
||||
})
|
||||
|
||||
@@ -667,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
|
||||
@@ -675,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
|
||||
@@ -727,12 +874,13 @@ onMounted(() => {
|
||||
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
|
||||
favoriteCount.value = Number(t.favoriteCount || 0)
|
||||
isFavorited.value = !!t.isFavorited
|
||||
groups.value = t.groups
|
||||
columns.value = normalizeLoadedColumns(t.groups)
|
||||
groups.value = normalizeLoadedGroups(t.groups, columns.value)
|
||||
const map = {}
|
||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||
itemsById.value = map
|
||||
const grouped = new Set()
|
||||
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
|
||||
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
|
||||
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
||||
} catch (e) {
|
||||
error.value = '티어표를 불러오지 못했어요.'
|
||||
@@ -757,13 +905,23 @@ onUnmounted(() => {
|
||||
<div class="previewOnly__sheet">
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="previewOnly__columns">
|
||||
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
|
||||
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__columnHeader">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="previewOnly__rows">
|
||||
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
|
||||
<div class="previewOnly__label">{{ g.name }}</div>
|
||||
<div class="previewOnly__drop">
|
||||
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
|
||||
<div class="previewOnly__drop">
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -886,13 +1044,26 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
|
||||
<div id="deleteGroupTitle" class="modalCard__title">티어 라인 삭제</div>
|
||||
<div id="deleteGroupTitle" class="modalCard__title">티어 행 삭제</div>
|
||||
<div class="modalCard__desc">
|
||||
이 라인을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
||||
이 행을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="confirmRemoveGroup">라인 삭제</button>
|
||||
<button class="btn btn--danger" @click="confirmRemoveGroup">행 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isColumnDeleteModalOpen" class="modalOverlay" @click.self="closeColumnDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteColumnTitle">
|
||||
<div id="deleteColumnTitle" class="modalCard__title">티어 열 삭제</div>
|
||||
<div class="modalCard__desc">
|
||||
이 열을 삭제하면 현재 들어 있는 아이템은 모두 첫 번째 열로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeColumnDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="confirmRemoveColumn">열 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -904,7 +1075,7 @@ onUnmounted(() => {
|
||||
<div class="editorMain__title">{{ gameName || gameId }}</div>
|
||||
<div class="editorMain__subtitle">
|
||||
<template v-if="canEdit">
|
||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
|
||||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||||
</template>
|
||||
<template v-else>
|
||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||
@@ -921,7 +1092,12 @@ onUnmounted(() => {
|
||||
<div ref="boardEl" class="board">
|
||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||
<div class="boardTools__left">
|
||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||
<button class="boardActionIcon" type="button" title="행 추가" aria-label="행 추가" @click="addGroup">
|
||||
<SvgIcon :src="addRowBelowIcon" :size="18" />
|
||||
</button>
|
||||
<button class="boardActionIcon" type="button" title="열 추가" aria-label="열 추가" @click="addColumn">
|
||||
<SvgIcon :src="addColumnRightIcon" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="boardTools__right">
|
||||
<span class="boardTools__label">아이콘 크기</span>
|
||||
@@ -941,6 +1117,22 @@ onUnmounted(() => {
|
||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
|
||||
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
|
||||
<div class="boardColumnsHeader__grid" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="boardColumnsHeader__cell">
|
||||
<template v-if="isExporting">
|
||||
<div class="boardColumnsHeader__name">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="columnHeader">
|
||||
<input v-model="column.name" class="columnName" maxlength="16" placeholder="열 이름" />
|
||||
<button class="columnRemoveText" type="button" title="열 삭제" aria-label="열 삭제" :disabled="columns.length <= 1" @click="openColumnDeleteModal(columnIndex)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="groupListEl" class="rows">
|
||||
<div v-for="g in groups" :key="g.id" class="row">
|
||||
<div class="row__label">
|
||||
@@ -948,40 +1140,46 @@ onUnmounted(() => {
|
||||
<div class="row__exportName">{{ g.name }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="rowRemoveText"
|
||||
type="button"
|
||||
title="티어 라인 삭제"
|
||||
title="행 삭제"
|
||||
aria-label="행 삭제"
|
||||
:disabled="groups.length <= 1"
|
||||
@click="openGroupDeleteModal(g.id)"
|
||||
>
|
||||
열 삭제
|
||||
</button>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||
>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
class="cellRemoveBtn"
|
||||
type="button"
|
||||
title="아이템 빼내기"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeItemFromGroup(g.id, id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="row__content" :style="{ '--column-count': columns.length }">
|
||||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:data-column-index="columnIndex"
|
||||
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
|
||||
>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
class="cellRemoveBtn"
|
||||
type="button"
|
||||
title="아이템 빼내기"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeItemFromGroup(g.id, columnIndex, id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1210,15 +1408,42 @@ onUnmounted(() => {
|
||||
line-height: 1.6;
|
||||
opacity: 0.76;
|
||||
}
|
||||
.previewOnly__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.previewOnly__columnsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.previewOnly__columnHeader {
|
||||
min-height: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.previewOnly__rows {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.previewOnly__row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.previewOnly__dropGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.previewOnly__dropColumn {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.previewOnly__label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1526,6 +1751,51 @@ onUnmounted(() => {
|
||||
.boardTools__left {
|
||||
margin-right: auto;
|
||||
}
|
||||
.boardActionIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.boardActionIcon:hover {
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
color: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
.boardColumnsHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.boardColumnsHeader__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.boardColumnsHeader__cell {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.boardColumnsHeader__name {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
opacity: 0.74;
|
||||
}
|
||||
.boardTools__label {
|
||||
font-size: 13px;
|
||||
opacity: 0.76;
|
||||
@@ -1592,7 +1862,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -1602,24 +1872,86 @@ onUnmounted(() => {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 12px 30px;
|
||||
padding: 14px 28px;
|
||||
font-weight: 900;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grab {
|
||||
cursor: grab;
|
||||
opacity: 0.85;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
.row__content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.row__column {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.columnHeader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 28px;
|
||||
}
|
||||
.columnName {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
}
|
||||
.columnName::placeholder {
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
.columnRemoveText {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.columnRemoveText:hover {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.columnRemoveText:disabled {
|
||||
opacity: 0.32;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.grab {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
cursor: grab;
|
||||
opacity: 0.72;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.groupName {
|
||||
width: 100%;
|
||||
@@ -1627,7 +1959,7 @@ onUnmounted(() => {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 10px;
|
||||
padding: 6px 8px;
|
||||
padding: 8px 10px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
@@ -1635,19 +1967,25 @@ onUnmounted(() => {
|
||||
}
|
||||
.rowRemoveText {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 10px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
.rowRemoveText:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.rowRemoveText:disabled {
|
||||
opacity: 0.32;
|
||||
@@ -1689,17 +2027,21 @@ onUnmounted(() => {
|
||||
.itemNameOverlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 16px 8px 6px;
|
||||
min-height: 26px;
|
||||
padding: 18px 8px 6px;
|
||||
border-radius: 0 0 10px 10px;
|
||||
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.cellRemoveBtn {
|
||||
@@ -2043,6 +2385,9 @@ onUnmounted(() => {
|
||||
.editorCanvas {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
@@ -2074,7 +2419,18 @@ onUnmounted(() => {
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
.previewOnly__row {
|
||||
.previewOnly__columns,
|
||||
.previewOnly__row,
|
||||
.boardColumnsHeader,
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.previewOnly__columnsSpacer,
|
||||
.boardColumnsHeader__spacer {
|
||||
display: none;
|
||||
}
|
||||
.previewOnly__dropGrid,
|
||||
.boardColumnsHeader__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pool {
|
||||
|
||||
Reference in New Issue
Block a user