글쓰기 태그 제한과 표 기능 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:10:16 +09:00
parent ed30926250
commit 95d234a625
24 changed files with 560 additions and 54 deletions

View File

@@ -2747,6 +2747,10 @@ defineExpose({
* @returns {void}
*/
const closeMediaPicker = () => {
if (isUploading.value) {
return
}
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
activeMediaPickerTab.value = 'library'
@@ -2999,7 +3003,7 @@ const handleFileInput = async (event, target) => {
}
/**
* 미디어 모달 업로드 탭에서 파일을 삽입한다.
* 미디어 모달 업로드 탭에서 파일을 업로드하고 라이브러리 목록을 갱신한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<void>}
*/
@@ -3008,10 +3012,6 @@ const uploadFromMediaModal = async (files) => {
return
}
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
? 'gallery'
: mediaPickerTarget.value
isUploading.value = true
try {
@@ -3020,8 +3020,10 @@ const uploadFromMediaModal = async (files) => {
...uploadedFiles,
...mediaItems.value
])
insertSelectedMediaItems(target === 'gallery' ? uploadedFiles : uploadedFiles.slice(0, 1))
closeMediaPicker()
selectedMediaUrls.value = []
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
showToast('success', '업로드가 완료되었습니다. 목록에서 파일을 선택해 삽입해 주세요.')
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
@@ -3360,7 +3362,12 @@ const handleKeydown = (event) => {
{{ selectedMediaUrls.length }} 선택됨
</p>
</div>
<button class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
<button
class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
닫기
</button>
</header>
@@ -3483,13 +3490,18 @@ const handleKeydown = (event) => {
</div>
<footer v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
<button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
<button
class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="isUploading"
@click="closeMediaPicker"
>
취소
</button>
<button
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="selectedMediaUrls.length === 0"
:disabled="isUploading || selectedMediaUrls.length === 0"
@click="applyMediaSelection"
>
삽입

View File

@@ -5,6 +5,7 @@ import {
toAdminPostStoredTitle
} from '../../lib/admin-post-title.js'
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
import { DEFAULT_POST_TAG_LIMIT, normalizePostTagLimit } from '../../lib/post-tag-limit.js'
const props = defineProps({
initialPost: {
@@ -83,6 +84,9 @@ const publishModalExpandedSection = ref(null)
const { data: adminTags } = useFetch('/admin/api/tags', {
default: () => []
})
const { data: siteSettings } = useFetch('/admin/api/settings', {
default: () => ({ postTagLimit: DEFAULT_POST_TAG_LIMIT })
})
const defaultTagColor = '#15171a'
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
@@ -459,6 +463,10 @@ const selectedTags = computed(() => parseTags(form.tagsText))
const selectedTagKeys = computed(() => new Set(selectedTags.value.map((tag) => tag.toLowerCase())))
const postTagLimit = computed(() => normalizePostTagLimit(siteSettings.value?.postTagLimit))
const canAddMoreTags = computed(() => selectedTags.value.length < postTagLimit.value)
const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? adminTags.value : [])
const managedTagOptions = computed(() => availableAdminTags.value.filter((tag) => tag.tagType === 'managed'))
@@ -469,6 +477,9 @@ const tagSuggestionOptions = computed(() => {
return sourceTags
.filter((tag) => {
if (!canAddMoreTags.value) {
return false
}
if (!tag?.slug || selectedTagKeys.value.has(tag.slug.toLowerCase())) {
return false
}
@@ -794,6 +805,12 @@ const addTagToken = (value) => {
return
}
if (!selectedTagKeys.value.has(nextTag.toLowerCase()) && !canAddMoreTags.value) {
tagInput.value = ''
isTagSuggestionsOpen.value = false
return
}
const nextTags = [...selectedTags.value]
if (!selectedTagKeys.value.has(nextTag.toLowerCase())) {
nextTags.push(nextTag)
@@ -872,6 +889,11 @@ const getTagSuggestionMeta = (tag) => {
* @returns {void}
*/
const openTagSuggestions = () => {
if (!canAddMoreTags.value) {
isTagSuggestionsOpen.value = false
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = 0
tagInputRef.value?.focus()
@@ -882,6 +904,11 @@ const openTagSuggestions = () => {
* @returns {void}
*/
const toggleTagSuggestions = () => {
if (!canAddMoreTags.value) {
isTagSuggestionsOpen.value = false
return
}
if (isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = false
return
@@ -1829,7 +1856,10 @@ defineExpose({
<div class="admin-post-form__field grid gap-1 text-sm">
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
<div class="admin-post-form__tag-combobox relative">
<div class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<div
class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]"
:class="!canAddMoreTags ? 'opacity-85' : ''"
>
<span
v-for="tag in selectedTags"
:key="tag"
@@ -1853,7 +1883,8 @@ defineExpose({
v-model="tagInput"
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="태그 입력"
:placeholder="canAddMoreTags ? '태그 입력' : `최대 ${postTagLimit}개까지 선택됨`"
:disabled="!canAddMoreTags"
role="combobox"
:aria-expanded="hasTagSuggestions"
aria-autocomplete="list"
@@ -1868,6 +1899,7 @@ defineExpose({
class="admin-post-form__tag-dropdown-trigger ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#15171a] transition-colors hover:bg-[#e3e6e8] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac]"
type="button"
:aria-expanded="hasTagSuggestions"
:disabled="!canAddMoreTags"
aria-label="메인 태그 목록 열기"
@mousedown.prevent="toggleTagSuggestions"
>
@@ -1877,6 +1909,9 @@ defineExpose({
</svg>
</button>
</div>
<p class="mt-1 text-xs text-[#8e9cac]">
{{ selectedTags.length }} / {{ postTagLimit }} 선택됨
</p>
<div
v-if="hasTagSuggestions"
class="admin-post-form__tag-suggestions absolute left-0 right-0 top-full z-40 mt-1 max-h-64 overflow-y-auto rounded-lg border border-[#d7dce0] bg-white py-1 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.14)]"

View File

@@ -87,6 +87,12 @@ const props = defineProps({
/>
</template>
<!-- table -->
<template v-else-if="commandId === 'table'">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 5.5h16M4 11.5h16M4 17.5h16M8.5 5.5v12M15.5 5.5v12" />
<rect x="3" y="4" width="18" height="15" rx="1.5" stroke="currentColor" stroke-width="1.8" />
</template>
<!-- code -->
<template v-else-if="commandId === 'code'">
<path

View File

@@ -12,6 +12,10 @@ const props = defineProps({
type: Boolean,
default: false
},
requireChanges: {
type: Boolean,
default: false
},
defaultTagType: {
type: String,
default: 'general'
@@ -29,6 +33,58 @@ const form = reactive({
color: props.initialTag.color || '#15171a'
})
/**
* 태그 입력값을 저장 비교용 형태로 정규화한다.
* @param {Object} tag - 태그 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 정규화된 태그 입력값
*/
const normalizeTagPayload = (tag) => ({
name: String(tag.name || '').trim(),
slug: toSlug(tag.slug || tag.name || ''),
description: String(tag.description || '').trim(),
sortOrder: Number(tag.sortOrder ?? 0),
color: String(tag.color || '#15171a'),
tagType: String(tag.tagType || props.defaultTagType)
})
/**
* 현재 폼 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 현재 저장 입력값
*/
const currentPayload = computed(() => normalizeTagPayload({
name: form.name,
slug: form.slug || form.name,
description: form.description,
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 최초 태그 입력값
* @returns {{ name: string, slug: string, description: string, sortOrder: number, color: string, tagType: string }} 최초 저장 입력값
*/
const initialPayload = computed(() => normalizeTagPayload({
name: props.initialTag.name || '',
slug: props.initialTag.slug || props.initialTag.name || '',
description: props.initialTag.description || '',
sortOrder: props.initialTag.sortOrder ?? 0,
color: props.initialTag.color || '#15171a',
tagType: props.initialTag.tagType || props.defaultTagType
}))
/**
* 태그 입력값 변경 여부
* @returns {boolean} 변경 여부
*/
const hasChanges = computed(() => JSON.stringify(currentPayload.value) !== JSON.stringify(initialPayload.value))
/**
* 태그 저장 가능 여부
* @returns {boolean} 저장 가능 여부
*/
const canSubmit = computed(() => !props.saving && (!props.requireChanges || hasChanges.value))
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
@@ -62,14 +118,11 @@ const touchSlug = () => {
* @returns {void}
*/
const submitTag = () => {
emit('submit', {
name: form.name.trim(),
slug: toSlug(form.slug || form.name),
description: form.description.trim(),
sortOrder: props.initialTag.sortOrder ?? 0,
color: form.color,
tagType: props.initialTag.tagType || props.defaultTagType
})
if (!canSubmit.value) {
return
}
emit('submit', currentPayload.value)
}
</script>
@@ -132,7 +185,7 @@ const submitTag = () => {
<button
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
:disabled="!canSubmit"
>
{{ saving ? '저장 중' : submitLabel }}
</button>

View File

@@ -467,6 +467,90 @@ const parseMediaMeta = (raw) => {
return meta
}
/**
* 마크다운 표 행 후보인지 확인한다.
* @param {string} line - 원본 줄
* @returns {boolean} 표 행 후보 여부
*/
const isMarkdownTableRow = (line) => {
const value = String(line || '').trim()
return value.startsWith('|') && value.endsWith('|') && value.split('|').length >= 4
}
/**
* 마크다운 표 구분선 행인지 확인한다.
* @param {string} line - 원본 줄
* @returns {boolean} 구분선 여부
*/
const isMarkdownTableSeparator = (line) => {
if (!isMarkdownTableRow(line)) {
return false
}
return splitMarkdownTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
}
/**
* 마크다운 표 행을 셀 배열로 나눈다.
* @param {string} line - 원본 줄
* @returns {string[]} 셀 목록
*/
const splitMarkdownTableRow = (line) => String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim().replace(/\\\|/g, '|'))
/**
* 표 정렬 행에서 셀 정렬 값을 파싱한다.
* @param {string} cell - 구분선 셀
* @returns {'left'|'center'|'right'} 정렬 값
*/
const parseMarkdownTableAlignment = (cell) => {
const value = String(cell || '').trim()
if (value.startsWith(':') && value.endsWith(':')) {
return 'center'
}
if (value.endsWith(':')) {
return 'right'
}
return 'left'
}
/**
* 마크다운 표 블록을 파싱한다.
* @param {string[]} lines - 전체 마크다운 줄
* @param {number} startIndex - 표 시작 후보 줄
* @returns {{headers: string[], alignments: string[], rows: string[][], endLine: number, nextIndex: number}|null} 표 메타
*/
const parseMarkdownTableBlock = (lines, startIndex) => {
if (!isMarkdownTableRow(lines[startIndex]) || !isMarkdownTableSeparator(lines[startIndex + 1])) {
return null
}
const headers = splitMarkdownTableRow(lines[startIndex])
const alignments = splitMarkdownTableRow(lines[startIndex + 1]).map(parseMarkdownTableAlignment)
const rows = []
let index = startIndex + 2
while (index < lines.length && isMarkdownTableRow(lines[index]) && !isMarkdownTableSeparator(lines[index])) {
rows.push(splitMarkdownTableRow(lines[index]))
index += 1
}
return {
headers,
alignments,
rows,
endLine: index - 1,
nextIndex: index
}
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열
@@ -697,6 +781,24 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
const tableBlock = parseMarkdownTableBlock(lines, index)
if (tableBlock) {
const startLine = index
blocks.push(attachSourceRange(
createBlock('table', '', null, `block-${blocks.length}`, {
meta: {
headers: tableBlock.headers,
alignments: tableBlock.alignments,
rows: tableBlock.rows
}
}),
startLine,
tableBlock.endLine
))
index = tableBlock.nextIndex
continue
}
if (isQuoteMarkerLine(line)) {
const startLine = index
const rawQuoteLines = []
@@ -1283,6 +1385,23 @@ const getHeadingEditableClass = (level) => {
return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]`
}
/**
* 표 셀 정렬 클래스를 반환한다.
* @param {string} alignment - 정렬 값
* @returns {string} Tailwind 정렬 클래스
*/
const getTableCellAlignClass = (alignment) => {
if (alignment === 'center') {
return 'text-center'
}
if (alignment === 'right') {
return 'text-right'
}
return 'text-left'
}
/**
* 제목 블록 마크다운 줄을 만든다.
* @param {number} level - 제목 레벨
@@ -3332,6 +3451,55 @@ onBeforeUnmount(() => {
</figure>
</div>
</div>
<div
v-else-if="block.type === 'table'"
class="content-markdown-renderer__table-wrap my-6 overflow-x-auto"
:data-source-line="block.meta.startLine"
:data-source-line-end="block.meta.endLine"
>
<table class="content-markdown-renderer__table w-full min-w-[520px] border-collapse text-sm text-[var(--site-text)]">
<thead>
<tr>
<th
v-for="(header, headerIndex) in block.meta.headers"
:key="`${block.id}-table-head-${headerIndex}`"
class="border border-line bg-[color-mix(in_srgb,var(--site-text)_6%,transparent)] px-3 py-2 font-semibold"
:class="getTableCellAlignClass(block.meta.alignments?.[headerIndex])"
>
<template v-for="(segment, segmentIndex) in parseInlineSegments(header)" :key="`${block.id}-table-head-${headerIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ 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>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in block.meta.rows" :key="`${block.id}-table-row-${rowIndex}`">
<td
v-for="(_, cellIndex) in block.meta.headers"
:key="`${block.id}-table-cell-${rowIndex}-${cellIndex}`"
class="border border-line px-3 py-2 align-top"
:class="getTableCellAlignClass(block.meta.alignments?.[cellIndex])"
>
<template v-for="(segment, segmentIndex) in parseInlineSegments(row[cellIndex] || '')" :key="`${block.id}-table-cell-${rowIndex}-${cellIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ 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>
</td>
</tr>
</tbody>
</table>
</div>
<ContentMarkdownCodeBlockEditor
v-else-if="block.type === 'code' && interactive"
:language="block.codeLanguage"

View File

@@ -0,0 +1,10 @@
ALTER TABLE site_settings
ADD COLUMN IF NOT EXISTS post_tag_limit INTEGER NOT NULL DEFAULT 5;
ALTER TABLE site_settings
ADD CONSTRAINT site_settings_post_tag_limit_range_check
CHECK (post_tag_limit BETWEEN 1 AND 10)
NOT VALID;
ALTER TABLE site_settings
VALIDATE CONSTRAINT site_settings_post_tag_limit_range_check;

View File

@@ -1,5 +1,18 @@
# 업데이트 요약
## v1.5.93
- 글쓰기 태그는 기본 최대 5개까지 선택할 수 있으며, 사이트 설정에서 1~10개 범위로 조절할 수 있게 했다.
- 게시물 저장 API도 설정된 태그 최대 개수를 초과하면 저장을 막는다.
- `/표` 또는 `/table` 슬래시 명령으로 기본 표를 삽입할 수 있고, 공개 본문에서 마크다운 표가 표 형태로 렌더링된다.
## v1.5.92
- 새 태그 저장 후 태그 목록으로 돌아가고, 저장·삭제 결과를 토스트로 확인할 수 있게 했다.
- 태그 수정의 변경 저장 버튼은 실제 변경사항이 있을 때만 활성화된다.
- 글쓰기 미디어 모달은 업로드 완료 후 자동 삽입·자동 닫기를 하지 않고, 목록에서 직접 선택해 삽입하도록 했다.
- 미디어 업로드 중에는 추가 업로드와 닫기를 막는다.
## v1.5.91
- 글쓰기 본문 미디어 선택 창에서 카드 썸네일 파생 이미지가 중복으로 보이지 않게 했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.91에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.93에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,21 @@
## 로컬 개발
### v1.5.93 참고
- DB 마이그레이션 `056_site_settings_post_tag_limit.sql` 적용이 필요하다.
- 관리자 사이트 설정의 POST 설정에서 태그 최대 개수를 1~10개 사이로 저장할 수 있는지 확인한다.
- 게시물 작성에서 설정된 개수 이상 태그를 추가할 수 없고, 저장 API도 초과 태그를 거부하는지 확인한다.
- 게시물 작성 본문에서 `/표` 또는 `/table`로 기본 표가 삽입되고 공개/미리보기 본문에서 표로 렌더링되는지 확인한다.
### v1.5.92 참고
- 추가 DB 마이그레이션은 없다.
- 관리자 새 태그 저장 후 `/admin/tags` 목록으로 이동하고 저장 완료 토스트가 표시되는지 확인한다.
- 태그 수정 화면에서 변경 전에는 `변경 저장` 버튼이 비활성화되고, 변경·저장 후 다시 비활성화되는지 확인한다.
- 게시물 작성에서 `/이미지` 미디어 모달 업로드 후 본문에 자동 삽입되지 않고 라이브러리 목록에서 직접 선택해 삽입할 수 있는지 확인한다.
- 미디어 모달 업로드 중 닫기·추가 업로드가 막히는지 확인한다.
### v1.5.91 참고
- DB 마이그레이션 `055_add_post_tag_sort_order.sql` 적용이 필요하다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-09 v1.5.93 — 태그 제한은 사이트 설정으로 관리하고 표는 표준 마크다운부터 지원한다
게시물 태그는 목록 UI와 공개 카드에서 스캔성을 좌우하므로 기본 최대 5개로 제한한다. 운영자가 블로그 성격에 맞게 조절할 수 있도록 `site_settings.post_tag_limit`으로 저장하되, 과도한 태그 입력을 막기 위해 허용 범위는 1~10개로 제한하고 글쓰기 UI와 저장 API 양쪽에서 검증한다. 표 기능은 셀 단위 라이브 편집까지 한 번에 확장하면 에디터 안정성에 영향이 크므로, 먼저 표준 마크다운 표 삽입과 렌더링을 지원해 콘텐츠 작성에 필요한 기본 기능을 제공한다.
## 2026-06-09 v1.5.91 — 게시물 태그 표시 순서는 저장 순서로 고정한다
게시물에 여러 태그를 설정했을 때 공개 목록은 작성자가 입력한 첫 번째 태그를 기준으로 보여 주어야 한다. 기존 `post_tags` 연결 테이블에는 게시물 안에서의 순서가 없어 PostgreSQL `array_agg` 결과가 안정적이지 않을 수 있으므로, `post_tags.sort_order`를 추가해 저장 시 입력 순서를 기록하고 조회 시 이 값을 기준으로 정렬한다. 관리자 게시물 목록은 운영 확인용 화면이므로 대표 태그 하나가 아니라 적용된 태그 전체를 보여 준다.

View File

@@ -35,6 +35,7 @@
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
| lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 |
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
@@ -53,6 +54,12 @@
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
| scripts/backfill-post-thumbnails.js | 게시물 대표 이미지 URL 기준 목록 카드용 WebP 썸네일 백필 |
## DB 마이그레이션
| 파일 | 용도 |
|------|------|
| db/migrations/056_site_settings_post_tag_limit.sql | 사이트 설정의 게시물 태그 최대 개수 컬럼 추가 |
## 서버 미들웨어
| 파일 | 용도 |
@@ -99,12 +106,12 @@
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·`block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시, 업로드 완료 후 목록 갱신 및 수동 선택 삽입), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·`block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
@@ -119,7 +126,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 표준 마크다운 표 파싱·렌더링, 공개 게시물 인아티클 광고를 본문 길이에 따라 0~2회 삽입, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |

View File

@@ -81,8 +81,9 @@
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다.
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다. 게시물 상세에서는 오른쪽 사이드바의 공통 광고를 숨기고, 게시물 왼쪽 사이드 광고 코드가 있을 때 데스크톱 왼쪽 사이드바 하단에 광고 슬롯을 표시한다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
@@ -169,7 +170,7 @@ layouts/
- 대시보드 메뉴는 관리자 기본 페이지(`/admin`)로 이동하는 활성 링크로 표시한다.
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다.
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다. 게시물별 태그 최대 개수는 기본 5개이며, 사이트 설정 POST 설정의 `postTagLimit` 값으로 1~10개 범위에서 조절한다.
- 관리자 글쓰기 상단 왼쪽 상태 영역은 `Published`, `Scheduled`, `Members`, `Private`, 초안 저장 상태 등 현재 상태 텍스트만 표시하며, 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서만 제공한다. 오른쪽 설정 패널 하단에는 본문 기준 단어 수, 공백 제외 문자 수와 공백 수, 예상 읽기 시간, 블록 수, 이미지 수를 작은 통계로 표시한다.
- 관리자 미디어 검색창은 글·멤버 목록과 같은 돋보기 아이콘 포함 입력 스타일을 사용한다. 미디어 라이브러리 탭에서는 파일 추가 버튼으로 `/admin/api/uploads`에 직접 업로드할 수 있고, 현재 폴더를 보고 있으면 업로드 후 해당 폴더로 배치한다. 전체 선택은 현재 검색·필터 결과만 대상으로 하며, 선택 삭제는 사용 중이거나 회원 프로필에 연결된 잠금 항목을 제외하고 삭제한다.
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
@@ -419,6 +420,7 @@ components/content/
| logo_url | String | 공개 로고 이미지 URL |
| favicon_url | String | 파비콘 이미지 URL |
| show_post_updated_at | Boolean | 관리자 글 목록 수정일 보조 표시 여부 |
| post_tag_limit | Integer | 게시물별 태그 최대 개수, 기본 5, 허용 범위 1~10 |
| home_cover_image_url | String | 라이트모드 홈 커버 이미지 URL |
| home_cover_dark_image_url | String | 다크모드 홈 커버 이미지 URL |
| home_cover_title | String | 홈 커버 오버레이 제목 |
@@ -600,8 +602,8 @@ components/content/
- `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /admin/api/tags/:id` - 태그 삭제
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, `postTagLimit`, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `postTagLimit`, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `postTagLimit`은 1~10 정수만 허용한다. `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
@@ -622,15 +624,16 @@ components/content/
- 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
- 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
- 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
- 게시물 생성·수정 API는 사이트 설정의 `postTagLimit`보다 많은 태그가 전달되면 400 오류로 저장을 거부한다.
- 공개 `GET /api/tags``managed`(메인 태그)만 반환한다.
- 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
- 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
- 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
- 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
- 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
- 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 저장 후 수정 화면이 아니라 태그 관리 목록으로 이동해 일반 태그 목록에 바로 표시한다.
- 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제, 태그 생성·수정 저장 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다. 태그 수정 화면의 `변경 저장` 버튼은 실제 변경사항이 있을 때만 활성화한다.
- 태그 `color``#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
### 관리자 글 편집
@@ -659,7 +662,7 @@ components/content/
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰을 지원한다. `$H_2O$``H`+아래첨자 `2`+`O`, `$2^8$``2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다.
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 라이브러리 목록을 갱신한다. 업로드 중에는 추가 드롭·파일 선택과 모달 닫기를 막고 로딩 스피너를 표시하며, 업로드 완료 후에는 모달을 자동으로 닫거나 본문에 자동 삽입하지 않는다. 본문 삽입은 라이브러리 목록에서 파일을 직접 선택한 뒤 `삽입` 버튼을 눌렀을 때만 실행한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄, 기존 `:::embed`, 인용문, `:::callout`, 코드 fenced 블록, `:::toggle` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 라이브 모드 멀티라인 편집기는 실제 커서가 있는 원본 줄을 패널 상태에 반영하며, 커서가 지원 블록 밖으로 이동하면 직전 코드·콜아웃·인용 설정 패널을 닫는다. 코드 fenced 블록 판별은 위에서 아래로 여는 펜스와 닫는 펜스를 짝지어 처리해, 닫는 ` ``` ` 줄을 다음 코드 블록 시작으로 오인하지 않는다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
@@ -764,7 +767,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **POST 설정**(`show_post_updated_at`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수를 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code``body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.

View File

@@ -1,5 +1,22 @@
# 업데이트 이력
## v1.5.93
- 게시물 글쓰기: 게시물별 태그 최대 개수를 기본 5개로 제한하도록 추가.
- 사이트 설정: POST 설정에서 태그 최대 개수를 1~10개 범위로 조절하는 항목 추가.
- 서버 API: 게시물 생성·수정 시 설정된 태그 최대 개수를 초과하면 저장을 차단하도록 보강.
- 공개 본문: 표준 마크다운 표 렌더링 추가.
- 게시물 글쓰기: `/표`·`/table` 슬래시 명령으로 기본 표 템플릿을 삽입하도록 추가.
- DB: `site_settings.post_tag_limit` 마이그레이션 추가.
## v1.5.92
- 관리자 태그: 새 태그 저장 후 수정 화면이 아니라 태그 목록으로 이동하도록 수정.
- 관리자 태그: 태그 수정 화면의 변경 저장 버튼을 실제 변경사항이 있을 때만 활성화하도록 수정.
- 관리자 태그: 저장·삭제 액션의 우측 상단 토스트 피드백 추가.
- 게시물 글쓰기: 미디어 모달 업로드 완료 후 본문에 자동 삽입하지 않고 목록에서 직접 선택해 삽입하도록 수정.
- 게시물 글쓰기: 미디어 모달 업로드 중 닫기·중복 업로드를 막도록 수정.
## v1.5.91
- 게시물 글쓰기: 본문 미디어 선택 모달에서 게시물 카드 썸네일 파생 파일을 숨기도록 수정.

View File

@@ -75,6 +75,14 @@ export const MARKDOWN_SLASH_COMMANDS = [
action: 'lines',
lines: ['- ']
},
{
id: 'table',
label: '표',
description: '기본 3열 표',
keywords: ['table', '표', '테이블'],
action: 'lines',
lines: ['| 제목 | 제목 | 제목 |', '| --- | --- | --- |', '| 내용 | 내용 | 내용 |']
},
{
id: 'code',
label: '코드',

18
lib/post-tag-limit.js Normal file
View File

@@ -0,0 +1,18 @@
export const DEFAULT_POST_TAG_LIMIT = 5
export const MIN_POST_TAG_LIMIT = 1
export const MAX_POST_TAG_LIMIT = 10
/**
* 게시물 태그 최대 개수를 설정 범위 안의 정수로 정규화한다.
* @param {unknown} value - 입력값
* @returns {number} 정규화된 태그 최대 개수
*/
export const normalizePostTagLimit = (value) => {
const parsed = Number.parseInt(String(value ?? DEFAULT_POST_TAG_LIMIT), 10)
if (!Number.isFinite(parsed)) {
return DEFAULT_POST_TAG_LIMIT
}
return Math.min(MAX_POST_TAG_LIMIT, Math.max(MIN_POST_TAG_LIMIT, parsed))
}

View File

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

View File

@@ -17,6 +17,12 @@ import {
getSocialIconPreset,
normalizeSocialLinks
} from '~/lib/social-links.js'
import {
DEFAULT_POST_TAG_LIMIT,
MAX_POST_TAG_LIMIT,
MIN_POST_TAG_LIMIT,
normalizePostTagLimit
} from '~/lib/post-tag-limit.js'
definePageMeta({
layout: 'admin'
@@ -103,7 +109,8 @@ const socialSnapshot = reactive({
})
/** 편집 시작 시점의 POST 설정(취소 시 복원용) */
const postSnapshot = reactive({
showPostUpdatedAt: false
showPostUpdatedAt: false,
postTagLimit: DEFAULT_POST_TAG_LIMIT
})
/** 편집 시작 시점의 메인 화면 커버(취소 시 복원용) */
const homeCoverSnapshot = reactive({
@@ -166,6 +173,7 @@ const form = reactive({
copyrightText: settings.value?.copyrightText || '©2026 sori.studio',
socialLinks: normalizeSocialLinks(settings.value?.socialLinks || []),
showPostUpdatedAt: Boolean(settings.value?.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(settings.value?.postTagLimit),
homeCoverImageUrl: settings.value?.homeCoverImageUrl || '',
homeCoverDarkImageUrl: settings.value?.homeCoverDarkImageUrl || '',
homeCoverTitle: settings.value?.homeCoverTitle || '',
@@ -222,7 +230,10 @@ const hasSocialChanges = computed(() => editSocial.value
* @returns {boolean} 변경 여부
*/
const hasPostChanges = computed(() => editPost.value
&& form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt)
&& (
form.showPostUpdatedAt !== postSnapshot.showPostUpdatedAt
|| normalizePostTagLimit(form.postTagLimit) !== normalizePostTagLimit(postSnapshot.postTagLimit)
))
/**
* 메인 화면 커버 변경 여부
@@ -1122,6 +1133,7 @@ const buildSiteSettingsPayload = () => ({
copyrightText: form.copyrightText,
socialLinks: normalizeSocialLinks(form.socialLinks),
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(form.postTagLimit),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
@@ -1351,6 +1363,8 @@ const saveSocialSection = async () => {
*/
const beginEditPost = () => {
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
form.postTagLimit = normalizePostTagLimit(form.postTagLimit)
editPost.value = true
}
@@ -1360,6 +1374,7 @@ const beginEditPost = () => {
*/
const cancelEditPost = () => {
form.showPostUpdatedAt = postSnapshot.showPostUpdatedAt
form.postTagLimit = normalizePostTagLimit(postSnapshot.postTagLimit)
editPost.value = false
}
@@ -1379,6 +1394,8 @@ const savePostSection = async () => {
if (ok) {
postSnapshot.showPostUpdatedAt = form.showPostUpdatedAt
postSnapshot.postTagLimit = normalizePostTagLimit(form.postTagLimit)
form.postTagLimit = postSnapshot.postTagLimit
editPost.value = false
}
}
@@ -2382,6 +2399,7 @@ onBeforeUnmount(() => {
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080]"
>
공개 상세·관리자 목록에서 발행 수정이 있었을 수정일을 함께 표시합니다.
글쓰기에서 선택할 있는 태그 최대 개수도 함께 관리합니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
@@ -2433,24 +2451,47 @@ onBeforeUnmount(() => {
<span class="relative ml-1 size-5 rounded-full bg-[#f4f6f8] shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</div>
<div class="mt-5 flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5">
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
<span class="font-mono text-sm font-semibold text-[#657080]">
{{ normalizePostTagLimit(form.postTagLimit) }}
</span>
</div>
</div>
<label
<div
v-else
class="admin-settings-screen__post-toggle flex items-center justify-between gap-4 border-t border-[#eceff2] pt-5 text-sm"
class="grid gap-5 border-t border-[#eceff2] pt-5 text-sm"
>
<span class="font-bold text-[#15171a]">수정일 표시</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<label class="admin-settings-screen__post-toggle flex items-center justify-between gap-4">
<span class="font-bold text-[#15171a]">수정일 표시</span>
<span class="admin-settings-screen__post-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
<input
v-model="form.showPostUpdatedAt"
class="peer sr-only"
type="checkbox"
aria-label="수정일 표시"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<label class="admin-settings-screen__field grid gap-2">
<span class="font-bold text-[#15171a]">태그 최대 개수</span>
<input
v-model="form.showPostUpdatedAt"
class="peer sr-only"
type="checkbox"
aria-label="수정일 표시"
v-model.number="form.postTagLimit"
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="number"
:min="MIN_POST_TAG_LIMIT"
:max="MAX_POST_TAG_LIMIT"
step="1"
@blur="form.postTagLimit = normalizePostTagLimit(form.postTagLimit)"
>
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
</span>
</label>
<span class="text-xs leading-relaxed text-[#657080]">
최소 {{ MIN_POST_TAG_LIMIT }}, 최대 {{ MAX_POST_TAG_LIMIT }}개까지 설정할 있습니다. 기본값은 {{ DEFAULT_POST_TAG_LIMIT }}개입니다.
</span>
</label>
</div>
</section>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">

View File

@@ -8,6 +8,8 @@ const id = computed(() => String(route.params.id || ''))
const saving = ref(false)
const deleting = ref(false)
const errorMessage = ref('')
const { toast, showToast } = useAdminToast()
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
const { data: tag } = await useFetch(() => `/admin/api/tags/${id.value}`)
@@ -26,6 +28,7 @@ if (!tag.value) {
const saveTag = async (payload) => {
saving.value = true
errorMessage.value = ''
showToast('info', '변경 내용을 저장하는 중입니다.')
try {
const updatedTag = await $fetch(`/admin/api/tags/${id.value}`, {
@@ -34,8 +37,10 @@ const saveTag = async (payload) => {
})
tag.value = updatedTag
showToast('success', '변경 내용이 저장되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 저장하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
saving.value = false
}
@@ -52,14 +57,20 @@ const deleteTag = async () => {
deleting.value = true
errorMessage.value = ''
showToast('info', '태그를 삭제하는 중입니다.')
try {
await $fetch(`/admin/api/tags/${id.value}`, {
method: 'DELETE'
})
sessionStorage.setItem(TAG_TOAST_STORAGE_KEY, JSON.stringify({
type: 'success',
message: '태그가 삭제되었습니다.'
}))
await navigateTo('/admin/tags')
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
deleting.value = false
}
@@ -89,6 +100,19 @@ const deleteTag = async () => {
<p v-if="errorMessage" class="admin-tag-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<AdminTagForm :initial-tag="tag" submit-label="변경 저장" :saving="saving" @submit="saveTag" />
<AdminTagForm :initial-tag="tag" submit-label="변경 저장" :saving="saving" require-changes @submit="saveTag" />
<div
v-if="toast"
class="admin-tag-edit__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-line bg-white text-ink': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</section>
</template>

View File

@@ -10,6 +10,7 @@ const promotingTagId = ref('')
const demotingTagId = ref('')
const deletingGeneralTagId = ref('')
const toast = ref(null)
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
const { openMenuId, closeMenu } = useAdminRowMenu()
let toastTimer = null
@@ -111,6 +112,21 @@ const showToast = (type, message) => {
}, 3200)
}
onMounted(() => {
const storedToast = sessionStorage.getItem(TAG_TOAST_STORAGE_KEY)
if (!storedToast) {
return
}
try {
const parsedToast = JSON.parse(storedToast)
showToast(parsedToast.type || 'success', parsedToast.message || '처리되었습니다.')
} finally {
sessionStorage.removeItem(TAG_TOAST_STORAGE_KEY)
}
})
/**
* 관리용 태그 드래그 시작
* @param {DragEvent} event - 드래그 이벤트

View File

@@ -5,6 +5,8 @@ definePageMeta({
const saving = ref(false)
const errorMessage = ref('')
const { toast, showToast } = useAdminToast()
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
/**
* 새 태그 저장
@@ -14,16 +16,22 @@ const errorMessage = ref('')
const saveTag = async (payload) => {
saving.value = true
errorMessage.value = ''
showToast('info', '태그를 저장하는 중입니다.')
try {
const tag = await $fetch('/admin/api/tags', {
await $fetch('/admin/api/tags', {
method: 'POST',
body: payload
})
await navigateTo(`/admin/tags/${tag.id}`)
sessionStorage.setItem(TAG_TOAST_STORAGE_KEY, JSON.stringify({
type: 'success',
message: '태그가 저장되었습니다.'
}))
await navigateTo('/admin/tags')
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 저장하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
saving.value = false
}
@@ -44,5 +52,18 @@ const saveTag = async (payload) => {
{{ errorMessage }}
</p>
<AdminTagForm submit-label="태그 저장" :saving="saving" @submit="saveTag" />
<div
v-if="toast"
class="admin-tag-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-line bg-white text-ink': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</section>
</template>

View File

@@ -20,6 +20,7 @@ import {
normalizeSignupBlockedUsernames,
parseSignupBlockedUsernamesFromDb
} from '../../lib/signup-blocked-usernames.js'
import { normalizePostTagLimit } from '../../lib/post-tag-limit.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
import {
createPostThumbnailForImageUrl,
@@ -116,6 +117,7 @@ const mapSiteSettingsRow = (row) => ({
copyrightText: row.copyright_text,
socialLinks: normalizeSocialLinks(row.social_links),
showPostUpdatedAt: Boolean(row.show_post_updated_at),
postTagLimit: normalizePostTagLimit(row.post_tag_limit),
homeCoverImageUrl: row.home_cover_image_url || '',
homeCoverDarkImageUrl: row.home_cover_dark_image_url || '',
homeCoverTitle: row.home_cover_title || '',
@@ -903,6 +905,7 @@ export const updateSiteSettings = async (input) => {
copyright_text,
social_links,
show_post_updated_at,
post_tag_limit,
home_cover_image_url,
home_cover_dark_image_url,
home_cover_title,
@@ -937,6 +940,7 @@ export const updateSiteSettings = async (input) => {
${input.copyrightText},
${JSON.stringify(normalizeSocialLinks(input.socialLinks))}::jsonb,
${input.showPostUpdatedAt ? true : false},
${normalizePostTagLimit(input.postTagLimit)},
${input.homeCoverImageUrl || ''},
${input.homeCoverDarkImageUrl || ''},
${input.homeCoverTitle || ''},
@@ -971,6 +975,7 @@ export const updateSiteSettings = async (input) => {
copyright_text = EXCLUDED.copyright_text,
social_links = EXCLUDED.social_links,
show_post_updated_at = EXCLUDED.show_post_updated_at,
post_tag_limit = EXCLUDED.post_tag_limit,
home_cover_image_url = EXCLUDED.home_cover_image_url,
home_cover_dark_image_url = EXCLUDED.home_cover_dark_image_url,
home_cover_title = EXCLUDED.home_cover_title,

View File

@@ -1,7 +1,7 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminPostInput } from '../../../utils/admin-post-input'
import { createAdminPost } from '../../../repositories/content-repository'
import { createAdminPost, getSiteSettings } from '../../../repositories/content-repository'
/**
* 관리자 게시물 생성 API
@@ -20,6 +20,16 @@ export default defineEventHandler(async (event) => {
})
}
const settings = await getSiteSettings()
const postTagLimit = Number(settings.postTagLimit || 5)
if (parsedBody.data.tags.length > postTagLimit) {
throw createError({
statusCode: 400,
message: `태그는 최대 ${postTagLimit}개까지 선택할 수 있습니다.`
})
}
try {
return await createAdminPost(parsedBody.data, adminSession.userId)
} catch (error) {

View File

@@ -1,7 +1,7 @@
import { createError, getRouterParam, readBody } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { parseAdminPostInput } from '../../../../utils/admin-post-input'
import { updateAdminPost } from '../../../../repositories/content-repository'
import { getSiteSettings, updateAdminPost } from '../../../../repositories/content-repository'
/**
* 관리자 게시물 수정 API
@@ -21,6 +21,16 @@ export default defineEventHandler(async (event) => {
})
}
const settings = await getSiteSettings()
const postTagLimit = Number(settings.postTagLimit || 5)
if (parsedBody.data.tags.length > postTagLimit) {
throw createError({
statusCode: 400,
message: `태그는 최대 ${postTagLimit}개까지 선택할 수 있습니다.`
})
}
try {
const post = await updateAdminPost(id, parsedBody.data, adminSession.userId)

View File

@@ -19,6 +19,12 @@ import {
normalizeSignupBlockedUsernames
} from '../../lib/signup-blocked-usernames.js'
import { normalizeSocialLinks } from '../../lib/social-links.js'
import {
DEFAULT_POST_TAG_LIMIT,
MAX_POST_TAG_LIMIT,
MIN_POST_TAG_LIMIT,
normalizePostTagLimit
} from '../../lib/post-tag-limit.js'
export const adminSiteSettingsInputSchema = z.object({
title: z.string().trim().min(1),
@@ -30,6 +36,7 @@ export const adminSiteSettingsInputSchema = z.object({
copyrightText: z.string().trim().min(1),
socialLinks: z.unknown().optional().default([]),
showPostUpdatedAt: z.boolean().optional().default(false),
postTagLimit: z.coerce.number().int().min(MIN_POST_TAG_LIMIT).max(MAX_POST_TAG_LIMIT).optional().default(DEFAULT_POST_TAG_LIMIT),
homeCoverImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverDarkImageUrl: z.string().trim().max(500).optional().default(''),
homeCoverTitle: z.string().trim().max(120).optional().default(''),
@@ -81,6 +88,7 @@ export const adminSiteSettingsInputSchema = z.object({
...data,
brandColor: normalizeBrandColor(data.brandColor),
socialLinks: normalizeSocialLinks(data.socialLinks),
postTagLimit: normalizePostTagLimit(data.postTagLimit),
announcementUrl: normalizeAnnouncementUrl(data.announcementUrl),
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(data.announcementBackgroundColor),
announcementAlignment: normalizeAnnouncementAlignment(data.announcementAlignment),

View File

@@ -4,6 +4,7 @@ import {
DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR
} from '../../lib/announcement-bar.js'
import { DEFAULT_SIGNUP_BLOCKED_USERNAMES } from '../../lib/signup-blocked-usernames.js'
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
/**
* 기본 사이트 설정 반환
@@ -23,6 +24,7 @@ export const getDefaultSiteSettings = () => {
copyrightText: `©${new Date().getFullYear()} ${title}`,
socialLinks: [],
showPostUpdatedAt: false,
postTagLimit: DEFAULT_POST_TAG_LIMIT,
homeCoverImageUrl: '',
homeCoverDarkImageUrl: '',
homeCoverTitle: '',