관리자 기능과 태그 표시 설정 추가
This commit is contained in:
178
components/admin/AdminPostForm.vue
Normal file
178
components/admin/AdminPostForm.vue
Normal 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>
|
||||||
150
components/admin/AdminTagForm.vue
Normal file
150
components/admin/AdminTagForm.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
18
db/migrations/003_add_tag_display_fields.sql
Normal file
18
db/migrations/003_add_tag_display_fields.sql
Normal 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);
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
- Vue 컴포넌트 파일: PascalCase
|
- Vue 컴포넌트 파일: PascalCase
|
||||||
- CSS 클래스: kebab-case
|
- CSS 클래스: kebab-case
|
||||||
- 고유 클래스명 필수 (Tailwind 외)
|
- 고유 클래스명 필수 (Tailwind 외)
|
||||||
|
- Nuxt 컴포넌트 자동 import는 경로 prefix 없이 파일명 기준으로 사용
|
||||||
|
|
||||||
## 스타일
|
## 스타일
|
||||||
|
|
||||||
|
|||||||
@@ -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`로 실행
|
||||||
|
|
||||||
## 사용자 액션 필요 항목
|
## 사용자 액션 필요 항목
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
### 환경 변수 파일 보안 기준 정리
|
### 환경 변수 파일 보안 기준 정리
|
||||||
|
|||||||
29
docs/map.md
29
docs/map.md
@@ -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 컨테이너 실행 초안 |
|
||||||
|
|||||||
20
docs/spec.md
20
docs/spec.md
@@ -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 서명으로 검증
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 미디어 관리
|
## 미디어 관리
|
||||||
|
|||||||
11
docs/todo.md
11
docs/todo.md
@@ -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 연결 방식 확정
|
||||||
|
|||||||
@@ -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로 교체.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
17
middleware/admin-auth.global.js
Normal file
17
middleware/admin-auth.global.js
Normal 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
79
pages/admin/login.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
</h1>
|
<p class="admin-post-edit__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
<p class="admin-post-edit__description mt-4 text-sm text-muted">
|
Posts
|
||||||
저장된 글 데이터 연결 후 수정 화면을 구성합니다.
|
</p>
|
||||||
|
<h1 class="admin-post-edit__title mt-2 text-3xl font-semibold">
|
||||||
|
글 수정
|
||||||
|
</h1>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</h1>
|
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
<p class="admin-posts__description mt-4 text-sm text-muted">
|
Posts
|
||||||
글 목록 조회는 DB 설계 이후 연결합니다.
|
</p>
|
||||||
|
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
|
||||||
|
글 목록
|
||||||
|
</h1>
|
||||||
|
</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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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">
|
||||||
</h1>
|
Posts
|
||||||
<p class="admin-post-editor__description mt-4 text-sm text-muted">
|
</p>
|
||||||
마크다운 기반 위지윅 에디터는 다음 단계에서 구현합니다.
|
<h1 class="admin-post-editor__title mt-2 text-3xl font-semibold">
|
||||||
|
새 글 작성
|
||||||
|
</h1>
|
||||||
|
</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
94
pages/admin/tags/[id].vue
Normal 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>
|
||||||
@@ -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>
|
||||||
</h1>
|
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
<p class="admin-tags__description mt-4 text-sm text-muted">
|
Tags
|
||||||
DEV, NOTE, REVIEW, PLAY 같은 카테고리성 태그를 관리합니다.
|
</p>
|
||||||
|
<h1 class="admin-tags__title mt-2 text-3xl font-semibold">
|
||||||
|
태그 관리
|
||||||
|
</h1>
|
||||||
|
</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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
48
pages/admin/tags/new.vue
Normal file
48
pages/admin/tags/new.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
123
scripts/migrate-development-db.js
Normal file
123
scripts/migrate-development-db.js
Normal 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()
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
43
server/routes/admin/api/auth/login.post.js
Normal file
43
server/routes/admin/api/auth/login.post.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
14
server/routes/admin/api/auth/logout.post.js
Normal file
14
server/routes/admin/api/auth/logout.post.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
8
server/routes/admin/api/auth/me.get.js
Normal file
8
server/routes/admin/api/auth/me.get.js
Normal 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))
|
||||||
13
server/routes/admin/api/posts.get.js
Normal file
13
server/routes/admin/api/posts.get.js
Normal 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()
|
||||||
|
})
|
||||||
35
server/routes/admin/api/posts.post.js
Normal file
35
server/routes/admin/api/posts.post.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
26
server/routes/admin/api/posts/[id].delete.js
Normal file
26
server/routes/admin/api/posts/[id].delete.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
24
server/routes/admin/api/posts/[id].get.js
Normal file
24
server/routes/admin/api/posts/[id].get.js
Normal 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
|
||||||
|
})
|
||||||
45
server/routes/admin/api/posts/[id].put.js
Normal file
45
server/routes/admin/api/posts/[id].put.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
13
server/routes/admin/api/tags.get.js
Normal file
13
server/routes/admin/api/tags.get.js
Normal 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()
|
||||||
|
})
|
||||||
35
server/routes/admin/api/tags.post.js
Normal file
35
server/routes/admin/api/tags.post.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
26
server/routes/admin/api/tags/[id].delete.js
Normal file
26
server/routes/admin/api/tags/[id].delete.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
24
server/routes/admin/api/tags/[id].get.js
Normal file
24
server/routes/admin/api/tags/[id].get.js
Normal 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
|
||||||
|
})
|
||||||
45
server/routes/admin/api/tags/[id].put.js
Normal file
45
server/routes/admin/api/tags/[id].put.js
Normal 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
147
server/utils/admin-auth.js
Normal 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
|
||||||
|
}
|
||||||
20
server/utils/admin-post-input.js
Normal file
20
server/utils/admin-post-input.js
Normal 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)
|
||||||
16
server/utils/admin-tag-input.js
Normal file
16
server/utils/admin-tag-input.js
Normal 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)
|
||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user