Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a64dc44c8 | |||
| 91e16ba415 | |||
| a550385ed8 | |||
| 5b53c73b56 | |||
| 7952f2f289 | |||
| b851100c89 |
@@ -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) => {
|
||||
|
||||
19
docs/todo.md
19
docs/todo.md
@@ -1,20 +1,11 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 레거시 파일 정리 스크립트는 준비됐으므로, 운영 단계에서는 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 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-01 v1.3.28
|
||||
- 책 아이콘 기반 사용법 모달은 기존의 단순 제작 흐름 안내를 넘어, 다른 사람 티어표 복사, 템플릿 업그레이드 요청, 새 템플릿 추가 요청, 즐겨찾기/내 티어표 관리까지 포함한 전체 기능 안내 허브로 확장함.
|
||||
- 사용법 모달 제목과 단계 표기를 더 넓은 개념의 `기능 안내` 기준으로 정리하고, 실제 스크린샷이 없어도 설명만으로 핵심 기능을 순서대로 이해할 수 있게 단계 문구를 전면 보강함.
|
||||
|
||||
## 2026-04-01 v1.3.27
|
||||
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
|
||||
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
|
||||
|
||||
## 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 텍스트가 카드 폭을 밀지 않도록 플레이스홀더로 즉시 대체해 카드 수와 헤더 폭이 흔들리지 않게 보정함.
|
||||
- 오른쪽 사이드 광고 프레임은 별도 보더·패딩·배경을 제거해, 광고 자체가 가진 각진 형태와 색이 그대로 보이도록 더 담백하게 정리함.
|
||||
|
||||
@@ -11,6 +11,7 @@ import iconFavorite from './assets/icons/favorite.svg'
|
||||
import iconLists from './assets/icons/lists.svg'
|
||||
import iconSearch from './assets/icons/search.svg'
|
||||
import iconSettings from './assets/icons/settings.svg'
|
||||
import iconMenuBook from './assets/icons/menu_book.svg'
|
||||
import RightRailAd from './components/RightRailAd.vue'
|
||||
import SvgIcon from './components/SvgIcon.vue'
|
||||
|
||||
@@ -24,6 +25,8 @@ const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const guideStepIndex = ref(0)
|
||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
@@ -44,7 +47,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 = [
|
||||
@@ -56,6 +59,67 @@ const leftNavItems = computed(() => {
|
||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||
})
|
||||
const showRightRailAction = computed(() => false)
|
||||
const guideSteps = [
|
||||
{
|
||||
id: 'select-game',
|
||||
title: '게임 또는 양식 선택',
|
||||
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||
description:
|
||||
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||
},
|
||||
{
|
||||
id: 'arrange-board',
|
||||
title: '행과 열 구성',
|
||||
summary: '랭크 행과 가로 열을 정리해 보드 구조를 먼저 잡습니다.',
|
||||
description:
|
||||
'기본 랭크를 그대로 써도 되고, 행 이름을 바꾸거나 행과 열을 추가해 공격·방어·지원처럼 더 세밀한 구조로 나눌 수도 있어요. 먼저 판을 정리한 뒤 배치를 시작하면 뒤에서 크게 손댈 일이 줄어듭니다.',
|
||||
},
|
||||
{
|
||||
id: 'drop-items',
|
||||
title: '아이템 배치와 커스텀 추가',
|
||||
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||
description:
|
||||
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||
},
|
||||
{
|
||||
id: 'save-share',
|
||||
title: '저장과 이미지 다운로드',
|
||||
summary: '완성한 티어표를 내 목록에 저장하거나 PNG 이미지로 내려받습니다.',
|
||||
description:
|
||||
'보드 작업이 끝나면 저장해서 내 티어표 목록에 남길 수 있고, 이미지 다운로드로 한 장의 결과물로 바로 공유할 수도 있어요. 공개 여부도 함께 정할 수 있어서 개인 메모용과 공유용 흐름을 나눠 쓰기 좋습니다.',
|
||||
},
|
||||
{
|
||||
id: 'copy-existing',
|
||||
title: '다른 사람 티어표 복사',
|
||||
summary: '공개된 티어표를 그대로 가져와 내 이름의 새 작업본으로 이어서 수정합니다.',
|
||||
description:
|
||||
'누군가 만든 티어표가 거의 마음에 드는데 일부만 바꾸고 싶다면, 복사 기능으로 현재 배치 상태를 그대로 가져와 새 티어표로 시작할 수 있어요. 복사본에는 원본을 참고했다는 정보가 함께 남아서 출처도 자연스럽게 구분됩니다.',
|
||||
},
|
||||
{
|
||||
id: 'request-template-update',
|
||||
title: '템플릿 업그레이드 요청',
|
||||
summary: '현재 게임 템플릿에 공통 아이템을 추가해 달라고 관리자에게 요청합니다.',
|
||||
description:
|
||||
'직접 추가한 아이템 중 여러 사람이 함께 써도 좋을 것 같은 항목이 있다면 템플릿 업데이트 요청을 보낼 수 있어요. 요청 모달에서는 현재 티어표 제목과 설명을 기본값으로 가져오고, 필요하면 요청 제목과 설명을 더 다듬어 공통 템플릿에 왜 필요한지 설명할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
id: 'request-new-template',
|
||||
title: '새 템플릿 추가 요청',
|
||||
summary: '아직 없는 게임이나 새로운 양식을 관리자에게 제안합니다.',
|
||||
description:
|
||||
'원하는 게임 템플릿이 아직 없다면 새 템플릿 추가 요청으로 관리자에게 직접 제안할 수 있어요. 이때는 제목과 설명에 어떤 게임인지, 어떤 캐릭터나 항목이 기본으로 필요할지 적어두면 검토 속도가 훨씬 빨라집니다.',
|
||||
},
|
||||
{
|
||||
id: 'manage-library',
|
||||
title: '즐겨찾기와 내 티어표 관리',
|
||||
summary: '마음에 드는 템플릿과 저장한 결과물을 나중에 다시 쉽게 찾습니다.',
|
||||
description:
|
||||
'게임 템플릿은 즐겨찾기로 상단에 고정해둘 수 있고, 저장한 보드는 내 티어표에서 다시 열어 이어서 수정할 수 있어요. 자주 보는 템플릿, 공개 티어표, 내가 만든 결과물을 각각 다른 화면에서 정리해두면 이후 작업이 훨씬 빨라집니다.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0)
|
||||
const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1)
|
||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
@@ -185,6 +249,10 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
if (event.key === 'Escape' && isGuideModalOpen.value) {
|
||||
closeGuideModal()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
|
||||
closeCollapsedSearch()
|
||||
}
|
||||
@@ -202,6 +270,7 @@ watch(
|
||||
() => {
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
isCollapsedSearchOpen.value = false
|
||||
isGuideModalOpen.value = false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -262,6 +331,29 @@ function closeCollapsedSearch() {
|
||||
isCollapsedSearchOpen.value = false
|
||||
}
|
||||
|
||||
function openGuideModal(stepIndex = 0) {
|
||||
guideStepIndex.value = Math.min(Math.max(Number(stepIndex) || 0, 0), guideSteps.length - 1)
|
||||
isGuideModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeGuideModal() {
|
||||
isGuideModalOpen.value = false
|
||||
}
|
||||
|
||||
function selectGuideStep(index) {
|
||||
guideStepIndex.value = Math.min(Math.max(index, 0), guideSteps.length - 1)
|
||||
}
|
||||
|
||||
function showPrevGuideStep() {
|
||||
if (isGuidePrevDisabled.value) return
|
||||
guideStepIndex.value -= 1
|
||||
}
|
||||
|
||||
function showNextGuideStep() {
|
||||
if (isGuideNextDisabled.value) return
|
||||
guideStepIndex.value += 1
|
||||
}
|
||||
|
||||
function handleLeftRailSearch() {
|
||||
if (leftRailCollapsed.value && !isMobileLayout.value) {
|
||||
openCollapsedSearch()
|
||||
@@ -392,6 +484,66 @@ function submitGlobalSearch() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="isGuideModalOpen" class="guideModal" role="dialog" aria-modal="true" aria-label="티어 메이커 기능 안내" @click.self="closeGuideModal">
|
||||
<div class="guideModal__dialog">
|
||||
<div class="guideModal__sidebar">
|
||||
<div class="guideModal__eyebrow">Guide</div>
|
||||
<div class="guideModal__title">티어 메이커 기능 안내</div>
|
||||
<div class="guideModal__list">
|
||||
<button
|
||||
v-for="(step, index) in guideSteps"
|
||||
:key="step.id"
|
||||
class="guideModal__listItem"
|
||||
:class="{ 'guideModal__listItem--active': index === guideStepIndex }"
|
||||
type="button"
|
||||
@click="selectGuideStep(index)"
|
||||
>
|
||||
<span class="guideModal__listIndex">{{ index + 1 }}</span>
|
||||
<span class="guideModal__listLabel">{{ step.title }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guideModal__main">
|
||||
<button class="guideModal__close" type="button" aria-label="사용법 닫기" @click="closeGuideModal">닫기</button>
|
||||
<div class="guideModal__content">
|
||||
<button class="guideModal__arrow" type="button" aria-label="이전 단계" :disabled="isGuidePrevDisabled" @click="showPrevGuideStep">‹</button>
|
||||
<div class="guideModal__body">
|
||||
<div class="guideModal__media">
|
||||
<div class="guideModal__mediaPlaceholder">
|
||||
<div class="guideModal__mediaBadge">16:9</div>
|
||||
<div class="guideModal__mediaTitle">{{ currentGuideStep.title }}</div>
|
||||
<div class="guideModal__mediaHint">스크린샷 준비 중</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guideModal__text">
|
||||
<div class="guideModal__stepLabel">GUIDE {{ guideStepIndex + 1 }}</div>
|
||||
<div class="guideModal__stepTitle">{{ currentGuideStep.title }}</div>
|
||||
<div class="guideModal__stepSummary">{{ currentGuideStep.summary }}</div>
|
||||
<p class="guideModal__stepDescription">{{ currentGuideStep.description }}</p>
|
||||
</div>
|
||||
<div class="guideModal__footer">
|
||||
<div class="guideModal__pagination">
|
||||
<button
|
||||
v-for="(step, index) in guideSteps"
|
||||
:key="step.id + '-dot'"
|
||||
class="guideModal__dot"
|
||||
:class="{ 'guideModal__dot--active': index === guideStepIndex }"
|
||||
type="button"
|
||||
:aria-label="step.title"
|
||||
@click="selectGuideStep(index)"
|
||||
></button>
|
||||
</div>
|
||||
<button class="guideModal__next" type="button" @click="isGuideNextDisabled ? closeGuideModal() : showNextGuideStep()">
|
||||
{{ isGuideNextDisabled ? '닫기' : '다음' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="guideModal__arrow" type="button" aria-label="다음 단계" :disabled="isGuideNextDisabled" @click="showNextGuideStep">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button v-if="rightRailOpen && isRightRailOverlay" class="rightRailBackdrop" type="button" aria-label="오른쪽 패널 닫기" @click="toggleRightRail"></button>
|
||||
|
||||
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen, 'rightRail--overlay': isRightRailOverlay }" :aria-hidden="!rightRailOpen">
|
||||
@@ -415,6 +567,9 @@ function submitGlobalSearch() {
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<button class="guideDockButton" type="button" aria-label="사용법 열기" @click="openGuideModal()">
|
||||
<SvgIcon :src="iconMenuBook" :size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -436,7 +591,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;
|
||||
@@ -924,9 +1079,30 @@ function submitGlobalSearch() {
|
||||
.rightRail__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.guideDockButton {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.guideDockButton:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.rightRailAction__button {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
@@ -942,6 +1118,240 @@ function submitGlobalSearch() {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 36;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.guideModal__dialog {
|
||||
width: min(1180px, calc(100vw - 40px));
|
||||
min-height: min(760px, calc(100dvh - 64px));
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: linear-gradient(180deg, rgba(34, 34, 34, 0.98), rgba(18, 18, 18, 0.98));
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.guideModal__sidebar {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
padding: 28px 22px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.guideModal__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.guideModal__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guideModal__listItem {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.guideModal__listItem--active {
|
||||
border-color: rgba(77, 127, 233, 0.5);
|
||||
background: rgba(77, 127, 233, 0.14);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.guideModal__listIndex {
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.guideModal__listLabel {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.guideModal__main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 24px 28px 28px;
|
||||
}
|
||||
|
||||
.guideModal__close {
|
||||
justify-self: end;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.guideModal__content {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) 52px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.guideModal__body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.guideModal__media {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guideModal__mediaPlaceholder {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guideModal__mediaBadge {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
}
|
||||
|
||||
.guideModal__mediaTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.guideModal__mediaHint {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
}
|
||||
|
||||
.guideModal__text {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guideModal__stepLabel {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.guideModal__stepTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.guideModal__stepSummary {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.guideModal__stepDescription {
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.guideModal__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.guideModal__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guideModal__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guideModal__dot--active {
|
||||
width: 26px;
|
||||
background: rgba(77, 127, 233, 0.9);
|
||||
}
|
||||
|
||||
.guideModal__next {
|
||||
padding: 12px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(77, 127, 233, 0.96);
|
||||
background: rgba(77, 127, 233, 0.88);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guideModal__arrow {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guideModal__arrow:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.collapsedSearchModal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1071,6 +1481,20 @@ function submitGlobalSearch() {
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.guideModal__dialog {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.guideModal__sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.guideModal__content {
|
||||
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
||||
}
|
||||
|
||||
.appShell {
|
||||
grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr);
|
||||
}
|
||||
@@ -1104,6 +1528,45 @@ function submitGlobalSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.guideModal {
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
.guideModal__dialog {
|
||||
width: min(100%, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.guideModal__main {
|
||||
padding: 20px 18px 18px;
|
||||
}
|
||||
|
||||
.guideModal__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.guideModal__arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.guideModal__next {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guideDockButton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guideModal__list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.appShell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
1
frontend/src/assets/icons/menu_book.svg
Normal file
1
frontend/src/assets/icons/menu_book.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="M560-564v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-600q-38 0-73 9.5T560-564Zm0 220v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-380q-38 0-73 9t-67 27Zm0-110v-68q33-14 67.5-21t72.5-7q26 0 51 4t49 10v64q-24-9-48.5-13.5T700-490q-38 0-73 9.5T560-454ZM260-320q47 0 91.5 10.5T440-278v-394q-41-24-87-36t-93-12q-36 0-71.5 7T120-692v396q35-12 69.5-18t70.5-6Zm260 42q44-21 88.5-31.5T700-320q36 0 70.5 6t69.5 18v-396q-33-14-68.5-21t-71.5-7q-47 0-93 12t-87 36v394Zm-40 118q-48-38-104-59t-116-21q-42 0-82.5 11T100-198q-21 11-40.5-1T40-234v-482q0-11 5.5-21T62-752q46-24 96-36t102-12q58 0 113.5 15T480-740q51-30 106.5-45T700-800q52 0 102 12t96 36q11 5 16.5 15t5.5 21v482q0 23-19.5 35t-40.5 1q-37-20-77.5-31T700-240q-60 0-116 21t-104 59ZM280-494Z"/></svg>
|
||||
|
After Width: | Height: | Size: 895 B |
@@ -77,11 +77,16 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.rightRailAd__frame {
|
||||
min-height: 520px;
|
||||
width: min(100%, 300px);
|
||||
min-height: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.rightRailAd__slot {
|
||||
width: 100%;
|
||||
min-height: 520px;
|
||||
display: block;
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
min-height: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 '목록 관리'
|
||||
@@ -378,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()
|
||||
@@ -2030,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">
|
||||
@@ -2370,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -149,8 +149,7 @@ 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 {
|
||||
@@ -283,7 +282,17 @@ function openList(t) {
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user