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

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> <template>
<aside class="left-sidebar site-sidebar hidden w-[287px] lg:flex lg:flex-col"> <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"> <div class="left-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
@@ -36,35 +42,14 @@
<span></span> <span></span>
</div> </div>
<div class="left-sidebar__category-grid mt-4 grid grid-cols-2 gap-x-6 gap-y-4 text-sm"> <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"> <NuxtLink
<span class="h-4 w-1 rounded-full bg-orange-500" /> Books v-for="tag in tags"
</NuxtLink> :key="tag.id"
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/business"> class="left-sidebar__category flex items-center gap-3"
<span class="h-4 w-1 rounded-full bg-indigo-500" /> Business :to="`/tags/${tag.slug}`"
</NuxtLink> >
<NuxtLink class="left-sidebar__category flex items-center gap-3" to="/tags/dev"> <span class="left-sidebar__category-color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="h-4 w-1 rounded-full bg-cyan-500" /> Tech <span class="left-sidebar__category-name">{{ tag.name }}</span>
</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> </NuxtLink>
</div> </div>
</div> </div>

View File

@@ -32,10 +32,21 @@ CREATE TABLE IF NOT EXISTS tags (
name TEXT NOT NULL, name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '', 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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 ( CREATE TABLE IF NOT EXISTS post_tags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(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 VALUES
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.'), ('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.', 10, '#f97316'),
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.') ('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.', 20, '#06b6d4')
ON CONFLICT (slug) DO NOTHING; ON CONFLICT (slug) DO NOTHING;
INSERT INTO posts ( 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 dockerfile: Dockerfile
container_name: sori-studio container_name: sori-studio
env_file: env_file:
- .env.production - ${ENV_FILE:-.env.production}
ports: ports:
- "${APP_PORT:-43118}:3000" - "${APP_PORT:-43118}:3000"
volumes: volumes:
@@ -18,7 +18,7 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
container_name: sori-studio-db container_name: sori-studio-db
env_file: env_file:
- .env.production - ${ENV_FILE:-.env.production}
environment: environment:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}

View File

@@ -19,6 +19,7 @@
- Vue 컴포넌트 파일: PascalCase - Vue 컴포넌트 파일: PascalCase
- CSS 클래스: kebab-case - CSS 클래스: kebab-case
- 고유 클래스명 필수 (Tailwind 외) - 고유 클래스명 필수 (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+ - npm 9+
- 개발 DB - 개발 DB
@@ -42,6 +42,27 @@ openssl rand -hex 32
npm run dev 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 - 개발 서버: http://127.0.0.1:43117
@@ -51,7 +72,7 @@ npm run dev
## UGREEN NAS Docker 배포 ## UGREEN NAS Docker 배포
> Dockerfile과 docker-compose 설정은 아직 작성 전이다. > Dockerfile과 docker-compose 설정은 초안이며 NAS 운영 환경에서는 아직 검증 전이다.
### SSH 접속 ### SSH 접속
@@ -114,6 +135,8 @@ docker run -d -p 3000:3000 sori.studio:latest
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음 - 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계 - 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 - 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 ## 2026-04-29 v0.0.6
### 환경 변수 파일 보안 기준 정리 ### 환경 변수 파일 보안 기준 정리

View File

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

View File

@@ -141,6 +141,8 @@ components/content/
| name | String | 태그명 | | name | String | 태그명 |
| slug | String | URL 슬러그 | | slug | String | URL 슬러그 |
| description | String | 설명 | | description | String | 설명 |
| sort_order | Integer | 사용자 화면 표시 순서 |
| color | String | 태그 색상 코드 |
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 | | updated_at | DateTime | 수정일 |
@@ -179,15 +181,31 @@ components/content/
### 관리자 API (`/admin/api/`) ### 관리자 API (`/admin/api/`)
- `POST /admin/api/auth/login` - 로그인 - `POST /admin/api/auth/login` - 로그인
- `POST /admin/api/auth/logout` - 로그아웃
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
- `GET /admin/api/posts` - 글 목록 - `GET /admin/api/posts` - 글 목록
- `POST /admin/api/posts` - 글 작성 - `POST /admin/api/posts` - 글 작성
- `GET /admin/api/posts/:id` - 글 상세
- `PUT /admin/api/posts/:id` - 글 수정 - `PUT /admin/api/posts/:id` - 글 수정
- `DELETE /admin/api/posts/:id` - 글 삭제 - `DELETE /admin/api/posts/:id` - 글 삭제
- `POST /admin/api/posts/:id/publish` - 글 발행
- `GET /admin/api/tags` - 태그 목록 - `GET /admin/api/tags` - 태그 목록
- `POST /admin/api/tags` - 태그 생성 - `POST /admin/api/tags` - 태그 생성
- `GET /admin/api/tags/:id` - 태그 상세
- `PUT /admin/api/tags/:id` - 태그 수정
- `DELETE /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차 관리자 개발 ## 1차 관리자 개발
- [ ] 로그인 기능 구현 - [ ] 마크다운 에디터 미리보기 및 편집 편의 기능 고도화
- [ ] 글 목록 조회
- [ ] 글 작성/수정 (마크다운 에디터)
- [ ] 글 발행/비공개 전환
- [ ] 태그 관리 (생성/수정/삭제)
- [ ] 이미지 업로드 - [ ] 이미지 업로드
## 2차 관리자 개발 ## 2차 관리자 개발
@@ -29,9 +25,6 @@
- [ ] 공개 화면 모바일 사이드바/네비게이션 방식 결정 - [ ] 공개 화면 모바일 사이드바/네비게이션 방식 결정
- [ ] Thred 참고 화면 기준 시각 QA - [ ] Thred 참고 화면 기준 시각 QA
- [ ] 사이드바 토글 애니메이션 세부 조정 - [ ] 사이드바 토글 애니메이션 세부 조정
- [ ] 게시물 카드 실제 데이터 연동
- [ ] 태그 페이지 실제 데이터 연동
- [ ] 고정 페이지 실제 데이터 연동
## 콘텐츠 스타일 구현 ## 콘텐츠 스타일 구현
@@ -51,8 +44,6 @@
## 데이터베이스 ## 데이터베이스
- [ ] PostgreSQL 마이그레이션 실행 스크립트 작성
- [ ] 로컬 개발 DB 컨테이너 실행 가이드 작성
- [ ] NAS 운영 DB 연결 설정 실제 값 작성 - [ ] NAS 운영 DB 연결 설정 실제 값 작성
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성 - [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
- [ ] CloudBeaver PostgreSQL 연결 방식 확정 - [ ] 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 ## v0.0.6
- `.env.example`의 실제 계정/비밀번호 값을 예시 전용 placeholder로 교체. - `.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> <template>
<div class="admin-layout min-h-screen bg-[#f5f5f2] text-ink"> <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"> <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 class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
설정 설정
</NuxtLink> </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> </nav>
</aside> </aside>
<main class="admin-layout__main min-h-screen p-5 lg:ml-64"> <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({ export default defineNuxtConfig({
compatibilityDate: '2026-04-29', compatibilityDate: '2026-04-29',
modules: ['@nuxtjs/tailwindcss'], modules: ['@nuxtjs/tailwindcss'],
components: [
{
path: '~/components',
pathPrefix: false
}
],
experimental: {
appManifest: false
},
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
app: { app: {
head: { head: {

4
package-lock.json generated
View File

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

View File

@@ -1,12 +1,13 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.6", "version": "0.0.7",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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", "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" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {

View File

@@ -2,6 +2,13 @@
definePageMeta({ definePageMeta({
layout: 'admin' 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> </script>
<template> <template>
@@ -14,8 +21,31 @@ definePageMeta({
대시보드 대시보드
</h1> </h1>
</div> </div>
<div class="admin-dashboard__body bg-paper p-6 text-sm text-muted"> <div class="admin-dashboard__body grid gap-4 bg-paper p-6 text-sm text-muted md:grid-cols-3">
관리자 기능은 Ghost 스타일의 글쓰기 흐름을 기준으로 단계별 구현합니다. <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> </div>
</section> </section>
</template> </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({ definePageMeta({
layout: 'admin' 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> </script>
<template> <template>
<section class="admin-post-edit bg-paper p-6"> <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> </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> </p>
<AdminPostForm :initial-post="post" submit-label="변경 저장" :saving="saving" @submit="savePost" />
</section> </section>
</template> </template>

View File

@@ -2,15 +2,124 @@
definePageMeta({ definePageMeta({
layout: 'admin' 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> </script>
<template> <template>
<section class="admin-posts bg-paper p-6"> <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> </h1>
<p class="admin-posts__description mt-4 text-sm text-muted"> </div>
목록 조회는 DB 설계 이후 연결합니다. <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> </p>
</section> </section>
</template> </template>

View File

@@ -2,15 +2,47 @@
definePageMeta({ definePageMeta({
layout: 'admin' 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> </script>
<template> <template>
<section class="admin-post-editor bg-paper p-6"> <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> </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> </p>
<AdminPostForm submit-label=" 저장" :saving="saving" @submit="savePost" />
</section> </section>
</template> </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({ definePageMeta({
layout: 'admin' 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> </script>
<template> <template>
<section class="admin-tags bg-paper p-6"> <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> </h1>
<p class="admin-tags__description mt-4 text-sm text-muted"> </div>
DEV, NOTE, REVIEW, PLAY 같은 카테고리성 태그를 관리합니다. <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> </p>
</section> </section>
</template> </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> <script setup>
const posts = [ const { data: posts } = await useFetch('/api/posts', {
{ default: () => []
title: 'sori.studio를 직접 만들기 시작하며', })
excerpt: '블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
tag: 'NOTE', /**
publishedAt: '2026.04.29', * 날짜 표시 형식 변환
to: '/posts/hello-sori-studio' * @param {string | null} value - ISO 날짜 문자열
}, * @returns {string} 화면 표시 날짜
{ */
title: '글쓰기 도구는 왜 직접 만들게 되는가', const formatPostDate = (value) => {
excerpt: '네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.', if (!value) {
tag: 'DEV', return ''
publishedAt: '2026.04.29',
to: '/posts/custom-writing-tool'
} }
]
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> </script>
<template> <template>
@@ -76,6 +96,6 @@ const posts = [
</div> </div>
</section> </section>
<PostCard v-for="post in posts" :key="post.to" :post="post" /> <PostCard v-for="post in postCards" :key="post.to" :post="post" />
</MainColumn> </MainColumn>
</template> </template>

View File

@@ -2,6 +2,18 @@
definePageMeta({ definePageMeta({
layout: 'page' 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> </script>
<template> <template>
@@ -10,10 +22,10 @@ definePageMeta({
Page Page
</p> </p>
<h1 class="static-page__title mt-4 text-5xl font-semibold leading-tight"> <h1 class="static-page__title mt-4 text-5xl font-semibold leading-tight">
고정 페이지 {{ page.title }}
</h1> </h1>
<p class="static-page__description mt-6 text-lg leading-8 text-muted"> <p class="static-page__description mt-6 whitespace-pre-line text-lg leading-8 text-muted">
About, Projects, Links, Contact 같은 고정 페이지는 헤더와 사이드바 없이 본문 중심으로 표시합니다. {{ page.content }}
</p> </p>
</article> </article>
</template> </template>

View File

@@ -2,43 +2,35 @@
definePageMeta({ definePageMeta({
layout: 'post' 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> </script>
<template> <template>
<ContentRenderer> <ContentRenderer>
<ProseHeaderCard> <ProseHeaderCard>
<p class="post-detail__eyebrow text-sm uppercase text-white/70"> <p class="post-detail__eyebrow text-sm uppercase text-white/70">
NOTE {{ postTag }}
</p> </p>
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight"> <h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
sori.studio를 직접 만들기 시작하며 {{ post.title }}
</h1> </h1>
</ProseHeaderCard> </ProseHeaderCard>
<p> <p class="post-detail__content whitespace-pre-line">
페이지는 게시물 본문 스타일을 확인하기 위한 초기 샘플입니다. {{ post.content }}
실제 데이터와 마크다운 기반 위지윅 렌더링은 다음 단계에서 연결합니다.
</p> </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> </ContentRenderer>
</template> </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> <template>
<MainColumn> <MainColumn>
<TagHeader title="NOTE" description="생각과 기록을 모아두는 태그 페이지입니다." /> <TagHeader
<section class="tag-posts site-section"> :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"> <div class="tag-posts__empty site-section-body text-sm text-muted">
태그별 목록은 DB 연결 표시합니다. 태그에 연결 글이 없습니다.
</div> </div>
</section> </section>
</MainColumn> </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, id: row.id,
name: row.name, name: row.name,
slug: row.slug, 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>} 게시물 목록 * @returns {Promise<Array>} 게시물 목록
@@ -79,6 +133,163 @@ export const listPosts = async () => {
return rows.map(mapPostRow) 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 - 게시물 슬러그 * @param {string} slug - 게시물 슬러그
@@ -163,8 +374,101 @@ export const listTags = async () => {
const rows = await sql` const rows = await sql`
SELECT * SELECT *
FROM tags FROM tags
ORDER BY name ASC ORDER BY sort_order ASC, name ASC
` `
return rows.map(mapTagRow) 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(), id: z.string().uuid(),
name: z.string().min(1), name: z.string().min(1),
slug: 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', id: '44444444-4444-4444-8444-444444444444',
name: 'NOTE', name: 'NOTE',
slug: 'note', slug: 'note',
description: '생각과 기록을 모아두는 태그입니다.' description: '생각과 기록을 모아두는 태그입니다.',
sortOrder: 10,
color: '#f97316'
}, },
{ {
id: '55555555-5555-4555-8555-555555555555', id: '55555555-5555-4555-8555-555555555555',
name: 'DEV', name: 'DEV',
slug: 'dev', slug: 'dev',
description: '개발과 제작 과정을 기록하는 태그입니다.' description: '개발과 제작 과정을 기록하는 태그입니다.',
sortOrder: 20,
color: '#06b6d4'
} }
] ]