Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf96e931e9 | |||
| 3a64dc44c8 |
@@ -8,4 +8,4 @@
|
|||||||
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.
|
||||||
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
|
||||||
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
|
||||||
- 책 아이콘 기반 사용법 모달은 구조를 먼저 붙였으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-01 v1.3.29
|
||||||
|
- 책 아이콘 사용법 모달 진입점은 항상 보이는 오른쪽 사이드 하단 버튼 대신, Settings 화면에서만 왼쪽 사이드 하단의 보조 액션 버튼으로 옮겨 더 필요할 때만 찾게 되는 문맥형 진입 방식으로 정리함.
|
||||||
|
- 인증 스토어에 초기 세션 동기화 완료 상태를 추가하고, 앱 셸·로그인 화면·프로필 화면은 세션 확인 전까지 비로그인 UI를 먼저 그리지 않도록 보강해 첫 진입 시 화면이 갑자기 로그인 상태로 뒤집히는 플래시를 줄임.
|
||||||
|
|
||||||
|
## 2026-04-01 v1.3.28
|
||||||
|
- 책 아이콘 기반 사용법 모달은 기존의 단순 제작 흐름 안내를 넘어, 다른 사람 티어표 복사, 템플릿 업그레이드 요청, 새 템플릿 추가 요청, 즐겨찾기/내 티어표 관리까지 포함한 전체 기능 안내 허브로 확장함.
|
||||||
|
- 사용법 모달 제목과 단계 표기를 더 넓은 개념의 `기능 안내` 기준으로 정리하고, 실제 스크린샷이 없어도 설명만으로 핵심 기능을 순서대로 이해할 수 있게 단계 문구를 전면 보강함.
|
||||||
|
|
||||||
## 2026-04-01 v1.3.27
|
## 2026-04-01 v1.3.27
|
||||||
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
|
- 오른쪽 사이드 하단에 책 아이콘 진입점을 추가하고, 중앙 대형 사용법 모달을 열어 좌측 기능 리스트와 우측 16:9 설명 영역, 좌우 이동, 하단 페이지네이션까지 포함한 기본 가이드 흐름을 붙임.
|
||||||
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
|
- 사용법 모달의 스크린샷 영역은 우선 16:9 플레이스홀더와 설명 텍스트만 배치해, 실제 이미지 자산은 나중에 채워 넣을 수 있게 구조를 먼저 준비함.
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 14
|
|||||||
provide('rightRailOpen', rightRailOpen)
|
provide('rightRailOpen', rightRailOpen)
|
||||||
provide('localRightRailTarget', '#local-right-rail-root')
|
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 isPreviewMode = computed(() => route.query.preview === '1')
|
||||||
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
|
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
|
||||||
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
|
||||||
@@ -44,7 +45,10 @@ const accountName = computed(() => {
|
|||||||
if (email) return email.split('@')[0] || email
|
if (email) return email.split('@')[0] || email
|
||||||
return 'Guest'
|
return 'Guest'
|
||||||
})
|
})
|
||||||
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
const accountEmail = computed(() => {
|
||||||
|
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
|
||||||
|
return (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 ? '325px' : '0px',
|
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
|
||||||
@@ -56,33 +60,66 @@ const leftNavItems = computed(() => {
|
|||||||
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
{ key: 'favorites', label: 'Favorites', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||||
{ key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, 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 showRightRailAction = computed(() => false)
|
||||||
|
const showSettingsGuideButton = computed(() => route.name === 'profile')
|
||||||
const guideSteps = [
|
const guideSteps = [
|
||||||
{
|
{
|
||||||
id: 'select-game',
|
id: 'select-game',
|
||||||
title: '게임 또는 양식 선택',
|
title: '게임 또는 양식 선택',
|
||||||
summary: '사용할 게임 템플릿을 고르거나 커스텀 티어표를 시작합니다.',
|
summary: '게임 템플릿을 고르거나 커스텀 티어표 만들기로 바로 시작합니다.',
|
||||||
description: '홈 화면에서 게임 템플릿을 고르거나 커스텀 티어표 만들기로 시작할 수 있어요. 게임 허브에서는 기존 공개 티어표도 살펴본 뒤 같은 흐름으로 새 보드를 만들 수 있습니다.',
|
description:
|
||||||
|
'홈 화면에서는 게임 템플릿을 선택하거나 커스텀 티어표 만들기로 바로 새 보드를 열 수 있어요. 게임을 먼저 고르면 해당 게임의 공개 티어표도 같이 살펴볼 수 있어서, 완전히 처음 만드는지 기존 흐름을 참고할지 결정하기 쉽습니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'arrange-board',
|
id: 'arrange-board',
|
||||||
title: '행과 열 구성',
|
title: '행과 열 구성',
|
||||||
summary: '필요한 랭크와 열을 만들고 이름을 정리합니다.',
|
summary: '랭크 행과 가로 열을 정리해 보드 구조를 먼저 잡습니다.',
|
||||||
description: '기본 행을 수정하거나 행·열을 추가해서 원하는 구조를 먼저 잡아보세요. 공격, 방어, 지원처럼 가로 열을 나누고 각 행 이름을 짧게 정리하면 실제 배치가 훨씬 빨라집니다.',
|
description:
|
||||||
|
'기본 랭크를 그대로 써도 되고, 행 이름을 바꾸거나 행과 열을 추가해 공격·방어·지원처럼 더 세밀한 구조로 나눌 수도 있어요. 먼저 판을 정리한 뒤 배치를 시작하면 뒤에서 크게 손댈 일이 줄어듭니다.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'drop-items',
|
id: 'drop-items',
|
||||||
title: '아이템 배치',
|
title: '아이템 배치와 커스텀 추가',
|
||||||
summary: '프리셋 아이템과 커스텀 이미지를 드래그로 배치합니다.',
|
summary: '프리셋 아이템과 직접 올린 이미지를 드래그로 배치합니다.',
|
||||||
description: '오른쪽 아이템 풀에서 이미지를 드래그해서 원하는 칸에 배치합니다. 직접 올린 커스텀 이미지도 같은 방식으로 다룰 수 있고, 이름 표시 옵션으로 결과 톤도 함께 맞출 수 있어요.',
|
description:
|
||||||
|
'오른쪽 아이템 영역의 이미지를 원하는 칸으로 끌어다 놓으면 바로 배치됩니다. 게임 템플릿에 없는 이미지는 커스텀 이미지로 추가해 같이 쓸 수 있고, 이름 표시 옵션을 켜면 결과 이미지를 더 설명적으로 정리할 수 있어요.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'save-share',
|
id: 'save-share',
|
||||||
title: '저장과 공유',
|
title: '저장과 이미지 다운로드',
|
||||||
summary: '저장, 이미지 다운로드, 템플릿 요청까지 마무리합니다.',
|
summary: '완성한 티어표를 내 목록에 저장하거나 PNG 이미지로 내려받습니다.',
|
||||||
description: '완성한 보드는 내 티어표로 저장하거나 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 currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||||
@@ -91,6 +128,7 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
|||||||
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
const showGameHubViewToggle = computed(() => route.name === 'gameHub')
|
||||||
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||||
const leftBottomPrimaryAction = computed(() => {
|
const leftBottomPrimaryAction = computed(() => {
|
||||||
|
if (!authReady.value) return null
|
||||||
if (route.name === 'home' && auth.user) {
|
if (route.name === 'home' && auth.user) {
|
||||||
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
|
||||||
}
|
}
|
||||||
@@ -369,7 +407,7 @@ function submitGlobalSearch() {
|
|||||||
|
|
||||||
<div class="leftRail__body">
|
<div class="leftRail__body">
|
||||||
<div class="leftRail__content">
|
<div class="leftRail__content">
|
||||||
<div v-if="auth.user" class="appUserCard">
|
<div v-if="authReady && auth.user" class="appUserCard">
|
||||||
<div class="appUserCard__button">
|
<div class="appUserCard__button">
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
<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>
|
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||||
@@ -410,8 +448,12 @@ function submitGlobalSearch() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="leftRail__bottom">
|
<div class="leftRail__bottom">
|
||||||
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
|
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
|
||||||
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
|
||||||
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -452,11 +494,11 @@ function submitGlobalSearch() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isGuideModalOpen" class="guideModal" role="dialog" aria-modal="true" aria-label="티어 메이커 사용법" @click.self="closeGuideModal">
|
<div v-if="isGuideModalOpen" class="guideModal" role="dialog" aria-modal="true" aria-label="티어 메이커 기능 안내" @click.self="closeGuideModal">
|
||||||
<div class="guideModal__dialog">
|
<div class="guideModal__dialog">
|
||||||
<div class="guideModal__sidebar">
|
<div class="guideModal__sidebar">
|
||||||
<div class="guideModal__eyebrow">Guide</div>
|
<div class="guideModal__eyebrow">Guide</div>
|
||||||
<div class="guideModal__title">티어표 제작 흐름</div>
|
<div class="guideModal__title">티어 메이커 기능 안내</div>
|
||||||
<div class="guideModal__list">
|
<div class="guideModal__list">
|
||||||
<button
|
<button
|
||||||
v-for="(step, index) in guideSteps"
|
v-for="(step, index) in guideSteps"
|
||||||
@@ -484,7 +526,7 @@ function submitGlobalSearch() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="guideModal__text">
|
<div class="guideModal__text">
|
||||||
<div class="guideModal__stepLabel">STEP {{ guideStepIndex + 1 }}</div>
|
<div class="guideModal__stepLabel">GUIDE {{ guideStepIndex + 1 }}</div>
|
||||||
<div class="guideModal__stepTitle">{{ currentGuideStep.title }}</div>
|
<div class="guideModal__stepTitle">{{ currentGuideStep.title }}</div>
|
||||||
<div class="guideModal__stepSummary">{{ currentGuideStep.summary }}</div>
|
<div class="guideModal__stepSummary">{{ currentGuideStep.summary }}</div>
|
||||||
<p class="guideModal__stepDescription">{{ currentGuideStep.description }}</p>
|
<p class="guideModal__stepDescription">{{ currentGuideStep.description }}</p>
|
||||||
@@ -535,9 +577,6 @@ function submitGlobalSearch() {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<button class="guideDockButton" type="button" aria-label="사용법 열기" @click="openGuideModal()">
|
|
||||||
<SvgIcon :src="iconMenuBook" :size="22" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -928,6 +967,7 @@ function submitGlobalSearch() {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
@@ -938,6 +978,14 @@ function submitGlobalSearch() {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adminButton--icon {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminButton__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.appMain {
|
.appMain {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1052,25 +1100,6 @@ function submitGlobalSearch() {
|
|||||||
padding-top: 12px;
|
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 {
|
.rightRailAction__button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|||||||
@@ -5,30 +5,40 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
user: null,
|
user: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
hydrated: false,
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async refresh() {
|
async refresh() {
|
||||||
|
if (this.status === 'loading') return this.user
|
||||||
this.status = 'loading'
|
this.status = 'loading'
|
||||||
try {
|
try {
|
||||||
const data = await api.me()
|
const data = await api.me()
|
||||||
this.user = data.user
|
this.user = data.user
|
||||||
|
return this.user
|
||||||
|
} catch (error) {
|
||||||
|
this.user = null
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
this.status = 'idle'
|
this.status = 'idle'
|
||||||
|
this.hydrated = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async signup(email, password) {
|
async signup(email, password) {
|
||||||
const user = await api.signup({ email, password })
|
const user = await api.signup({ email, password })
|
||||||
this.user = user
|
this.user = user
|
||||||
|
this.hydrated = true
|
||||||
return user
|
return user
|
||||||
},
|
},
|
||||||
async login(email, password) {
|
async login(email, password) {
|
||||||
const user = await api.login({ email, password })
|
const user = await api.login({ email, password })
|
||||||
this.user = user
|
this.user = user
|
||||||
|
this.hydrated = true
|
||||||
return user
|
return user
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
this.user = null
|
this.user = null
|
||||||
|
this.hydrated = true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,8 +30,15 @@ const description = computed(() =>
|
|||||||
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
||||||
)
|
)
|
||||||
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
||||||
|
const authReady = computed(() => auth.hydrated)
|
||||||
|
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!auth.hydrated) await auth.refresh()
|
||||||
|
if (auth.user) {
|
||||||
|
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const meta = await api.authMeta()
|
const meta = await api.authMeta()
|
||||||
hasUsers.value = !!meta.hasUsers
|
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() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
||||||
@@ -66,7 +82,11 @@ async function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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="로그인 또는 회원가입">
|
<div class="authTabs" role="tablist" aria-label="로그인 또는 회원가입">
|
||||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
<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;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authScreen--loading {
|
||||||
|
min-height: 220px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authLoading {
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.authTabs {
|
.authTabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -30,14 +30,19 @@ const avatarUrl = computed(() => {
|
|||||||
return toApiUrl(auth.user.avatarSrc)
|
return toApiUrl(auth.user.avatarSrc)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const authReady = computed(() => auth.hydrated)
|
||||||
|
|
||||||
const displayInitial = computed(() => {
|
const displayInitial = computed(() => {
|
||||||
const email = auth.user?.email || 'U'
|
const email = auth.user?.email || 'U'
|
||||||
return email[0].toUpperCase()
|
return email[0].toUpperCase()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
if (!auth.hydrated) await auth.refresh()
|
||||||
if (!auth.user) router.push('/login')
|
if (!auth.user) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
nickname.value = auth.user?.nickname || ''
|
nickname.value = auth.user?.nickname || ''
|
||||||
removeAvatar.value = false
|
removeAvatar.value = false
|
||||||
})
|
})
|
||||||
@@ -121,7 +126,11 @@ async function logout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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="settingsIdentity">
|
||||||
<div class="avatarButtonWrap">
|
<div class="avatarButtonWrap">
|
||||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||||
@@ -185,6 +194,16 @@ async function logout() {
|
|||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settingsScreen--loading {
|
||||||
|
min-height: 240px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsLoading {
|
||||||
|
color: rgba(255, 255, 255, 0.62);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.settingsIdentity {
|
.settingsIdentity {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px minmax(0, 1fr);
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user