v0.0.53: 공유 모달·헤더 사용자 메뉴·회원가입·로그인 화면

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 11:09:26 +09:00
parent add0fa51c0
commit f3f971ab1b
14 changed files with 812 additions and 27 deletions

View File

@@ -106,8 +106,6 @@
.site-main {
min-height: 0;
border-left: 1px solid var(--site-line);
border-right: 1px solid var(--site-line);
background: var(--site-bg);
}

View File

@@ -22,7 +22,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
<template>
<aside
class="left-sidebar site-sidebar hidden overflow-hidden transition-[width,opacity,border-color] duration-300 ease-out lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start lg:flex lg:flex-col"
class="left-sidebar site-sidebar hidden overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,border-color] duration-300 ease-out lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start lg:flex lg:flex-col"
:class="menuOpen ? 'w-[287px] opacity-100' : 'w-0 opacity-0 border-transparent'"
>
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
@@ -87,7 +87,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
</div>
</div>
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between border-t border-[var(--site-line)] px-1 py-4 text-xs">
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between px-1 py-4 text-xs">
<nav class="left-sidebar__footer-nav flex gap-4">
<NuxtLink
v-for="item in navigation.footer"

View File

@@ -179,7 +179,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
</div>
</div>
<footer class="right-sidebar__footer shrink-0 border-t border-[var(--site-line)] py-4 pl-5 pr-0 text-xs site-muted">
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-0 text-xs site-muted">
{{ siteSettings.copyrightText }}
</footer>
</aside>

View File

@@ -1,12 +1,58 @@
<script setup>
const { menuOpen, toggleMenu } = useMenuState()
const { isDarkMode, toggleTheme } = useThemeMode()
const menuUserOpen = ref(false)
const userMenuRef = ref(null)
const userMenuToggleRef = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio'
})
})
/**
* 사용자 메뉴를 닫는다.
* @returns {void}
*/
const closeUserMenu = () => {
menuUserOpen.value = false
}
/**
* 사용자 메뉴를 토글한다.
* @returns {void}
*/
const toggleUserMenu = () => {
menuUserOpen.value = !menuUserOpen.value
}
/**
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const onDocumentClick = (event) => {
const target = /** @type {Node | null} */ (event.target instanceof Node ? event.target : null)
if (!target) {
closeUserMenu()
return
}
const isInsideMenu = userMenuRef.value instanceof HTMLElement && userMenuRef.value.contains(target)
const isToggleButton = userMenuToggleRef.value instanceof HTMLElement && userMenuToggleRef.value.contains(target)
if (!isInsideMenu && !isToggleButton) {
closeUserMenu()
}
}
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>
<template>
@@ -54,19 +100,64 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
<NuxtLink class="site-header__buy site-accent-button rounded-lg px-4 py-2 font-semibold" to="/pages/about">
Subscribe
</NuxtLink>
<NuxtLink class="site-header__nav-link site-interactive rounded-md px-2 py-1" to="/pages/about">
Account
</NuxtLink>
<button
class="site-header__theme-toggle site-panel-hover site-interactive grid h-8 w-8 place-items-center rounded-full border border-[var(--site-line)]"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
@click="toggleTheme"
>
<span v-if="isDarkMode"></span>
<span v-else></span>
</button>
<div class="site-header__user-menu relative">
<button
ref="userMenuToggleRef"
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
type="button"
aria-label="Toggle user menu"
:aria-expanded="menuUserOpen.toString()"
@click="toggleUserMenu"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="h-6 w-6 stroke-current stroke-[1.75] md:h-7 md:w-7 md:stroke-[1.5]">
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
</svg>
</button>
<Transition
enter-active-class="transition-[transform,opacity,visibility] duration-200 ease-out"
enter-from-class="-translate-y-2 scale-95 opacity-0"
enter-to-class="translate-y-0 scale-100 opacity-100"
leave-active-class="transition-[transform,opacity,visibility] duration-150 ease-in"
leave-from-class="translate-y-0 scale-100 opacity-100"
leave-to-class="-translate-y-2 scale-95 opacity-0"
>
<div
v-if="menuUserOpen"
ref="userMenuRef"
class="site-header__user-dropdown absolute top-12 right-0 z-30 flex min-w-[200px] max-w-xs flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3 pb-2 text-sm font-medium shadow-[0_12px_30px_rgba(0,0,0,0.12)]"
>
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
<span class="text-base font-normal uppercase md:text-lg">@</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="max-w-xs truncate leading-[1.15]">Anonymous</div>
</div>
</div>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M15 9l-6 6" />
<path d="M15 15v-6h-6" />
</svg>
<span>Sign up</span>
</NuxtLink>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>Sign in</span>
</NuxtLink>
</div>
</Transition>
</div>
</nav>
</div>
</header>

View File

@@ -1,5 +1,21 @@
# 의사결정 이력
## 2026-05-11 v0.0.53
### 게시물 공유 모달 UI
게시물 상세의 제목 오른쪽 공유 버튼은 단순 아이콘만 두지 않고, 모달에서 공유 미리보기 카드와 채널별 링크를 제공하도록 확장했다. 사용자가 외부 공유 전 게시물 정보(썸네일·제목·요약)를 한번 확인할 수 있고, 링크 복사까지 같은 컨텍스트에서 끝낼 수 있어 Thred 참고 UX와 운영 편의성을 함께 맞출 수 있기 때문이다.
### 헤더 사용자 메뉴 단순화
헤더 우측은 Account 텍스트 링크 대신 아바타 아이콘 버튼으로 전환하고, 비로그인 기준 드롭다운 메뉴에서 Sign up/Sign in만 제공한다. 다크 모드나 메뉴 열기 토글은 이미 헤더와 사이드바에 노출되어 기능이 중복되므로 사용자 메뉴에서는 제거해 정보 밀도를 낮췄다.
### 회원가입/로그인 공개 화면 초안
회원가입은 `/signup` 단일 화면에서 3단계(환영, 정보 입력, 이메일 확인) 플로우로 처리한다. 초기 단계에서는 실제 메일 인프라 연결 전이므로 3단계에서 인증 메일 재전송과 인증 완료 액션을 시뮬레이션하고, 인증 완료 후 로그인 화면으로 이동시키는 흐름을 먼저 고정한다. 로그인 화면(`/signin`)은 같은 다크 톤 레이아웃으로 맞춰 인증 화면군의 시각 일관성을 유지한다.
회원가입 스텝 인디케이터는 단계별 콘텐츠 높이에 따라 위치가 바뀌지 않도록, 화면 높이를 기준으로 한 `min-h` 레이아웃의 하단 고정 영역에 둔다. 회원가입 1단계 환영 문구는 하드코딩 대신 사이트 설정 API의 `title`, `description` 값을 사용해 추후 블로그 이름/인사말 관리 화면과 자연스럽게 연결한다.
## 2026-05-08 v0.0.52
### 목록 Featured 아이콘 정렬과 상세 메타 구분자

View File

@@ -21,7 +21,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in) |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `sticky`+`h/max-h: calc(100vh-57px)`+내부 무스크롤바 스크롤, 하단 푸터 고정 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 동일 패턴(고정 열 높이), 카피라이트 하단 고정 |
| components/site/MainColumn.vue | 메인 화면 중앙 |
@@ -86,11 +86,13 @@
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드 |
| pages/posts/index.vue | 게시물 전체 목록 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력 |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
| pages/pages/[slug].vue | 고정 페이지 상세 |
| pages/signup.vue | 회원가입 3단계 화면(환영/입력/이메일 확인, 재전송) |
| pages/signin.vue | 로그인 화면(다크 톤 폼) |
## 서버 API

View File

@@ -30,6 +30,7 @@
- 메뉴 상태는 Nuxt/Vue 상태로 관리
- 브라우저에서는 `localStorage.MENU_STATE``open` 또는 `closed` 저장
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
### 공개 화면 색상
@@ -44,6 +45,8 @@
- Main 좌우 패딩: 24px → 20px
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
### 공개 목록·상세의 발행일 표시
@@ -64,8 +67,18 @@
- `/post/:slug` - 개별 게시물 상세
- `/tags` - 태그 전체 목록
- `/tag/:slug` - 태그별 게시물 목록
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
- `/signin` - 로그인
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
### 공개 인증 화면(초기)
- 회원가입 화면은 AFFiNE 참고 다크 테마 3단계 플로우를 제공한다.
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings``title`, `description` 값을 우선 사용한다.
### 레이아웃 파일
```
@@ -497,6 +510,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.50
- 현재 버전: v0.0.53
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v0.0.53
- 게시물 상세 제목 우측 공유 버튼에 공유 모달 추가(X/Bluesky/Facebook/LinkedIn/Email/링크복사).
- 헤더 우측 Account 링크 제거, 사용자 아바타 버튼+드롭다운(Anonymous/Sign up/Sign in) 추가.
- 회원가입 화면(`/signup`)을 AFFiNE 참고 3단계(환영/입력/이메일 확인) 플로우로 추가.
- 회원가입 3단계에 인증 메일 재전송(쿨다운)과 인증 완료 후 로그인 이동 흐름 추가.
- 로그인 화면(`/signin`)을 동일한 다크 톤 폼 스타일로 추가.
- 회원가입 하단 스텝 인디케이터를 `min-h` 기반 하단 고정 배치로 보정.
- 회원가입 1단계 문구를 사이트 설정(`site-settings.title`, `site-settings.description`) 연동으로 변경.
- 회원가입 페이지 타이머(`setInterval`)를 `onMounted`에서만 실행하도록 수정해 SSR 오류를 해결.
## v0.0.52
- 홈/태그 목록 Featured 아이콘이 제목 줄 높이를 흔들지 않도록 고정 크기(`h-4 w-4`)·정렬 방식으로 보정.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.52",
"version": "0.0.53",
"private": true,
"type": "module",
"scripts": {

View File

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

View File

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

View File

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