사이트 설정 관리 추가

This commit is contained in:
2026-05-02 16:37:11 +09:00
parent d5666fdcc3
commit 27cf05aba6
18 changed files with 431 additions and 25 deletions

View File

@@ -417,14 +417,6 @@ const normalizeTrailingTextBlock = () => {
isNormalizingTrailingBlock.value = true isNormalizingTrailingBlock.value = true
while (
editorBlocks.value.length > 1
&& isBlankParagraphBlock(editorBlocks.value.at(-1))
&& isBlankParagraphBlock(editorBlocks.value.at(-2))
) {
editorBlocks.value.pop()
}
if (!isBlankParagraphBlock(editorBlocks.value.at(-1))) { if (!isBlankParagraphBlock(editorBlocks.value.at(-1))) {
editorBlocks.value.push(createEditorBlock('paragraph')) editorBlocks.value.push(createEditorBlock('paragraph'))
} }

View File

@@ -1,17 +1,28 @@
<script setup>
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio',
description: 'sori.studio 개인 블로그',
logoText: '井',
copyrightText: '©2026 sori.studio'
})
})
</script>
<template> <template>
<aside class="right-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col"> <aside class="right-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col">
<div class="right-sidebar__scroll min-h-0 flex-1 overflow-y-auto"> <div class="right-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0"> <div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div class="right-sidebar__profile flex items-center gap-3"> <div class="right-sidebar__profile flex items-center gap-3">
<div class="right-sidebar__logo grid h-12 w-12 place-items-center rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]"> <div class="right-sidebar__logo grid h-12 w-12 place-items-center rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
{{ siteSettings.logoText }}
</div> </div>
<div> <div>
<p class="right-sidebar__title font-semibold"> <p class="right-sidebar__title font-semibold">
sori.studio {{ siteSettings.title }}
</p> </p>
<p class="right-sidebar__description text-sm site-muted"> <p class="right-sidebar__description text-sm site-muted">
Thoughts, stories and ideas. {{ siteSettings.description }}
</p> </p>
</div> </div>
</div> </div>
@@ -58,16 +69,16 @@
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0"> <div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<p class="right-sidebar__about text-sm leading-6 site-muted"> <p class="right-sidebar__about text-sm leading-6 site-muted">
sori.studio는 글과 프로젝트 링크를 곳에 쌓아두는 개인 블로그/CMS입니다. {{ siteSettings.description }}
</p> </p>
<NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about"> <NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about">
About sori.studio About {{ siteSettings.title }}
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<footer class="right-sidebar__footer py-4 pl-5 pr-0 text-xs site-muted"> <footer class="right-sidebar__footer py-4 pl-5 pr-0 text-xs site-muted">
©2026 sori.studio {{ siteSettings.copyrightText }}
</footer> </footer>
</aside> </aside>
</template> </template>

View File

@@ -1,5 +1,11 @@
<script setup> <script setup>
const { menuOpen, toggleMenu } = useMenuState() const { menuOpen, toggleMenu } = useMenuState()
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'sori.studio'
})
})
</script> </script>
<template> <template>
@@ -36,7 +42,7 @@ const { menuOpen, toggleMenu } = useMenuState()
</svg> </svg>
</span> </span>
</button> </button>
sori.studio {{ siteSettings.title }}
</NuxtLink> </NuxtLink>
<div class="site-header__search hidden h-9 w-[470px] items-center rounded-lg px-3 text-sm md:flex site-input"> <div class="site-header__search hidden h-9 w-[470px] items-center rounded-lg px-3 text-sm md:flex site-input">
<span class="site-header__search-icon mr-2 text-lg leading-none"></span> <span class="site-header__search-icon mr-2 text-lg leading-none"></span>

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS site_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
title TEXT NOT NULL DEFAULT 'sori.studio',
description TEXT NOT NULL DEFAULT 'sori.studio 개인 블로그',
site_url TEXT NOT NULL DEFAULT 'https://sori.studio',
logo_text TEXT NOT NULL DEFAULT '',
copyright_text TEXT NOT NULL DEFAULT '©2026 sori.studio',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT site_settings_singleton_check CHECK (id = 1)
);
INSERT INTO site_settings (
id,
title,
description,
site_url,
logo_text,
copyright_text
)
VALUES (
1,
'sori.studio',
'sori.studio 개인 블로그',
'https://sori.studio',
'',
'©2026 sori.studio'
)
ON CONFLICT (id) DO NOTHING;

View File

@@ -1,5 +1,13 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-02 v0.0.24
### 빈 줄 입력 보존과 사이트 설정 범위 결정
관리자 블록 에디터는 마지막에 클릭 가능한 빈 문단을 유지하지만, 사용자가 Enter로 만든 연속 빈 문단은 자동 삭제하지 않는다. 글 작성 중 여러 줄을 띄워 생각의 구간을 나누는 행동이 자연스럽고, 보조 입력 블록 정리 로직이 사용자의 입력 의도를 지우면 안 되기 때문이다.
사이트 설정은 우선 단일 `site_settings` 레코드로 관리한다. 개인 블로그 초기 단계에서는 여러 사이트나 다국어 설정보다 사이트 이름, 설명, 기본 URL, 텍스트 로고, 저작권 문구를 안정적으로 저장하고 공개 화면에 반영하는 흐름이 더 중요하다. 이미지 기반 로고와 프로필 이미지는 미디어 사용처 추적과 연결해야 하므로 이후 미디어 설정 확장 단계에서 다룬다.
## 2026-05-02 v0.0.23 ## 2026-05-02 v0.0.23
### 고정 페이지 관리 구조 결정 ### 고정 페이지 관리 구조 결정

View File

@@ -17,7 +17,7 @@
|------|-----------| |------|-----------|
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 | | components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
| components/site/LeftSidebar.vue | 메인 화면 왼쪽 | | components/site/LeftSidebar.vue | 메인 화면 왼쪽 |
| components/site/RightSidebar.vue | 메인 화면 오른쪽 | | components/site/RightSidebar.vue | 메인 화면 오른쪽, 사이트 설정 표시 |
| components/site/MainColumn.vue | 메인 화면 중앙 | | components/site/MainColumn.vue | 메인 화면 중앙 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일 | | components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일 |
| components/site/TagHeader.vue | 태그 페이지 헤더 | | components/site/TagHeader.vue | 태그 페이지 헤더 |
@@ -91,6 +91,7 @@
| server/api/pages.get.js | 고정 페이지 목록 샘플 API | | server/api/pages.get.js | 고정 페이지 목록 샘플 API |
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API | | server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
| server/api/tags.get.js | 태그 목록 샘플 API | | server/api/tags.get.js | 태그 목록 샘플 API |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API | | server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API | | server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API | | server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
@@ -113,12 +114,16 @@
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API | | server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
| server/routes/admin/api/tags/[id].put.js | 관리자 태그 수정 API | | server/routes/admin/api/tags/[id].put.js | 관리자 태그 수정 API |
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API | | server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 | | server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 | | server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 | | server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 | | server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 | | server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 | | server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
| server/utils/media-library.js | 업로드 미디어 파일 관리 유틸리티 | | server/utils/media-library.js | 업로드 미디어 파일 관리 유틸리티 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
@@ -130,6 +135,7 @@
| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 | | db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 |
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 | | db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 | | db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
## 설정/배포 ## 설정/배포

View File

@@ -154,6 +154,18 @@ components/content/
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 | | updated_at | DateTime | 수정일 |
### SiteSettings
| 필드 | 타입 | 설명 |
|------|------|------|
| id | Integer | 단일 설정 레코드 ID, 항상 1 |
| title | String | 사이트 이름 |
| description | String | 사이트 설명 |
| site_url | String | 사이트 기본 URL |
| logo_text | String | 텍스트 로고 |
| copyright_text | String | 저작권 문구 |
| updated_at | DateTime | 수정일 |
### PostTags (다대다) ### PostTags (다대다)
| 필드 | 타입 | 설명 | | 필드 | 타입 | 설명 |
@@ -185,6 +197,7 @@ components/content/
- `GET /api/pages` - 고정 페이지 목록 - `GET /api/pages` - 고정 페이지 목록
- `GET /api/pages/:slug` - 고정 페이지 상세 - `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록 - `GET /api/tags` - 태그 목록
- `GET /api/site-settings` - 공개 사이트 설정
### 관리자 API (`/admin/api/`) ### 관리자 API (`/admin/api/`)
@@ -210,6 +223,8 @@ components/content/
- `GET /admin/api/tags/:id` - 태그 상세 - `GET /admin/api/tags/:id` - 태그 상세
- `PUT /admin/api/tags/:id` - 태그 수정 - `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /admin/api/tags/:id` - 태그 삭제 - `DELETE /admin/api/tags/:id` - 태그 삭제
- `GET /admin/api/settings` - 사이트 설정 조회
- `PUT /admin/api/settings` - 사이트 설정 수정
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다. > 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다. > 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
@@ -262,6 +277,13 @@ components/content/
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다. - 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다. - 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
### 사이트 설정
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
### 관리자 인증 ### 관리자 인증
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용 - 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용

View File

@@ -16,7 +16,7 @@
## 2차 관리자 개발 ## 2차 관리자 개발
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인 - [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
- [ ] 사이트 설정 - [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
- [ ] 메뉴/네비게이션 관리 - [ ] 메뉴/네비게이션 관리
- [ ] 미디어 라이브러리 카테고리 분류 - [ ] 미디어 라이브러리 카테고리 분류
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 - [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적

View File

@@ -1,5 +1,15 @@
# 업데이트 이력 # 업데이트 이력
## v0.0.24
- 관리자 블록 에디터에서 마지막 빈 문단 Enter 입력 시 연속 빈 줄이 유지되도록 수정.
- 사이트 설정 데이터베이스 테이블 추가.
- 공개 사이트 설정 조회 API 추가.
- 관리자 사이트 설정 조회/수정 API 추가.
- 관리자 사이트 설정 화면을 실제 저장 API와 연결.
- 공개 헤더와 오른쪽 사이드바에 사이트 설정 값을 연결.
- 패키지 버전을 0.0.24로 갱신.
## v0.0.23 ## v0.0.23
- 관리자 고정 페이지 목록 화면을 실제 API와 연결. - 관리자 고정 페이지 목록 화면을 실제 API와 연결.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.23", "version": "0.0.24",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -2,15 +2,180 @@
definePageMeta({ definePageMeta({
layout: 'admin' layout: 'admin'
}) })
const saving = ref(false)
const errorMessage = ref('')
const toast = ref(null)
let toastTimer = null
const { data: settings } = await useFetch('/admin/api/settings')
const form = reactive({
title: settings.value?.title || 'sori.studio',
description: settings.value?.description || '',
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
logoText: settings.value?.logoText || '井',
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
})
/**
* 저장 상태 토스트 표시
* @param {'success'|'error'|'info'} type - 토스트 타입
* @param {string} message - 표시 메시지
* @returns {void}
*/
const showToast = (type, message) => {
window.clearTimeout(toastTimer)
toast.value = { type, message }
toastTimer = window.setTimeout(() => {
toast.value = null
}, 3200)
}
/**
* 사이트 설정 저장
* @returns {Promise<void>} 저장 결과
*/
const saveSettings = async () => {
saving.value = true
errorMessage.value = ''
showToast('info', '사이트 설정을 저장하는 중입니다.')
try {
const updatedSettings = await $fetch('/admin/api/settings', {
method: 'PUT',
body: {
title: form.title,
description: form.description,
siteUrl: form.siteUrl,
logoText: form.logoText,
copyrightText: form.copyrightText
}
})
Object.assign(form, updatedSettings)
showToast('success', '사이트 설정이 저장되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '사이트 설정을 저장하지 못했습니다.'
showToast('error', errorMessage.value)
} finally {
saving.value = false
}
}
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script> </script>
<template> <template>
<section class="admin-settings bg-paper p-6"> <section class="admin-settings bg-paper p-6">
<h1 class="admin-settings__title text-3xl font-semibold"> <div class="admin-settings__header mb-8">
사이트 설정 <p class="admin-settings__eyebrow text-xs font-semibold uppercase text-muted">
</h1> Settings
<p class="admin-settings__description mt-4 text-sm text-muted"> </p>
사이트 설정은 2 관리자 개발 범위입니다. <h1 class="admin-settings__title mt-2 text-3xl font-semibold">
사이트 설정
</h1>
</div>
<p v-if="errorMessage" class="admin-settings__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p> </p>
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 이름</span>
<input
v-model="form.title"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 설명</span>
<textarea
v-model="form.description"
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
/>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 URL</span>
<input
v-model="form.siteUrl"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="url"
required
>
</label>
<div class="admin-settings__grid grid gap-4 md:grid-cols-2">
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">로고 텍스트</span>
<input
v-model="form.logoText"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
maxlength="8"
required
>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">저작권 문구</span>
<input
v-model="form.copyrightText"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
</div>
<div class="admin-settings__preview rounded border border-line bg-white p-5">
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
공개 화면 미리보기
</p>
<div class="admin-settings__preview-body mt-4 flex items-center gap-3">
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center rounded-2xl bg-[#15171a] text-2xl font-bold text-white">
{{ form.logoText || '井' }}
</div>
<div>
<p class="admin-settings__preview-title font-semibold">
{{ form.title || 'sori.studio' }}
</p>
<p class="admin-settings__preview-description text-sm text-muted">
{{ form.description || '사이트 설명이 공개 화면에 표시됩니다.' }}
</p>
</div>
</div>
</div>
<div class="admin-settings__actions flex justify-end border-t border-line pt-5">
<button
class="admin-settings__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
>
{{ saving ? '저장 중' : '설정 저장' }}
</button>
</div>
</form>
<div
v-if="toast"
class="admin-settings__toast fixed right-5 top-5 z-50 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> </section>
</template> </template>

View File

@@ -0,0 +1,7 @@
import { getSiteSettings } from '../repositories/content-repository'
/**
* 공개 사이트 설정 API
* @returns {Promise<Object>} 사이트 설정
*/
export default defineEventHandler(() => getSiteSettings())

View File

@@ -5,6 +5,7 @@ import {
getSamplePosts, getSamplePosts,
getSampleTags getSampleTags
} from '../utils/sample-content' } from '../utils/sample-content'
import { getDefaultSiteSettings } from '../utils/site-settings'
import { getPostgresClient } from './postgres-client' import { getPostgresClient } from './postgres-client'
/** /**
@@ -55,6 +56,20 @@ const mapTagRow = (row) => ({
color: row.color color: row.color
}) })
/**
* 사이트 설정 행을 API 응답 구조로 변환
* @param {Object} row - 사이트 설정 행
* @returns {Object} 사이트 설정 응답
*/
const mapSiteSettingsRow = (row) => ({
title: row.title,
description: row.description,
siteUrl: row.site_url,
logoText: row.logo_text,
copyrightText: row.copyright_text,
updatedAt: row.updated_at.toISOString()
})
/** /**
* 태그 슬러그 목록 정규화 * 태그 슬러그 목록 정규화
* @param {Array<string>} tags - 태그 슬러그 목록 * @param {Array<string>} tags - 태그 슬러그 목록
@@ -488,6 +503,72 @@ export const listTags = async () => {
return rows.map(mapTagRow) return rows.map(mapTagRow)
} }
/**
* 사이트 설정 조회
* @returns {Promise<Object>} 사이트 설정
*/
export const getSiteSettings = async () => {
const sql = getPostgresClient()
if (!sql) {
return getDefaultSiteSettings()
}
const rows = await sql`
SELECT *
FROM site_settings
WHERE id = 1
LIMIT 1
`
return rows[0] ? mapSiteSettingsRow(rows[0]) : getDefaultSiteSettings()
}
/**
* 관리자 사이트 설정 수정
* @param {Object} input - 사이트 설정 입력값
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export const updateSiteSettings = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO site_settings (
id,
title,
description,
site_url,
logo_text,
copyright_text,
updated_at
)
VALUES (
1,
${input.title},
${input.description},
${input.siteUrl},
${input.logoText},
${input.copyrightText},
now()
)
ON CONFLICT (id) DO UPDATE
SET
title = EXCLUDED.title,
description = EXCLUDED.description,
site_url = EXCLUDED.site_url,
logo_text = EXCLUDED.logo_text,
copyright_text = EXCLUDED.copyright_text,
updated_at = now()
RETURNING *
`
return mapSiteSettingsRow(rows[0])
}
/** /**
* 관리자 태그 상세 조회 * 관리자 태그 상세 조회
* @param {string} id - 태그 ID * @param {string} id - 태그 ID

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { getSiteSettings } from '../../../repositories/content-repository'
/**
* 관리자 사이트 설정 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 사이트 설정
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return getSiteSettings()
})

View File

@@ -0,0 +1,24 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminSiteSettingsInput } from '../../../utils/admin-site-settings-input'
import { updateSiteSettings } from '../../../repositories/content-repository'
/**
* 관리자 사이트 설정 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = parseAdminSiteSettingsInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
})
}
return updateSiteSettings(parsedBody.data)
})

View File

@@ -0,0 +1,16 @@
import { z } from 'zod'
export const adminSiteSettingsInputSchema = z.object({
title: z.string().trim().min(1),
description: z.string().trim().default(''),
siteUrl: z.string().trim().url(),
logoText: z.string().trim().min(1).max(8),
copyrightText: z.string().trim().min(1)
})
/**
* 관리자 사이트 설정 입력값 정리
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminSiteSettingsInput = (body) => adminSiteSettingsInputSchema.safeParse(body)

View File

@@ -0,0 +1,17 @@
/**
* 기본 사이트 설정 반환
* @returns {Object} 기본 사이트 설정
*/
export const getDefaultSiteSettings = () => {
const config = useRuntimeConfig()
const title = config.public.siteTitle || 'sori.studio'
return {
title,
description: 'sori.studio 개인 블로그',
siteUrl: config.public.siteUrl || 'https://sori.studio',
logoText: '井',
copyrightText: `©${new Date().getFullYear()} ${title}`,
updatedAt: null
}
}