Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a550385ed8 | |||
| 5b53c73b56 | |||
| 7952f2f289 | |||
| b851100c89 | |||
| e70e685a06 | |||
| 09acebc2d5 | |||
| e3391b5f07 |
@@ -26,6 +26,20 @@ const profileSchema = z.object({
|
|||||||
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
|
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) {
|
async function serializeUser(user) {
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
const primaryAdmin = await findPrimaryAdminUser()
|
const primaryAdmin = await findPrimaryAdminUser()
|
||||||
@@ -56,12 +70,12 @@ router.post('/signup', async (req, res) => {
|
|||||||
const isAdmin = (await countUsers()) === 0
|
const isAdmin = (await countUsers()) === 0
|
||||||
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin })
|
||||||
|
|
||||||
req.session.userId = user.id
|
try {
|
||||||
req.session.isAdmin = !!user.isAdmin
|
await establishSession(req, user)
|
||||||
req.session.save(async (err) => {
|
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
|
||||||
res.json(await serializeUser(user))
|
res.json(await serializeUser(user))
|
||||||
})
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: 'session_save_failed' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
@@ -75,12 +89,12 @@ router.post('/login', async (req, res) => {
|
|||||||
const ok = await bcrypt.compare(password, user.passwordHash)
|
const ok = await bcrypt.compare(password, user.passwordHash)
|
||||||
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
|
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
|
||||||
|
|
||||||
req.session.userId = user.id
|
try {
|
||||||
req.session.isAdmin = !!user.isAdmin
|
await establishSession(req, user)
|
||||||
req.session.save(async (err) => {
|
|
||||||
if (err) return res.status(500).json({ error: 'session_save_failed' })
|
|
||||||
res.json(await serializeUser(user))
|
res.json(await serializeUser(user))
|
||||||
})
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: 'session_save_failed' })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
|
|||||||
18
docs/todo.md
18
docs/todo.md
@@ -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를 추가한다.
|
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||||
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
|
||||||
|
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
||||||
|
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||||
|
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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
|
## 2026-04-01 v1.3.19
|
||||||
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
|
- 관리자 Image Optimization 기간 선택은 연도/월을 가로로 나란히 두고, 연도를 고르기 전에는 월 셀렉트를 숨겨 비어 있는 박스처럼 보이던 상태를 없앰.
|
||||||
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
|
- 전체 초기화 버튼도 실제 월이 선택된 경우에만 보이도록 정리해, 사이드바 상단 필터 줄이 더 단정하게 보이도록 보정함.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const accountName = computed(() => {
|
|||||||
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
||||||
const shellStyle = computed(() => ({
|
const shellStyle = computed(() => ({
|
||||||
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
|
'--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 leftNavItems = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
@@ -436,7 +436,7 @@ function submitGlobalSearch() {
|
|||||||
.appShell {
|
.appShell {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
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);
|
background: rgba(14, 14, 14, 0.96);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
transition: grid-template-columns 220ms ease;
|
transition: grid-template-columns 220ms ease;
|
||||||
|
|||||||
@@ -77,15 +77,16 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rightRailAd__frame {
|
.rightRailAd__frame {
|
||||||
min-height: 520px;
|
width: min(100%, 300px);
|
||||||
padding: 14px;
|
min-height: 600px;
|
||||||
border-radius: 20px;
|
margin: 0 auto;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rightRailAd__slot {
|
.rightRailAd__slot {
|
||||||
width: 100%;
|
display: block;
|
||||||
min-height: 490px;
|
width: 300px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const games = ref([])
|
|||||||
const selectedGameId = ref('')
|
const selectedGameId = ref('')
|
||||||
const selectedGame = ref(null)
|
const selectedGame = ref(null)
|
||||||
const featuredGameIds = ref([])
|
const featuredGameIds = ref([])
|
||||||
|
const gameAdminQuery = ref('')
|
||||||
|
const gameAdminSort = ref('recent')
|
||||||
|
|
||||||
const customItems = ref([])
|
const customItems = ref([])
|
||||||
const customItemQuery = ref('')
|
const customItemQuery = ref('')
|
||||||
@@ -104,6 +106,19 @@ const featuredGames = computed(() =>
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
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 importModalItemCount = computed(() => importModalItems.value.length)
|
||||||
const activeTabTitle = computed(() => {
|
const activeTabTitle = computed(() => {
|
||||||
if (activeTab.value === 'featured') return '목록 관리'
|
if (activeTab.value === 'featured') return '목록 관리'
|
||||||
@@ -378,6 +393,12 @@ async function handleSelectedGameChange(event) {
|
|||||||
await loadGame()
|
await loadGame()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectAdminGame(gameId) {
|
||||||
|
if (!gameId || selectedGameId.value === gameId) return
|
||||||
|
selectedGameId.value = gameId
|
||||||
|
await loadGame()
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshGames() {
|
async function refreshGames() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
@@ -2030,10 +2051,25 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="adminSidebar__label">Game</div>
|
<div class="adminSidebar__label">Game</div>
|
||||||
<div class="adminSidebar__group">
|
<div class="adminSidebar__group">
|
||||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
||||||
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
|
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
|
||||||
<option value="">게임을 선택해주세요</option>
|
<select v-model="gameAdminSort" class="select">
|
||||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
<option value="recent">최신순</option>
|
||||||
|
<option value="oldest">오래된순</option>
|
||||||
</select>
|
</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 v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
||||||
@@ -2370,6 +2406,39 @@ async function saveFeaturedOrder() {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: rgba(255, 255, 255, 0.84);
|
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 {
|
.sidebarStat {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -222,8 +222,7 @@ function submitSearch() {
|
|||||||
}
|
}
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user