고정 페이지 HTML 문서 모드 추가 v1.5.1

This commit is contained in:
2026-05-26 11:03:33 +09:00
parent 0ad2ab3f9d
commit a25306389b
15 changed files with 169 additions and 14 deletions

View File

@@ -18,6 +18,7 @@ const emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialPage.slug))
const blockEditor = ref(null)
const htmlEditor = ref(null)
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
@@ -26,6 +27,7 @@ const isUploadingFeaturedImage = ref(false)
const form = reactive({
title: props.initialPage.title || '',
slug: props.initialPage.slug || '',
renderMode: props.initialPage.renderMode || 'markdown',
content: props.initialPage.content || '',
featuredImage: props.initialPage.featuredImage || ''
})
@@ -140,9 +142,23 @@ const uploadFeaturedImage = async (event) => {
* @returns {void}
*/
const focusContentEditor = () => {
if (form.renderMode === 'html_document') {
htmlEditor.value?.focus()
return
}
blockEditor.value?.focusFirstBlock()
}
/**
* 페이지 작성 모드를 변경한다.
* @param {'markdown'|'html_document'} mode - 페이지 작성 모드
* @returns {void}
*/
const setRenderMode = (mode) => {
form.renderMode = mode
}
/**
* 페이지 입력값 제출
* @returns {void}
@@ -151,6 +167,7 @@ const submitPage = () => {
emit('submit', {
title: form.title.trim(),
slug: toSlug(form.slug || form.title),
renderMode: form.renderMode,
content: form.content,
featuredImage: form.featuredImage.trim() || null
})
@@ -170,12 +187,58 @@ const submitPage = () => {
@keydown.enter.prevent="focusContentEditor"
>
<div class="admin-page-form__field grid gap-2 text-sm">
<div v-if="form.renderMode === 'markdown'" class="admin-page-form__field grid gap-2 text-sm">
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
</div>
<label v-else class="admin-page-form__field admin-page-form__html-field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">HTML 문서</span>
<textarea
ref="htmlEditor"
v-model="form.content"
class="admin-page-form__html-editor min-h-[68vh] resize-y rounded border border-line bg-[#15171a] px-4 py-4 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/35 focus:border-[#8e9cac]"
spellcheck="false"
placeholder="<!doctype html>
<html lang=&quot;ko&quot;>
<head>
<meta charset=&quot;utf-8&quot;>
<title>Landing</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
</body>
</html>"
/>
<span class="admin-page-form__hint text-xs text-muted">
모드는 공개 URL에서 저장한 HTML을 그대로 응답합니다.
</span>
</label>
</section>
<aside class="admin-page-form__settings grid content-start gap-4">
<div 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-line bg-white p-1">
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'markdown' ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-surface hover:text-ink'"
type="button"
@click="setRenderMode('markdown')"
>
기본
</button>
<button
class="admin-page-form__mode-button rounded px-3 py-2 text-sm font-semibold transition"
:class="form.renderMode === 'html_document' ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-surface hover:text-ink'"
type="button"
@click="setRenderMode('html_document')"
>
HTML
</button>
</div>
</div>
<label class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">슬러그</span>
<input

View File

@@ -0,0 +1,14 @@
ALTER TABLE pages
ADD COLUMN IF NOT EXISTS render_mode TEXT NOT NULL DEFAULT 'markdown';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'pages_render_mode_check'
) THEN
ALTER TABLE pages
ADD CONSTRAINT pages_render_mode_check CHECK (render_mode IN ('markdown', 'html_document'));
END IF;
END $$;

View File

@@ -1,5 +1,14 @@
# 업데이트 요약
## v1.5.1
- 고정 페이지에서 전체 HTML 문서를 붙여넣어 `/pages/:slug`에서 단일 랜딩 페이지처럼 보여줄 수 있는 HTML 문서 모드를 추가했다.
## v1.5.0
- 관리자 글쓰기 태그 입력을 검색형 선택으로 개선하고, 태그별 색상을 배지에 반영했다.
- 관리자 글·페이지 목록의 더보기 메뉴가 테이블 밖에서 잘리지 않도록 수정했다.
## v1.4.7
- 글쓰기 라이브 모드에서 문단 이동 시 인라인 마크다운 서식이 사라지던 문제를 수정했다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-05-26 v1.5.1 — 고정 페이지에 원문 HTML 문서 모드 추가
고정 페이지는 아직 운영에서 본격 사용 전이므로 구조를 크게 바꿀 수 있다. 랜딩 페이지처럼 한 주소에서 단일 `index.html` 문서를 보여주는 목적에는 Nuxt 컴포넌트 안의 `v-html`보다 서버에서 `text/html`로 원문을 응답하는 방식이 맞다. 따라서 페이지에는 `render_mode`를 두고, 기본 Markdown 모드는 기존 경로를 유지하되 `html_document` 모드만 서버 미들웨어가 `/pages/:slug` 요청을 가로채 저장된 HTML을 그대로 반환한다. 이 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 한다.
## 2026-05-26 v1.5.0 — 글쓰기 태그 입력을 검색형 선택으로 정리
게시물 태그는 여러 개 저장되지만 관리자 글 목록에서는 운영자가 빠르게 분류를 읽는 것이 우선이므로 첫 번째 태그만 대표 태그로 표시한다. 글쓰기 화면은 직접 입력 흐름을 유지하되, 메인 태그처럼 이미 관리자가 등록한 태그는 다시 타이핑하지 않아도 되도록 오른쪽 트리거를 선택 드롭다운으로 바꾼다. 기존 태그는 이름과 슬러그 부분 일치로 추천해 `no` 입력만으로 `note` 같은 태그를 찾고, 방향키와 Enter로 추가할 수 있게 한다. 태그별 색상은 관리 화면에서 이미 운영자가 지정하는 분류 신호이므로 글쓰기 배지와 글 목록 대표 배지에도 같은 색상을 반영한다.

View File

@@ -51,6 +51,7 @@
| 파일 | 용도 |
|------|------|
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
| server/middleware/html-page-renderer.js | HTML 문서 모드 고정 페이지(`/pages/:slug`)를 Nuxt 렌더링 대신 `text/html` 원문으로 응답 |
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
## 사이트 컴포넌트
@@ -79,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 | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, Markdown/HTML 문서 모드 선택, HTML 붙여넣기 textarea, 대표 이미지 선택 |
| 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 표시 |
@@ -129,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 | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/pages/new.vue | 페이지 작성, Markdown/HTML 문서 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, Markdown/HTML 문서 모드 저장, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
@@ -155,7 +156,7 @@
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
| pages/pages/[slug].vue | 고정 페이지 상세 |
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 해당 URL 재진입 |
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |

View File

@@ -91,6 +91,8 @@
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
- 기본 게시물 목록에는 노출하지 않음
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `markdown`이며 기존 Markdown 콘텐츠 렌더러를 사용한다. `html_document`는 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다.
- HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며, `<head>`, `<style>`, `<body>`를 포함한 단일 랜딩 페이지 용도로 사용한다.
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
### 공개 URL 구조
@@ -251,7 +253,8 @@ components/content/
| id | UUID | Primary Key |
| title | String | 제목 |
| slug | String | URL 슬러그 |
| content | Text | 마크다운 콘텐츠 |
| content | Text | Markdown 콘텐츠 또는 HTML 문서 원문 |
| render_mode | String | 렌더링 방식(`markdown`, `html_document`) |
| excerpt | String | 요약 |
| featured_image | String nullable | 대표 이미지 |
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
@@ -633,8 +636,9 @@ components/content/
### 관리자 페이지 편집
- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다.
- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다.
- 고정 페이지 작성/수정 화면은 기본 모드에서 게시물과 같은 블록형 에디터를 사용한다.
- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문, 대표 이미지를 저장한다.
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.1
- 고정 페이지: 기본 Markdown 모드와 원문 HTML 문서 모드 선택 추가.
- 관리자 페이지 작성: HTML 문서 모드에서 전체 HTML 붙여넣기용 코드형 textarea 추가.
- 공개 페이지: HTML 문서 모드 페이지는 `/pages/:slug`에서 저장된 HTML을 `text/html` 원문으로 응답하도록 추가.
- DB: `pages.render_mode` 컬럼과 `markdown`/`html_document` 제약 추가.
## v1.5.0
- 관리자 글 목록: 태그 컬럼에 첫 번째 태그만 대표 태그로 표시하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.0",
"version": "1.5.1",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -14,10 +14,16 @@ if (!page.value) {
statusMessage: '페이지를 찾을 수 없습니다.'
})
}
onMounted(() => {
if (page.value?.renderMode === 'html_document') {
window.location.replace(`/pages/${page.value.slug}`)
}
})
</script>
<template>
<article class="static-page mx-auto min-h-screen max-w-3xl px-6 py-16">
<article v-if="page.renderMode !== 'html_document'" class="static-page mx-auto min-h-screen max-w-3xl px-6 py-16">
<p class="static-page__eyebrow text-xs font-semibold uppercase text-muted">
Page
</p>
@@ -28,4 +34,5 @@ if (!page.value) {
<ContentMarkdownRenderer :content="page.content" />
</ContentRenderer>
</article>
<div v-else class="static-page__html-redirect min-h-screen" />
</template>

View File

@@ -0,0 +1,34 @@
import { getMethod, getRequestURL, setResponseHeader } from 'h3'
import { getPageBySlug } from '../repositories/content-repository'
/**
* 공개 페이지가 HTML 문서 모드일 때 Nuxt 렌더링 대신 원문 HTML을 응답한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<string | void>} HTML 문서 또는 다음 핸들러 진행
*/
export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method !== 'GET' && method !== 'HEAD') {
return
}
const pathname = getRequestURL(event).pathname
const match = pathname.match(/^\/pages\/([^/]+)\/?$/)
if (!match) {
return
}
const slug = decodeURIComponent(match[1])
const page = await getPageBySlug(slug)
if (page?.renderMode !== 'html_document') {
return
}
setResponseHeader(event, 'content-type', 'text/html; charset=utf-8')
setResponseHeader(event, 'cache-control', 'no-cache')
return page.content || ''
})

View File

@@ -62,6 +62,7 @@ const mapPageRow = (row) => ({
title: row.title,
slug: row.slug,
content: row.content,
renderMode: row.render_mode || 'markdown',
featuredImage: row.featured_image,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString()
@@ -538,12 +539,14 @@ export const createAdminPage = async (input) => {
title,
slug,
content,
render_mode,
featured_image
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.renderMode},
${input.featuredImage}
)
RETURNING *
@@ -571,6 +574,7 @@ export const updateAdminPage = async (id, input) => {
title = ${input.title},
slug = ${input.slug},
content = ${input.content},
render_mode = ${input.renderMode},
featured_image = ${input.featuredImage},
updated_at = now()
WHERE id = ${id}

View File

@@ -4,9 +4,15 @@ 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가-힣]+)*$/),
content: z.preprocess(normalizeMarkdownContent, z.string()).default(''),
renderMode: z.enum(['markdown', 'html_document']).default('markdown'),
content: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null)
})
}).transform((input) => ({
...input,
content: input.renderMode === 'html_document'
? input.content
: normalizeMarkdownContent(input.content)
}))
/**
* 관리자 페이지 입력값 정리

View File

@@ -28,6 +28,7 @@ export const pageSchema = z.object({
title: z.string().min(1),
slug: z.string().min(1),
content: z.string(),
renderMode: z.enum(['markdown', 'html_document']).default('markdown'),
featuredImage: z.string().nullable().default(null),
createdAt: z.string(),
updatedAt: z.string()

View File

@@ -37,6 +37,7 @@ const samplePages = [
title: 'About',
slug: 'about',
content: 'sori.studio 소개 페이지입니다.',
renderMode: 'markdown',
featuredImage: null,
createdAt: now,
updatedAt: now