Compare commits
6 Commits
2f7ce64391
...
ff6526c997
| Author | SHA1 | Date | |
|---|---|---|---|
| ff6526c997 | |||
| bcf3acd432 | |||
| 5141a63294 | |||
| 3f7f51ff86 | |||
| fd55d8af08 | |||
| ed7709ab59 |
@@ -60,6 +60,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.site-search-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
@@ -75,6 +79,18 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@keyframes site-search-modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.99);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -96,6 +112,10 @@
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.site-search-modal__panel--animate {
|
||||
animation: site-search-modal-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.post-prose {
|
||||
@apply max-w-none text-[17px] leading-8;
|
||||
color: var(--site-text);
|
||||
@@ -196,4 +216,23 @@
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
|
||||
*/
|
||||
.auth-form-input {
|
||||
color: #f5f7fa;
|
||||
caret-color: #2f6feb;
|
||||
}
|
||||
|
||||
.auth-form-input::placeholder {
|
||||
color: #5c6570;
|
||||
}
|
||||
|
||||
.auth-form-input:-webkit-autofill,
|
||||
.auth-form-input:-webkit-autofill:hover,
|
||||
.auth-form-input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f5f7fa;
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
106
components/auth/AuthPasswordVisibilityToggle.vue
Normal file
106
components/auth/AuthPasswordVisibilityToggle.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 비밀번호 필드 표시/숨김 토글(Material 스타일 눈 아이콘 SVG)
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** 비밀번호를 평문으로 표시할 때 true */
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* 스크린 리더용 필드 이름(예: 비밀번호 확인)
|
||||
*/
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '비밀번호'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* 접근성용 레이블 문자열
|
||||
* @param {'show' | 'hide'} kind - 보기 또는 숨기기
|
||||
* @returns {string}
|
||||
*/
|
||||
const labelFor = (kind) => {
|
||||
if (kind === 'show') {
|
||||
return `${props.fieldName} 보기`
|
||||
}
|
||||
|
||||
return `${props.fieldName} 숨기기`
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시 상태를 반전한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggle = () => {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="auth-password-visibility-toggle"
|
||||
type="button"
|
||||
:aria-label="modelValue ? labelFor('hide') : labelFor('show')"
|
||||
:aria-pressed="modelValue"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg
|
||||
v-if="!modelValue"
|
||||
class="auth-password-visibility-toggle__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="auth-password-visibility-toggle__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 22 19.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-password-visibility-toggle {
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ba3af;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-password-visibility-toggle:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.auth-password-visibility-toggle:focus-visible {
|
||||
outline: 2px solid rgba(47, 111, 235, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.auth-password-visibility-toggle__icon {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ const { menuOpen, toggleMenu, closeMenu } = useMenuState()
|
||||
const menuUserOpen = ref(false)
|
||||
const userMenuRef = ref(null)
|
||||
const userMenuToggleRef = ref(null)
|
||||
const searchOpen = ref(false)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
@@ -18,6 +19,37 @@ const closeUserMenu = () => {
|
||||
menuUserOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 검색 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openSearchModal = () => {
|
||||
searchOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 필드에 포커스가 있으면 `/` 검색 단축키를 무시한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {boolean} 무시할 때 true
|
||||
*/
|
||||
const shouldIgnoreSearchHotkey = (event) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return true
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
const tag = target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return true
|
||||
}
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴를 토글한다.
|
||||
* @returns {void}
|
||||
@@ -47,24 +79,37 @@ const onDocumentClick = (event) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape로 열린 패널을 닫는다(사용자 메뉴 우선, 이어서 모바일 좌측 메뉴).
|
||||
* Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기).
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGlobalKeydown = (event) => {
|
||||
if (event.key !== 'Escape') {
|
||||
if (event.key === 'Escape') {
|
||||
if (searchOpen.value) {
|
||||
searchOpen.value = false
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (menuUserOpen.value) {
|
||||
closeUserMenu()
|
||||
return
|
||||
}
|
||||
if (!menuOpen.value) {
|
||||
return
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
||||
closeMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (menuUserOpen.value) {
|
||||
closeUserMenu()
|
||||
if (event.key !== '/') {
|
||||
return
|
||||
}
|
||||
if (!menuOpen.value) {
|
||||
if (shouldIgnoreSearchHotkey(event)) {
|
||||
return
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
||||
closeMenu()
|
||||
}
|
||||
event.preventDefault()
|
||||
openSearchModal()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -114,11 +159,16 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
<div class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 items-center rounded-lg px-3 text-sm md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input">
|
||||
<button
|
||||
type="button"
|
||||
class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
|
||||
aria-label="검색 열기"
|
||||
@click="openSearchModal"
|
||||
>
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||
<span class="site-header__search-text site-soft">Search</span>
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</div>
|
||||
</button>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
<NuxtLink class="site-header__buy site-accent-button shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold sm:px-4 sm:py-2 sm:text-sm" to="/pages/about">
|
||||
Subscribe
|
||||
@@ -184,4 +234,5 @@ onBeforeUnmount(() => {
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<SiteSearchModal v-model="searchOpen" />
|
||||
</template>
|
||||
|
||||
254
components/site/SiteSearchModal.vue
Normal file
254
components/site/SiteSearchModal.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<script setup>
|
||||
/**
|
||||
* @typedef {{ name: string, slug: string }} SearchTagHit
|
||||
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit
|
||||
*/
|
||||
|
||||
const open = defineModel({ type: Boolean, default: false })
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const query = ref('')
|
||||
const debouncedQuery = ref('')
|
||||
const isComposing = ref(false)
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let debounceTimer = null
|
||||
|
||||
watch(query, (value) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debouncedQuery.value = value
|
||||
}, 200)
|
||||
})
|
||||
|
||||
const { data, pending } = await useFetch('/api/search', {
|
||||
query: { q: debouncedQuery },
|
||||
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
||||
})
|
||||
|
||||
/**
|
||||
* 정규식 특수 문자 이스케이프
|
||||
* @param {string} value - 원문
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*/
|
||||
const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* 검색어 일치 구간을 나누어 하이라이트 표시에 사용한다.
|
||||
* @param {string} text - 표시할 문자열
|
||||
* @param {string} needle - 검색어
|
||||
* @returns {Array<{ text: string, hit: boolean }>} 구간 목록
|
||||
*/
|
||||
const highlightParts = (text, needle) => {
|
||||
const base = String(text ?? '')
|
||||
const q = String(needle ?? '').trim()
|
||||
if (!q) {
|
||||
return [{ text: base, hit: false }]
|
||||
}
|
||||
const parts = base.split(new RegExp(`(${escapeRegExp(q)})`, 'gi'))
|
||||
return parts
|
||||
.filter((part) => part !== '')
|
||||
.map((part, index) => ({
|
||||
text: part,
|
||||
hit: index % 2 === 1
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록용 요약 문자열을 한 줄로 자른다.
|
||||
* @param {string} text - 요약 원문
|
||||
* @param {number} max - 최대 글자 수
|
||||
* @returns {string} 잘린 문자열
|
||||
*/
|
||||
const clipExcerpt = (text, max = 140) => {
|
||||
const line = String(text ?? '').replace(/\s+/g, ' ').trim()
|
||||
if (line.length <= max) {
|
||||
return line
|
||||
}
|
||||
return `${line.slice(0, max)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* 배경 클릭 시 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBackdropPointerDown = () => {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어를 비운다(입력 포커스 유지).
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearQuery = () => {
|
||||
query.value = ''
|
||||
debouncedQuery.value = ''
|
||||
nextTick(() => {
|
||||
const el = searchInputRef.value
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 시작 처리
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionStart = () => {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionEnd = () => {
|
||||
isComposing.value = false
|
||||
clearTimeout(debounceTimer)
|
||||
debouncedQuery.value = query.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 열림·닫힘에 따라 스크롤 잠금과 입력을 동기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
watch(open, (isOpen) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
document.documentElement.classList.toggle('site-search-open', Boolean(isOpen))
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
const el = searchInputRef.value
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
query.value = ''
|
||||
debouncedQuery.value = ''
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('site-search-open')
|
||||
}
|
||||
clearTimeout(debounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="open"
|
||||
class="site-search-modal fixed inset-0 z-[60] flex justify-center bg-black/35 px-3 pt-14 pb-8 backdrop-blur-sm sm:px-4 sm:pt-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="사이트 검색"
|
||||
@pointerdown.self="onBackdropPointerDown"
|
||||
>
|
||||
<div
|
||||
class="site-search-modal__panel site-search-modal__panel--animate flex h-[min(78vh,640px)] w-full max-w-[95vw] flex-col overflow-hidden rounded-lg border border-[var(--site-line)] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_20px_50px_rgba(0,0,0,0.18)] sm:max-w-lg"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="site-search-modal__header flex shrink-0 items-center gap-2 border-b border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-3 sm:gap-3 sm:px-5 sm:py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="site-search-modal__icon flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-[var(--site-text)] transition-colors hover:bg-[var(--site-panel)]"
|
||||
:aria-label="query.trim() ? '검색어 지우기' : '검색'"
|
||||
:disabled="!query.trim()"
|
||||
@click="query.trim() ? clearQuery() : null"
|
||||
>
|
||||
<svg v-if="!query.trim()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M23.38 21.62l-6.53-6.53a9.15 9.15 0 0 0 1.9-5.59 9.27 9.27 0 1 0-3.66 7.36l6.53 6.53a1.26 1.26 0 0 0 1.76 0 1.25 1.25 0 0 0 0-1.77ZM2.75 9.5A6.75 6.75 0 1 1 9.5 16.25 6.76 6.76 0 0 1 2.75 9.5Z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
enterkeyhint="search"
|
||||
autocomplete="off"
|
||||
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
||||
placeholder="글 제목, 본문, 태그 검색"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionend="onCompositionEnd"
|
||||
/>
|
||||
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
|
||||
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
|
||||
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="pending" class="text-sm text-[var(--site-soft)]">
|
||||
검색 중…
|
||||
</div>
|
||||
<template v-else>
|
||||
<section v-if="(data?.tags?.length ?? 0) > 0" class="site-search-modal__section mb-6">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Tags
|
||||
</h2>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="tag in data.tags" :key="tag.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__tag-link flex items-baseline gap-1 rounded-md px-2 py-2 text-[15px] transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/tag/${tag.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="text-[var(--site-soft)]">#</span>
|
||||
<span class="font-medium text-[var(--site-text)]">{{ tag.name }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Posts
|
||||
</h2>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="post in data.posts" :key="post.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__post-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/post/${post.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||
<template v-for="(seg, i) in highlightParts(post.title, query)" :key="`t-${post.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="clipExcerpt(post.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
|
||||
<template v-for="(seg, i) in highlightParts(clipExcerpt(post.excerpt), query)" :key="`e-${post.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
|
||||
일치하는 결과가 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -24,7 +24,8 @@
|
||||
## 스타일
|
||||
|
||||
- TailwindCSS 기본 사용
|
||||
- 주요 요소: Tailwind + 고유 className 동시 적용
|
||||
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||
- Tailwind 엔트리는 `nuxt.config.js`의 `tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
|
||||
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
|
||||
|
||||
```html
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.65
|
||||
|
||||
### 통합 검색 모달과 `GET /api/search`
|
||||
|
||||
헤더 검색은 장식이 아니라 Ghost류 UX로 `/` 단축키·모달·태그·게시물 섹션 구분이 필요했다. `INPUT`/`TEXTAREA` 등에 포커스가 있을 때는 브라우저 입력과 충돌하지 않도록 `/`를 무시한다. 검색은 저장소 `searchPublicContent`에 모아 `LIKE` 대신 `position(lower(q) in lower(column))`로 부분 일치를 구현해 `%`·`_` 이스케이프 이슈를 줄였다. 저자(author) 검색은 현재 도메인 모델에 없어 제외했다.
|
||||
|
||||
## 2026-05-11 v0.0.63
|
||||
|
||||
### Tailwind 엔트리 단일화
|
||||
|
||||
`@nuxtjs/tailwindcss` 기본 `cssPath`는 `assets/css/tailwind.css`인데 저장소에 해당 파일이 없으면 모듈이 패키지 내 `tailwind.css`를 `nuxt.options.css` 앞에 끼워 넣는다. 프로젝트는 이미 `main.css`에 `@tailwind`와 커스텀 `@layer`를 두고 있어 두 엔트리가 겹치면 유틸·레이어 순서가 기대와 달라질 수 있다. `tailwindcss.cssPath`를 `main.css`로 고정하고, JIT `content`에 composables·modules·plugins를 포함해 클래스 수집을 보강했다.
|
||||
|
||||
## 2026-05-11 v0.0.62
|
||||
|
||||
### 인증 폼 다크 스타일이 안 보이던 현상
|
||||
|
||||
`layout/page.vue`의 `text-ink`는 본문에 전달되지만, 폼 컨트롤은 UA 스타일로 `color`를 상속하지 않는 경우가 많아 다크 배경에서 입력 글자와 `currentColor` SVG가 사실상 사라질 수 있다. 전역 `.auth-form-input`으로 텍스트·캐럿·placeholder·WebKit autofill 글자색을 고정하고, 토글 버튼은 SFC `scoped` 스타일로 동일하게 맞췄다. `color-scheme: dark`는 네이티브 컨트롤 테마를 맞추기 위해 섹션에 추가했다.
|
||||
|
||||
## 2026-05-11 v0.0.61
|
||||
|
||||
### 인증 폼 비밀번호 토글 아이콘화
|
||||
|
||||
보기/숨기기 텍스트는 좁은 모바일에서 시각적 잡음이 되고 다국어·아이콘 일관성도 떨어져, Material 스타일 단일 경로 SVG(눈 열림·가림)를 공통 컴포넌트로 두었다. `aria-label`은 필드명(`field-name`)을 받아 회원가입의 확인 필드와 구분한다.
|
||||
|
||||
## 2026-05-11 v0.0.60
|
||||
|
||||
### 홈 Featured 모바일 스크롤·화살표 상태
|
||||
|
||||
가로 오버플로 트랙은 기본적으로 스크롤 가능하지만, 카드 전체가 링크일 때 브라우저가 세로 제스처에 가깝게 해석하거나 체인 스크롤이 나는 경우가 있어 `touch-pan-x`와 `overscroll-x-contain`으로 가로 우선·부모 스크롤 전파를 줄였다. 화살표는 스크롤 한계에서 의미 없는 클릭을 막기 위해 `scrollLeft`와 `scrollWidth - clientWidth` 비교로 `disabled`를 두고, 레이아웃 변화에 맞추기 위해 `ResizeObserver`를 함께 썼다.
|
||||
|
||||
## 2026-05-11 v0.0.59
|
||||
|
||||
### Nuxt `#internal/nuxt/paths` Node 해석 오류
|
||||
|
||||
Nuxt 3.21과 `@nuxt/vite-builder`는 SSR 엔트리에서 `#internal/nuxt/paths`를 롤업 외부 모듈로 남기는데, 동일 경로의 `paths.mjs` 템플릿은 기본적으로 VFS에만 있어 디스크 파일이 없다. Node는 프로젝트 루트 `package.json`의 `imports`로만 서브패스를 해석하므로, 템플릿을 디스크에 쓰도록(`write: true`) 훅하는 로컬 모듈과 루트 `imports` 매핑을 추가했다. `nitro-server` 경로만으로 브리지하면 `nitropack/runtime` 쪽 내부 specifier가 끌려와 단독 해석이 깨지므로, Nuxt가 생성하는 `paths.mjs` 본문을 그대로 두는 방식을 택했다.
|
||||
|
||||
## 2026-05-11 v0.0.58
|
||||
|
||||
### 중앙 본문과 우측 사이드 가로 넘침
|
||||
|
||||
19
docs/map.md
19
docs/map.md
@@ -17,11 +17,19 @@
|
||||
|------|------|
|
||||
| composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 |
|
||||
|
||||
## Nuxt 모듈
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| modules/nuxt-ssr-paths-write.mjs | `paths.mjs`를 `.nuxt`에 기록해 Node가 `#internal/nuxt/paths`를 해석할 수 있게 함 |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭 |
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
@@ -83,7 +91,7 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드 |
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) |
|
||||
@@ -91,8 +99,8 @@
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||
| pages/signup.vue | 회원가입 3단계 화면(환영/입력/이메일 확인, 재전송) |
|
||||
| pages/signin.vue | 로그인 화면(다크 톤 폼) |
|
||||
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
|
||||
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글 |
|
||||
|
||||
## 서버 API
|
||||
|
||||
@@ -103,6 +111,7 @@
|
||||
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
||||
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
||||
@@ -166,7 +175,7 @@
|
||||
| 파일 | 기능 |
|
||||
|------|------|
|
||||
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||
| nuxt.config.js | Nuxt 앱 설정, Tailwind 모듈 연결, 관리자 QA를 위한 개발 도구 비활성화 |
|
||||
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화 |
|
||||
| tailwind.config.js | Tailwind 테마 설정 |
|
||||
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
|
||||
|
||||
16
docs/spec.md
16
docs/spec.md
@@ -6,8 +6,9 @@
|
||||
- **유형**: 커스텀 블로그/CMS
|
||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
||||
- **현재 상태**: Nuxt 3 초기 스캐폴딩과 PostgreSQL 저장소 계층 구성 완료
|
||||
- **현재 상태**: Nuxt 3.21(SSR)·PostgreSQL 저장소 계층 구성 완료. Node가 SSR 번들의 `#internal/nuxt/paths`를 해석하도록 루트 `package.json` `imports`와 `modules/nuxt-ssr-paths-write.mjs`(`.nuxt/paths.mjs` 디스크 기록)을 둔다.
|
||||
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
||||
- **스타일**: Tailwind 엔트리는 `assets/css/main.css` 한 곳(`nuxt.config`의 `tailwindcss.cssPath`)이며, `tailwind.config.js`의 `content`가 Vue·composables·modules·plugins를 스캔한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +35,8 @@
|
||||
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
||||
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
|
||||
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||
- `Escape` 키는 사용자 드롭다운이 열려 있으면 우선 닫고, 그렇지 않으면 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
|
||||
|
||||
### 공개 화면 색상
|
||||
@@ -46,6 +48,12 @@
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
|
||||
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
||||
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
||||
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
||||
|
||||
### Post 페이지
|
||||
|
||||
- Main 좌우 패딩: 24px → 20px
|
||||
@@ -82,7 +90,7 @@
|
||||
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
|
||||
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
|
||||
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
|
||||
- 로그인 비밀번호 입력은 보기/숨기기 토글을 제공한다.
|
||||
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||
|
||||
@@ -104,6 +112,7 @@ layouts/
|
||||
```
|
||||
components/site/
|
||||
├── SiteHeader.vue # 상단 헤더
|
||||
├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과)
|
||||
├── LeftSidebar.vue # 왼쪽 사이드바
|
||||
├── RightSidebar.vue # 오른쪽 사이드바
|
||||
├── MainColumn.vue # 메인 컬럼
|
||||
@@ -289,6 +298,7 @@ components/content/
|
||||
- `GET /api/pages` - 고정 페이지 목록
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션
|
||||
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.66
|
||||
|
||||
- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임.
|
||||
- 검색 모달 헤더 아이콘은 입력 비어있으면 돋보기, 입력이 있으면 X(클리어)로 전환하고 클릭 시 입력값을 비운다. 좌측/우측 닫기 X는 제거하고 `Esc`·백드롭 클릭·모바일 취소로 닫는다.
|
||||
- 검색 입력은 IME(한글 조합) 중에도 디바운스로 검색을 갱신해 `워`처럼 조합 상태가 유지되는 입력에서도 결과가 나오게 하고, 조합 종료 시점에는 확정값으로 즉시 한 번 더 갱신한다.
|
||||
|
||||
## v0.0.65
|
||||
|
||||
- 헤더 `/` 단축키·검색 영역 클릭으로 통합 검색 모달(`SiteSearchModal`)을 연다. `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable` 포커스일 때는 `/`를 가로채지 않는다.
|
||||
- `GET /api/search?q=`와 `searchPublicContent`(저장소)로 태그·게시물(제목·요약·본문) 부분 일치 검색, 모달에서 Tags·Posts 섹션·일치 구간 강조·`html.site-search-open` 스크롤 잠금.
|
||||
|
||||
## v0.0.64
|
||||
|
||||
- 비개발용 `paths.mjs`가 `#internal/nitro`를 import하는데 루트 `package.json` `imports`에 없어 `Package import specifier "#internal/nitro" is not defined`가 나던 문제를, `scripts/node-paths-nitro-shim.mjs`로 최소 `useRuntimeConfig().app`만 제공하고 `#internal/nitro`를 매핑해 해결.
|
||||
|
||||
## v0.0.63
|
||||
|
||||
- `tailwindcss.cssPath`를 `~/assets/css/main.css`로 지정해, 없는 기본 경로 때문에 `node_modules/tailwindcss/tailwind.css`가 추가로 주입되던 이중 `@tailwind` 로딩을 제거.
|
||||
- `tailwind.config.js` `content`에 `composables`·`modules`·`plugins` 스캔 경로 추가.
|
||||
|
||||
## v0.0.62
|
||||
|
||||
- 다크 인증 페이지에서 `input`이 UA 기본색으로 남아 글자·아이콘이 안 보이던 문제를 `main.css`의 `.auth-form-input`(글자색·캐럿·placeholder·autofill)로 보정.
|
||||
- `AuthPasswordVisibilityToggle`은 Tailwind 의존을 줄이고 `scoped` CSS로 버튼·아이콘 크기·포커스 링을 고정.
|
||||
- signin/signup 루트에 `[color-scheme:dark]`, signup 패널에 `border`·`bg-[#0d1116]`(`auth-signup__panel`)로 카드 대비 보강.
|
||||
|
||||
## v0.0.61
|
||||
|
||||
- 로그인·회원가입 비밀번호 표시 토글을 `AuthPasswordVisibilityToggle`(Material 스타일 SVG 눈 아이콘)으로 통일, 텍스트 보기/숨기기 제거.
|
||||
- 회원가입 비밀번호·비밀번호 확인 각각 독립 토글·포커스 링을 가진 입력 행으로 정리.
|
||||
|
||||
## v0.0.60
|
||||
|
||||
- 홈 Featured 가로 트랙에 `touch-pan-x`·`-webkit-overflow-scrolling:touch`·`overscroll-x-contain`을 두어 모바일에서 손가락으로 가로 슬라이드(스크롤·스냅)가 잘 먹도록 함.
|
||||
- Featured 이전/다음 화살표는 스크롤 시작·끝에 따라 `disabled`와 시각적 비활성 처리, `ResizeObserver`·`scroll`로 상태 동기화.
|
||||
|
||||
## v0.0.59
|
||||
|
||||
- Nuxt 3.21 SSR이 `#internal/nuxt/paths`를 외부 import로 두는데 `.nuxt/paths.mjs`가 기본적으로 디스크에 쓰이지 않아 Node가 루트 `package.json`에서 해석하지 못하던 오류 수정.
|
||||
- `modules/nuxt-ssr-paths-write.mjs`에서 `paths.mjs` 템플릿에 `write: true`를 부여하고, 루트 `package.json` `imports`로 `./.nuxt/paths.mjs`를 매핑.
|
||||
- `nuxt` 의존성을 `^3.21.2`로 올려 lockfile과 정렬.
|
||||
|
||||
## v0.0.58
|
||||
|
||||
- 공개 3열 그리드 중앙을 `minmax(0,1fr)`로 바꾸고 `lg:gap-x-4`·`xl:gap-x-5`를 두어 본문과 오른쪽 사이드 사이 시각적 간격 확보.
|
||||
|
||||
25
modules/nuxt-ssr-paths-write.mjs
Normal file
25
modules/nuxt-ssr-paths-write.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineNuxtModule } from '@nuxt/kit'
|
||||
|
||||
/**
|
||||
* Nuxt 3.21 SSR 번들이 `#internal/nuxt/paths`를 외부 모듈로 두는데,
|
||||
* 기본 `paths.mjs` 템플릿은 `write: true`가 아니어서 `.nuxt/paths.mjs`가 디스크에 없고
|
||||
* Node가 `package.json`의 `imports`로 해석할 실제 파일이 없어 오류가 난다.
|
||||
* 동일 템플릿을 빌드 디렉터리에 기록하도록 한다.
|
||||
* @param {unknown} _options - 모듈 옵션(미사용)
|
||||
* @param {import('@nuxt/schema').Nuxt} nuxt - Nuxt 인스턴스
|
||||
* @returns {void}
|
||||
*/
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'nuxt-ssr-paths-write'
|
||||
},
|
||||
setup(_options, nuxt) {
|
||||
nuxt.hook('app:templates', (app) => {
|
||||
for (const template of app.templates) {
|
||||
if (template.filename === 'paths.mjs') {
|
||||
template.write = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,14 @@ export default defineNuxtConfig({
|
||||
devtools: {
|
||||
enabled: false
|
||||
},
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
modules: ['./modules/nuxt-ssr-paths-write.mjs', '@nuxtjs/tailwindcss'],
|
||||
/**
|
||||
* 기본 cssPath(assets/css/tailwind.css)가 없으면 패키지의 tailwind.css가 먼저 주입되어
|
||||
* main.css와 @tailwind 이중 로딩·스타일 꼬임이 날 수 있어 엔트리를 main.css로 통일한다.
|
||||
*/
|
||||
tailwindcss: {
|
||||
cssPath: '~/assets/css/main.css'
|
||||
},
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.59",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.59",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"nuxt": "^3.21.2",
|
||||
"postgres": "^3.4.9",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.58",
|
||||
"version": "0.0.66",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#internal/nuxt/paths": "./.nuxt/paths.mjs",
|
||||
"#internal/nitro": {
|
||||
"node": "./scripts/node-paths-nitro-shim.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-server.js",
|
||||
"build": "nuxt build",
|
||||
@@ -12,7 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"nuxt": "^3.21.2",
|
||||
"postgres": "^3.4.9",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
|
||||
@@ -88,6 +88,67 @@ const featuredPosts = computed(() => posts.value.slice(0, 6))
|
||||
const latestPosts = computed(() => posts.value.map(mapLatestPost))
|
||||
|
||||
const featuredTrackRef = ref(null)
|
||||
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
|
||||
const featuredAtStart = ref(true)
|
||||
/** Featured 트랙이 스크롤 끝에 붙었는지 — 다음 화살표 비활성 */
|
||||
const featuredAtEnd = ref(true)
|
||||
|
||||
let unbindFeaturedScroll = () => {}
|
||||
|
||||
/**
|
||||
* Featured 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다.
|
||||
* @param {HTMLElement | null} el - 스크롤 컨테이너
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateFeaturedScrollEdges = (el) => {
|
||||
const target = el || featuredTrackRef.value
|
||||
|
||||
if (!target) {
|
||||
featuredAtStart.value = true
|
||||
featuredAtEnd.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = target
|
||||
const maxScroll = Math.max(0, scrollWidth - clientWidth)
|
||||
const epsilon = 2
|
||||
|
||||
featuredAtStart.value = scrollLeft <= epsilon
|
||||
featuredAtEnd.value = scrollLeft >= maxScroll - epsilon
|
||||
}
|
||||
|
||||
watch(featuredTrackRef, (el) => {
|
||||
unbindFeaturedScroll()
|
||||
unbindFeaturedScroll = () => {}
|
||||
|
||||
if (!import.meta.client || !el) {
|
||||
updateFeaturedScrollEdges(null)
|
||||
return
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
updateFeaturedScrollEdges(el)
|
||||
}
|
||||
|
||||
onScroll()
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
const resizeObserver = new ResizeObserver(onScroll)
|
||||
resizeObserver.observe(el)
|
||||
unbindFeaturedScroll = () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(featuredPosts, () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
updateFeaturedScrollEdges(featuredTrackRef.value)
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) {
|
||||
@@ -107,6 +168,7 @@ onBeforeUnmount(() => {
|
||||
return
|
||||
}
|
||||
|
||||
unbindFeaturedScroll()
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
@@ -120,6 +182,14 @@ const scrollFeatured = (direction) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'left' && featuredAtStart.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'right' && featuredAtEnd.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstCard = featuredTrackRef.value.querySelector('[data-featured-slide]')
|
||||
const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 244
|
||||
const gap = 24
|
||||
@@ -161,13 +231,29 @@ const scrollFeatured = (direction) => {
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||
<div class="flex justify-between gap-2">
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Previous" @click="scrollFeatured('left')">‹</button>
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Next" @click="scrollFeatured('right')">›</button>
|
||||
<button
|
||||
class="featured-nav-prev cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
|
||||
type="button"
|
||||
aria-label="Featured 이전"
|
||||
:disabled="featuredAtStart"
|
||||
@click="scrollFeatured('left')"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="featured-nav-next cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
|
||||
type="button"
|
||||
aria-label="Featured 다음"
|
||||
:disabled="featuredAtEnd"
|
||||
@click="scrollFeatured('right')"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="featuredTrackRef"
|
||||
class="mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth pb-1 [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
class="featured-posts-track mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto overscroll-x-contain scroll-smooth pb-1 touch-pan-x [-webkit-overflow-scrolling:touch] [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="post in featuredPosts"
|
||||
|
||||
@@ -51,9 +51,9 @@ const submitSignIn = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-signin min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
|
||||
<section class="auth-signin min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-5 py-12 sm:px-10 lg:px-16">
|
||||
<div class="w-full max-w-[430px] rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:p-8">
|
||||
<div class="w-full max-w-[430px] p-5 sm:p-8">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
로그인
|
||||
</p>
|
||||
@@ -66,7 +66,7 @@ const submitSignIn = async () => {
|
||||
<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]"
|
||||
class="auth-form-input 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"
|
||||
>
|
||||
@@ -77,17 +77,11 @@ const submitSignIn = async () => {
|
||||
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="h-10 w-full bg-transparent px-3 text-sm outline-none"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
<button
|
||||
class="px-3 text-xs text-[#9ba3af] transition-opacity hover:opacity-80"
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? '숨기기' : '보기' }}
|
||||
</button>
|
||||
<AuthPasswordVisibilityToggle v-model="showPassword" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ const errors = reactive({
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const showSignupPassword = ref(false)
|
||||
const showSignupPasswordConfirm = ref(false)
|
||||
|
||||
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.')
|
||||
@@ -163,9 +166,9 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
|
||||
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24">
|
||||
<div class="flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
|
||||
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
|
||||
<div>
|
||||
<template v-if="currentStep === 1">
|
||||
<p class="text-[32px] font-semibold leading-tight sm:text-[40px]">
|
||||
@@ -189,7 +192,7 @@ onBeforeUnmount(() => {
|
||||
<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="auth-form-input 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"
|
||||
@@ -203,7 +206,7 @@ onBeforeUnmount(() => {
|
||||
<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="auth-form-input 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"
|
||||
@@ -215,13 +218,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<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"
|
||||
<div
|
||||
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
|
||||
:class="errors.password ? 'border-[#b03b43]' : 'border-[#1a212a]'"
|
||||
>
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showSignupPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showSignupPassword" />
|
||||
</div>
|
||||
<p v-if="errors.password" class="text-xs text-[#e05d67]">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
@@ -229,13 +237,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<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"
|
||||
<div
|
||||
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
|
||||
:class="errors.passwordConfirm ? 'border-[#b03b43]' : 'border-[#1a212a]'"
|
||||
>
|
||||
<input
|
||||
v-model="form.passwordConfirm"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showSignupPasswordConfirm ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showSignupPasswordConfirm" field-name="비밀번호 확인" />
|
||||
</div>
|
||||
<p v-if="errors.passwordConfirm" class="text-xs text-[#e05d67]">
|
||||
{{ errors.passwordConfirm }}
|
||||
</p>
|
||||
|
||||
15
scripts/node-paths-nitro-shim.mjs
Normal file
15
scripts/node-paths-nitro-shim.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 루트 `package.json`의 `imports`로 Node가 직접 해석할 때만 쓰인다.
|
||||
* 비개발용 `.nuxt/paths.mjs`가 `useRuntimeConfig().app`만 참조하므로 해당 형태만 맞춘다.
|
||||
* @returns {{ app: { baseURL: string, buildAssetsDir: string, cdnURL: string } }} 최소 런타임 설정
|
||||
*/
|
||||
export function useRuntimeConfig () {
|
||||
const env = process.env
|
||||
return {
|
||||
app: {
|
||||
baseURL: env.NUXT_APP_BASE_URL || env.NUXT_PUBLIC_APP_BASE_URL || '/',
|
||||
buildAssetsDir: env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/',
|
||||
cdnURL: env.NUXT_APP_CDN_URL || env.NUXT_PUBLIC_CDN_URL || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
12
server/api/search.get.js
Normal file
12
server/api/search.get.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { searchPublicContent } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 통합 검색 API(태그·게시물)
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 검색 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const raw = getQuery(event).q
|
||||
const q = Array.isArray(raw) ? raw[0] : raw
|
||||
return searchPublicContent(typeof q === 'string' ? q : '')
|
||||
})
|
||||
@@ -548,6 +548,82 @@ export const listTags = async () => {
|
||||
return rows.map(mapTagRow)
|
||||
}
|
||||
|
||||
const SEARCH_TAG_LIMIT = 12
|
||||
const SEARCH_POST_LIMIT = 12
|
||||
|
||||
/**
|
||||
* 공개 검색: 태그·게시물 제목·요약·본문에서 부분 일치(대소문자 무시)
|
||||
* @param {string} rawQuery - 검색어
|
||||
* @returns {Promise<{ tags: Array<{ name: string, slug: string }>, posts: Array<{ slug: string, title: string, excerpt: string }> }>} 태그·게시물 요약 결과
|
||||
*/
|
||||
export const searchPublicContent = async (rawQuery) => {
|
||||
const q = String(rawQuery || '').trim()
|
||||
if (!q) {
|
||||
return { tags: [], posts: [] }
|
||||
}
|
||||
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
const needle = q.toLowerCase()
|
||||
const posts = getSamplePosts()
|
||||
.filter((post) => {
|
||||
const hay = `${post.title}\n${post.excerpt || ''}\n${post.content || ''}`.toLowerCase()
|
||||
return hay.includes(needle)
|
||||
})
|
||||
.slice(0, SEARCH_POST_LIMIT)
|
||||
.map((post) => ({
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
excerpt: post.excerpt || ''
|
||||
}))
|
||||
const tags = getSampleTags()
|
||||
.filter((tag) => {
|
||||
const hay = `${tag.name}\n${tag.slug}`.toLowerCase()
|
||||
return hay.includes(needle)
|
||||
})
|
||||
.slice(0, SEARCH_TAG_LIMIT)
|
||||
.map((tag) => ({
|
||||
name: tag.name,
|
||||
slug: tag.slug
|
||||
}))
|
||||
return { tags, posts }
|
||||
}
|
||||
|
||||
const tagRows = await sql`
|
||||
SELECT name, slug
|
||||
FROM tags
|
||||
WHERE
|
||||
position(lower(${q}) in lower(name)) > 0
|
||||
OR position(lower(${q}) in lower(slug)) > 0
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
LIMIT ${SEARCH_TAG_LIMIT}
|
||||
`
|
||||
|
||||
const postRows = await sql`
|
||||
SELECT posts.slug, posts.title, posts.excerpt
|
||||
FROM posts
|
||||
WHERE posts.status = 'published'
|
||||
AND (posts.published_at IS NULL OR posts.published_at <= now())
|
||||
AND (
|
||||
position(lower(${q}) in lower(posts.title)) > 0
|
||||
OR position(lower(${q}) in lower(coalesce(posts.excerpt, ''))) > 0
|
||||
OR position(lower(${q}) in lower(posts.content)) > 0
|
||||
)
|
||||
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
||||
LIMIT ${SEARCH_POST_LIMIT}
|
||||
`
|
||||
|
||||
return {
|
||||
tags: tagRows.map((row) => ({ name: row.name, slug: row.slug })),
|
||||
posts: postRows.map((row) => ({
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
excerpt: row.excerpt || ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정 조회
|
||||
* @returns {Promise<Object>} 사이트 설정
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
export default {
|
||||
content: [
|
||||
'./components/**/*.{vue,js}',
|
||||
'./composables/**/*.{js,vue}',
|
||||
'./layouts/**/*.vue',
|
||||
'./modules/**/*.{js,mjs}',
|
||||
'./pages/**/*.vue',
|
||||
'./plugins/**/*.{js,mjs}',
|
||||
'./app.vue',
|
||||
'./error.vue'
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user