v0.0.53: 공유 모달·헤더 사용자 메뉴·회원가입·로그인 화면
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -349,7 +349,7 @@ const scrollFeatured = (direction) => {
|
||||
:class="postFeedStyle === 'cards' ? '' : ''"
|
||||
>
|
||||
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||
<NuxtLink :to="post.to" class="transition-opacity duration-200 hover:opacity-75">
|
||||
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
|
||||
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||
|
||||
@@ -34,6 +34,8 @@ const primaryTagMeta = computed(() => {
|
||||
|
||||
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
|
||||
const authorLabel = computed(() => 'sori.studio')
|
||||
const shareModalOpen = ref(false)
|
||||
const copyButtonLabel = ref('Copy link')
|
||||
|
||||
const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug))
|
||||
const previousPost = computed(() => {
|
||||
@@ -59,6 +61,44 @@ const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
|
||||
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
|
||||
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
|
||||
const ogImage = computed(() => post.value.featuredImage || '')
|
||||
const shareMetadata = computed(() => ({
|
||||
title: post.value.title || 'sori.studio',
|
||||
description: post.value.excerpt || 'sori.studio 개인 블로그',
|
||||
image: post.value.featuredImage || '',
|
||||
url: pageUrl.value
|
||||
}))
|
||||
|
||||
const encodedShareText = computed(() => encodeURIComponent(shareMetadata.value.title))
|
||||
const encodedShareUrl = computed(() => encodeURIComponent(shareMetadata.value.url))
|
||||
const encodedShareSummary = computed(() => encodeURIComponent(shareMetadata.value.description))
|
||||
|
||||
const shareLinks = computed(() => [
|
||||
{
|
||||
id: 'x',
|
||||
label: 'Share on X',
|
||||
href: `https://twitter.com/share?text=${encodedShareText.value}&url=${encodedShareUrl.value}`
|
||||
},
|
||||
{
|
||||
id: 'bluesky',
|
||||
label: 'Share on Bluesky',
|
||||
href: `https://bsky.app/intent/compose?text=${encodedShareText.value}%20${encodedShareUrl.value}`
|
||||
},
|
||||
{
|
||||
id: 'facebook',
|
||||
label: 'Share on Facebook',
|
||||
href: `https://www.facebook.com/sharer.php?u=${encodedShareUrl.value}`
|
||||
},
|
||||
{
|
||||
id: 'linkedin',
|
||||
label: 'Share on Linkedin',
|
||||
href: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedShareUrl.value}&title=${encodedShareText.value}&summary=${encodedShareSummary.value}`
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Share by email',
|
||||
href: `mailto:?subject=${encodedShareText.value}&body=${encodedShareUrl.value}`
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 절대 URL 생성
|
||||
@@ -77,6 +117,54 @@ const toAbsoluteUrl = (value) => {
|
||||
return `${siteUrl.value}${value.startsWith('/') ? value : `/${value}`}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openShareModal = () => {
|
||||
shareModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeShareModal = () => {
|
||||
shareModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 URL을 클립보드에 복사한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const copyShareLink = async () => {
|
||||
const url = shareMetadata.value.url
|
||||
|
||||
try {
|
||||
if (import.meta.client && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url)
|
||||
} else if (import.meta.client) {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = url
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'absolute'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
copyButtonLabel.value = 'Link copied!'
|
||||
} catch {
|
||||
copyButtonLabel.value = '복사 실패'
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
copyButtonLabel.value = 'Copy link'
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
title: seoTitle.value,
|
||||
link: [
|
||||
@@ -152,7 +240,7 @@ useHead(() => ({
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<a class="flex items-center gap-0.75 hover:opacity-75" :href="`${pageUrl}#comments`">
|
||||
<a class="flex items-center gap-1 hover:opacity-75" :href="`${pageUrl}#comments`">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
@@ -160,7 +248,7 @@ useHead(() => ({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button class="absolute right-0 bottom-4 flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post">
|
||||
<button class="absolute right-0 bottom-4 flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post" data-post-share-toggle @click="openShareModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 4v4c-6.575 1.028-9.02 6.788-10 12c-.037.206 5.384-5.962 10-6v4l8-7-8-7z" />
|
||||
</svg>
|
||||
@@ -225,5 +313,145 @@ useHead(() => ({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="shareModalOpen" class="post-detail__share-modal fixed inset-0 z-40 flex items-center justify-center bg-[rgba(0,0,0,0.45)] px-4" @click.self="closeShareModal">
|
||||
<div class="relative mt-8 flex w-full max-w-[min(calc(100%-2rem),480px)] translate-y-0 scale-100 flex-col items-center rounded-[10px] bg-[var(--site-bg)] p-6 shadow-[0_20px_45px_rgba(0,0,0,0.18)]">
|
||||
<button class="absolute right-5 top-5 flex h-6 w-6 cursor-pointer items-center justify-center opacity-30 transition-opacity duration-200 hover:opacity-70" type="button" aria-label="Close share modal" @click="closeShareModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current stroke-[1.2]">
|
||||
<path d="M.75 23.249l22.5-22.5M23.25 23.249L.75.749" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="mb-4 block self-start text-xs font-semibold uppercase tracking-wide site-muted">Share this post</span>
|
||||
|
||||
<div class="mb-4 w-full overflow-hidden rounded-[10px] border border-[var(--site-line)]">
|
||||
<figure class="aspect-[2/1] w-full overflow-hidden bg-[var(--site-panel)]">
|
||||
<img
|
||||
v-if="shareMetadata.image"
|
||||
:src="shareMetadata.image"
|
||||
:alt="shareMetadata.title"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<div v-else class="h-full w-full bg-[linear-gradient(135deg,#253444,#8f9dad)]" />
|
||||
</figure>
|
||||
<div class="flex flex-col gap-1.5 px-5 py-4">
|
||||
<h2 class="text-sm font-medium leading-tight md:text-base">
|
||||
{{ shareMetadata.title }}
|
||||
</h2>
|
||||
<p class="line-clamp-2 text-xs opacity-75 md:text-sm">
|
||||
{{ shareMetadata.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<a
|
||||
v-for="item in shareLinks"
|
||||
:key="item.id"
|
||||
class="flex cursor-pointer items-center justify-center gap-1.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-2 transition-opacity duration-200 hover:opacity-75 md:p-2.5"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
:href="item.href"
|
||||
:title="item.label"
|
||||
aria-label="Share"
|
||||
>
|
||||
<svg
|
||||
v-if="item.id === 'x'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M4 4l11.733 16H20L8.267 4z" />
|
||||
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.id === 'bluesky'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M6.335 5.144c-1.654 -1.199 -4.335 -2.127 -4.335 .826c0 .59 .35 4.953 .556 5.661c.713 2.463 3.13 2.75 5.444 2.369c-4.045 .665 -4.889 3.208 -2.667 5.41c1.03 1.018 1.913 1.59 2.667 1.59c2 0 3.134 -2.769 3.5 -3.5c.333 -.667 .5 -1.167 .5 -1.5c0 .333 .167 .833 .5 1.5c.366 .731 1.5 3.5 3.5 3.5c.754 0 1.637 -.571 2.667 -1.59c2.222 -2.203 1.378 -4.746 -2.667 -5.41c2.314 .38 4.73 .094 5.444 -2.369c.206 -.708 .556 -5.072 .556 -5.661c0 -2.953 -2.68 -2.025 -4.335 -.826c-2.293 1.662 -4.76 5.048 -5.665 6.856c-.905 -1.808 -3.372 -5.194 -5.665 -6.856z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.id === 'facebook'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="item.id === 'linkedin'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
||||
<rect x="2" y="9" width="4" height="12" />
|
||||
<circle cx="4" cy="4" r="2" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<polyline points="3 7 12 13 21 7" />
|
||||
</svg>
|
||||
<span class="sr-only">{{ item.label }}</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-2 transition-opacity duration-200 hover:opacity-75 md:p-2.5"
|
||||
type="button"
|
||||
:title="copyButtonLabel"
|
||||
aria-label="Copy link"
|
||||
@click="copyShareLink"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4">
|
||||
<path d="M9 15l6 -6" />
|
||||
<path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" />
|
||||
<path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" />
|
||||
</svg>
|
||||
<span>{{ copyButtonLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
104
pages/signin.vue
Normal file
104
pages/signin.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'page'
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 로그인 입력값을 검증한다.
|
||||
* @returns {boolean} 검증 통과 여부
|
||||
*/
|
||||
const validateSignIn = () => {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!form.email.trim() || !form.password) {
|
||||
errorMessage.value = '이메일과 비밀번호를 입력해 주세요.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
errorMessage.value = '이메일 형식이 올바르지 않습니다.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 요청을 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitSignIn = async () => {
|
||||
if (!validateSignIn()) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
isSubmitting.value = false
|
||||
errorMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-signin min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-8 py-12 sm:px-16">
|
||||
<div class="w-full max-w-[430px]">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
로그인
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
가입한 이메일과 비밀번호로 로그인하세요.
|
||||
</p>
|
||||
|
||||
<form class="mt-8 space-y-5" @submit.prevent="submitSignIn">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">이메일</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">비밀번호</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<p class="mt-6 text-sm text-[#9ba3af]">
|
||||
계정이 없으신가요?
|
||||
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signup/">
|
||||
회원가입
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
322
pages/signup.vue
Normal file
322
pages/signup.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'page'
|
||||
})
|
||||
|
||||
const currentStep = ref(1)
|
||||
const resendCooldown = ref(0)
|
||||
const isSubmitting = ref(false)
|
||||
const signupCompleted = ref(false)
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'AFFiNE',
|
||||
description: 'Configure your Self Host AFFiNE with a few simple settings.'
|
||||
})
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const errors = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const canResend = computed(() => resendCooldown.value <= 0)
|
||||
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value.title || 'AFFiNE'}`)
|
||||
const welcomeDescription = computed(() => siteSettings.value.description || 'Configure your Self Host AFFiNE with a few simple settings.')
|
||||
|
||||
/**
|
||||
* 필드 에러 메시지를 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetErrors = () => {
|
||||
errors.username = ''
|
||||
errors.email = ''
|
||||
errors.password = ''
|
||||
errors.passwordConfirm = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 입력값을 검증한다.
|
||||
* @returns {boolean} 검증 통과 여부
|
||||
*/
|
||||
const validateStepTwo = () => {
|
||||
resetErrors()
|
||||
let valid = true
|
||||
|
||||
if (!form.username.trim()) {
|
||||
errors.username = '사용자명을 입력해 주세요.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
errors.email = '이메일을 입력해 주세요.'
|
||||
valid = false
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
errors.email = '이메일 주소가 유효하지 않습니다.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
errors.password = '비밀번호를 입력해 주세요.'
|
||||
valid = false
|
||||
} else if (form.password.length < 8 || form.password.length > 32) {
|
||||
errors.password = '비밀번호는 8~32자로 입력해 주세요.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!form.passwordConfirm) {
|
||||
errors.passwordConfirm = '비밀번호 확인을 입력해 주세요.'
|
||||
valid = false
|
||||
} else if (form.password !== form.passwordConfirm) {
|
||||
errors.passwordConfirm = '비밀번호가 일치하지 않습니다.'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 단계로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goNextStep = () => {
|
||||
if (currentStep.value === 1) {
|
||||
currentStep.value = 2
|
||||
return
|
||||
}
|
||||
|
||||
if (currentStep.value === 2) {
|
||||
if (!validateStepTwo()) {
|
||||
return
|
||||
}
|
||||
|
||||
currentStep.value = 3
|
||||
resendCooldown.value = 30
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 단계로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goPreviousStep = () => {
|
||||
if (currentStep.value > 1 && !isSubmitting.value) {
|
||||
currentStep.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 메일 재전송을 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const resendVerificationEmail = async () => {
|
||||
if (!canResend.value || isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
isSubmitting.value = false
|
||||
resendCooldown.value = 30
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 인증 완료를 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const completeSignup = async () => {
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
isSubmitting.value = false
|
||||
signupCompleted.value = true
|
||||
}
|
||||
|
||||
const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null))
|
||||
|
||||
onMounted(() => {
|
||||
countdownTimer.value = setInterval(() => {
|
||||
if (resendCooldown.value > 0) {
|
||||
resendCooldown.value -= 1
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer.value) {
|
||||
clearInterval(countdownTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-8 py-24 sm:px-16">
|
||||
<div class="flex min-h-[calc(100vh-12rem)] w-full max-w-[430px] flex-col">
|
||||
<div>
|
||||
<template v-if="currentStep === 1">
|
||||
<p class="text-[40px] font-semibold leading-tight">
|
||||
{{ welcomeTitle }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
{{ welcomeDescription }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 2">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
회원 가입
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
처음 생성하는 계정은 관리자 계정으로 자동 생성됩니다.
|
||||
</p>
|
||||
|
||||
<form class="mt-8 space-y-5" @submit.prevent="goNextStep">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">사용자명</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.username ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
>
|
||||
<p v-if="errors.username" class="text-xs text-[#e05d67]">
|
||||
{{ errors.username }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">이메일</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.email ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
>
|
||||
<p v-if="errors.email" class="text-xs text-[#e05d67]">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">비밀번호</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.password ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<p v-if="errors.password" class="text-xs text-[#e05d67]">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">비밀번호 확인</label>
|
||||
<input
|
||||
v-model="form.passwordConfirm"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.passwordConfirm ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<p v-if="errors.passwordConfirm" class="text-xs text-[#e05d67]">
|
||||
{{ errors.passwordConfirm }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-8 text-xs leading-relaxed text-[#8c95a3]">
|
||||
비밀번호는 8~32자로 설정해 주세요.<br>
|
||||
권장사항: 대문자, 소문자, 숫자, 기호 2개를 포함해 주세요.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
이메일 확인
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
{{ form.email }} 주소로 인증 메일을 보냈습니다.<br>
|
||||
이메일 링크를 확인해야 회원가입이 확정됩니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
|
||||
<p class="text-sm text-[#d8dee6]">
|
||||
메일이 오지 않았다면 인증 메일을 재전송해 주세요.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 h-9 rounded-[8px] border border-[#2f6feb] px-4 text-xs font-medium text-[#7eb8ff] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="!canResend || isSubmitting"
|
||||
@click="resendVerificationEmail"
|
||||
>
|
||||
{{ canResend ? '인증 메일 재전송' : `${resendCooldown}초 후 재전송` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="signupCompleted" class="mt-4 text-sm text-[#7ccf90]">
|
||||
이메일 인증이 완료되었습니다. 로그인 화면으로 이동해 주세요.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="h-9 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="currentStep === 1 || isSubmitting"
|
||||
@click="goPreviousStep"
|
||||
>
|
||||
뒤로
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentStep < 3"
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90"
|
||||
type="button"
|
||||
@click="goNextStep"
|
||||
>
|
||||
다음으로
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="!signupCompleted"
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeSignup"
|
||||
>
|
||||
인증 완료
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
v-else
|
||||
class="inline-flex h-9 items-center rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90"
|
||||
to="/signin/"
|
||||
>
|
||||
로그인으로 이동
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center gap-1.5">
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 1 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 2 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 3 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -72,7 +72,7 @@ const tagPosts = computed(() => posts.value
|
||||
<div class="relative flex-[3] md:flex-[4]">
|
||||
<div class="flex h-full flex-col gap-1.5">
|
||||
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||
<NuxtLink :to="post.to" class="transition-opacity duration-200 hover:opacity-75">
|
||||
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
|
||||
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||
|
||||
Reference in New Issue
Block a user