사이트 설정 관리 추가
This commit is contained in:
@@ -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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
28
db/migrations/004_add_site_settings.sql
Normal file
28
db/migrations/004_add_site_settings.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|
||||||
### 고정 페이지 관리 구조 결정
|
### 고정 페이지 관리 구조 결정
|
||||||
|
|||||||
@@ -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 | 사이트 설정 테이블 추가 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
22
docs/spec.md
22
docs/spec.md
@@ -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` 환경 변수를 사용
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|
||||||
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
||||||
- [ ] 사이트 설정
|
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
||||||
- [ ] 메뉴/네비게이션 관리
|
- [ ] 메뉴/네비게이션 관리
|
||||||
- [ ] 미디어 라이브러리 카테고리 분류
|
- [ ] 미디어 라이브러리 카테고리 분류
|
||||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
7
server/api/site-settings.get.js
Normal file
7
server/api/site-settings.get.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getSiteSettings } from '../repositories/content-repository'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 사이트 설정 API
|
||||||
|
* @returns {Promise<Object>} 사이트 설정
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(() => getSiteSettings())
|
||||||
@@ -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
|
||||||
|
|||||||
13
server/routes/admin/api/settings.get.js
Normal file
13
server/routes/admin/api/settings.get.js
Normal 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()
|
||||||
|
})
|
||||||
24
server/routes/admin/api/settings.put.js
Normal file
24
server/routes/admin/api/settings.put.js
Normal 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)
|
||||||
|
})
|
||||||
16
server/utils/admin-site-settings-input.js
Normal file
16
server/utils/admin-site-settings-input.js
Normal 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)
|
||||||
17
server/utils/site-settings.js
Normal file
17
server/utils/site-settings.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user