From d5666fdcc37e1dff1ef5a8fe33124cbccd8a6de6 Mon Sep 17 00:00:00 2001 From: zenn Date: Sat, 2 May 2026 16:24:57 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminPageForm.vue | 278 +++++++++++++++++++ docs/history.md | 8 + docs/map.md | 9 + docs/spec.md | 12 + docs/todo.md | 2 +- docs/update.md | 9 + package-lock.json | 4 +- package.json | 2 +- pages/admin/pages/[id].vue | 161 +++++++++++ pages/admin/pages/index.vue | 111 +++++++- pages/admin/pages/new.vue | 86 ++++++ server/repositories/content-repository.js | 108 +++++++ server/routes/admin/api/pages.get.js | 13 + server/routes/admin/api/pages.post.js | 35 +++ server/routes/admin/api/pages/[id].delete.js | 26 ++ server/routes/admin/api/pages/[id].get.js | 24 ++ server/routes/admin/api/pages/[id].put.js | 45 +++ server/utils/admin-page-input.js | 15 + 18 files changed, 939 insertions(+), 9 deletions(-) create mode 100644 components/admin/AdminPageForm.vue create mode 100644 pages/admin/pages/[id].vue create mode 100644 pages/admin/pages/new.vue create mode 100644 server/routes/admin/api/pages.get.js create mode 100644 server/routes/admin/api/pages.post.js create mode 100644 server/routes/admin/api/pages/[id].delete.js create mode 100644 server/routes/admin/api/pages/[id].get.js create mode 100644 server/routes/admin/api/pages/[id].put.js create mode 100644 server/utils/admin-page-input.js diff --git a/components/admin/AdminPageForm.vue b/components/admin/AdminPageForm.vue new file mode 100644 index 0000000..832e111 --- /dev/null +++ b/components/admin/AdminPageForm.vue @@ -0,0 +1,278 @@ + + + diff --git a/docs/history.md b/docs/history.md index 0126436..355f864 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-05-02 v0.0.23 + +### 고정 페이지 관리 구조 결정 + +고정 페이지 작성과 수정은 게시물과 같은 블록형 에디터를 공유하되, 별도 `AdminPageForm`으로 분리한다. 페이지는 상태, 요약, 태그, 발행일이 없는 정적 콘텐츠이므로 게시물 폼을 그대로 재사용하면 불필요한 필드와 저장 조건이 섞이기 때문이다. + +관리자 경로는 내부 리소스 컬렉션 기준으로 `/admin/pages/:id`를 사용하고, 공개 보기 경로는 기존 고정 페이지 공개 구조인 `/pages/:slug`를 유지한다. 페이지는 목록과 태그 흐름에 노출되지 않는 독립 콘텐츠로 다루기 위해서다. + ## 2026-05-02 v0.0.22 ### 글쓰기 하단 빈 블록과 저장 피드백 보정 diff --git a/docs/map.md b/docs/map.md index 7539a59..034fed7 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,6 +27,7 @@ | 파일 | 화면 위치 | |------|-----------| | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장 | +| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 | @@ -60,6 +61,8 @@ | pages/admin/posts/new.vue | 글 작성, 저장 토스트 | | pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 | | pages/admin/pages/index.vue | 페이지 목록 | +| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 | +| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/media/index.vue | 미디어 관리 | | pages/admin/tags/index.vue | 태그 관리 | | pages/admin/tags/new.vue | 태그 생성 | @@ -96,6 +99,11 @@ | server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API | | server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API | | server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API | +| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API | +| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API | +| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API | +| server/routes/admin/api/pages/[id].put.js | 관리자 고정 페이지 수정 API | +| server/routes/admin/api/pages/[id].delete.js | 관리자 고정 페이지 삭제 API | | server/routes/admin/api/media.get.js | 관리자 미디어 목록 API | | server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 API | | server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API | @@ -109,6 +117,7 @@ | server/utils/sample-content.js | 샘플 콘텐츠 저장소 | | server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 | | server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 | +| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 | | server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 | | server/utils/media-library.js | 업로드 미디어 파일 관리 유틸리티 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 | diff --git a/docs/spec.md b/docs/spec.md index 9778f2d..a9006a0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -196,6 +196,11 @@ components/content/ - `GET /admin/api/posts/:id` - 글 상세 - `PUT /admin/api/posts/:id` - 글 수정 - `DELETE /admin/api/posts/:id` - 글 삭제 +- `GET /admin/api/pages` - 고정 페이지 목록 +- `POST /admin/api/pages` - 고정 페이지 작성 +- `GET /admin/api/pages/:id` - 고정 페이지 상세 +- `PUT /admin/api/pages/:id` - 고정 페이지 수정 +- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제 - `GET /admin/api/media` - 업로드 미디어 목록 - `PUT /admin/api/media` - 업로드 미디어 파일명 변경 - `DELETE /admin/api/media` - 업로드 미디어 삭제 @@ -250,6 +255,13 @@ components/content/ - 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다. - YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다. +### 관리자 페이지 편집 + +- 고정 페이지 작성/수정 화면은 게시물과 같은 블록형 에디터를 사용한다. +- 고정 페이지는 제목, 슬러그, 본문, 대표 이미지만 저장한다. +- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다. +- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다. + ### 관리자 인증 - 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용 diff --git a/docs/todo.md b/docs/todo.md index 931a2ee..065baf9 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,7 +15,7 @@ ## 2차 관리자 개발 -- [ ] 페이지 관리 (CRUD) +- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인 - [ ] 사이트 설정 - [ ] 메뉴/네비게이션 관리 - [ ] 미디어 라이브러리 카테고리 분류 diff --git a/docs/update.md b/docs/update.md index d4771a1..79b7a1d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v0.0.23 + +- 관리자 고정 페이지 목록 화면을 실제 API와 연결. +- 관리자 고정 페이지 생성 화면 추가. +- 관리자 고정 페이지 수정 화면 추가. +- 관리자 고정 페이지 생성/수정/삭제 API 추가. +- 고정 페이지 작성 폼에 블록 에디터와 대표 이미지 선택 기능 연결. +- 패키지 버전을 0.0.23으로 갱신. + ## v0.0.22 - 관리자 블록 에디터 마지막에 클릭 가능한 빈 문단 블록을 항상 유지하도록 수정. diff --git a/package-lock.json b/package-lock.json index 0f8666b..00f37cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.22", + "version": "0.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.22", + "version": "0.0.23", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 075b8ad..6e57a58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.22", + "version": "0.0.23", "private": true, "type": "module", "scripts": { diff --git a/pages/admin/pages/[id].vue b/pages/admin/pages/[id].vue new file mode 100644 index 0000000..6f4ca20 --- /dev/null +++ b/pages/admin/pages/[id].vue @@ -0,0 +1,161 @@ + + + diff --git a/pages/admin/pages/index.vue b/pages/admin/pages/index.vue index ff50544..b7af9a9 100644 --- a/pages/admin/pages/index.vue +++ b/pages/admin/pages/index.vue @@ -2,15 +2,116 @@ definePageMeta({ layout: 'admin' }) + +const deletingId = ref('') +const errorMessage = ref('') + +const { data: pages, refresh } = await useFetch('/admin/api/pages', { + default: () => [] +}) + +/** + * 날짜 표시 형식 변환 + * @param {string | null} value - ISO 날짜 문자열 + * @returns {string} 화면 표시 날짜 + */ +const formatDate = (value) => { + if (!value) { + return '-' + } + + const date = new Date(value) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + return `${year}.${month}.${day}` +} + +/** + * 고정 페이지 삭제 + * @param {Object} page - 삭제할 고정 페이지 + * @returns {Promise} 삭제 처리 결과 + */ +const deletePage = async (page) => { + if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) { + return + } + + deletingId.value = page.id + errorMessage.value = '' + + try { + await $fetch(`/admin/api/pages/${page.id}`, { + method: 'DELETE' + }) + await refresh() + } catch (error) { + errorMessage.value = error?.data?.message || '페이지를 삭제하지 못했습니다.' + } finally { + deletingId.value = '' + } +} diff --git a/pages/admin/pages/new.vue b/pages/admin/pages/new.vue new file mode 100644 index 0000000..8b0a993 --- /dev/null +++ b/pages/admin/pages/new.vue @@ -0,0 +1,86 @@ + + + diff --git a/server/repositories/content-repository.js b/server/repositories/content-repository.js index e18de00..d8a5ac4 100644 --- a/server/repositories/content-repository.js +++ b/server/repositories/content-repository.js @@ -338,6 +338,114 @@ export const listPages = async () => { return rows.map(mapPageRow) } +/** + * 관리자 고정 페이지 목록 조회 + * @returns {Promise} 관리자 고정 페이지 목록 + */ +export const listAdminPages = async () => listPages() + +/** + * 관리자 고정 페이지 상세 조회 + * @param {string} id - 페이지 ID + * @returns {Promise} 관리자 고정 페이지 상세 + */ +export const getAdminPageById = async (id) => { + const sql = getPostgresClient() + + if (!sql) { + return getSamplePages().find((page) => page.id === id) || null + } + + const rows = await sql` + SELECT * + FROM pages + WHERE id = ${id} + LIMIT 1 + ` + + return rows[0] ? mapPageRow(rows[0]) : null +} + +/** + * 관리자 고정 페이지 생성 + * @param {Object} input - 페이지 입력값 + * @returns {Promise} 생성된 페이지 + */ +export const createAdminPage = async (input) => { + const sql = getPostgresClient() + + if (!sql) { + throw new Error('DATABASE_REQUIRED') + } + + const rows = await sql` + INSERT INTO pages ( + title, + slug, + content, + featured_image + ) + VALUES ( + ${input.title}, + ${input.slug}, + ${input.content}, + ${input.featuredImage} + ) + RETURNING * + ` + + return mapPageRow(rows[0]) +} + +/** + * 관리자 고정 페이지 수정 + * @param {string} id - 페이지 ID + * @param {Object} input - 페이지 입력값 + * @returns {Promise} 수정된 페이지 + */ +export const updateAdminPage = async (id, input) => { + const sql = getPostgresClient() + + if (!sql) { + throw new Error('DATABASE_REQUIRED') + } + + const rows = await sql` + UPDATE pages + SET + title = ${input.title}, + slug = ${input.slug}, + content = ${input.content}, + featured_image = ${input.featuredImage}, + updated_at = now() + WHERE id = ${id} + RETURNING * + ` + + return rows[0] ? mapPageRow(rows[0]) : null +} + +/** + * 관리자 고정 페이지 삭제 + * @param {string} id - 페이지 ID + * @returns {Promise} 삭제 여부 + */ +export const deleteAdminPage = async (id) => { + const sql = getPostgresClient() + + if (!sql) { + throw new Error('DATABASE_REQUIRED') + } + + const rows = await sql` + DELETE FROM pages + WHERE id = ${id} + RETURNING id + ` + + return Boolean(rows[0]) +} + /** * 공개 고정 페이지 상세 조회 * @param {string} slug - 페이지 슬러그 diff --git a/server/routes/admin/api/pages.get.js b/server/routes/admin/api/pages.get.js new file mode 100644 index 0000000..76c0eff --- /dev/null +++ b/server/routes/admin/api/pages.get.js @@ -0,0 +1,13 @@ +import { requireAdminSession } from '../../../utils/admin-auth' +import { listAdminPages } from '../../../repositories/content-repository' + +/** + * 관리자 고정 페이지 목록 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 고정 페이지 목록 + */ +export default defineEventHandler((event) => { + requireAdminSession(event) + + return listAdminPages() +}) diff --git a/server/routes/admin/api/pages.post.js b/server/routes/admin/api/pages.post.js new file mode 100644 index 0000000..7ccae8d --- /dev/null +++ b/server/routes/admin/api/pages.post.js @@ -0,0 +1,35 @@ +import { createError, readBody } from 'h3' +import { requireAdminSession } from '../../../utils/admin-auth' +import { parseAdminPageInput } from '../../../utils/admin-page-input' +import { createAdminPage } from '../../../repositories/content-repository' + +/** + * 관리자 고정 페이지 생성 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 생성된 페이지 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const parsedBody = parseAdminPageInput(await readBody(event)) + + if (!parsedBody.success) { + throw createError({ + statusCode: 400, + message: '페이지 입력 형식이 올바르지 않습니다.' + }) + } + + try { + return await createAdminPage(parsedBody.data) + } catch (error) { + if (error?.code === '23505') { + throw createError({ + statusCode: 409, + message: '이미 사용 중인 페이지 슬러그입니다.' + }) + } + + throw error + } +}) diff --git a/server/routes/admin/api/pages/[id].delete.js b/server/routes/admin/api/pages/[id].delete.js new file mode 100644 index 0000000..cb7d299 --- /dev/null +++ b/server/routes/admin/api/pages/[id].delete.js @@ -0,0 +1,26 @@ +import { createError, getRouterParam } from 'h3' +import { requireAdminSession } from '../../../../utils/admin-auth' +import { deleteAdminPage } from '../../../../repositories/content-repository' + +/** + * 관리자 고정 페이지 삭제 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise<{ id: string }>} 삭제된 페이지 ID + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const id = getRouterParam(event, 'id') + const deleted = await deleteAdminPage(id) + + if (!deleted) { + throw createError({ + statusCode: 404, + message: '페이지를 찾을 수 없습니다.' + }) + } + + return { + id + } +}) diff --git a/server/routes/admin/api/pages/[id].get.js b/server/routes/admin/api/pages/[id].get.js new file mode 100644 index 0000000..fc2cf8c --- /dev/null +++ b/server/routes/admin/api/pages/[id].get.js @@ -0,0 +1,24 @@ +import { createError, getRouterParam } from 'h3' +import { requireAdminSession } from '../../../../utils/admin-auth' +import { getAdminPageById } from '../../../../repositories/content-repository' + +/** + * 관리자 고정 페이지 상세 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 고정 페이지 상세 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const id = getRouterParam(event, 'id') + const page = await getAdminPageById(id) + + if (!page) { + throw createError({ + statusCode: 404, + message: '페이지를 찾을 수 없습니다.' + }) + } + + return page +}) diff --git a/server/routes/admin/api/pages/[id].put.js b/server/routes/admin/api/pages/[id].put.js new file mode 100644 index 0000000..c85cd48 --- /dev/null +++ b/server/routes/admin/api/pages/[id].put.js @@ -0,0 +1,45 @@ +import { createError, getRouterParam, readBody } from 'h3' +import { requireAdminSession } from '../../../../utils/admin-auth' +import { parseAdminPageInput } from '../../../../utils/admin-page-input' +import { updateAdminPage } from '../../../../repositories/content-repository' + +/** + * 관리자 고정 페이지 수정 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 수정된 페이지 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const id = getRouterParam(event, 'id') + const parsedBody = parseAdminPageInput(await readBody(event)) + + if (!parsedBody.success) { + throw createError({ + statusCode: 400, + message: '페이지 입력 형식이 올바르지 않습니다.' + }) + } + + try { + const page = await updateAdminPage(id, parsedBody.data) + + if (!page) { + throw createError({ + statusCode: 404, + message: '페이지를 찾을 수 없습니다.' + }) + } + + return page + } catch (error) { + if (error?.code === '23505') { + throw createError({ + statusCode: 409, + message: '이미 사용 중인 페이지 슬러그입니다.' + }) + } + + throw error + } +}) diff --git a/server/utils/admin-page-input.js b/server/utils/admin-page-input.js new file mode 100644 index 0000000..3691425 --- /dev/null +++ b/server/utils/admin-page-input.js @@ -0,0 +1,15 @@ +import { z } from 'zod' + +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(''), + featuredImage: z.string().trim().nullable().default(null) +}) + +/** + * 관리자 페이지 입력값 정리 + * @param {unknown} body - 요청 본문 + * @returns {import('zod').SafeParseReturnType} 검증 결과 + */ +export const parseAdminPageInput = (body) => adminPageInputSchema.safeParse(body)