Compare commits

..

5 Commits

8 changed files with 580 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
# 할 일 및 이슈
## 중기 개선
- 현재 다크 톤 기준 UI를 유지하되, 다음 단계에서 라이트모드/다크모드 전환 구조를 설계하고 테마 토글을 추가한다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
@@ -8,3 +9,4 @@
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.

View File

@@ -1,5 +1,24 @@
# 업데이트 로그
## 2026-04-01 v1.3.30
- 헤더의 `Tier Maker` 로고는 레인보우 그라데이션 텍스트로 바꿔 서비스 첫인상이 더 또렷하게 보이도록 정리하고, `by zenn`은 새 창으로 프로필 페이지를 여는 외부 링크로 연결함.
- 다음 단계 작업용으로 라이트모드/다크모드 전환 항목을 todo 문서에 추가해, 현재의 다크 톤 UI를 유지하면서도 이후 테마 확장 흐름을 공식 작업 목록에 올림.
## 2026-04-01 v1.3.29
- 책 아이콘 사용법 모달 진입점은 항상 보이는 오른쪽 사이드 하단 버튼 대신, Settings 화면에서만 왼쪽 사이드 하단의 보조 액션 버튼으로 옮겨 더 필요할 때만 찾게 되는 문맥형 진입 방식으로 정리함.
- 인증 스토어에 초기 세션 동기화 완료 상태를 추가하고, 앱 셸·로그인 화면·프로필 화면은 세션 확인 전까지 비로그인 UI를 먼저 그리지 않도록 보강해 첫 진입 시 화면이 갑자기 로그인 상태로 뒤집히는 플래시를 줄임.
## 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 문서에서는 운영 정책/배포 체크 성격 항목을 우선 제거하고, 제품/보안 후속 작업 중심으로 다시 정리함.
- 관리자 게임 관리는 우측 셀렉트 박스 대신 검색 가능한 리스트와 최신순/오래된순 정렬로 바꿔, 게임 수가 많아져도 실제로 선택 가능한 구조로 개선함.

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,11 +25,14 @@ 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')
const isAdmin = computed(() => !!auth.user?.isAdmin)
const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
@@ -41,10 +45,13 @@ const accountName = computed(() => {
if (email) return email.split('@')[0] || email
return 'Guest'
})
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
const accountEmail = computed(() => {
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
return (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 = [
@@ -53,12 +60,75 @@ const leftNavItems = computed(() => {
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
]
return items.filter((item) => !item.requiresAuth || auth.user)
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
})
const showRightRailAction = computed(() => false)
const showSettingsGuideButton = computed(() => route.name === 'profile')
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(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
}
@@ -185,6 +255,10 @@ onMounted(async () => {
})
function handleGlobalKeydown(event) {
if (event.key === 'Escape' && isGuideModalOpen.value) {
closeGuideModal()
return
}
if (event.key === 'Escape' && isCollapsedSearchOpen.value) {
closeCollapsedSearch()
}
@@ -202,6 +276,7 @@ watch(
() => {
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
isCollapsedSearchOpen.value = false
isGuideModalOpen.value = false
}
)
@@ -262,6 +337,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()
@@ -309,7 +407,7 @@ function submitGlobalSearch() {
<div class="leftRail__body">
<div class="leftRail__content">
<div v-if="auth.user" class="appUserCard">
<div v-if="authReady && auth.user" class="appUserCard">
<div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
@@ -350,8 +448,12 @@ function submitGlobalSearch() {
</div>
<div class="leftRail__bottom">
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
<span>가이드 보기</span>
</button>
<RouterLink v-if="authReady && isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="authReady && !auth.user" to="/login" class="adminButton">로그인</RouterLink>
</div>
</div>
</aside>
@@ -361,7 +463,15 @@ function submitGlobalSearch() {
<header class="workspaceHead railHeader">
<div class="workspaceHead__brand" @click="$router.push('/')">
<span class="workspaceHead__brandTitle">Tier Maker</span>
<span class="workspaceHead__brandSub">by zenn</span>
<a
class="workspaceHead__brandSub"
href="https://zenn.town/@murabito"
target="_blank"
rel="noreferrer"
@click.stop
>
by zenn
</a>
</div>
<div class="workspaceHead__actions">
<div v-if="showGameHubViewToggle" class="viewToggle" role="group" aria-label="티어표 보기 방식">
@@ -392,6 +502,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">
@@ -436,7 +606,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;
@@ -805,6 +975,7 @@ function submitGlobalSearch() {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
@@ -815,6 +986,14 @@ function submitGlobalSearch() {
font-weight: 800;
}
.adminButton--icon {
text-align: center;
}
.adminButton__icon {
flex: 0 0 auto;
}
.appMain {
min-width: 0;
min-height: 0;
@@ -857,12 +1036,22 @@ function submitGlobalSearch() {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.05em;
background-image: linear-gradient(90deg, #ff75c3 0%, #ffa647 20%, #ffe83f 40%, #9fff5b 60%, #70e2ff 80%, #cd93ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.workspaceHead__brandSub {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.58);
text-decoration: none;
transition: color 180ms ease, opacity 180ms ease;
}
.workspaceHead__brandSub:hover {
color: rgba(255, 255, 255, 0.92);
}
.workspaceHead__actions {
@@ -924,6 +1113,8 @@ function submitGlobalSearch() {
.rightRail__bottom {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 10px;
padding-top: 12px;
}
@@ -942,6 +1133,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 +1496,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 +1543,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

View File

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

View File

@@ -5,30 +5,40 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
status: 'idle',
hydrated: false,
}),
actions: {
async refresh() {
if (this.status === 'loading') return this.user
this.status = 'loading'
try {
const data = await api.me()
this.user = data.user
return this.user
} catch (error) {
this.user = null
return null
} finally {
this.status = 'idle'
this.hydrated = true
}
},
async signup(email, password) {
const user = await api.signup({ email, password })
this.user = user
this.hydrated = true
return user
},
async login(email, password) {
const user = await api.login({ email, password })
this.user = user
this.hydrated = true
return user
},
async logout() {
await api.logout()
this.user = null
this.hydrated = true
},
},
})

View File

@@ -30,8 +30,15 @@ const description = computed(() =>
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
)
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
const authReady = computed(() => auth.hydrated)
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (auth.user) {
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
return
}
try {
const meta = await api.authMeta()
hasUsers.value = !!meta.hasUsers
@@ -40,6 +47,15 @@ onMounted(async () => {
}
})
watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
},
{ immediate: true }
)
async function submit() {
error.value = ''
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
@@ -66,7 +82,11 @@ async function submit() {
</div>
</header>
<section class="authScreen">
<section v-if="checkingSession" class="authScreen authScreen--loading">
<div class="authLoading">로그인 상태를 확인하고 있어요.</div>
</section>
<section v-else class="authScreen">
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
로그인
@@ -128,6 +148,16 @@ async function submit() {
padding-top: 4px;
}
.authScreen--loading {
min-height: 220px;
align-items: center;
}
.authLoading {
color: rgba(255, 255, 255, 0.62);
font-size: 15px;
}
.authTabs {
display: inline-flex;
gap: 8px;

View File

@@ -30,14 +30,19 @@ const avatarUrl = computed(() => {
return toApiUrl(auth.user.avatarSrc)
})
const authReady = computed(() => auth.hydrated)
const displayInitial = computed(() => {
const email = auth.user?.email || 'U'
return email[0].toUpperCase()
})
onMounted(async () => {
await auth.refresh()
if (!auth.user) router.push('/login')
if (!auth.hydrated) await auth.refresh()
if (!auth.user) {
router.replace('/login')
return
}
nickname.value = auth.user?.nickname || ''
removeAvatar.value = false
})
@@ -121,7 +126,11 @@ async function logout() {
</div>
</header>
<section v-if="auth.user" class="settingsScreen">
<section v-if="!authReady" class="settingsScreen settingsScreen--loading">
<div class="settingsLoading">계정 정보를 불러오고 있어요.</div>
</section>
<section v-else-if="auth.user" class="settingsScreen">
<div class="settingsIdentity">
<div class="avatarButtonWrap">
<button class="avatarButton" type="button" @click="openAvatarPicker">
@@ -185,6 +194,16 @@ async function logout() {
padding-top: 4px;
}
.settingsScreen--loading {
min-height: 240px;
align-items: center;
}
.settingsLoading {
color: rgba(255, 255, 255, 0.62);
font-size: 15px;
}
.settingsIdentity {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);