@@ -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' )
@@ -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 >
@@ -924,9 +1079,30 @@ function submitGlobalSearch() {
. rightRail _ _bottom {
display : flex ;
align - items : flex - end ;
justify - content : flex - end ;
gap : 10 px ;
padding - top : 12 px ;
}
. guideDockButton {
width : 42 px ;
height : 42 px ;
border - radius : 14 px ;
border : 1 px 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 : 12 px 14 px ;
@@ -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 : 32 px 20 px ;
background : rgba ( 0 , 0 , 0 , 0.62 ) ;
backdrop - filter : blur ( 10 px ) ;
}
. guideModal _ _dialog {
width : min ( 1180 px , calc ( 100 vw - 40 px ) ) ;
min - height : min ( 760 px , calc ( 100 dvh - 64 px ) ) ;
display : grid ;
grid - template - columns : 260 px minmax ( 0 , 1 fr ) ;
border - radius : 28 px ;
overflow : hidden ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ) ;
background : linear - gradient ( 180 deg , rgba ( 34 , 34 , 34 , 0.98 ) , rgba ( 18 , 18 , 18 , 0.98 ) ) ;
box - shadow : 0 28 px 90 px rgba ( 0 , 0 , 0 , 0.42 ) ;
}
. guideModal _ _sidebar {
display : grid ;
align - content : start ;
gap : 18 px ;
padding : 28 px 22 px ;
background : rgba ( 255 , 255 , 255 , 0.03 ) ;
border - right : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
}
. guideModal _ _eyebrow {
font - size : 11 px ;
letter - spacing : 0.14 em ;
text - transform : uppercase ;
color : rgba ( 255 , 255 , 255 , 0.4 ) ;
}
. guideModal _ _title {
font - size : 28 px ;
font - weight : 900 ;
line - height : 1.1 ;
letter - spacing : - 0.04 em ;
}
. guideModal _ _list {
display : grid ;
gap : 8 px ;
}
. guideModal _ _listItem {
display : grid ;
grid - template - columns : 26 px minmax ( 0 , 1 fr ) ;
gap : 10 px ;
align - items : center ;
padding : 12 px 14 px ;
border - radius : 16 px ;
border : 1 px 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 : 12 px ;
font - weight : 900 ;
color : rgba ( 255 , 255 , 255 , 0.54 ) ;
}
. guideModal _ _listLabel {
min - width : 0 ;
font - size : 14 px ;
font - weight : 700 ;
}
. guideModal _ _main {
min - width : 0 ;
display : grid ;
grid - template - rows : auto minmax ( 0 , 1 fr ) ;
padding : 24 px 28 px 28 px ;
}
. guideModal _ _close {
justify - self : end ;
border : 0 ;
background : transparent ;
color : rgba ( 255 , 255 , 255 , 0.56 ) ;
cursor : pointer ;
font - size : 13 px ;
}
. guideModal _ _content {
min - width : 0 ;
display : grid ;
grid - template - columns : 52 px minmax ( 0 , 1 fr ) 52 px ;
gap : 16 px ;
align - items : center ;
}
. guideModal _ _body {
min - width : 0 ;
display : grid ;
gap : 18 px ;
}
. guideModal _ _media {
width : 100 % ;
}
. guideModal _ _mediaPlaceholder {
position : relative ;
width : 100 % ;
aspect - ratio : 16 / 9 ;
border - radius : 24 px ;
border : 1 px 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 : 8 px ;
text - align : center ;
}
. guideModal _ _mediaBadge {
font - size : 11 px ;
letter - spacing : 0.16 em ;
text - transform : uppercase ;
color : rgba ( 255 , 255 , 255 , 0.38 ) ;
}
. guideModal _ _mediaTitle {
font - size : 24 px ;
font - weight : 900 ;
}
. guideModal _ _mediaHint {
font - size : 13 px ;
color : rgba ( 255 , 255 , 255 , 0.48 ) ;
}
. guideModal _ _text {
display : grid ;
gap : 8 px ;
}
. guideModal _ _stepLabel {
font - size : 11 px ;
letter - spacing : 0.14 em ;
text - transform : uppercase ;
color : rgba ( 255 , 255 , 255 , 0.42 ) ;
}
. guideModal _ _stepTitle {
font - size : 28 px ;
font - weight : 900 ;
letter - spacing : - 0.04 em ;
}
. guideModal _ _stepSummary {
font - size : 16 px ;
font - weight : 700 ;
color : rgba ( 255 , 255 , 255 , 0.86 ) ;
}
. guideModal _ _stepDescription {
margin : 0 ;
max - width : 720 px ;
line - height : 1.7 ;
color : rgba ( 255 , 255 , 255 , 0.62 ) ;
}
. guideModal _ _footer {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 18 px ;
}
. guideModal _ _pagination {
display : flex ;
align - items : center ;
gap : 8 px ;
}
. guideModal _ _dot {
width : 10 px ;
height : 10 px ;
border - radius : 999 px ;
border : 0 ;
background : rgba ( 255 , 255 , 255 , 0.18 ) ;
cursor : pointer ;
}
. guideModal _ _dot -- active {
width : 26 px ;
background : rgba ( 77 , 127 , 233 , 0.9 ) ;
}
. guideModal _ _next {
padding : 12 px 18 px ;
border - radius : 14 px ;
border : 1 px solid rgba ( 77 , 127 , 233 , 0.96 ) ;
background : rgba ( 77 , 127 , 233 , 0.88 ) ;
color : # fff ;
font - weight : 800 ;
cursor : pointer ;
}
. guideModal _ _arrow {
width : 52 px ;
height : 52 px ;
border - radius : 999 px ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
background : rgba ( 255 , 255 , 255 , 0.04 ) ;
color : rgba ( 255 , 255 , 255 , 0.92 ) ;
font - size : 28 px ;
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 : 1200 px ) {
. guideModal _ _dialog {
grid - template - columns : 1 fr ;
min - height : auto ;
}
. guideModal _ _sidebar {
border - right : 0 ;
border - bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ) ;
}
. guideModal _ _content {
grid - template - columns : 40 px minmax ( 0 , 1 fr ) 40 px ;
}
. appShell {
grid - template - columns : var ( -- left - rail - width , 248 px ) minmax ( 0 , 1 fr ) ;
}
@@ -1104,6 +1528,45 @@ function submitGlobalSearch() {
}
}
@ media ( max - width : 860 px ) {
. guideModal {
padding : 20 px 12 px ;
}
. guideModal _ _dialog {
width : min ( 100 % , calc ( 100 vw - 24 px ) ) ;
}
. guideModal _ _main {
padding : 20 px 18 px 18 px ;
}
. guideModal _ _content {
grid - template - columns : 1 fr ;
}
. guideModal _ _arrow {
display : none ;
}
. guideModal _ _footer {
flex - direction : column ;
align - items : stretch ;
}
. guideModal _ _next {
width : 100 % ;
}
. guideDockButton {
display : none ;
}
. guideModal _ _list {
grid - template - columns : 1 fr 1 fr ;
}
}
@ media ( max - width : 860 px ) {
. appShell {
grid - template - columns : 1 fr ;