24 Commits

Author SHA1 Message Date
b4e4e37f5a 사이트 로고 캐시 갱신 보강 2026-05-15 11:00:48 +09:00
536ee7079e 일반 태그 배지 목록 정리 2026-05-15 10:50:25 +09:00
9e544d97fa 운영 업로드 파일 제공 경로 보강 2026-05-15 10:27:25 +09:00
20b901d4a1 관리자 멤버 썸네일 업로드 경로 수정 2026-05-15 10:11:02 +09:00
0ed848a2eb 사이드바 행 호버를 #F7F4EF로 완화하고 v1.1.3으로 갱신
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 18:51:27 +09:00
08f0aa0efa 태그 없는 게시물에서 POST 더미 표시 제거(v1.1.2) 2026-05-14 18:42:01 +09:00
17dcd04339 공개 문단에서 leading-7 제거, text-base만 유지(v1.1.1) 2026-05-14 17:56:05 +09:00
36625de1eb 타이포 조정 및 v1.1.0
관리자 제목 text-3xl, 공개 문단 text-base·leading-7, ProseHeading mt-12 제거.
문서·맵·명세 반영.
2026-05-14 17:52:34 +09:00
62e501f8d0 수정 모드 줄바꿈 표식 개선 2026-05-14 17:09:10 +09:00
849e86802f 미리보기 집중 모드와 빈 줄 공백 보존 2026-05-14 17:01:04 +09:00
5da93b9aa4 글쓰기 문단 입력과 편집 영역 정리 2026-05-14 16:51:30 +09:00
113c974ee5 본문 문단과 줄바꿈 처리 정리 2026-05-14 16:33:30 +09:00
f5bfb560e2 본문 빈 줄 간격 보존 2026-05-14 16:24:40 +09:00
941355cae9 레거시 본문 저장 형식 정규화 2026-05-14 16:12:22 +09:00
91b7369a07 마크다운 에디터 붙여넣기와 미디어 편집 개선 2026-05-14 16:03:12 +09:00
5eb6c88381 AdminMarkdownEditor: 논리 줄 번호 거터·현재 줄 강조(v1.0.12)
textarea 왼쪽 거터, 스크롤 동기화, 미리보기 문구 오타 수정.
명세·맵·이력 반영.
2026-05-14 15:49:44 +09:00
eab81697e5 관리자 에디터를 마크다운 우선 방식으로 개편 2026-05-14 15:12:23 +09:00
88a0860078 관리자 블록 에디터를 태그 v1.0.5 시점으로 복원(v1.0.10)
v1.0.6 이후 붙여넣기 분할·Cmd+A MD 복사·블록 범위 선택 등 제거.
명세·맵·이력·업데이트 동기화.
2026-05-14 14:53:55 +09:00
35c378c8f5 블록 범위 레인 드래그: 행 간 margin에서도 인덱스 추적(v1.0.9)
elementFromPoint 실패 시 루트 내 행 박스와 clientY 거리로 스냅.
레인 히트 폭 소폭 확대. 문서 반영.
2026-05-14 14:49:08 +09:00
bd0e2ad120 관리자 블록 에디터 범위 선택 보완 및 복사 시 네이티브 우선(v1.0.8)
블록 범위가 있어도 contenteditable 비접힘 선택·textarea/input 선택 시 copy 가로채기 생략.
문서·버전 v1.0.8 반영.
2026-05-14 14:42:08 +09:00
1b035de16c Docker 런타임 환경 변수 우선 적용 2026-05-14 13:48:23 +09:00
4862b52b3a 관리자 부트스트랩 복구 보강 2026-05-14 13:36:51 +09:00
6367e62ef0 관리자 최초 로그인 보강 2026-05-14 12:39:55 +09:00
1487e9da76 Docker 네트워크 충돌 대응 2026-05-14 12:26:10 +09:00
50 changed files with 3001 additions and 364 deletions

View File

@@ -31,3 +31,4 @@ NUXT_PUBLIC_SITE_TITLE=sori.studio
# Server
APP_PORT=43118
DOCKER_SUBNET=10.250.50.0/24

View File

@@ -216,6 +216,27 @@
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
/**
* 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 등 행 호버 — 라이트 테마에서 밝은 크림 톤, 다크는 패널 대비 유지
*/
.site-sidebar-nav-row {
transition: background-color 0.2s ease;
}
.site-sidebar-nav-row:hover {
background-color: #f7f4ef;
}
:root[data-theme='dark'] .site-sidebar-nav-row:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) .site-sidebar-nav-row:hover {
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
}
}
/**
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
*/

File diff suppressed because it is too large Load Diff

View File

@@ -191,12 +191,23 @@ const uploadAvatar = async (event) => {
try {
const formData = new FormData()
formData.append('files', file)
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.avatarUrl = result.files?.[0]?.url || ''
formData.append('file', file)
const result = isNewMember.value
? await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
method: 'POST',
body: formData
})
form.avatarUrl = result.avatarUrl || ''
if (!isNewMember.value) {
emit('saved', result)
savedMemberSnapshot.value = serializeMemberPayload()
saveMessage.value = '썸네일이 변경되었습니다.'
}
} catch (error) {
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
} finally {

View File

@@ -1,4 +1,6 @@
<script setup>
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
const props = defineProps({
initialPost: {
type: Object,
@@ -101,7 +103,7 @@ const form = reactive({
title: props.initialPost.title || '',
slug: props.initialPost.slug || '',
excerpt: props.initialPost.excerpt || '',
content: props.initialPost.content || '',
content: normalizeMarkdownContent(props.initialPost.content),
featuredImage: props.initialPost.featuredImage || '',
noindex: Boolean(props.initialPost.noindex),
status: props.initialPost.status || 'draft',
@@ -297,7 +299,7 @@ const createPostPayload = () => {
title: form.title.trim(),
slug: toSlug(form.slug || form.title),
excerpt: form.excerpt.trim(),
content: form.content,
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage.trim() || null,
seoTitle: form.title.trim(),
seoDescription: form.excerpt.trim(),
@@ -326,7 +328,7 @@ const createAutosavePayload = () => ({
title: form.title,
slug: form.slug,
excerpt: form.excerpt,
content: form.content,
content: normalizeMarkdownContent(form.content),
featuredImage: form.featuredImage,
noindex: form.noindex,
status: form.status,
@@ -407,7 +409,10 @@ const restoreAutosave = () => {
}
isRestoringAutosave.value = true
Object.assign(form, autosaveNotice.value.payload)
Object.assign(form, {
...autosaveNotice.value.payload,
content: normalizeMarkdownContent(autosaveNotice.value.payload.content)
})
slugTouched.value = Boolean(form.slug)
autosaveStatus.value = `${formatAutosaveTime(autosaveNotice.value.savedAt)} 자동 저장본 복원됨`
autosaveNotice.value = null
@@ -895,7 +900,7 @@ defineExpose({
<input
v-model="form.title"
class="admin-post-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-5xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
class="admin-post-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="제목"
required
@@ -905,7 +910,7 @@ defineExpose({
>
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
<AdminMarkdownEditor ref="blockEditor" v-model="form.content" />
</div>
</section>
</main>

View File

@@ -11,6 +11,10 @@ const props = defineProps({
saving: {
type: Boolean,
default: false
},
defaultTagType: {
type: String,
default: 'general'
}
})
@@ -64,7 +68,7 @@ const submitTag = () => {
description: form.description.trim(),
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || 'general'
tagType: props.initialTag.tagType || props.defaultTagType
})
}
</script>

View File

@@ -99,6 +99,52 @@ const parseImageLine = (line) => {
}
}
/**
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 블록 시작 여부
*/
const isMarkdownBlockStart = (line) => {
const trimmedLine = line.trim()
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
trimmedLine === '>>>' ||
trimmedLine === ':::bookmark' ||
trimmedLine === ':::signup' ||
trimmedLine === ':::gallery' ||
trimmedLine === ':::embed' ||
trimmedLine.startsWith(':::callout') ||
trimmedLine.startsWith(':::toggle') ||
trimmedLine.startsWith('```') ||
trimmedLine === '---' ||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
trimmedLine.startsWith('> ') ||
/^- /.test(trimmedLine) ||
/^\d+\.\s+/.test(trimmedLine) ||
Boolean(parseImageLine(trimmedLine))
}
/**
* 마크다운 hard break 표식이 있는 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} hard break 여부
*/
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
/**
* 문단 행에서 hard break 표식을 제거한다.
* @param {string} line - 마크다운 행
* @returns {string} 정리된 문단 행
*/
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
* @returns {string} Tailwind 높이 클래스
*/
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
/**
* 닫힘 표식까지의 행 목록을 반환
* @param {Array<string>} lines - 전체 마크다운 행
@@ -225,12 +271,13 @@ const parseMarkdownBlocks = (markdown) => {
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createBlock('paragraph', '', null, `block-${blocks.length}`))
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
index += 1
continue
}
if (!trimmedLine) {
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
index += 1
continue
}
@@ -379,8 +426,24 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
const paragraphLines = [cleanParagraphLine(line)]
let shouldJoinNextLine = hasMarkdownHardBreak(line)
index += 1
while (shouldJoinNextLine && index < lines.length) {
const nextLine = lines[index]
const nextTrimmedLine = nextLine.trim()
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
break
}
paragraphLines.push(cleanParagraphLine(nextLine))
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
index += 1
}
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
}
return blocks
@@ -389,6 +452,72 @@ const parseMarkdownBlocks = (markdown) => {
const blocks = computed(() => parseMarkdownBlocks(props.content))
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
*/
const parseInlineSegments = (value) => {
const source = String(value || '')
const segments = []
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
let lastIndex = 0
let match = pattern.exec(source)
while (match) {
if (match.index > lastIndex) {
segments.push({
type: 'text',
text: source.slice(lastIndex, match.index)
})
}
if (match[2] && match[3]) {
segments.push({
type: 'link',
text: match[2],
href: match[3]
})
} else if (match[4]) {
segments.push({
type: 'strong',
text: match[4]
})
} else if (match[5]) {
segments.push({
type: 'code',
text: match[5]
})
} else if (match[6]) {
segments.push({
type: 'em',
text: match[6]
})
}
lastIndex = pattern.lastIndex
match = pattern.exec(source)
}
if (lastIndex < source.length) {
segments.push({
type: 'text',
text: source.slice(lastIndex)
})
}
return segments.length ? segments : [{ type: 'text', text: source }]
}
/**
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
*/
const parseInlineSegmentLines = (value) => {
return String(value || '').split('\n').map(parseInlineSegments)
}
/**
* 라이트박스를 연다
* @param {Array<Object>} images - 이미지 목록
@@ -431,15 +560,34 @@ const showNextImage = () => {
<template>
<div class="content-markdown-renderer">
<template v-for="block in blocks" :key="block.id">
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
{{ block.text }}
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseHeading>
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
{{ block.text }}
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseBlockquote>
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
{{ item }}
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</li>
</ProseList>
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
@@ -451,10 +599,22 @@ const showNextImage = () => {
:emoji="block.calloutEmoji"
:background="block.calloutBackground"
>
{{ block.text }}
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseCallout>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
{{ block.text }}
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</ProseToggle>
<ProseBookmark
v-else-if="block.type === 'bookmark' && block.meta.url"
@@ -487,8 +647,17 @@ const showNextImage = () => {
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
<p v-else class="content-markdown-renderer__paragraph my-5 text-[15px] leading-8 text-[var(--site-text)]">
{{ block.text }}
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
<br v-if="lineIndex > 0">
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</template>
</p>
</template>

View File

@@ -12,7 +12,7 @@ const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
<template>
<component
:is="tagName"
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
:class="{
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,

View File

@@ -158,7 +158,7 @@ onMounted(() => {
<NuxtLink
v-for="tag in tags"
:key="tag.id"
class="left-sidebar__category site-panel-hover group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
:to="`/tag/${tag.slug}`"
>
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
@@ -206,7 +206,7 @@ onMounted(() => {
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
class="left-sidebar__theme-dot site-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"

View File

@@ -28,7 +28,7 @@ defineProps({
{{ post.excerpt }}
</p>
<p class="post-card__meta mt-2 text-xs site-muted">
{{ post.publishedAt }} / {{ post.tag }}
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
</p>
</div>
</div>

View File

@@ -23,9 +23,9 @@ const navBarBeforeInactive =
const navBarBeforeActive =
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
/** 행 공통: site-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
const navRowShell =
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
'site-sidebar-nav-row flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
/**
* 노드가 펼쳐져 있는지

View File

@@ -12,6 +12,8 @@ services:
- ./public/uploads:/app/public/uploads
depends_on:
- sori-studio-db
networks:
- sori-studio-network
restart: unless-stopped
sori-studio-db:
@@ -27,8 +29,18 @@ services:
- "${DB_PORT:-43119}:5432"
volumes:
- sori-studio-postgres:/var/lib/postgresql/data
# NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인.
- ./db/migrations:/docker-entrypoint-initdb.d:ro
networks:
- sori-studio-network
restart: unless-stopped
volumes:
sori-studio-postgres:
networks:
sori-studio-network:
driver: bridge
ipam:
config:
- subnet: ${DOCKER_SUBNET:-10.250.50.0/24}

View File

@@ -1,5 +1,85 @@
# 업데이트 요약
## v1.1.7
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
## v1.1.6
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
## v1.1.5
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
## v1.1.4
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
- 태그 목록 카드 그리드 여백 수정 반영.
## v1.0.19
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
- 기존 공백 2개 hard break도 계속 렌더링되도록 호환 처리.
## v1.0.18
- 여러 줄을 비워둔 경우 미리보기와 공개 본문에서도 비운 만큼 공백이 보이도록 보강.
- 미리보기 모드에서 편집 툴바와 카드형 패널 외곽을 숨겨 본문만 보이게 정리.
- 줄 번호 영역의 스크롤바를 숨겨 작성 화면을 더 차분하게 정리.
## v1.0.17
- 글쓰기 영역의 보더와 카드형 배경을 제거해 본문 편집 화면을 더 가볍게 정리.
- 줄 번호를 본문 바깥에 띄우고 현재 줄 액센트 배경을 제거.
- Enter는 한 줄만 내려가는 새 문단으로, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 조정.
- 문단과 제목 아래 기본 간격을 10px 기준으로 정리.
## v1.0.16
- 글쓰기에서 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 정리.
- 미리보기 전환 후 작성 모드로 돌아오면 기존 커서 위치에서 계속 입력할 수 있도록 개선.
- 공개 본문과 관리자 미리보기의 문단 간격을 24px 기준으로 통일.
## v1.0.15
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
## v1.0.14
- Markdown-first 전환 후 레거시 블록 본문이나 기존 자동 저장본 때문에 게시물 발행이 막히는 문제를 보강.
## v1.0.13
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
## v1.0.11
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
## v1.0.5
- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강.
## v1.0.4
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
## v1.0.3
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
## v1.0.2
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
- 배포 문서의 운영 환경 변수 생성 안내를 정리.
## v1.0.1
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
## v1.0.0
- 운영 시작 기준 버전.

View File

@@ -26,7 +26,7 @@
- TailwindCSS 기본 사용
- 다크 인증(`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로 작성하되 저장 값은 기존 마크다운 문자열을 유지
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다.
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).

View File

@@ -117,7 +117,7 @@ ssh [NAS_IP]
```bash
# 프로젝트 디렉토리로 이동
cd /volume1/docker/sori.studio
cd /volume1/docker/projects/apps/
# 프로젝트 클론
git clone https://git.sori.studio/zenn/sori.studio.git
@@ -129,22 +129,22 @@ cd sori.studio
# .env.production은 Git에 올리지 않는 운영 전용 파일
cp .env.example .env.production
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
# Docker 빌드 및 실행
docker compose --env-file .env.production up -d
docker compose --env-file .env.production up -d --build
```
### 프로덕션 빌드 (NAS에서)
### Docker 네트워크 충돌 대응
NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.
```bash
# 프로덕션 빌드
npm run build
# 또는 Docker로 빌드
docker build -t sori.studio:latest .
docker run -d -p 3000:3000 sori.studio:latest
DOCKER_SUBNET=10.250.51.0/24
docker compose --env-file .env.production up -d --build
```
### 포트
@@ -153,6 +153,7 @@ docker run -d -p 3000:3000 sori.studio:latest
- NAS Docker 외부: 43118
- 컨테이너 내부: 3000
- PostgreSQL 외부: 43119
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
- HTTPS: 3001 (SSL 설정 시)
---
@@ -165,10 +166,12 @@ docker run -d -p 3000:3000 sori.studio:latest
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다.
### 이메일 인증(Resend, 선택)
@@ -261,12 +264,67 @@ git diff -- . ':!package-lock.json'
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
### 컨테이너가 `Restarting`일 때
`Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다.
1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`).
```bash
docker ps -a --filter "name=sori-studio"
```
2. **해당 컨테이너 로그** (가장 중요).
```bash
docker logs sori-studio --tail 150
docker logs sori-studio-db --tail 150
```
Compose로 올렸다면:
```bash
docker compose --env-file .env.production logs sori-studio --tail 200
docker compose --env-file .env.production logs sori-studio-db --tail 200
```
3. **자주 나오는 원인**
- **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료.
- **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제.
- **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다.
4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다.
### NAS·호스트에서 `db/migrations` 권한
`docker-compose.yml``./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`**읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다.
프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다.
```bash
cd /volume1/docker/projects/apps/sori.studio
# 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능
sudo chmod -R a+rX db/migrations
# 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용
sudo chmod a+x . db db/migrations
```
그다음 DB 컨테이너만 재시작한다.
```bash
docker compose --env-file .env.production restart sori-studio-db
```
여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다.
## 업로드 파일
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
- `public/uploads/`는 Git에 포함하지 않는다.
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
- NAS 운영에서는 `docker-compose.yml``./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.

View File

@@ -1,5 +1,147 @@
# 의사결정 이력
## 2026-05-15 v1.1.7
### 사이트 로고 파일명을 교체마다 고유하게 저장
사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp``/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다.
## 2026-05-15 v1.1.6
### 일반 태그도 검색 없이 보이는 관리 화면
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
## 2026-05-15 v1.1.5
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
Nuxt 운영 빌드는 `public/`을 빌드 시점에 `.output/public`으로 복사해 정적 파일로 제공한다. 반면 Docker 운영 업로드는 컨테이너 실행 중 `/app/public/uploads` 볼륨에 기록되므로, 새 파일이 `.output/public` 스냅샷에 없으면 업로드 직후 이미지가 깨져 보일 수 있다. 업로드 파일은 사용자 콘텐츠이자 런타임 데이터이므로 빌드 산출물에 의존하지 않고 `/uploads/**` 요청을 `public/uploads`에서 직접 스트리밍하도록 결정했다.
## 2026-05-15 v1.1.4
### 관리자 멤버 썸네일 업로드 경로 분리
회원 프로필 썸네일은 관리자 계정인지 일반 회원인지와 무관하게 회원 자산이므로 `/uploads/members/avatars`에 저장해야 한다. 관리자 멤버 편집 화면이 공용 게시물 이미지 업로드 API를 사용하면 `/uploads/posts`에 저장되어 미디어 분류와 썸네일 생명주기 규칙이 어긋난다. 회원 설정 업로드와 관리자 멤버 업로드가 같은 검증·WebP 변환·1:1 크롭 로직을 쓰도록 공통 유틸로 분리하고, 관리자 멤버 화면은 회원 전용 업로드 API를 사용하도록 정리했다.
## 2026-05-13 v1.1.3
### 사이드바 행 호버 배경 분리
전역 `site-panel-hover`는 패널과 텍스트 색을 `color-mix`해 라이트에서도 호버가 진하게 느껴진다. 카드·태그 목록 등 다른 패널은 기존 대비를 유지하고, 왼쪽 사이드바 네비·카테고리·테마 점만 `site-sidebar-nav-row`로 분리해 라이트에서 `#F7F4EF`로 완화했다. 다크에서는 가독성을 위해 기존과 동일한 `color-mix` 호버를 유지한다.
## 2026-05-14 v1.1.2
### 태그 없을 때 “POST” 더미 표시 제거
태그 배열이 비어 있을 때 UI 폴백으로 `POST` 문자열을 넣어 두어, 사용자는 실제 태그가 붙은 것으로 오해했다. 저장 데이터와 무관한 표시이므로 슬러그가 있을 때만 첫 태그를 노출하고 없으면 태그 영역을 렌더하지 않는다.
## 2026-05-14 v1.1.1
### 공개 문단 행간 기본값으로 복귀
문단 글자 크기만 16px(`text-base`)로 고정하고 행간은 `leading-7` 대신 Tailwind·브라우저 기본(`leading-normal` 계열)에 맡긴다.
## 2026-05-14 v1.1.0
### 관리자 제목·공개 본문 타이포 마이너 조정
관리자 글 제목 입력은 전체 화면 폼에서 과도하게 커 보일 수 있어 `text-3xl`로 낮췄다. 공개 본문 문단은 15px·좁은 행간 조합보다 `text-base`·`leading-7`로 읽기 리듬을 맞추고, 제목 블록의 고정 `mt-12`를 제거해 본문 첫 블록과의 간격을 렌더 트리에 맡긴다.
## 2026-05-14 v1.0.19
### 수정 모드에서 보이는 hard break 표식
마크다운 표준의 공백 2개 hard break는 렌더링 결과는 맞지만 textarea 수정 모드에서는 공백이 보이지 않아 일반 Enter와 Shift+Enter를 구분할 수 없다. Markdown-first 에디터가 아직 plaintext textarea 기반인 동안에는 작성자가 줄바꿈 의미를 눈으로 확인할 수 있어야 하므로, Shift+Enter는 줄끝 백슬래시 hard break를 삽입하도록 바꾼다. 렌더러는 새 백슬래시 방식과 이전 공백 2개 방식을 모두 지원해 기존 저장 콘텐츠와 호환한다.
## 2026-05-14 v1.0.18
### 빈 줄 공백 보존과 미리보기 집중 모드
일반 Enter를 단일 줄 이동으로 유지하더라도, 작성자가 의도적으로 여러 줄을 비우면 미리보기와 공개 본문에서도 그만큼의 공백이 보여야 한다. 빈 줄을 문단 경계로만 소비하면 제목 아래나 문단 사이에 넣은 여백이 모두 사라지므로, 내용 없는 줄은 다시 spacer 블록으로 렌더링해 줄 수 정보를 보존한다. 다만 기본 문단 간격은 10px로 유지해 일반 문단 흐름과 의도적 공백을 분리한다.
미리보기 모드는 작성 도구가 아니라 결과 확인 화면이므로 툴바와 카드형 패널 외곽을 숨긴다. 작성 모드 줄 번호 거터는 본문 바깥 위치 안내로만 쓰고 스크롤바는 숨겨 편집 화면의 시각 잡음을 줄인다.
## 2026-05-14 v1.0.17
### textarea 기준 문단 입력 재조정
일반 Enter를 `\\n\\n`으로 저장하면 마크다운 문단 구분은 명확하지만, 작성 화면에서는 커서가 두 줄 내려가 보여 긴 글 작성 리듬이 어색해진다. textarea 기반 편집에서는 Affine처럼 일반 Enter를 단일 줄 이동의 새 문단으로 보고, Shift+Enter만 같은 문단 안 hard break로 구분하는 쪽이 더 편하다. 따라서 일반 Enter는 브라우저 기본 입력을 유지하고, Shift+Enter만 마크다운 hard break(`공백 2개 + \\n`)를 삽입한다. 렌더러는 hard break가 있는 행만 같은 문단으로 묶고, 일반 줄은 각각 10px 간격의 문단으로 렌더링한다.
### 작성 영역 카드감 축소
본문 textarea에 외곽 보더와 배경 카드가 있으면 관리자 화면의 다른 패널과 겹쳐 편집 영역이 과하게 박스처럼 보인다. 작성 영역의 보더와 배경을 제거하고, 줄 번호 거터는 본문 흐름 바깥 absolute 영역으로 분리한다. 현재 줄 강조 액센트는 제거하고 줄 번호 자체만 위치 안내로 사용한다.
## 2026-05-14 v1.0.16
### 문단과 줄바꿈 의미 분리
Markdown-first 에디터에서 빈 줄을 그대로 spacer로 보존하면 세로 간격은 조절할 수 있지만, 글쓰기 경험이 옵시디언·고스트처럼 “문단”과 “줄바꿈”으로 나뉘지 않는다. 운영 글쓰기에서는 일반 Enter를 새 문단, Shift+Enter를 같은 문단 안 줄바꿈으로 보는 Ghost형 규칙이 더 예측 가능하다. 따라서 에디터는 일반 Enter 입력 시 `\\n\\n`을 넣고, Shift+Enter는 브라우저 기본 단일 줄바꿈을 유지한다. 렌더러는 빈 줄을 별도 spacer가 아니라 문단 경계로만 쓰며, 연속 텍스트 줄은 한 문단으로 묶어 단일 줄바꿈을 `<br>`로 표시한다. 문단 간 구분감은 개별 spacer가 아니라 문단 하단 24px 간격으로 통일한다.
### 미리보기 전환 후 커서 복원
`Cmd/Ctrl+E`로 미리보기를 확인한 뒤 작성 모드로 돌아왔을 때 포커스가 사라지면 긴 글을 이어 쓰는 흐름이 끊긴다. 작성 textarea의 선택 시작·끝 위치와 스크롤 위치를 기억하고, 미리보기에서 돌아올 때 같은 위치로 포커스와 선택 영역을 복원하도록 했다.
## 2026-05-14 v1.0.15
### 마크다운 빈 줄 간격 보존
Markdown-first 에디터에서는 작성자가 빈 줄을 넣어 문단 사이 호흡을 직접 조절할 수 있어야 한다. 기존 `ContentMarkdownRenderer`는 빈 줄을 파싱 단계에서 건너뛰어 1줄과 2줄의 차이가 모두 사라졌다. 빈 줄과 레거시 빈 문단 마커를 `spacer` 블록으로 렌더링해 공개 본문과 관리자 미리보기에서 작성자가 넣은 세로 간격을 보존한다. 세부 높이는 이후 본문 스타일 QA에서 조정하되, 우선 줄 수 정보가 사라지지 않는 것을 기준으로 한다.
## 2026-05-14 v1.0.14
### Markdown-first 전환 후 레거시 본문 정규화
Markdown-first 에디터로 전환한 뒤에도 브라우저 자동 저장본이나 이전 블록 에디터 상태가 배열·객체 형태로 남아 있으면, 저장 API의 `content: string` 검증에서 “게시물 입력 형식” 오류가 날 수 있다. 데이터베이스 저장 형식은 마크다운 문자열로 유지하되, 클라이언트 복원 단계와 서버 입력 검증 단계에 공통 정규화 유틸을 두어 레거시 블록 값을 마크다운으로 변환한다. 이렇게 하면 사용자가 기존 자동 저장본을 복원해도 발행 흐름이 끊기지 않고, 이후 페이지 편집 쪽도 같은 기준을 공유한다.
## 2026-05-14 v1.0.13
### Markdown 에디터 붙여넣기와 미디어 편집 보강
textarea 기반 Markdown-first 편집은 범위 선택과 복사/붙여넣기 문제를 줄였지만, 외부 웹 글을 붙여넣을 때 HTML 구조가 일반 텍스트로 무너지거나 이미지·갤러리 마크다운을 직접 고쳐야 하는 불편이 남았다. 우선 브라우저 클립보드의 `text/html`을 제목·문단·목록·링크·강조·이미지 중심의 마크다운 조각으로 변환하고, 커서가 이미지 또는 갤러리 블록 안에 있을 때 별도 편집 패널을 보여 alt·URL·너비·갤러리 순서를 수정하도록 했다. 작성과 미리보기 전환은 반복 작업이므로 `Cmd/Ctrl+E` 단축키로 접근하게 하고, 관리자 미리보기는 공개 렌더러를 쓰되 밝은 관리자 패널에 맞는 색상 변수를 별도로 고정한다. 완전한 옵시디언식 토큰 숨김 Live Preview와 표준 마크다운 파서는 더 큰 편집 엔진 선택이 필요하므로 후속 단계로 둔다.
## 2026-05-13 v1.0.12
### Markdown 에디터 줄 번호 거터
옵시디언·CodeMirror는 편집 줄 왼쪽에 줄 번호와 현재 줄 하이라이트를 둔다. 본문 편집이 textarea 단일 호스트로 바뀐 뒤에도 동일한 방향의 안내가 있으면 긴 본문에서 위치 파악이 쉬워진다. CodeMirror 수준의 **시각 줄**(줄바꿈 wrap) 단위 번호는 별도 미러 레이아웃 없이는 맞추기 어렵고, 우선 `\\n` 기준 **논리 줄** 번호와 캐럿이 속한 논리 줄의 거터 셀 배경 강조, 스크롤 동기화만 구현했다.
## 2026-05-14 v1.0.11
### 관리자 글쓰기를 Markdown-first로 전환
블록형 에디터는 이미지·갤러리 같은 카드형 입력에는 편하지만, 여러 문단 범위 선택, 외부 블로그/옵시디언 복사·붙여넣기, 다중 블록 복사 같은 기본 글쓰기 동작이 브라우저 텍스트 편집 모델과 계속 충돌했다. 저장 포맷은 이미 마크다운 문자열이므로 본문 편집의 원본도 마크다운 문자열로 되돌리고, textarea 기반 작성 모드와 공개 렌더러 기반 미리보기를 제공한다. 이미지와 갤러리는 기존 업로드·미디어 라이브러리 API를 유지하고 커서 위치에 마크다운으로 삽입한다. 옵시디언식 토큰 숨김 Live Preview는 이 기반이 안정화된 뒤 별도 단계로 확장한다.
## 2026-05-13 v1.0.10
### 관리자 블록 에디터를 v1.0.5 파일 기준으로 복원
v1.0.6부터 적용했던 다중 줄 붙여넣기 분할, Cmd/Ctrl+A로 전체 마크다운 복사, 블록 단위 범위 선택·레인 드래그·복사 가로채기 등이 실제 사용에서 어색하다는 피드백이 있어, `AdminBlockEditor.vue`는 Git 태그 `v1.0.5` 시점 내용으로 되돌렸다. Docker·부트스트랩 등 v1.0.5 이후 서버/배포 변경은 유지하고 에디터 파일만 이전 동작으로 맞춘다.
## 2026-05-14 v1.0.5
### Docker 런타임 환경 변수 우선
Nuxt `runtimeConfig``process.env.*`를 직접 대입하면 Docker 이미지 빌드 시점에 값이 비어 있는 상태로 번들에 들어갈 수 있다. 운영 컨테이너는 `.env.production`을 런타임에 주입하므로, 서버 전용 비밀값과 DB 연결값은 `useRuntimeConfig()`보다 `process.env`를 우선 조회하도록 공통 유틸을 추가했다. 이 기준은 관리자 최초 로그인, 세션 서명, DB 연결, 이메일 OTP 설정에 적용한다.
## 2026-05-14 v1.0.4
### 최초 관리자 기준을 owner/admin 존재 여부로 변경
운영 DB에 일반 회원이 먼저 생성되면 기존의 “사용자 0명” 기준 부트스트랩이 더 이상 동작하지 않아 관리자 계정이 없는 잠금 상태가 생길 수 있다. 최초 관리자 필요 여부를 전체 사용자 수가 아니라 `owner`/`admin` 권한 보유자 존재 여부로 판단하고, `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 `owner`로 승격하면서 `ADMIN_PASSWORD` 기준 비밀번호 해시를 갱신해 운영 복구 경로를 보장한다. 이미 `owner`/`admin`이 있으면 환경 변수 로그인은 우회 권한으로 쓰지 않는다.
## 2026-05-14 v1.0.3
### NAS에서 Postgres init 디렉터리 Permission denied
Docker가 호스트의 `db/migrations``postgres:16-alpine``/docker-entrypoint-initdb.d`에 마운트할 때, NAS 파일 시스템이나 복사 시 기본 umask 때문에 디렉터리가 `700`·파일이 `600`만 되면 컨테이너 내부 `postgres` UID로는 목록을 읽지 못한다. 엔트리포인트가 `ls /docker-entrypoint-initdb.d`에서 실패하면 DB가 즉시 종료되고 `restart: unless-stopped`로 루프에 들어간다. 배포 문서에 `chmod -R a+rX db/migrations` 및 상위 경로 `a+x` 절차를 명시하고, compose에 주석으로 동일 원인을 남겨 재발 시 빠르게 대응할 수 있게 했다.
## 2026-05-14 v1.0.1
### Docker Compose 전용 네트워크 대역 명시
NAS에서 이미 실행 중인 Docker 서비스가 많으면 Docker가 자동으로 고르는 기본 브리지 네트워크 주소 풀이 기존 네트워크와 겹쳐 Compose 실행이 실패할 수 있다. 기존 네트워크를 정리할 수 없는 운영 환경을 고려해 이 프로젝트 전용 브리지 네트워크와 기본 subnet을 명시하고, 필요 시 `.env.production``DOCKER_SUBNET`으로 다른 사설 대역을 지정할 수 있게 했다.
## 2026-05-14 v1.0.0
### 운영 환경의 샘플 콘텐츠 fallback 차단

View File

@@ -24,6 +24,7 @@
| 파일 | 용도 |
|------|------|
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
## Nuxt 모듈
@@ -42,6 +43,7 @@
| 파일 | 용도 |
|------|------|
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
## 사이트 컴포넌트
@@ -50,11 +52,11 @@
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
@@ -62,8 +64,9 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
@@ -81,8 +84,8 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
| components/content/ProseHeading.vue | h1~h6 제목 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
| components/content/ProseImage.vue | 본문 내 이미지 |
| components/content/ProseList.vue | 목록 |
| components/content/ProseBlockquote.vue | 인용구 |
@@ -111,12 +114,12 @@
| pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
@@ -125,10 +128,10 @@
| 파일 | 화면 |
|------|------|
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록 |
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| 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 | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
@@ -188,7 +191,8 @@
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, 성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
@@ -197,18 +201,21 @@
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-*.webp`, `/uploads/system/favicon-*.png` 생성, `시스템` 미디어 메타 저장) |
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |

View File

@@ -180,6 +180,13 @@ components/content/
- 이미지 갤러리
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
- 문단과 줄바꿈
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기는 `ContentMarkdownRenderer` 문단에 `text-base`(16px·`1rem`)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다.
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
- 카드류
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
- Toggle: `:::toggle 제목` ~ `:::`
@@ -199,6 +206,7 @@ components/content/
- 로컬 개발 서버는 개발 DB만 연결
- NAS 배포 환경은 운영 DB만 연결
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
- Docker Compose는 전용 브리지 네트워크를 사용하며 기본 subnet은 `DOCKER_SUBNET`(`10.250.50.0/24`)으로 관리
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
@@ -387,6 +395,10 @@ components/content/
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
### 업로드 파일 제공
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
### 관리자 API (`/admin/api/`)
- `POST /admin/api/auth/login` - 로그인
@@ -408,7 +420,8 @@ components/content/
- `GET /admin/api/media-folders` - 미디어 폴더 목록
- `POST /admin/api/media-folders` - 미디어 폴더 생성
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata``미분류`로 되돌림)
- `POST /admin/api/uploads` - 관리자 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링)
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
- `POST /admin/api/tags` - 태그 생성
- `GET /admin/api/tags/:id` - 태그 상세
@@ -422,53 +435,45 @@ components/content/
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
### 관리자 글 편집
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
- `/` 입력 시 블록 선택 메뉴를 표시한다.
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
- `/` 명령 메뉴의 검색어가 바뀌지 않은 경우에는 현재 강조 인덱스를 유지해 연속 방향키 이동이 가능해야 한다.
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
- 슬래시 메뉴 방향키 이동 로직은 현재 블록 텍스트`/`로 시작할 때만 동작한다.
- 슬래시 메뉴는 화면 높이에 맞춰 최대 높이를 제한하고, 넘치는 항목은 내부 스크롤로 표시한다.
- 슬래시 메뉴 방향키 이동 시 현재 선택 항목이 스크롤 영역 안에 유지되도록 자동 스크롤한다.
- 일반 본문 블록에서는 위/아래 방향키 입력 시 커서가 블록 시작/끝에 도달하면 인접 블록으로 커서를 이동한다.
- 관리자 에디터에서 의도적으로 만든 빈 문단은 `<!--sori:blank-paragraph-->` 마커로 저장해 저장/재진입 후에도 유지한다.
- 공개 본문 렌더러는 빈 문단 마커를 빈 문단 블록으로 파싱해 문단 간 추가 여백 의도를 유지한다.
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
- 작성 모드 textarea 왼쪽 바깥에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 absolute 영역으로 두고 textarea와 거터의 세로 스크롤을 동기화한다. 거터 스크롤바는 숨긴다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
- 본문 작성 모드에서 일반 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 처리한다. 일반 Enter는 단일 줄 이동으로 보여야 하며, Shift+Enter는 수정 모드에서도 보이는 줄끝 백슬래시 hard break를 남긴다.
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용하며, 툴바와 카드형 패널 외곽을 숨겨 본문만 표시한다.
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I` 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url){width=...}` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
- 현재 미디어 블록 편집 패널은 alt, URL, 너비 값을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
@@ -509,6 +514,7 @@ components/content/
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![alt](url){width=wide}` 형식으로 저장한다.
@@ -544,7 +550,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp``/uploads/system/favicon.png`함께 생성한다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-*.webp``/uploads/system/favicon-*.png`고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
@@ -559,14 +565,15 @@ components/content/
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev``017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
### 관리자 인증
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login` `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
@@ -593,6 +600,7 @@ components/content/
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다.
- Docker 운영 서버 환경 변수는 이미지 빌드 시점 `runtimeConfig`보다 컨테이너 런타임 `process.env` 값을 우선한다.
---
@@ -604,13 +612,14 @@ components/content/
/uploads/posts/YYYY/MM/filename.webp
/uploads/pages/YYYY/MM/filename.webp
/uploads/members/avatars/YYYY/MM/filename.webp
/uploads/system/logo.png
/uploads/system/favicon.png
/uploads/system/logo-YYYYMMDDTHHMMSS-random.webp
/uploads/system/favicon-YYYYMMDDTHHMMSS-random.png
```
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category``미분류`로 저장된 항목이 여기에 모인다.
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
@@ -627,7 +636,7 @@ components/content/
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지본문 내 URL을 기준으로 표시한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다.
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.

View File

@@ -2,11 +2,10 @@
## 1차 관리자 개발
- [ ] 블록 핸들 액션 메뉴 확장: 잘라내기, 복사, 붙여넣기, 블록 타입 변경, 선택 블록 서식 적용
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
## 2차 관리자 개발
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
## 프론트엔드 개발

View File

@@ -1,5 +1,158 @@
# 업데이트 이력
## v1.1.7
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.
- 사이트 파비콘도 로고와 같은 고유 접미사 파일명으로 생성해 브라우저 캐시로 이전 이미지가 남는 문제를 완화.
- 미디어 라이브러리 사용 현황에 사이트 설정 로고·파비콘 참조를 포함하고, 현재 사용 중인 시스템 이미지는 파일명 변경·삭제가 잠기도록 수정.
- 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 todo 정리.
- 패키지 버전 `1.1.7`로 갱신.
## v1.1.6
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬.
- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가.
- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리.
- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리.
- 패키지 버전 `1.1.6`으로 갱신.
## v1.1.5
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
- Docker 운영 이미지의 `.output/public` 빌드 시점 스냅샷에 의존하지 않고 새로 업로드한 로고·게시물 이미지·회원 썸네일이 즉시 표시되도록 수정.
- 패키지 버전 `1.1.5`로 갱신.
## v1.1.4
- 관리자 멤버 썸네일 업로드가 게시물용 `/uploads/posts`가 아니라 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
- 회원 썸네일 업로드 검증·WebP 변환·1:1 크롭 로직을 공통 유틸로 분리.
- 관리자 멤버 편집 전용 썸네일 업로드 API와 새 멤버 생성 전 썸네일 사전 업로드 API 추가.
- 관리자 회원 기본 정보 저장에서 기존 회원 전용 썸네일 URL이 교체·제거되면 `media_metadata` 연결을 분리하도록 정리.
- 태그 목록 카드 그리드에 사용자 수정 `px-6` 반영.
- 패키지 버전 `1.1.4`로 갱신.
## v1.1.3
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.
- 패키지 버전 `1.1.3`으로 갱신.
## v1.1.2
- 태그가 없는 게시물에 기본값으로 보이던 `POST` 표기 제거: 공개 상세·홈 Latest·게시물 목록 카드에서 태그가 있을 때만 배지·메타에 표시.
- 패키지 버전 `1.1.2`로 갱신.
## v1.1.1
- 공개 본문 `ContentMarkdownRenderer` 문단에서 `leading-7`을 제거하고 `text-base`(16px)만 적용.
- 패키지 버전 `1.1.1`로 갱신.
## v1.1.0
- 관리자 글 작성 폼 제목 입력 타이포를 `text-5xl`에서 `text-3xl`로 조정.
- 공개 본문 `ContentMarkdownRenderer` 문단을 `text-base`·`leading-7` 기준으로 조정(기존 `text-[15px] leading-4` 대비 크기·행간 정리).
- `ProseHeading`에서 제목 블록 상단 `mt-12` 제거로 제목·본문 간 세로 리듬 정리.
- 패키지 버전 `1.1.0`으로 갱신.
## v1.0.19
- 관리자 `AdminMarkdownEditor`에서 Shift+Enter가 보이지 않는 `공백 2개 + \\n` 대신 줄끝 백슬래시 hard break(`\\ + 줄바꿈`)를 삽입하도록 수정.
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 줄끝 백슬래시 hard break와 기존 공백 2개 hard break를 모두 같은 문단 안 줄바꿈으로 렌더링하도록 보강.
- 패키지 버전 `1.0.19`로 갱신.
## v1.0.18
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 내용 없는 빈 줄을 다시 spacer 블록으로 렌더링해 여러 줄을 비우면 비운 줄 수만큼 공백이 보이도록 수정.
- 관리자 `AdminMarkdownEditor` 미리보기 모드에서 툴바를 숨기도록 수정.
- 관리자 미리보기 패널의 보더·라운드·흰 배경 카드 처리를 제거.
- 작성 모드 줄 번호 거터의 스크롤바를 숨기도록 정리.
- 패키지 버전 `1.0.18`로 갱신.
## v1.0.17
- 관리자 `AdminMarkdownEditor` 작성 영역의 외곽 보더와 배경 카드 처리를 제거.
- 작성 영역 줄 번호 거터를 본문 textarea 바깥의 absolute 영역으로 분리하고 활성 줄 액센트 배경을 제거.
- 일반 Enter는 브라우저 기본 단일 줄 이동으로 되돌리고, Shift+Enter만 마크다운 hard break(`공백 2개 + \\n`)로 저장하도록 수정.
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 hard break 행만 같은 문단 안 줄바꿈으로 묶고, 일반 줄은 각각 문단으로 렌더링하도록 정리.
- 문단과 제목 하단 기본 간격을 10px 기준으로 조정.
- 패키지 버전 `1.0.17`로 갱신.
## v1.0.16
- 관리자 `AdminMarkdownEditor`에서 일반 Enter는 새 문단(`\\n\\n`), Shift+Enter는 같은 문단 안 줄바꿈(`\\n`)으로 입력되도록 수정.
- `Cmd/Ctrl+E`로 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 스크롤을 복원하도록 보강.
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 연속 텍스트 줄을 한 문단으로 묶고, 단일 줄바꿈은 `<br>`, 빈 줄은 문단 경계로 렌더링하도록 정리.
- 본문 문단 하단 간격을 24px 기준으로 조정.
- 패키지 버전 `1.0.16`으로 갱신.
## v1.0.15
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 빈 줄을 버리지 않고 spacer 블록으로 렌더링하도록 수정.
- 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)도 동일한 spacer 블록으로 표시하도록 정리.
- 패키지 버전 `1.0.15`로 갱신.
## v1.0.14
- 관리자 게시물/페이지 입력 스키마에서 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 정규화하도록 보강.
- `AdminMarkdownEditor``AdminPostForm`에서 기존 자동 저장본 또는 레거시 블록 본문을 복원할 때 마크다운 문자열로 변환하도록 수정.
- 공통 `normalizeMarkdownContent` 유틸 추가.
- 패키지 버전 `1.0.14`로 갱신.
## v1.0.13
- 관리자 `AdminMarkdownEditor`에 HTML 클립보드 붙여넣기 기본 변환을 추가해 외부 블로그/웹 문서를 붙여넣을 때 제목·문단·목록·링크·굵게·기울임·이미지를 마크다운 조각으로 정리.
- 작성 모드에서 커서가 이미지 줄 또는 `:::gallery` 블록 안에 있을 때 현재 미디어 블록 편집 패널을 표시하고 alt·URL·너비 수정, 갤러리 순서 변경·삭제·이미지 추가를 지원.
- 관리자 `AdminMarkdownEditor`에서 `Cmd/Ctrl+E`로 작성/미리보기 모드를 전환하도록 변경하고, 관리자 미리보기의 본문 색상 변수를 밝은 배경 기준으로 고정.
- 패키지 버전 `1.0.13`으로 갱신.
## v1.0.12
- 관리자 `AdminMarkdownEditor` 작성 모드에 왼쪽 줄 번호 거터(`\\n` 기준 논리 줄)와 현재 줄 배경 강조 추가, textarea와 거터 세로 스크롤 동기화.
- 패키지 버전 `1.0.12`로 갱신.
## v1.0.11
- 관리자 글 본문 에디터를 블록형 `AdminBlockEditor`에서 Markdown-first `AdminMarkdownEditor`로 교체.
- 새 에디터에 textarea 기반 범위 선택·복사/붙여넣기, 작성/미리보기 전환, 마크다운 툴바, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입 추가.
- 공개 본문 렌더러에 굵게, 기울임, 인라인 코드, 링크 인라인 마크다운 표시 추가.
- 패키지 버전 `1.0.11`로 갱신.
## v1.0.10
- 관리자 `AdminBlockEditor.vue`를 저장소 태그 `v1.0.5` 시점과 동일한 내용으로 복원(다중 줄·마크다운 붙여넣기 분할, Cmd/Ctrl+A 전체 MD 복사 안내, 블록 단위 범위 선택 등 v1.0.6 이후 에디터 UX 변경 제거). 동작 불만에 따른 되돌림.
- 패키지 버전 `1.0.10`으로 갱신.
## v1.0.5
- Docker 운영 이미지에서 빌드 시점 `runtimeConfig`가 비어도 컨테이너 런타임 환경 변수(`DATABASE_URL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `MEMBER_SESSION_SECRET`, Resend 설정)를 우선 읽도록 수정.
- 서버 런타임 환경 변수 조회 유틸 추가.
- 패키지 버전 `1.0.5`로 갱신.
## v1.0.4
- 최초 관리자 부트스트랩 기준을 전체 사용자 수가 아니라 owner/admin 존재 여부로 변경.
- owner/admin이 없는 상태에서 `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 `ADMIN_PASSWORD`로 비밀번호를 갱신하도록 수정.
- 패키지 버전 `1.0.4`로 갱신.
## v1.0.3
- NAS 등에서 `db/migrations` 바인드 마운트 권한 부족 시 `docker-entrypoint-initdb.d` Permission denied로 DB 컨테이너가 재시작하는 경우를 배포 문서에 정리.
- `docker-compose.yml`에 동일 이슈용 주석 추가.
- 패키지 버전 `1.0.3`으로 갱신.
## v1.0.2
- 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정.
- 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리.
- 배포 문서에 Docker 컨테이너 `Restarting` 루프 시 로그 확인 절차 추가.
- 패키지 버전 `1.0.2`로 갱신.
## v1.0.1
- Docker Compose 기본 네트워크 주소 풀 충돌을 피하기 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
- 배포 문서에 NAS Docker 네트워크 충돌 시 `DOCKER_SUBNET` 변경 기준 추가.
- 패키지 버전 `1.0.1`로 갱신.
## v1.0.0
- 운영 환경에서 `DATABASE_URL` 누락 시 샘플 콘텐츠 fallback 대신 즉시 실패하도록 수정.

View File

@@ -0,0 +1,172 @@
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
const blockSpacingTypes = new Set(['list'])
/**
* 이미지 블록을 마크다운 문자열로 변환한다.
* @param {Object} image - 이미지 데이터
* @returns {string} 이미지 마크다운
*/
const serializeImageBlock = (image = {}) => {
const url = String(image.url || '').trim()
if (!url) {
return ''
}
const width = image.width && image.width !== 'regular'
? `{width=${image.width}}`
: ''
return `![${image.alt || ''}](${url})${width}`
}
/**
* 레거시 블록 하나를 마크다운 조각으로 변환한다.
* @param {Object} block - 레거시 에디터 블록
* @param {number} index - 블록 인덱스
* @param {number} total - 전체 블록 수
* @returns {{ type: string, value: string }|null} 마크다운 조각
*/
const serializeLegacyBlock = (block = {}, index = 0, total = 1) => {
if (typeof block.value === 'string') {
return block.value.trim()
? { type: block.type || 'paragraph', value: block.value }
: null
}
const type = block.type || 'paragraph'
const rawText = String(block.text || '')
const text = rawText.trim()
if (type === 'divider') {
return { type, value: '---' }
}
if (type === 'image') {
const image = serializeImageBlock(block)
return image ? { type, value: image } : null
}
if (type === 'gallery') {
const images = Array.isArray(block.images)
? block.images.map(serializeImageBlock).filter(Boolean)
: []
return images.length
? { type, value: [':::gallery', ...images, ':::'].join('\n') }
: null
}
if (type === 'callout') {
const emoji = block.calloutEmojiEnabled === false
? 'none'
: (block.calloutEmoji || '💡')
const background = block.calloutBackground || 'blue'
return text
? { type, value: `:::callout emoji=${emoji} bg=${background}\n${text}\n:::` }
: null
}
if (type === 'toggle') {
const title = String(block.title || '').trim()
return title || text
? { type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
: null
}
if (type === 'embed') {
const url = String(block.url || '').trim()
return url
? { type, value: `:::embed\n${url}\n:::` }
: null
}
if (type === 'paragraph' && !text) {
return index === total - 1
? null
: { type, value: BLANK_PARAGRAPH_MARKER }
}
if (!text) {
return null
}
if (type === 'heading') {
return { type, value: `${'#'.repeat(block.level || 2)} ${text}` }
}
if (type === 'quote') {
return { type, value: `> ${text}` }
}
if (type === 'list') {
return { type, value: `- ${text}` }
}
if (type === 'code') {
return { type, value: `\`\`\`\n${rawText}\n\`\`\`` }
}
return { type, value: text }
}
/**
* 레거시 블록 배열을 저장용 마크다운 문자열로 변환한다.
* @param {Array<Object>} blocks - 레거시 블록 목록
* @returns {string} 마크다운 문자열
*/
const serializeLegacyBlocks = (blocks) => blocks
.map((block, index) => serializeLegacyBlock(block, index, blocks.length))
.filter(Boolean)
.reduce((markdown, block, index, blocksList) => {
if (index === 0) {
return block.value
}
const previousBlock = blocksList[index - 1]
const joiner = blockSpacingTypes.has(previousBlock.type) && blockSpacingTypes.has(block.type)
? '\n'
: '\n\n'
return `${markdown}${joiner}${block.value}`
}, '')
/**
* 게시물/페이지 본문 값을 저장 가능한 마크다운 문자열로 정규화한다.
* @param {unknown} value - 본문 값
* @returns {string} 마크다운 문자열
*/
export const normalizeMarkdownContent = (value) => {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return serializeLegacyBlocks(value)
}
if (value && typeof value === 'object') {
if (typeof value.content === 'string') {
return value.content
}
if (Array.isArray(value.blocks)) {
return serializeLegacyBlocks(value.blocks)
}
if (typeof value.markdown === 'string') {
return value.markdown
}
if (typeof value.type === 'string') {
const block = serializeLegacyBlock(value)
return block?.value || ''
}
}
return ''
}

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.0.0",
"version": "1.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.0.0",
"version": "1.1.7",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -9929,7 +9929,7 @@
}
},
"node_modules/readdir-glob": {
"version": "1.1.3",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"license": "Apache-2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.0.0",
"version": "1.1.7",
"private": true,
"type": "module",
"imports": {

View File

@@ -12,14 +12,47 @@ const deletingGeneralTagId = ref('')
const toast = ref(null)
let toastTimer = null
const generalTagQuery = ref('')
const generalTagSearchResults = ref([])
const generalTagSearchLoading = ref(false)
const generalTagSortMode = ref('recent')
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
default: () => []
})
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
const filteredGeneralTags = computed(() => {
const keyword = generalTagQuery.value.trim().toLowerCase()
const sortedTags = [...generalTags.value].sort((a, b) => {
if (generalTagSortMode.value === 'count') {
const countDiff = Number(b.postCount || 0) - Number(a.postCount || 0)
if (countDiff !== 0) {
return countDiff
}
}
if (generalTagSortMode.value === 'name') {
return a.name.localeCompare(b.name, 'ko')
}
const aTime = new Date(a.lastUsedAt || a.updatedAt || 0).getTime()
const bTime = new Date(b.lastUsedAt || b.updatedAt || 0).getTime()
if (aTime !== bTime) {
return bTime - aTime
}
return a.name.localeCompare(b.name, 'ko')
})
if (!keyword) {
return sortedTags
}
return sortedTags.filter((tag) =>
tag.name.toLowerCase().includes(keyword) ||
tag.slug.toLowerCase().includes(keyword)
)
})
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */
const baselineManagedTagIds = ref([])
@@ -184,27 +217,16 @@ const saveManagedOrder = async () => {
* @returns {Promise<void>}
*/
const searchGeneralTags = async () => {
const keyword = generalTagQuery.value.trim()
if (!keyword) {
generalTagSearchResults.value = []
return
}
generalTagQuery.value = generalTagQuery.value.trim()
}
generalTagSearchLoading.value = true
try {
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
query: {
tagType: 'general',
q: keyword,
limit: 30
}
})
} catch (error) {
showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
} finally {
generalTagSearchLoading.value = false
}
/**
* 일반 태그 정렬 기준을 변경한다.
* @param {'recent'|'count'|'name'} mode - 정렬 기준
* @returns {void}
*/
const setGeneralTagSortMode = (mode) => {
generalTagSortMode.value = mode
}
/**
@@ -232,7 +254,6 @@ const promoteToMainTag = async (tag) => {
}
})
await refreshTagsFromServer()
await searchGeneralTags()
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
@@ -266,7 +287,6 @@ const demoteToGeneralTag = async (tag) => {
}
})
await refreshTagsFromServer()
await searchGeneralTags()
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
@@ -292,7 +312,6 @@ const deleteGeneralTag = async (tag) => {
method: 'DELETE'
})
await refreshTagsFromServer()
await searchGeneralTags()
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
@@ -323,7 +342,7 @@ onBeforeUnmount(() => {
</NuxtLink>
</div>
<p class="mt-3 text-xs text-muted">
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 있습니다.
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
</p>
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
@@ -404,44 +423,65 @@ onBeforeUnmount(() => {
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">일반 태그 검색</p>
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
</div>
<div class="space-y-3 bg-white p-4">
<div class="flex gap-2">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<input
v-model="generalTagQuery"
type="text"
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
placeholder="일반 태그 이름 또는 슬러그 검색"
placeholder="일반 태그 이름 또는 슬러그 필터"
@keydown.enter.prevent="searchGeneralTags"
>
<button
type="button"
class="h-10 rounded border border-line bg-white px-4 text-sm font-semibold disabled:opacity-50"
:disabled="generalTagSearchLoading"
@click="searchGeneralTags"
>
{{ generalTagSearchLoading ? '검색 중' : '검색' }}
</button>
</div>
<div v-if="generalTagSearchResults.length" class="divide-y divide-line rounded border border-line">
<div v-for="tag in generalTagSearchResults" :key="tag.id" class="flex items-center gap-3 px-3 py-2.5">
<span class="h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">{{ tag.name }}</p>
<p class="truncate text-xs text-muted">{{ tag.slug }}</p>
</div>
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
<button
type="button"
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('recent')"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인 태그로 전환' }}
최근 사용순
</button>
<button
type="button"
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'count' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('count')"
>
많이 사용순
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'name' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('name')"
>
이름순
</button>
</div>
</div>
<div v-if="filteredGeneralTags.length" class="flex flex-wrap gap-2">
<div
v-for="tag in filteredGeneralTags"
:key="tag.id"
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] px-3 py-2 text-sm"
:title="tag.slug"
>
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="truncate font-semibold text-ink">{{ tag.name }}</span>
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span>
<button
type="button"
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인' }}
</button>
<button
type="button"
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50"
:disabled="deletingGeneralTagId === tag.id"
@click="deleteGeneralTag(tag)"
>
@@ -449,8 +489,8 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
검색 결과가 없습니다.
<p v-else class="text-sm text-muted">
{{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}
</p>
</div>
</div>

View File

@@ -53,10 +53,17 @@ const onDocumentPointerDown = (event) => {
* @returns {{name: string, color: string}} 태그 정보
*/
const getTagMeta = (slug) => {
if (!slug) {
return {
name: '',
color: '#4d4d4d'
}
}
const matchedTag = tags.value.find((item) => item.slug === slug)
return {
name: matchedTag?.name || (slug ? slug.toUpperCase() : 'POST'),
name: matchedTag?.name || String(slug).toUpperCase(),
color: matchedTag?.color || '#4d4d4d'
}
}
@@ -454,14 +461,16 @@ const scrollFeatured = (direction) => {
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
<span class="text-[var(--site-line)]">/</span>
<span
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="{ backgroundColor: `${post.tagColor}1a` }"
>
{{ post.tagName }}
</span>
<span class="text-[var(--site-line)]">/</span>
<template v-if="post.tagName">
<span v-if="post.publishedAt" class="text-[var(--site-line)]">/</span>
<span
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="{ backgroundColor: `${post.tagColor}1a` }"
>
{{ post.tagName }}
</span>
</template>
<span v-if="post.publishedAt || post.tagName" class="text-[var(--site-line)]">/</span>
<span class="flex items-center gap-1">
<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" />

View File

@@ -23,12 +23,22 @@ if (!post.value) {
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
const primaryTagMeta = computed(() => {
const matchedTag = tags.value.find((item) => item.slug === primaryTagSlug.value)
const slug = primaryTagSlug.value
if (!slug) {
return {
name: '',
color: '',
to: '/tags'
}
}
const matchedTag = tags.value.find((item) => item.slug === slug)
return {
name: matchedTag?.name || (primaryTagSlug.value ? primaryTagSlug.value.toUpperCase() : 'POST'),
name: matchedTag?.name || slug.toUpperCase(),
color: matchedTag?.color || '#4d4d4d',
to: primaryTagSlug.value ? `/tag/${primaryTagSlug.value}` : '/tags'
to: `/tag/${slug}`
}
})
@@ -228,8 +238,8 @@ useHead(() => ({
{{ authorLabel }}
</a>
<ul class="flex flex-wrap items-center font-medium">
<li v-if="primaryTagMeta.name" :style="{ '--color-accent': primaryTagMeta.color }">
<ul v-if="primaryTagSlug" class="flex flex-wrap items-center font-medium">
<li :style="{ '--color-accent': primaryTagMeta.color }">
<NuxtLink
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"

View File

@@ -7,7 +7,7 @@ const postCards = computed(() => posts.value.map((post) => ({
title: post.title,
excerpt: post.excerpt,
featuredImage: post.featuredImage,
tag: post.tags?.[0]?.toUpperCase() || 'POST',
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
publishedAt: formatPostDate(post.publishedAt),
to: `/post/${post.slug}`
})))

View File

@@ -31,7 +31,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
</section>
<section class="tags-page-list mb-8">
<ul class="mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
<ul class="px-6 mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
<li
v-for="tag in tags"
:key="tag.id"

View File

@@ -1,82 +1,10 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { createError } from 'h3'
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
const allowedImageTypes = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
['image/gif', '.gif']
])
/**
* 업로드 경로 조각을 URL 안전 문자열로 정리
* @param {string} value - 원본 경로 조각
* @returns {string} 정리된 경로 조각
*/
const sanitizePathPart = (value) => value
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
/**
* 숫자 설정값을 최소/최대 범위로 보정한다.
* @param {number} value - 원본 값
* @param {number} minimum - 최소값
* @param {number} maximum - 최대값
* @returns {number} 보정된 값
*/
const clampNumber = (value, minimum, maximum) => {
if (!Number.isFinite(value)) {
return minimum
}
if (value < minimum) {
return minimum
}
if (value > maximum) {
return maximum
}
return Math.round(value)
}
/**
* 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다. 동일 stem이 있으면 `-2`, `-3` 넘버링한다.
* @param {string} directoryPath - 저장 디렉터리 절대 경로
* @param {string} stem - 확장자 제외 파일명
* @returns {Promise<{ fileName: string, filePath: string }>} 파일명과 절대 경로
*/
const pickUniqueWebpFileName = async (directoryPath, stem) => {
let suffix = 1
while (suffix < 10000) {
const fileName = suffix === 1 ? `${stem}.webp` : `${stem}-${suffix}.webp`
const filePath = join(directoryPath, fileName)
try {
await stat(filePath)
suffix += 1
} catch {
return {
fileName,
filePath
}
}
}
throw createError({
statusCode: 500,
message: '저장할 고유 파일명을 만들 수 없습니다.'
})
}
/**
* 회원 썸네일 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -92,80 +20,7 @@ export default defineEventHandler(async (event) => {
})
}
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const avatarMinWidth = clampNumber(Number(config.avatarMinWidth || 96), 1, 4096)
const avatarMinHeight = clampNumber(Number(config.avatarMinHeight || 96), 1, 4096)
const avatarMaxWidth = clampNumber(Number(config.avatarMaxWidth || 512), avatarMinWidth, 4096)
const avatarMaxHeight = clampNumber(Number(config.avatarMaxHeight || 512), avatarMinHeight, 4096)
const avatarSquareSize = Math.min(avatarMaxWidth, avatarMaxHeight)
const avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: '업로드할 이미지가 없습니다.'
})
}
if (!allowedImageTypes.has(file.type)) {
throw createError({
statusCode: 400,
message: '이미지 파일만 업로드할 수 있습니다.'
})
}
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
})
}
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
await mkdir(directoryPath, { recursive: true })
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem)
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
const metadata = await sharp(file.data).metadata()
if (!metadata.width || !metadata.height) {
throw createError({
statusCode: 400,
message: '이미지 메타데이터를 읽을 수 없습니다.'
})
}
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
throw createError({
statusCode: 400,
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
})
}
const resizedBuffer = await sharp(file.data)
.rotate()
.resize({
width: avatarSquareSize,
height: avatarSquareSize,
fit: 'cover',
position: 'centre'
})
.webp({
quality: avatarWebpQuality
})
.toBuffer()
await writeFile(filePath, resizedBuffer)
const { avatarUrl } = await uploadMemberAvatarImage(event)
await updateMemberProfile({
userId: session.userId,
@@ -183,4 +38,3 @@ export default defineEventHandler(async (event) => {
avatarUrl
}
})

View File

@@ -1,5 +1,6 @@
import { getMemberBootstrapState } from '../../repositories/member-repository'
import { isResendConfigured } from '../../utils/resend-mail'
import { getRuntimeEnvValue } from '../../utils/runtime-env'
/**
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
@@ -8,7 +9,7 @@ import { isResendConfigured } from '../../utils/resend-mail'
export default defineEventHandler(async () => {
const base = await getMemberBootstrapState()
const config = useRuntimeConfig()
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
const hasPepper = Boolean(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim())
const emailOtpConfigured = isResendConfigured(config) && hasPepper
return {
...base,

View File

@@ -13,6 +13,7 @@ import {
} from '../../../repositories/email-otp-repository'
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
import { getRuntimeEnvValue } from '../../../utils/runtime-env'
const bodySchema = z.object({
email: z.string().trim().email(),
@@ -28,7 +29,7 @@ const MAX_SENDS_PER_HOUR = 5
* @returns {string}
*/
const resolveOtpPepper = (config) => {
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim()
if (!pepper) {
throw createError({
statusCode: 500,
@@ -150,8 +151,8 @@ export default defineEventHandler(async (event) => {
try {
await sendResendEmail({
apiKey: String(config.resendApiKey).trim(),
from: String(config.resendFromEmail).trim(),
apiKey: getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey').trim(),
from: getRuntimeEnvValue('RESEND_FROM_EMAIL', 'resendFromEmail').trim(),
to: email,
subject,
html

View File

@@ -3,6 +3,7 @@ import { z } from 'zod'
import { createError, readBody } from 'h3'
import { updateMemberPasswordByEmail } from '../../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository'
import { getRuntimeEnvValue } from '../../../utils/runtime-env'
const bodySchema = z.object({
email: z.string().trim().email(),
@@ -24,8 +25,7 @@ export default defineEventHandler(async (event) => {
})
}
const config = useRuntimeConfig()
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim()
if (!pepper) {
throw createError({
statusCode: 500,

View File

@@ -6,6 +6,7 @@ import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repositor
import { setMemberSession } from '../../utils/member-auth'
import { setAdminSession } from '../../utils/admin-auth'
import { isResendConfigured } from '../../utils/resend-mail'
import { getRuntimeEnvValue } from '../../utils/runtime-env'
const signupSchema = z.object({
username: z.string().trim().min(1),
@@ -24,7 +25,7 @@ const isSignupOtpRequired = (config, bootstrap) => {
if (bootstrap.needsAdminSetup) {
return false
}
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
const hasPepper = Boolean(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim())
return isResendConfigured(config) && hasPepper
}
@@ -77,7 +78,7 @@ export default defineEventHandler(async (event) => {
message: '이메일 인증번호를 입력해 주세요.'
})
}
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim()
const verify = await verifyAndConsumeEmailOtp({
email: emailNorm,
purpose: 'signup',

View File

@@ -61,7 +61,10 @@ const mapTagRow = (row) => ({
description: row.description,
sortOrder: row.sort_order,
color: row.color,
tagType: row.tag_type || 'managed'
tagType: row.tag_type || 'managed',
postCount: Number(row.post_count || 0),
lastUsedAt: row.last_used_at ? row.last_used_at.toISOString() : null,
updatedAt: row.updated_at ? row.updated_at.toISOString() : null
})
/**
@@ -572,7 +575,10 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
if (!sql) {
const sampleTags = getSampleTags().map((tag) => ({
...tag,
tagType: 'managed'
tagType: 'managed',
postCount: 0,
lastUsedAt: null,
updatedAt: null
}))
let filteredTags = sampleTags
if (tagType) {
@@ -588,17 +594,25 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
}
const rows = await sql`
SELECT *
SELECT
tags.*,
COUNT(post_tags.post_id)::int AS post_count,
MAX(posts.updated_at) AS last_used_at
FROM tags
LEFT JOIN post_tags ON post_tags.tag_id = tags.id
LEFT JOIN posts ON posts.id = post_tags.post_id
WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null})
AND (
${trimmedSearchQuery || null}::text IS NULL
OR strpos(lower(name), ${trimmedSearchQuery || ''}) > 0
OR strpos(lower(slug), ${trimmedSearchQuery || ''}) > 0
OR strpos(lower(tags.name), ${trimmedSearchQuery || ''}) > 0
OR strpos(lower(tags.slug), ${trimmedSearchQuery || ''}) > 0
)
GROUP BY tags.id
ORDER BY
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
sort_order ASC,
MAX(posts.updated_at) DESC NULLS LAST,
tags.updated_at DESC,
name ASC
LIMIT ${resolvedLimit || 1000}
`

View File

@@ -217,6 +217,14 @@ export const createUser = async (input) => {
const rows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const privilegedRows = await tx`
SELECT EXISTS (
SELECT 1
FROM users
WHERE user_role = ANY(${PRIVILEGED_ROLES})
) AS "hasPrivilegedUsers"
`
const shouldCreateOwner = !Boolean(privilegedRows?.[0]?.hasPrivilegedUsers)
return tx`
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
@@ -225,9 +233,9 @@ export const createUser = async (input) => {
${input.email},
${input.passwordHash},
'',
NOT EXISTS (SELECT 1 FROM users),
${shouldCreateOwner},
CASE
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER}
ELSE ${MEMBER_ROLE.MEMBER}
END
)
@@ -258,6 +266,91 @@ export const createUser = async (input) => {
return created
}
/**
* 관리자 권한이 없을 때 환경 변수 기반 owner 계정을 생성하거나 기존 회원을 승격한다.
* @param {{ username: string, email: string, passwordHash: string }} input - 입력
* @returns {Promise<MemberUser | null>} 생성 또는 승격된 owner 회원
*/
export const upsertBootstrapOwner = async (input) => {
const sql = requireSql()
const rows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const privilegedRows = await tx`
SELECT id
FROM users
WHERE user_role = ANY(${PRIVILEGED_ROLES})
LIMIT 1
`
if (privilegedRows?.[0]) {
return []
}
const existingRows = await tx`
SELECT id
FROM users
WHERE lower(email) = lower(${input.email})
LIMIT 1
`
if (existingRows?.[0]) {
return tx`
UPDATE users
SET
password_hash = ${input.passwordHash},
is_admin = true,
user_role = ${MEMBER_ROLE.OWNER},
updated_at = now()
WHERE id = ${existingRows[0].id}
RETURNING
id,
username,
email,
password_hash AS "passwordHash",
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
}
return tx`
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
VALUES (
${input.username},
${input.email},
${input.passwordHash},
'',
true,
${MEMBER_ROLE.OWNER}
)
RETURNING
id,
username,
email,
password_hash AS "passwordHash",
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
})
return rows?.[0] || null
}
/**
* 회원 최근 활동 정보를 기록한다.
* @param {{ userId: string, ip: string }} input - 사용자 ID와 접속 IP
@@ -585,6 +678,25 @@ export const updateMemberByAdmin = async (input) => {
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
}
/**
* 관리자 화면에서 회원 썸네일만 수정한다.
* @param {{ memberId: string, avatarUrl: string }} input - 수정 값
* @returns {Promise<Object | null>} 수정된 회원
*/
export const updateMemberAvatarByAdmin = async (input) => {
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
avatar_url = ${input.avatarUrl},
updated_at = now()
WHERE id = ${input.memberId}
RETURNING id
`
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
}
/**
* 이메일 기준 관리자 회원 조회
* @param {string} email - 이메일
@@ -618,20 +730,24 @@ export const getAdminUserByEmail = async (email) => {
/**
* 최초 관리자 등록 필요 여부를 확인한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
* @returns {Promise<{ hasUsers: boolean, hasPrivilegedUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
*/
export const getMemberBootstrapState = async () => {
const sql = requireSql()
const rows = await sql`
SELECT COUNT(*)::int AS "userCount"
SELECT
COUNT(*)::int AS "userCount",
COUNT(*) FILTER (WHERE user_role = ANY(${PRIVILEGED_ROLES}))::int AS "privilegedUserCount"
FROM users
`
const userCount = Number(rows?.[0]?.userCount || 0)
const privilegedUserCount = Number(rows?.[0]?.privilegedUserCount || 0)
return {
hasUsers: userCount > 0,
needsAdminSetup: userCount === 0
hasPrivilegedUsers: privilegedUserCount > 0,
needsAdminSetup: privilegedUserCount === 0
}
}

View File

@@ -1,4 +1,5 @@
import postgres from 'postgres'
import { getRuntimeEnvValue } from '../utils/runtime-env'
let client = null
@@ -13,9 +14,9 @@ const isProductionRuntime = () => process.env.NODE_ENV === 'production'
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
*/
export const getPostgresClient = () => {
const config = useRuntimeConfig()
const databaseUrl = getRuntimeEnvValue('DATABASE_URL', 'databaseUrl').trim()
if (!config.databaseUrl) {
if (!databaseUrl) {
if (isProductionRuntime()) {
throw new Error('DATABASE_URL_REQUIRED')
}
@@ -24,7 +25,7 @@ export const getPostgresClient = () => {
}
if (!client) {
client = postgres(config.databaseUrl, {
client = postgres(databaseUrl, {
max: 5,
idle_timeout: 20,
connect_timeout: 10

View File

@@ -1,15 +1,52 @@
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import bcrypt from 'bcrypt'
import { setAdminSession } from '../../../../utils/admin-auth'
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsertBootstrapOwner } from '../../../../repositories/member-repository'
import { setMemberSession } from '../../../../utils/member-auth'
import { getRuntimeEnvValue } from '../../../../utils/runtime-env'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
})
/**
* 이메일에서 최초 관리자 닉네임을 만든다.
* @param {string} email - 관리자 이메일
* @returns {string} 닉네임
*/
const createBootstrapUsername = (email) => {
const localPart = String(email).split('@')[0] || 'admin'
return localPart.replace(/[^a-zA-Z0-9._-]/g, '').trim() || 'admin'
}
/**
* 운영 환경 변수 기반 최초 관리자 계정을 생성한다.
* @param {{ email: string, password: string }} credentials - 로그인 입력값
* @returns {Promise<import('../../../../repositories/member-repository').MemberUser | null>} 생성된 관리자
*/
const createBootstrapAdminUser = async (credentials) => {
const adminEmail = getRuntimeEnvValue('ADMIN_EMAIL', 'adminEmail').trim().toLowerCase()
const adminPassword = getRuntimeEnvValue('ADMIN_PASSWORD', 'adminPassword')
if (!adminEmail || !adminPassword || credentials.email.trim().toLowerCase() !== adminEmail || !safeCompare(credentials.password, adminPassword)) {
return null
}
const bootstrap = await getMemberBootstrapState()
if (!bootstrap.needsAdminSetup) {
return null
}
const passwordHash = await bcrypt.hash(credentials.password, 12)
return upsertBootstrapOwner({
username: createBootstrapUsername(adminEmail),
email: adminEmail,
passwordHash
})
}
/**
* 관리자 로그인 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -27,7 +64,7 @@ export default defineEventHandler(async (event) => {
const body = parsedBody.data
const adminUser = await getAdminUserByEmail(body.email)
const adminUser = await getAdminUserByEmail(body.email) || await createBootstrapAdminUser(body)
const passwordMatched = adminUser
? await bcrypt.compare(body.password, adminUser.passwordHash)
: false

View File

@@ -0,0 +1,19 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { uploadMemberAvatarImage } from '../../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../utils/media-library'
/**
* 관리자 새 회원용 썸네일 사전 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const { avatarUrl } = await uploadMemberAvatarImage(event)
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
return {
avatarUrl
}
})

View File

@@ -2,6 +2,7 @@ import { createError, getRouterParam, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
const memberInputSchema = z.object({
username: z.string().trim().min(1).max(60),
@@ -76,5 +77,9 @@ export default defineEventHandler(async (event) => {
})
}
if (existing.avatarUrl && existing.avatarUrl !== updated.avatarUrl) {
await removeManagedAvatarAsset(existing.avatarUrl)
}
return updated
})

View File

@@ -0,0 +1,52 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../../utils/admin-auth'
import { getMemberForAdmin, updateMemberAvatarByAdmin } from '../../../../../repositories/member-repository'
import { removeManagedAvatarAsset } from '../../../../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../../../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../../utils/media-library'
/**
* 관리자 회원 썸네일 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 회원
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const memberId = String(getRouterParam(event, 'id') || '')
if (!memberId) {
throw createError({
statusCode: 400,
message: '회원 ID가 필요합니다.'
})
}
const member = await getMemberForAdmin(memberId)
if (!member) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
const { avatarUrl } = await uploadMemberAvatarImage(event)
const updated = await updateMemberAvatarByAdmin({
memberId,
avatarUrl
})
if (!updated) {
throw createError({
statusCode: 500,
message: '회원 썸네일 수정에 실패했습니다.'
})
}
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
if (member.avatarUrl && member.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(member.avatarUrl)
}
return updated
})

View File

@@ -31,6 +31,14 @@ const clampNumber = (value, minimum, maximum) => {
return Math.round(value)
}
/**
* 시스템 로고 파일명에 사용할 짧은 고유 접미사를 만든다.
* @returns {string} 파일명 접미사
*/
const createSystemAssetSuffix = () => `${new Date().toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/g, '')}-${Math.random().toString(36).slice(2, 8)}`
/**
* 사이트 로고 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -77,10 +85,13 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const logoPath = join(directoryPath, 'logo.webp')
const faviconPath = join(directoryPath, 'favicon.png')
const logoUrl = `${uploadBaseUrl}/system/logo.webp`
const faviconUrl = `${uploadBaseUrl}/system/favicon.png`
const assetSuffix = createSystemAssetSuffix()
const logoFileName = `logo-${assetSuffix}.webp`
const faviconFileName = `favicon-${assetSuffix}.png`
const logoPath = join(directoryPath, logoFileName)
const faviconPath = join(directoryPath, faviconFileName)
const logoUrl = `${uploadBaseUrl}/system/${logoFileName}`
const faviconUrl = `${uploadBaseUrl}/system/${faviconFileName}`
await mkdir(directoryPath, { recursive: true })

View File

@@ -0,0 +1,84 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import { createError, getRequestURL, sendStream, setResponseHeader } from 'h3'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const contentTypes = new Map([
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.png', 'image/png'],
['.webp', 'image/webp'],
['.gif', 'image/gif'],
['.svg', 'image/svg+xml'],
['.ico', 'image/x-icon']
])
/**
* 업로드 요청 URL을 디스크 파일 경로로 변환한다.
* @param {string} pathname - 요청 경로
* @returns {string} 디스크 파일 경로
*/
const resolveUploadFilePath = (pathname) => {
let decodedPath = ''
try {
decodedPath = decodeURIComponent(pathname)
} catch {
throw createError({
statusCode: 400,
message: '업로드 파일 경로가 올바르지 않습니다.'
})
}
const relativeUrlPath = decodedPath.replace(/^\/uploads\/?/g, '')
if (!relativeUrlPath || relativeUrlPath.includes('\0')) {
throw createError({
statusCode: 404,
message: '업로드 파일을 찾을 수 없습니다.'
})
}
const filePath = join(uploadRoot, relativeUrlPath)
const relativeDiskPath = relative(uploadRoot, filePath)
if (relativeDiskPath.startsWith('..') || relativeDiskPath === '') {
throw createError({
statusCode: 400,
message: '업로드 파일 경로가 올바르지 않습니다.'
})
}
return filePath
}
/**
* 런타임 업로드 파일 제공 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<void>} 업로드 파일 스트림
*/
export default defineEventHandler(async (event) => {
const filePath = resolveUploadFilePath(getRequestURL(event).pathname)
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile()) {
throw createError({
statusCode: 404,
message: '업로드 파일을 찾을 수 없습니다.'
})
}
const extension = extname(filePath).toLowerCase()
const contentType = contentTypes.get(extension)
if (contentType) {
setResponseHeader(event, 'content-type', contentType)
}
setResponseHeader(event, 'content-length', String(fileStat.size))
setResponseHeader(event, 'cache-control', 'no-cache')
setResponseHeader(event, 'last-modified', fileStat.mtime.toUTCString())
return sendStream(event, createReadStream(filePath))
})

View File

@@ -1,5 +1,6 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
const adminSessionCookieName = 'sori_admin_session'
const sessionMaxAge = 60 * 60 * 12
@@ -9,16 +10,16 @@ const sessionMaxAge = 60 * 60 * 12
* @returns {string} 세션 서명 비밀값
*/
const getSessionSecret = () => {
const config = useRuntimeConfig()
const adminPassword = getRuntimeEnvValue('ADMIN_PASSWORD', 'adminPassword')
if (!config.adminPassword) {
if (!adminPassword) {
throw createError({
statusCode: 500,
message: '관리자 비밀번호 환경 변수가 없습니다.'
})
}
return config.adminPassword
return adminPassword
}
/**

View File

@@ -1,9 +1,10 @@
import { z } from 'zod'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
export const adminPageInputSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
content: z.string().default(''),
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
featuredImage: z.string().trim().nullable().default(null)
})

View File

@@ -1,10 +1,11 @@
import { z } from 'zod'
import { postStatusSchema } from './content-schema'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { postStatusSchema } from './content-schema.js'
export const adminPostInputSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
content: z.string().default(''),
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
seoTitle: z.string().trim().default(''),

View File

@@ -1,7 +1,7 @@
import { readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createError } from 'h3'
import { listAdminPosts, listPages } from '../repositories/content-repository'
import { getSiteSettings, listAdminPosts, listPages } from '../repositories/content-repository'
import { getPostgresClient } from '../repositories/postgres-client'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
@@ -478,6 +478,46 @@ const getMediaUsage = (url, posts, pages) => {
return [...postUsages, ...pageUsages]
}
/**
* 사이트 설정에서 미디어 URL 사용처 조회
* @param {string} url - 미디어 URL
* @param {Object} siteSettings - 사이트 설정
* @returns {Array<Object>} 사용처 목록
*/
const getSiteSettingsMediaUsage = (url, siteSettings) => {
const usages = []
if (siteSettings.logoUrl === url) {
usages.push({
type: 'settings',
typeLabel: '사이트 설정',
id: 'site-logo',
title: '사이트 로고',
adminUrl: '/admin/settings',
publicUrl: '/',
status: 'system',
location: 'logoUrl',
label: '사이트 로고'
})
}
if (siteSettings.faviconUrl === url) {
usages.push({
type: 'settings',
typeLabel: '사이트 설정',
id: 'site-favicon',
title: '파비콘',
adminUrl: '/admin/settings',
publicUrl: '/',
status: 'system',
location: 'faviconUrl',
label: '파비콘'
})
}
return usages
}
/**
* 미디어 목록 조회
* @returns {Promise<Array<Object>>} 미디어 항목 목록
@@ -485,9 +525,10 @@ const getMediaUsage = (url, posts, pages) => {
export const listMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const [posts, pages] = await Promise.all([
const [posts, pages, siteSettings] = await Promise.all([
listAdminPosts(),
listPages()
listPages(),
getSiteSettings()
])
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
const itemsWithUsage = items.map((item) => {
@@ -498,7 +539,10 @@ export const listMediaItems = async () => {
return {
...item,
category,
usage: getMediaUsage(item.url, posts, pages),
usage: [
...getMediaUsage(item.url, posts, pages),
...getSiteSettingsMediaUsage(item.url, siteSettings)
],
avatarOwner
}
})
@@ -615,11 +659,15 @@ export const updateMediaCategories = async (urls, category) => {
* @returns {Promise<void>}
*/
export const deleteMediaItem = async (url) => {
const [posts, pages] = await Promise.all([
const [posts, pages, siteSettings] = await Promise.all([
listAdminPosts(),
listPages()
listPages(),
getSiteSettings()
])
const usage = getMediaUsage(url, posts, pages)
const usage = [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings)
]
if (usage.length) {
throw createError({
@@ -646,11 +694,15 @@ export const deleteMediaItem = async (url) => {
* @returns {Promise<Object>} 변경된 미디어 항목
*/
export const renameMediaItem = async (url, name) => {
const [posts, pages] = await Promise.all([
const [posts, pages, siteSettings] = await Promise.all([
listAdminPosts(),
listPages()
listPages(),
getSiteSettings()
])
const usage = getMediaUsage(url, posts, pages)
const usage = [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings)
]
if (usage.length) {
throw createError({

View File

@@ -1,5 +1,6 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
const memberSessionCookieName = 'sori_member_session'
const sessionMaxAge = 60 * 60 * 24 * 14
@@ -9,8 +10,7 @@ const sessionMaxAge = 60 * 60 * 24 * 14
* @returns {string} 세션 서명 비밀값
*/
const getSessionSecret = () => {
const config = useRuntimeConfig()
const sessionSecret = String(config.memberSessionSecret || '').trim()
const sessionSecret = getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret').trim()
if (!sessionSecret) {
throw createError({

View File

@@ -0,0 +1,160 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
const allowedImageTypes = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
['image/gif', '.gif']
])
/**
* 업로드 경로 조각을 URL 안전 문자열로 정리한다.
* @param {string} value - 원본 경로 조각
* @returns {string} 정리된 경로 조각
*/
const sanitizePathPart = (value) => value
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
/**
* 숫자 설정값을 최소/최대 범위로 보정한다.
* @param {number} value - 원본 값
* @param {number} minimum - 최소값
* @param {number} maximum - 최대값
* @returns {number} 보정된 값
*/
const clampNumber = (value, minimum, maximum) => {
if (!Number.isFinite(value)) {
return minimum
}
if (value < minimum) {
return minimum
}
if (value > maximum) {
return maximum
}
return Math.round(value)
}
/**
* 아바타 저장 디렉터리에서 비어 있는 `.webp` 파일명을 고른다.
* @param {string} directoryPath - 저장 디렉터리 절대 경로
* @param {string} stem - 확장자 제외 파일명
* @returns {Promise<{ fileName: string, filePath: string }>} 파일명과 절대 경로
*/
const pickUniqueWebpFileName = async (directoryPath, stem) => {
let suffix = 1
while (suffix < 10000) {
const fileName = suffix === 1 ? `${stem}.webp` : `${stem}-${suffix}.webp`
const filePath = join(directoryPath, fileName)
try {
await stat(filePath)
suffix += 1
} catch {
return {
fileName,
filePath
}
}
}
throw createError({
statusCode: 500,
message: '저장할 고유 파일명을 만들 수 없습니다.'
})
}
/**
* 회원 썸네일 파일을 검증하고 회원 전용 경로에 저장한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ avatarUrl: string }>} 저장된 썸네일 URL
*/
export const uploadMemberAvatarImage = async (event) => {
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const avatarMinWidth = clampNumber(Number(config.avatarMinWidth || 96), 1, 4096)
const avatarMinHeight = clampNumber(Number(config.avatarMinHeight || 96), 1, 4096)
const avatarMaxWidth = clampNumber(Number(config.avatarMaxWidth || 512), avatarMinWidth, 4096)
const avatarMaxHeight = clampNumber(Number(config.avatarMaxHeight || 512), avatarMinHeight, 4096)
const avatarSquareSize = Math.min(avatarMaxWidth, avatarMaxHeight)
const avatarWebpQuality = clampNumber(Number(config.avatarWebpQuality || 82), 1, 100)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => ['file', 'files'].includes(String(part.name || '')) && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: '업로드할 이미지가 없습니다.'
})
}
if (!allowedImageTypes.has(file.type)) {
throw createError({
statusCode: 400,
message: '이미지 파일만 업로드할 수 있습니다.'
})
}
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
})
}
const metadata = await sharp(file.data).metadata()
if (!metadata.width || !metadata.height) {
throw createError({
statusCode: 400,
message: '이미지 메타데이터를 읽을 수 없습니다.'
})
}
if (metadata.width < avatarMinWidth || metadata.height < avatarMinHeight) {
throw createError({
statusCode: 400,
message: `최소 ${avatarMinWidth}x${avatarMinHeight} 이상 이미지만 업로드할 수 있습니다.`
})
}
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
await mkdir(directoryPath, { recursive: true })
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'avatar'
const { fileName, filePath } = await pickUniqueWebpFileName(directoryPath, originalStem)
const avatarUrl = `${uploadBaseUrl}/members/avatars/${year}/${month}/${fileName}`
const resizedBuffer = await sharp(file.data)
.rotate()
.resize({
width: avatarSquareSize,
height: avatarSquareSize,
fit: 'cover',
position: 'centre'
})
.webp({
quality: avatarWebpQuality
})
.toBuffer()
await writeFile(filePath, resizedBuffer)
return {
avatarUrl
}
}

View File

@@ -1,4 +1,5 @@
import { createError } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
/**
* Resend가 서버 설정으로 사용 가능한지
@@ -6,8 +7,8 @@ import { createError } from 'h3'
* @returns {boolean}
*/
export const isResendConfigured = (config) => {
const key = String(config?.resendApiKey || '').trim()
const from = String(config?.resendFromEmail || '').trim()
const key = getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey', String(config?.resendApiKey || '')).trim()
const from = getRuntimeEnvValue('RESEND_FROM_EMAIL', 'resendFromEmail', String(config?.resendFromEmail || '')).trim()
return Boolean(key && from)
}

View File

@@ -0,0 +1,36 @@
/**
* 서버 런타임 환경 변수 값을 조회한다.
* @param {string} envName - process.env 변수명
* @param {string} configName - Nuxt runtimeConfig 키
* @param {string} fallback - 기본값
* @returns {string} 환경 변수 값
*/
export const getRuntimeEnvValue = (envName, configName, fallback = '') => {
const directValue = process.env[envName]
if (typeof directValue === 'string' && directValue.length > 0) {
return directValue
}
const config = useRuntimeConfig()
const configValue = config?.[configName]
return typeof configValue === 'string' && configValue.length > 0
? configValue
: fallback
}
/**
* 숫자형 서버 런타임 환경 변수 값을 조회한다.
* @param {string} envName - process.env 변수명
* @param {string} configName - Nuxt runtimeConfig 키
* @param {number} fallback - 기본값
* @returns {number} 환경 변수 숫자 값
*/
export const getRuntimeEnvNumber = (envName, configName, fallback) => {
const value = getRuntimeEnvValue(envName, configName, '')
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0
? parsed
: fallback
}