릴리스: v1.3.27 사용법 모달 구조 추가

This commit is contained in:
2026-04-01 14:52:50 +09:00
parent a550385ed8
commit 91e16ba415
4 changed files with 437 additions and 0 deletions

View File

@@ -8,3 +8,4 @@
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 구조를 먼저 붙였으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.

View File

@@ -1,5 +1,9 @@
# 업데이트 로그
## 2026-04-01 v1.3.27
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
## 2026-04-01 v1.3.26
- 오른쪽 사이드는 실제 광고 슬롯 기준을 300x600 세로 비율로 잡고, 데스크톱 우측 레일 폭도 325px로 조정해 300px 광고가 내부 패딩과 보더를 제외한 실폭 안에 자연스럽게 들어가도록 보정함.

View File

@@ -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,35 @@ 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: '저장, 이미지 다운로드, 템플릿 요청까지 마무리합니다.',
description: '완성한 보드는 내 티어표로 저장하거나 PNG 이미지로 내려받을 수 있습니다. 공통 템플릿으로 쓰면 좋겠다면 템플릿 요청을 보내 관리자에게 추가를 제안할 수도 있어요.',
},
]
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 +217,10 @@ onMounted(async () => {
})
function handleGlobalKeydown(event) {
if (event.key === 'Escape' && isGuideModalOpen.value) {
closeGuideModal()
return
}
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
closeCollapsedSearch()
}
@@ -202,6 +238,7 @@ watch(
() => {
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
isCollapsedSearchOpen.value = false
isGuideModalOpen.value = false
}
)
@@ -262,6 +299,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 +452,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">STEP {{ 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 +535,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 +1047,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 +1086,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 +1449,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 +1496,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;

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="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