사용자 이동과 미디어 선택 개선
This commit is contained in:
57
app.vue
57
app.vue
@@ -18,6 +18,31 @@ const siteAccentStyle = computed(() => ({
|
||||
'--site-accent': siteAccentColor.value
|
||||
}))
|
||||
|
||||
const isSitePageNavigating = ref(false)
|
||||
|
||||
if (import.meta.client) {
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
/**
|
||||
* 공개 페이지 이동 로딩 오버레이를 표시한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const showSitePageNavigationOverlay = () => {
|
||||
isSitePageNavigating.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 페이지 이동 로딩 오버레이를 숨긴다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const hideSitePageNavigationOverlay = () => {
|
||||
isSitePageNavigating.value = false
|
||||
}
|
||||
|
||||
nuxtApp.hook('page:loading:start', showSitePageNavigationOverlay)
|
||||
nuxtApp.hook('page:loading:end', hideSitePageNavigationOverlay)
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
|
||||
link: appSiteSettings.value.faviconUrl
|
||||
@@ -35,8 +60,40 @@ useHead(() => ({
|
||||
<template>
|
||||
<div class="site-app" :style="siteAccentStyle">
|
||||
<NuxtLoadingIndicator :color="siteAccentColor" :height="3" :throttle="80" :duration="5000" />
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 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="isSitePageNavigating"
|
||||
class="site-page-loading fixed inset-0 z-[90] grid place-items-center bg-[var(--site-bg)]/78 text-[var(--site-text)] backdrop-blur-[2px]"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="site-page-loading__panel grid place-items-center gap-3 text-center">
|
||||
<span class="site-page-loading__spinner size-8 rounded-full border-2 border-[var(--site-line)] border-t-[var(--site-accent)]" aria-hidden="true" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-page-loading__spinner {
|
||||
animation: site-page-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes site-page-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,8 +102,17 @@ const {
|
||||
displayValue: mediaSearchInput,
|
||||
onInput: onMediaSearchInput,
|
||||
onCompositionEnd: onMediaSearchCompositionEnd
|
||||
} = useImeSearchInput(mediaSearchQuery)
|
||||
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
|
||||
const mediaPickerPage = ref(1)
|
||||
const mediaPickerPageSize = 50
|
||||
const mediaPickerPagination = ref({
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
})
|
||||
const selectedMediaUrls = ref([])
|
||||
const selectedMediaItemMap = ref({})
|
||||
const lastSelectionState = ref({
|
||||
start: 0,
|
||||
end: 0
|
||||
@@ -160,6 +169,18 @@ const mediaPickerAccept = computed(() => {
|
||||
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
|
||||
})
|
||||
|
||||
const mediaPickerKind = computed(() => {
|
||||
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
|
||||
return mediaPickerTarget.value
|
||||
}
|
||||
|
||||
return 'all'
|
||||
})
|
||||
|
||||
/** 작성 textarea 최소 높이(px) */
|
||||
const MIN_TEXTAREA_HEIGHT_PX = 620
|
||||
|
||||
@@ -3084,7 +3105,25 @@ const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
const response = await $fetch('/admin/api/media', {
|
||||
query: {
|
||||
paged: '1',
|
||||
tab: 'library',
|
||||
page: mediaPickerPage.value,
|
||||
pageSize: mediaPickerPageSize,
|
||||
kind: mediaPickerKind.value,
|
||||
search: mediaSearchQuery.value.trim() || undefined
|
||||
}
|
||||
})
|
||||
|
||||
mediaItems.value = response?.items || []
|
||||
mediaPickerPagination.value = response?.pagination || {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: mediaItems.value.length,
|
||||
pageCount: 1
|
||||
}
|
||||
mediaPickerPage.value = mediaPickerPagination.value.page || 1
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
@@ -3103,9 +3142,16 @@ const openMediaPicker = async (target) => {
|
||||
mediaPickerTarget.value = target
|
||||
activeMediaPickerTab.value = 'upload'
|
||||
mediaSearchQuery.value = ''
|
||||
mediaPickerPage.value = 1
|
||||
mediaPickerPagination.value = {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
}
|
||||
selectedMediaUrls.value = []
|
||||
selectedMediaItemMap.value = {}
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -3139,39 +3185,103 @@ const closeMediaPicker = () => {
|
||||
|
||||
isMediaPickerOpen.value = false
|
||||
selectedMediaUrls.value = []
|
||||
selectedMediaItemMap.value = {}
|
||||
activeMediaPickerTab.value = 'upload'
|
||||
mediaSearchQuery.value = ''
|
||||
mediaPickerPage.value = 1
|
||||
mediaItems.value = []
|
||||
mediaPickerPagination.value = {
|
||||
page: 1,
|
||||
pageSize: mediaPickerPageSize,
|
||||
total: 0,
|
||||
pageCount: 1
|
||||
}
|
||||
liveSlashInsertLine.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목이 검색어와 일치하는지 확인한다.
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {string} query - 소문자 검색어
|
||||
* @returns {boolean} 일치 여부
|
||||
* 현재 미디어 선택 페이지의 표시 목록
|
||||
* @returns {Array<Object>} 표시할 미디어 목록
|
||||
*/
|
||||
const mediaItemMatchesQuery = (item, query) => {
|
||||
if (!query) {
|
||||
return true
|
||||
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForTarget))
|
||||
|
||||
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
|
||||
|
||||
const mediaPickerRangeLabel = computed(() => {
|
||||
const total = Number(mediaPickerPagination.value.total) || 0
|
||||
|
||||
if (total <= 0) {
|
||||
return '0개'
|
||||
}
|
||||
|
||||
return [item.name, item.title, item.url]
|
||||
.some((value) => String(value || '').toLowerCase().includes(query))
|
||||
}
|
||||
const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value)
|
||||
const pageSize = Number(mediaPickerPagination.value.pageSize) || mediaPickerPageSize
|
||||
const start = ((page - 1) * pageSize) + 1
|
||||
const end = Math.min(start + pageSize - 1, total)
|
||||
|
||||
return `${total}개 중 ${start}-${end}개`
|
||||
})
|
||||
|
||||
const mediaPickerPageItems = computed(() => {
|
||||
const total = mediaPickerPageCount.value
|
||||
const current = Math.min(Math.max(Number(mediaPickerPage.value) || 1, 1), total)
|
||||
const pages = new Set([1, total, current])
|
||||
|
||||
for (let page = current - 2; page <= current + 2; page += 1) {
|
||||
pages.add(page)
|
||||
}
|
||||
|
||||
if (current <= 3) {
|
||||
pages.add(2)
|
||||
pages.add(3)
|
||||
pages.add(4)
|
||||
}
|
||||
|
||||
if (current >= total - 2) {
|
||||
pages.add(total - 3)
|
||||
pages.add(total - 2)
|
||||
pages.add(total - 1)
|
||||
}
|
||||
|
||||
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
|
||||
return orderedPages.reduce((items, page, index) => {
|
||||
const previousPage = orderedPages[index - 1]
|
||||
|
||||
if (previousPage && page - previousPage > 1) {
|
||||
items.push(`ellipsis-${previousPage}-${page}`)
|
||||
}
|
||||
|
||||
items.push(page)
|
||||
return items
|
||||
}, [])
|
||||
})
|
||||
|
||||
/**
|
||||
* 검색어로 필터링된 미디어 목록
|
||||
* @returns {Array<Object>} 필터링된 미디어 목록
|
||||
* 미디어 선택 모달 페이지를 변경한다.
|
||||
* @param {number} page - 이동할 페이지
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const filteredMediaItems = computed(() => {
|
||||
const query = mediaSearchQuery.value.trim().toLowerCase()
|
||||
const targetItems = mediaItems.value.filter(isMediaItemSelectableForTarget)
|
||||
const setMediaPickerPage = async (page) => {
|
||||
mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value)
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return targetItems
|
||||
watch(mediaSearchQuery, async () => {
|
||||
if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
return targetItems.filter((item) => mediaItemMatchesQuery(item, query))
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems()
|
||||
})
|
||||
|
||||
watch(activeMediaPickerTab, async (tab) => {
|
||||
if (!isMediaPickerOpen.value || tab !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -3182,12 +3292,25 @@ const filteredMediaItems = computed(() => {
|
||||
const toggleMediaSelection = (mediaItem) => {
|
||||
if (!isGalleryMediaPicker.value) {
|
||||
selectedMediaUrls.value = [mediaItem.url]
|
||||
selectedMediaItemMap.value = {
|
||||
[mediaItem.url]: mediaItem
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedMediaUrls.value = selectedMediaUrls.value.includes(mediaItem.url)
|
||||
? selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
|
||||
: [...selectedMediaUrls.value, mediaItem.url]
|
||||
if (selectedMediaUrls.value.includes(mediaItem.url)) {
|
||||
selectedMediaUrls.value = selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
|
||||
const nextMap = { ...selectedMediaItemMap.value }
|
||||
delete nextMap[mediaItem.url]
|
||||
selectedMediaItemMap.value = nextMap
|
||||
return
|
||||
}
|
||||
|
||||
selectedMediaUrls.value = [...selectedMediaUrls.value, mediaItem.url]
|
||||
selectedMediaItemMap.value = {
|
||||
...selectedMediaItemMap.value,
|
||||
[mediaItem.url]: mediaItem
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3267,7 +3390,7 @@ const insertSelectedMediaItems = (selectedItems) => {
|
||||
*/
|
||||
const applyMediaSelection = () => {
|
||||
const selectedItems = selectedMediaUrls.value
|
||||
.map((url) => mediaItems.value.find((item) => item.url === url))
|
||||
.map((url) => selectedMediaItemMap.value[url] || mediaItems.value.find((item) => item.url === url))
|
||||
.filter(Boolean)
|
||||
|
||||
insertSelectedMediaItems(selectedItems)
|
||||
@@ -3381,7 +3504,9 @@ const uploadAndInsert = async (files, target = 'image') => {
|
||||
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
|
||||
}
|
||||
|
||||
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
|
||||
if (isMediaPickerOpen.value && activeMediaPickerTab.value === 'library') {
|
||||
await fetchMediaItems()
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('error', resolveUploadFetchErrorMessage(error))
|
||||
} finally {
|
||||
@@ -3414,19 +3539,29 @@ const uploadFromMediaModal = async (files) => {
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadMediaFiles(files)
|
||||
mediaItems.value = await $fetch('/admin/api/media').catch(() => [
|
||||
...uploadedFiles,
|
||||
...mediaItems.value
|
||||
])
|
||||
const uploadedUrls = uploadedFiles
|
||||
.filter(isMediaItemSelectableForTarget)
|
||||
.map((file) => file.url)
|
||||
selectedMediaItemMap.value = uploadedFiles.reduce((map, file) => {
|
||||
if (uploadedUrls.includes(file.url)) {
|
||||
map[file.url] = file
|
||||
}
|
||||
|
||||
return map
|
||||
}, {})
|
||||
|
||||
selectedMediaUrls.value = isGalleryMediaPicker.value
|
||||
? uploadedUrls
|
||||
: uploadedUrls.slice(0, 1)
|
||||
activeMediaPickerTab.value = 'library'
|
||||
mediaSearchQuery.value = ''
|
||||
mediaPickerPage.value = 1
|
||||
await fetchMediaItems().catch(() => {
|
||||
mediaItems.value = [
|
||||
...uploadedFiles,
|
||||
...mediaItems.value
|
||||
]
|
||||
})
|
||||
showToast('success', '업로드가 완료되어 파일이 선택되었습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', resolveUploadFetchErrorMessage(error))
|
||||
@@ -3898,23 +4033,61 @@ const handleKeydown = (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
|
||||
<button
|
||||
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploading"
|
||||
@click="closeMediaPicker"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploading || selectedMediaUrls.length === 0"
|
||||
@click="applyMediaSelection"
|
||||
>
|
||||
삽입
|
||||
</button>
|
||||
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#e3e6e8] px-5 py-4">
|
||||
<div class="admin-markdown-editor__media-pagination flex flex-wrap items-center gap-2 text-xs font-semibold text-[#6b7280]">
|
||||
<span>{{ mediaPickerRangeLabel }}</span>
|
||||
<nav v-if="mediaPickerPageCount > 1" class="flex flex-wrap items-center gap-1" aria-label="미디어 선택 페이지">
|
||||
<button
|
||||
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isLoadingMedia || mediaPickerPage <= 1"
|
||||
aria-label="이전 페이지"
|
||||
@click="setMediaPickerPage(mediaPickerPage - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<template v-for="item in mediaPickerPageItems" :key="item">
|
||||
<span v-if="typeof item === 'string'" class="px-1 text-[#8e9cac]">...</span>
|
||||
<button
|
||||
v-else
|
||||
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full text-xs font-semibold transition"
|
||||
:class="item === mediaPickerPage ? 'bg-[#15171a] text-white' : 'text-[#8e9cac] hover:bg-[#f1f3f5] hover:text-[#394047]'"
|
||||
type="button"
|
||||
:disabled="isLoadingMedia"
|
||||
@click="setMediaPickerPage(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="admin-markdown-editor__media-page-button grid size-8 place-items-center rounded-full border border-[#d7dde2] bg-white text-[#394047] transition hover:border-[#8e9cac] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isLoadingMedia || mediaPickerPage >= mediaPickerPageCount"
|
||||
aria-label="다음 페이지"
|
||||
@click="setMediaPickerPage(mediaPickerPage + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="admin-markdown-editor__media-actions flex items-center justify-end gap-2">
|
||||
<button
|
||||
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploading"
|
||||
@click="closeMediaPicker"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="isUploading || selectedMediaUrls.length === 0"
|
||||
@click="applyMediaSelection"
|
||||
>
|
||||
삽입
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.10.28에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.10.42에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-07-06 v1.10.42 — 이동 중 화면은 이전 화면을 가리고 준비 후 보여 준다
|
||||
|
||||
게시물 상세 이동을 블로킹 조회로 되돌려도 브라우저 스크롤 제어 타이밍에 따라 이전 화면이 순간적으로 최상단처럼 보이면 사용자는 화면이 튄다고 느낀다. 이번 수정은 즉시 전환·스켈레톤 방식으로 다시 흔들기보다, 이동 중 전체 화면 로딩 오버레이를 표시해 이전 화면의 중간 상태를 가리고 준비된 다음 화면만 보이게 한다.
|
||||
|
||||
글쓰기 이미지 선택 모달은 미디어가 수천·수만 개로 늘어날 수 있으므로 전체 원본 배열을 한 번에 받아 클라이언트에서 필터링하지 않는다. 이미 관리자 미디어 API가 사용처 제목 검색과 페이지 응답을 지원하므로, 모달도 같은 서버 페이지 응답을 사용해 파일명과 게시물·페이지 제목으로 좁히고 현재 페이지 단위로만 표시한다.
|
||||
|
||||
## 2026-07-03 v1.10.28 — 공개 검색은 정규화 컬럼과 trigram 인덱스를 쓴다
|
||||
|
||||
게시물이 수천 개로 늘면 통합 검색 입력마다 `lower(content)`를 직접 계산하는 방식은 본문 길이에 비례해 비용이 커진다. 공개 검색은 부분 문자열 검색과 한글 검색이 중요하므로 PostgreSQL full-text search보다 `pg_trgm` 인덱스를 먼저 사용한다. 게시물과 페이지에는 업로드 경로·마크다운 이미지 토큰을 제거한 `search_text` 컬럼을 두고, DB 트리거가 저장·수정 시 자동 갱신한다.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| layouts/post.vue | 개별 게시물 — `default`와 동일, 오른쪽 컬럼 전체 높이 구분선 |
|
||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
|
||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크, 페이지 제목이 있으면 제목만 쓰고 없으면 사이트 이름을 쓰는 title template, 브랜드 컬러 `--site-accent` 적용, 페이지 이동 로딩 진행바(`NuxtLoadingIndicator`, 브랜드 컬러) |
|
||||
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크, 페이지 제목이 있으면 제목만 쓰고 없으면 사이트 이름을 쓰는 title template, 브랜드 컬러 `--site-accent` 적용, 페이지 이동 로딩 진행바(`NuxtLoadingIndicator`, 브랜드 컬러)와 전체 화면 이동 오버레이 |
|
||||
| error.vue | 공개 404/서버 오류 화면, 홈 이동 버튼 |
|
||||
|
||||
## Composables
|
||||
@@ -137,7 +137,7 @@
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
|
||||
@@ -144,7 +144,8 @@
|
||||
### 공개 URL 구조
|
||||
|
||||
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다.
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/post/:slug` - 개별 게시물 상세. 클라이언트 내부 이동도 본문을 블로킹 조회한 뒤 전환하며, 이동 중 `NuxtLoadingIndicator` 상단 진행바와 전체 화면 로딩 오버레이·중복 클릭 잠금으로 피드백을 제공한다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다.
|
||||
- `/` - 홈. 클라이언트 복귀 시에도 주요 데이터를 블로킹 조회한 뒤 전환한다.
|
||||
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
|
||||
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
|
||||
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
|
||||
@@ -204,7 +205,7 @@ layouts/
|
||||
- 관리자 글 목록의 대표 이미지 미리보기는 작은 카드 썸네일이 있으면 `featuredImageThumbnail`을 우선 사용하고, 없을 때만 원본 `featuredImage`를 fallback으로 사용한다.
|
||||
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 태그 추천 목록을 열 수 있다. 입력이 비어 있으면 공개 발행글 기준 최근 사용 태그 5개를 먼저 표시한 뒤 메인 태그를 이어서 보여 주고, 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천한다. 추천 목록은 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.
|
||||
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. 미디어 그리드와 글쓰기 이미지 선택 모달은 원본 삽입·상세 확인은 유지하되, 목록 미리보기에는 가능한 작은 본문 720px 파생 이미지나 카드 썸네일을 우선 사용한다.
|
||||
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 미디어 관리 화면은 `/admin/api/media?paged=1` 응답을 사용해 서버에서 탭·폴더·파일 종류·미사용·검색 조건을 적용한 뒤 50·100·200개 단위 페이지네이션을 제공하며 번호와 이전·다음 버튼으로 이동한다. 현재 페이지 선택은 화면에 표시된 삭제 가능 항목만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다. 미디어 그리드와 글쓰기 이미지 선택 모달은 원본 삽입·상세 확인은 유지하되, 목록 미리보기에는 가능한 작은 본문 720px 파생 이미지나 카드 썸네일을 우선 사용한다. 글쓰기 이미지 선택 모달의 라이브러리 탭도 `/admin/api/media?paged=1&tab=library` 서버 페이지 응답을 사용하며 파일명과 사용처 제목(게시물·페이지 제목) 검색, 번호형 페이지 이동을 제공한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.10.42
|
||||
|
||||
- 사용자 화면 페이지 이동 중 전체 로딩 오버레이를 표시해 이전 화면 스크롤 튐이 보이지 않도록 수정.
|
||||
- 게시물 상세 인접 글 방향을 `PREVIOUS=과거 글`, `NEXT=최신 글` 기준으로 수정.
|
||||
- 글쓰기 이미지 선택 모달이 전체 미디어 배열 대신 `/admin/api/media?paged=1` 서버 페이지 응답을 사용하도록 수정.
|
||||
- 이미지 선택 모달에 파일명·사용처 제목 검색과 번호형 페이지 이동을 추가.
|
||||
|
||||
## v1.10.41
|
||||
|
||||
- 즉시 전환·스켈레톤·커스텀 스크롤 제어를 제거하고 블로킹 로딩 방식으로 복귀.
|
||||
- 게시물·홈 클라이언트 이동 시 데이터 준비 후 전환(`NuxtLoadingIndicator`·중복 클릭 잠금 유지).
|
||||
- `app/router.options.js`, `navigation-scroll.client.js`, `usePageReadyGate`, `site-navigation-scroll.js` 삭제.
|
||||
- 상세 API 인접 글(`previous`/`next`) 최적화는 유지.
|
||||
|
||||
## v1.10.40
|
||||
|
||||
- 앞으로 이동 후에도 이전 스크롤 위치가 유지되던 문제 수정.
|
||||
- `scrollBehavior` 비동기 최상단 이동을 제거하고, 앞으로 이동은 `afterEach`에서 `window.scrollTo(0, 0)`로 확실히 초기화.
|
||||
- 뒤로가기(lazy 홈·게시물)만 복원 좌표를 저장해 본문 준비 후 복원.
|
||||
|
||||
## v1.10.39
|
||||
|
||||
- 페이지 전환 시 스크롤이 최상단으로 튀며 깜빡이던 현상 완화.
|
||||
- `app/router.options.js` 추가: 앞으로 이동은 다음 페인트 후 최상단 이동, 뒤로가기는 lazy 홈·게시물 본문 준비 후 스크롤 복원.
|
||||
- `usePageReadyGate`·`navigation-scroll.client.js`로 lazy 로딩과 스크롤 복원 타이밍 동기화.
|
||||
- `NuxtPage` 짧은 opacity 전환(0.12s) 추가, 로딩 스켈레톤 `min-h-[70vh]`로 높이 점프 완화.
|
||||
|
||||
## v1.10.38
|
||||
|
||||
- 게시물·홈 클라이언트 이동을 즉시 전환하도록 변경(`lazy: import.meta.client`). SSR 직접 접속은 기존처럼 블로킹 유지.
|
||||
- 게시물 상세 로딩 중 스켈레톤 표시, 홈 복귀 시 본문 영역 스켈레톤 표시(히어로는 캐시 설정 즉시 노출).
|
||||
- 게시물 없음 404는 클라이언트에서 fetch 완료 후 `showError`로 처리.
|
||||
|
||||
## v1.10.37
|
||||
|
||||
- 게시물 상세 이동 지연 개선: 본문만 블로킹 조회, 태그·광고는 lazy로 전환해 이동 후 채움.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.28",
|
||||
"version": "1.10.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.28",
|
||||
"version": "1.10.42",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.10.37",
|
||||
"version": "1.10.42",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '~/lib/display-count-settings.js'
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
key: 'site-settings-public',
|
||||
default: () => ({})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ definePageMeta({
|
||||
const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
// 본문은 화면 구성의 필수 데이터이므로 블로킹으로 받아 이동 시점에 완성된 글을 보여준다.
|
||||
// 이전·다음 글은 상세 응답에 포함되어 별도 목록 요청 없이 즉시 표시된다.
|
||||
/**
|
||||
* 본문은 블로킹으로 받아 로딩 표시 후 완성된 화면으로 전환한다.
|
||||
* 이전·다음 글은 상세 응답에 포함되어 별도 목록 요청 없이 표시된다.
|
||||
*/
|
||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||
// 태그 표시명/색, 광고는 이동을 막지 않도록 lazy로 받아 이동 후 채운다.
|
||||
const { data: tags } = useLazyFetch('/api/tags?tagType=all', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
@@ -1086,7 +1086,7 @@ export const getPostBySlug = async (slug, { includeMembership = false } = {}) =>
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 게시물 상세의 이전(더 최신)·다음(더 예전) 글을 조회한다.
|
||||
* 공개 게시물 상세의 이전(더 예전)·다음(더 최신) 글을 조회한다.
|
||||
* 목록과 같은 정렬(발행일 DESC) 기준으로 인접 글의 제목·슬러그만 반환한다.
|
||||
* @param {string} slug - 기준 게시물 슬러그
|
||||
* @param {{ includeMembership?: boolean }} [options] - 조회 옵션
|
||||
@@ -1109,8 +1109,8 @@ export const getAdjacentPosts = async (slug, { includeMembership = false } = {})
|
||||
}
|
||||
const toNeighbor = (post) => (post ? { title: post.title, slug: post.slug } : null)
|
||||
return {
|
||||
previous: index > 0 ? toNeighbor(samplePosts[index - 1]) : null,
|
||||
next: index < samplePosts.length - 1 ? toNeighbor(samplePosts[index + 1]) : null
|
||||
previous: index < samplePosts.length - 1 ? toNeighbor(samplePosts[index + 1]) : null,
|
||||
next: index > 0 ? toNeighbor(samplePosts[index - 1]) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1147,8 +1147,8 @@ export const getAdjacentPosts = async (slug, { includeMembership = false } = {})
|
||||
}
|
||||
|
||||
return {
|
||||
previous: row.previous_slug ? { title: row.previous_title, slug: row.previous_slug } : null,
|
||||
next: row.next_slug ? { title: row.next_title, slug: row.next_slug } : null
|
||||
previous: row.next_slug ? { title: row.next_title, slug: row.next_slug } : null,
|
||||
next: row.previous_slug ? { title: row.previous_title, slug: row.previous_slug } : null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user