Compare commits

..

12 Commits

11 changed files with 534 additions and 122 deletions

View File

@@ -26,6 +26,20 @@ const profileSchema = z.object({
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
function establishSession(req, user) {
return new Promise((resolve, reject) => {
req.session.regenerate((regenerateError) => {
if (regenerateError) return reject(regenerateError)
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((saveError) => {
if (saveError) return reject(saveError)
resolve()
})
})
})
}
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
@@ -56,12 +70,12 @@ router.post('/signup', async (req, res) => {
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
})
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/login', async (req, res) => {
@@ -75,12 +89,12 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save(async (err) => {
if (err) return res.status(500).json({ error: 'session_save_failed' })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
})
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/logout', async (req, res) => {

View File

@@ -1,20 +1,10 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 cron 등으로 주기 실행할지와 삭제 전 보관 기간을 함께 정한다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
- 이미지 최적화 기록은 월별 조회/비우기까지 지원하므로, 운영 단계에서는 보관 기간 정책과 자동 아카이브 기준을 정한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
- MariaDB 접속 정보 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`를 설정한다.
- HTTPS를 사용할 경우 `SESSION_COOKIE_SECURE=true`로 설정하고 리버스 프록시 헤더 전달을 확인한다.
- `backend/uploads/`, `backend/.sessions/`, MariaDB 백업 정책을 정한다.
- 로컬 docker compose와 NAS MariaDB 사이의 버전 차이가 크지 않도록 유지한다.
## 중기 개선
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.

View File

@@ -1,5 +1,53 @@
# 업데이트 로그
## 2026-04-01 v1.3.26
- 오른쪽 사이드는 실제 광고 슬롯 기준을 300x600 세로 비율로 잡고, 데스크톱 우측 레일 폭도 325px로 조정해 300px 광고가 내부 패딩과 보더를 제외한 실폭 안에 자연스럽게 들어가도록 보정함.
## 2026-04-01 v1.3.25
- todo 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함.
- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함.
- 로그인과 회원가입은 기존 세션을 그대로 덮어쓰지 않고 세션을 재생성한 뒤 사용자 정보를 저장하도록 바꿔, 세션 고정 공격 방어를 보강함.
## 2026-04-01 v1.3.24
- 게임 선택 후 보이는 공개 티어표 목록 그리드도 auto-fit 최대폭 방식 대신 4/3/2/1열 고정 반응형 규칙으로 바꿔, 넓은 화면에서 카드 한 장이 애매하게 다음 줄로 넘어가며 공백이 크게 남던 문제를 줄임.
## 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
- 티어 에디터를 단일 세로 랭크형에서 행/열 혼합 보드로 확장해, 공격·방어·지원 같은 가로 열을 추가하고 각 열 이름도 직접 입력할 수 있게 함.
- 에디터 액션 문구를 `행 추가 / 열 추가` 기준으로 정리하고, 행 라벨 폭과 드래그 아이콘 위치를 다듬어 실제 사용 빈도에 맞는 더 압축된 보드 레이아웃으로 보정함.

View File

@@ -44,7 +44,7 @@ const accountName = computed(() => {
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '320px' : '0px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
}))
const leftNavItems = computed(() => {
const items = [
@@ -436,7 +436,7 @@ function submitGlobalSearch() {
.appShell {
min-height: 100dvh;
display: grid;
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 325px);
background: rgba(14, 14, 14, 0.96);
color: rgba(255, 255, 255, 0.92);
transition: grid-template-columns 220ms ease;

View 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

View 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

View File

@@ -77,15 +77,16 @@ 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);
width: min(100%, 300px);
min-height: 600px;
margin: 0 auto;
}
.rightRailAd__slot {
width: 100%;
min-height: 490px;
display: block;
width: 300px;
max-width: 100%;
min-height: 600px;
margin: 0 auto;
}
</style>

View File

@@ -22,6 +22,8 @@ const games = ref([])
const selectedGameId = ref('')
const selectedGame = ref(null)
const featuredGameIds = ref([])
const gameAdminQuery = ref('')
const gameAdminSort = ref('recent')
const customItems = ref([])
const customItemQuery = ref('')
@@ -104,6 +106,19 @@ const featuredGames = computed(() =>
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const filteredAdminGames = computed(() => {
const query = gameAdminQuery.value.trim().toLowerCase()
const list = games.value.filter((game) => {
if (!query) return true
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
return haystack.includes(query)
})
return list.slice().sort((a, b) => {
if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -270,6 +285,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 {
@@ -335,6 +393,12 @@ async function handleSelectedGameChange(event) {
await loadGame()
}
async function selectAdminGame(gameId) {
if (!gameId || selectedGameId.value === gameId) return
selectedGameId.value = gameId
await loadGame()
}
async function refreshGames() {
try {
const data = await api.listGames()
@@ -1987,10 +2051,25 @@ async function saveFeaturedOrder() {
<div class="adminSidebar__label">Game</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
<select v-model="gameAdminSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
<div class="adminGamePicker">
<button
v-for="game in filteredAdminGames"
:key="game.id"
class="adminGamePicker__item"
:class="{ 'adminGamePicker__item--active': selectedGameId === game.id }"
type="button"
@click="selectAdminGame(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
</button>
<div v-if="!filteredAdminGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
<div v-if="hasSelectedGame" class="adminSidebar__group">
@@ -2100,8 +2179,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 +2377,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%;
}
@@ -2299,6 +2406,39 @@ async function saveFeaturedOrder() {
font-weight: 800;
color: rgba(255, 255, 255, 0.84);
}
.adminGamePicker {
display: grid;
gap: 8px;
max-height: 320px;
overflow: auto;
padding-right: 4px;
}
.adminGamePicker__item {
display: grid;
gap: 2px;
padding: 11px 12px;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
}
.adminGamePicker__item--active {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminGamePicker__name {
font-size: 13px;
font-weight: 800;
}
.adminGamePicker__meta {
font-size: 11px;
color: rgba(255, 255, 255, 0.56);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebarStat {
display: grid;
gap: 4px;

View File

@@ -222,8 +222,7 @@ function submitSearch() {
}
.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;
}

View File

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

View File

@@ -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'
@@ -48,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('')
@@ -337,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,
@@ -381,6 +395,24 @@ async function removeColumn(columnIndex) {
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)
@@ -417,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]
}
@@ -551,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'), {
@@ -774,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
@@ -782,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
@@ -865,12 +905,17 @@ 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__dropGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
<div v-if="columns.length > 1" class="previewOnly__columnLabel">{{ column.name || '' }}</div>
<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" />
@@ -999,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>
@@ -1017,7 +1075,7 @@ onUnmounted(() => {
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
/ 이름 순서 바꾸고 아이템 드래그해서 배치할 있어요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
@@ -1034,8 +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="btn btn--ghost" @click="addColumn"> 추가</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>
@@ -1055,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">
@@ -1069,19 +1147,16 @@ onUnmounted(() => {
class="rowRemoveText"
type="button"
title="행 삭제"
aria-label=" 삭제"
:disabled="groups.length <= 1"
@click="openGroupDeleteModal(g.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 v-if="!isExporting && columns.length > 1" class="columnHeader">
<input v-model="column.name" class="columnName" maxlength="16" placeholder="열 이름" />
<button class="columnRemoveText" type="button" :disabled="columns.length <= 1" @click="removeColumn(columnIndex)"> 삭제</button>
</div>
<div
class="row__drop"
:data-list-type="'group'"
@@ -1333,6 +1408,24 @@ 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;
@@ -1351,13 +1444,6 @@ onUnmounted(() => {
display: grid;
gap: 8px;
}
.previewOnly__columnLabel {
min-height: 20px;
font-size: 12px;
font-weight: 800;
text-align: center;
opacity: 0.72;
}
.previewOnly__label {
display: grid;
place-items: center;
@@ -1665,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;
@@ -1743,7 +1874,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
padding: 14px 12px 30px;
padding: 14px 28px;
font-weight: 900;
overflow: hidden;
}
@@ -1758,9 +1889,11 @@ onUnmounted(() => {
min-width: 0;
}
.columnHeader {
position: relative;
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 28px;
}
.columnName {
width: 100%;
@@ -1771,6 +1904,7 @@ onUnmounted(() => {
padding: 4px 0;
text-align: center;
font-size: 12px;
line-height: 1.2;
font-weight: 800;
outline: none;
}
@@ -1778,14 +1912,28 @@ onUnmounted(() => {
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;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
font-size: 11px;
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;
@@ -1819,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: 11px;
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;
@@ -2265,10 +2419,18 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.previewOnly__row {
.previewOnly__columns,
.previewOnly__row,
.boardColumnsHeader,
.row {
grid-template-columns: 1fr;
}
.previewOnly__dropGrid {
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.pool {