관리자 기능과 태그 표시 설정 추가

This commit is contained in:
2026-05-01 18:00:22 +09:00
parent 237eb2990f
commit 787747aa7f
51 changed files with 2261 additions and 128 deletions

View File

@@ -0,0 +1,178 @@
<script setup>
const props = defineProps({
initialPost: {
type: Object,
default: () => ({})
},
submitLabel: {
type: String,
default: '저장'
},
saving: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialPost.slug))
const form = reactive({
title: props.initialPost.title || '',
slug: props.initialPost.slug || '',
excerpt: props.initialPost.excerpt || '',
content: props.initialPost.content || '',
featuredImage: props.initialPost.featuredImage || '',
status: props.initialPost.status || 'draft',
tagsText: props.initialPost.tags?.join(', ') || ''
})
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 슬러그
*/
const toSlug = (value) => value
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
watch(() => form.title, (title) => {
if (!slugTouched.value) {
form.slug = toSlug(title)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
/**
* 쉼표 구분 태그 문자열을 슬러그 배열로 변환
* @param {string} value - 태그 입력 문자열
* @returns {Array<string>} 태그 슬러그 목록
*/
const parseTags = (value) => [...new Set(value
.split(',')
.map((tag) => toSlug(tag))
.filter(Boolean))]
/**
* 게시물 입력값 제출
* @returns {void}
*/
const submitPost = () => {
const publishedAt = form.status === 'published'
? props.initialPost.publishedAt || new Date().toISOString()
: null
emit('submit', {
title: form.title.trim(),
slug: toSlug(form.slug || form.title),
excerpt: form.excerpt.trim(),
content: form.content,
featuredImage: form.featuredImage.trim() || null,
status: form.status,
publishedAt,
tags: parseTags(form.tagsText)
})
}
</script>
<template>
<form class="admin-post-form grid gap-6" @submit.prevent="submitPost">
<div class="admin-post-form__main grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<section class="admin-post-form__content grid gap-4">
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">제목</span>
<input
v-model="form.title"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">본문</span>
<textarea
v-model="form.content"
class="admin-post-form__textarea min-h-[28rem] rounded border border-line bg-white px-3 py-3 font-mono text-sm leading-6"
required
/>
</label>
</section>
<aside class="admin-post-form__settings grid content-start gap-4">
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">상태</span>
<select v-model="form.status" class="admin-post-form__select rounded border border-line bg-white px-3 py-2">
<option value="draft">초안</option>
<option value="published">발행</option>
<option value="private">비공개</option>
</select>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">슬러그</span>
<input
v-model="form.slug"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
required
@input="touchSlug"
>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">요약</span>
<textarea
v-model="form.excerpt"
class="admin-post-form__textarea min-h-24 rounded border border-line bg-white px-3 py-2"
/>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">태그</span>
<input
v-model="form.tagsText"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="text"
>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">대표 이미지 URL</span>
<input
v-model="form.featuredImage"
class="admin-post-form__input rounded border border-line bg-white px-3 py-2"
type="url"
>
</label>
</aside>
</div>
<div class="admin-post-form__actions flex justify-end gap-3 border-t border-line pt-5">
<NuxtLink class="admin-post-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/posts">
취소
</NuxtLink>
<button
class="admin-post-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
>
{{ saving ? '저장 중' : submitLabel }}
</button>
</div>
</form>
</template>

View File

@@ -0,0 +1,150 @@
<script setup>
const props = defineProps({
initialTag: {
type: Object,
default: () => ({})
},
submitLabel: {
type: String,
default: '저장'
},
saving: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialTag.slug))
const form = reactive({
name: props.initialTag.name || '',
slug: props.initialTag.slug || '',
description: props.initialTag.description || '',
sortOrder: props.initialTag.sortOrder ?? 0,
color: props.initialTag.color || '#15171a'
})
/**
* 문자열을 URL 슬러그로 변환
* @param {string} value - 원본 문자열
* @returns {string} 슬러그
*/
const toSlug = (value) => value
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
watch(() => form.name, (name) => {
if (!slugTouched.value) {
form.slug = toSlug(name)
}
})
/**
* 슬러그 직접 입력 상태 표시
* @returns {void}
*/
const touchSlug = () => {
slugTouched.value = true
form.slug = toSlug(form.slug)
}
/**
* 태그 입력값 제출
* @returns {void}
*/
const submitTag = () => {
emit('submit', {
name: form.name.trim(),
slug: toSlug(form.slug || form.name),
description: form.description.trim(),
sortOrder: Number(form.sortOrder) || 0,
color: form.color
})
}
</script>
<template>
<form class="admin-tag-form grid gap-6" @submit.prevent="submitTag">
<section class="admin-tag-form__panel grid gap-5 border border-line bg-white p-5">
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">이름</span>
<input
v-model="form.name"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">슬러그</span>
<input
v-model="form.slug"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="text"
pattern="[a-z0-9가-힣]+(-[a-z0-9가-힣]+)*"
required
@input="touchSlug"
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">설명</span>
<textarea
v-model="form.description"
class="admin-tag-form__textarea min-h-28 rounded border border-line bg-white px-3 py-2"
/>
</label>
<div class="admin-tag-form__display grid gap-4 md:grid-cols-2">
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">정렬 순서</span>
<input
v-model.number="form.sortOrder"
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
type="number"
min="0"
step="1"
>
</label>
<label class="admin-tag-form__field grid gap-2 text-sm">
<span class="admin-tag-form__label font-medium">색상 코드</span>
<span class="admin-tag-form__color-row flex items-center gap-3">
<input
v-model="form.color"
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
type="color"
>
<input
v-model="form.color"
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
type="text"
pattern="#[0-9a-fA-F]{6}"
required
>
</span>
</label>
</div>
</section>
<div class="admin-tag-form__actions flex justify-end gap-3">
<NuxtLink class="admin-tag-form__cancel rounded border border-line bg-white px-4 py-2 text-sm font-semibold" to="/admin/tags">
취소
</NuxtLink>
<button
class="admin-tag-form__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
>
{{ saving ? '저장 중' : submitLabel }}
</button>
</div>
</form>
</template>

View File

@@ -1,3 +1,9 @@
<script setup>
const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
</script>
<template>
<aside class="left-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col">
<div class="left-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
@@ -36,35 +42,14 @@
<span></span>
</div>
<div class="left-sidebar__category-grid mt-4 grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/books">
<span class="h-4 w-1 rounded-full bg-orange-500" /> Books
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/business">
<span class="h-4 w-1 rounded-full bg-indigo-500" /> Business
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/dev">
<span class="h-4 w-1 rounded-full bg-cyan-500" /> Tech
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/science">
<span class="h-4 w-1 rounded-full bg-teal-400" /> Science
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/design">
<span class="h-4 w-1 rounded-full bg-fuchsia-500" /> Design
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/music">
<span class="h-4 w-1 rounded-full bg-pink-500" /> Music
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/health">
<span class="h-4 w-1 rounded-full bg-green-500" /> Health
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/play">
<span class="h-4 w-1 rounded-full bg-violet-500" /> Gaming
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/travel">
<span class="h-4 w-1 rounded-full bg-purple-500" /> Travel
</NuxtLink>
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/diy">
<span class="h-4 w-1 rounded-full bg-yellow-400" /> DIY
<NuxtLink
v-for="tag in tags"
:key="tag.id"
class="left-sidebar__category flex items-center gap-3"
:to="`/tags/${tag.slug}`"
>
<span class="left-sidebar__category-color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="left-sidebar__category-name">{{ tag.name }}</span>
</NuxtLink>
</div>
</div>

View File

@@ -32,10 +32,21 @@ CREATE TABLE IF NOT EXISTS tags (
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '#15171a',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
ON tags (sort_order ASC, name ASC);
CREATE TABLE IF NOT EXISTS post_tags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,

View File

@@ -1,7 +1,7 @@
INSERT INTO tags (id, name, slug, description)
INSERT INTO tags (id, name, slug, description, sort_order, color)
VALUES
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.'),
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.')
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.', 10, '#f97316'),
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.', 20, '#06b6d4')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO posts (

View File

@@ -0,0 +1,18 @@
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tags
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#15171a';
UPDATE tags
SET sort_order = 10,
color = '#f97316'
WHERE slug = 'note';
UPDATE tags
SET sort_order = 20,
color = '#06b6d4'
WHERE slug = 'dev';
CREATE INDEX IF NOT EXISTS tags_sort_order_name_idx
ON tags (sort_order ASC, name ASC);

View File

@@ -5,7 +5,7 @@ services:
dockerfile: Dockerfile
container_name: sori-studio
env_file:
- .env.production
- ${ENV_FILE:-.env.production}
ports:
- "${APP_PORT:-43118}:3000"
volumes:
@@ -18,7 +18,7 @@ services:
image: postgres:16-alpine
container_name: sori-studio-db
env_file:
- .env.production
- ${ENV_FILE:-.env.production}
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}

View File

@@ -19,6 +19,7 @@
- Vue 컴포넌트 파일: PascalCase
- CSS 클래스: kebab-case
- 고유 클래스명 필수 (Tailwind 외)
- Nuxt 컴포넌트 자동 import는 경로 prefix 없이 파일명 기준으로 사용
## 스타일

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 초안이며 운영 DB 확정 후 NAS에서 검증한다.
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
## 빌드 유형
@@ -15,7 +15,7 @@
### 필수 조건
- Node.js 20+ 권장
- Node.js 22 LTS 권장
- npm 9+
- 개발 DB
@@ -42,6 +42,27 @@ openssl rand -hex 32
npm run dev
```
### 로컬 개발 DB
로컬 개발 DB는 Docker Compose의 `sori-studio-db` 서비스만 실행한다.
```bash
# Docker daemon 시작
colima start
# 개발 DB 컨테이너 실행
ENV_FILE=.env.development docker compose --env-file .env.development up -d sori-studio-db
# 개발 DB 마이그레이션 실행
npm run db:migrate:dev
# DB 준비 상태 확인
docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
# 시드 데이터 확인
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
### 확인 주소
- 개발 서버: http://127.0.0.1:43117
@@ -51,7 +72,7 @@ npm run dev
## UGREEN NAS Docker 배포
> Dockerfile과 docker-compose 설정은 아직 작성 전이다.
> Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다.
### SSH 접속
@@ -114,6 +135,8 @@ docker run -d -p 3000:3000 sori.studio:latest
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
## 사용자 액션 필요 항목

View File

@@ -1,5 +1,35 @@
# 의사결정 이력
## 2026-05-01 v0.0.7
### 관리자 글 작성/수정 구조 결정
관리자 글 작성과 수정은 `AdminPostForm` 단일 컴포넌트를 공유한다. 현재 단계에서는 별도 위지윅 편집기를 도입하지 않고 마크다운 textarea 입력을 먼저 저장 가능한 형태로 연결한다. 글 관리의 핵심 흐름인 생성, 수정, 상태 변경을 먼저 검증한 뒤 미리보기, 자동 저장, 이미지 업로드를 분리해 확장하기 위해서다.
발행/초안/비공개 전환은 별도 publish API가 아니라 게시물 수정 API의 `status` 값으로 처리한다. 초기 관리자에서는 버튼과 API를 늘리기보다 저장 모델을 단순하게 유지하고, 추후 목록에서 빠른 발행 전환이 필요해질 때 별도 액션 API를 추가한다.
### 관리자 태그 관리 방식 결정
태그 관리는 목록 화면에서 생성/수정 입력을 인라인으로 열지 않고 생성/수정 전용 페이지로 분리한다. 태그에 표시 순서와 색상 코드가 추가되면서 입력 항목이 늘었고, 목록 행 안에서 수정 폼을 열면 테이블 레이아웃이 흔들리기 때문이다.
태그 삭제 시 게시물 자체는 삭제하지 않고 `post_tags` 연결만 외래 키 규칙으로 정리한다. 태그는 분류 메타데이터이고 게시물 본문 데이터와 생명주기가 다르기 때문이다.
태그의 `sort_order`는 공개 화면 카테고리 정렬 기준으로 사용하고, `color`는 태그 옆 색상 바와 이후 태그 배지 배경색에 사용할 수 있도록 `#RRGGBB` 문자열로 저장한다.
### 초기 관리자 인증 방식 결정
관리자 기능 1차 구현은 별도 사용자 테이블을 만들지 않고 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수로 시작한다. 개인 블로그/CMS 초기 단계에서는 운영 계정 수가 하나이고, 데이터 모델을 먼저 늘리기보다 글 관리 흐름을 빠르게 검증하는 편이 유지보수에 유리하다.
로그인 성공 시 `/admin` 경로에만 적용되는 httpOnly 세션 쿠키를 설정한다. 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증해 쿠키 위조를 막고, 운영 단계에서 사용자 테이블이나 더 강한 인증 방식이 필요해지는 시점에 확장한다.
### 로컬 개발 컨테이너 실행 환경 결정
새 개발 환경에서 Docker Desktop 없이 터미널 중심으로 PostgreSQL 개발 DB를 실행하기 위해 Homebrew, Docker CLI, Docker Compose, Colima 조합을 사용한다. 이 방식은 Docker daemon을 Colima가 담당하고, 프로젝트는 기존 `docker-compose.yml`을 그대로 활용할 수 있어 NAS Docker 배포 구조와 로컬 개발 구조를 크게 벌리지 않는다.
로컬 개발 DB는 `.env.development`만 사용하고, Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 넘긴다. 이렇게 하면 Git에 포함되지 않는 로컬 비밀번호를 사용하면서도 운영 기본값인 `.env.production` 기준은 유지할 수 있다.
개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행한다. Docker entrypoint는 새 볼륨 생성 시에만 SQL을 자동 실행하므로, 이미 생성된 개발 DB에도 반복 적용할 수 있는 별도 실행 명령을 둔다.
## 2026-04-29 v0.0.6
### 환경 변수 파일 보안 기준 정리

View File

@@ -22,6 +22,13 @@
| components/site/PostCard.vue | 목록의 게시물 카드 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
## 관리자 컴포넌트
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
## 콘텐츠 컴포넌트
| 파일 | 화면 위치 |
@@ -46,11 +53,14 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인 |
| pages/admin/posts/index.vue | 글 목록 |
| pages/admin/posts/new.vue | 글 작성 |
| pages/admin/posts/[id].vue | 글 수정 |
| pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/tags/index.vue | 태그 관리 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 |
## 공개 페이지
@@ -71,8 +81,24 @@
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
| server/api/tags.get.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/me.get.js | 관리자 세션 조회 API |
| server/routes/admin/api/posts.get.js | 관리자 게시물 목록 API |
| server/routes/admin/api/posts.post.js | 관리자 게시물 생성 API |
| 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/tags.get.js | 관리자 태그 목록 API |
| server/routes/admin/api/tags.post.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].delete.js | 관리자 태그 삭제 API |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
@@ -82,6 +108,7 @@
|------|------|
| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 |
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
## 설정/배포
@@ -92,6 +119,8 @@
| tailwind.config.js | Tailwind 테마 설정 |
| assets/css/main.css | 전역 스타일 |
| composables/useMenuState.js | 좌측 메뉴 열림 상태 관리 |
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |
| .env.example | 환경 변수 예시 |
| Dockerfile | NAS 운영 이미지 빌드 |
| docker-compose.yml | NAS 컨테이너 실행 초안 |

View File

@@ -141,6 +141,8 @@ components/content/
| name | String | 태그명 |
| slug | String | URL 슬러그 |
| description | String | 설명 |
| sort_order | Integer | 사용자 화면 표시 순서 |
| color | String | 태그 색상 코드 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
@@ -179,15 +181,31 @@ components/content/
### 관리자 API (`/admin/api/`)
- `POST /admin/api/auth/login` - 로그인
- `POST /admin/api/auth/logout` - 로그아웃
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
- `GET /admin/api/posts` - 글 목록
- `POST /admin/api/posts` - 글 작성
- `GET /admin/api/posts/:id` - 글 상세
- `PUT /admin/api/posts/:id` - 글 수정
- `DELETE /admin/api/posts/:id` - 글 삭제
- `POST /admin/api/posts/:id/publish` - 글 발행
- `GET /admin/api/tags` - 태그 목록
- `POST /admin/api/tags` - 태그 생성
- `GET /admin/api/tags/:id` - 태그 상세
- `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /admin/api/tags/:id` - 태그 삭제
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
### 관리자 인증
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
- 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
---
## 미디어 관리

View File

@@ -2,11 +2,7 @@
## 1차 관리자 개발
- [ ] 로그인 기능 구현
- [ ] 글 목록 조회
- [ ] 글 작성/수정 (마크다운 에디터)
- [ ] 글 발행/비공개 전환
- [ ] 태그 관리 (생성/수정/삭제)
- [ ] 마크다운 에디터 미리보기 및 편집 편의 기능 고도화
- [ ] 이미지 업로드
## 2차 관리자 개발
@@ -29,9 +25,6 @@
- [ ] 공개 화면 모바일 사이드바/네비게이션 방식 결정
- [ ] Thred 참고 화면 기준 시각 QA
- [ ] 사이드바 토글 애니메이션 세부 조정
- [ ] 게시물 카드 실제 데이터 연동
- [ ] 태그 페이지 실제 데이터 연동
- [ ] 고정 페이지 실제 데이터 연동
## 콘텐츠 스타일 구현
@@ -51,8 +44,6 @@
## 데이터베이스
- [ ] PostgreSQL 마이그레이션 실행 스크립트 작성
- [ ] 로컬 개발 DB 컨테이너 실행 가이드 작성
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
- [ ] CloudBeaver PostgreSQL 연결 방식 확정

View File

@@ -1,5 +1,44 @@
# 업데이트 이력
## v0.0.7
- 태그 정렬 순서와 색상 코드 필드 추가.
- 태그 표시 필드 마이그레이션 추가.
- 관리자 태그 생성/수정 화면을 개별 페이지로 분리.
- 관리자 태그 목록 화면의 인라인 수정 제거.
- 공개 좌측 사이드바 카테고리를 실제 태그 색상과 정렬 순서 기준으로 연결.
- 관리자 태그 상세 조회 API 추가.
- 관리자 태그 목록 API 추가.
- 관리자 태그 생성 API 추가.
- 관리자 태그 수정 API 추가.
- 관리자 태그 삭제 API 추가.
- 관리자 태그 관리 화면을 실제 API에 연결.
- 관리자 글 삭제 API 추가.
- 관리자 글 목록과 수정 화면에 삭제 액션 추가.
- 관리자 글 작성 API 추가.
- 관리자 글 상세 조회 API 추가.
- 관리자 글 수정 API 추가.
- 관리자 글 작성/수정 공통 폼 추가.
- 관리자 새 글 작성 화면과 수정 화면을 실제 저장 API에 연결.
- 관리자 글 상태를 초안/발행/비공개로 저장할 수 있도록 수정.
- 관리자 접근 미들웨어의 서버 인증 확인 방식 수정.
- 환경 변수 기반 관리자 로그인 기능 추가.
- 관리자 세션 쿠키 인증 유틸리티 추가.
- 관리자 로그아웃 및 세션 조회 API 추가.
- 관리자 글 목록 API와 화면 연결.
- 개발 서버의 `#app-manifest` 가상 모듈 분석 오류를 피하도록 Nuxt app manifest 실험 기능 비활성화.
- Nuxt 컴포넌트 자동 import 설정을 문서의 컴포넌트명 기준에 맞게 수정.
- 홈, 태그, 게시물, 고정 페이지 공개 화면을 실제 API 데이터에 연결.
- 로컬 PostgreSQL 마이그레이션 실행 스크립트 추가.
- 개발 DB 마이그레이션 npm 명령 추가.
- Homebrew, Docker CLI, Docker Compose, Colima 기반 로컬 컨테이너 실행 환경 구성.
- Docker Compose가 `ENV_FILE` 값으로 로컬/운영 환경 파일을 선택할 수 있도록 수정.
- 로컬 PostgreSQL 개발 DB 컨테이너 실행 및 시드 데이터 확인.
- Nuxt 개발/프리뷰 스크립트가 `.env.development`를 명시적으로 읽도록 수정.
- 새 개발 환경에서 Node.js 22 LTS 기준 의존성 설치 및 빌드 검증.
- 로컬 개발 필수 조건 문서의 Node.js 권장 버전 정리.
- 패키지 버전을 0.0.7로 갱신.
## v0.0.6
- `.env.example`의 실제 계정/비밀번호 값을 예시 전용 placeholder로 교체.

View File

@@ -1,3 +1,16 @@
<script setup>
/**
* 관리자 로그아웃
* @returns {Promise<void>} 로그아웃 처리 결과
*/
const logoutAdmin = async () => {
await $fetch('/admin/api/auth/logout', {
method: 'POST'
})
await navigateTo('/admin/login')
}
</script>
<template>
<div class="admin-layout min-h-screen bg-[#f5f5f2] text-ink">
<aside class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#15171a] p-5 text-white lg:block">
@@ -17,6 +30,9 @@
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
설정
</NuxtLink>
<button class="admin-layout__logout rounded px-3 py-2 text-left hover:bg-white/10 hover:text-white" type="button" @click="logoutAdmin">
로그아웃
</button>
</nav>
</aside>
<main class="admin-layout__main min-h-screen p-5 lg:ml-64">

View File

@@ -0,0 +1,17 @@
/**
* 관리자 페이지 접근 인증
* @param {import('#app').RouteLocationNormalized} to - 이동 대상 라우트
* @returns {Promise<ReturnType<typeof navigateTo> | void>} 라우트 이동 결과
*/
export default defineNuxtRouteMiddleware(async (to) => {
if (!to.path.startsWith('/admin') || to.path.startsWith('/admin/api') || to.path === '/admin/login') {
return
}
try {
const requestFetch = import.meta.server ? useRequestFetch() : $fetch
await requestFetch('/admin/api/auth/me')
} catch {
return navigateTo('/admin/login')
}
})

View File

@@ -2,6 +2,15 @@
export default defineNuxtConfig({
compatibilityDate: '2026-04-29',
modules: ['@nuxtjs/tailwindcss'],
components: [
{
path: '~/components',
pathPrefix: false
}
],
experimental: {
appManifest: false
},
css: ['~/assets/css/main.css'],
app: {
head: {

4
package-lock.json generated
View File

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

View File

@@ -1,12 +1,13 @@
{
"name": "sori.studio",
"version": "0.0.6",
"version": "0.0.7",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev --host 127.0.0.1 --port 43117",
"dev": "nuxt dev --dotenv .env.development --host 127.0.0.1 --port 43117",
"build": "nuxt build",
"preview": "nuxt preview --host 127.0.0.1 --port 43117",
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
"db:migrate:dev": "node scripts/migrate-development-db.js",
"postinstall": "nuxt prepare"
},
"dependencies": {

View File

@@ -2,6 +2,13 @@
definePageMeta({
layout: 'admin'
})
const { data: posts } = await useFetch('/admin/api/posts', {
default: () => []
})
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
</script>
<template>
@@ -14,8 +21,31 @@ definePageMeta({
대시보드
</h1>
</div>
<div class="admin-dashboard__body bg-paper p-6 text-sm text-muted">
관리자 기능은 Ghost 스타일의 글쓰기 흐름을 기준으로 단계별 구현합니다.
<div class="admin-dashboard__body grid gap-4 bg-paper p-6 text-sm text-muted md:grid-cols-3">
<section class="admin-dashboard__metric border border-line bg-white p-4">
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
Posts
</p>
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
{{ posts.length }}
</strong>
</section>
<section class="admin-dashboard__metric border border-line bg-white p-4">
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
Published
</p>
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
{{ publishedCount }}
</strong>
</section>
<section class="admin-dashboard__metric border border-line bg-white p-4">
<p class="admin-dashboard__metric-label text-xs font-semibold uppercase">
Draft
</p>
<strong class="admin-dashboard__metric-value mt-2 block text-3xl text-ink">
{{ draftCount }}
</strong>
</section>
</div>
</section>
</template>

79
pages/admin/login.vue Normal file
View File

@@ -0,0 +1,79 @@
<script setup>
definePageMeta({
layout: false
})
const form = reactive({
email: '',
password: ''
})
const pending = ref(false)
const errorMessage = ref('')
/**
* 관리자 로그인 제출
* @returns {Promise<void>} 로그인 처리 결과
*/
const submitLogin = async () => {
pending.value = true
errorMessage.value = ''
try {
await $fetch('/admin/api/auth/login', {
method: 'POST',
body: form
})
await navigateTo('/admin')
} catch {
errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
} finally {
pending.value = false
}
}
</script>
<template>
<main class="admin-login flex min-h-screen items-center justify-center bg-[#f5f5f2] px-5 text-ink">
<section class="admin-login__panel w-full max-w-sm border border-line bg-paper p-8">
<p class="admin-login__eyebrow text-xs font-semibold uppercase text-muted">
Admin
</p>
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
로그인
</h1>
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
<label class="admin-login__field grid gap-2 text-sm">
<span class="admin-login__label font-medium">이메일</span>
<input
v-model="form.email"
class="admin-login__input rounded border border-line bg-white px-3 py-2"
type="email"
autocomplete="username"
required
>
</label>
<label class="admin-login__field grid gap-2 text-sm">
<span class="admin-login__label font-medium">비밀번호</span>
<input
v-model="form.password"
class="admin-login__input rounded border border-line bg-white px-3 py-2"
type="password"
autocomplete="current-password"
required
>
</label>
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
{{ errorMessage }}
</p>
<button
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="pending"
>
{{ pending ? '확인 중' : '로그인' }}
</button>
</form>
</section>
</main>
</template>

View File

@@ -2,15 +2,103 @@
definePageMeta({
layout: 'admin'
})
const route = useRoute()
const id = computed(() => String(route.params.id || ''))
const saving = ref(false)
const deleting = ref(false)
const errorMessage = ref('')
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: '게시물을 찾을 수 없습니다.'
})
}
/**
* 게시물 수정 저장
* @param {Object} payload - 게시물 입력값
* @returns {Promise<void>} 저장 결과
*/
const savePost = async (payload) => {
saving.value = true
errorMessage.value = ''
try {
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
method: 'PUT',
body: payload
})
post.value = updatedPost
} catch (error) {
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
} finally {
saving.value = false
}
}
/**
* 게시물 삭제
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePost = async () => {
if (!confirm(`"${post.value.title}" 글을 삭제할까요?`)) {
return
}
deleting.value = true
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${id.value}`, {
method: 'DELETE'
})
await navigateTo('/admin/posts')
} catch (error) {
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
} finally {
deleting.value = false
}
}
</script>
<template>
<section class="admin-post-edit bg-paper p-6">
<h1 class="admin-post-edit__title text-3xl font-semibold">
<div class="admin-post-edit__header mb-8 flex items-start justify-between gap-4">
<div>
<p class="admin-post-edit__eyebrow text-xs font-semibold uppercase text-muted">
Posts
</p>
<h1 class="admin-post-edit__title mt-2 text-3xl font-semibold">
수정
</h1>
<p class="admin-post-edit__description mt-4 text-sm text-muted">
저장된 데이터 연결 수정 화면을 구성합니다.
</div>
<div class="admin-post-edit__actions flex gap-2">
<NuxtLink
v-if="post.status === 'published'"
class="admin-post-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
:to="`/posts/${post.slug}`"
target="_blank"
>
보기
</NuxtLink>
<button
class="admin-post-edit__delete rounded border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deleting"
@click="deletePost"
>
{{ deleting ? '삭제 중' : '삭제' }}
</button>
</div>
</div>
<p v-if="errorMessage" class="admin-post-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<AdminPostForm :initial-post="post" submit-label="변경 저장" :saving="saving" @submit="savePost" />
</section>
</template>

View File

@@ -2,15 +2,124 @@
definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const errorMessage = ref('')
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
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} post - 삭제할 게시물
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePost = async (post) => {
if (!confirm(`"${post.title}" 글을 삭제할까요?`)) {
return
}
deletingId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}`, {
method: 'DELETE'
})
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
} finally {
deletingId.value = ''
}
}
</script>
<template>
<section class="admin-posts bg-paper p-6">
<h1 class="admin-posts__title text-3xl font-semibold">
<div class="admin-posts__header flex items-center justify-between gap-4">
<div>
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
Posts
</p>
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
목록
</h1>
<p class="admin-posts__description mt-4 text-sm text-muted">
목록 조회는 DB 설계 이후 연결합니다.
</div>
<NuxtLink class="admin-posts__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new">
</NuxtLink>
</div>
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<div class="admin-posts__table mt-8 overflow-hidden border border-line">
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-posts__cell px-4 py-3">제목</th>
<th class="admin-posts__cell px-4 py-3">상태</th>
<th class="admin-posts__cell px-4 py-3">태그</th>
<th class="admin-posts__cell px-4 py-3">수정일</th>
<th class="admin-posts__cell px-4 py-3">관리</th>
</tr>
</thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
<tr v-for="post in posts" :key="post.id" class="admin-posts__row">
<td class="admin-posts__cell px-4 py-4">
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
{{ post.title }}
</NuxtLink>
<p class="admin-posts__slug mt-1 text-xs text-muted">
/posts/{{ post.slug }}
</p>
</td>
<td class="admin-posts__cell px-4 py-4">
{{ post.status }}
</td>
<td class="admin-posts__cell px-4 py-4">
{{ post.tags.join(', ') || '-' }}
</td>
<td class="admin-posts__cell px-4 py-4">
{{ formatDate(post.updatedAt) }}
</td>
<td class="admin-posts__cell px-4 py-4">
<button
class="admin-posts__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deletingId === post.id"
@click="deletePost(post)"
>
{{ deletingId === post.id ? '삭제 중' : '삭제' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="posts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
아직 작성된 글이 없습니다.
</p>
</section>
</template>

View File

@@ -2,15 +2,47 @@
definePageMeta({
layout: 'admin'
})
const saving = ref(false)
const errorMessage = ref('')
/**
* 새 게시물 저장
* @param {Object} payload - 게시물 입력값
* @returns {Promise<void>} 저장 결과
*/
const savePost = async (payload) => {
saving.value = true
errorMessage.value = ''
try {
const post = await $fetch('/admin/api/posts', {
method: 'POST',
body: payload
})
await navigateTo(`/admin/posts/${post.id}`)
} catch (error) {
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
} finally {
saving.value = false
}
}
</script>
<template>
<section class="admin-post-editor bg-paper p-6">
<h1 class="admin-post-editor__title text-3xl font-semibold">
<div class="admin-post-editor__header mb-8">
<p class="admin-post-editor__eyebrow text-xs font-semibold uppercase text-muted">
Posts
</p>
<h1 class="admin-post-editor__title mt-2 text-3xl font-semibold">
작성
</h1>
<p class="admin-post-editor__description mt-4 text-sm text-muted">
마크다운 기반 위지윅 에디터는 다음 단계에서 구현합니다.
</div>
<p v-if="errorMessage" class="admin-post-editor__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<AdminPostForm submit-label=" 저장" :saving="saving" @submit="savePost" />
</section>
</template>

94
pages/admin/tags/[id].vue Normal file
View File

@@ -0,0 +1,94 @@
<script setup>
definePageMeta({
layout: 'admin'
})
const route = useRoute()
const id = computed(() => String(route.params.id || ''))
const saving = ref(false)
const deleting = ref(false)
const errorMessage = ref('')
const { data: tag } = await useFetch(() => `/admin/api/tags/${id.value}`)
if (!tag.value) {
throw createError({
statusCode: 404,
statusMessage: '태그를 찾을 수 없습니다.'
})
}
/**
* 태그 수정 저장
* @param {Object} payload - 태그 입력값
* @returns {Promise<void>} 저장 결과
*/
const saveTag = async (payload) => {
saving.value = true
errorMessage.value = ''
try {
const updatedTag = await $fetch(`/admin/api/tags/${id.value}`, {
method: 'PUT',
body: payload
})
tag.value = updatedTag
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 저장하지 못했습니다.'
} finally {
saving.value = false
}
}
/**
* 태그 삭제
* @returns {Promise<void>} 삭제 처리 결과
*/
const deleteTag = async () => {
if (!confirm(`"${tag.value.name}" 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
return
}
deleting.value = true
errorMessage.value = ''
try {
await $fetch(`/admin/api/tags/${id.value}`, {
method: 'DELETE'
})
await navigateTo('/admin/tags')
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
} finally {
deleting.value = false
}
}
</script>
<template>
<section class="admin-tag-edit bg-paper p-6">
<div class="admin-tag-edit__header mb-8 flex items-start justify-between gap-4">
<div>
<p class="admin-tag-edit__eyebrow text-xs font-semibold uppercase text-muted">
Tags
</p>
<h1 class="admin-tag-edit__title mt-2 text-3xl font-semibold">
태그 수정
</h1>
</div>
<button
class="admin-tag-edit__delete rounded border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deleting"
@click="deleteTag"
>
{{ deleting ? '삭제 중' : '삭제' }}
</button>
</div>
<p v-if="errorMessage" class="admin-tag-edit__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<AdminTagForm :initial-tag="tag" submit-label="변경 저장" :saving="saving" @submit="saveTag" />
</section>
</template>

View File

@@ -2,15 +2,114 @@
definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const errorMessage = ref('')
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
default: () => []
})
/**
* 태그 삭제
* @param {Object} tag - 삭제할 태그
* @returns {Promise<void>} 삭제 처리 결과
*/
const deleteTag = async (tag) => {
if (!confirm(`"${tag.name}" 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
return
}
deletingId.value = tag.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/tags/${tag.id}`, {
method: 'DELETE'
})
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
} finally {
deletingId.value = ''
}
}
</script>
<template>
<section class="admin-tags bg-paper p-6">
<h1 class="admin-tags__title text-3xl font-semibold">
<div class="admin-tags__header flex items-center justify-between gap-4">
<div>
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
Tags
</p>
<h1 class="admin-tags__title mt-2 text-3xl font-semibold">
태그 관리
</h1>
<p class="admin-tags__description mt-4 text-sm text-muted">
DEV, NOTE, REVIEW, PLAY 같은 카테고리성 태그를 관리합니다.
</div>
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/tags/new">
태그 추가
</NuxtLink>
</div>
<p v-if="errorMessage" class="admin-tags__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-tags__cell px-4 py-3">순서</th>
<th class="admin-tags__cell px-4 py-3">색상</th>
<th class="admin-tags__cell px-4 py-3">이름</th>
<th class="admin-tags__cell px-4 py-3">슬러그</th>
<th class="admin-tags__cell px-4 py-3">설명</th>
<th class="admin-tags__cell px-4 py-3">관리</th>
</tr>
</thead>
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
<tr v-for="tag in tags" :key="tag.id" class="admin-tags__row">
<td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.sortOrder }}
</td>
<td class="admin-tags__cell px-4 py-4">
<span class="admin-tags__color flex items-center gap-2">
<span class="admin-tags__color-swatch h-5 w-2 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="admin-tags__color-code text-xs text-muted">{{ tag.color }}</span>
</span>
</td>
<td class="admin-tags__cell px-4 py-4 font-semibold">
{{ tag.name }}
</td>
<td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.slug }}
</td>
<td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.description || '-' }}
</td>
<td class="admin-tags__cell px-4 py-4">
<div class="admin-tags__actions flex gap-2">
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`">
수정
</NuxtLink>
<button
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deletingId === tag.id"
@click="deleteTag(tag)"
>
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">
아직 등록된 태그가 없습니다.
</p>
</section>
</template>

48
pages/admin/tags/new.vue Normal file
View File

@@ -0,0 +1,48 @@
<script setup>
definePageMeta({
layout: 'admin'
})
const saving = ref(false)
const errorMessage = ref('')
/**
* 새 태그 저장
* @param {Object} payload - 태그 입력값
* @returns {Promise<void>} 저장 결과
*/
const saveTag = async (payload) => {
saving.value = true
errorMessage.value = ''
try {
const tag = await $fetch('/admin/api/tags', {
method: 'POST',
body: payload
})
await navigateTo(`/admin/tags/${tag.id}`)
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 저장하지 못했습니다.'
} finally {
saving.value = false
}
}
</script>
<template>
<section class="admin-tag-editor bg-paper p-6">
<div class="admin-tag-editor__header mb-8">
<p class="admin-tag-editor__eyebrow text-xs font-semibold uppercase text-muted">
Tags
</p>
<h1 class="admin-tag-editor__title mt-2 text-3xl font-semibold">
태그
</h1>
</div>
<p v-if="errorMessage" class="admin-tag-editor__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<AdminTagForm submit-label="태그 저장" :saving="saving" @submit="saveTag" />
</section>
</template>

View File

@@ -1,20 +1,40 @@
<script setup>
const posts = [
{
title: 'sori.studio를 직접 만들기 시작하며',
excerpt: '블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
tag: 'NOTE',
publishedAt: '2026.04.29',
to: '/posts/hello-sori-studio'
},
{
title: '글쓰기 도구는 왜 직접 만들게 되는가',
excerpt: '네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
tag: 'DEV',
publishedAt: '2026.04.29',
to: '/posts/custom-writing-tool'
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
* @returns {string} 화면 표시 날짜
*/
const formatPostDate = (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} post - API 게시물
* @returns {Object} 게시물 카드 데이터
*/
const mapPostCard = (post) => ({
title: post.title,
excerpt: post.excerpt,
tag: post.tags?.[0]?.toUpperCase() || 'POST',
publishedAt: formatPostDate(post.publishedAt),
to: `/posts/${post.slug}`
})
const postCards = computed(() => posts.value.map(mapPostCard))
</script>
<template>
@@ -76,6 +96,6 @@ const posts = [
</div>
</section>
<PostCard v-for="post in posts" :key="post.to" :post="post" />
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
</MainColumn>
</template>

View File

@@ -2,6 +2,18 @@
definePageMeta({
layout: 'page'
})
const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))
const { data: page } = await useFetch(() => `/api/pages/${slug.value}`)
if (!page.value) {
throw createError({
statusCode: 404,
statusMessage: '페이지를 찾을 수 없습니다.'
})
}
</script>
<template>
@@ -10,10 +22,10 @@ definePageMeta({
Page
</p>
<h1 class="static-page__title mt-4 text-5xl font-semibold leading-tight">
고정 페이지
{{ page.title }}
</h1>
<p class="static-page__description mt-6 text-lg leading-8 text-muted">
About, Projects, Links, Contact 같은 고정 페이지는 헤더와 사이드바 없이 본문 중심으로 표시합니다.
<p class="static-page__description mt-6 whitespace-pre-line text-lg leading-8 text-muted">
{{ page.content }}
</p>
</article>
</template>

View File

@@ -2,43 +2,35 @@
definePageMeta({
layout: 'post'
})
const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: '게시물을 찾을 수 없습니다.'
})
}
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
</script>
<template>
<ContentRenderer>
<ProseHeaderCard>
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
NOTE
{{ postTag }}
</p>
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
sori.studio를 직접 만들기 시작하며
{{ post.title }}
</h1>
</ProseHeaderCard>
<p>
페이지는 게시물 본문 스타일을 확인하기 위한 초기 샘플입니다.
실제 데이터와 마크다운 기반 위지윅 렌더링은 다음 단계에서 연결합니다.
<p class="post-detail__content whitespace-pre-line">
{{ post.content }}
</p>
<ProseHeading :level="2">
본문 스타일 기준
</ProseHeading>
<p>
제목, 리스트, 인용구, 이미지, 버튼, 카드류 컴포넌트를 개별 컴포넌트로 분리해 이후 스타일 변경이 쉽도록 둡니다.
</p>
<ProseList>
<li>Regular image, Wide image, Full-width image 구분</li>
<li>Callout, Toggle, File, Product 카드 분리</li>
<li>YouTube, Twitter 임베드 영역 분리</li>
</ProseList>
<ProseBlockquote>
글쓰기 경험은 Ghost를 참고하되, 공개 화면은 sori.studio에 맞게 조정합니다.
</ProseBlockquote>
<ProseCallout>
<strong>초기 상태:</strong> 지금은 샘플 콘텐츠이며, DB와 관리자 글쓰기 연결 실제 데이터로 교체합니다.
</ProseCallout>
</ContentRenderer>
</template>

View File

@@ -1,9 +1,56 @@
<script setup>
const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))
const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
* @returns {string} 화면 표시 날짜
*/
const formatPostDate = (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}`
}
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
const tagPosts = computed(() => posts.value
.filter((post) => post.tags.includes(slug.value))
.map((post) => ({
title: post.title,
excerpt: post.excerpt,
tag: tag.value?.name || slug.value.toUpperCase(),
publishedAt: formatPostDate(post.publishedAt),
to: `/posts/${post.slug}`
})))
</script>
<template>
<MainColumn>
<TagHeader title="NOTE" description="생각과 기록을 모아두는 태그 페이지입니다." />
<section class="tag-posts site-section">
<TagHeader
:title="tag?.name || slug.toUpperCase()"
:description="tag?.description || ''"
/>
<PostCard v-for="post in tagPosts" :key="post.to" :post="post" />
<section v-if="tagPosts.length === 0" class="tag-posts site-section">
<div class="tag-posts__empty site-section-body text-sm text-muted">
태그별 목록은 DB 연결 표시합니다.
태그에 연결 글이 없습니다.
</div>
</section>
</MainColumn>

View File

@@ -0,0 +1,123 @@
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
const rootDir = process.cwd()
const envFile = process.env.ENV_FILE || '.env.development'
const serviceName = process.env.DB_SERVICE || 'sori-studio-db'
const databaseName = process.env.POSTGRES_DB || 'sori_studio'
const databaseUser = process.env.POSTGRES_USER || 'sori_studio'
const migrationsDir = join(rootDir, 'db', 'migrations')
const containerMigrationsDir = '/docker-entrypoint-initdb.d'
/**
* 명령 실행 결과 확인
* @param {string} command - 실행할 명령
* @param {string[]} args - 명령 인자
* @param {Object} options - 실행 옵션
* @returns {void}
*/
const runCommand = (command, args, options = {}) => {
const result = spawnSync(command, args, {
cwd: rootDir,
env: {
...process.env,
ENV_FILE: envFile
},
stdio: 'inherit',
...options
})
if (result.status !== 0) {
process.exit(result.status || 1)
}
}
/**
* 마이그레이션 파일 목록 조회
* @returns {string[]} SQL 파일 목록
*/
const getMigrationFiles = () => readdirSync(migrationsDir)
.filter((fileName) => fileName.endsWith('.sql'))
.sort((firstFile, secondFile) => firstFile.localeCompare(secondFile))
/**
* 개발 DB 컨테이너 시작
* @returns {void}
*/
const startDatabase = () => {
runCommand('docker', [
'compose',
'--env-file',
envFile,
'up',
'-d',
serviceName
])
}
/**
* 개발 DB 준비 상태 확인
* @returns {void}
*/
const waitForDatabase = () => {
for (let attempt = 1; attempt <= 20; attempt += 1) {
const result = spawnSync('docker', [
'exec',
serviceName,
'pg_isready',
'-U',
databaseUser,
'-d',
databaseName
], {
cwd: rootDir,
stdio: 'ignore'
})
if (result.status === 0) {
return
}
spawnSync('sleep', ['1'])
}
console.error('개발 DB가 준비되지 않았습니다.')
process.exit(1)
}
/**
* SQL 마이그레이션 파일 실행
* @param {string} fileName - 실행할 SQL 파일명
* @returns {void}
*/
const runMigrationFile = (fileName) => {
runCommand('docker', [
'exec',
serviceName,
'psql',
'-v',
'ON_ERROR_STOP=1',
'-U',
databaseUser,
'-d',
databaseName,
'-f',
`${containerMigrationsDir}/${fileName}`
])
}
/**
* 개발 DB 마이그레이션 실행
* @returns {void}
*/
const migrateDevelopmentDatabase = () => {
startDatabase()
waitForDatabase()
for (const fileName of getMigrationFiles()) {
runMigrationFile(fileName)
}
}
migrateDevelopmentDatabase()

View File

@@ -50,9 +50,63 @@ const mapTagRow = (row) => ({
id: row.id,
name: row.name,
slug: row.slug,
description: row.description
description: row.description,
sortOrder: row.sort_order,
color: row.color
})
/**
* 태그 슬러그 목록 정규화
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Array<string>} 정규화된 태그 슬러그 목록
*/
const normalizeTagSlugs = (tags = []) => [...new Set(tags
.map((tag) => String(tag).trim().toLowerCase())
.filter(Boolean))]
/**
* 태그 슬러그를 태그명으로 변환
* @param {string} slug - 태그 슬러그
* @returns {string} 태그명
*/
const getTagNameFromSlug = (slug) => slug
.split('-')
.filter(Boolean)
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ')
/**
* 게시물 태그 연결 저장
* @param {Function} sql - PostgreSQL 트랜잭션 클라이언트
* @param {string} postId - 게시물 ID
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Promise<void>} 저장 결과
*/
const syncPostTags = async (sql, postId, tags) => {
const tagSlugs = normalizeTagSlugs(tags)
await sql`
DELETE FROM post_tags
WHERE post_id = ${postId}
`
for (const slug of tagSlugs) {
const tagRows = await sql`
INSERT INTO tags (name, slug)
VALUES (${getTagNameFromSlug(slug)}, ${slug})
ON CONFLICT (slug) DO UPDATE
SET updated_at = now()
RETURNING id
`
await sql`
INSERT INTO post_tags (post_id, tag_id)
VALUES (${postId}, ${tagRows[0].id})
ON CONFLICT DO NOTHING
`
}
}
/**
* 공개 게시물 목록 조회
* @returns {Promise<Array>} 게시물 목록
@@ -79,6 +133,163 @@ export const listPosts = async () => {
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 목록 조회
* @returns {Promise<Array>} 관리자 게시물 목록
*/
export const listAdminPosts = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts()
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
GROUP BY posts.id
ORDER BY posts.updated_at DESC
`
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 상세 조회
* @param {string} id - 게시물 ID
* @returns {Promise<Object | null>} 관리자 게시물 상세
*/
export const getAdminPostById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts().find((post) => post.id === id) || null
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.id = ${id}
GROUP BY posts.id
LIMIT 1
`
return rows[0] ? mapPostRow(rows[0]) : null
}
/**
* 관리자 게시물 생성
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object>} 생성된 게시물
*/
export const createAdminPost = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const insertedRows = await transaction`
INSERT INTO posts (
title,
slug,
content,
excerpt,
featured_image,
status,
published_at
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.excerpt},
${input.featuredImage},
${input.status},
${input.publishedAt}
)
RETURNING *
`
await syncPostTags(transaction, insertedRows[0].id, input.tags)
return insertedRows
})
return getAdminPostById(rows[0].id)
}
/**
* 관리자 게시물 수정
* @param {string} id - 게시물 ID
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object | null>} 수정된 게시물
*/
export const updateAdminPost = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const updatedRows = await transaction`
UPDATE posts
SET
title = ${input.title},
slug = ${input.slug},
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
status = ${input.status},
published_at = ${input.publishedAt},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
if (!updatedRows[0]) {
return []
}
await syncPostTags(transaction, id, input.tags)
return updatedRows
})
return rows[0] ? getAdminPostById(rows[0].id) : null
}
/**
* 관리자 게시물 삭제
* @param {string} id - 게시물 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminPost = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM posts
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}
/**
* 공개 게시물 상세 조회
* @param {string} slug - 게시물 슬러그
@@ -163,8 +374,101 @@ export const listTags = async () => {
const rows = await sql`
SELECT *
FROM tags
ORDER BY name ASC
ORDER BY sort_order ASC, name ASC
`
return rows.map(mapTagRow)
}
/**
* 관리자 태그 상세 조회
* @param {string} id - 태그 ID
* @returns {Promise<Object | null>} 태그 상세
*/
export const getAdminTagById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSampleTags().find((tag) => tag.id === id) || null
}
const rows = await sql`
SELECT *
FROM tags
WHERE id = ${id}
LIMIT 1
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 생성
* @param {Object} input - 태그 입력값
* @returns {Promise<Object>} 생성된 태그
*/
export const createAdminTag = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO tags (name, slug, description, sort_order, color)
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color})
RETURNING *
`
return mapTagRow(rows[0])
}
/**
* 관리자 태그 수정
* @param {string} id - 태그 ID
* @param {Object} input - 태그 입력값
* @returns {Promise<Object | null>} 수정된 태그
*/
export const updateAdminTag = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
UPDATE tags
SET
name = ${input.name},
slug = ${input.slug},
description = ${input.description},
sort_order = ${input.sortOrder},
color = ${input.color},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 삭제
* @param {string} id - 태그 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminTag = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM tags
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
})
/**
* 관리자 로그인 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ email: string }>} 관리자 세션 정보
*/
export default defineEventHandler(async (event) => {
const parsedBody = loginSchema.safeParse(await readBody(event))
const config = useRuntimeConfig()
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '로그인 요청 형식이 올바르지 않습니다.'
})
}
const body = parsedBody.data
if (
!safeCompare(body.email, config.adminEmail) ||
!safeCompare(body.password, config.adminPassword)
) {
throw createError({
statusCode: 401,
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
})
}
setAdminSession(event, body.email)
return {
email: body.email
}
})

View File

@@ -0,0 +1,14 @@
import { clearAdminSession } from '../../../../utils/admin-auth'
/**
* 관리자 로그아웃 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ ok: boolean }} 로그아웃 결과
*/
export default defineEventHandler((event) => {
clearAdminSession(event)
return {
ok: true
}
})

View File

@@ -0,0 +1,8 @@
import { requireAdminSession } from '../../../../utils/admin-auth'
/**
* 관리자 세션 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ email: string }} 관리자 세션 정보
*/
export default defineEventHandler((event) => requireAdminSession(event))

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listAdminPosts } from '../../../repositories/content-repository'
/**
* 관리자 게시물 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 게시물 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listAdminPosts()
})

View File

@@ -0,0 +1,35 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminPostInput } from '../../../utils/admin-post-input'
import { createAdminPost } from '../../../repositories/content-repository'
/**
* 관리자 게시물 생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = parseAdminPostInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '게시물 입력 형식이 올바르지 않습니다.'
})
}
try {
return await createAdminPost(parsedBody.data)
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 슬러그입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,26 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { deleteAdminPost } 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 deleteAdminPost(id)
if (!deleted) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return {
id
}
})

View File

@@ -0,0 +1,24 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getAdminPostById } from '../../../../repositories/content-repository'
/**
* 관리자 게시물 상세 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 게시물 상세
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const post = await getAdminPostById(id)
if (!post) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return post
})

View File

@@ -0,0 +1,45 @@
import { createError, getRouterParam, readBody } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { parseAdminPostInput } from '../../../../utils/admin-post-input'
import { updateAdminPost } from '../../../../repositories/content-repository'
/**
* 관리자 게시물 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const parsedBody = parseAdminPostInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '게시물 입력 형식이 올바르지 않습니다.'
})
}
try {
const post = await updateAdminPost(id, parsedBody.data)
if (!post) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return post
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 슬러그입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listTags } from '../../../repositories/content-repository'
/**
* 관리자 태그 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 태그 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listTags()
})

View File

@@ -0,0 +1,35 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminTagInput } from '../../../utils/admin-tag-input'
import { createAdminTag } from '../../../repositories/content-repository'
/**
* 관리자 태그 생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 태그
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = parseAdminTagInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '태그 입력 형식이 올바르지 않습니다.'
})
}
try {
return await createAdminTag(parsedBody.data)
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 태그 슬러그입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,26 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { deleteAdminTag } 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 deleteAdminTag(id)
if (!deleted) {
throw createError({
statusCode: 404,
message: '태그를 찾을 수 없습니다.'
})
}
return {
id
}
})

View File

@@ -0,0 +1,24 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getAdminTagById } from '../../../../repositories/content-repository'
/**
* 관리자 태그 상세 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 태그 상세
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const tag = await getAdminTagById(id)
if (!tag) {
throw createError({
statusCode: 404,
message: '태그를 찾을 수 없습니다.'
})
}
return tag
})

View File

@@ -0,0 +1,45 @@
import { createError, getRouterParam, readBody } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { parseAdminTagInput } from '../../../../utils/admin-tag-input'
import { updateAdminTag } from '../../../../repositories/content-repository'
/**
* 관리자 태그 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 태그
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const parsedBody = parseAdminTagInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '태그 입력 형식이 올바르지 않습니다.'
})
}
try {
const tag = await updateAdminTag(id, parsedBody.data)
if (!tag) {
throw createError({
statusCode: 404,
message: '태그를 찾을 수 없습니다.'
})
}
return tag
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 태그 슬러그입니다.'
})
}
throw error
}
})

147
server/utils/admin-auth.js Normal file
View File

@@ -0,0 +1,147 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
const adminSessionCookieName = 'sori_admin_session'
const sessionMaxAge = 60 * 60 * 12
/**
* 세션 서명 비밀값 조회
* @returns {string} 세션 서명 비밀값
*/
const getSessionSecret = () => {
const config = useRuntimeConfig()
if (!config.adminPassword) {
throw createError({
statusCode: 500,
message: '관리자 비밀번호 환경 변수가 없습니다.'
})
}
return config.adminPassword
}
/**
* 문자열 안전 비교
* @param {string} left - 비교 문자열
* @param {string} right - 비교 대상 문자열
* @returns {boolean} 일치 여부
*/
export const safeCompare = (left, right) => {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return timingSafeEqual(leftBuffer, rightBuffer)
}
/**
* 세션 페이로드 서명
* @param {string} payload - 인코딩된 세션 페이로드
* @returns {string} 세션 서명
*/
const signPayload = (payload) => createHmac('sha256', getSessionSecret())
.update(payload)
.digest('base64url')
/**
* 관리자 세션 토큰 생성
* @param {string} email - 관리자 이메일
* @returns {string} 세션 토큰
*/
export const createAdminSessionToken = (email) => {
const payload = Buffer.from(JSON.stringify({
email,
expiresAt: Date.now() + sessionMaxAge * 1000
})).toString('base64url')
return `${payload}.${signPayload(payload)}`
}
/**
* 관리자 세션 토큰 검증
* @param {string | undefined} token - 세션 토큰
* @returns {{ email: string } | null} 세션 정보
*/
export const verifyAdminSessionToken = (token) => {
if (!token) {
return null
}
const [payload, signature] = token.split('.')
if (!payload || !signature || !safeCompare(signature, signPayload(payload))) {
return null
}
let session = null
try {
session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
} catch {
return null
}
if (!session.email || !session.expiresAt || session.expiresAt < Date.now()) {
return null
}
return {
email: session.email
}
}
/**
* 관리자 세션 쿠키 설정
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {string} email - 관리자 이메일
* @returns {void}
*/
export const setAdminSession = (event, email) => {
setCookie(event, adminSessionCookieName, createAdminSessionToken(email), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/admin',
maxAge: sessionMaxAge
})
}
/**
* 관리자 세션 쿠키 삭제
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {void}
*/
export const clearAdminSession = (event) => {
deleteCookie(event, adminSessionCookieName, {
path: '/admin'
})
}
/**
* 관리자 세션 조회
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ email: string } | null} 세션 정보
*/
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
/**
* 관리자 세션 필수 확인
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ email: string }} 세션 정보
*/
export const requireAdminSession = (event) => {
const session = getAdminSession(event)
if (!session) {
throw createError({
statusCode: 401,
message: '관리자 로그인이 필요합니다.'
})
}
return session
}

View File

@@ -0,0 +1,20 @@
import { z } from 'zod'
import { postStatusSchema } from './content-schema'
export const adminPostInputSchema = 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(''),
excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([])
})
/**
* 관리자 게시물 입력값 정리
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminPostInput = (body) => adminPostInputSchema.safeParse(body)

View File

@@ -0,0 +1,16 @@
import { z } from 'zod'
export const adminTagInputSchema = z.object({
name: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
description: z.string().default(''),
sortOrder: z.number().int().min(0).default(0),
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a')
})
/**
* 관리자 태그 입력값 정리
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminTagInput = (body) => adminTagInputSchema.safeParse(body)

View File

@@ -30,5 +30,7 @@ export const tagSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().default('')
description: z.string().default(''),
sortOrder: z.number().int().default(0),
color: z.string().default('#15171a')
})

View File

@@ -48,13 +48,17 @@ const sampleTags = [
id: '44444444-4444-4444-8444-444444444444',
name: 'NOTE',
slug: 'note',
description: '생각과 기록을 모아두는 태그입니다.'
description: '생각과 기록을 모아두는 태그입니다.',
sortOrder: 10,
color: '#f97316'
},
{
id: '55555555-5555-4555-8555-555555555555',
name: 'DEV',
slug: 'dev',
description: '개발과 제작 과정을 기록하는 태그입니다.'
description: '개발과 제작 과정을 기록하는 태그입니다.',
sortOrder: 20,
color: '#06b6d4'
}
]