페이지 HTML 작성 기본값과 자산 업로드 개선 v1.5.3
This commit is contained in:
@@ -32,30 +32,60 @@ const slugTouched = ref(Boolean(props.initialPage.slug))
|
||||
const blockEditor = ref(null)
|
||||
const htmlEditor = ref(null)
|
||||
const editorMode = ref('write')
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploadingFeaturedImage = ref(false)
|
||||
const isUploadingPageAsset = ref(false)
|
||||
const isSettingsOpen = ref(true)
|
||||
const savedPageSnapshot = ref('')
|
||||
const htmlCursorRange = reactive({
|
||||
start: 0,
|
||||
end: 0
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: props.initialPage.title || '',
|
||||
slug: props.initialPage.slug || '',
|
||||
renderMode: props.initialPage.renderMode || 'markdown',
|
||||
content: props.initialPage.content || '',
|
||||
featuredImage: props.initialPage.featuredImage || ''
|
||||
renderMode: props.initialPage.renderMode || 'html_document',
|
||||
content: props.initialPage.content || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* 한글 음절 1자를 영문 표기로 변환
|
||||
* @param {string} char - 변환할 문자
|
||||
* @returns {string} 영문 표기
|
||||
*/
|
||||
const romanizeHangulSyllable = (char) => {
|
||||
const syllableCode = char.charCodeAt(0)
|
||||
const hangulBase = 0xac00
|
||||
const hangulLast = 0xd7a3
|
||||
|
||||
if (syllableCode < hangulBase || syllableCode > hangulLast) {
|
||||
return char
|
||||
}
|
||||
|
||||
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
|
||||
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
|
||||
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
|
||||
|
||||
const offset = syllableCode - hangulBase
|
||||
const choseongIndex = Math.floor(offset / 588)
|
||||
const jungseongIndex = Math.floor((offset % 588) / 28)
|
||||
const jongseongIndex = offset % 28
|
||||
|
||||
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 영문 URL 슬러그로 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 슬러그
|
||||
* @returns {string} 영문 슬러그
|
||||
*/
|
||||
const toSlug = (value) => value
|
||||
.normalize('NFC')
|
||||
.split('')
|
||||
.map((char) => romanizeHangulSyllable(char))
|
||||
.join('')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣\s-]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
@@ -72,8 +102,7 @@ const serializePageForm = () => JSON.stringify({
|
||||
title: form.title.trim(),
|
||||
slug: pageSlug.value,
|
||||
renderMode: form.renderMode,
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage.trim() || null
|
||||
content: form.content
|
||||
})
|
||||
|
||||
const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value)
|
||||
@@ -114,83 +143,6 @@ const toggleSettingsPanel = () => {
|
||||
isSettingsOpen.value = !isSettingsOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록 조회
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 열기
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async () => {
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택 창 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 선택
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectFeaturedImage = (item) => {
|
||||
form.featuredImage = item.url
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 삭제
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFeaturedImage = () => {
|
||||
form.featuredImage = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 이미지 파일 업로드
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFeaturedImage = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', files[0])
|
||||
isUploadingFeaturedImage.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.featuredImage = result.files?.[0]?.url || ''
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
isUploadingFeaturedImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
@@ -216,6 +168,73 @@ const setRenderMode = (mode) => {
|
||||
form.renderMode = mode
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML textarea 커서 위치를 기억한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const rememberHtmlCursor = () => {
|
||||
if (!htmlEditor.value) {
|
||||
return
|
||||
}
|
||||
|
||||
htmlCursorRange.start = htmlEditor.value.selectionStart ?? form.content.length
|
||||
htmlCursorRange.end = htmlEditor.value.selectionEnd ?? htmlCursorRange.start
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 본문 커서 위치에 텍스트를 삽입한다.
|
||||
* @param {string} text - 삽입할 텍스트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const insertTextAtHtmlCursor = async (text) => {
|
||||
const start = Math.max(0, htmlCursorRange.start)
|
||||
const end = Math.max(start, htmlCursorRange.end)
|
||||
|
||||
form.content = `${form.content.slice(0, start)}${text}${form.content.slice(end)}`
|
||||
|
||||
await nextTick()
|
||||
|
||||
const nextCursor = start + text.length
|
||||
htmlEditor.value?.focus()
|
||||
htmlEditor.value?.setSelectionRange(nextCursor, nextCursor)
|
||||
htmlCursorRange.start = nextCursor
|
||||
htmlCursorRange.end = nextCursor
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다.
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadPageAsset = async (event) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberHtmlCursor()
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', files[0])
|
||||
isUploadingPageAsset.value = true
|
||||
|
||||
try {
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const uploadedUrl = result.files?.[0]?.url || ''
|
||||
|
||||
if (uploadedUrl && form.renderMode === 'html_document') {
|
||||
await insertTextAtHtmlCursor(uploadedUrl)
|
||||
}
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
isUploadingPageAsset.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 입력값을 생성한다.
|
||||
* @returns {Object} 페이지 입력값
|
||||
@@ -225,7 +244,7 @@ const createPayload = () => ({
|
||||
slug: pageSlug.value,
|
||||
renderMode: form.renderMode,
|
||||
content: form.content,
|
||||
featuredImage: form.featuredImage.trim() || null
|
||||
featuredImage: null
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -305,27 +324,7 @@ defineExpose({
|
||||
</header>
|
||||
|
||||
<main class="admin-page-form__editor-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<section class="admin-page-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-16">
|
||||
<div class="admin-page-form__feature-block mb-9">
|
||||
<figure v-if="form.featuredImage" class="admin-page-form__featured-editor group relative overflow-hidden bg-white">
|
||||
<img class="admin-page-form__featured-editor-image aspect-[16/9] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||
<figcaption class="admin-page-form__featured-editor-actions pointer-events-none absolute inset-0 flex items-end justify-end gap-2 bg-gradient-to-t from-black/40 via-black/5 to-transparent p-4 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
||||
<button class="admin-page-form__featured-change rounded bg-white/95 px-3 py-1.5 text-xs font-semibold text-[#15171a] shadow-sm transition-colors hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" type="button" @click="openMediaPicker">
|
||||
변경
|
||||
</button>
|
||||
<button class="admin-page-form__featured-remove rounded bg-[#fff1f2]/95 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm transition-colors hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" type="button" @click="removeFeaturedImage">
|
||||
삭제
|
||||
</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-page-form__feature-empty flex h-6 items-start">
|
||||
<button class="admin-page-form__feature-add inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]" type="button" @click="openMediaPicker">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span>대표 이미지 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="admin-page-form__content mx-auto w-full max-w-[804px] px-8 pb-16 pt-24">
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-page-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]"
|
||||
@@ -349,6 +348,12 @@ defineExpose({
|
||||
v-model="form.content"
|
||||
class="admin-page-form__html-editor min-h-[68vh] w-full resize-y rounded border border-[#e3e6e8] bg-white px-4 py-4 font-mono text-sm leading-6 text-[#15171a] outline-none placeholder:text-[#8e9cac] focus:border-[#8e9cac]"
|
||||
spellcheck="false"
|
||||
@blur="rememberHtmlCursor"
|
||||
@click="rememberHtmlCursor"
|
||||
@focus="rememberHtmlCursor"
|
||||
@input="rememberHtmlCursor"
|
||||
@keyup="rememberHtmlCursor"
|
||||
@select="rememberHtmlCursor"
|
||||
placeholder="<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -404,7 +409,7 @@ defineExpose({
|
||||
v-model="form.slug"
|
||||
class="admin-page-form__input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none"
|
||||
type="text"
|
||||
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
required
|
||||
@input="touchSlug"
|
||||
>
|
||||
@@ -414,7 +419,7 @@ defineExpose({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
|
||||
<span class="admin-page-form__label font-medium">페이지 형식</span>
|
||||
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">
|
||||
<button
|
||||
@@ -423,7 +428,7 @@ defineExpose({
|
||||
type="button"
|
||||
@click="setRenderMode('markdown')"
|
||||
>
|
||||
기본
|
||||
일반 텍스트
|
||||
</button>
|
||||
<button
|
||||
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
|
||||
@@ -437,36 +442,22 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<div class="admin-page-form__field grid gap-2 text-sm">
|
||||
<span class="admin-page-form__label font-medium">대표 이미지</span>
|
||||
<figure v-if="form.featuredImage" class="admin-page-form__featured overflow-hidden rounded border border-[#e3e6e8] bg-white">
|
||||
<img class="admin-page-form__featured-image aspect-[4/3] w-full bg-surface object-cover" :src="form.featuredImage" alt="">
|
||||
<figcaption class="admin-page-form__featured-actions grid gap-2 p-3">
|
||||
<p class="admin-page-form__featured-url break-all text-xs text-muted">
|
||||
{{ form.featuredImage }}
|
||||
</p>
|
||||
<div class="admin-page-form__featured-buttons flex flex-wrap gap-2">
|
||||
<button class="admin-page-form__featured-change rounded border border-[#e3e6e8] px-3 py-1.5 text-xs font-semibold" type="button" @click="openMediaPicker">
|
||||
변경
|
||||
</button>
|
||||
<label class="admin-page-form__featured-reupload cursor-pointer rounded border border-[#e3e6e8] px-3 py-1.5 text-xs font-semibold">
|
||||
새 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
<button class="admin-page-form__featured-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeFeaturedImage">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div v-else class="admin-page-form__featured-empty grid gap-2 rounded border border-dashed border-[#e3e6e8] bg-white p-4">
|
||||
<button class="admin-page-form__featured-select rounded border border-[#e3e6e8] px-3 py-2 text-sm font-semibold" type="button" @click="openMediaPicker">
|
||||
미디어에서 선택
|
||||
</button>
|
||||
<label class="admin-page-form__featured-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white">
|
||||
{{ isUploadingFeaturedImage ? '업로드 중' : '새 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
|
||||
</label>
|
||||
</div>
|
||||
<span class="admin-page-form__label font-medium">HTML 자산</span>
|
||||
<label
|
||||
class="admin-page-form__asset-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"
|
||||
:class="{ 'pointer-events-none opacity-50': isUploadingPageAsset }"
|
||||
>
|
||||
{{ isUploadingPageAsset ? '업로드 중' : '파일 업로드' }}
|
||||
<input
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*,.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx"
|
||||
@change="uploadPageAsset"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-page-form__asset-upload-hint text-xs leading-5 text-[#7c8b9a]">
|
||||
HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. 예: <img src="여기">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -486,43 +477,5 @@ defineExpose({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="isMediaPickerOpen"
|
||||
class="admin-page-form__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeMediaPicker"
|
||||
>
|
||||
<section class="admin-page-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
|
||||
<div class="admin-page-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 class="admin-page-form__media-picker-title text-lg font-semibold">
|
||||
대표 이미지 선택
|
||||
</h2>
|
||||
<button class="admin-page-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-page-form__media-picker-body max-h-[62vh] overflow-y-auto p-5">
|
||||
<p v-if="isLoadingMedia" class="admin-page-form__media-picker-loading text-sm text-muted">
|
||||
미디어를 불러오는 중입니다.
|
||||
</p>
|
||||
<div v-else-if="mediaItems.length" class="admin-page-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-page-form__media-picker-item overflow-hidden border border-line bg-white text-left"
|
||||
type="button"
|
||||
@click="selectFeaturedImage(item)"
|
||||
>
|
||||
<img class="admin-page-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span class="admin-page-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="admin-page-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
|
||||
선택할 미디어가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
2
db/migrations/035_default_pages_to_html_document.sql
Normal file
2
db/migrations/035_default_pages_to_html_document.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE pages
|
||||
ALTER COLUMN render_mode SET DEFAULT 'html_document';
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.3
|
||||
|
||||
- 페이지 작성 기본값을 HTML 문서 모드로 바꾸고, 페이지 슬러그도 한글 제목에서 영문으로 자동 생성되도록 개선했다.
|
||||
- 페이지 작성 화면에서 대표 이미지를 제거하고, HTML 자산 업로드 시 업로드 URL을 현재 커서 위치에 바로 넣을 수 있게 했다.
|
||||
|
||||
## v1.5.2
|
||||
|
||||
- 페이지 작성/수정 화면을 게시글 작성 화면처럼 전체 화면 에디터, 상단 저장 툴바, 오른쪽 설정 패널 구조로 변경했다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-26 v1.5.3 — 고정 페이지 기본값을 HTML 랜딩 페이지 작성에 맞춤
|
||||
|
||||
고정 페이지는 운영에서 아직 본격 사용 전이고, 앞으로는 단일 랜딩 페이지 HTML을 붙여넣어 공개 URL에서 원문 HTML로 응답하는 용도가 중심이다. 따라서 새 페이지의 기본 형식은 HTML 문서 모드로 바꾸고, 기존 Markdown 모드는 실제 사용 가능성이 낮으므로 관리자 UI에서 `일반 텍스트`로 명확히 표시한다. 페이지 대표 이미지는 공개 원문 HTML 응답과 연결되지 않으므로 제거하고, HTML 안에서 필요한 이미지는 기존 미디어 업로드 API로 파일을 올린 뒤 URL을 현재 커서 위치에 삽입하는 방식으로 정리한다. 미디어 사용 여부는 페이지 본문 문자열 안에 저장된 URL도 검사하므로, HTML 코드에 업로드 URL이 포함되면 페이지 사용처로 추적된다.
|
||||
|
||||
## 2026-05-26 v1.5.2 — 페이지 작성 화면을 게시글 작성 화면과 통일
|
||||
|
||||
고정 페이지는 랜딩 페이지 작성 용도로 확장되므로 일반 관리자 폼보다 게시글 작성과 같은 집중형 전체 화면 에디터가 더 적합하다. 게시글과 페이지의 작성 화면이 다르면 저장 위치, 설정 위치, 본문 입력 방식이 매번 달라져 운영 피로가 커진다. 따라서 페이지 작성/수정도 상단 툴바와 오른쪽 접이식 설정 패널을 쓰고, 페이지 형식 선택과 URL·대표 이미지·삭제 액션은 설정 패널로 모은다. 기본 콘텐츠 입력도 게시글과 같은 Markdown-first 에디터를 사용해 작성 경험을 통일한다.
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, Markdown/HTML 문서 모드 선택, HTML 붙여넣기 textarea, 대표 이미지 선택 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, HTML 문서 기본 모드, 일반 텍스트 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
@@ -130,8 +130,8 @@
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, Markdown/HTML 문서 모드 저장, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, Markdown/HTML 문서 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
|
||||
@@ -288,6 +288,8 @@
|
||||
| db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 |
|
||||
| db/migrations/032_add_post_author.sql | 게시물 작성자(`posts.author_id`) 컬럼 추가 및 기존 글 owner/admin backfill |
|
||||
| db/migrations/033_site_settings_home_cover_dark_image.sql | 사이트 설정 다크모드 홈 커버 이미지 URL 컬럼 추가 |
|
||||
| db/migrations/034_add_page_render_mode.sql | 고정 페이지 렌더링 모드 컬럼 추가 |
|
||||
| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
18
docs/spec.md
18
docs/spec.md
@@ -91,7 +91,7 @@
|
||||
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
|
||||
- 기본 게시물 목록에는 노출하지 않음
|
||||
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
||||
- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `markdown`이며 기존 Markdown 콘텐츠 렌더러를 사용한다. `html_document`는 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다.
|
||||
- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `html_document`이며 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다. `markdown`은 관리자 UI에서 `일반 텍스트`로 표시하며 기존 Markdown 콘텐츠 렌더러를 사용한다.
|
||||
- HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며, `<head>`, `<style>`, `<body>`를 포함한 단일 랜딩 페이지 용도로 사용한다.
|
||||
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
||||
|
||||
@@ -253,8 +253,7 @@ components/content/
|
||||
| id | UUID | Primary Key |
|
||||
| title | String | 제목 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | Markdown 콘텐츠 또는 HTML 문서 원문 |
|
||||
| render_mode | String | 렌더링 방식(`markdown`, `html_document`) |
|
||||
| content | Text | Markdown 콘텐츠 |
|
||||
| excerpt | String | 요약 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
|
||||
@@ -314,8 +313,9 @@ components/content/
|
||||
| id | UUID | Primary Key |
|
||||
| title | String | 제목 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| content | Text | HTML 문서 원문 또는 일반 텍스트 콘텐츠 |
|
||||
| render_mode | String | 렌더링 방식(`html_document`, `markdown`) |
|
||||
| featured_image | String nullable | 레거시 컬럼, 관리자 페이지 작성 UI에서는 사용하지 않음 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -637,10 +637,12 @@ components/content/
|
||||
### 관리자 페이지 편집
|
||||
|
||||
- 고정 페이지 작성/수정 화면은 게시글 작성 화면과 같은 전체 화면 에디터 구조를 사용한다. 상단 툴바에 목록 이동, 저장 상태, 저장 버튼, 설정 패널 토글을 두고 오른쪽 설정 패널은 접고 펼칠 수 있다.
|
||||
- 고정 페이지 작성/수정 화면의 기본 모드는 게시글 작성과 같은 Markdown-first 에디터를 사용한다.
|
||||
- 고정 페이지 작성/수정 화면의 기본 모드는 HTML 문서 모드이며, `markdown` 모드는 `일반 텍스트`로 표시한다.
|
||||
- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다.
|
||||
- 페이지 형식, Page URL, 대표 이미지, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
|
||||
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문, 대표 이미지를 저장한다.
|
||||
- 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다.
|
||||
- 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
|
||||
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
|
||||
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
|
||||
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
|
||||
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.3
|
||||
|
||||
- 관리자 페이지 작성/수정: 새 페이지 기본 형식을 HTML 문서 모드로 변경.
|
||||
- 관리자 페이지 작성/수정: 페이지 슬러그도 게시글처럼 한글 제목을 영문 슬러그로 자동 변환하도록 수정.
|
||||
- 관리자 페이지 작성/수정: 페이지 형식의 `기본` 라벨을 `일반 텍스트`로 변경.
|
||||
- 관리자 페이지 작성/수정: 대표 이미지 UI 제거 및 저장 시 페이지 대표 이미지를 사용하지 않도록 정리.
|
||||
- 관리자 페이지 작성/수정: HTML 자산 업로드 버튼 추가, 업로드된 파일 URL을 HTML textarea 현재 커서 위치에 삽입하도록 추가.
|
||||
- DB: `pages.render_mode` 기본값을 `html_document`로 변경하는 마이그레이션 추가.
|
||||
|
||||
## v1.5.2
|
||||
|
||||
- 관리자 페이지 작성/수정: 게시글 작성 화면과 같은 전체 화면 에디터, 상단 저장 툴바, 접이식 오른쪽 설정 패널 구조로 변경.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.3",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.
|
||||
|
||||
export const adminPageInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
renderMode: z.enum(['markdown', 'html_document']).default('markdown'),
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
renderMode: z.enum(['markdown', 'html_document']).default('html_document'),
|
||||
content: z.string().default(''),
|
||||
featuredImage: z.string().trim().nullable().default(null)
|
||||
}).transform((input) => ({
|
||||
|
||||
Reference in New Issue
Block a user